2026-07-13 08:59:13 +00:00
|
|
|
|
import { useEffect, useEffectEvent, useMemo, useState } from "react";
|
2026-07-10 05:10:31 +00:00
|
|
|
|
import { Link } from "react-router-dom";
|
|
|
|
|
|
import { PageHeader } from "../components/layout/PageHeader";
|
|
|
|
|
|
import { Badge, Button, Card, EmptyState, Input, Pager, Select, Textarea } from "../components/ui";
|
|
|
|
|
|
import { pageSlice } from "../lib/pagination";
|
|
|
|
|
|
import { useData, useRepos } from "../data/DataContext";
|
|
|
|
|
|
import type {
|
|
|
|
|
|
Brand,
|
|
|
|
|
|
BrandProduct,
|
|
|
|
|
|
ScoutHomeworkRecord,
|
|
|
|
|
|
ScoutPost,
|
|
|
|
|
|
ScoutPurpose,
|
|
|
|
|
|
ScoutRunBrief,
|
|
|
|
|
|
} from "../domain/types";
|
|
|
|
|
|
import { newId } from "../lib/id";
|
2026-07-15 15:23:59 +00:00
|
|
|
|
import { allowHttpUrl } from "../lib/externalUrl";
|
2026-07-10 05:10:31 +00:00
|
|
|
|
import { bumpScoutTodayDone, loadScoutToday, saveScoutToday } from "../lib/scoutToday";
|
|
|
|
|
|
import { nowUnixNano } from "../lib/time";
|
|
|
|
|
|
import { useI18n } from "../i18n/I18nContext";
|
2026-07-13 08:59:13 +00:00
|
|
|
|
import { useJobLive } from "../data/JobLiveContext";
|
|
|
|
|
|
import { jobStatusLabel, jobStatusTone } from "../lib/jobLabels";
|
|
|
|
|
|
import { formatLocalDateTime } from "../lib/time";
|
|
|
|
|
|
import type { Job } from "../domain/types";
|
2026-07-10 05:10:31 +00:00
|
|
|
|
|
|
|
|
|
|
function isPending(p: ScoutPost): boolean {
|
|
|
|
|
|
return p.outreach_status === "new" || p.outreach_status === "drafted";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
function outreachStatusLabel(
|
|
|
|
|
|
p: ScoutPost,
|
|
|
|
|
|
t: (k: string, p?: Record<string, string | number>) => string,
|
|
|
|
|
|
): string {
|
|
|
|
|
|
return t(`scout.status.${p.outreach_status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function MatchQueue({
|
|
|
|
|
|
title,
|
|
|
|
|
|
posts,
|
|
|
|
|
|
page,
|
|
|
|
|
|
onPageChange,
|
|
|
|
|
|
currentId,
|
|
|
|
|
|
onSelect,
|
|
|
|
|
|
t,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
posts: ScoutPost[];
|
|
|
|
|
|
page: number;
|
|
|
|
|
|
onPageChange: (page: number) => void;
|
|
|
|
|
|
currentId: string | null;
|
|
|
|
|
|
onSelect: (id: string) => void;
|
|
|
|
|
|
t: (k: string, p?: Record<string, string | number>) => string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const pageSize = 8;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Card title={title}>
|
|
|
|
|
|
{posts.length === 0 ? (
|
|
|
|
|
|
<EmptyState title={t("scout.noMatchesInQueue")} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="hb-stack">
|
|
|
|
|
|
{pageSlice(posts, page, pageSize).map((p) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={p.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`hb-scout-queue-item${currentId === p.id ? " is-active" : ""}`}
|
|
|
|
|
|
onClick={() => onSelect(p.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="hb-scout-queue-item__meta">
|
|
|
|
|
|
@{p.author} · {p.search_tag} · {Math.round(p.score)}
|
|
|
|
|
|
<Badge tone={isPending(p) ? "brand" : "neutral"}>{outreachStatusLabel(p, t)}</Badge>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="hb-scout-queue-item__text">
|
|
|
|
|
|
{p.text.slice(0, 72)}
|
|
|
|
|
|
{p.text.length > 72 ? "…" : ""}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<Pager total={posts.length} page={page} pageSize={pageSize} onPageChange={onPageChange} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 05:10:31 +00:00
|
|
|
|
function stanceOf(p: ScoutPost, t: (k: string, p?: Record<string, string | number>) => string): string {
|
|
|
|
|
|
if (p.scout_mode === "activity") return t("scout.stanceActivity");
|
|
|
|
|
|
if (p.scout_mode === "product" || p.matched_product_label) return t("scout.stanceProduct");
|
|
|
|
|
|
return t("scout.stanceRelation");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 每次海巡批次的分組 key(與 theme_key 對齊) */
|
|
|
|
|
|
function postRunKey(p: ScoutPost): string {
|
|
|
|
|
|
if (p.theme_key) return p.theme_key;
|
|
|
|
|
|
if (p.matched_product_id) return `product|${p.matched_product_id}`;
|
|
|
|
|
|
if (p.intent_snippet) return `intent|${p.intent_snippet}`;
|
|
|
|
|
|
if (p.scout_mode === "activity") return `activity|${p.search_tag || "x"}`;
|
|
|
|
|
|
return `tag|${p.search_tag || "other"}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function postRunLabel(p: ScoutPost, t: (k: string, p?: Record<string, string | number>) => string): string {
|
|
|
|
|
|
return (
|
|
|
|
|
|
p.theme_label ||
|
|
|
|
|
|
p.matched_product_label ||
|
|
|
|
|
|
p.intent_snippet ||
|
|
|
|
|
|
p.search_tag ||
|
|
|
|
|
|
t("scout.unnamedRun")
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function runTimeSuffix(): string {
|
|
|
|
|
|
const d = new Date();
|
|
|
|
|
|
const hh = String(d.getHours()).padStart(2, "0");
|
|
|
|
|
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
|
|
|
|
return `${hh}:${mm}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 批次名過長時截斷(chip/標題用) */
|
|
|
|
|
|
function shortRunLabel(label: string, max = 20): string {
|
|
|
|
|
|
const t = (label || "").trim();
|
|
|
|
|
|
if (t.length <= max) return t;
|
|
|
|
|
|
return `${t.slice(0, Math.max(1, max - 1))}…`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 海巡:今日目標 + 現在這一則 + 收合功課/佇列
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function ScoutPage() {
|
|
|
|
|
|
const repos = useRepos();
|
2026-07-13 08:59:13 +00:00
|
|
|
|
const { tick } = useData();
|
2026-07-10 05:10:31 +00:00
|
|
|
|
const { t } = useI18n();
|
2026-07-13 08:59:13 +00:00
|
|
|
|
const { jobs: liveJobs, reload: reloadJobs } = useJobLive();
|
2026-07-10 05:10:31 +00:00
|
|
|
|
|
|
|
|
|
|
const [brands, setBrands] = useState<Brand[]>([]);
|
|
|
|
|
|
const [allProducts, setAllProducts] = useState<BrandProduct[]>([]);
|
|
|
|
|
|
const [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("");
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
const [valueQueuePage, setValueQueuePage] = useState(1);
|
|
|
|
|
|
const [activityQueuePage, setActivityQueuePage] = useState(1);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
const [homeworkList, setHomeworkList] = useState<ScoutHomeworkRecord[]>([]);
|
|
|
|
|
|
/** 目前檢視的海巡批次(每次按開始 = 一筆) */
|
|
|
|
|
|
const [activeRunKey, setActiveRunKey] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const [busy, setBusy] = useState("");
|
|
|
|
|
|
const [message, setMessage] = useState("");
|
2026-07-23 05:56:42 +00:00
|
|
|
|
const [convAmount, setConvAmount] = useState("");
|
|
|
|
|
|
const [convNote, setConvNote] = useState("");
|
2026-07-13 08:59:13 +00:00
|
|
|
|
const [scanJob, setScanJob] = useState<Job | null>(null);
|
|
|
|
|
|
const [scanJobThemeKey, setScanJobThemeKey] = useState<string | null>(null);
|
|
|
|
|
|
const [crawlerSessionRequired, setCrawlerSessionRequired] = useState(false);
|
|
|
|
|
|
const [loadError, setLoadError] = useState("");
|
2026-07-10 05:10:31 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const t = loadScoutToday();
|
|
|
|
|
|
setTodayDone(t.done);
|
|
|
|
|
|
setGoal(purpose === "activity" ? t.goalActivity : t.goalValue);
|
|
|
|
|
|
}, [purpose]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
void (async () => {
|
2026-07-13 08:59:13 +00:00
|
|
|
|
try {
|
2026-07-20 06:33:14 +00:00
|
|
|
|
const [b, hw, prods, postList] = await Promise.all([
|
2026-07-13 08:59:13 +00:00
|
|
|
|
repos.scout.listBrands(),
|
2026-07-20 06:33:14 +00:00
|
|
|
|
repos.scout.listHomework(),
|
|
|
|
|
|
repos.scout.listAllProducts(),
|
|
|
|
|
|
repos.scout.listPosts(),
|
2026-07-13 08:59:13 +00:00
|
|
|
|
]);
|
2026-07-20 06:33:14 +00:00
|
|
|
|
setBrands(b);
|
|
|
|
|
|
setHomeworkList(hw);
|
|
|
|
|
|
setAllProducts(prods);
|
|
|
|
|
|
setPosts(postList);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
const pending = postList.filter(isPending).sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
|
|
|
|
if (pending[0]) setActiveRunKey((cur) => cur || postRunKey(pending[0]!));
|
|
|
|
|
|
setLoadError("");
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setLoadError(e instanceof Error ? e.message : t("scout.loadFail"));
|
2026-07-10 05:10:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
})();
|
2026-07-13 08:59:13 +00:00
|
|
|
|
}, [repos, t, tick]);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
|
|
|
|
|
|
const productOptions = useMemo(() => {
|
|
|
|
|
|
return allProducts.map((p) => {
|
|
|
|
|
|
const brand = brands.find((b) => b.id === p.brand_id);
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: p.id,
|
|
|
|
|
|
label: `${brand?.display_name || t("scout.brandFallback")} · ${p.label}`,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [allProducts, brands, t]);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedProduct = useMemo(
|
|
|
|
|
|
() => allProducts.find((p) => p.id === productId) || null,
|
|
|
|
|
|
[allProducts, productId],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/** 每次搜尋一筆:依 posts 出現順序(新掃在前) */
|
|
|
|
|
|
const runGroups = useMemo(() => {
|
|
|
|
|
|
const order: string[] = [];
|
|
|
|
|
|
const map = new Map<
|
|
|
|
|
|
string,
|
|
|
|
|
|
{ key: string; label: string; pending: number; total: number; mode?: string }
|
|
|
|
|
|
>();
|
|
|
|
|
|
for (const p of posts) {
|
|
|
|
|
|
const key = postRunKey(p);
|
|
|
|
|
|
let g = map.get(key);
|
|
|
|
|
|
if (!g) {
|
|
|
|
|
|
g = {
|
|
|
|
|
|
key,
|
|
|
|
|
|
label: postRunLabel(p, t),
|
|
|
|
|
|
pending: 0,
|
|
|
|
|
|
total: 0,
|
|
|
|
|
|
mode: p.scout_mode,
|
|
|
|
|
|
};
|
|
|
|
|
|
map.set(key, g);
|
|
|
|
|
|
order.push(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
g.total += 1;
|
|
|
|
|
|
if (isPending(p)) g.pending += 1;
|
|
|
|
|
|
if (!g.label || g.label === t("scout.unnamedRun")) g.label = postRunLabel(p, t);
|
|
|
|
|
|
}
|
|
|
|
|
|
// 有功課但命中已清空的批次也列出來(可刪)
|
|
|
|
|
|
for (const h of homeworkList) {
|
|
|
|
|
|
if (map.has(h.theme_key)) continue;
|
|
|
|
|
|
order.push(h.theme_key);
|
|
|
|
|
|
map.set(h.theme_key, {
|
|
|
|
|
|
key: h.theme_key,
|
|
|
|
|
|
label: h.theme_label,
|
|
|
|
|
|
pending: 0,
|
|
|
|
|
|
total: 0,
|
|
|
|
|
|
mode: h.brief.mode,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return order.map((k) => map.get(k)!).filter(Boolean);
|
|
|
|
|
|
}, [posts, homeworkList, t]);
|
|
|
|
|
|
|
|
|
|
|
|
const activeRunMeta = useMemo(
|
|
|
|
|
|
() => runGroups.find((g) => g.key === activeRunKey) || null,
|
|
|
|
|
|
[runGroups, activeRunKey],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
const runQueue = useMemo(() => {
|
2026-07-10 05:10:31 +00:00
|
|
|
|
return posts
|
|
|
|
|
|
.filter((p) => !activeRunKey || postRunKey(p) === activeRunKey)
|
|
|
|
|
|
.slice()
|
2026-07-13 08:59:13 +00:00
|
|
|
|
.sort((a, b) => Number(isPending(b)) - Number(isPending(a)) || (b.score || 0) - (a.score || 0));
|
2026-07-10 05:10:31 +00:00
|
|
|
|
}, [posts, activeRunKey]);
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
const pendingQueue = useMemo(() => runQueue.filter(isPending), [runQueue]);
|
|
|
|
|
|
const valueQueue = useMemo(() => runQueue.filter((p) => p.scout_mode !== "activity"), [runQueue]);
|
|
|
|
|
|
const activityQueue = useMemo(() => runQueue.filter((p) => p.scout_mode === "activity"), [runQueue]);
|
|
|
|
|
|
|
2026-07-10 05:10:31 +00:00
|
|
|
|
const current = useMemo(
|
|
|
|
|
|
() => posts.find((p) => p.id === currentId) || null,
|
|
|
|
|
|
[posts, currentId],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
const visibleScanJob = useMemo(
|
|
|
|
|
|
() => (scanJob ? liveJobs.find((job) => job.id === scanJob.id) || scanJob : null),
|
|
|
|
|
|
[liveJobs, scanJob],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const pollScanJob = useEffectEvent(async (jobId: string, cancelled: () => boolean) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await reloadJobs();
|
|
|
|
|
|
const job = await repos.jobs.get(jobId);
|
|
|
|
|
|
if (cancelled() || !job) return;
|
|
|
|
|
|
setScanJob((previous) =>
|
|
|
|
|
|
previous?.id === job.id &&
|
|
|
|
|
|
previous.status === job.status &&
|
|
|
|
|
|
previous.updated_at === job.updated_at
|
|
|
|
|
|
? previous
|
|
|
|
|
|
: job,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (job.status === "succeeded") {
|
|
|
|
|
|
const list = await repos.scout.listPosts();
|
|
|
|
|
|
if (cancelled()) return;
|
|
|
|
|
|
setPosts(list);
|
|
|
|
|
|
const completedThemeKey = scanJobThemeKey || job.ref_id || activeRunKey;
|
|
|
|
|
|
const pending = list
|
|
|
|
|
|
.filter((p) => postRunKey(p) === completedThemeKey && isPending(p))
|
|
|
|
|
|
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
|
|
|
|
setMessage(t("scout.scanReady", { n: pending.length }));
|
|
|
|
|
|
} else if (job.status === "failed" || job.status === "cancelled") {
|
|
|
|
|
|
setMessage(job.error || t("scout.patrolFail"));
|
|
|
|
|
|
setCrawlerSessionRequired(/crawler.?session|chrome session/i.test(job.error || ""));
|
|
|
|
|
|
} else if (
|
|
|
|
|
|
job.status === "queued" &&
|
|
|
|
|
|
Number(job.created_at || 0) > 0 &&
|
|
|
|
|
|
Date.now() - Number(job.created_at) / 1_000_000 > 12_000
|
|
|
|
|
|
) {
|
|
|
|
|
|
setMessage(t("scout.workerWaiting"));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// The shared JobLiveContext keeps refreshing; leave the last known status visible.
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-15 15:23:59 +00:00
|
|
|
|
const scanJobId = scanJob?.id;
|
|
|
|
|
|
const scanJobStatus = scanJob?.status;
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
useEffect(() => {
|
2026-07-15 15:23:59 +00:00
|
|
|
|
if (!scanJobId || !scanJobStatus || ["succeeded", "failed", "cancelled"].includes(scanJobStatus)) return;
|
2026-07-13 08:59:13 +00:00
|
|
|
|
let cancelled = false;
|
2026-07-15 15:23:59 +00:00
|
|
|
|
const poll = () => pollScanJob(scanJobId, () => cancelled);
|
2026-07-13 08:59:13 +00:00
|
|
|
|
void poll();
|
|
|
|
|
|
const id = window.setInterval(() => void poll(), 1500);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
window.clearInterval(id);
|
|
|
|
|
|
};
|
2026-07-15 15:23:59 +00:00
|
|
|
|
}, [scanJobId, scanJobStatus]);
|
2026-07-13 08:59:13 +00:00
|
|
|
|
|
|
|
|
|
|
// 選取貼文只帶入草稿;批次由使用者明確選取,不能反向切換。
|
2026-07-10 05:10:31 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!current) {
|
|
|
|
|
|
setDraftText("");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setDraftText(current.draft_text || "");
|
|
|
|
|
|
}, [current?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
|
|
|
|
function pickNext(afterId?: string | null, list?: ScoutPost[], runKey?: string | null) {
|
|
|
|
|
|
const key = runKey === undefined ? activeRunKey : runKey;
|
|
|
|
|
|
let src = (list || posts).filter(isPending);
|
|
|
|
|
|
if (key) src = src.filter((p) => postRunKey(p) === key);
|
|
|
|
|
|
src = src.slice().sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
|
|
|
|
if (!src.length) {
|
|
|
|
|
|
setCurrentId(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!afterId) {
|
|
|
|
|
|
setCurrentId(src[0]!.id);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const idx = src.findIndex((p) => p.id === afterId);
|
|
|
|
|
|
const next = src[idx + 1] || src.find((p) => p.id !== afterId) || null;
|
|
|
|
|
|
setCurrentId(next?.id || null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selectRun(key: string) {
|
|
|
|
|
|
setActiveRunKey(key);
|
2026-07-13 08:59:13 +00:00
|
|
|
|
setValueQueuePage(1);
|
|
|
|
|
|
setActivityQueuePage(1);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
const pending = posts
|
|
|
|
|
|
.filter((p) => postRunKey(p) === key && isPending(p))
|
|
|
|
|
|
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
2026-07-13 08:59:13 +00:00
|
|
|
|
setCurrentId(pending[0]?.id || null);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeRun(key: string) {
|
|
|
|
|
|
const g = runGroups.find((x) => x.key === key);
|
|
|
|
|
|
const label = g?.label || t("scout.thisRun");
|
|
|
|
|
|
if (
|
|
|
|
|
|
!window.confirm(t("scout.confirmDeleteRun", { label }))
|
|
|
|
|
|
) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setBusy("del-run");
|
|
|
|
|
|
try {
|
|
|
|
|
|
await repos.scout.removeTheme(key);
|
|
|
|
|
|
const list = await reloadPosts();
|
|
|
|
|
|
const hw = await repos.scout.listHomework();
|
|
|
|
|
|
setHomeworkList(hw);
|
|
|
|
|
|
if (activeRunKey === key) {
|
2026-07-13 08:59:13 +00:00
|
|
|
|
setActiveRunKey(null);
|
|
|
|
|
|
setCurrentId(null);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
} else if (currentId && !list.some((p) => p.id === currentId)) {
|
|
|
|
|
|
pickNext(currentId, list, activeRunKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
setMessage(t("scout.deletedRun", { label }));
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setMessage(e instanceof Error ? e.message : t("scout.deleteRunFail"));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setGoalPersist(n: number) {
|
|
|
|
|
|
const g = Math.max(1, Math.min(99, n));
|
|
|
|
|
|
setGoal(g);
|
|
|
|
|
|
const t = loadScoutToday();
|
|
|
|
|
|
if (purpose === "activity") {
|
|
|
|
|
|
saveScoutToday({ ...t, goalActivity: g });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
saveScoutToday({ ...t, goalValue: g });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function startPatrol() {
|
|
|
|
|
|
const text = intent.trim();
|
|
|
|
|
|
if (!text) {
|
|
|
|
|
|
setMessage(purpose === "activity" ? t("scout.needKeyword") : t("scout.needIntent"));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setBusy("run");
|
|
|
|
|
|
setMessage("");
|
2026-07-13 08:59:13 +00:00
|
|
|
|
setCrawlerSessionRequired(false);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const freshProducts = await repos.scout.listAllProducts();
|
|
|
|
|
|
setAllProducts(freshProducts);
|
|
|
|
|
|
const selected =
|
|
|
|
|
|
purpose === "activity" ? null : freshProducts.find((p) => p.id === productId) || null;
|
|
|
|
|
|
if (purpose === "value" && productId && !selected) {
|
|
|
|
|
|
throw new Error(t("scout.productMissing"));
|
|
|
|
|
|
}
|
|
|
|
|
|
// ① 輕量 brief → 立刻海巡;每次按開始 = 獨立批次(unique theme_key)
|
|
|
|
|
|
const brief = await repos.scout.prepareBrief({
|
|
|
|
|
|
intent: text,
|
|
|
|
|
|
brandId: purpose === "activity" ? null : selected?.brand_id || null,
|
|
|
|
|
|
productId: purpose === "activity" ? null : selected?.id || null,
|
|
|
|
|
|
purpose,
|
|
|
|
|
|
deep: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
const baseLabel =
|
|
|
|
|
|
brief.theme_label || brief.product_label || brief.intent.slice(0, 36) || t("scout.defaultLabel");
|
|
|
|
|
|
const theme_key = newId("run");
|
|
|
|
|
|
const theme_label = `${baseLabel} · ${runTimeSuffix()}`;
|
|
|
|
|
|
const briefSaved: ScoutRunBrief = { ...brief, theme_key, theme_label };
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
const { job } = await repos.scout.runScanFromBrief({
|
|
|
|
|
|
...briefSaved,
|
|
|
|
|
|
scan_terms: briefSaved.scan_terms,
|
|
|
|
|
|
});
|
2026-07-10 05:10:31 +00:00
|
|
|
|
await repos.scout.saveHomework({
|
|
|
|
|
|
theme_key,
|
|
|
|
|
|
theme_label,
|
|
|
|
|
|
purpose,
|
|
|
|
|
|
brief: briefSaved,
|
|
|
|
|
|
created_at: nowUnixNano(),
|
|
|
|
|
|
});
|
|
|
|
|
|
setHomeworkList(await repos.scout.listHomework());
|
2026-07-13 08:59:13 +00:00
|
|
|
|
setScanJob(job);
|
|
|
|
|
|
setScanJobThemeKey(theme_key);
|
|
|
|
|
|
void reloadJobs();
|
|
|
|
|
|
setMessage(t("scout.scanQueued", { label: theme_label }));
|
2026-07-10 05:10:31 +00:00
|
|
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setMessage(e instanceof Error ? e.message : t("scout.patrolFail"));
|
2026-07-13 08:59:13 +00:00
|
|
|
|
setCrawlerSessionRequired(
|
|
|
|
|
|
typeof e === "object" && e !== null && "code" in e && (e as { code?: number }).code === 400061,
|
|
|
|
|
|
);
|
|
|
|
|
|
setBusy("");
|
|
|
|
|
|
} finally {
|
2026-07-10 05:10:31 +00:00
|
|
|
|
setBusy("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function reloadPosts(): Promise<ScoutPost[]> {
|
|
|
|
|
|
const list = await repos.scout.listPosts();
|
|
|
|
|
|
setPosts(list);
|
|
|
|
|
|
return list;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function regenDraft() {
|
|
|
|
|
|
if (!current) return;
|
|
|
|
|
|
setBusy("draft");
|
|
|
|
|
|
try {
|
2026-07-13 08:59:13 +00:00
|
|
|
|
const next = await repos.scout.draftOutreach(current.id);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
await reloadPosts();
|
|
|
|
|
|
setDraftText(next.draft_text || "");
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setMessage(e instanceof Error ? e.message : t("scout.draftFail"));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function skipCurrent() {
|
|
|
|
|
|
if (!current) return;
|
|
|
|
|
|
const id = current.id;
|
|
|
|
|
|
setBusy("skip");
|
|
|
|
|
|
try {
|
|
|
|
|
|
await repos.scout.skipOutreach(id);
|
|
|
|
|
|
const list = await reloadPosts();
|
|
|
|
|
|
pickNext(id, list);
|
2026-07-13 08:59:13 +00:00
|
|
|
|
setTodayDone(bumpScoutTodayDone().done);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
setMessage(t("scout.skipped"));
|
2026-07-13 08:59:13 +00:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setMessage(e instanceof Error ? e.message : t("scout.patrolFail"));
|
2026-07-10 05:10:31 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 06:33:14 +00:00
|
|
|
|
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() {
|
2026-07-10 05:10:31 +00:00
|
|
|
|
if (!current) return;
|
2026-07-20 06:33:14 +00:00
|
|
|
|
const id = current.id;
|
|
|
|
|
|
setBusy("manual-done");
|
2026-07-10 05:10:31 +00:00
|
|
|
|
setMessage("");
|
|
|
|
|
|
try {
|
2026-07-20 06:33:14 +00:00
|
|
|
|
await repos.scout.markPublished(id);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
const list = await reloadPosts();
|
|
|
|
|
|
pickNext(id, list);
|
2026-07-13 08:59:13 +00:00
|
|
|
|
const today = bumpScoutTodayDone();
|
|
|
|
|
|
setTodayDone(today.done);
|
2026-07-20 06:33:14 +00:00
|
|
|
|
setMessage(t("scout.manualDone", { done: today.done, goal }));
|
2026-07-10 05:10:31 +00:00
|
|
|
|
} catch (e) {
|
2026-07-20 06:33:14 +00:00
|
|
|
|
setMessage(e instanceof Error ? e.message : t("scout.manualDoneFail"));
|
2026-07-10 05:10:31 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 05:56:42 +00:00
|
|
|
|
async function reportConversionForCurrent() {
|
|
|
|
|
|
if (!current || current.outreach_status !== "published") return;
|
|
|
|
|
|
const amountRaw = window.prompt("成交金額(可留空)", convAmount);
|
|
|
|
|
|
if (amountRaw === null) return;
|
|
|
|
|
|
const note = window.prompt("備註(可留空)", convNote) ?? "";
|
|
|
|
|
|
const amount = Number(amountRaw) || 0;
|
|
|
|
|
|
setBusy("conversion");
|
|
|
|
|
|
setMessage("");
|
|
|
|
|
|
try {
|
|
|
|
|
|
// find outcome by listing recent and matching source
|
|
|
|
|
|
const outcomes = await repos.growth.listOutcomes(1, 50);
|
|
|
|
|
|
const hit =
|
|
|
|
|
|
outcomes.find(
|
|
|
|
|
|
(o) => o.source_type === "scout_outreach" && o.source_id === current.id,
|
|
|
|
|
|
) ?? null;
|
|
|
|
|
|
if (!hit) {
|
|
|
|
|
|
setMessage("尚無歸因紀錄(請稍後再試,或先完成標記)");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await repos.growth.reportConversion(hit.id, amount, note, "TWD");
|
|
|
|
|
|
setConvAmount(String(amount || ""));
|
|
|
|
|
|
setConvNote(note);
|
|
|
|
|
|
setMessage(
|
|
|
|
|
|
amount > 0
|
|
|
|
|
|
? `已回報成交 $${Math.round(amount)}`
|
|
|
|
|
|
: "已回報成交(無金額)",
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setMessage(e instanceof Error ? e.message : "回報成交失敗");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 05:10:31 +00:00
|
|
|
|
async function removeCurrent() {
|
|
|
|
|
|
if (!current) return;
|
|
|
|
|
|
if (!window.confirm(t("scout.confirmDeletePost"))) return;
|
|
|
|
|
|
const id = current.id;
|
|
|
|
|
|
setBusy("del");
|
|
|
|
|
|
try {
|
|
|
|
|
|
await repos.scout.removePost(id);
|
|
|
|
|
|
const list = await reloadPosts();
|
|
|
|
|
|
pickNext(id, list);
|
2026-07-13 08:59:13 +00:00
|
|
|
|
setMessage(t("scout.deletedPost"));
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setMessage(e instanceof Error ? e.message : t("common.error"));
|
2026-07-10 05:10:31 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const progressPct = Math.min(100, Math.round((todayDone / Math.max(1, goal)) * 100));
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<PageHeader title={t("scout.title")} />
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
{loadError ? <EmptyState title={loadError} /> : null}
|
|
|
|
|
|
|
2026-07-10 05:10:31 +00:00
|
|
|
|
{message ? (
|
|
|
|
|
|
<p className="hb-banner-ok" role="status">
|
|
|
|
|
|
{message}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
{crawlerSessionRequired ? (
|
|
|
|
|
|
<p className="hb-banner-ok" role="alert">
|
|
|
|
|
|
{t("scout.crawlerSessionRequired")} <Link to="/app/settings">{t("scout.openSettings")}</Link>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-07-10 05:10:31 +00:00
|
|
|
|
{/* ① 今日設定 */}
|
|
|
|
|
|
<Card title={t("scout.today")}>
|
|
|
|
|
|
<div className="hb-stack">
|
|
|
|
|
|
<div className="hb-tabs hb-tabs--sm" role="tablist">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`hb-tab ${purpose === "value" ? "is-active" : ""}`}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setPurpose("value");
|
|
|
|
|
|
const t = loadScoutToday();
|
|
|
|
|
|
setGoal(t.goalValue);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("scout.purposeValue")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`hb-tab ${purpose === "activity" ? "is-active" : ""}`}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setPurpose("activity");
|
|
|
|
|
|
setProductId("");
|
|
|
|
|
|
const t = loadScoutToday();
|
|
|
|
|
|
setGoal(t.goalActivity);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("scout.purposeActivity")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hb-scout-today-row">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
label={t("scout.goal")}
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={1}
|
|
|
|
|
|
max={99}
|
|
|
|
|
|
value={String(goal)}
|
|
|
|
|
|
onChange={(e) => setGoalPersist(Number(e.target.value) || 1)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="hb-scout-progress">
|
|
|
|
|
|
<p className="hb-field__label" style={{ margin: 0 }}>
|
|
|
|
|
|
{t("scout.progress", { done: todayDone, goal })}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="hb-progress" aria-hidden>
|
|
|
|
|
|
<div className="hb-progress__bar" style={{ width: `${progressPct}%` }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
label={purpose === "activity" ? t("scout.keyword") : t("scout.intent")}
|
|
|
|
|
|
value={intent}
|
|
|
|
|
|
onChange={(e) => setIntent(e.target.value)}
|
|
|
|
|
|
rows={purpose === "activity" ? 2 : 3}
|
|
|
|
|
|
placeholder={
|
|
|
|
|
|
purpose === "activity"
|
|
|
|
|
|
? t("scout.keywordPh")
|
|
|
|
|
|
: t("scout.intentPh")
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{purpose === "value" ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
label={t("scout.productOptional")}
|
|
|
|
|
|
value={
|
|
|
|
|
|
productId && productOptions.some((o) => o.id === productId) ? productId : ""
|
|
|
|
|
|
}
|
|
|
|
|
|
onChange={(e) => setProductId(e.target.value)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">{t("scout.noProduct")}</option>
|
|
|
|
|
|
{productOptions.map((o) => (
|
|
|
|
|
|
<option key={o.id} value={o.id}>
|
|
|
|
|
|
{o.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
{selectedProduct ? (
|
|
|
|
|
|
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
|
|
|
|
|
|
{t("scout.placement", { label: selectedProduct.label })}
|
|
|
|
|
|
{selectedProduct.pain_points?.[0]
|
|
|
|
|
|
? t("scout.painPart", { pain: selectedProduct.pain_points[0] })
|
|
|
|
|
|
: ""}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{allProducts.length === 0 ? (
|
|
|
|
|
|
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
|
|
|
|
|
|
{t("scout.noProductsBefore")}{" "}
|
|
|
|
|
|
<Link to="/app/brands">{t("nav.brands")}</Link>{" "}
|
|
|
|
|
|
{t("scout.noProductsAfter")}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hb-wizard-actions">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void startPatrol()}
|
|
|
|
|
|
disabled={busy === "run" || !intent.trim()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{busy === "run" ? t("scout.fetching") : pendingQueue.length ? t("scout.startMore") : t("scout.start")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
{visibleScanJob ? (
|
|
|
|
|
|
<Card title={t("scout.scanJob")}>
|
|
|
|
|
|
<div className="hb-inline-badges" role="status" aria-live="polite">
|
|
|
|
|
|
<Badge tone={jobStatusTone(visibleScanJob.status)}>
|
|
|
|
|
|
{jobStatusLabel(visibleScanJob.status, t)}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<span className="text-muted">
|
|
|
|
|
|
{visibleScanJob.progress_summary || t("scout.scanInProgress")}
|
|
|
|
|
|
{visibleScanJob.progress_percent ? ` · ${visibleScanJob.progress_percent}%` : ""}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ② 海巡批次:只由使用者選取,不會因 Job 或貼文更新跳動。 */}
|
2026-07-10 05:10:31 +00:00
|
|
|
|
{runGroups.length > 0 ? (
|
|
|
|
|
|
<Card title={t("scout.runs")}>
|
|
|
|
|
|
<div className="hb-stack">
|
2026-07-13 08:59:13 +00:00
|
|
|
|
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
|
|
|
|
|
{t("scout.runCount", { n: runGroups.length })}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="hb-scout-run-list">
|
|
|
|
|
|
{runGroups.map((g) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={g.key}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`hb-scout-run-item${activeRunKey === g.key ? " is-active" : ""}`}
|
|
|
|
|
|
onClick={() => selectRun(g.key)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>{g.label}</span>
|
|
|
|
|
|
<span className="hb-scout-run-item__meta">
|
2026-07-10 05:10:31 +00:00
|
|
|
|
{g.pending > 0 ? t("scout.runPending", { n: g.pending }) : t("scout.runDone")}
|
|
|
|
|
|
{g.total ? t("scout.runTotal", { n: g.total }) : ""}
|
2026-07-13 08:59:13 +00:00
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="hb-scout-run-row">
|
2026-07-10 05:10:31 +00:00
|
|
|
|
{activeRunKey ? (
|
|
|
|
|
|
<div className="hb-scout-run-row__action">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="danger"
|
|
|
|
|
|
disabled={Boolean(busy)}
|
|
|
|
|
|
onClick={() => void removeRun(activeRunKey)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{busy === "del-run" ? t("scout.deleting") : t("scout.deleteRun")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ③ 現在這一則(僅目前批次) */}
|
|
|
|
|
|
<Card title={t("scout.now")}>
|
|
|
|
|
|
{!current || (activeRunKey && postRunKey(current) !== activeRunKey) ? (
|
|
|
|
|
|
<EmptyState title={t("scout.emptyBatch")} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="hb-stack hb-scout-now">
|
|
|
|
|
|
<div className="hb-scout-now__meta">
|
|
|
|
|
|
<div className="hb-inline-badges">
|
|
|
|
|
|
<Badge tone="neutral">@{current.author}</Badge>
|
|
|
|
|
|
<Badge tone="brand">{current.search_tag}</Badge>
|
|
|
|
|
|
<Badge tone="neutral">{Math.round(current.score)}</Badge>
|
2026-07-13 08:59:13 +00:00
|
|
|
|
{current.scan_path ? <Badge tone="neutral">{t("scout.source", { source: current.scan_path })}</Badge> : null}
|
|
|
|
|
|
{current.classification ? (
|
|
|
|
|
|
<Badge tone="neutral">{t("scout.classification", { classification: current.classification })}</Badge>
|
|
|
|
|
|
) : null}
|
2026-07-10 05:10:31 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<p className="hb-scout-now__stance text-muted">
|
|
|
|
|
|
{stanceOf(current, t)}
|
|
|
|
|
|
{current.matched_product_label ? ` · ${current.matched_product_label}` : ""}
|
|
|
|
|
|
{activeRunMeta ? ` · ${shortRunLabel(activeRunMeta.label, 18)}` : ""}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="hb-scout-now__text">{current.text}</p>
|
2026-07-15 15:23:59 +00:00
|
|
|
|
{current.created_at || allowHttpUrl(current.permalink) ? (
|
2026-07-13 08:59:13 +00:00
|
|
|
|
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
|
|
|
|
|
|
{current.created_at ? t("scout.createdAt", { time: formatLocalDateTime(current.created_at) }) : ""}
|
2026-07-15 15:23:59 +00:00
|
|
|
|
{current.created_at && allowHttpUrl(current.permalink) ? " · " : ""}
|
|
|
|
|
|
{allowHttpUrl(current.permalink) ? (
|
|
|
|
|
|
<a href={allowHttpUrl(current.permalink) ?? undefined} target="_blank" rel="noreferrer">
|
2026-07-13 08:59:13 +00:00
|
|
|
|
{t("scout.openPermalink")}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
2026-07-10 05:10:31 +00:00
|
|
|
|
{current.match_reason ? (
|
|
|
|
|
|
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
|
|
|
|
|
|
{current.match_reason}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{current.opportunity ? (
|
|
|
|
|
|
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
|
|
|
|
|
|
{current.opportunity}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
label={t("scout.draft")}
|
|
|
|
|
|
value={draftText}
|
|
|
|
|
|
onChange={(e) => setDraftText(e.target.value)}
|
2026-07-13 08:59:13 +00:00
|
|
|
|
rows={current.scout_mode === "activity" ? 3 : 5}
|
|
|
|
|
|
placeholder={current.scout_mode === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")}
|
2026-07-10 05:10:31 +00:00
|
|
|
|
/>
|
|
|
|
|
|
|
2026-07-20 06:33:14 +00:00
|
|
|
|
<p className="text-muted" style={{ fontSize: "0.85rem", margin: 0 }}>
|
|
|
|
|
|
{t("scout.manualReplyHint")}
|
|
|
|
|
|
</p>
|
2026-07-10 05:10:31 +00:00
|
|
|
|
|
|
|
|
|
|
<div className="hb-wizard-actions">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
2026-07-13 08:59:13 +00:00
|
|
|
|
disabled={Boolean(busy) || !isPending(current)}
|
2026-07-10 05:10:31 +00:00
|
|
|
|
onClick={() => void skipCurrent()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{busy === "skip" ? "…" : t("scout.skip")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
2026-07-13 08:59:13 +00:00
|
|
|
|
disabled={Boolean(busy) || !isPending(current)}
|
2026-07-10 05:10:31 +00:00
|
|
|
|
onClick={() => void regenDraft()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{busy === "draft" ? "…" : t("scout.regen")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
2026-07-20 06:33:14 +00:00
|
|
|
|
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()}
|
2026-07-10 05:10:31 +00:00
|
|
|
|
>
|
2026-07-20 06:33:14 +00:00
|
|
|
|
{busy === "manual-done" ? "…" : t("scout.markManualDone")}
|
2026-07-10 05:10:31 +00:00
|
|
|
|
</Button>
|
2026-07-23 05:56:42 +00:00
|
|
|
|
{current.outreach_status === "published" ? (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
disabled={Boolean(busy)}
|
|
|
|
|
|
onClick={() => void reportConversionForCurrent()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{busy === "conversion" ? "…" : "回報成交"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : null}
|
2026-07-10 05:10:31 +00:00
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
disabled={Boolean(busy)}
|
|
|
|
|
|
onClick={() => void removeCurrent()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("common.delete")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
{/* ⑤ 不同回覆策略不能混排,避免將短回誤當成痛點接話。 */}
|
|
|
|
|
|
<MatchQueue
|
|
|
|
|
|
title={t("scout.valueQueue", { n: valueQueue.length })}
|
|
|
|
|
|
posts={valueQueue}
|
|
|
|
|
|
page={valueQueuePage}
|
|
|
|
|
|
onPageChange={setValueQueuePage}
|
|
|
|
|
|
currentId={currentId}
|
|
|
|
|
|
onSelect={setCurrentId}
|
|
|
|
|
|
t={t}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<MatchQueue
|
|
|
|
|
|
title={t("scout.activityQueue", { n: activityQueue.length })}
|
|
|
|
|
|
posts={activityQueue}
|
|
|
|
|
|
page={activityQueuePage}
|
|
|
|
|
|
onPageChange={setActivityQueuePage}
|
|
|
|
|
|
currentId={currentId}
|
|
|
|
|
|
onSelect={setCurrentId}
|
|
|
|
|
|
t={t}
|
|
|
|
|
|
/>
|
2026-07-10 05:10:31 +00:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|