refactor(frontend): unify list and create pages with compact topbar layout

Move resource list and guide page titles into the shared topbar, introduce ListPageLayout for tighter spacing, and align knowledge edit with the prompt editor chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Eric Wang
2026-08-05 15:05:16 +08:00
parent b27efb2892
commit a9c70effae
9 changed files with 529 additions and 467 deletions

View File

@@ -241,6 +241,26 @@
padding: 0;
}
/* Resource list pages use a tighter content band below the shared topbar. */
.app-content:has([data-app-content="list"]) {
padding-top: 1rem;
padding-bottom: 1.5rem;
}
@media (min-width: 640px) {
.app-content:has([data-app-content="list"]) {
padding-top: 1.25rem;
padding-bottom: 2rem;
}
}
@media (min-width: 1024px) {
.app-content:has([data-app-content="list"]) {
padding-top: 1.5rem;
padding-bottom: 2.5rem;
}
}
.scrollbar-subtle {
scrollbar-width: thin;
scrollbar-color: color-mix(

View File

@@ -50,14 +50,20 @@ import type {
import type { RuntimeMode } from "./types";
export function EditorBackButton({ onClick }: { onClick: () => void }) {
export function EditorBackButton({
onClick,
ariaLabel = "返回助手列表",
}: {
onClick: () => void;
ariaLabel?: string;
}) {
return (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={onClick}
aria-label="返回助手列表"
aria-label={ariaLabel}
>
<ChevronLeft size={20} />
</Button>

View File

@@ -0,0 +1,80 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { PageTopbar, TopbarPortal } from "./topbar-portal";
export function ListPageLayout({
title,
description,
action,
children,
className,
}: {
title: string;
description?: ReactNode;
action?: ReactNode;
children: ReactNode;
className?: string;
}) {
const hasHeader = Boolean(description || action);
return (
<>
<TopbarPortal>
<PageTopbar title={title} />
</TopbarPortal>
<div
data-app-content="list"
className={cn(
"mx-auto flex w-full max-w-[1440px] flex-col gap-4",
className,
)}
>
{hasHeader && (
<div
className={cn(
"flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4",
description && action
? "sm:justify-between"
: action
? "sm:justify-end"
: "",
)}
>
{description && (
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
{description}
</p>
)}
{action}
</div>
)}
{children}
</div>
</>
);
}
export function ListPageSection({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<section
className={cn(
"rounded-2xl border border-hairline bg-card p-5 shadow-sm",
className,
)}
>
{children}
</section>
);
}

View File

@@ -33,10 +33,9 @@ import {
} from "@/components/ui/dropdown-menu";
import { DataList } from "@/components/ui/data-list";
import {
PageTopbar,
TopbarPortal,
} from "@/components/layout/topbar-portal";
import { PageHeader } from "@/components/ui/page-header";
ListPageLayout,
ListPageSection,
} from "@/components/layout/list-page-layout";
import { FilterPills } from "@/components/ui/filter-pills";
import { SearchInput } from "@/components/ui/search-input";
import { ListToolbar } from "@/components/ui/list-toolbar";
@@ -538,9 +537,10 @@ export function AssistantPage(props: AssistantPageProps) {
}
// 删除助手
async function handleDelete(id: string) {
async function handleDelete(assistant: AssistantListItem) {
if (!window.confirm(`确认删除助手“${assistant.name}”?`)) return;
try {
await assistantsApi.remove(id);
await assistantsApi.remove(assistant.id);
await loadAssistants();
} catch (error) {
setListError(error instanceof Error ? error.message : "删除失败");
@@ -997,23 +997,17 @@ export function AssistantPage(props: AssistantPageProps) {
if (view === "list") {
return (
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
<PageHeader
title="助手列表"
description="管理已有的视频助手支持提示词、工作流、Dify 和 FastGPT 类型。"
action={
<Button
size="lg"
className="w-full gap-2 sm:w-auto"
onClick={startCreate}
>
<Plus size={16} />
</Button>
}
/>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<ListPageLayout
title="助手列表"
description="管理已有的视频助手支持提示词、工作流、Dify 和 FastGPT 类型。"
action={
<Button className="w-full shrink-0 gap-2 sm:w-auto" onClick={startCreate}>
<Plus size={16} />
</Button>
}
>
<ListPageSection>
<ListToolbar
filters={
<FilterPills
@@ -1039,6 +1033,7 @@ export function AssistantPage(props: AssistantPageProps) {
loadingText="正在加载助手列表…"
error={listError}
onRetry={() => void loadAssistants()}
onRowClick={handleEdit}
empty={{
title: listItems.length === 0 ? "暂无助手" : "未找到匹配的助手",
description:
@@ -1116,12 +1111,19 @@ export function AssistantPage(props: AssistantPageProps) {
align: "right",
cellClassName: "flex justify-end gap-2",
cell: (assistant) => (
<>
<div
className="flex justify-end gap-2"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
onClick={() => handleEdit(assistant)}
onClick={(event) => {
event.stopPropagation();
handleEdit(assistant);
}}
>
<Pencil size={14} />
@@ -1134,6 +1136,7 @@ export function AssistantPage(props: AssistantPageProps) {
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
aria-label={`${assistant.name} 更多操作`}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal size={15} />
</Button>
@@ -1154,7 +1157,10 @@ export function AssistantPage(props: AssistantPageProps) {
className="rounded-lg"
onSelect={(event) => {
event.preventDefault();
void handleDelete(assistant.id);
window.setTimeout(
() => void handleDelete(assistant),
0,
);
}}
>
<Trash2 size={14} />
@@ -1162,59 +1168,52 @@ export function AssistantPage(props: AssistantPageProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
</div>
),
},
]}
/>
</section>
</div>
</ListPageSection>
</ListPageLayout>
);
}
if (view === "choose") {
return (
<>
<TopbarPortal>
<PageTopbar
title="创建助手"
action={
<Button
variant="outline"
className="gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => router.push("/assistants")}
>
<ChevronLeft size={16} />
</Button>
}
/>
</TopbarPortal>
<ListPageLayout
title="创建助手"
className="max-w-[1180px]"
description="先为助手取个名字,再选择构建方式。确认后将立即创建助手并进入编辑页。"
action={
<Button
variant="outline"
className="w-full shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground sm:w-auto"
onClick={() => router.push("/assistants")}
>
<ChevronLeft size={16} />
</Button>
}
>
<ListPageSection>
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">
</div>
<Input
value={draftName}
autoFocus
onChange={(event) => setDraftName(event.target.value)}
placeholder="请输入助手名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
</ListPageSection>
<div className="mx-auto flex w-full max-w-[1180px] flex-col gap-8">
<p className="max-w-2xl text-[15px] leading-7 text-muted-foreground">
</p>
<section className="flex flex-col gap-3">
<div className="text-sm font-medium text-foreground"></div>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">
</div>
<Input
value={draftName}
autoFocus
onChange={(event) => setDraftName(event.target.value)}
placeholder="请输入助手名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
</section>
<section className="flex flex-col gap-4">
<div className="text-sm font-medium text-foreground"></div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{assistantTypeOptions.map((option) => {
const selected = draftType === option.type;
@@ -1264,21 +1263,19 @@ export function AssistantPage(props: AssistantPageProps) {
</div>
</section>
<div className="flex items-center justify-end gap-3">
{createError && (
<span className="text-xs text-destructive">{createError}</span>
)}
<Button
variant="outline"
size="lg"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={creating}
onClick={() => router.push("/assistants")}
>
</Button>
<div className="flex items-center justify-end gap-3">
{createError && (
<span className="text-xs text-destructive">{createError}</span>
)}
<Button
variant="outline"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={creating}
onClick={() => router.push("/assistants")}
>
</Button>
<Button
size="lg"
className="gap-2"
disabled={!draftName.trim() || !draftType || creating}
onClick={() => void confirmType()}
@@ -1290,9 +1287,8 @@ export function AssistantPage(props: AssistantPageProps) {
)}
</Button>
</div>
</div>
</>
</ListPageLayout>
);
}

View File

@@ -22,7 +22,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DataList } from "@/components/ui/data-list";
import {
Dialog,
@@ -40,7 +39,17 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ListToolbar } from "@/components/ui/list-toolbar";
import { PageHeader } from "@/components/ui/page-header";
import {
AssistantIdentity,
EditorBackButton,
EditableTitle,
} from "@/components/assistant-editor/editor-controls";
import { SectionCard } from "@/components/editor/section-card";
import {
ListPageLayout,
ListPageSection,
} from "@/components/layout/list-page-layout";
import { TopbarPortal } from "@/components/layout/topbar-portal";
import { SearchInput } from "@/components/ui/search-input";
import {
Select,
@@ -196,23 +205,20 @@ function KnowledgeListView() {
}
return (
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
<PageHeader
title="知识库"
description="管理可检索的文件与文字资料。"
action={
<Button
size="lg"
className="w-full gap-2 sm:w-auto"
onClick={() => router.push("/components/knowledge/new")}
>
<Plus size={16} />
</Button>
}
/>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<ListPageLayout
title="知识库"
description="管理可检索的文件与文字资料。"
action={
<Button
className="w-full shrink-0 gap-2 sm:w-auto"
onClick={() => router.push("/components/knowledge/new")}
>
<Plus size={16} />
</Button>
}
>
<ListPageSection>
<ListToolbar
className="lg:justify-end"
search={
@@ -375,8 +381,8 @@ function KnowledgeListView() {
},
]}
/>
</section>
</div>
</ListPageSection>
</ListPageLayout>
);
}
@@ -425,26 +431,21 @@ function KnowledgeCreateView() {
}
return (
<div className="mx-auto flex w-full max-w-[1180px] flex-col gap-8">
<div className="flex items-start justify-between gap-6">
<div>
<h1 className="font-display display-lg text-ink"></h1>
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
Embedding
</p>
</div>
<ListPageLayout
title="添加知识库"
className="max-w-[1180px]"
description="填写名称、说明并选择 Embedding 模型。确认后将立即创建并进入编辑页。"
action={
<Button
variant="outline"
size="lg"
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
className="w-full shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground sm:w-auto"
onClick={() => router.push("/components/knowledge")}
>
<ChevronLeft size={16} />
</Button>
</div>
}
>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
@@ -452,57 +453,65 @@ function KnowledgeCreateView() {
</div>
) : (
<>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<div className="space-y-5">
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">
</div>
<Input
value={name}
autoFocus
onChange={(event) => setName(event.target.value)}
placeholder="请输入知识库名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">
</div>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="用途说明(可选)"
rows={4}
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
<div>
<div className="mb-2 text-sm font-medium text-foreground">
Embedding
</div>
<Select value={embeddingId} onValueChange={setEmbeddingId}>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue placeholder="选择 Embedding 模型" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
{models.length === 0 && (
<p className="mt-2 text-xs text-muted-foreground">
Embedding
</p>
)}
<ListPageSection>
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">
</div>
<Input
value={name}
autoFocus
onChange={(event) => setName(event.target.value)}
placeholder="请输入知识库名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
</ListPageSection>
<section className="flex flex-col gap-3">
<div className="text-sm font-medium text-foreground">
Embedding
</div>
<ListPageSection>
<div className="space-y-5">
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">
</div>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="用途说明(可选)"
rows={4}
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
<div>
<div className="mb-2 text-sm font-medium text-foreground">
Embedding
</div>
<Select value={embeddingId} onValueChange={setEmbeddingId}>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue placeholder="选择 Embedding 模型" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
{models.length === 0 && (
<p className="mt-2 text-xs text-muted-foreground">
Embedding
</p>
)}
</div>
</div>
</ListPageSection>
</section>
<div className="flex items-center justify-end gap-3">
@@ -511,7 +520,6 @@ function KnowledgeCreateView() {
)}
<Button
variant="outline"
size="lg"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={creating}
onClick={() => router.push("/components/knowledge")}
@@ -519,7 +527,6 @@ function KnowledgeCreateView() {
</Button>
<Button
size="lg"
className="gap-2"
disabled={!name.trim() || !embeddingId || creating}
onClick={() => void confirmCreate()}
@@ -534,7 +541,7 @@ function KnowledgeCreateView() {
</div>
</>
)}
</div>
</ListPageLayout>
);
}
@@ -774,206 +781,219 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
}
return (
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-6">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3">
<div className="flex min-w-0 items-center gap-2">
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => router.push("/components/knowledge")}
aria-label="返回知识库列表"
>
<ChevronLeft size={20} />
</Button>
<EditableTitle value={base.name} onChange={(value) => void renameBase(value)} />
</div>
<>
<TopbarPortal>
<div className="flex h-full min-w-0 flex-1 items-center justify-between gap-4 sm:-ml-2 lg:-ml-4">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton
ariaLabel="返回知识库列表"
onClick={() => router.push("/components/knowledge")}
/>
<EditableTitle
value={base.name}
onChange={(value) => void renameBase(value)}
placeholder="未命名知识库"
editLabel="知识库名称"
/>
<AssistantIdentity assistantId={knowledgeBaseId} />
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => setSearchOpen(true)}
>
<Search size={14} />
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={openSettings}
>
<Settings size={14} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className="gap-1.5" disabled={busy}>
<Upload size={14} />
{busy ? "上传中" : "上传"}
<ChevronDown size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-36 rounded-xl border border-hairline bg-popover p-1"
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{error && (
<span
role="alert"
title={error}
className="line-clamp-1 max-w-[min(32vw,420px)] text-right text-xs text-destructive"
>
{error}
</span>
)}
<Button
variant="outline"
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
onClick={() => setSearchOpen(true)}
>
<DropdownMenuItem
className="rounded-lg"
onSelect={() => fileInputRef.current?.click()}
<Search size={16} />
</Button>
<Button
variant="outline"
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
onClick={openSettings}
>
<Settings size={16} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="gap-2" disabled={busy}>
<Upload size={16} />
{busy ? "上传中" : "上传"}
<ChevronDown size={14} className="opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-36 rounded-xl border border-hairline bg-popover p-1"
>
<Upload size={14} />
</DropdownMenuItem>
<DropdownMenuItem
className="rounded-lg"
onSelect={() => setTextOpen(true)}
>
<FileText size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<input
ref={fileInputRef}
hidden
type="file"
accept=".pdf,.docx,.txt,.md,.csv,.json,.html,.htm"
disabled={busy}
onChange={(event) => {
void addFile(event.target.files?.[0]);
event.target.value = "";
}}
/>
<DropdownMenuItem
className="rounded-lg"
onSelect={() => fileInputRef.current?.click()}
>
<Upload size={14} />
</DropdownMenuItem>
<DropdownMenuItem
className="rounded-lg"
onSelect={() => setTextOpen(true)}
>
<FileText size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</TopbarPortal>
<div
data-app-content="full-bleed"
className="flex h-full min-h-0 overflow-hidden bg-background"
>
<div className="scrollbar-subtle min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none px-4 pb-6 pt-3 sm:px-6 sm:pb-8 lg:px-8 lg:pb-10">
<div className="mx-auto max-w-7xl space-y-3">
<SectionCard
icon={<FileText size={15} />}
title="文档"
description="上传文件或文本,系统将自动切分并生成向量。"
>
<div className="relative">
<Search
className="absolute left-3 top-2.5 text-muted-foreground"
size={15}
/>
<Input
className="border-hairline-strong bg-background pl-9"
placeholder="搜索文档名称"
value={documentQuery}
onChange={(event) => setDocumentQuery(event.target.value)}
/>
</div>
<div className="space-y-2">
{filteredDocuments.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">
</p>
)}
{filteredDocuments.map((document) => {
const canPreview = document.status === "ready";
return (
<div
key={document.id}
role={canPreview ? "button" : undefined}
tabIndex={canPreview ? 0 : undefined}
onClick={() => {
if (canPreview) void previewDocument(document);
}}
onKeyDown={(event) => {
if (
canPreview &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
void previewDocument(document);
}
}}
className={`rounded-xl border border-hairline p-3 transition-colors ${
canPreview
? "cursor-pointer hover:bg-surface-strong/40"
: ""
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">
{document.name}
</span>
<span
className={`rounded-full px-2 py-0.5 text-xs ${statusClass(document.status)}`}
>
{statusLabel(document.status)}
</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{document.sourceType === "file" ? "文件" : "文字"} ·{" "}
{formatBytes(document.sizeBytes)} ·{" "}
{document.chunkCount}
</div>
</div>
<div
className="flex shrink-0 gap-1"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{document.status === "failed" && (
<Button
variant="ghost"
size="icon"
title="重新处理"
onClick={() => void retryDocument(document.id)}
>
<RefreshCw size={15} />
</Button>
)}
{canPreview && (
<Button
variant="ghost"
size="icon"
title="查看分块"
onClick={() => void previewDocument(document)}
>
<Eye size={15} />
</Button>
)}
<Button
variant="ghost"
size="icon"
title="删除"
onClick={() => void removeDocument(document)}
>
<Trash2 size={15} />
</Button>
</div>
</div>
{document.status === "processing" && (
<div className="mt-2 flex items-center gap-2 text-xs text-amber-600">
<Loader2 className="animate-spin" size={13} />
</div>
)}
{document.errorMessage && (
<p className="mt-2 rounded bg-destructive/5 p-2 text-xs text-destructive">
{document.errorMessage}
</p>
)}
</div>
);
})}
</div>
</SectionCard>
</div>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</div>
)}
<Card className="rounded-2xl border-hairline bg-card shadow-sm">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<div className="relative mb-4">
<Search
className="absolute left-3 top-2.5 text-muted-foreground"
size={15}
/>
<Input
className="border-hairline-strong bg-background pl-9"
placeholder="搜索文档名称"
value={documentQuery}
onChange={(event) => setDocumentQuery(event.target.value)}
/>
</div>
<div className="space-y-2">
{filteredDocuments.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">
</p>
)}
{filteredDocuments.map((document) => {
const canPreview = document.status === "ready";
return (
<div
key={document.id}
role={canPreview ? "button" : undefined}
tabIndex={canPreview ? 0 : undefined}
onClick={() => {
if (canPreview) void previewDocument(document);
}}
onKeyDown={(event) => {
if (
canPreview &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
void previewDocument(document);
}
}}
className={`rounded-xl border border-hairline p-3 transition-colors ${
canPreview
? "cursor-pointer hover:bg-surface-strong/40"
: ""
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">
{document.name}
</span>
<span
className={`rounded-full px-2 py-0.5 text-xs ${statusClass(document.status)}`}
>
{statusLabel(document.status)}
</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{document.sourceType === "file" ? "文件" : "文字"} ·{" "}
{formatBytes(document.sizeBytes)} ·{" "}
{document.chunkCount}
</div>
</div>
<div
className="flex shrink-0 gap-1"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{document.status === "failed" && (
<Button
variant="ghost"
size="icon"
title="重新处理"
onClick={() => void retryDocument(document.id)}
>
<RefreshCw size={15} />
</Button>
)}
{canPreview && (
<Button
variant="ghost"
size="icon"
title="查看分块"
onClick={() => void previewDocument(document)}
>
<Eye size={15} />
</Button>
)}
<Button
variant="ghost"
size="icon"
title="删除"
onClick={() => void removeDocument(document)}
>
<Trash2 size={15} />
</Button>
</div>
</div>
{document.status === "processing" && (
<div className="mt-2 flex items-center gap-2 text-xs text-amber-600">
<Loader2 className="animate-spin" size={13} />
</div>
)}
{document.errorMessage && (
<p className="mt-2 rounded bg-destructive/5 p-2 text-xs text-destructive">
{document.errorMessage}
</p>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
<input
ref={fileInputRef}
hidden
type="file"
accept=".pdf,.docx,.txt,.md,.csv,.json,.html,.htm"
disabled={busy}
onChange={(event) => {
void addFile(event.target.files?.[0]);
event.target.value = "";
}}
/>
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-xl">
@@ -1152,75 +1172,7 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
</div>
</DialogContent>
</Dialog>
</div>
);
}
function EditableTitle({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
function startEdit() {
setDraft(value);
setEditing(true);
}
function commit() {
const next = draft.trim();
if (next) onChange(next);
setEditing(false);
}
if (editing) {
return (
<input
ref={inputRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commit();
} else if (event.key === "Escape") {
event.preventDefault();
setEditing(false);
}
}}
className="font-display display-sm w-[min(60vw,420px)] border-b border-primary bg-transparent text-ink outline-none"
/>
);
}
return (
<button
type="button"
onClick={startEdit}
title="点击修改知识库名称"
className="group -mx-2 flex min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-left transition-colors hover:bg-surface-strong"
>
<span className="font-display display-sm truncate text-ink">
{value || "未命名知识库"}
</span>
<Pencil
size={16}
className="shrink-0 text-muted-soft opacity-0 transition-opacity group-hover:opacity-100"
/>
</button>
</>
);
}

View File

@@ -33,7 +33,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { DataList } from "@/components/ui/data-list";
import { PageHeader } from "@/components/ui/page-header";
import {
ListPageLayout,
ListPageSection,
} from "@/components/layout/list-page-layout";
import { FilterPills } from "@/components/ui/filter-pills";
import { SearchInput } from "@/components/ui/search-input";
import { ListToolbar } from "@/components/ui/list-toolbar";
@@ -495,23 +498,18 @@ export function ComponentsModelsPage() {
}
return (
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
<PageHeader
<>
<ListPageLayout
title="模型资源"
description="接口定义决定能力、鉴权字段和调用参数,表单会随接口类型动态更新。"
action={
<Button
size="lg"
className="w-full gap-2 sm:w-auto"
onClick={openCreate}
>
<Button className="w-full shrink-0 gap-2 sm:w-auto" onClick={openCreate}>
<Plus size={16} />
</Button>
}
/>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
>
<ListPageSection>
<ListToolbar
filters={
<FilterPills
@@ -702,7 +700,8 @@ export function ComponentsModelsPage() {
},
]}
/>
</section>
</ListPageSection>
</ListPageLayout>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
@@ -895,7 +894,7 @@ export function ComponentsModelsPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
);
}

View File

@@ -40,7 +40,10 @@ import {
import { Input } from "@/components/ui/input";
import { FilterPills } from "@/components/ui/filter-pills";
import { ListToolbar } from "@/components/ui/list-toolbar";
import { PageHeader } from "@/components/ui/page-header";
import {
ListPageLayout,
ListPageSection,
} from "@/components/layout/list-page-layout";
import { SearchInput } from "@/components/ui/search-input";
import {
Select,
@@ -689,14 +692,14 @@ export function ComponentsToolsPage() {
];
return (
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
<PageHeader
<>
<ListPageLayout
title="工具资源"
description="管理可复用的助手工具,并将启用的工具绑定到提示词助手。"
action={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="lg" className="w-full gap-2 sm:w-auto">
<Button className="w-full shrink-0 gap-2 sm:w-auto">
<Plus size={16} />
<ChevronDown size={14} className="opacity-70" />
@@ -710,16 +713,18 @@ export function ComponentsToolsPage() {
<Plus size={15} />
</DropdownMenuItem>
<DropdownMenuItem className="rounded-lg" onSelect={openCreateMcpServer}>
<DropdownMenuItem
className="rounded-lg"
onSelect={openCreateMcpServer}
>
<ServerCog size={15} />
MCP
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
>
<ListPageSection>
<ListToolbar
filters={
<FilterPills options={toolFilters} value={filter} onChange={changeFilter} />
@@ -758,7 +763,8 @@ export function ComponentsToolsPage() {
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredResources.length)} / 共 ${filteredResources.length} 个工具资源`,
}}
/>
</section>
</ListPageSection>
</ListPageLayout>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className={TOOL_DIALOG_CONTENT_CLASS}>
@@ -866,7 +872,7 @@ export function ComponentsToolsPage() {
onOpenChange={setMcpDialogOpen}
onChanged={loadResources}
/>
</div>
</>
);
}

View File

@@ -31,7 +31,10 @@ import {
} from "@/components/ui/dialog";
import { FilterPills } from "@/components/ui/filter-pills";
import { ListToolbar } from "@/components/ui/list-toolbar";
import { PageHeader } from "@/components/ui/page-header";
import {
ListPageLayout,
ListPageSection,
} from "@/components/layout/list-page-layout";
import { SearchInput } from "@/components/ui/search-input";
import {
conversationsApi,
@@ -364,13 +367,12 @@ export function HistoryPage() {
const pageEnd = pageStart + rows.length;
return (
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
<PageHeader
<>
<ListPageLayout
title="历史记录"
description="查看每次语音或文字会话中最终确认的用户转写和助手回复。"
/>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
>
<ListPageSection>
<ListToolbar
filters={
<FilterPills
@@ -417,7 +419,8 @@ export function HistoryPage() {
: `显示 ${pageStart + 1}-${Math.min(pageEnd, total)} / 共 ${total} 次会话`,
}}
/>
</section>
</ListPageSection>
</ListPageLayout>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[calc(100vh-3rem)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-4xl">
@@ -522,7 +525,7 @@ export function HistoryPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
);
}

View File

@@ -15,7 +15,7 @@ export function ListToolbar({ filters, search, className }: ListToolbarProps) {
return (
<div
className={cn(
"mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between",
"mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between",
className,
)}
>