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

108 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}