b25f5ae6e9
Keep manual editing while making save the only user action and applying changes immediately without a separate publish workflow.
317 lines
10 KiB
TypeScript
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">
|
|
从 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;
|
|
}
|