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-18 16:26:10 +00:00
|
|
|
|
{s.basis_text ? <p className="hb-radar-section__hint">{t("radar.suggest.basis", { text: 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|