Files
OSGAccountServer/admin-web/src/features/content/hint-auto-section.tsx
T
Rocky b25f5ae6e9 Clarify Hint pack save behavior
Keep manual editing while making save the only user action and applying changes immediately without a separate publish workflow.
2026-08-21 15:37:13 +08:00

317 lines
10 KiB
TypeScript

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">
TopHubGoogle TrendsGoogle 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;
}