diff --git a/admin/src/pages/site-settings-page.tsx b/admin/src/pages/site-settings-page.tsx
index f5c7233..0e35f9c 100644
--- a/admin/src/pages/site-settings-page.tsx
+++ b/admin/src/pages/site-settings-page.tsx
@@ -1,19 +1,32 @@
-import { Bot, Check, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react'
-import type { ReactNode } from 'react'
-import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
-import { Link } from 'react-router-dom'
-import { toast } from 'sonner'
+import { Bot, Check, Plus, RefreshCcw, Save, Trash2 } from "lucide-react";
+import type { ReactNode } from "react";
+import {
+ startTransition,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+import { Link } from "react-router-dom";
+import { toast } from "sonner";
-import { MediaUrlControls } from '@/components/media-url-controls'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Select } from '@/components/ui/select'
-import { Skeleton } from '@/components/ui/skeleton'
-import { Textarea } from '@/components/ui/textarea'
-import { adminApi, ApiError } from '@/lib/api'
+import { MediaUrlControls } from "@/components/media-url-controls";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select } from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Textarea } from "@/components/ui/textarea";
+import { adminApi, ApiError } from "@/lib/api";
+import { formatWorkerProgress } from "@/lib/worker-progress";
import type {
AdminSiteSettingsResponse,
AiProviderConfig,
@@ -21,102 +34,113 @@ import type {
MusicTrack,
SiteSettingsPayload,
WorkerJobRecord,
-} from '@/lib/types'
+} from "@/lib/types";
function createEmptyMusicTrack(): MusicTrack {
return {
- title: '',
- artist: '',
- album: '',
- url: '',
- cover_image_url: '',
- accent_color: '',
- description: '',
- }
+ title: "",
+ artist: "",
+ album: "",
+ url: "",
+ cover_image_url: "",
+ accent_color: "",
+ description: "",
+ };
}
function createAiProviderId() {
- if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
- return `provider-${crypto.randomUUID()}`
+ if (
+ typeof crypto !== "undefined" &&
+ typeof crypto.randomUUID === "function"
+ ) {
+ return `provider-${crypto.randomUUID()}`;
}
- return `provider-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
+ return `provider-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function createEmptyAiProvider(): AiProviderConfig {
return {
id: createAiProviderId(),
- name: '',
- provider: 'openai',
- api_base: '',
- api_key: '',
- chat_model: '',
- image_model: '',
- }
+ name: "",
+ provider: "openai",
+ api_base: "",
+ api_key: "",
+ chat_model: "",
+ image_model: "",
+ };
}
const AI_PROVIDER_OPTIONS = [
- { value: 'openai', label: 'OpenAI' },
- { value: 'anthropic', label: 'Anthropic' },
- { value: 'gemini', label: 'Gemini' },
- { value: 'cloudflare', label: 'Cloudflare Workers AI' },
- { value: 'newapi', label: 'NewAPI / Responses' },
- { value: 'openai-compatible', label: 'OpenAI Compatible' },
-] as const
+ { value: "openai", label: "OpenAI" },
+ { value: "anthropic", label: "Anthropic" },
+ { value: "gemini", label: "Gemini" },
+ { value: "cloudflare", label: "Cloudflare Workers AI" },
+ { value: "newapi", label: "NewAPI / Responses" },
+ { value: "openai-compatible", label: "OpenAI Compatible" },
+] as const;
const MEDIA_STORAGE_PROVIDER_OPTIONS = [
- { value: 'r2', label: 'Cloudflare R2' },
- { value: 'minio', label: 'MinIO' },
-] as const
+ { value: "r2", label: "Cloudflare R2" },
+ { value: "minio", label: "MinIO" },
+] as const;
const NOTIFICATION_CHANNEL_OPTIONS = [
- { value: 'webhook', label: 'Webhook' },
- { value: 'ntfy', label: 'ntfy' },
-] as const
+ { value: "webhook", label: "Webhook" },
+ { value: "ntfy", label: "ntfy" },
+] as const;
const HUMAN_VERIFICATION_MODE_OPTIONS = [
- { value: 'off', label: '关闭' },
- { value: 'captcha', label: '普通验证码' },
- { value: 'turnstile', label: 'Turnstile' },
-] as const
+ { value: "off", label: "关闭" },
+ { value: "captcha", label: "普通验证码" },
+ { value: "turnstile", label: "Turnstile" },
+] as const;
function normalizeHumanVerificationMode(
value: string | null | undefined,
fallback: HumanVerificationMode,
): HumanVerificationMode {
- switch ((value ?? '').trim().toLowerCase()) {
- case 'off':
- return 'off'
- case 'captcha':
- case 'normal':
- case 'simple':
- return 'captcha'
- case 'turnstile':
- return 'turnstile'
+ switch ((value ?? "").trim().toLowerCase()) {
+ case "off":
+ return "off";
+ case "captcha":
+ case "normal":
+ case "simple":
+ return "captcha";
+ case "turnstile":
+ return "turnstile";
default:
- return fallback
+ return fallback;
}
}
function isCloudflareProvider(provider: string | null | undefined) {
- const normalized = provider?.trim().toLowerCase()
- return normalized === 'cloudflare' || normalized === 'cloudflare-workers-ai' || normalized === 'workers-ai'
+ const normalized = provider?.trim().toLowerCase();
+ return (
+ normalized === "cloudflare" ||
+ normalized === "cloudflare-workers-ai" ||
+ normalized === "workers-ai"
+ );
}
function buildCloudflareAiPreset(current: AiProviderConfig): AiProviderConfig {
return {
...current,
- name: current.name?.trim() ? current.name : 'Cloudflare Workers AI',
- provider: 'cloudflare',
- chat_model: current.chat_model?.trim() || '@cf/meta/llama-3.1-8b-instruct',
- }
+ name: current.name?.trim() ? current.name : "Cloudflare Workers AI",
+ provider: "cloudflare",
+ chat_model: current.chat_model?.trim() || "@cf/meta/llama-3.1-8b-instruct",
+ };
}
function normalizeSettingsResponse(
input: AdminSiteSettingsResponse,
): AdminSiteSettingsResponse {
- const aiProviders = Array.isArray(input.ai_providers) ? input.ai_providers : []
- const searchSynonyms = Array.isArray(input.search_synonyms) ? input.search_synonyms : []
+ const aiProviders = Array.isArray(input.ai_providers)
+ ? input.ai_providers
+ : [];
+ const searchSynonyms = Array.isArray(input.search_synonyms)
+ ? input.search_synonyms
+ : [];
return {
...input,
@@ -124,11 +148,11 @@ function normalizeSettingsResponse(
search_synonyms: searchSynonyms,
comment_verification_mode: normalizeHumanVerificationMode(
input.comment_verification_mode,
- input.comment_turnstile_enabled ? 'turnstile' : 'captcha',
+ input.comment_turnstile_enabled ? "turnstile" : "captcha",
),
subscription_verification_mode: normalizeHumanVerificationMode(
input.subscription_verification_mode,
- input.subscription_turnstile_enabled ? 'turnstile' : 'off',
+ input.subscription_turnstile_enabled ? "turnstile" : "off",
),
turnstile_site_key: input.turnstile_site_key ?? null,
turnstile_secret_key: input.turnstile_secret_key ?? null,
@@ -140,7 +164,7 @@ function normalizeSettingsResponse(
maintenance_access_code: input.maintenance_access_code ?? null,
ai_active_provider_id:
input.ai_active_provider_id ?? aiProviders[0]?.id ?? null,
- }
+ };
}
function Field({
@@ -148,22 +172,26 @@ function Field({
hint,
children,
}: {
- label: string
- hint?: string
- children: ReactNode
+ label: string;
+ hint?: string;
+ children: ReactNode;
}) {
return (
{children}
- {hint ?
{hint}
: null}
+ {hint ? (
+
{hint}
+ ) : null}
- )
+ );
}
function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
- const commentTurnstileEnabled = form.comment_verification_mode === 'turnstile'
- const subscriptionTurnstileEnabled = form.subscription_verification_mode === 'turnstile'
+ const commentTurnstileEnabled =
+ form.comment_verification_mode === "turnstile";
+ const subscriptionTurnstileEnabled =
+ form.subscription_verification_mode === "turnstile";
return {
siteName: form.site_name,
@@ -231,162 +259,198 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
subscriptionPopupDescription: form.subscription_popup_description,
subscriptionPopupDelaySeconds: form.subscription_popup_delay_seconds,
searchSynonyms: form.search_synonyms,
- }
+ };
}
export function SiteSettingsPage() {
- const [form, setForm] = useState(null)
- const [loading, setLoading] = useState(true)
- const [saving, setSaving] = useState(false)
- const [reindexing, setReindexing] = useState(false)
- const [reindexJobId, setReindexJobId] = useState(null)
- const [reindexJobStatus, setReindexJobStatus] = useState(null)
- const [testingProvider, setTestingProvider] = useState(false)
- const [testingImageProvider, setTestingImageProvider] = useState(false)
- const [testingR2Storage, setTestingR2Storage] = useState(false)
- const [selectedTrackIndex, setSelectedTrackIndex] = useState(0)
- const [selectedProviderIndex, setSelectedProviderIndex] = useState(0)
+ const [form, setForm] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [reindexing, setReindexing] = useState(false);
+ const [reindexJobId, setReindexJobId] = useState(null);
+ const [reindexJobStatus, setReindexJobStatus] = useState<
+ WorkerJobRecord["status"] | null
+ >(null);
+ const [reindexJobResult, setReindexJobResult] =
+ useState(null);
+ const [testingProvider, setTestingProvider] = useState(false);
+ const [testingImageProvider, setTestingImageProvider] = useState(false);
+ const [testingR2Storage, setTestingR2Storage] = useState(false);
+ const [selectedTrackIndex, setSelectedTrackIndex] = useState(0);
+ const [selectedProviderIndex, setSelectedProviderIndex] = useState(0);
const loadSettings = useCallback(async (showToast = false) => {
try {
- const next = normalizeSettingsResponse(await adminApi.getSiteSettings())
+ const next = normalizeSettingsResponse(await adminApi.getSiteSettings());
+ let activeReindexJob: WorkerJobRecord | null | undefined = undefined;
+
+ try {
+ const workerJobs = await adminApi.listWorkerJobs({
+ workerName: "worker.ai_reindex",
+ limit: 8,
+ });
+ activeReindexJob =
+ workerJobs.jobs.find(
+ (item) => item.status === "queued" || item.status === "running",
+ ) ?? null;
+ } catch {
+ activeReindexJob = undefined;
+ }
+
startTransition(() => {
- setForm(next)
- })
+ setForm(next);
+ if (activeReindexJob !== undefined) {
+ setReindexing(Boolean(activeReindexJob));
+ setReindexJobId(activeReindexJob?.id ?? null);
+ setReindexJobStatus(activeReindexJob?.status ?? null);
+ setReindexJobResult(activeReindexJob?.result ?? null);
+ }
+ });
if (showToast) {
- toast.success('站点设置已刷新。')
+ toast.success("站点设置已刷新。");
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
- return
+ return;
}
- toast.error(error instanceof ApiError ? error.message : '无法加载站点设置。')
+ toast.error(
+ error instanceof ApiError ? error.message : "无法加载站点设置。",
+ );
} finally {
- setLoading(false)
+ setLoading(false);
}
- }, [])
+ }, []);
useEffect(() => {
- void loadSettings(false)
- }, [loadSettings])
+ void loadSettings(false);
+ }, [loadSettings]);
useEffect(() => {
if (!form?.music_playlist.length) {
- setSelectedTrackIndex(0)
- return
+ setSelectedTrackIndex(0);
+ return;
}
- setSelectedTrackIndex((current) => Math.min(current, form.music_playlist.length - 1))
- }, [form?.music_playlist.length])
+ setSelectedTrackIndex((current) =>
+ Math.min(current, form.music_playlist.length - 1),
+ );
+ }, [form?.music_playlist.length]);
useEffect(() => {
if (!form?.ai_providers.length) {
- setSelectedProviderIndex(0)
- return
+ setSelectedProviderIndex(0);
+ return;
}
setSelectedProviderIndex((current) => {
const activeIndex = form.ai_providers.findIndex(
(provider) => provider.id === form.ai_active_provider_id,
- )
+ );
if (activeIndex >= 0) {
- return activeIndex
+ return activeIndex;
}
- return Math.min(current, form.ai_providers.length - 1)
- })
- }, [form?.ai_active_provider_id, form?.ai_providers])
+ return Math.min(current, form.ai_providers.length - 1);
+ });
+ }, [form?.ai_active_provider_id, form?.ai_providers]);
useEffect(() => {
if (!reindexJobId) {
- return
+ return;
}
- let cancelled = false
- let timer: ReturnType | null = null
+ let cancelled = false;
+ let timer: ReturnType | null = null;
const scheduleNextPoll = () => {
timer = setTimeout(() => {
- void pollJob()
- }, 4000)
- }
+ void pollJob();
+ }, 4000);
+ };
const pollJob = async () => {
try {
- const job = await adminApi.getWorkerJob(reindexJobId)
+ const job = await adminApi.getWorkerJob(reindexJobId);
if (cancelled) {
- return
+ return;
}
- setReindexJobStatus(job.status)
+ setReindexJobStatus(job.status);
+ setReindexJobResult(job.result);
- if (job.status === 'succeeded') {
- setReindexing(false)
- setReindexJobId(null)
- setReindexJobStatus(null)
- const indexedChunks = Number(job.result?.indexed_chunks ?? 0)
+ if (job.status === "succeeded") {
+ setReindexing(false);
+ setReindexJobId(null);
+ setReindexJobStatus(null);
+ setReindexJobResult(null);
+ const indexedChunks = Number(job.result?.indexed_chunks ?? 0);
toast.success(
indexedChunks > 0
? `AI 索引重建完成,共生成 ${indexedChunks} 个分块。`
- : 'AI 索引重建完成。',
- )
- await loadSettings(false)
- return
+ : "AI 索引重建完成。",
+ );
+ await loadSettings(false);
+ return;
}
- if (job.status === 'failed' || job.status === 'cancelled') {
- setReindexing(false)
- setReindexJobId(null)
- setReindexJobStatus(null)
- toast.error(job.error_text?.trim() || 'AI 重建索引失败。')
- return
+ if (job.status === "failed" || job.status === "cancelled") {
+ setReindexing(false);
+ setReindexJobId(null);
+ setReindexJobStatus(null);
+ setReindexJobResult(null);
+ toast.error(job.error_text?.trim() || "AI 重建索引失败。");
+ return;
}
- scheduleNextPoll()
+ scheduleNextPoll();
} catch (error) {
if (cancelled) {
- return
+ return;
}
- scheduleNextPoll()
+ scheduleNextPoll();
if (error instanceof ApiError && error.status === 401) {
- return
+ return;
}
}
- }
+ };
- void pollJob()
+ void pollJob();
return () => {
- cancelled = true
+ cancelled = true;
if (timer) {
- clearTimeout(timer)
+ clearTimeout(timer);
}
- }
- }, [loadSettings, reindexJobId])
+ };
+ }, [loadSettings, reindexJobId]);
const updateField = (
key: K,
value: AdminSiteSettingsResponse[K],
) => {
- setForm((current) => (current ? { ...current, [key]: value } : current))
- }
+ setForm((current) => (current ? { ...current, [key]: value } : current));
+ };
- const updateMusicTrack = (index: number, key: K, value: MusicTrack[K]) => {
+ const updateMusicTrack = (
+ index: number,
+ key: K,
+ value: MusicTrack[K],
+ ) => {
setForm((current) => {
if (!current) {
- return current
+ return current;
}
const nextPlaylist = current.music_playlist.map((track, trackIndex) =>
trackIndex === index ? { ...track, [key]: value } : track,
- )
+ );
- return { ...current, music_playlist: nextPlaylist }
- })
- }
+ return { ...current, music_playlist: nextPlaylist };
+ });
+ };
const updateAiProvider = (
index: number,
@@ -395,153 +459,182 @@ export function SiteSettingsPage() {
) => {
setForm((current) => {
if (!current) {
- return current
+ return current;
}
- const nextProviders = current.ai_providers.map((provider, providerIndex) =>
- providerIndex === index ? { ...provider, [key]: value } : provider,
- )
+ const nextProviders = current.ai_providers.map(
+ (provider, providerIndex) =>
+ providerIndex === index ? { ...provider, [key]: value } : provider,
+ );
- return { ...current, ai_providers: nextProviders }
- })
- }
+ return { ...current, ai_providers: nextProviders };
+ });
+ };
const addMusicTrack = () => {
setForm((current) => {
if (!current) {
- return current
+ return current;
}
- const nextPlaylist = [...current.music_playlist, createEmptyMusicTrack()]
- setSelectedTrackIndex(nextPlaylist.length - 1)
+ const nextPlaylist = [...current.music_playlist, createEmptyMusicTrack()];
+ setSelectedTrackIndex(nextPlaylist.length - 1);
- return { ...current, music_playlist: nextPlaylist }
- })
- }
+ return { ...current, music_playlist: nextPlaylist };
+ });
+ };
const removeMusicTrack = (index: number) => {
setForm((current) => {
if (!current) {
- return current
+ return current;
}
- const nextPlaylist = current.music_playlist.filter((_, trackIndex) => trackIndex !== index)
+ const nextPlaylist = current.music_playlist.filter(
+ (_, trackIndex) => trackIndex !== index,
+ );
setSelectedTrackIndex((currentIndex) =>
- Math.max(0, Math.min(currentIndex > index ? currentIndex - 1 : currentIndex, nextPlaylist.length - 1)),
- )
+ Math.max(
+ 0,
+ Math.min(
+ currentIndex > index ? currentIndex - 1 : currentIndex,
+ nextPlaylist.length - 1,
+ ),
+ ),
+ );
return {
...current,
music_playlist: nextPlaylist,
- }
- })
- }
+ };
+ });
+ };
const addAiProvider = () => {
setForm((current) => {
if (!current) {
- return current
+ return current;
}
- const nextProvider = createEmptyAiProvider()
- const nextProviders = [...current.ai_providers, nextProvider]
- setSelectedProviderIndex(nextProviders.length - 1)
+ const nextProvider = createEmptyAiProvider();
+ const nextProviders = [...current.ai_providers, nextProvider];
+ setSelectedProviderIndex(nextProviders.length - 1);
return {
...current,
ai_providers: nextProviders,
ai_active_provider_id: current.ai_active_provider_id ?? nextProvider.id,
- }
- })
- }
+ };
+ });
+ };
const removeAiProvider = (index: number) => {
setForm((current) => {
if (!current) {
- return current
+ return current;
}
- const removed = current.ai_providers[index]
- const nextProviders = current.ai_providers.filter((_, providerIndex) => providerIndex !== index)
+ const removed = current.ai_providers[index];
+ const nextProviders = current.ai_providers.filter(
+ (_, providerIndex) => providerIndex !== index,
+ );
const nextActiveProviderId =
- removed?.id === current.ai_active_provider_id ? (nextProviders[0]?.id ?? null) : current.ai_active_provider_id
+ removed?.id === current.ai_active_provider_id
+ ? (nextProviders[0]?.id ?? null)
+ : current.ai_active_provider_id;
setSelectedProviderIndex((currentIndex) =>
- Math.max(0, Math.min(currentIndex > index ? currentIndex - 1 : currentIndex, nextProviders.length - 1)),
- )
+ Math.max(
+ 0,
+ Math.min(
+ currentIndex > index ? currentIndex - 1 : currentIndex,
+ nextProviders.length - 1,
+ ),
+ ),
+ );
return {
...current,
ai_providers: nextProviders,
ai_active_provider_id: nextActiveProviderId,
- }
- })
- }
+ };
+ });
+ };
const setActiveAiProvider = (providerId: string) => {
- updateField('ai_active_provider_id', providerId)
- }
+ updateField("ai_active_provider_id", providerId);
+ };
const applyCloudflarePreset = (index: number) => {
setForm((current) => {
if (!current) {
- return current
+ return current;
}
- const nextProviders = current.ai_providers.map((provider, providerIndex) =>
- providerIndex === index ? buildCloudflareAiPreset(provider) : provider,
- )
+ const nextProviders = current.ai_providers.map(
+ (provider, providerIndex) =>
+ providerIndex === index
+ ? buildCloudflareAiPreset(provider)
+ : provider,
+ );
return {
...current,
ai_providers: nextProviders,
- }
- })
- }
+ };
+ });
+ };
const applyCloudflareImagePreset = () => {
setForm((current) => {
if (!current) {
- return current
+ return current;
}
return {
...current,
- ai_image_provider: 'cloudflare',
+ ai_image_provider: "cloudflare",
ai_image_model:
- current.ai_image_model?.trim() || '@cf/black-forest-labs/flux-2-klein-4b',
- }
- })
- }
+ current.ai_image_model?.trim() ||
+ "@cf/black-forest-labs/flux-2-klein-4b",
+ };
+ });
+ };
const techStackValue = useMemo(
- () => (form?.tech_stack.length ? form.tech_stack.join('\n') : ''),
+ () => (form?.tech_stack.length ? form.tech_stack.join("\n") : ""),
[form?.tech_stack],
- )
+ );
const selectedTrack = useMemo(
() => form?.music_playlist[selectedTrackIndex] ?? createEmptyMusicTrack(),
[form, selectedTrackIndex],
- )
+ );
const selectedProvider = useMemo(
() => form?.ai_providers[selectedProviderIndex] ?? createEmptyAiProvider(),
[form, selectedProviderIndex],
- )
+ );
const activeProvider = useMemo(
- () => form?.ai_providers.find((provider) => provider.id === form.ai_active_provider_id) ?? null,
+ () =>
+ form?.ai_providers.find(
+ (provider) => provider.id === form.ai_active_provider_id,
+ ) ?? null,
[form],
- )
+ );
const selectedProviderIsCloudflare = useMemo(
() => isCloudflareProvider(selectedProvider.provider),
[selectedProvider.provider],
- )
+ );
const imageProviderIsCloudflare = useMemo(
() => isCloudflareProvider(form?.ai_image_provider),
[form?.ai_image_provider],
- )
+ );
const mediaStorageProvider = useMemo(
- () => (form?.media_storage_provider?.trim().toLowerCase() === 'minio' ? 'minio' : 'r2'),
+ () =>
+ form?.media_storage_provider?.trim().toLowerCase() === "minio"
+ ? "minio"
+ : "r2",
[form?.media_storage_provider],
- )
+ );
if (loading || !form) {
return (
@@ -549,7 +642,7 @@ export function SiteSettingsPage() {
- )
+ );
}
return (
@@ -558,20 +651,31 @@ export function SiteSettingsPage() {
站点设置
-
品牌、资料与 AI 控制
+
+ 品牌、资料与 AI 控制
+
- 这里统一维护前台站点使用的品牌信息、站长资料与 AI 配置,确保和后端数据模型一致。
+ 这里统一维护前台站点使用的品牌信息、站长资料与 AI
+ 配置,确保和后端数据模型一致。
-
@@ -635,138 +753,175 @@ export function SiteSettingsPage() {
-
- 前台站点资料
-
- 前台站点用于展示品牌、首页文案、站长资料和社交信息的全部字段。
-
-
-
-
- updateField('site_name', event.target.value)}
- />
-
-
- updateField('site_short_name', event.target.value)}
- />
-
-
- updateField('site_url', event.target.value)}
- />
-
-
- updateField('location', event.target.value)}
- />
-
-
- updateField('site_title', event.target.value)}
- />
-
-
- updateField('owner_title', event.target.value)}
- />
-
-
-
-
-
-
- updateField('hero_title', event.target.value)}
- />
-
-
- updateField('hero_subtitle', event.target.value)}
- />
-
-
- updateField('owner_name', event.target.value)}
- />
-
-
-
+
+ 前台站点资料
+
+ 前台站点用于展示品牌、首页文案、站长资料和社交信息的全部字段。
+
+
+
+
updateField('owner_avatar_url', event.target.value)}
- />
- updateField('owner_avatar_url', ownerAvatarUrl)}
- prefix="site-assets/"
- contextLabel="站长头像上传"
- remoteTitle={form.owner_name || form.site_name || '站长头像'}
- dataTestIdPrefix="site-owner-avatar"
- />
-
-
-
-
-
-
-
- updateField('social_github', event.target.value)}
- />
-
-
- updateField('social_twitter', event.target.value)}
- />
-
-
-
- updateField('social_email', event.target.value)}
- />
-
-
-
-
-
-
-
-
+
+
+ updateField("site_short_name", event.target.value)
+ }
+ />
+
+
+
+ updateField("site_url", event.target.value)
+ }
+ />
+
+
+
+ updateField("location", event.target.value)
+ }
+ />
+
+
+
+ updateField("site_title", event.target.value)
+ }
+ />
+
+
+
+ updateField("owner_title", event.target.value)
+ }
+ />
+
+
+
+
+
+
+
+ updateField("hero_title", event.target.value)
+ }
+ />
+
+
+
+ updateField("hero_subtitle", event.target.value)
+ }
+ />
+
+
+
+ updateField("owner_name", event.target.value)
+ }
+ />
+
+
+
+
+ updateField("owner_avatar_url", event.target.value)
+ }
+ />
+
+ updateField("owner_avatar_url", ownerAvatarUrl)
+ }
+ prefix="site-assets/"
+ contextLabel="站长头像上传"
+ remoteTitle={
+ form.owner_name || form.site_name || "站长头像"
+ }
+ dataTestIdPrefix="site-owner-avatar"
+ />
+
+
+
+
+
+
+
+
+ updateField("social_github", event.target.value)
+ }
+ />
+
+
+
+ updateField("social_twitter", event.target.value)
+ }
+ />
+
+
+
+
+ updateField("social_email", event.target.value)
+ }
+ />
+
+
+
+
+
+
+
+
@@ -781,7 +936,10 @@ export function SiteSettingsPage() {
type="checkbox"
checked={form.subscription_popup_enabled}
onChange={(event) =>
- updateField('subscription_popup_enabled', event.target.checked)
+ updateField(
+ "subscription_popup_enabled",
+ event.target.checked,
+ )
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
@@ -798,13 +956,16 @@ export function SiteSettingsPage() {
updateField('web_push_enabled', event.target.checked)}
+ onChange={(event) =>
+ updateField("web_push_enabled", event.target.checked)
+ }
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
开启浏览器推送
- 前台订阅弹窗会增加浏览器通知授权入口。保存下方 VAPID 公私钥后,前台会直接读取当前后台配置。
+ 前台订阅弹窗会增加浏览器通知授权入口。保存下方 VAPID
+ 公私钥后,前台会直接读取当前后台配置。
@@ -818,8 +979,11 @@ export function SiteSettingsPage() {
value={form.subscription_verification_mode}
onChange={(event) =>
updateField(
- 'subscription_verification_mode',
- normalizeHumanVerificationMode(event.target.value, 'off'),
+ "subscription_verification_mode",
+ normalizeHumanVerificationMode(
+ event.target.value,
+ "off",
+ ),
)
}
>
@@ -834,16 +998,25 @@ export function SiteSettingsPage() {
-
+
- updateField('subscription_popup_title', event.target.value)
+ updateField(
+ "subscription_popup_title",
+ event.target.value,
+ )
}
/>
-
+
updateField(
- 'subscription_popup_delay_seconds',
+ "subscription_popup_delay_seconds",
event.target.value ? Number(event.target.value) : 18,
)
}
@@ -866,7 +1039,10 @@ export function SiteSettingsPage() {
@@ -886,7 +1062,10 @@ export function SiteSettingsPage() {
type="checkbox"
checked={form.maintenance_mode_enabled}
onChange={(event) =>
- updateField('maintenance_mode_enabled', event.target.checked)
+ updateField(
+ "maintenance_mode_enabled",
+ event.target.checked,
+ )
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
@@ -905,17 +1084,21 @@ export function SiteSettingsPage() {
>
- updateField('maintenance_access_code', event.target.value)
+ updateField("maintenance_access_code", event.target.value)
}
placeholder="例如:staging-2026"
/>
-
- {form.maintenance_mode_enabled ? '维护中' : '正常开放'}
+
+ {form.maintenance_mode_enabled ? "维护中" : "正常开放"}
@@ -935,8 +1118,10 @@ export function SiteSettingsPage() {
hint="评论区和订阅弹窗共用这一套站点 key,保存后前台会在运行时读取。"
>
updateField('turnstile_site_key', event.target.value)}
+ value={form.turnstile_site_key ?? ""}
+ onChange={(event) =>
+ updateField("turnstile_site_key", event.target.value)
+ }
placeholder="0x4AAAA..."
/>
@@ -945,8 +1130,10 @@ export function SiteSettingsPage() {
hint="后端验证 token 使用;留空可清除数据库配置并回退到环境变量。"
>
updateField('turnstile_secret_key', event.target.value)}
+ value={form.turnstile_secret_key ?? ""}
+ onChange={(event) =>
+ updateField("turnstile_secret_key", event.target.value)
+ }
placeholder="ts-secret-key"
/>
@@ -955,9 +1142,9 @@ export function SiteSettingsPage() {
hint="浏览器订阅按钮会把这把 public key 下发到前台。"
>
-
+
添加提供商
@@ -1244,8 +1481,9 @@ export function SiteSettingsPage() {
{form.ai_providers.length ? (
form.ai_providers.map((provider, index) => {
- const active = provider.id === form.ai_active_provider_id
- const selected = index === selectedProviderIndex
+ const active =
+ provider.id === form.ai_active_provider_id;
+ const selected = index === selectedProviderIndex;
return (
setSelectedProviderIndex(index)}
className={
selected
- ? 'w-full rounded-[1.35rem] border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-[0_12px_28px_rgba(37,99,235,0.12)]'
- : 'w-full rounded-[1.35rem] border border-border/70 bg-background/70 px-4 py-4 text-left transition hover:border-border hover:bg-accent/35'
+ ? "w-full rounded-[1.35rem] border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-[0_12px_28px_rgba(37,99,235,0.12)]"
+ : "w-full rounded-[1.35rem] border border-border/70 bg-background/70 px-4 py-4 text-left transition hover:border-border hover:bg-accent/35"
}
>
- {provider.name?.trim() || `提供商 ${index + 1}`}
+ {provider.name?.trim() ||
+ `提供商 ${index + 1}`}
- Provider:{provider.provider?.trim() || '未填写'}
+ Provider:
+ {provider.provider?.trim() || "未填写"}
{active ? (
@@ -1274,10 +1514,10 @@ export function SiteSettingsPage() {
) : null}
- {provider.chat_model?.trim() || '未填写模型'}
+ {provider.chat_model?.trim() || "未填写模型"}
- )
+ );
})
) : (
@@ -1295,17 +1535,21 @@ export function SiteSettingsPage() {
当前编辑
- {selectedProvider.name?.trim() || `提供商 ${selectedProviderIndex + 1}`}
+ {selectedProvider.name?.trim() ||
+ `提供商 ${selectedProviderIndex + 1}`}
- 保存后,系统会使用“当前启用”的提供商处理文本 AI 请求。
+ 保存后,系统会使用“当前启用”的提供商处理文本 AI
+ 请求。
applyCloudflarePreset(selectedProviderIndex)}
+ onClick={() =>
+ applyCloudflarePreset(selectedProviderIndex)
+ }
>
套用 Cloudflare
@@ -1317,35 +1561,52 @@ export function SiteSettingsPage() {
disabled={testingProvider}
onClick={async () => {
try {
- setTestingProvider(true)
- const result = await adminApi.testAiProvider(selectedProvider)
+ setTestingProvider(true);
+ const result =
+ await adminApi.testAiProvider(
+ selectedProvider,
+ );
toast.success(
`连通成功:${result.provider} / ${result.chat_model} / ${result.reply_preview}`,
- )
+ );
} catch (error) {
toast.error(
- error instanceof ApiError ? error.message : '模型连通性测试失败。',
- )
+ error instanceof ApiError
+ ? error.message
+ : "模型连通性测试失败。",
+ );
} finally {
- setTestingProvider(false)
+ setTestingProvider(false);
}
}}
>
- {testingProvider ? '测试中...' : '测试连通性'}
+ {testingProvider ? "测试中..." : "测试连通性"}
setActiveAiProvider(selectedProvider.id)}
+ variant={
+ selectedProvider.id ===
+ form.ai_active_provider_id
+ ? "secondary"
+ : "outline"
+ }
+ onClick={() =>
+ setActiveAiProvider(selectedProvider.id)
+ }
>
- {selectedProvider.id === form.ai_active_provider_id ? '已启用' : '设为启用'}
+ {selectedProvider.id ===
+ form.ai_active_provider_id
+ ? "已启用"
+ : "设为启用"}
removeAiProvider(selectedProviderIndex)}
+ onClick={() =>
+ removeAiProvider(selectedProviderIndex)
+ }
>
删除
@@ -1353,11 +1614,18 @@ export function SiteSettingsPage() {
-
+
- updateAiProvider(selectedProviderIndex, 'name', event.target.value)
+ updateAiProvider(
+ selectedProviderIndex,
+ "name",
+ event.target.value,
+ )
}
/>
@@ -1366,9 +1634,13 @@ export function SiteSettingsPage() {
hint="选择文本模型提供方。Cloudflare 文本模型也支持。"
>
。' : undefined}
+ hint={
+ selectedProviderIsCloudflare
+ ? "Cloudflare 可直接填写 Account ID,或填写 https://api.cloudflare.com/client/v4/accounts/。"
+ : undefined
+ }
>
- updateAiProvider(selectedProviderIndex, 'api_base', event.target.value)
+ updateAiProvider(
+ selectedProviderIndex,
+ "api_base",
+ event.target.value,
+ )
+ }
+ placeholder={
+ selectedProviderIsCloudflare
+ ? "Cloudflare Account ID 或完整 accounts URL"
+ : undefined
}
- placeholder={selectedProviderIsCloudflare ? 'Cloudflare Account ID 或完整 accounts URL' : undefined}
/>
- updateAiProvider(selectedProviderIndex, 'api_key', event.target.value)
+ updateAiProvider(
+ selectedProviderIndex,
+ "api_key",
+ event.target.value,
+ )
}
/>
- updateAiProvider(selectedProviderIndex, 'chat_model', event.target.value)
+ updateAiProvider(
+ selectedProviderIndex,
+ "chat_model",
+ event.target.value,
+ )
}
/>
{selectedProviderIsCloudflare ? (
-
文本问答 / Cloudflare 说明
+
+ 文本问答 / Cloudflare 说明
+
- API 地址可直接填 Cloudflare Account ID。
- - 这里的模型只负责文本问答与后台文字类 AI 能力。
+ -
+ 这里的模型只负责文本问答与后台文字类 AI 能力。
+
) : null}
) : (
- 添加第一套 provider 后,就可以在这里编辑它的 API 地址、密钥和模型名。
+ 添加第一套 provider 后,就可以在这里编辑它的 API
+ 地址、密钥和模型名。
)}
@@ -1433,8 +1738,8 @@ export function SiteSettingsPage() {
文本 AI 当前生效:
{activeProvider
- ? `${activeProvider.provider || activeProvider.name} / ${activeProvider.chat_model || '未填写模型'}`
- : '未选择提供商'}
+ ? `${activeProvider.provider || activeProvider.name} / ${activeProvider.chat_model || "未填写模型"}`
+ : "未选择提供商"}
@@ -1445,7 +1750,11 @@ export function SiteSettingsPage() {
-
+
套用 Cloudflare
@@ -1456,27 +1765,29 @@ export function SiteSettingsPage() {
disabled={testingImageProvider}
onClick={async () => {
try {
- setTestingImageProvider(true)
+ setTestingImageProvider(true);
const result = await adminApi.testAiImageProvider({
- provider: form.ai_image_provider ?? '',
+ provider: form.ai_image_provider ?? "",
api_base: form.ai_image_api_base,
api_key: form.ai_image_api_key,
image_model: form.ai_image_model,
- })
+ });
toast.success(
`图片连通成功:${result.provider} / ${result.image_model} / ${result.result_preview}`,
- )
+ );
} catch (error) {
toast.error(
- error instanceof ApiError ? error.message : '图片模型连通性测试失败。',
- )
+ error instanceof ApiError
+ ? error.message
+ : "图片模型连通性测试失败。",
+ );
} finally {
- setTestingImageProvider(false)
+ setTestingImageProvider(false);
}
}}
>
- {testingImageProvider ? '测试中...' : '测试图片连通性'}
+ {testingImageProvider ? "测试中..." : "测试图片连通性"}
@@ -1487,8 +1798,10 @@ export function SiteSettingsPage() {
hint="选择图片模型提供方。这里专门用于封面图生成。"
>