Files
writer-work-flow/apps/web/components/settings/ProvidersSettings.tsx
Yaojia Wang 67e30a6863 feat(ui): P3 页面级应用(作品库卡片/空态/加载/设置/次级页)
- 作品库卡片重设计:书脊封面字块 + 衬线书名 + 一句话故事(缺失给占位) +
  元信息行(题材徽章/StatusDot 待审/mono 时间),Card tone=card 浮起消塌陷
- EmptyState 中性化 + inline/card/bare 变体 + eyebrow(向后兼容)
- loading 骨架化(Skeleton 拼作品库结构,motion-safe)
- 设置页拆分 ProvidersSettings 489→198 + 5 个子组件,凭据点用 StatusDot
- 次级页(向导/模板/codex/大纲)排版字阶与圆角 token 收敛
2026-07-11 07:36:32 +02:00

199 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}