feat(ui): P3 页面级应用(作品库卡片/空态/加载/设置/次级页)

- 作品库卡片重设计:书脊封面字块 + 衬线书名 + 一句话故事(缺失给占位) +
  元信息行(题材徽章/StatusDot 待审/mono 时间),Card tone=card 浮起消塌陷
- EmptyState 中性化 + inline/card/bare 变体 + eyebrow(向后兼容)
- loading 骨架化(Skeleton 拼作品库结构,motion-safe)
- 设置页拆分 ProvidersSettings 489→198 + 5 个子组件,凭据点用 StatusDot
- 次级页(向导/模板/codex/大纲)排版字阶与圆角 token 收敛
This commit is contained in:
Yaojia Wang
2026-07-11 07:36:32 +02:00
parent d3e9ae972f
commit 67e30a6863
14 changed files with 826 additions and 370 deletions

View File

@@ -0,0 +1,24 @@
import { Badge } from "@/components/ui/Badge";
import type { CapabilitiesView } from "@/lib/api/types";
// 探活结果的能力徽章:结构化输出 / 前缀缓存 / 思考,各显示开关态。
export function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
const badges: { on: boolean; label: string }[] = [
{ on: caps.structured_output, label: "结构化" },
{ on: caps.prefix_cache, label: "前缀缓存" },
{ on: caps.thinking, label: "思考" },
];
return (
<div className="flex gap-1.5">
{badges.map((b) => (
<Badge
key={b.label}
variant={b.on ? "accent" : "neutral"}
className="text-2xs"
>
{b.label}
</Badge>
))}
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { CheckCircle2, PlugZap, Save, XCircle } from "lucide-react";
import { CapabilityBadges } from "@/components/settings/CapabilityBadges";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { StatusDot } from "@/components/ui/StatusDot";
import { TextInput } from "@/components/ui/TextInput";
import type { CapabilitiesView } from "@/lib/api/types";
import type { KnownProvider } from "@/lib/settings/providers";
export interface TestResult {
ok: boolean;
capabilities: CapabilitiesView;
}
interface CredentialRowProps {
provider: KnownProvider;
masked: string | null;
draft: string;
saving: boolean;
testing: boolean;
result: TestResult | undefined;
onDraftChange: (value: string) => void;
onSave: () => void;
onTest: () => void;
}
// 单个 API-key 提供商行:脱敏态 + Key 输入 + 保存/测试 + 探活结果。
export function CredentialRow({
provider,
masked,
draft,
saving,
testing,
result,
onDraftChange,
onSave,
onTest,
}: CredentialRowProps) {
return (
<li className="px-4 py-4">
<div className="grid gap-3 lg:grid-cols-[10rem_8rem_minmax(12rem,1fr)_auto_auto] lg:items-center">
<span className="flex items-center gap-2 text-sm text-ink">
<StatusDot
tone={masked ? "success" : "neutral"}
label={masked ? "已配置" : "未配置"}
/>
{provider.label}
</span>
<span className="font-mono text-xs text-ink-soft">
{masked ?? "未配置"}
</span>
<label className="sr-only" htmlFor={`key-${provider.id}`}>
{provider.label} API Key
</label>
<TextInput
id={`key-${provider.id}`}
type="password"
autoComplete="off"
value={draft}
onChange={(e) => onDraftChange(e.target.value)}
placeholder={masked ? "输入新 Key 以更新" : "输入 API Key"}
/>
<Button
onClick={onSave}
disabled={saving || draft.trim().length === 0}
variant="primary"
size="sm"
>
<Save className="h-4 w-4" aria-hidden="true" />
{saving ? "保存中…" : masked ? "更新" : "添加"}
</Button>
<Button
onClick={onTest}
disabled={testing}
variant="secondary"
size="sm"
>
<PlugZap className="h-4 w-4" aria-hidden="true" />
{testing ? "测试中…" : "测试"}
</Button>
</div>
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
{masked
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
: "保存后再测试连接,成功后即可在档位路由中使用。"}
</p>
{result ? (
<div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
<Badge variant={result.ok ? "success" : "danger"}>
{result.ok ? (
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
) : (
<XCircle className="h-3 w-3" aria-hidden="true" />
)}
{result.ok ? "已连接" : "未连接"}
</Badge>
<CapabilityBadges caps={result.capabilities} />
</div>
) : null}
</li>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import { PlugZap } from "lucide-react";
import {
CredentialRow,
type TestResult,
} from "@/components/settings/CredentialRow";
import { EmptyState } from "@/components/ui/EmptyState";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { API_KEY_PROVIDERS } from "@/lib/settings/providers";
import type { ProviderView } from "@/lib/api/types";
interface CredentialsPanelProps {
providers: ProviderView[];
drafts: Record<string, string>;
savingId: string | null;
testingId: string | null;
results: Record<string, TestResult>;
onDraftChange: (providerId: string, value: string) => void;
onSave: (providerId: string) => void;
onTest: (providerId: string) => void;
}
// 提供商凭据面板API-key 提供商列表,逐行保存/测试UX §6.10)。
export function CredentialsPanel({
providers,
drafts,
savingId,
testingId,
results,
onDraftChange,
onSave,
onTest,
}: CredentialsPanelProps) {
const maskedFor = (id: string): string | null =>
providers.find((p) => p.provider === id)?.masked_key ?? null;
return (
<section className="min-w-0">
<SectionHeader
eyebrow="凭据"
title="提供商凭据"
description="API Key 只用于后端探活和调用,列表中只显示脱敏后的已保存凭据。"
/>
<div className="mt-3 grid gap-2 text-xs text-ink-soft sm:grid-cols-3">
<StatItem label="已保存" value={providers.length} />
<StatItem label="可配置" value={API_KEY_PROVIDERS.length} />
<StatItem label="测试结果" value={Object.keys(results).length} />
</div>
{providers.length === 0 ? (
<EmptyState
icon={PlugZap}
title="还没有可用提供商"
description="至少连接一个提供商即可开始写作。求质量可选 Anthropic求性价比可选 DeepSeek。"
className="mt-4"
/>
) : null}
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
{API_KEY_PROVIDERS.map((prov) => (
<CredentialRow
key={prov.id}
provider={prov}
masked={maskedFor(prov.id)}
draft={drafts[prov.id] ?? ""}
saving={savingId === prov.id}
testing={testingId === prov.id}
result={results[prov.id]}
onDraftChange={(value) => onDraftChange(prov.id, value)}
onSave={() => onSave(prov.id)}
onTest={() => onTest(prov.id)}
/>
))}
</ul>
</section>
);
}
function StatItem({ label, value }: { label: string; value: number }) {
return (
<div className="rounded border border-line bg-panel px-3 py-2">
{label} <span className="font-mono text-ink">{value}</span>
</div>
);
}

View File

@@ -1,41 +1,26 @@
"use client";
import {
CheckCircle2,
KeyRound,
PlugZap,
Route,
Save,
XCircle,
type LucideIcon,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import { useState } from "react";
import { useToast } from "@/components/Toast";
import { CredentialsPanel } from "@/components/settings/CredentialsPanel";
import type { TestResult } from "@/components/settings/CredentialRow";
import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { Select } from "@/components/ui/Select";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput";
import { api } from "@/lib/api/client";
import { cardClass } from "@/lib/ui/variants";
import { RoutingPanel } from "@/components/settings/RoutingPanel";
import {
SECTION_OPTIONS,
SettingsNav,
type SettingsSection,
} from "@/components/settings/SettingsNav";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { api } from "@/lib/api/client";
import {
API_KEY_PROVIDERS,
KNOWN_PROVIDERS,
TIER_LABELS,
applyProviderChange,
draftsToRoutingInput,
toRoutingDrafts,
type RoutingDraft,
} from "@/lib/settings/providers";
import type {
CapabilitiesView,
ProvidersResponse,
} from "@/lib/api/types";
import type { ProvidersResponse } from "@/lib/api/types";
interface ProvidersSettingsProps {
initial: ProvidersResponse;
@@ -43,42 +28,6 @@ interface ProvidersSettingsProps {
kimiOauth: { connected: boolean; expiresAt: string | null };
}
interface TestResult {
ok: boolean;
capabilities: CapabilitiesView;
}
type SettingsSection = "routing" | "oauth" | "keys";
// 分组的单一标签源:桌面 aside 与移动 SegmentedControl 共用,消除「档位路由 / 路由」漂移。
const SETTINGS_SECTIONS: Array<{
value: SettingsSection;
label: string;
icon: LucideIcon;
}> = [
{ value: "routing", label: "档位路由", icon: Route },
{ value: "oauth", label: "OAuth", icon: PlugZap },
{ value: "keys", label: "API Key", icon: KeyRound },
];
const SECTION_OPTIONS = SETTINGS_SECTIONS.map(({ value, label }) => ({
value,
label,
}));
// 档位路由「模型」输入框的候选 model iddatalist 建议,仍可自由输入)。按 provider 分组。
const MODEL_SUGGESTIONS: Record<string, readonly string[]> = {
anthropic: ["claude-opus-4", "claude-sonnet-4", "claude-3-5-haiku"],
deepseek: ["deepseek-chat", "deepseek-reasoner"],
kimi: ["moonshot-v1-128k", "moonshot-v1-32k", "kimi-k2"],
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
qwen: ["qwen-max", "qwen-plus", "qwen-turbo"],
glm: ["glm-4-plus", "glm-4-air", "glm-4-flash"],
gemini: ["gemini-2.0-flash", "gemini-1.5-pro"],
"kimi-code-key": ["kimi-for-coding"],
"kimi-code": ["kimi-for-coding"],
};
// 设置页主体UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
export function ProvidersSettings({
initial,
@@ -97,9 +46,6 @@ export function ProvidersSettings({
const [testingId, setTestingId] = useState<string | null>(null);
const [results, setResults] = useState<Record<string, TestResult>>({});
const maskedFor = (id: string): string | null =>
providers.find((p) => p.provider === id)?.masked_key ?? null;
const sectionDetail = (value: SettingsSection): string => {
if (value === "routing") {
return `${routing.filter((r) => r.provider && r.model).length}/3 已配置`;
@@ -194,22 +140,17 @@ export function ProvidersSettings({
}
};
const updateDraft = (providerId: string, value: string): void => {
setDrafts((prev) => ({ ...prev, [providerId]: value }));
};
return (
<div className="grid gap-6 lg:grid-cols-[12rem_1fr]">
<aside className="hidden lg:block">
<nav aria-label="设置分组" className={cardClass("sticky top-20 p-2")}>
{SETTINGS_SECTIONS.map((section) => (
<SettingsNavButton
key={section.value}
active={activeSection === section.value}
icon={<section.icon className="h-4 w-4" aria-hidden="true" />}
label={section.label}
detail={sectionDetail(section.value)}
onClick={() => setActiveSection(section.value)}
/>
))}
</nav>
</aside>
<SettingsNav
activeSection={activeSection}
onSelect={setActiveSection}
detailFor={sectionDetail}
/>
<div className="min-w-0">
<SegmentedControl
@@ -221,269 +162,37 @@ export function ProvidersSettings({
/>
{activeSection === "routing" ? (
<SettingsPanel>
<SectionHeader
title="能力档位路由"
description="写手、分析、轻量三类能力可分别指向不同模型。保存时只提交完整填写的行。"
/>
<StatusNote className="mt-3" variant="info">
稿
</StatusNote>
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
{routing.map((row) => (
<li
key={row.tier}
className="grid gap-3 px-4 py-3 text-sm md:grid-cols-[6rem_minmax(10rem,14rem)_1fr]"
>
<span className="self-center text-ink">
{TIER_LABELS[row.tier] ?? row.tier}
</span>
<label
className="sr-only"
htmlFor={`route-provider-${row.tier}`}
>
{TIER_LABELS[row.tier] ?? row.tier}
</label>
<Select
id={`route-provider-${row.tier}`}
value={row.provider}
onChange={(e) => updateRouting(row.tier, e.target.value)}
>
<option value=""></option>
{KNOWN_PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</Select>
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
{TIER_LABELS[row.tier] ?? row.tier}
</label>
<div className="min-w-0">
<TextInput
id={`route-model-${row.tier}`}
value={row.model}
onChange={(e) =>
updateRoutingModel(row.tier, e.target.value)
}
placeholder="model"
className="w-full font-mono"
list={`route-models-${row.tier}`}
/>
<datalist id={`route-models-${row.tier}`}>
{(MODEL_SUGGESTIONS[row.provider] ?? []).map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div>
</li>
))}
</ul>
<div className="mt-3 flex justify-end">
<Button
onClick={() => void saveRouting()}
disabled={savingRouting}
variant="primary"
size="sm"
>
<Save className="h-4 w-4" aria-hidden="true" />
{savingRouting ? "保存中…" : "保存档位路由"}
</Button>
</div>
</SettingsPanel>
<RoutingPanel
routing={routing}
saving={savingRouting}
onProviderChange={updateRouting}
onModelChange={updateRoutingModel}
onSave={() => void saveRouting()}
/>
) : null}
{activeSection === "oauth" ? (
<SettingsPanel>
<section className="min-w-0">
<KimiCodeOauth
initialConnected={kimiOauth.connected}
initialExpiresAt={kimiOauth.expiresAt}
/>
</SettingsPanel>
</section>
) : null}
{activeSection === "keys" ? (
<SettingsPanel>
<SectionHeader
title="提供商凭据"
description="API Key 只用于后端探活和调用,列表中只显示脱敏后的已保存凭据。"
/>
<div className="mt-3 grid gap-2 text-xs text-ink-soft sm:grid-cols-3">
<div className="rounded border border-line bg-panel px-3 py-2">
<span className="font-mono text-ink">{providers.length}</span>
</div>
<div className="rounded border border-line bg-panel px-3 py-2">
{" "}
<span className="font-mono text-ink">
{API_KEY_PROVIDERS.length}
</span>
</div>
<div className="rounded border border-line bg-panel px-3 py-2">
{" "}
<span className="font-mono text-ink">
{Object.keys(results).length}
</span>
</div>
</div>
{providers.length === 0 ? (
<EmptyState
icon={PlugZap}
title="还没有可用提供商"
description="至少连接一个提供商即可开始写作。求质量可选 Anthropic求性价比可选 DeepSeek。"
className="mt-4"
/>
) : null}
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
{API_KEY_PROVIDERS.map((prov) => {
const masked = maskedFor(prov.id);
const result = results[prov.id];
return (
<li key={prov.id} className="px-4 py-4">
<div className="grid gap-3 lg:grid-cols-[10rem_8rem_minmax(12rem,1fr)_auto_auto] lg:items-center">
<span className="flex items-center gap-2 text-sm text-ink">
<span
className={`h-2 w-2 rounded ${
masked ? "bg-pass" : "bg-line"
}`}
aria-hidden="true"
/>
{prov.label}
</span>
<span className="font-mono text-xs text-ink-soft">
{masked ?? "未配置"}
</span>
<label className="sr-only" htmlFor={`key-${prov.id}`}>
{prov.label} API Key
</label>
<TextInput
id={`key-${prov.id}`}
type="password"
autoComplete="off"
value={drafts[prov.id] ?? ""}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[prov.id]: e.target.value,
}))
}
placeholder={
masked ? "输入新 Key 以更新" : "输入 API Key"
}
/>
<Button
onClick={() => saveCredential(prov.id)}
disabled={
savingId === prov.id ||
(drafts[prov.id] ?? "").trim().length === 0
}
variant="primary"
size="sm"
>
<Save className="h-4 w-4" aria-hidden="true" />
{savingId === prov.id
? "保存中…"
: masked
? "更新"
: "添加"}
</Button>
<Button
onClick={() => testConnection(prov.id)}
disabled={testingId === prov.id}
variant="secondary"
size="sm"
>
<PlugZap className="h-4 w-4" aria-hidden="true" />
{testingId === prov.id ? "测试中…" : "测试"}
</Button>
</div>
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
{masked
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
: "保存后再测试连接,成功后即可在档位路由中使用。"}
</p>
{result ? (
<div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
<Badge variant={result.ok ? "success" : "danger"}>
{result.ok ? (
<CheckCircle2
className="h-3 w-3"
aria-hidden="true"
/>
) : (
<XCircle className="h-3 w-3" aria-hidden="true" />
)}
{result.ok ? "已连接" : "未连接"}
</Badge>
<CapabilityBadges caps={result.capabilities} />
</div>
) : null}
</li>
);
})}
</ul>
</SettingsPanel>
<CredentialsPanel
providers={providers}
drafts={drafts}
savingId={savingId}
testingId={testingId}
results={results}
onDraftChange={updateDraft}
onSave={(id) => void saveCredential(id)}
onTest={(id) => void testConnection(id)}
/>
) : null}
</div>
</div>
);
}
function SettingsPanel({ children }: { children: ReactNode }) {
return <section className="min-w-0">{children}</section>;
}
interface SettingsNavButtonProps {
active: boolean;
icon: ReactNode;
label: string;
detail: string;
onClick: () => void;
}
function SettingsNavButton({
active,
icon,
label,
detail,
onClick,
}: SettingsNavButtonProps) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`mb-1 flex w-full items-start gap-2 rounded px-3 py-2 text-left transition-colors ${
active
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-bg hover:text-cinnabar"
}`}
>
<span className="mt-0.5 shrink-0">{icon}</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{label}</span>
<span className="block truncate text-xs text-ink-soft">{detail}</span>
</span>
</button>
);
}
function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
const badges: { on: boolean; label: string }[] = [
{ on: caps.structured_output, label: "结构化" },
{ on: caps.prefix_cache, label: "前缀缓存" },
{ on: caps.thinking, label: "思考" },
];
return (
<div className="flex gap-1.5">
{badges.map((b) => (
<Badge
key={b.label}
variant={b.on ? "accent" : "neutral"}
className="text-2xs"
>
{b.label}
</Badge>
))}
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { Select } from "@/components/ui/Select";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput";
import {
KNOWN_PROVIDERS,
TIER_LABELS,
type RoutingDraft,
} from "@/lib/settings/providers";
// 档位路由「模型」输入框的候选 model iddatalist 建议,仍可自由输入)。按 provider 分组。
const MODEL_SUGGESTIONS: Record<string, readonly string[]> = {
anthropic: ["claude-opus-4", "claude-sonnet-4", "claude-3-5-haiku"],
deepseek: ["deepseek-chat", "deepseek-reasoner"],
kimi: ["moonshot-v1-128k", "moonshot-v1-32k", "kimi-k2"],
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
qwen: ["qwen-max", "qwen-plus", "qwen-turbo"],
glm: ["glm-4-plus", "glm-4-air", "glm-4-flash"],
gemini: ["gemini-2.0-flash", "gemini-1.5-pro"],
"kimi-code-key": ["kimi-for-coding"],
"kimi-code": ["kimi-for-coding"],
};
interface RoutingPanelProps {
routing: RoutingDraft[];
saving: boolean;
onProviderChange: (tier: string, providerId: string) => void;
onModelChange: (tier: string, model: string) => void;
onSave: () => void;
}
// 能力档位路由面板:写手/分析/轻量三档各指向一个 provider+modelUX §6.10)。
export function RoutingPanel({
routing,
saving,
onProviderChange,
onModelChange,
onSave,
}: RoutingPanelProps) {
return (
<section className="min-w-0">
<SectionHeader
eyebrow="路由"
title="能力档位路由"
description="写手、分析、轻量三类能力可分别指向不同模型。保存时只提交完整填写的行。"
/>
<StatusNote className="mt-3" variant="info">
稿
</StatusNote>
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
{routing.map((row) => (
<RoutingRow
key={row.tier}
row={row}
onProviderChange={onProviderChange}
onModelChange={onModelChange}
/>
))}
</ul>
<div className="mt-3 flex justify-end">
<Button
onClick={onSave}
disabled={saving}
variant="primary"
size="sm"
>
<Save className="h-4 w-4" aria-hidden="true" />
{saving ? "保存中…" : "保存档位路由"}
</Button>
</div>
</section>
);
}
interface RoutingRowProps {
row: RoutingDraft;
onProviderChange: (tier: string, providerId: string) => void;
onModelChange: (tier: string, model: string) => void;
}
function RoutingRow({ row, onProviderChange, onModelChange }: RoutingRowProps) {
const tierLabel = TIER_LABELS[row.tier] ?? row.tier;
return (
<li className="grid gap-3 px-4 py-3 text-sm md:grid-cols-[6rem_minmax(10rem,14rem)_1fr]">
<span className="self-center text-ink">{tierLabel}</span>
<label className="sr-only" htmlFor={`route-provider-${row.tier}`}>
{tierLabel}
</label>
<Select
id={`route-provider-${row.tier}`}
value={row.provider}
onChange={(e) => onProviderChange(row.tier, e.target.value)}
>
<option value=""></option>
{KNOWN_PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</Select>
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
{tierLabel}
</label>
<div className="min-w-0">
<TextInput
id={`route-model-${row.tier}`}
value={row.model}
onChange={(e) => onModelChange(row.tier, e.target.value)}
placeholder="model"
className="w-full font-mono"
list={`route-models-${row.tier}`}
/>
<datalist id={`route-models-${row.tier}`}>
{(MODEL_SUGGESTIONS[row.provider] ?? []).map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div>
</li>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { KeyRound, PlugZap, Route, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cardClass } from "@/lib/ui/variants";
export type SettingsSection = "routing" | "oauth" | "keys";
// 分组的单一标签源:桌面 aside 与移动 SegmentedControl 共用,消除「档位路由 / 路由」漂移。
export const SETTINGS_SECTIONS: Array<{
value: SettingsSection;
label: string;
icon: LucideIcon;
}> = [
{ value: "routing", label: "档位路由", icon: Route },
{ value: "oauth", label: "OAuth", icon: PlugZap },
{ value: "keys", label: "API Key", icon: KeyRound },
];
export const SECTION_OPTIONS = SETTINGS_SECTIONS.map(({ value, label }) => ({
value,
label,
}));
interface SettingsNavProps {
activeSection: SettingsSection;
onSelect: (value: SettingsSection) => void;
detailFor: (value: SettingsSection) => string;
}
// 桌面侧栏:设置分组导航(移动端由 SegmentedControl 承担)。
export function SettingsNav({
activeSection,
onSelect,
detailFor,
}: SettingsNavProps) {
return (
<aside className="hidden lg:block">
<nav aria-label="设置分组" className={cardClass("sticky top-20 p-2")}>
{SETTINGS_SECTIONS.map((section) => (
<SettingsNavButton
key={section.value}
active={activeSection === section.value}
icon={<section.icon className="h-4 w-4" aria-hidden="true" />}
label={section.label}
detail={detailFor(section.value)}
onClick={() => onSelect(section.value)}
/>
))}
</nav>
</aside>
);
}
interface SettingsNavButtonProps {
active: boolean;
icon: ReactNode;
label: string;
detail: string;
onClick: () => void;
}
function SettingsNavButton({
active,
icon,
label,
detail,
onClick,
}: SettingsNavButtonProps) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`mb-1 flex w-full items-start gap-2 rounded px-3 py-2 text-left transition-colors ${
active
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-bg hover:text-cinnabar"
}`}
>
<span className="mt-0.5 shrink-0">{icon}</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{label}</span>
<span className="block truncate text-xs text-ink-soft">{detail}</span>
</span>
</button>
);
}