108 lines
4.0 KiB
TypeScript
108 lines
4.0 KiB
TypeScript
import { useState } from "react";
|
||
import { useRepos } from "../../data/DataContext";
|
||
import type { DemandMap, WatchTermSuggestion } from "../../domain/types";
|
||
import { useI18n } from "../../i18n/I18nContext";
|
||
import { useFormatApiError } from "../../lib/apiErrors";
|
||
import { Badge, Button } from "../ui";
|
||
import { QueryPlanPreview } from "./QueryPlanPreview";
|
||
|
||
type Props = {
|
||
/** 採用一條:include 進關鍵字、exclude 進排除詞,由呼叫端決定放哪 */
|
||
onAdopt: (suggestion: WatchTermSuggestion) => void;
|
||
onAdoptAll: (suggestions: WatchTermSuggestion[]) => void;
|
||
/** 已在表單裡的詞(含排除詞),用來標示重複,避免使用者按了沒反應 */
|
||
adopted: string[];
|
||
context?: { brand_id?: string; product_id?: string };
|
||
demandMap?: DemandMap;
|
||
};
|
||
|
||
const SUGGEST_LIMIT = 8;
|
||
|
||
/**
|
||
* 關鍵字建議:讀服務檔案問 AI,逐條或全部採用。
|
||
*
|
||
* 這裡只給候選詞,不會自己建立訂閱——建立與否由使用者在表單按下儲存決定。
|
||
*/
|
||
export function WatchSuggestPanel({ onAdopt, onAdoptAll, adopted, context, demandMap }: Props) {
|
||
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 {
|
||
setList(await repos.radar.suggestWatchTerms({ limit: SUGGEST_LIMIT, ...context }));
|
||
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>
|
||
{demandMap ? <QueryPlanPreview map={demandMap} /> : null}
|
||
|
||
<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>
|
||
{s.basis_text ? <p className="hb-radar-section__hint">依據:{s.basis_text}</p> : null}
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
onClick={() => onAdopt(s)}
|
||
disabled={already}
|
||
>
|
||
{already ? t("radar.suggest.adopted") : t("radar.suggest.adopt")}
|
||
</Button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|