Migrate AI Hint feed generation
Bring dynamic hint generation into the account service while preserving the legacy key.osglab.com deployment for existing clients.
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
import { Play, RefreshCw, Save, Settings2 } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type {
|
||||
HintFeedGenerationStatus,
|
||||
HintFeedSettings,
|
||||
UpdateHintFeedSettingsRequest,
|
||||
} from "../../api/types";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
LoadingState,
|
||||
Textarea,
|
||||
} from "../../components/primitives";
|
||||
|
||||
interface HintAutoSectionProps {
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function HintAutoSection({ canEdit }: HintAutoSectionProps) {
|
||||
const [settings, setSettings] = useState<HintFeedSettings>();
|
||||
const [status, setStatus] = useState<HintFeedGenerationStatus>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextSettings, nextStatus] = await Promise.all([
|
||||
adminApi.hintFeedSettings(),
|
||||
adminApi.hintFeedStatus(),
|
||||
]);
|
||||
setSettings(nextSettings);
|
||||
setStatus(nextStatus);
|
||||
} catch (error) {
|
||||
toast.error(message(error, "Hint 自动生成状态加载失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function save(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: UpdateHintFeedSettingsRequest = {
|
||||
generationIntervalHours: settings.generationIntervalHours,
|
||||
holidayCountriesZh: settings.holidayCountriesZh,
|
||||
holidayCountriesEn: settings.holidayCountriesEn,
|
||||
weatherCitiesZh: settings.weatherCitiesZh,
|
||||
weatherCitiesEn: settings.weatherCitiesEn,
|
||||
googleTrendsGeos: settings.googleTrendsGeos,
|
||||
};
|
||||
setSettings(await adminApi.updateHintFeedSettings(payload));
|
||||
setStatus(await adminApi.hintFeedStatus());
|
||||
toast.success("Hint 自动生成配置已保存");
|
||||
} catch (error) {
|
||||
toast.error(message(error, "Hint 自动生成配置保存失败"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerate() {
|
||||
if (
|
||||
!window.confirm(
|
||||
"将立即抓取外部数据,并原子覆盖 zh/en 提示包。旧版本会保留到新一代全部生成成功。是否继续?",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setGenerating(true);
|
||||
try {
|
||||
const result = await adminApi.regenerateHintFeed();
|
||||
toast.success(
|
||||
`生成完成:zh ${result.zh.cardCount} 条,en ${result.en.cardCount} 条`,
|
||||
);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(message(error, "Hint 提示包生成失败,旧版本仍保持可用"));
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !settings || !status) {
|
||||
return <LoadingState label="加载 Hint 自动生成配置" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="hint-auto-heading" className="space-y-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="hint-auto-heading" className="text-xl font-bold">
|
||||
Hint 自动生成
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
从 TopHub、Google Trends、Google News、节日和天气来源生成双语提示包。
|
||||
任一来源失败不会影响其他来源,整代生成失败时继续提供旧版本。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => void load()}>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
刷新状态
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<Button loading={generating} onClick={() => void regenerate()}>
|
||||
<Play className="size-4" aria-hidden />
|
||||
立即生成
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatusCard
|
||||
label="调度"
|
||||
value={settings.enabled ? `每 ${status.intervalHours} 小时` : "未启用"}
|
||||
tone={settings.enabled ? "success" : "neutral"}
|
||||
/>
|
||||
<StatusCard
|
||||
label="上次结果"
|
||||
value={outcomeLabel(status.outcome)}
|
||||
detail={formatInstant(status.lastCompletedAt ?? status.lastStartedAt)}
|
||||
tone={outcomeTone(status.outcome)}
|
||||
/>
|
||||
<StatusCard
|
||||
label="中文包"
|
||||
value={status.zhVersion ? `v${status.zhVersion}` : "未发布"}
|
||||
detail={
|
||||
status.zhCardCount === undefined ? undefined : `${status.zhCardCount} 条`
|
||||
}
|
||||
tone={status.zhVersion ? "success" : "warning"}
|
||||
/>
|
||||
<StatusCard
|
||||
label="英文包"
|
||||
value={status.enVersion ? `v${status.enVersion}` : "未发布"}
|
||||
detail={
|
||||
status.enCardCount === undefined ? undefined : `${status.enCardCount} 条`
|
||||
}
|
||||
tone={status.enVersion ? "success" : "warning"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="p-5 sm:p-6">
|
||||
<form className="space-y-5" onSubmit={(event) => void save(event)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="size-4 text-primary" aria-hidden />
|
||||
<h3 className="font-semibold">生成设置</h3>
|
||||
<Badge className="ml-auto" tone={settings.topHubApiKeyConfigured ? "success" : "neutral"}>
|
||||
TopHub Key {settings.topHubApiKeyConfigured ? "已配置" : "未配置"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Field label="生成间隔(小时)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={168}
|
||||
value={settings.generationIntervalHours}
|
||||
readOnly={!canEdit}
|
||||
onChange={(event) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
generationIntervalHours: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="中文节日国家">
|
||||
<Input
|
||||
value={settings.holidayCountriesZh}
|
||||
readOnly={!canEdit}
|
||||
onChange={(event) =>
|
||||
setSettings({ ...settings, holidayCountriesZh: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="英文节日国家">
|
||||
<Input
|
||||
value={settings.holidayCountriesEn}
|
||||
readOnly={!canEdit}
|
||||
onChange={(event) =>
|
||||
setSettings({ ...settings, holidayCountriesEn: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Google Trends 地区(逗号分隔)">
|
||||
<Input
|
||||
value={settings.googleTrendsGeos}
|
||||
readOnly={!canEdit}
|
||||
onChange={(event) =>
|
||||
setSettings({ ...settings, googleTrendsGeos: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Field label="中文天气城市(城市:纬度,经度;…)">
|
||||
<Textarea
|
||||
className="min-h-24 font-mono text-xs"
|
||||
value={settings.weatherCitiesZh}
|
||||
readOnly={!canEdit}
|
||||
onChange={(event) =>
|
||||
setSettings({ ...settings, weatherCitiesZh: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="英文天气城市(城市:纬度,经度;…)">
|
||||
<Textarea
|
||||
className="min-h-24 font-mono text-xs"
|
||||
value={settings.weatherCitiesEn}
|
||||
readOnly={!canEdit}
|
||||
onChange={(event) =>
|
||||
setSettings({ ...settings, weatherCitiesEn: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted">
|
||||
TopHub API Key 仅从服务器环境变量读取,不会保存到数据库或返回浏览器。
|
||||
手动 JSON 发布仍保留;下一次自动生成会发布新的双语版本。
|
||||
</p>
|
||||
|
||||
{canEdit ? (
|
||||
<Button type="submit" loading={saving}>
|
||||
<Save className="size-4" aria-hidden />
|
||||
保存生成设置
|
||||
</Button>
|
||||
) : null}
|
||||
</form>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
detail?: string;
|
||||
tone: "success" | "warning" | "danger" | "neutral" | "info" | "violet";
|
||||
}) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted">{label}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="font-semibold">{value}</span>
|
||||
<Badge tone={tone}>{detail ?? value}</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block text-sm font-semibold">
|
||||
<span className="mb-2 block">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function outcomeLabel(outcome: HintFeedGenerationStatus["outcome"]): string {
|
||||
return {
|
||||
IDLE: "尚未运行",
|
||||
RUNNING: "生成中",
|
||||
SUCCEEDED: "成功",
|
||||
FAILED: "失败",
|
||||
}[outcome];
|
||||
}
|
||||
|
||||
function outcomeTone(
|
||||
outcome: HintFeedGenerationStatus["outcome"],
|
||||
): "success" | "warning" | "danger" | "neutral" {
|
||||
return {
|
||||
IDLE: "neutral",
|
||||
RUNNING: "warning",
|
||||
SUCCEEDED: "success",
|
||||
FAILED: "danger",
|
||||
}[outcome] as "success" | "warning" | "danger" | "neutral";
|
||||
}
|
||||
|
||||
function formatInstant(value?: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
function message(error: unknown, fallback: string): string {
|
||||
return error instanceof ApiError ? error.message : fallback;
|
||||
}
|
||||
Reference in New Issue
Block a user