2026-08-13 07:54:25 +00:00
|
|
|
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
2026-08-13 02:22:24 +00:00
|
|
|
|
import { Link, useSearchParams } from "react-router-dom";
|
|
|
|
|
|
import { PageHeader } from "../components/layout/PageHeader";
|
|
|
|
|
|
import { OpportunityInboxCard } from "../components/radar/OpportunityInboxCard";
|
|
|
|
|
|
import { OpportunityDetailDrawer } from "../components/radar/OpportunityDetailDrawer";
|
2026-08-13 07:54:25 +00:00
|
|
|
|
import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary";
|
2026-08-13 02:22:24 +00:00
|
|
|
|
import { Button, EmptyState, Select } from "../components/ui";
|
|
|
|
|
|
import { useRepos } from "../data/DataContext";
|
2026-08-13 07:54:25 +00:00
|
|
|
|
import type { Brand, BrandProduct, JobStatus, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope, RadarSweep, RadarToday, RadarWatch } from "../domain/types";
|
2026-08-13 02:22:24 +00:00
|
|
|
|
import { useFormatApiError } from "../lib/apiErrors";
|
2026-08-13 07:54:25 +00:00
|
|
|
|
import { formatLocalDateTime } from "../lib/time";
|
2026-08-13 02:22:24 +00:00
|
|
|
|
import "../styles/radar.css";
|
|
|
|
|
|
|
|
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeSort(value: string | null): string {
|
|
|
|
|
|
if (value === "posted") return "newest";
|
|
|
|
|
|
if (value === "score") return "recommended";
|
|
|
|
|
|
return value || "recommended";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function RadarOpportunitiesPage() {
|
|
|
|
|
|
const repos = useRepos();
|
|
|
|
|
|
const formatError = useFormatApiError();
|
|
|
|
|
|
const [params, setParams] = useSearchParams();
|
|
|
|
|
|
const [list, setList] = useState<Opportunity[]>([]);
|
|
|
|
|
|
const [total, setTotal] = useState(0);
|
|
|
|
|
|
const [page, setPage] = useState(() => Math.max(1, Number(params.get("page")) || 1));
|
|
|
|
|
|
const [band, setBand] = useState(params.get("band") || "");
|
|
|
|
|
|
const [state, setState] = useState(params.get("match_state") || "");
|
|
|
|
|
|
const [brandId, setBrandId] = useState(params.get("brand_id") || "");
|
|
|
|
|
|
const [productId, setProductId] = useState(params.get("product_id") || "");
|
|
|
|
|
|
const [sort, setSort] = useState(() => normalizeSort(params.get("sort")));
|
|
|
|
|
|
const [reviewState, setReviewState] = useState<OpportunityReviewState>((params.get("review_state") as OpportunityReviewState) || "pending");
|
|
|
|
|
|
const [timeScope, setTimeScope] = useState<OpportunityTimeScope>((params.get("time_scope") as OpportunityTimeScope) || "today");
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState("");
|
|
|
|
|
|
const [notice, setNotice] = useState<{ text: string; contactId?: string } | null>(null);
|
|
|
|
|
|
const [brands, setBrands] = useState<Brand[]>([]);
|
|
|
|
|
|
const [products, setProducts] = useState<BrandProduct[]>([]);
|
|
|
|
|
|
const [selected, setSelected] = useState<Opportunity | null>(null);
|
|
|
|
|
|
const [busyId, setBusyId] = useState<string | null>(null);
|
2026-08-13 07:54:25 +00:00
|
|
|
|
const [sweeping, setSweeping] = useState(false);
|
|
|
|
|
|
const [lastSweep, setLastSweep] = useState<RadarSweep | null>(null);
|
|
|
|
|
|
const [watches, setWatches] = useState<RadarWatch[]>([]);
|
|
|
|
|
|
const [activeCount, setActiveCount] = useState(0);
|
|
|
|
|
|
const [watchTotal, setWatchTotal] = useState(0);
|
|
|
|
|
|
const [todayMeta, setTodayMeta] = useState<RadarToday | null>(null);
|
2026-08-13 02:22:24 +00:00
|
|
|
|
const [advancedOpen, setAdvancedOpen] = useState(() => Boolean(params.get("band") || params.get("match_state") || params.get("brand_id") || params.get("product_id")));
|
|
|
|
|
|
const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (scout) void scout.listBrands().then(setBrands).catch(() => setBrands([]));
|
|
|
|
|
|
}, [scout]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!scout || !brandId) {
|
|
|
|
|
|
setProducts([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
void scout.listProducts(brandId).then(setProducts).catch(() => setProducts([]));
|
|
|
|
|
|
}, [scout, brandId]);
|
|
|
|
|
|
|
2026-08-13 07:54:25 +00:00
|
|
|
|
const loadPatrol = useCallback(async () => {
|
|
|
|
|
|
const [watchRes, today] = await Promise.all([
|
|
|
|
|
|
repos.radar.listWatches(1, 50).catch(() => null),
|
|
|
|
|
|
repos.radar.getToday().catch(() => null),
|
|
|
|
|
|
]);
|
|
|
|
|
|
if (watchRes) {
|
|
|
|
|
|
setWatches(watchRes.list);
|
|
|
|
|
|
setActiveCount(watchRes.active_count);
|
|
|
|
|
|
setWatchTotal(watchRes.total);
|
|
|
|
|
|
}
|
|
|
|
|
|
setTodayMeta(today);
|
|
|
|
|
|
if (typeof repos.radar.listSweeps === "function") {
|
|
|
|
|
|
const sweeps = await repos.radar.listSweeps(1, 1).catch(() => null);
|
|
|
|
|
|
setLastSweep(sweeps?.list[0] ?? null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [repos.radar]);
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
|
setLoading(true);
|
2026-08-13 07:54:25 +00:00
|
|
|
|
const query = (scope: OpportunityTimeScope, review: OpportunityReviewState = reviewState) =>
|
|
|
|
|
|
repos.radar.listOpportunities({
|
|
|
|
|
|
page: scope === timeScope ? page : 1,
|
2026-08-13 02:22:24 +00:00
|
|
|
|
pageSize: PAGE_SIZE,
|
|
|
|
|
|
band: band || undefined,
|
|
|
|
|
|
match_state: state || undefined,
|
|
|
|
|
|
brand_id: brandId || undefined,
|
|
|
|
|
|
product_id: productId || undefined,
|
2026-08-13 07:54:25 +00:00
|
|
|
|
review_state: review,
|
|
|
|
|
|
time_scope: scope,
|
2026-08-13 02:22:24 +00:00
|
|
|
|
sort,
|
|
|
|
|
|
});
|
2026-08-13 07:54:25 +00:00
|
|
|
|
try {
|
|
|
|
|
|
let result = await query(timeScope);
|
|
|
|
|
|
const canWiden = reviewState === "pending" && timeScope === "today" && !band && !state && !brandId && !productId;
|
|
|
|
|
|
if (canWiden && result.total === 0) {
|
|
|
|
|
|
const week = await query("7d");
|
|
|
|
|
|
if (week.total > 0) {
|
|
|
|
|
|
setTimeScope("7d");
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
|
|
|
|
|
|
next.set("time_scope", "7d");
|
|
|
|
|
|
next.delete("page");
|
|
|
|
|
|
setParams(next, { replace: true });
|
|
|
|
|
|
result = week;
|
|
|
|
|
|
setNotice({ text: `巡邏結果不是都在「今天發的文」。已改看近 7 天(${week.total} 筆)。任務上的判定/新建數字包含同一篇再命中,不一定全是新卡片。` });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const all = await query("all");
|
|
|
|
|
|
if (all.total > 0) {
|
|
|
|
|
|
setTimeScope("all");
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
|
|
|
|
|
|
next.set("time_scope", "all");
|
|
|
|
|
|
next.delete("page");
|
|
|
|
|
|
setParams(next, { replace: true });
|
|
|
|
|
|
result = all;
|
|
|
|
|
|
setNotice({ text: `近 7 天沒有待處理結果,已改看全部(${all.total} 筆)。` });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const seen = await query("7d", "completed");
|
|
|
|
|
|
if (seen.total > 0) {
|
|
|
|
|
|
setNotice({ text: `這輪判定到的 ${seen.total} 筆已在「已看過」,所以「新找到」是空的。任務數字含再次命中的舊文。` });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
setList(result.list);
|
|
|
|
|
|
setTotal(result.total);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(formatError(e));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
2026-08-13 07:54:25 +00:00
|
|
|
|
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError, setParams]);
|
2026-08-13 02:22:24 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => { void load(); }, [load]);
|
2026-08-13 07:54:25 +00:00
|
|
|
|
useEffect(() => { void loadPatrol(); }, [loadPatrol]);
|
2026-08-13 02:22:24 +00:00
|
|
|
|
|
|
|
|
|
|
async function updateReviewState(
|
|
|
|
|
|
opportunity: Opportunity,
|
|
|
|
|
|
patch: { state: OpportunityReviewState; removal_reason?: OpportunityRemovalReason; removal_note?: string },
|
|
|
|
|
|
successText: string,
|
|
|
|
|
|
) {
|
|
|
|
|
|
setBusyId(opportunity.id);
|
|
|
|
|
|
setNotice(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const updated = await repos.radar.updateOpportunityReviewState(opportunity.id, patch);
|
|
|
|
|
|
setSelected((current) => current?.id === updated.id ? null : current);
|
|
|
|
|
|
await load();
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
setNotice({ text: successText });
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(formatError(e));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusyId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function acceptOpportunity(opportunity: Opportunity) {
|
|
|
|
|
|
setBusyId(opportunity.id);
|
|
|
|
|
|
setNotice(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await repos.radar.acceptOpportunity(opportunity.id);
|
|
|
|
|
|
setSelected((current) => current?.id === opportunity.id ? null : current);
|
|
|
|
|
|
await load();
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
setNotice({
|
2026-08-13 07:54:25 +00:00
|
|
|
|
text: "已加入名單。這步是可選的,之後要追蹤再去名單即可。",
|
2026-08-13 02:22:24 +00:00
|
|
|
|
contactId: result.contact_id,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(formatError(e));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusyId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 07:54:25 +00:00
|
|
|
|
async function waitForJob(jobId: string): Promise<{ status: JobStatus; progress_summary: string; error: string; timedOut: boolean }> {
|
|
|
|
|
|
const deadline = Date.now() + 120_000;
|
|
|
|
|
|
let last: { status: JobStatus; progress_summary: string; error: string } = {
|
|
|
|
|
|
status: "queued",
|
|
|
|
|
|
progress_summary: "立即巡邏已排程 · 等待 worker",
|
|
|
|
|
|
error: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
|
|
|
const job = await repos.jobs.get(jobId);
|
|
|
|
|
|
if (job) {
|
|
|
|
|
|
last = { status: job.status, progress_summary: job.progress_summary, error: job.error || "" };
|
|
|
|
|
|
if (job.status === "succeeded" || job.status === "failed" || job.status === "cancelled") {
|
|
|
|
|
|
return { ...last, timedOut: false };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await new Promise((resolve) => window.setTimeout(resolve, 1500));
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ...last, timedOut: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function runNow() {
|
|
|
|
|
|
const active = watches.filter((watch) => watch.status === "active");
|
|
|
|
|
|
if (!active.length) {
|
|
|
|
|
|
setError("沒有開著的每日巡邏。先設定要巡的產品與關鍵字,或恢復一組訂閱。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setSweeping(true);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
setNotice(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const jobIds: string[] = [];
|
|
|
|
|
|
for (const watch of active) {
|
|
|
|
|
|
const res = await repos.radar.triggerWatchSweep(watch.id);
|
|
|
|
|
|
if (res.job_id) jobIds.push(res.job_id);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!jobIds.length) {
|
|
|
|
|
|
setError("沒有排到巡邏任務。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setNotice({ text: "立即巡邏進行中… 跑完才會把痛點列在下面。" });
|
|
|
|
|
|
// All active watches were queued together, so observe them together too.
|
|
|
|
|
|
// Waiting serially made the page look frozen for up to 120s per watch.
|
|
|
|
|
|
const finished = await Promise.all(jobIds.map((id) => waitForJob(id)));
|
|
|
|
|
|
const failed = finished.find((job) => job.status === "failed");
|
|
|
|
|
|
const cancelled = finished.find((job) => job.status === "cancelled");
|
|
|
|
|
|
const running = finished.filter((job) => job.timedOut || job.status === "pending" || job.status === "queued" || job.status === "running" || job.status === "cancel_requested");
|
|
|
|
|
|
const terminalError = failed
|
|
|
|
|
|
? failed.progress_summary || failed.error || "巡邏失敗。"
|
|
|
|
|
|
: cancelled
|
|
|
|
|
|
? "巡邏已取消,不會誤顯示為已完成。可再按一次立即巡邏。"
|
|
|
|
|
|
: "";
|
|
|
|
|
|
if (typeof repos.radar.listSweeps === "function") {
|
|
|
|
|
|
const sweeps = await repos.radar.listSweeps(1, 1).catch(() => null);
|
|
|
|
|
|
setLastSweep(sweeps?.list[0] ?? null);
|
|
|
|
|
|
}
|
|
|
|
|
|
await Promise.all([load(), loadPatrol()]);
|
|
|
|
|
|
const summary = finished.map((job) => job.progress_summary).filter(Boolean).join(" ");
|
|
|
|
|
|
// load() clears stale list errors on a successful refresh. Re-apply the
|
|
|
|
|
|
// job outcome afterwards so the actual patrol failure stays visible.
|
|
|
|
|
|
if (terminalError) {
|
|
|
|
|
|
setError(terminalError);
|
|
|
|
|
|
setNotice(null);
|
|
|
|
|
|
} else if (running.length > 0) {
|
|
|
|
|
|
setNotice({ text: `已排入 ${jobIds.length} 組巡邏,目前仍在後台執行。可先離開這頁,完成後結果會留在這裡。` });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setNotice({
|
|
|
|
|
|
text: summary.includes("新建")
|
|
|
|
|
|
? `${summary} 不是今天發的文也會留在下面。`
|
|
|
|
|
|
: "這一輪巡邏跑完了。找到的痛點會留在下面。",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(formatError(e));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSweeping(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
function writeParams(changes: Record<string, string>) {
|
|
|
|
|
|
const next = new URLSearchParams(params);
|
|
|
|
|
|
for (const [name, value] of Object.entries(changes)) {
|
|
|
|
|
|
if (value) next.set(name, value);
|
|
|
|
|
|
else next.delete(name);
|
|
|
|
|
|
}
|
|
|
|
|
|
setParams(next, { replace: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetFilters() {
|
|
|
|
|
|
setBand("");
|
|
|
|
|
|
setState("");
|
|
|
|
|
|
setBrandId("");
|
|
|
|
|
|
setProductId("");
|
|
|
|
|
|
setSort("recommended");
|
|
|
|
|
|
setTimeScope("today");
|
|
|
|
|
|
setAdvancedOpen(false);
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
const next = new URLSearchParams();
|
2026-08-13 07:54:25 +00:00
|
|
|
|
if (reviewState !== "pending") next.set("review_state", reviewState);
|
2026-08-13 02:22:24 +00:00
|
|
|
|
setParams(next, { replace: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 07:54:25 +00:00
|
|
|
|
const lastSweptAt = Math.max(
|
|
|
|
|
|
todayMeta?.last_swept_at ?? 0,
|
|
|
|
|
|
...watches.map((watch) => watch.last_swept_at ?? 0),
|
|
|
|
|
|
);
|
|
|
|
|
|
const scheduledOn = activeCount > 0;
|
2026-08-13 02:22:24 +00:00
|
|
|
|
const advancedFilterCount = [band, state, brandId, productId].filter(Boolean).length;
|
|
|
|
|
|
const filtered = Boolean(advancedFilterCount || sort !== "recommended" || timeScope !== "today");
|
|
|
|
|
|
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
|
|
|
|
|
2026-08-13 07:54:25 +00:00
|
|
|
|
function emptyCopy(): { title: string; description: string; action?: ReactNode } {
|
|
|
|
|
|
if (filtered) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: reviewState === "pending" ? "這個篩選下沒有結果" : reviewState === "completed" ? "目前沒有已看過的結果" : "目前沒有已丟掉的結果",
|
|
|
|
|
|
description: "清除篩選或改看其他時間範圍。巡邏剛跑完的結果也可能在「近 7 天」或「全部」。",
|
|
|
|
|
|
action: <Button type="button" variant="ghost" onClick={resetFilters}>清除篩選</Button>,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (reviewState === "completed") {
|
|
|
|
|
|
return { title: "還沒有已看過的結果", description: "切回「新找到」繼續看巡邏到的痛點。" };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (reviewState === "removed") {
|
|
|
|
|
|
return { title: "還沒有丟掉的結果", description: "切回「新找到」繼續看巡邏到的痛點。" };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (watchTotal === 0) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: "還沒設定巡邏",
|
|
|
|
|
|
description: "先選產品與客人會搜的關鍵字。設好後可立即巡邏,每日定時巡邏也會接著跑。",
|
|
|
|
|
|
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">設定巡邏</Link>,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!scheduledOn) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: "每日定時巡邏關著",
|
|
|
|
|
|
description: "立即巡邏與每日定時都還在這個頁面。恢復至少一組訂閱後,兩個都能用;關掉其中一個不會藏掉另一個。",
|
|
|
|
|
|
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">打開每日定時巡邏</Link>,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!lastSweptAt) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: "還沒巡邏過",
|
|
|
|
|
|
description: "每日定時巡邏已開著,也可現在按「立即巡邏」。不是空白收件匣,只是第一輪還沒跑完。",
|
|
|
|
|
|
action: <Button type="button" onClick={() => void runNow()} disabled={sweeping}>{sweeping ? "巡邏中…" : "立即巡邏"}</Button>,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (todayMeta?.empty_reason === "sweep_failed") {
|
|
|
|
|
|
const crawlerDead = /crawler session|Chrome crawler|Chrome 登入已過期/i.test(
|
|
|
|
|
|
`${todayMeta.empty_hint || ""} ${lastSweep?.failed_reason || ""}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: "上一輪巡邏沒跑完",
|
|
|
|
|
|
description: todayMeta.empty_hint || "巡邏失敗。可再按立即巡邏,或改看近 7 天/全部。",
|
|
|
|
|
|
action: (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{crawlerDead ? <Link className="hb-btn hb-btn--secondary" to="/app/settings">重新同步 Chrome</Link> : null}
|
|
|
|
|
|
<Button type="button" onClick={() => void runNow()} disabled={sweeping}>{sweeping ? "巡邏中…" : "再巡一次"}</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (lastSweep && lastSweep.hit_count === 0) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: "搜尋沒撈到貼文",
|
|
|
|
|
|
description: "門檻前就空了:關鍵字太長、太產品名、或 Threads 查無結果。改成客人會打的 2–4 字痛點詞再巡。",
|
|
|
|
|
|
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">改關鍵字</Link>,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: "這輪有巡,但沒找到符合的痛點",
|
|
|
|
|
|
description: lastSweep
|
|
|
|
|
|
? `搜尋命中 ${lastSweep.hit_count}、判定 ${lastSweep.judged_count}、新建 ${lastSweep.created_count}。不是今天發的文可改看近 7 天/全部。`
|
|
|
|
|
|
: "新文章或產品對得上的需求會出現在這裡。也可改看「近 7 天」或「全部」,或調整要巡的關鍵字。",
|
|
|
|
|
|
action: <Button type="button" variant="ghost" onClick={() => { setTimeScope("7d"); setPage(1); writeParams({ time_scope: "7d", page: "" }); }}>看近 7 天</Button>,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const empty = emptyCopy();
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-08-13 07:54:25 +00:00
|
|
|
|
<PageHeader title="商機" />
|
|
|
|
|
|
|
|
|
|
|
|
<section className="hb-radar-patrol" data-testid="radar-patrol-desk" aria-label="巡邏狀態">
|
|
|
|
|
|
<div className="hb-radar-patrol__status">
|
|
|
|
|
|
<p>
|
|
|
|
|
|
<strong>{scheduledOn ? "每日定時巡邏:開著" : "每日定時巡邏:關著"}</strong>
|
|
|
|
|
|
<span>每天台北 06:00 自動巡一輪。關掉立即巡邏不會停每日定時。</span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
<strong>{lastSweptAt ? `上次巡邏:${formatLocalDateTime(lastSweptAt)}` : "還沒巡邏過"}</strong>
|
|
|
|
|
|
<span>{scheduledOn ? `啟用中 ${activeCount} 組` : watchTotal ? "訂閱都暫停了,立即巡邏也需要至少一組開著" : "還沒設定要巡的產品與關鍵字"}</span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="hb-radar-patrol__actions">
|
|
|
|
|
|
<Button type="button" onClick={() => void runNow()} disabled={sweeping || !scheduledOn}>
|
|
|
|
|
|
{sweeping ? "巡邏中…" : "立即巡邏"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Link className="hb-btn hb-btn--ghost" to="/app/radar/watches">設定巡邏</Link>
|
|
|
|
|
|
<small>Chrome 失效時會備援改用 API;只有 provider 成功回傳才計點。</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
{lastSweep ? <SweepFunnelSummary sweep={lastSweep} /> : null}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
|
|
|
|
|
|
<section className="hb-radar-intro">
|
|
|
|
|
|
<div>
|
2026-08-13 07:54:25 +00:00
|
|
|
|
<strong>巡邏到痛點就看這裡</strong>
|
|
|
|
|
|
<p>先讀「為什麼推薦」,留下或丟掉即可。加入名單是可選的,不是看結果的必要步驟。</p>
|
2026-08-13 02:22:24 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-08-13 07:54:25 +00:00
|
|
|
|
<section className="hb-radar-filter-panel" aria-label="商機結果">
|
2026-08-13 02:22:24 +00:00
|
|
|
|
<div className="hb-radar-filter-panel__head">
|
2026-08-13 07:54:25 +00:00
|
|
|
|
<div className="hb-radar-inbox-tabs" role="tablist" aria-label="結果狀態">
|
2026-08-13 02:22:24 +00:00
|
|
|
|
{(["pending", "completed", "removed"] as OpportunityReviewState[]).map((value) => (
|
|
|
|
|
|
<Button key={value} type="button" variant={reviewState === value ? "primary" : "ghost"} aria-pressed={reviewState === value} onClick={() => {
|
2026-08-13 07:54:25 +00:00
|
|
|
|
setReviewState(value); setPage(1); writeParams({ review_state: value === "pending" ? "" : value, page: "" });
|
2026-08-13 02:22:24 +00:00
|
|
|
|
}}>
|
2026-08-13 07:54:25 +00:00
|
|
|
|
{value === "pending" ? "新找到" : value === "completed" ? "已看過" : "已丟掉"}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<span>共 {total} 筆</span>
|
|
|
|
|
|
</div>
|
2026-08-13 07:54:25 +00:00
|
|
|
|
{filtered ? <Button type="button" variant="ghost" onClick={resetFilters}>清除篩選</Button> : <span>預設先看今天剛巡到的結果</span>}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="hb-radar-inbox-essential-filters">
|
|
|
|
|
|
<Select name="all-time-scope" label="看哪段時間" value={timeScope} onChange={(e) => {
|
|
|
|
|
|
const value = e.target.value as OpportunityTimeScope;
|
2026-08-13 07:54:25 +00:00
|
|
|
|
setTimeScope(value); setPage(1); writeParams({ time_scope: value === "today" ? "" : value, page: "" });
|
2026-08-13 02:22:24 +00:00
|
|
|
|
}}>
|
|
|
|
|
|
<option value="today">今天</option><option value="7d">近 7 天</option><option value="all">全部</option>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Select name="all-sort" label="先看哪些" value={sort} onChange={(e) => {
|
|
|
|
|
|
setSort(e.target.value); setPage(1); writeParams({ sort: e.target.value === "recommended" ? "" : e.target.value, page: "" });
|
|
|
|
|
|
}}>
|
2026-08-13 07:54:25 +00:00
|
|
|
|
<option value="recommended">最對得上產品</option><option value="newest">最新貼文</option><option value="oldest">最舊貼文</option><option value="product_fit">最符合產品</option><option value="demand_intent">需求最明確</option>
|
2026-08-13 02:22:24 +00:00
|
|
|
|
</Select>
|
|
|
|
|
|
<Button type="button" variant="ghost" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen((value) => !value)}>
|
|
|
|
|
|
{advancedOpen ? "收起更多篩選" : `更多篩選${advancedFilterCount ? `(${advancedFilterCount})` : ""}`}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{advancedOpen ? <div className="hb-radar-filter-grid hb-radar-filter-grid--all" aria-label="更多篩選">
|
|
|
|
|
|
{brands.length ? (
|
|
|
|
|
|
<Select name="all-brand" label="品牌" value={brandId} onChange={(e) => {
|
|
|
|
|
|
const value = e.target.value;
|
|
|
|
|
|
setBrandId(value); setProductId(""); setPage(1);
|
|
|
|
|
|
writeParams({ brand_id: value, product_id: "", page: "" });
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<option value="">全部品牌</option>
|
|
|
|
|
|
{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{products.length ? (
|
|
|
|
|
|
<Select name="all-product" label="產品" value={productId} onChange={(e) => {
|
|
|
|
|
|
setProductId(e.target.value); setPage(1);
|
|
|
|
|
|
writeParams({ product_id: e.target.value, page: "" });
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<option value="">全部產品</option>
|
|
|
|
|
|
{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<Select name="all-band" label="商機意向" value={band} onChange={(e) => {
|
|
|
|
|
|
setBand(e.target.value); setPage(1); writeParams({ band: e.target.value, page: "" });
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<option value="">全部意向</option><option value="high">高意向</option><option value="mid">中意向</option><option value="low">低意向</option>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Select name="all-state" label="產品匹配" value={state} onChange={(e) => {
|
|
|
|
|
|
setState(e.target.value); setPage(1); writeParams({ match_state: e.target.value, page: "" });
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<option value="">全部狀態</option><option value="eligible">可跟進</option><option value="weak">弱適配</option><option value="excluded">已排除</option><option value="generic">未指定產品</option><option value="stale">超過 14 天</option>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div> : null}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
{error ? <p className="hb-banner-error" role="alert">{error}</p> : null}
|
2026-08-13 07:54:25 +00:00
|
|
|
|
{notice ? <div className="hb-banner-ok" role="status"><span>{notice.text}</span>{notice.contactId ? <Link to={`/app/crm?contact=${encodeURIComponent(notice.contactId)}`}>前往名單</Link> : null}</div> : null}
|
|
|
|
|
|
{loading ? <p className="hb-radar-section__hint" role="status">正在整理巡邏結果…</p> : null}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
{!loading && !error && !list.length ? (
|
2026-08-13 07:54:25 +00:00
|
|
|
|
<EmptyState title={empty.title} description={empty.description} action={empty.action} />
|
2026-08-13 02:22:24 +00:00
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{!error ? (
|
|
|
|
|
|
<section className="hb-radar-page hb-radar-all-results">
|
|
|
|
|
|
{list.map((o) => <OpportunityInboxCard
|
|
|
|
|
|
key={o.id}
|
|
|
|
|
|
opportunity={o}
|
|
|
|
|
|
busy={busyId === o.id}
|
|
|
|
|
|
onOpen={setSelected}
|
|
|
|
|
|
onAccept={(item) => void acceptOpportunity(item)}
|
2026-08-13 07:54:25 +00:00
|
|
|
|
onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已留下。沒有建立名單。")}
|
|
|
|
|
|
onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, "已丟掉。可從「已丟掉」還原。")}
|
|
|
|
|
|
onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, "已還原。")}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
/>)}
|
|
|
|
|
|
{total > PAGE_SIZE ? (
|
|
|
|
|
|
<div className="hb-radar-pager">
|
|
|
|
|
|
<Button variant="ghost" disabled={page <= 1} onClick={() => { const next = page - 1; setPage(next); writeParams({ page: String(next) }); }}>上一頁</Button>
|
|
|
|
|
|
<span>第 {page} 頁/共 {pageCount} 頁</span>
|
|
|
|
|
|
<Button variant="ghost" disabled={page >= pageCount} onClick={() => { const next = page + 1; setPage(next); writeParams({ page: String(next) }); }}>下一頁</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{selected ? <OpportunityDetailDrawer
|
|
|
|
|
|
opportunity={selected}
|
|
|
|
|
|
busy={busyId === selected.id}
|
|
|
|
|
|
onClose={() => setSelected(null)}
|
|
|
|
|
|
onAccept={(item) => void acceptOpportunity(item)}
|
2026-08-13 07:54:25 +00:00
|
|
|
|
onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已留下。沒有建立名單。")}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
/> : null}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|