thread-master/apps/web/src/components/radar/WatchSuggestPanel.tsx

110 lines
4.1 KiB
TypeScript
Raw Normal View History

2026-08-03 05:52:02 +00:00
import { useState } from "react";
import { useRepos } from "../../data/DataContext";
2026-08-13 02:22:24 +00:00
import type { DemandMap, WatchTermSuggestion } from "../../domain/types";
2026-08-03 05:52:02 +00:00
import { useI18n } from "../../i18n/I18nContext";
import { useFormatApiError } from "../../lib/apiErrors";
import { Badge, Button } from "../ui";
2026-08-13 02:22:24 +00:00
import { QueryPlanPreview } from "./QueryPlanPreview";
2026-08-03 05:52:02 +00:00
type Props = {
/** 採用一條include 進關鍵字、exclude 進排除詞,由呼叫端決定放哪 */
onAdopt: (suggestion: WatchTermSuggestion) => void;
onAdoptAll: (suggestions: WatchTermSuggestion[]) => void;
2026-08-13 07:54:25 +00:00
/** 採用查詢計畫裡已收成的可搜短詞 */
onAdoptQueries?: (queries: string[]) => void;
2026-08-03 05:52:02 +00:00
/** 已在表單裡的詞(含排除詞),用來標示重複,避免使用者按了沒反應 */
adopted: string[];
2026-08-13 02:22:24 +00:00
context?: { brand_id?: string; product_id?: string };
demandMap?: DemandMap;
2026-08-03 05:52:02 +00:00
};
const SUGGEST_LIMIT = 8;
/**
* AI
*
* 使
*/
2026-08-13 07:54:25 +00:00
export function WatchSuggestPanel({ onAdopt, onAdoptAll, onAdoptQueries, adopted, context, demandMap }: Props) {
2026-08-03 05:52:02 +00:00
const { t } = useI18n();
const repos = useRepos();
const formatError = useFormatApiError();
const [list, setList] = useState<WatchTermSuggestion[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [asked, setAsked] = useState(false);
const taken = new Set(adopted.map((s) => s.trim().toLowerCase()));
async function ask() {
setLoading(true);
setError("");
try {
2026-08-13 02:22:24 +00:00
setList(await repos.radar.suggestWatchTerms({ limit: SUGGEST_LIMIT, ...context }));
2026-08-03 05:52:02 +00:00
setAsked(true);
} catch (e) {
// 沒服務檔案、沒 API key、AI 失敗都會走到這;照後端原因說,別假裝沒建議。
setError(formatError(e));
} finally {
setLoading(false);
}
}
return (
<div className="hb-radar-section">
<h3 className="hb-radar-section__title">{t("radar.suggest.title")}</h3>
<p className="hb-radar-section__hint">{t("radar.suggest.hint")}</p>
2026-08-13 07:54:25 +00:00
{demandMap ? <QueryPlanPreview map={demandMap} onAdoptQueries={onAdoptQueries} /> : null}
2026-08-03 05:52:02 +00:00
<div className="hb-radar-actions">
<Button type="button" variant="secondary" onClick={() => void ask()} disabled={loading}>
{loading ? t("radar.suggest.asking") : asked ? t("radar.suggest.again") : t("radar.suggest.ask")}
</Button>
{list.length ? (
<Button type="button" variant="ghost" onClick={() => onAdoptAll(list)}>
{t("radar.suggest.adoptAll")}
</Button>
) : null}
</div>
{error ? (
<p className="hb-banner-error" role="alert">
{error}
</p>
) : null}
{asked && !error && list.length === 0 ? (
<p className="hb-radar-empty">{t("radar.suggest.none")}</p>
) : null}
{list.length ? (
<ul className="hb-radar-suggest-list">
{list.map((s) => {
const already = taken.has(s.term.toLowerCase());
return (
<li className="hb-radar-suggest" key={`${s.usage}-${s.term}`}>
<div className="hb-radar-suggest__head">
<span className="hb-radar-suggest__term">{s.term}</span>
<Badge tone={s.usage === "exclude" ? "danger" : "brand"}>
{s.usage === "exclude" ? t("radar.suggest.exclude") : t("radar.suggest.include")}
</Badge>
</div>
<p className="hb-radar-suggest__reason">{s.reason}</p>
2026-08-13 02:22:24 +00:00
{s.basis_text ? <p className="hb-radar-section__hint">{s.basis_text}</p> : null}
2026-08-03 05:52:02 +00:00
<Button
type="button"
variant="ghost"
onClick={() => onAdopt(s)}
disabled={already}
>
{already ? t("radar.suggest.adopted") : t("radar.suggest.adopt")}
</Button>
</li>
);
})}
</ul>
) : null}
</div>
);
}