Refactor frontend routing and component structure for improved navigation

- Update CLAUDE.md to reflect changes in the navigation model, emphasizing the use of App Router routes for sidebar sections.
- Refactor layout.tsx to wrap children in AppShell, enhancing the overall layout structure.
- Replace AppShell usage in page.tsx with HomePage component for better separation of concerns.
- Introduce new pages for assistants, components, dashboard, history, profile, and test, each rendering their respective components.
- Revise Sidebar component to utilize Next.js Link for navigation and improve active state handling based on the current pathname.
- Update AssistantPage to support routing-driven modes (list, choose, edit) and streamline form handling for assistant creation and editing.
This commit is contained in:
Xin Wang
2026-06-10 14:39:52 +08:00
parent 0adb3ed8a1
commit b711350c0c
17 changed files with 424 additions and 355 deletions

View File

@@ -14,13 +14,24 @@ npm run lint # ESLint (no test suite exists yet)
## Architecture
This is a single-page admin console for managing AI video assistants. It is a Next.js 16 app using the App Router, React 19, Tailwind CSS v4, and shadcn components backed by Radix UI primitives.
This is an admin console for managing AI video assistants. It is a Next.js 16 app using the App Router, React 19, Tailwind CSS v4, and shadcn components backed by Radix UI primitives.
**Navigation model**the app has no Next.js routes beyond `/`. All "pages" are React components in `src/components/pages/` that are conditionally rendered by `AppShell` based on a `NavKey` state value. `AppShell` owns the active page and sidebar-collapsed state and threads them down as props.
**Navigation model**each sidebar section is a real App Router route, so refresh/deep-link lands on the same page. Route files in `src/app/` are thin server components that render the page components from `src/components/pages/`. The route map:
| Route | Page |
| --- | --- |
| `/` | `HomePage` |
| `/assistants` | `AssistantPage mode="list"` (助手列表) |
| `/assistants/new` | `AssistantPage mode="choose"` (引导:取名+选构建方式,确认即 POST 建库并跳转编辑页) |
| `/assistants/[id]` | `AssistantPage mode="edit"` (按 id 拉取并按类型回填编辑器) |
| `/components/{models,knowledge,tools}` | 组件库三页 |
| `/history`, `/dashboard`, `/test`, `/profile` | 其余侧栏页 |
`AssistantPage` is one client component driven by a discriminated-union `mode` prop; all in-page transitions (创建/编辑/返回) navigate via `router.push` instead of local view state. There is no separate create form: the 引导 page creates the assistant immediately (blank fields + chosen name/type) and the editor always works against an existing id. 保存 stays on the editor page — the save button is enabled only when the form differs from the last-saved snapshot (`savedSnapshot` JSON diff), and the header back button returns to the list.
**Component layers:**
- `src/app/` — Next.js entry: `layout.tsx` (fonts, theme-flash script, metadata) and `page.tsx` (renders `<AppShell />`)
- `src/components/layout/``AppShell` (page-switching shell), `Sidebar` (collapsible nav, 252 → 76px), `Topbar` (theme toggle, notifications), `ThemeToggle`
- `src/app/` — Next.js entry: `layout.tsx` (fonts, theme-flash script, metadata, wraps everything in `AppShell`) plus one thin `page.tsx` per route
- `src/components/layout/``AppShell` (sidebar+topbar shell around route children, owns sidebar-collapsed state), `Sidebar` (collapsible nav, 252 → 76px; `Link`-based, active state from `usePathname`), `Topbar` (theme toggle, notifications), `ThemeToggle`
- `src/components/pages/` — one component per nav section; `PlaceholderPage` is a shared editorial header for unimplemented pages
- `src/components/ui/` — shadcn primitives (button, card, badge, dialog, etc.)
- `src/hooks/``use-mobile.ts`

View File

@@ -0,0 +1,10 @@
import { AssistantPage } from "@/components/pages/AssistantPage";
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <AssistantPage mode="edit" assistantId={id} />;
}

View File

@@ -0,0 +1,5 @@
import { AssistantPage } from "@/components/pages/AssistantPage";
export default function Page() {
return <AssistantPage mode="choose" />;
}

View File

@@ -0,0 +1,5 @@
import { AssistantPage } from "@/components/pages/AssistantPage";
export default function Page() {
return <AssistantPage mode="list" />;
}

View File

@@ -0,0 +1,5 @@
import { ComponentsKnowledgePage } from "@/components/pages/ComponentsKnowledgePage";
export default function Page() {
return <ComponentsKnowledgePage />;
}

View File

@@ -0,0 +1,5 @@
import { ComponentsModelsPage } from "@/components/pages/ComponentsModelsPage";
export default function Page() {
return <ComponentsModelsPage />;
}

View File

@@ -0,0 +1,5 @@
import { ComponentsToolsPage } from "@/components/pages/ComponentsToolsPage";
export default function Page() {
return <ComponentsToolsPage />;
}

View File

@@ -0,0 +1,5 @@
import { DashboardPage } from "@/components/pages/DashboardPage";
export default function Page() {
return <DashboardPage />;
}

View File

@@ -0,0 +1,5 @@
import { HistoryPage } from "@/components/pages/HistoryPage";
export default function Page() {
return <HistoryPage />;
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist_Mono, Inter, Cormorant_Garamond } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
import { AppShell } from "@/components/layout/AppShell";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
@@ -48,7 +49,9 @@ export default function RootLayout({
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<AppShell>{children}</AppShell>
</body>
</html>
);
}

View File

@@ -1,5 +1,5 @@
import { AppShell } from "@/components/layout/AppShell";
import { HomePage } from "@/components/pages/HomePage";
export default function Home() {
return <AppShell />;
}
return <HomePage />;
}

View File

@@ -0,0 +1,5 @@
import { ProfilePage } from "@/components/pages/ProfilePage";
export default function Page() {
return <ProfilePage />;
}

View File

@@ -0,0 +1,5 @@
import { TestPage } from "@/components/pages/TestPage";
export default function Page() {
return <TestPage />;
}

View File

@@ -4,56 +4,18 @@ import { useState } from "react";
import { Sidebar } from "./Sidebar";
import { Topbar } from "./Topbar";
import { HomePage } from "@/components/pages/HomePage";
import { AssistantPage } from "@/components/pages/AssistantPage";
import { ComponentsModelsPage } from "@/components/pages/ComponentsModelsPage";
import { ComponentsKnowledgePage } from "@/components/pages/ComponentsKnowledgePage";
import { ComponentsToolsPage } from "@/components/pages/ComponentsToolsPage";
import { HistoryPage } from "@/components/pages/HistoryPage";
import { DashboardPage } from "@/components/pages/DashboardPage";
import { TestPage } from "@/components/pages/TestPage";
import { ProfilePage } from "@/components/pages/ProfilePage";
export type NavKey =
| "home"
| "assistants"
| "components-models"
| "components-knowledge"
| "components-tools"
| "history"
| "dashboard"
| "test"
| "profile";
export function AppShell() {
const [active, setActive] = useState<NavKey>("home");
export function AppShell({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background text-foreground">
<Sidebar
active={active}
collapsed={collapsed}
onNavigate={setActive}
onToggle={() => setCollapsed((v) => !v)}
/>
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed((v) => !v)} />
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Topbar />
<div className="flex-1 overflow-y-auto px-8 py-10">
{active === "home" && <HomePage onNavigate={setActive} />}
{active === "assistants" && <AssistantPage />}
{active === "components-models" && <ComponentsModelsPage />}
{active === "components-knowledge" && <ComponentsKnowledgePage />}
{active === "components-tools" && <ComponentsToolsPage />}
{active === "history" && <HistoryPage />}
{active === "dashboard" && <DashboardPage />}
{active === "test" && <TestPage />}
{active === "profile" && <ProfilePage />}
</div>
<div className="flex-1 overflow-y-auto px-8 py-10">{children}</div>
</main>
</div>
);
}
}

View File

@@ -1,11 +1,12 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Bot,
Boxes,
Brain,
ChevronLeft,
ChevronRight,
Clock3,
Database,
Wrench,
@@ -13,55 +14,38 @@ import {
PlayCircle,
Video,
} from "lucide-react";
import type { NavKey } from "./AppShell";
type SidebarProps = {
active: NavKey;
collapsed: boolean;
onNavigate: (key: NavKey) => void;
onToggle: () => void;
};
const mainItems: Array<{
key: NavKey;
type NavItem = {
href: string;
label: string;
icon: React.ComponentType<{ size?: number; className?: string }>;
}> = [
{ key: "home", label: "首页", icon: Home },
{ key: "test", label: "测试助手", icon: PlayCircle },
};
const componentSubItems: NavItem[] = [
{ href: "/components/models", label: "模型资源", icon: Brain },
{ href: "/components/knowledge", label: "知识库", icon: Database },
{ href: "/components/tools", label: "工具资源", icon: Wrench },
];
const componentSubItems: Array<{
key: NavKey;
label: string;
icon: React.ComponentType<{ size?: number; className?: string }>;
}> = [
{ key: "components-models", label: "模型资源", icon: Brain },
{ key: "components-knowledge", label: "知识库", icon: Database },
{ key: "components-tools", label: "工具资源", icon: Wrench },
const monitorSubItems: NavItem[] = [
{ href: "/history", label: "历史记录", icon: Clock3 },
{ href: "/dashboard", label: "数据看板", icon: Database },
];
const monitorSubItems: Array<{
key: NavKey;
label: string;
icon: React.ComponentType<{ size?: number; className?: string }>;
}> = [
{ key: "history", label: "历史记录", icon: Clock3 },
{ key: "dashboard", label: "数据看板", icon: Database },
];
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
const pathname = usePathname();
export function Sidebar({
active,
collapsed,
onNavigate,
onToggle,
}: SidebarProps) {
const assistantActive = active === "assistants";
// 精确匹配或前缀匹配(带 / 边界),让 /assistants/xxx 也高亮"创建助手"
const isActive = (href: string) =>
pathname === href || pathname.startsWith(`${href}/`);
const componentActive =
active === "components-models" || active === "components-knowledge" || active === "components-tools";
const monitorActive = monitorSubItems.some((item) => item.key === active);
const componentActive = componentSubItems.some((item) => isActive(item.href));
const monitorActive = monitorSubItems.some((item) => isActive(item.href));
return (
<aside
@@ -97,19 +81,19 @@ export function Sidebar({
<nav className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-5 pr-2 [scrollbar-width:thin] [scrollbar-color:var(--hairline-strong)_transparent]">
<div className="space-y-1">
<NavButton
active={active === "home"}
active={pathname === "/"}
collapsed={collapsed}
icon={Home}
label="首页"
onClick={() => onNavigate("home")}
href="/"
/>
<div className="pt-2">
<NavButton
active={assistantActive}
active={isActive("/assistants")}
collapsed={collapsed}
icon={Bot}
label="创建助手"
onClick={() => onNavigate("assistants")}
href="/assistants"
/>
</div>
@@ -142,12 +126,12 @@ export function Sidebar({
>
{componentSubItems.map((item) => (
<NavButton
key={item.key}
active={active === item.key}
key={item.href}
active={isActive(item.href)}
collapsed={collapsed}
icon={item.icon}
label={item.label}
onClick={() => onNavigate(item.key)}
href={item.href}
small
/>
))}
@@ -184,28 +168,25 @@ export function Sidebar({
>
{monitorSubItems.map((item) => (
<NavButton
key={item.key}
active={active === item.key}
key={item.href}
active={isActive(item.href)}
collapsed={collapsed}
icon={item.icon}
label={item.label}
onClick={() => onNavigate(item.key)}
href={item.href}
/>
))}
</div>
</div>
<div className="pt-2">
{mainItems.slice(1).map((item) => (
<NavButton
key={item.key}
active={active === item.key}
collapsed={collapsed}
icon={item.icon}
label={item.label}
onClick={() => onNavigate(item.key)}
/>
))}
<NavButton
active={isActive("/test")}
collapsed={collapsed}
icon={PlayCircle}
label="测试助手"
href="/test"
/>
</div>
</div>
@@ -213,12 +194,12 @@ export function Sidebar({
<div className="shrink-0 space-y-2 border-t border-sidebar-border bg-sidebar p-3 shadow-[0_-12px_24px_rgba(0,0,0,0.12)]">
{/* 个人中心 */}
<button
onClick={() => onNavigate("profile")}
<Link
href="/profile"
title={collapsed ? "个人中心 · 管理员" : undefined}
className={[
"group relative flex w-full items-center overflow-hidden rounded-2xl border py-2 text-left transition-[background-color,color,border-color,box-shadow,transform] duration-200 active:scale-[0.98]",
active === "profile"
isActive("/profile")
? "border-sidebar-border bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "border-transparent text-muted-foreground hover:border-sidebar-border hover:bg-sidebar-accent/60 hover:text-foreground hover:shadow-sm",
collapsed ? "justify-center gap-0 px-0" : "gap-3 px-2.5",
@@ -251,7 +232,7 @@ export function Sidebar({
</span>
</span>
</button>
</Link>
{/* 收起 / 展开侧栏 */}
<button
@@ -288,19 +269,19 @@ function NavButton({
collapsed,
icon: Icon,
label,
onClick,
href,
small = false,
}: {
active: boolean;
collapsed: boolean;
icon: React.ComponentType<{ size?: number; className?: string }>;
label: string;
onClick: () => void;
href: string;
small?: boolean;
}) {
return (
<button
onClick={onClick}
<Link
href={href}
title={collapsed ? label : undefined}
className={[
"group mt-1 flex w-full items-center overflow-hidden rounded-full text-sm transition-[background-color,color,transform] duration-200 active:scale-[0.98]",
@@ -323,6 +304,6 @@ function NavButton({
>
{label}
</span>
</button>
</Link>
);
}

View File

@@ -67,6 +67,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
assistantsApi,
credentialsApi,
@@ -142,6 +143,83 @@ const typeToLabel: Record<ApiAssistantType, AssistantType> = {
fastgpt: "FastGPT",
opencode: "OpenCode",
};
const typeFromLabel: Record<AssistantType, ApiAssistantType> = {
: "prompt",
: "workflow",
Dify: "dify",
FastGPT: "fastgpt",
OpenCode: "opencode",
};
// 后端 type → 编辑器视图(工作流暂为占位页)
const typeToView = {
prompt: "create",
dify: "create-dify",
fastgpt: "create-fastgpt",
opencode: "create-opencode",
workflow: "placeholder",
} as const;
type View = "list" | "choose" | "loading" | (typeof typeToView)[ApiAssistantType];
// 路由驱动的页面模式:
// /assistants → list | /assistants/new → choose(引导,确认即建库) | /assistants/[id] → edit
export type AssistantPageProps =
| { mode: "list" }
| { mode: "choose" }
| { mode: "edit"; assistantId: string };
// 各类型的空白表单模板(新建用)
function blankPromptForm(name: string): AssistantForm {
return {
name,
greeting: "",
prompt: "",
runtimeMode: "pipeline",
realtimeModel: "",
model: "",
asr: "",
voice: "",
knowledgeBase: "",
enableInterrupt: true,
};
}
function blankFastGptForm(name: string): FastGptForm {
return {
name,
appId: "",
apiUrl: "",
apiKey: "",
asr: "",
voice: "",
enableInterrupt: true,
};
}
function blankDifyForm(name: string): DifyForm {
return {
name,
apiUrl: "",
apiKey: "",
asr: "",
voice: "",
enableInterrupt: true,
};
}
function blankOpenCodeForm(name: string): OpenCodeForm {
return {
name,
prompt: "",
apiUrl: "",
apiKey: "",
model: "",
asr: "",
voice: "",
enableInterrupt: true,
};
}
function formatTimestamp(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
@@ -210,73 +288,49 @@ type TypeFilter = "全部" | AssistantType;
const typeFilters: TypeFilter[] = ["全部", ...assistantTypes];
export function AssistantPage() {
const [form, setForm] = useState<AssistantForm>({
name: "政务视频咨询助手",
greeting: "您好,我是 AI 视频助手,请问有什么可以帮您?",
prompt:
"你是一名专业的政务视频咨询助手,负责为市民提供政策解读、办事指南和常见问题解答。\n\n请遵循以下原则\n1. 回答准确、简洁,使用通俗易懂的语言\n2. 不确定的信息应明确告知,不编造政策内容\n3. 涉及具体办事流程时,引导用户前往官方渠道核实",
runtimeMode: "pipeline",
realtimeModel: "",
model: "",
asr: "",
voice: "",
knowledgeBase: "",
enableInterrupt: true,
});
const [fastGptForm, setFastGptForm] = useState<FastGptForm>({
name: "FastGPT 售后咨询助手",
appId: "",
apiUrl: "https://api.fastgpt.in/api/v1/chat/completions",
apiKey: "",
asr: "",
voice: "",
enableInterrupt: true,
});
const [difyForm, setDifyForm] = useState<DifyForm>({
name: "Dify 知识库问答助手",
apiUrl: "https://api.dify.ai/v1/chat-messages",
apiKey: "",
asr: "",
voice: "",
enableInterrupt: true,
});
const [openCodeForm, setOpenCodeForm] = useState<OpenCodeForm>({
name: "OpenCode 代码助手",
prompt:
"你是一个代码助手的语音交互界面,请用简洁、口语化的方式回答用户关于代码与工程的问题。",
apiUrl: "http://localhost:4096",
apiKey: "",
model: "",
asr: "",
voice: "",
enableInterrupt: true,
});
export function AssistantPage(props: AssistantPageProps) {
const router = useRouter();
// 编辑中的助手 id(来自路由)
const editingId = props.mode === "edit" ? props.assistantId : null;
const [form, setForm] = useState<AssistantForm>(() => blankPromptForm(""));
const [fastGptForm, setFastGptForm] = useState<FastGptForm>(() =>
blankFastGptForm(""),
);
const [difyForm, setDifyForm] = useState<DifyForm>(() => blankDifyForm(""));
const [openCodeForm, setOpenCodeForm] = useState<OpenCodeForm>(() =>
blankOpenCodeForm(""),
);
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [listLoading, setListLoading] = useState(true);
const [listError, setListError] = useState<string | null>(null);
// 编辑中的助手 id;null = 新建
const [editingId, setEditingId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// 编辑模式:加载指定 id 助手失败时的错误
const [loadError, setLoadError] = useState<string | null>(null);
// 编辑模式:后端返回的打码 API Key(用于编辑页展示"当前密钥")
const [storedApiKeyMask, setStoredApiKeyMask] = useState("");
// 下拉数据源:模型凭证 + 知识库
const [credentials, setCredentials] = useState<Credential[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [view, setView] = useState<
| "list"
| "choose"
| "create"
| "create-dify"
| "create-fastgpt"
| "create-opencode"
| "placeholder"
>("list");
// 视图由路由模式决定;仅编辑模式需要先 loading,等拿到助手类型后切换
const [view, setView] = useState<View>(() => {
if (props.mode === "choose") return "choose";
if (props.mode === "edit") return "loading";
return "list";
});
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
const [currentPage, setCurrentPage] = useState(1);
// choose 步骤的草稿:名称与已选类型,确认后才决定进入哪个构建
// choose 步骤的草稿:名称与已选类型,确认后直接建库并进入编辑
// (工作流占位页也用它展示名称与类型)
const [draftName, setDraftName] = useState("");
const [draftType, setDraftType] = useState<AssistantType | null>(null);
// 引导页:创建请求进行中 / 创建失败
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
// 已保存基线(当前类型表单的 JSON);与表单不一致时保存按钮才可点击
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
const loadAssistants = useCallback(async () => {
setListLoading(true);
@@ -291,10 +345,11 @@ export function AssistantPage() {
}, []);
useEffect(() => {
// 挂载时拉取助手列表(与后端同步)
// 列表页挂载时拉取助手列表(与后端同步)
if (props.mode !== "list") return;
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadAssistants();
}, [loadAssistants]);
}, [props.mode, loadAssistants]);
// 进入创建/编辑前加载下拉数据源(模型凭证 + 知识库)
const loadResources = useCallback(async () => {
@@ -310,6 +365,13 @@ export function AssistantPage() {
}
}, []);
// 编辑页挂载时加载下拉数据源
useEffect(() => {
if (props.mode !== "edit") return;
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadResources();
}, [props.mode, loadResources]);
// 按资源类型生成 {value:id, label:name} 选项
const credOptions = (type: Credential["type"]) =>
credentials
@@ -318,30 +380,13 @@ export function AssistantPage() {
const kbOptions = knowledgeBases.map((k) => ({ value: k.id, label: k.name }));
function startCreate() {
setDraftName("");
setDraftType(null);
setView("choose");
}
// 提示词类型的空白模板(新建用)
function blankPromptForm(name: string): AssistantForm {
return {
name,
greeting: "",
prompt: "",
runtimeMode: "pipeline",
realtimeModel: "",
model: "",
asr: "",
voice: "",
knowledgeBase: "",
enableInterrupt: true,
};
router.push("/assistants/new");
}
// 把后端 Assistant 回填进提示词表单(注意:model/asr/voice 等存的是凭证 id)
function fillPromptForm(a: Assistant) {
setForm({
// 返回回填后的表单,供调用方记录"已保存基线"
function fillPromptForm(a: Assistant): AssistantForm {
const next: AssistantForm = {
name: a.name,
greeting: a.greeting,
prompt: a.prompt,
@@ -352,119 +397,35 @@ export function AssistantPage() {
voice: a.ttsCredentialId ?? "",
knowledgeBase: a.knowledgeBaseId ?? "",
enableInterrupt: a.enableInterrupt,
});
};
setForm(next);
return next;
}
// 编辑:根据助手类型进入对应的构建/编辑页
async function handleEdit(assistant: AssistantListItem) {
if (assistant.type === "提示词") {
void loadResources();
setSaveError(null);
setEditingId(assistant.id);
try {
fillPromptForm(await assistantsApi.get(assistant.id));
setView("create");
} catch (error) {
setListError(error instanceof Error ? error.message : "加载助手失败");
}
} else if (assistant.type === "FastGPT") {
void loadResources();
setSaveError(null);
setEditingId(assistant.id);
try {
fillFastGptForm(await assistantsApi.get(assistant.id));
setView("create-fastgpt");
} catch (error) {
setListError(error instanceof Error ? error.message : "加载助手失败");
}
} else if (assistant.type === "Dify") {
void loadResources();
setSaveError(null);
setEditingId(assistant.id);
try {
fillDifyForm(await assistantsApi.get(assistant.id));
setView("create-dify");
} catch (error) {
setListError(error instanceof Error ? error.message : "加载助手失败");
}
} else if (assistant.type === "OpenCode") {
void loadResources();
setSaveError(null);
setEditingId(assistant.id);
try {
fillOpenCodeForm(await assistantsApi.get(assistant.id));
setView("create-opencode");
} catch (error) {
setListError(error instanceof Error ? error.message : "加载助手失败");
}
} else {
// 工作流:暂时显示占位页
setDraftName(assistant.name);
setDraftType(assistant.type);
setView("placeholder");
}
// 编辑:跳转到 /assistants/[id],由编辑页按助手类型回填表单
function handleEdit(assistant: AssistantListItem) {
router.push(`/assistants/${assistant.id}`);
}
function confirmType() {
if (!draftName.trim() || !draftType) {
// 引导页确认:直接创建到数据库拿到 id再进入该助手的编辑页
async function confirmType() {
if (!draftName.trim() || !draftType || creating) {
return;
}
if (draftType === "提示词") {
// 提示词类型:新建,空白模板 + 带入名称
void loadResources();
setEditingId(null);
setSaveError(null);
setForm(blankPromptForm(draftName.trim()));
setView("create");
} else if (draftType === "FastGPT") {
// FastGPT 类型:新建,清空表单 + 带入名称
void loadResources();
setEditingId(null);
setSaveError(null);
setFastGptForm({
name: draftName.trim(),
appId: "",
apiUrl: "",
apiKey: "",
asr: "",
voice: "",
enableInterrupt: true,
});
setView("create-fastgpt");
} else if (draftType === "Dify") {
// Dify 类型:新建,清空表单 + 带入名称
void loadResources();
setEditingId(null);
setSaveError(null);
setDifyForm({
name: draftName.trim(),
apiUrl: "",
apiKey: "",
asr: "",
voice: "",
enableInterrupt: true,
});
setView("create-dify");
} else if (draftType === "OpenCode") {
// OpenCode 类型:新建,清空表单 + 带入名称
void loadResources();
setEditingId(null);
setSaveError(null);
setOpenCodeForm({
name: draftName.trim(),
prompt: "",
apiUrl: "",
apiKey: "",
model: "",
asr: "",
voice: "",
enableInterrupt: true,
});
setView("create-opencode");
} else {
// 工作流:暂时显示占位页
setView("placeholder");
setCreating(true);
setCreateError(null);
try {
const created = await assistantsApi.create(
baseUpsert({
name: draftName.trim(),
type: typeFromLabel[draftType],
}),
);
router.push(`/assistants/${created.id}`);
} catch (error) {
setCreateError(error instanceof Error ? error.message : "创建失败");
setCreating(false);
}
}
@@ -510,15 +471,30 @@ export function AssistantPage() {
};
}
// 统一的保存:新建 POST / 编辑 PUT
// 保存:停留在编辑页,刷新密钥掩码并把当前表单记为已保存基线(按钮随之置灰)
async function save(payload: AssistantUpsert) {
if (!editingId) return;
setSaving(true);
setSaveError(null);
try {
if (editingId) await assistantsApi.update(editingId, payload);
else await assistantsApi.create(payload);
await loadAssistants();
setView("list");
const updated = await assistantsApi.update(editingId, payload);
setStoredApiKeyMask(updated.apiKey ?? "");
// 密钥输入框清空(空值=保留已存密钥),并以清空后的表单为新基线
if (view === "create") {
setSavedSnapshot(JSON.stringify(form));
} else if (view === "create-dify") {
const next = { ...difyForm, apiKey: "" };
setDifyForm(next);
setSavedSnapshot(JSON.stringify(next));
} else if (view === "create-fastgpt") {
const next = { ...fastGptForm, apiKey: "" };
setFastGptForm(next);
setSavedSnapshot(JSON.stringify(next));
} else if (view === "create-opencode") {
const next = { ...openCodeForm, apiKey: "" };
setOpenCodeForm(next);
setSavedSnapshot(JSON.stringify(next));
}
} catch (error) {
setSaveError(error instanceof Error ? error.message : "保存失败");
} finally {
@@ -545,8 +521,8 @@ export function AssistantPage() {
}
// ---- Dify ----
function fillDifyForm(a: Assistant) {
setDifyForm({
function fillDifyForm(a: Assistant): DifyForm {
const next: DifyForm = {
name: a.name,
apiUrl: a.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
@@ -554,7 +530,9 @@ export function AssistantPage() {
asr: a.asrCredentialId ?? "",
voice: a.ttsCredentialId ?? "",
enableInterrupt: a.enableInterrupt,
});
};
setDifyForm(next);
return next;
}
function handleSaveDify() {
@@ -572,8 +550,8 @@ export function AssistantPage() {
}
// ---- FastGPT ----
function fillFastGptForm(a: Assistant) {
setFastGptForm({
function fillFastGptForm(a: Assistant): FastGptForm {
const next: FastGptForm = {
name: a.name,
appId: a.appId,
apiUrl: a.apiUrl,
@@ -582,7 +560,9 @@ export function AssistantPage() {
asr: a.asrCredentialId ?? "",
voice: a.ttsCredentialId ?? "",
enableInterrupt: a.enableInterrupt,
});
};
setFastGptForm(next);
return next;
}
function handleSaveFastGpt() {
@@ -601,8 +581,8 @@ export function AssistantPage() {
}
// ---- OpenCode ----
function fillOpenCodeForm(a: Assistant) {
setOpenCodeForm({
function fillOpenCodeForm(a: Assistant): OpenCodeForm {
const next: OpenCodeForm = {
name: a.name,
prompt: a.prompt,
apiUrl: a.apiUrl,
@@ -612,9 +592,49 @@ export function AssistantPage() {
asr: a.asrCredentialId ?? "",
voice: a.ttsCredentialId ?? "",
enableInterrupt: a.enableInterrupt,
});
};
setOpenCodeForm(next);
return next;
}
// 编辑模式:按路由中的 id 拉取助手,回填对应类型的表单后再切换视图
useEffect(() => {
if (props.mode !== "edit" || !editingId) return;
let cancelled = false;
void (async () => {
try {
const assistant = await assistantsApi.get(editingId);
if (cancelled) return;
setStoredApiKeyMask(assistant.apiKey ?? "");
if (assistant.type === "prompt") {
setSavedSnapshot(JSON.stringify(fillPromptForm(assistant)));
} else if (assistant.type === "fastgpt") {
setSavedSnapshot(JSON.stringify(fillFastGptForm(assistant)));
} else if (assistant.type === "dify") {
setSavedSnapshot(JSON.stringify(fillDifyForm(assistant)));
} else if (assistant.type === "opencode") {
setSavedSnapshot(JSON.stringify(fillOpenCodeForm(assistant)));
} else {
// 工作流:暂时显示占位页
setDraftName(assistant.name);
setDraftType(typeToLabel[assistant.type]);
}
setView(typeToView[assistant.type]);
} catch (error) {
if (!cancelled) {
setLoadError(
error instanceof Error ? error.message : "加载助手失败",
);
}
}
})();
return () => {
cancelled = true;
};
}, [props.mode, editingId]);
function handleSaveOpenCode() {
void save(
baseUpsert({
@@ -631,17 +651,28 @@ export function AssistantPage() {
);
}
// 当前编辑器表单相对已保存基线是否有改动(决定保存按钮是否可点)
const activeFormJson =
view === "create"
? JSON.stringify(form)
: view === "create-dify"
? JSON.stringify(difyForm)
: view === "create-fastgpt"
? JSON.stringify(fastGptForm)
: view === "create-opencode"
? JSON.stringify(openCodeForm)
: null;
const dirty =
activeFormJson !== null &&
savedSnapshot !== null &&
activeFormJson !== savedSnapshot;
const listItems: AssistantListItem[] = assistants.map((a) => ({
id: a.id,
name: a.name,
type: typeToLabel[a.type],
updatedAt: formatTimestamp(a.updatedAt),
}));
const storedApiKeyMask =
(editingId &&
assistants.find((assistant) => assistant.id === editingId)?.apiKey) ||
"";
const filteredAssistants = listItems.filter((assistant) => {
if (typeFilter !== "全部" && assistant.type !== typeFilter) {
return false;
@@ -715,6 +746,34 @@ export function AssistantPage() {
[key]: value,
}));
}
if (view === "loading") {
return (
<div className="flex h-full items-center justify-center">
{loadError ? (
<div className="text-center">
<div className="font-medium text-destructive"></div>
<div className="mt-2 text-sm text-muted-foreground">
{loadError}
</div>
<Button
variant="outline"
size="sm"
className="mt-4 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => router.push("/assistants")}
>
</Button>
</div>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
)}
</div>
);
}
if (view === "list") {
return (
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
@@ -963,17 +1022,13 @@ export function AssistantPage() {
}
if (view === "choose") {
const selectedOption = assistantTypeOptions.find(
(option) => option.type === draftType,
);
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">
</p>
</div>
@@ -981,7 +1036,7 @@ export function AssistantPage() {
variant="outline"
size="lg"
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => setView("list")}
onClick={() => router.push("/assistants")}
>
<ChevronLeft size={16} />
@@ -1057,24 +1112,30 @@ export function AssistantPage() {
</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"
onClick={() => setView("list")}
disabled={creating}
onClick={() => router.push("/assistants")}
>
</Button>
<Button
size="lg"
className="gap-2"
disabled={!draftName.trim() || !draftType}
onClick={confirmType}
disabled={!draftName.trim() || !draftType || creating}
onClick={() => void confirmType()}
>
<Rocket size={16} />
{selectedOption && !selectedOption.available
? "下一步"
: "开始构建"}
{creating ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Rocket size={16} />
)}
</Button>
</div>
</div>
@@ -1098,7 +1159,7 @@ export function AssistantPage() {
variant="outline"
size="lg"
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => setView("list")}
onClick={() => router.push("/assistants")}
>
<ChevronLeft size={16} />
@@ -1143,7 +1204,7 @@ export function AssistantPage() {
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={() => setView("list")} />
<EditorBackButton onClick={() => router.push("/assistants")} />
<EditableTitle
value={difyForm.name}
onChange={(value) => updateDifyForm("name", value)}
@@ -1159,7 +1220,7 @@ export function AssistantPage() {
)}
<Button
className="gap-2"
disabled={saving || !difyForm.name.trim()}
disabled={saving || !dirty || !difyForm.name.trim()}
onClick={() => void handleSaveDify()}
>
{saving ? (
@@ -1242,7 +1303,7 @@ export function AssistantPage() {
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={() => setView("list")} />
<EditorBackButton onClick={() => router.push("/assistants")} />
<EditableTitle
value={fastGptForm.name}
onChange={(value) => updateFastGptForm("name", value)}
@@ -1258,7 +1319,7 @@ export function AssistantPage() {
)}
<Button
className="gap-2"
disabled={saving || !fastGptForm.name.trim()}
disabled={saving || !dirty || !fastGptForm.name.trim()}
onClick={() => void handleSaveFastGpt()}
>
{saving ? (
@@ -1347,7 +1408,7 @@ export function AssistantPage() {
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={() => setView("list")} />
<EditorBackButton onClick={() => router.push("/assistants")} />
<EditableTitle
value={openCodeForm.name}
onChange={(value) => updateOpenCodeForm("name", value)}
@@ -1363,7 +1424,7 @@ export function AssistantPage() {
)}
<Button
className="gap-2"
disabled={saving || !openCodeForm.name.trim()}
disabled={saving || !dirty || !openCodeForm.name.trim()}
onClick={() => void handleSaveOpenCode()}
>
{saving ? (
@@ -1465,7 +1526,7 @@ export function AssistantPage() {
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={() => setView("list")} />
<EditorBackButton onClick={() => router.push("/assistants")} />
<EditableTitle
value={form.name}
onChange={(value) => updateForm("name", value)}
@@ -1481,7 +1542,7 @@ export function AssistantPage() {
)}
<Button
className="gap-2"
disabled={saving || !form.name.trim()}
disabled={saving || !dirty || !form.name.trim()}
onClick={() => void handleSavePrompt()}
>
{saving ? (

View File

@@ -1,12 +1,8 @@
import Link from "next/link";
import { Boxes, Plus } from "lucide-react";
import type { NavKey } from "@/components/layout/AppShell";
import { Button } from "@/components/ui/button";
type HomePageProps = {
onNavigate: (key: NavKey) => void;
};
export function HomePage({ onNavigate }: HomePageProps) {
export function HomePage() {
return (
<div className="mx-auto flex w-full max-w-[1180px] flex-col gap-12 pt-[3vh]">
{/* Hero band — atmospheric gradient orbs behind editorial display copy */}
@@ -39,23 +35,23 @@ export function HomePage({ onNavigate }: HomePageProps) {
</p>
<div className="mt-9 flex gap-3">
<Button
size="lg"
className="gap-2 px-5"
onClick={() => onNavigate("assistants")}
>
<Plus size={17} />
<Button size="lg" className="gap-2 px-5" asChild>
<Link href="/assistants">
<Plus size={17} />
</Link>
</Button>
<Button
variant="outline"
size="lg"
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
onClick={() => onNavigate("components-models")}
asChild
>
<Boxes size={17} />
<Link href="/components/models">
<Boxes size={17} />
</Link>
</Button>
</div>
</div>