Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

271 lines
8.6 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { ChevronRight, Rocket, type LucideIcon } from "lucide-react";
import { apiFetch, apiUrl } from "@/lib/api";
import {
serviceReadiness,
useSettings,
type ServiceReadiness,
} from "@/components/settings/SettingsContext";
import SettingsStatusPanel from "@/components/settings/SettingsStatusPanel";
import {
SETTINGS_CATEGORIES,
type Lang,
type SettingsCategory,
} from "@/lib/settings-nav";
/**
* Settings hub — the landing page of `/settings`.
*
* Six category blocks and a resident Status module, nothing else. The blocks
* are intentionally calmer than the Learning Space tiles (monochrome inline
* icons, a chevron, a quiet preview line instead of a focal count) so Settings
* reads as a control surface rather than a dashboard. Categories with several
* settings (Models, Chat) open a sub-hub; the rest link straight to their leaf.
*/
type NetworkPreview = {
apiBase: string;
};
export default function SettingsHub() {
const { i18n } = useTranslation();
const zh = i18n.language?.toLowerCase().startsWith("zh");
const tr = useCallback((l: Lang) => (zh ? l.zh : l.en), [zh]);
const { catalog, catalogEditable, diagnosticsResults, startTour } =
useSettings();
// Model preview: how many of the model-service leaves are configured.
const modelStats = useMemo(() => {
const cat = SETTINGS_CATEGORIES.find((c) => c.key === "models");
const services = (cat?.children ?? []).filter((l) => l.service);
if (catalogEditable !== true) {
return {
total: services.length,
configured: -1,
passed: 0,
failed: 0,
states: [] as ServiceReadiness[],
};
}
const states = services.map((leaf) =>
serviceReadiness(catalog, leaf.service!, diagnosticsResults),
);
return {
total: services.length,
configured: states.filter((state) => state !== "not_configured").length,
passed: states.filter((state) => state === "passed").length,
failed: states.filter((state) => state === "failed").length,
states,
};
}, [catalog, catalogEditable, diagnosticsResults]);
// Network preview: a guarded peek at the effective browser API base. Fails
// quietly (non-admins get 403) → the block falls back to its blurb.
const [network, setNetwork] = useState<NetworkPreview | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await apiFetch(apiUrl("/api/v1/settings/network"));
if (!res.ok) return;
const data = (await res.json()) as {
effective?: { browser_api_base?: string };
};
if (cancelled) return;
setNetwork({ apiBase: data.effective?.browser_api_base || "" });
} catch {
/* leave null → block shows its blurb */
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<div>
<header className="mb-7 flex items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="font-serif text-[24px] font-semibold leading-tight tracking-tight text-[var(--foreground)]">
{tr({ zh: "设置", en: "Settings" })}
</h1>
<p className="mt-1.5 max-w-xl text-[13px] leading-relaxed text-[var(--muted-foreground)]">
{tr({
zh: "管理外观、模型与服务、知识库、聊天与记忆。",
en: "Manage appearance, models and services, knowledge base, chat, and memory.",
})}
</p>
</div>
<button
type="button"
onClick={startTour}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-[var(--border)]/60 px-3 py-1.5 text-[12.5px] font-medium text-[var(--muted-foreground)] transition-colors hover:border-[var(--border)] hover:text-[var(--foreground)]"
>
<Rocket size={13} />
{tr({ zh: "引导", en: "Tour" })}
</button>
</header>
<SettingsStatusPanel />
<div className="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{SETTINGS_CATEGORIES.map((category) => (
<CategoryBlock
key={category.key}
category={category}
tr={tr}
modelStats={category.key === "models" ? modelStats : undefined}
network={category.key === "network" ? network : undefined}
/>
))}
</div>
</div>
);
}
function CategoryBlock({
category,
tr,
modelStats,
network,
}: {
category: SettingsCategory;
tr: (l: Lang) => string;
modelStats?: {
total: number;
configured: number;
passed: number;
failed: number;
states: ServiceReadiness[];
};
network?: NetworkPreview | null;
}) {
const Icon: LucideIcon = category.icon;
return (
<Link
href={category.href}
data-tour={`tour-cat-${category.key}`}
className="group relative flex min-h-[120px] flex-col justify-between rounded-2xl border border-[var(--border)]/70 bg-[var(--card)] p-5 transition-all duration-150 hover:border-[var(--foreground)]/20 hover:shadow-[0_4px_24px_-16px_rgba(0,0,0,0.3)]"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5">
<Icon
size={19}
strokeWidth={1.6}
className="text-[var(--muted-foreground)] transition-colors group-hover:text-[var(--foreground)]"
/>
<h3 className="text-[15.5px] font-medium tracking-tight text-[var(--foreground)]">
{tr(category.label)}
</h3>
</div>
<ChevronRight
size={16}
className="mt-0.5 shrink-0 text-[var(--muted-foreground)]/30 transition-all group-hover:translate-x-0.5 group-hover:text-[var(--muted-foreground)]"
/>
</div>
<div className="mt-4">
{modelStats ? (
<ModelPreview stats={modelStats} blurb={tr(category.blurb)} tr={tr} />
) : network !== undefined && network !== null ? (
<NetworkPreviewRow network={network} tr={tr} />
) : (
<p className="text-[12.5px] leading-relaxed text-[var(--muted-foreground)]">
{tr(category.blurb)}
</p>
)}
</div>
</Link>
);
}
function ModelPreview({
stats,
blurb,
tr,
}: {
stats: {
total: number;
configured: number;
passed: number;
failed: number;
states: ServiceReadiness[];
};
blurb: string;
tr: (l: Lang) => string;
}) {
// Restricted deployments (no editable catalog) can't know — show the blurb.
if (stats.configured < 0) {
return (
<p className="text-[12.5px] leading-relaxed text-[var(--muted-foreground)]">
{blurb}
</p>
);
}
return (
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-1">
{stats.states.map((state, i) => (
<span
key={i}
className={`h-1.5 w-1.5 rounded-full ${
state === "passed"
? "bg-emerald-500"
: state === "failed"
? "bg-red-500"
: state === "untested"
? "bg-zinc-400 dark:bg-zinc-500"
: "bg-zinc-300/60 dark:bg-zinc-700"
}`}
/>
))}
</div>
<span className="text-[12px] tabular-nums text-[var(--muted-foreground)]">
{stats.failed > 0
? tr({
zh: `${stats.configured}/${stats.total} 已配置 · ${stats.failed} 个失败`,
en: `${stats.configured}/${stats.total} configured · ${stats.failed} failed`,
})
: stats.passed > 0
? tr({
zh: `${stats.configured}/${stats.total} 已配置 · ${stats.passed} 个通过`,
en: `${stats.configured}/${stats.total} configured · ${stats.passed} passed`,
})
: tr({
zh: `${stats.configured}/${stats.total} 已配置`,
en: `${stats.configured}/${stats.total} configured`,
})}
</span>
</div>
);
}
function NetworkPreviewRow({
network,
tr,
}: {
network: NetworkPreview;
tr: (l: Lang) => string;
}) {
return (
<div className="flex items-center gap-2 text-[12px] text-[var(--muted-foreground)]">
<span className="shrink-0 text-[var(--muted-foreground)]/70">
{tr({ zh: "API", en: "API" })}
</span>
<span
className="truncate font-mono text-[11.5px] text-[var(--foreground)]"
title={network.apiBase}
>
{network.apiBase || tr({ zh: "本地", en: "local" })}
</span>
</div>
);
}