- 作品库卡片重设计:书脊封面字块 + 衬线书名 + 一句话故事(缺失给占位) + 元信息行(题材徽章/StatusDot 待审/mono 时间),Card tone=card 浮起消塌陷 - EmptyState 中性化 + inline/card/bare 变体 + eyebrow(向后兼容) - loading 骨架化(Skeleton 拼作品库结构,motion-safe) - 设置页拆分 ProvidersSettings 489→198 + 5 个子组件,凭据点用 StatusDot - 次级页(向导/模板/codex/大纲)排版字阶与圆角 token 收敛
199 lines
6.2 KiB
TypeScript
199 lines
6.2 KiB
TypeScript
"use client";
|
||
|
||
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 { 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 {
|
||
applyProviderChange,
|
||
draftsToRoutingInput,
|
||
toRoutingDrafts,
|
||
type RoutingDraft,
|
||
} from "@/lib/settings/providers";
|
||
import type { ProvidersResponse } from "@/lib/api/types";
|
||
|
||
interface ProvidersSettingsProps {
|
||
initial: ProvidersResponse;
|
||
// Kimi Code OAuth 连接态(GET .../oauth/status,进页 Server Component 取)。
|
||
kimiOauth: { connected: boolean; expiresAt: string | null };
|
||
}
|
||
|
||
// 设置页主体(UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
|
||
export function ProvidersSettings({
|
||
initial,
|
||
kimiOauth,
|
||
}: ProvidersSettingsProps) {
|
||
const toast = useToast();
|
||
const [activeSection, setActiveSection] =
|
||
useState<SettingsSection>("routing");
|
||
const [providers, setProviders] = useState(initial.providers ?? []);
|
||
const [routing, setRouting] = useState<RoutingDraft[]>(
|
||
toRoutingDrafts(initial.tier_routing ?? []),
|
||
);
|
||
const [savingRouting, setSavingRouting] = useState(false);
|
||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||
const [savingId, setSavingId] = useState<string | null>(null);
|
||
const [testingId, setTestingId] = useState<string | null>(null);
|
||
const [results, setResults] = useState<Record<string, TestResult>>({});
|
||
|
||
const sectionDetail = (value: SettingsSection): string => {
|
||
if (value === "routing") {
|
||
return `${routing.filter((r) => r.provider && r.model).length}/3 已配置`;
|
||
}
|
||
if (value === "oauth") {
|
||
return kimiOauth.connected ? "Kimi 已连接" : "未连接";
|
||
}
|
||
return `${providers.length} 个凭据`;
|
||
};
|
||
|
||
const updateRouting = (tier: string, providerId: string): void => {
|
||
setRouting((prev) =>
|
||
prev.map((d) =>
|
||
d.tier === tier ? applyProviderChange(d, providerId) : d,
|
||
),
|
||
);
|
||
};
|
||
|
||
const updateRoutingModel = (tier: string, model: string): void => {
|
||
setRouting((prev) =>
|
||
prev.map((d) => (d.tier === tier ? { ...d, model } : d)),
|
||
);
|
||
};
|
||
|
||
const saveRouting = async (): Promise<void> => {
|
||
setSavingRouting(true);
|
||
try {
|
||
const { data, error } = await api.PUT("/settings/providers", {
|
||
body: { tier_routing: draftsToRoutingInput(routing) },
|
||
});
|
||
if (error || !data) {
|
||
toast("保存档位路由失败,请重试", "error");
|
||
return;
|
||
}
|
||
setProviders(data.providers ?? []);
|
||
setRouting(toRoutingDrafts(data.tier_routing ?? []));
|
||
toast("档位路由已保存", "success");
|
||
} catch {
|
||
toast("保存档位路由失败,请检查后端连接", "error");
|
||
} finally {
|
||
setSavingRouting(false);
|
||
}
|
||
};
|
||
|
||
const saveCredential = async (providerId: string): Promise<void> => {
|
||
const apiKey = (drafts[providerId] ?? "").trim();
|
||
if (!apiKey) {
|
||
toast("请先输入 API Key", "error");
|
||
return;
|
||
}
|
||
setSavingId(providerId);
|
||
try {
|
||
const { data, error } = await api.PUT("/settings/providers", {
|
||
body: { credentials: [{ provider: providerId, api_key: apiKey }] },
|
||
});
|
||
if (error || !data) {
|
||
toast("保存凭据失败,请重试", "error");
|
||
return;
|
||
}
|
||
setProviders(data.providers ?? []);
|
||
setDrafts((prev) => ({ ...prev, [providerId]: "" }));
|
||
toast("凭据已保存", "success");
|
||
} catch {
|
||
toast("保存凭据失败,请检查后端连接", "error");
|
||
} finally {
|
||
setSavingId(null);
|
||
}
|
||
};
|
||
|
||
const testConnection = async (providerId: string): Promise<void> => {
|
||
setTestingId(providerId);
|
||
try {
|
||
const { data, error } = await api.POST("/settings/providers/test", {
|
||
body: { provider: providerId },
|
||
});
|
||
if (error || !data) {
|
||
toast("测试连接失败", "error");
|
||
return;
|
||
}
|
||
setResults((prev) => ({
|
||
...prev,
|
||
[providerId]: { ok: data.ok, capabilities: data.capabilities },
|
||
}));
|
||
toast(
|
||
data.ok ? "连接成功" : "连接未通过",
|
||
data.ok ? "success" : "error",
|
||
);
|
||
} catch {
|
||
toast("测试连接失败,请检查后端连接", "error");
|
||
} finally {
|
||
setTestingId(null);
|
||
}
|
||
};
|
||
|
||
const updateDraft = (providerId: string, value: string): void => {
|
||
setDrafts((prev) => ({ ...prev, [providerId]: value }));
|
||
};
|
||
|
||
return (
|
||
<div className="grid gap-6 lg:grid-cols-[12rem_1fr]">
|
||
<SettingsNav
|
||
activeSection={activeSection}
|
||
onSelect={setActiveSection}
|
||
detailFor={sectionDetail}
|
||
/>
|
||
|
||
<div className="min-w-0">
|
||
<SegmentedControl
|
||
options={SECTION_OPTIONS}
|
||
value={activeSection}
|
||
onChange={setActiveSection}
|
||
ariaLabel="设置分组"
|
||
className="mb-4 w-full justify-center lg:hidden"
|
||
/>
|
||
|
||
{activeSection === "routing" ? (
|
||
<RoutingPanel
|
||
routing={routing}
|
||
saving={savingRouting}
|
||
onProviderChange={updateRouting}
|
||
onModelChange={updateRoutingModel}
|
||
onSave={() => void saveRouting()}
|
||
/>
|
||
) : null}
|
||
|
||
{activeSection === "oauth" ? (
|
||
<section className="min-w-0">
|
||
<KimiCodeOauth
|
||
initialConnected={kimiOauth.connected}
|
||
initialExpiresAt={kimiOauth.expiresAt}
|
||
/>
|
||
</section>
|
||
) : null}
|
||
|
||
{activeSection === "keys" ? (
|
||
<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>
|
||
);
|
||
}
|