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:
@@ -107,9 +107,9 @@ export function ProjectWizard() {
|
||||
const isLast = step === WIZARD_STEPS;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl rounded border border-line bg-panel p-6 shadow-paper sm:p-8">
|
||||
<div className="mx-auto max-w-2xl rounded-lg border border-line bg-panel p-6 shadow-paper sm:p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="font-serif text-2xl text-ink">新建作品</h1>
|
||||
<h1 className="font-serif text-display-sm text-ink">新建作品</h1>
|
||||
<StepDots current={step} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
@@ -175,7 +175,7 @@ function StepDots({ current }: { current: number }) {
|
||||
{Array.from({ length: WIZARD_STEPS }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-2 w-2 rounded ${
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
i + 1 <= current ? "bg-cinnabar" : "bg-line"
|
||||
}`}
|
||||
/>
|
||||
@@ -235,7 +235,7 @@ function PlanAssistant({
|
||||
const generating = status === "generating";
|
||||
|
||||
return (
|
||||
<div className="my-4 rounded border border-line bg-bg p-3">
|
||||
<div className="my-4 rounded-md border border-line bg-bg p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-1.5 text-sm text-ink">
|
||||
@@ -449,7 +449,7 @@ function StepConfirm({ form }: { form: WizardForm }) {
|
||||
<p className="mb-3 text-sm text-ink-soft">
|
||||
复核以下信息,确认无误后即可完成立项。其余设定可进工作台后在「设定库」继续完善。
|
||||
</p>
|
||||
<dl className="divide-y divide-line rounded border border-line bg-bg">
|
||||
<dl className="divide-y divide-line rounded-md border border-line bg-bg">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex gap-4 px-4 py-2.5 text-sm">
|
||||
<dt className="w-20 shrink-0 text-ink-soft">{row.label}</dt>
|
||||
|
||||
@@ -50,7 +50,7 @@ const RelationshipGraph = dynamic(
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-[28rem] items-center justify-center rounded border border-line bg-bg text-sm text-ink-soft">
|
||||
<div className="flex h-[28rem] items-center justify-center rounded-lg border border-line bg-bg text-sm text-ink-soft">
|
||||
关系图谱加载中…
|
||||
</div>
|
||||
),
|
||||
@@ -101,7 +101,7 @@ export function CodexPage({
|
||||
{tab === "characters" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 font-serif text-sm text-ink">
|
||||
<h3 className="mb-3 font-serif text-title-md text-ink">
|
||||
已入库人物({characters.length})
|
||||
</h3>
|
||||
{characters.length > 0 ? (
|
||||
@@ -109,10 +109,10 @@ export function CodexPage({
|
||||
{characters.map((c, i) => (
|
||||
<li
|
||||
key={`${c.name}-${i}`}
|
||||
className="rounded border border-line bg-bg p-3 text-sm"
|
||||
className="rounded-lg border border-line bg-bg p-3 text-sm"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<UserRound className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
@@ -127,7 +127,7 @@ export function CodexPage({
|
||||
{c.relations.map((r, j) => (
|
||||
<li
|
||||
key={`${r.name}-${r.kind}-${j}`}
|
||||
className="rounded bg-panel px-1.5 py-0.5 text-2xs"
|
||||
className="rounded-full bg-panel px-2 py-0.5 text-2xs"
|
||||
title={r.note ?? undefined}
|
||||
>
|
||||
{r.kind} · {r.name}
|
||||
@@ -163,7 +163,7 @@ export function CodexPage({
|
||||
</Card>
|
||||
{characters.length > 0 ? (
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 font-serif text-sm text-ink">
|
||||
<h3 className="mb-3 flex items-center gap-2 font-serif text-title-md text-ink">
|
||||
<Share2 className="h-4 w-4 text-cinnabar" aria-hidden="true" />
|
||||
关系图谱
|
||||
</h3>
|
||||
@@ -184,7 +184,7 @@ export function CodexPage({
|
||||
{tab === "world" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 font-serif text-sm text-ink">
|
||||
<h3 className="mb-3 font-serif text-title-md text-ink">
|
||||
已入库世界观({initialWorldEntities.length})
|
||||
</h3>
|
||||
{initialWorldEntities.length > 0 ? (
|
||||
@@ -192,7 +192,7 @@ export function CodexPage({
|
||||
{initialWorldEntities.map((entity, i) => (
|
||||
<article
|
||||
key={`${entity.name}-${i}`}
|
||||
className="rounded border border-line bg-bg p-3 text-sm"
|
||||
className="rounded-lg border border-line bg-bg p-3 text-sm"
|
||||
>
|
||||
<header className="mb-2 flex items-center gap-2">
|
||||
<Globe2
|
||||
|
||||
@@ -217,7 +217,7 @@ export function OutlineEditor({
|
||||
) : (
|
||||
volumes.map((group) => (
|
||||
<section key={group.volume} className="mb-6">
|
||||
<h2 className="mb-2 font-serif text-base text-ink">
|
||||
<h2 className="mb-3 font-serif text-title-md text-ink">
|
||||
卷 {group.volume}
|
||||
</h2>
|
||||
<ul className="space-y-1">
|
||||
|
||||
164
apps/web/components/projects/ProjectCard.tsx
Normal file
164
apps/web/components/projects/ProjectCard.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Clock3 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { StatusDot } from "@/components/ui/StatusDot";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
formatProjectUpdatedAt,
|
||||
pendingReviewCount,
|
||||
} from "@/lib/projects/projects";
|
||||
import { cardClass, cn, focusRing } from "@/lib/ui/variants";
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: ProjectResponse;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// 缺一句话故事时的中性占位——避免卡片正文塌陷成空白(P3-2)。
|
||||
const LOGLINE_PLACEHOLDER = "还没有一句话故事,进入写作台补上一句梗概。";
|
||||
|
||||
// 书脊/封面暖色板:全部取自现有语义 token(朱砂/琥珀),仅调透明度,无硬编码 hex。
|
||||
const COVER_TINTS = [
|
||||
"bg-[var(--color-cinnabar-wash)] text-cinnabar",
|
||||
"bg-cinnabar/10 text-cinnabar",
|
||||
"bg-cinnabar/[0.16] text-cinnabar",
|
||||
"bg-overdue/10 text-overdue",
|
||||
"bg-overdue/[0.18] text-overdue",
|
||||
"bg-surface-strong text-body-strong",
|
||||
] as const;
|
||||
|
||||
// 由 id/title 确定性取暖色底色块(djb2 变体哈希,同一本书永远同一色)。
|
||||
function coverTint(seed: string): string {
|
||||
let hash = 0;
|
||||
for (const ch of seed) hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0;
|
||||
return COVER_TINTS[hash % COVER_TINTS.length] ?? COVER_TINTS[0];
|
||||
}
|
||||
|
||||
// 取书名首个字符作封面字(Array.from 保证 CJK/emoji 不被截断)。
|
||||
function coverGlyph(title: string): string {
|
||||
return Array.from(title.trim())[0] ?? "书";
|
||||
}
|
||||
|
||||
interface CoverProps {
|
||||
project: ProjectResponse;
|
||||
size: "sm" | "md";
|
||||
}
|
||||
|
||||
// 书脊色块:填补此前的空白,给每本书一个稳定的视觉锚点。
|
||||
function Cover({ project, size }: CoverProps) {
|
||||
const tint = coverTint(project.id || project.title || "");
|
||||
const box = size === "md" ? "h-12 w-12 text-title-lg" : "h-10 w-10 text-title-md";
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-md font-serif",
|
||||
box,
|
||||
tint,
|
||||
)}
|
||||
>
|
||||
{coverGlyph(project.title || "")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 作品卡(UX §6.1):书脊色块 + 书名衬线 + 一句话故事 + 元信息行。
|
||||
// M1 无字数/章数端点 → 不展示该统计(避免编造 API)。
|
||||
export function ProjectCard({ project, compact = false }: ProjectCardProps) {
|
||||
const pendingCount = pendingReviewCount(project);
|
||||
const updatedLabel = formatProjectUpdatedAt(project.updated_at);
|
||||
const title = project.title || "未命名作品";
|
||||
const logline = project.logline?.trim();
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${project.id}/write`}
|
||||
className={cardClass({
|
||||
tone: "card",
|
||||
interactive: true,
|
||||
className: cn("group flex items-center gap-4 p-4", focusRing),
|
||||
})}
|
||||
>
|
||||
<Cover project={project} size="sm" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-serif text-title-md text-ink">
|
||||
〈{title}〉
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-caption">
|
||||
<GenreBadge genre={project.genre} />
|
||||
<PendingNote count={pendingCount} />
|
||||
<span className="min-w-0 flex-1 truncate text-muted-soft">
|
||||
{logline ?? LOGLINE_PLACEHOLDER}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-1 font-mono text-2xs text-muted-soft">
|
||||
<Clock3 className="h-3 w-3" aria-hidden="true" />
|
||||
最近编辑 {updatedLabel}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="h-4 w-4 shrink-0 text-muted-soft transition-colors duration-fast ease-standard group-hover:text-cinnabar"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${project.id}/write`}
|
||||
className={cardClass({
|
||||
tone: "card",
|
||||
interactive: true,
|
||||
className: cn("group flex h-full flex-col gap-4 p-5", focusRing),
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Cover project={project} size="md" />
|
||||
<h2 className="min-w-0 flex-1 truncate font-serif text-title-lg text-ink">
|
||||
〈{title}〉
|
||||
</h2>
|
||||
<ChevronRight
|
||||
className="h-4 w-4 shrink-0 text-muted-soft transition-colors duration-fast ease-standard group-hover:text-cinnabar"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"line-clamp-2 min-h-[3rem] text-body leading-6",
|
||||
logline ? "text-body" : "text-muted-soft",
|
||||
)}
|
||||
>
|
||||
{logline ?? LOGLINE_PLACEHOLDER}
|
||||
</p>
|
||||
<div className="mt-auto flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<GenreBadge genre={project.genre} />
|
||||
<PendingNote count={pendingCount} />
|
||||
<span className="ml-auto flex items-center gap-1 font-mono text-caption text-muted-soft">
|
||||
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{updatedLabel}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// 题材徽章:有值用中性徽章,缺失给稳定占位而非空白。
|
||||
function GenreBadge({ genre }: { genre?: string | null }) {
|
||||
const value = genre?.trim();
|
||||
if (value) return <Badge>{value}</Badge>;
|
||||
return <Badge className="border-dashed text-muted-soft">未定题材</Badge>;
|
||||
}
|
||||
|
||||
// 待审提示:状态点 + 文字(不单靠颜色传达状态)。
|
||||
function PendingNote({ count }: { count: number }) {
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-caption text-ink-soft">
|
||||
<StatusDot tone="warning" label={`${count} 章待审`} />
|
||||
{count} 待审
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { ProjectCard } from "@/components/ProjectCard";
|
||||
import { ProjectCard } from "@/components/projects/ProjectCard";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ProjectSort,
|
||||
type ProjectViewMode,
|
||||
} from "@/lib/projects/projects";
|
||||
import { buttonClass, cardClass } from "@/lib/ui/variants";
|
||||
import { buttonClass, cardClass, cn, focusRing } from "@/lib/ui/variants";
|
||||
|
||||
interface ProjectLibraryProps {
|
||||
projects: ProjectResponse[];
|
||||
@@ -189,16 +189,21 @@ function NewProjectCard() {
|
||||
return (
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({
|
||||
variant: "outline",
|
||||
className: "group h-full min-h-[176px] flex-col border-dashed text-center",
|
||||
className={cardClass({
|
||||
tone: "soft",
|
||||
flat: true,
|
||||
interactive: true,
|
||||
className: cn(
|
||||
"group flex h-full min-h-[184px] flex-col items-center justify-center gap-3 border-dashed p-5 text-center",
|
||||
focusRing,
|
||||
),
|
||||
})}
|
||||
>
|
||||
<span className="mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar transition-colors group-hover:bg-cinnabar group-hover:text-panel">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar transition-colors duration-fast ease-standard group-hover:bg-cinnabar group-hover:text-panel">
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="font-serif text-xl text-cinnabar">新建</span>
|
||||
<span className="mt-2 text-sm text-ink-soft">从一句灵感开始一本书</span>
|
||||
<span className="font-serif text-title-md text-cinnabar">新建作品</span>
|
||||
<span className="text-caption text-muted-soft">从一句灵感开始一本书</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
24
apps/web/components/settings/CapabilityBadges.tsx
Normal file
24
apps/web/components/settings/CapabilityBadges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/web/components/settings/CredentialRow.tsx
Normal file
105
apps/web/components/settings/CredentialRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/web/components/settings/CredentialsPanel.tsx
Normal file
85
apps/web/components/settings/CredentialsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 id(datalist 建议,仍可自由输入)。按 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>
|
||||
);
|
||||
}
|
||||
|
||||
126
apps/web/components/settings/RoutingPanel.tsx
Normal file
126
apps/web/components/settings/RoutingPanel.tsx
Normal 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 id(datalist 建议,仍可自由输入)。按 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+model(UX §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>
|
||||
);
|
||||
}
|
||||
89
apps/web/components/settings/SettingsNav.tsx
Normal file
89
apps/web/components/settings/SettingsNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -182,7 +182,7 @@ export function TemplatesManager({ initial, tools }: TemplatesManagerProps) {
|
||||
</form>
|
||||
|
||||
<section className="flex flex-col gap-3" aria-label="模板列表">
|
||||
<h2 className="font-serif text-lg text-ink">已存模板({templates.length})</h2>
|
||||
<h2 className="font-serif text-title-md text-ink">已存模板({templates.length})</h2>
|
||||
{templates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
@@ -194,7 +194,7 @@ export function TemplatesManager({ initial, tools }: TemplatesManagerProps) {
|
||||
{templates.map((t) => (
|
||||
<li
|
||||
key={t.id}
|
||||
className="flex items-start justify-between gap-4 rounded border border-line bg-bg p-3 text-sm"
|
||||
className="flex items-start justify-between gap-4 rounded-lg border border-line bg-bg p-3 text-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-serif text-base text-ink">{t.title}</p>
|
||||
|
||||
@@ -1,38 +1,117 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
// 空态外框形态:
|
||||
// - "inline"(默认):虚线中性框,用于列表/面板内联占位(向后兼容旧默认外观)。
|
||||
// - "card":包裹在 Card(tone=soft) 内,用于独立区块的引导卡片。
|
||||
// - "bare":无外框,仅内容,交由调用方自行放置(如已在卡片里)。
|
||||
type EmptyStateVariant = "inline" | "card" | "bare";
|
||||
type EmptyStateSize = "sm" | "md";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
/** 可选眉题,置于标题上方,用于分类/上下文提示。 */
|
||||
eyebrow?: string;
|
||||
/** 外框形态,默认 "inline"。 */
|
||||
variant?: EmptyStateVariant;
|
||||
/** 留白密度,默认 "md"。 */
|
||||
size?: EmptyStateSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
const SIZE_PAD: Record<EmptyStateSize, string> = {
|
||||
sm: "px-5 py-8",
|
||||
md: "px-6 py-10",
|
||||
};
|
||||
|
||||
const SIZE_CHIP: Record<EmptyStateSize, string> = {
|
||||
sm: "h-10 w-10",
|
||||
md: "h-11 w-11",
|
||||
};
|
||||
|
||||
// 内容主体:图标片 + 眉题 + 标题 + 描述 + 动作。中性、编辑部化,留白克制。
|
||||
function EmptyStateBody({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
eyebrow,
|
||||
size,
|
||||
}: Pick<
|
||||
EmptyStateProps,
|
||||
"icon" | "title" | "description" | "action" | "eyebrow"
|
||||
> & {
|
||||
size: EmptyStateSize;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border border-dashed border-line bg-panel/70 px-6 py-10 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto mb-3 flex items-center justify-center rounded-md bg-surface-soft text-muted-soft",
|
||||
SIZE_CHIP[size],
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="font-serif text-lg text-ink">{title}</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-ink-soft">
|
||||
{eyebrow ? <Eyebrow className="mb-2">{eyebrow}</Eyebrow> : null}
|
||||
<h2 className="font-serif text-title-md text-ink">{title}</h2>
|
||||
<p className="mx-auto mt-2 max-w-sm text-caption leading-6 text-ink-soft">
|
||||
{description}
|
||||
</p>
|
||||
{action ? <div className="mt-4">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
eyebrow,
|
||||
variant = "inline",
|
||||
size = "md",
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
const body = (
|
||||
<EmptyStateBody
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
eyebrow={eyebrow}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
|
||||
if (variant === "bare") {
|
||||
return <div className={cn(SIZE_PAD[size], className)}>{body}</div>;
|
||||
}
|
||||
|
||||
if (variant === "card") {
|
||||
return (
|
||||
<Card tone="soft" flat className={cn(SIZE_PAD[size], className)}>
|
||||
{body}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-dashed border-line bg-panel/60",
|
||||
SIZE_PAD[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user