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

507 lines
24 KiB
TypeScript

import { useCallback, useEffect, useState, type ReactNode } from "react";
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";
import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary";
import { Button, EmptyState, Select } from "../components/ui";
import { useRepos } from "../data/DataContext";
import type { Brand, BrandProduct, JobStatus, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope, RadarSweep, RadarToday, RadarWatch } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { useFormatApiError } from "../lib/apiErrors";
import { sanitizeReviewCopy } from "../lib/reviewCopy";
import { formatLocalDateTime } from "../lib/time";
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 { t, locale } = useI18n();
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);
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);
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]);
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]);
const load = useCallback(async () => {
setLoading(true);
const query = (scope: OpportunityTimeScope, review: OpportunityReviewState = reviewState) =>
repos.radar.listOpportunities({
page: scope === timeScope ? page : 1,
pageSize: PAGE_SIZE,
band: band || undefined,
match_state: state || undefined,
brand_id: brandId || undefined,
product_id: productId || undefined,
review_state: review,
time_scope: scope,
sort,
});
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: t("radar.inbox.msg.widened7d", { n: 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: t("radar.inbox.msg.widenedAll", { n: all.total }) });
} else {
const seen = await query("7d", "completed");
if (seen.total > 0) {
setNotice({ text: t("radar.inbox.msg.alreadyReviewed", { n: seen.total }) });
}
}
}
}
setList(result.list);
setTotal(result.total);
setError("");
} catch (e) {
setError(formatError(e));
} finally {
setLoading(false);
}
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError, setParams, t]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { void loadPatrol(); }, [loadPatrol]);
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({
text: t("radar.inbox.msg.accepted"),
contactId: result.contact_id,
});
} catch (e) {
setError(formatError(e));
} finally {
setBusyId(null);
}
}
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: t("radar.inbox.msg.waitingWorker"),
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(t("radar.inbox.err.noActive"));
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(t("radar.inbox.err.noJob"));
return;
}
setNotice({ text: t("radar.inbox.msg.running") });
// 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 || t("radar.inbox.err.failed")
: cancelled
? t("radar.inbox.err.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: t("radar.inbox.msg.queuedN", { n: jobIds.length }) });
} else {
setNotice({
text: /新建|created/i.test(summary)
? t("radar.inbox.msg.doneWithSummary", { summary })
: t("radar.inbox.msg.done"),
});
}
} catch (e) {
setError(formatError(e));
} finally {
setSweeping(false);
}
}
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();
if (reviewState !== "pending") next.set("review_state", reviewState);
setParams(next, { replace: true });
}
const lastSweptAt = Math.max(
todayMeta?.last_swept_at ?? 0,
...watches.map((watch) => watch.last_swept_at ?? 0),
);
const scheduledOn = activeCount > 0;
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));
function emptyCopy(): { title: string; description: string; action?: ReactNode } {
if (filtered) {
return {
title: t(
reviewState === "pending"
? "radar.inbox.empty.filteredPending"
: reviewState === "completed"
? "radar.inbox.empty.filteredCompleted"
: "radar.inbox.empty.filteredRemoved",
),
description: t("radar.inbox.empty.filteredHint"),
action: <Button type="button" variant="ghost" onClick={resetFilters}>{t("radar.inbox.clearFilters")}</Button>,
};
}
if (reviewState === "completed") {
return { title: t("radar.inbox.empty.noCompleted"), description: t("radar.inbox.empty.noCompletedHint") };
}
if (reviewState === "removed") {
return { title: t("radar.inbox.empty.noRemoved"), description: t("radar.inbox.empty.noRemovedHint") };
}
if (watchTotal === 0) {
return {
title: t("radar.inbox.empty.noWatchesTitle"),
description: t("radar.inbox.empty.noWatchesHint"),
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">{t("radar.inbox.setupWatches")}</Link>,
};
}
if (!scheduledOn) {
return {
title: t("radar.inbox.empty.pausedTitle"),
description: t("radar.inbox.empty.pausedHint"),
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">{t("radar.inbox.empty.openSchedule")}</Link>,
};
}
if (!lastSweptAt) {
return {
title: t("radar.inbox.empty.neverTitle"),
description: t("radar.inbox.empty.neverHint"),
action: <Button type="button" onClick={() => void runNow()} disabled={sweeping}>{sweeping ? t("radar.inbox.sweeping") : t("radar.inbox.sweepNow")}</Button>,
};
}
if (todayMeta?.empty_reason === "sweep_failed") {
const crawlerDead = /crawler session|Chrome crawler|Chrome 登入已過期/i.test(
`${todayMeta.empty_hint || ""} ${lastSweep?.failed_reason || ""}`,
);
return {
title: t("radar.inbox.empty.failedTitle"),
description: sanitizeReviewCopy(
todayMeta.empty_hint || t("radar.empty.sweepFailedHint"),
locale,
),
action: (
<>
{crawlerDead ? (
<Link className="hb-btn hb-btn--secondary" to="/app/settings">
{t("radar.reconnectSearch")}
</Link>
) : null}
<Button type="button" onClick={() => void runNow()} disabled={sweeping}>{sweeping ? t("radar.inbox.sweeping") : t("radar.inbox.sweepAgain")}</Button>
</>
),
};
}
if (lastSweep && lastSweep.hit_count === 0) {
return {
title: t("radar.inbox.empty.noHitsTitle"),
description: t("radar.inbox.empty.noHitsHint"),
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">{t("radar.inbox.empty.editTerms")}</Link>,
};
}
return {
title: t("radar.inbox.empty.noFitTitle"),
description: lastSweep
? t("radar.inbox.empty.noFitStats", { hits: lastSweep.hit_count, judged: lastSweep.judged_count, created: lastSweep.created_count })
: t("radar.inbox.empty.noFitHint"),
action: <Button type="button" variant="ghost" onClick={() => { setTimeScope("7d"); setPage(1); writeParams({ time_scope: "7d", page: "" }); }}>{t("radar.inbox.see7d")}</Button>,
};
}
const empty = emptyCopy();
return (
<>
<PageHeader title={t("radar.inbox.title")} />
<section className="hb-radar-patrol" data-testid="radar-patrol-desk" aria-label={t("radar.inbox.patrolAria")}>
<div className="hb-radar-patrol__status">
<p>
<strong>{scheduledOn ? t("radar.inbox.scheduledOn") : t("radar.inbox.scheduledOff")}</strong>
<span>{t("radar.inbox.scheduleHint")}</span>
</p>
<p>
<strong>{lastSweptAt ? t("radar.inbox.lastSweep", { time: formatLocalDateTime(lastSweptAt) }) : t("radar.inbox.neverSwept")}</strong>
<span>{scheduledOn ? t("radar.inbox.activeWatches", { n: activeCount }) : watchTotal ? t("radar.inbox.allPaused") : t("radar.inbox.noWatches")}</span>
</p>
</div>
<div className="hb-radar-patrol__actions">
<Button type="button" onClick={() => void runNow()} disabled={sweeping || !scheduledOn}>
{sweeping ? t("radar.inbox.sweeping") : t("radar.inbox.sweepNow")}
</Button>
<Link className="hb-btn hb-btn--ghost" to="/app/radar/watches">{t("radar.inbox.setupWatches")}</Link>
<small>{t("radar.patrol.searchFallback")}</small>
</div>
</section>
{lastSweep ? <SweepFunnelSummary sweep={lastSweep} /> : null}
<section className="hb-radar-intro">
<div>
<strong>{t("radar.inbox.introTitle")}</strong>
<p>{t("radar.inbox.introBody")}</p>
</div>
</section>
<section className="hb-radar-filter-panel" aria-label={t("radar.inbox.resultsAria")}>
<div className="hb-radar-filter-panel__head">
<div className="hb-radar-inbox-tabs" role="tablist" aria-label={t("radar.inbox.tabsAria")}>
{(["pending", "completed", "removed"] as OpportunityReviewState[]).map((value) => (
<Button key={value} type="button" variant={reviewState === value ? "primary" : "ghost"} aria-pressed={reviewState === value} onClick={() => {
setReviewState(value); setPage(1); writeParams({ review_state: value === "pending" ? "" : value, page: "" });
}}>
{t(`radar.inbox.tab.${value}`)}
</Button>
))}
<span>{t("radar.inbox.total", { n: total })}</span>
</div>
{filtered ? <Button type="button" variant="ghost" onClick={resetFilters}>{t("radar.inbox.clearFilters")}</Button> : <span>{t("radar.inbox.defaultToday")}</span>}
</div>
<div className="hb-radar-inbox-essential-filters">
<Select name="all-time-scope" label={t("radar.inbox.timeScope")} value={timeScope} onChange={(e) => {
const value = e.target.value as OpportunityTimeScope;
setTimeScope(value); setPage(1); writeParams({ time_scope: value === "today" ? "" : value, page: "" });
}}>
<option value="today">{t("radar.inbox.time.today")}</option><option value="7d">{t("radar.inbox.time.7d")}</option><option value="all">{t("radar.inbox.time.all")}</option>
</Select>
<Select name="all-sort" label={t("radar.inbox.sort")} value={sort} onChange={(e) => {
setSort(e.target.value); setPage(1); writeParams({ sort: e.target.value === "recommended" ? "" : e.target.value, page: "" });
}}>
<option value="recommended">{t("radar.inbox.sort.recommended")}</option><option value="newest">{t("radar.inbox.sort.newest")}</option><option value="oldest">{t("radar.inbox.sort.oldest")}</option><option value="product_fit">{t("radar.inbox.sort.productFit")}</option><option value="demand_intent">{t("radar.inbox.sort.demandIntent")}</option>
</Select>
<Button type="button" variant="ghost" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen((value) => !value)}>
{advancedOpen ? t("radar.inbox.hideFilters") : advancedFilterCount ? t("radar.inbox.moreFiltersN", { n: advancedFilterCount }) : t("radar.inbox.moreFilters")}
</Button>
</div>
{advancedOpen ? <div className="hb-radar-filter-grid hb-radar-filter-grid--all" aria-label={t("radar.inbox.moreFiltersAria")}>
{brands.length ? (
<Select name="all-brand" label={t("radar.inbox.brand")} value={brandId} onChange={(e) => {
const value = e.target.value;
setBrandId(value); setProductId(""); setPage(1);
writeParams({ brand_id: value, product_id: "", page: "" });
}}>
<option value="">{t("radar.inbox.allBrands")}</option>
{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}
</Select>
) : null}
{products.length ? (
<Select name="all-product" label={t("radar.inbox.product")} value={productId} onChange={(e) => {
setProductId(e.target.value); setPage(1);
writeParams({ product_id: e.target.value, page: "" });
}}>
<option value="">{t("radar.inbox.allProducts")}</option>
{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</Select>
) : null}
<Select name="all-band" label={t("radar.inbox.band")} value={band} onChange={(e) => {
setBand(e.target.value); setPage(1); writeParams({ band: e.target.value, page: "" });
}}>
<option value="">{t("radar.inbox.allBands")}</option><option value="high">{t("radar.inbox.band.high")}</option><option value="mid">{t("radar.inbox.band.mid")}</option><option value="low">{t("radar.inbox.band.low")}</option>
</Select>
<Select name="all-state" label={t("radar.inbox.match")} value={state} onChange={(e) => {
setState(e.target.value); setPage(1); writeParams({ match_state: e.target.value, page: "" });
}}>
<option value="">{t("radar.inbox.allStates")}</option><option value="eligible">{t("radar.inbox.state.eligible")}</option><option value="weak">{t("radar.inbox.state.weak")}</option><option value="excluded">{t("radar.inbox.state.excluded")}</option><option value="generic">{t("radar.inbox.state.generic")}</option><option value="stale">{t("radar.inbox.state.stale")}</option>
</Select>
</div> : null}
</section>
{error ? <p className="hb-banner-error" role="alert">{error}</p> : null}
{notice ? <div className="hb-banner-ok" role="status"><span>{notice.text}</span>{notice.contactId ? <Link to={`/app/crm?contact=${encodeURIComponent(notice.contactId)}`}>{t("radar.inbox.goCrm")}</Link> : null}</div> : null}
{loading ? <p className="hb-radar-section__hint" role="status">{t("radar.inbox.loading")}</p> : null}
{!loading && !error && !list.length ? (
<EmptyState title={empty.title} description={empty.description} action={empty.action} />
) : 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)}
onComplete={(item) => void updateReviewState(item, { state: "completed" }, t("radar.inbox.msg.kept"))}
onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, t("radar.inbox.msg.removed"))}
onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, t("radar.inbox.msg.restored"))}
/>)}
{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) }); }}>{t("radar.inbox.prevPage")}</Button>
<span>{t("radar.inbox.pageOf", { page, pages: pageCount })}</span>
<Button variant="ghost" disabled={page >= pageCount} onClick={() => { const next = page + 1; setPage(next); writeParams({ page: String(next) }); }}>{t("radar.inbox.nextPage")}</Button>
</div>
) : null}
</section>
) : null}
{selected ? <OpportunityDetailDrawer
opportunity={selected}
busy={busyId === selected.id}
onClose={() => setSelected(null)}
onAccept={(item) => void acceptOpportunity(item)}
onComplete={(item) => void updateReviewState(item, { state: "completed" }, t("radar.inbox.msg.kept"))}
/> : null}
</>
);
}