chore: 前端代码优化 - ESLint 配置、Prettier 格式化、加载骨架屏、React 组件规范

- 更新 ESLint 配置添加 react-hooks 规则
- 运行 Prettier 格式化所有前端文件
- 为 users、apps、audit、quotas 页面添加 Skeleton 加载骨架屏
- 修复动态组件渲染问题(CategoryIcon/TypeIcon 使用 React.createElement)
- 修复 useRef 渲染期间访问问题(改用 useMemo)
- 修复 effect 中 setState 同步调用问题
- 新增 global-not-found.tsx 404 页面
- 修复 knowledge 页面引号转义问题
This commit is contained in:
selfrelease
2026-06-23 14:43:04 +08:00
parent 6ca1c27162
commit 96b944ac8d
68 changed files with 2182 additions and 1969 deletions
+164 -80
View File
@@ -1,5 +1,6 @@
"use client";
import React from "react";
import { useState, useRef, useEffect, useCallback, memo, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -56,7 +57,10 @@ function parseToolCalls(content: string): { cleanContent: string; tools: ToolCal
const tool = tools.find((t) => t.name === name);
if (tool) tool.status = "done";
}
cleanContent = cleanContent.replace(/\[工具调用:\s*.+?\]/g, "").replace(/\[工具结果:\s*.+?\]/g, "").trim();
cleanContent = cleanContent
.replace(/\[工具调用:\s*.+?\]/g, "")
.replace(/\[工具结果:\s*.+?\]/g, "")
.trim();
return { cleanContent, tools };
}
@@ -96,10 +100,16 @@ const AgentMessage = memo(function AgentMessage({
<div
key={tool.name}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs ${
tool.status === "done" ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"
tool.status === "done"
? "bg-emerald-50 text-emerald-700"
: "bg-amber-50 text-amber-700"
}`}
>
{tool.status === "done" ? <CheckCircle2 className="h-3 w-3" /> : <Loader2 className="h-3 w-3 animate-spin" />}
{tool.status === "done" ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<Loader2 className="h-3 w-3 animate-spin" />
)}
{tool.name}
</div>
))}
@@ -130,7 +140,11 @@ interface AgentUIProps {
export default function AgentUI({ app }: AgentUIProps) {
const router = useRouter();
const queryClient = useQueryClient();
const [messages, setMessages] = useState<Message[]>([]);
const [messages, setMessages] = useState<Message[]>(
app.welcome_message && !conversationId
? [{ id: "welcome", role: "assistant", content: app.welcome_message }]
: [],
);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>();
@@ -153,7 +167,9 @@ export default function AgentUI({ app }: AgentUIProps) {
try {
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
return app.app_config || {};
} catch { return {}; }
} catch {
return {};
}
}, [app.app_config]);
const tools: string[] = appConfig.tools || [];
@@ -171,39 +187,43 @@ export default function AgentUI({ app }: AgentUIProps) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => { scrollToBottom(); }, [messages, scrollToBottom]);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
useEffect(() => {
if (app.welcome_message && messages.length === 0 && !conversationId) {
setMessages([{ id: "welcome", role: "assistant", content: app.welcome_message }]);
}
}, [app.welcome_message, messages.length, conversationId]);
useEffect(() => {
return () => { abortRef.current?.abort(); };
return () => {
abortRef.current?.abort();
};
}, []);
const loadConversation = useCallback(async (convId: string) => {
setConversationId(convId);
try {
const data = await api.get<{ data: Message[] }>(`/api/v1/apps/${app.id}/conversations/${convId}/messages`);
setMessages(data.data || []);
} catch {
setMessages([]);
}
}, [app.id]);
const loadConversation = useCallback(
async (convId: string) => {
setConversationId(convId);
try {
const data = await api.get<{ data: Message[] }>(
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
);
setMessages(data.data || []);
} catch {
setMessages([]);
}
},
[app.id],
);
const CategoryIcon = getCategoryIcon(app.category_slug);
const CategoryIconComponent = getCategoryIcon(app.category_slug);
const categoryColor = getCategoryColor(app.category_slug);
const suggestedPrompts = useRef(
(() => {
try {
if (typeof app.suggested_prompts === "string") return JSON.parse(app.suggested_prompts) as string[];
return (app.suggested_prompts as string[]) || [];
} catch { return []; }
})()
).current;
const suggestedPrompts = useMemo(() => {
try {
if (typeof app.suggested_prompts === "string")
return JSON.parse(app.suggested_prompts) as string[];
return (app.suggested_prompts as string[]) || [];
} catch {
return [];
}
}, [app.suggested_prompts]);
const copyText = useCallback((text: string) => {
navigator.clipboard.writeText(text);
@@ -275,13 +295,13 @@ export default function AgentUI({ app }: AgentUIProps) {
const snap = accumulated;
setMessages((prev) =>
prev.map((m, i) =>
i === prev.length - 1 && m.role === "assistant"
? { ...m, content: snap }
: m
)
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
),
);
}
} catch { /* skip */ }
} catch {
/* skip */
}
}
}
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
@@ -291,8 +311,8 @@ export default function AgentUI({ app }: AgentUIProps) {
prev.map((m, i) =>
i === prev.length - 1 && m.role === "assistant" && !m.content
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
: m
)
: m,
),
);
} finally {
abortRef.current = null;
@@ -300,9 +320,15 @@ export default function AgentUI({ app }: AgentUIProps) {
}
}, [input, isStreaming, app.id, conversationId, queryClient]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
}, [sendMessage]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
},
[sendMessage],
);
const startNewConversation = useCallback(() => {
abortRef.current?.abort();
@@ -324,15 +350,20 @@ export default function AgentUI({ app }: AgentUIProps) {
setSelectedIds(new Set(conversations.map((c) => c.id)));
}, [conversations]);
const confirmDeleteSingle = useCallback(async (convId: string) => {
try {
await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`);
if (conversationId === convId) startNewConversation();
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
toast.success("对话已删除");
} catch { toast.error("删除失败"); }
setDeleteTarget(null);
}, [app.id, conversationId, startNewConversation, queryClient]);
const confirmDeleteSingle = useCallback(
async (convId: string) => {
try {
await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`);
if (conversationId === convId) startNewConversation();
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
toast.success("对话已删除");
} catch {
toast.error("删除失败");
}
setDeleteTarget(null);
},
[app.id, conversationId, startNewConversation, queryClient],
);
const confirmBatchDelete = useCallback(async () => {
if (selectedIds.size === 0) return;
@@ -345,18 +376,17 @@ export default function AgentUI({ app }: AgentUIProps) {
setSelectMode(false);
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
toast.success(`已删除 ${selectedIds.size} 个对话`);
} catch { toast.error("批量删除失败"); }
} catch {
toast.error("批量删除失败");
}
setDeleteTarget(null);
}, [selectedIds, app.id, conversationId, startNewConversation, queryClient]);
const startRename = useCallback(
(convId: string, currentName: string) => {
setEditingConvId(convId);
setEditingName(currentName);
setTimeout(() => renameInputRef.current?.focus(), 50);
},
[]
);
const startRename = useCallback((convId: string, currentName: string) => {
setEditingConvId(convId);
setEditingName(currentName);
setTimeout(() => renameInputRef.current?.focus(), 50);
}, []);
const saveRename = useCallback(async () => {
if (!editingConvId || !editingName.trim()) {
@@ -364,10 +394,9 @@ export default function AgentUI({ app }: AgentUIProps) {
return;
}
try {
await api.put(
`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`,
{ name: editingName.trim() }
);
await api.put(`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`, {
name: editingName.trim(),
});
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
toast.success("已重命名");
} catch {
@@ -379,10 +408,7 @@ export default function AgentUI({ app }: AgentUIProps) {
return (
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
{/* 删除确认弹窗 */}
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
@@ -419,12 +445,23 @@ export default function AgentUI({ app }: AgentUIProps) {
)}
{/* 侧边栏 */}
<div className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`}>
<div
className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`}
>
<div className="p-3 border-b space-y-2 shrink-0">
<Button variant="ghost" size="sm" className="w-full justify-start gap-1.5 text-muted-foreground" onClick={() => router.push("/store")}>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-1.5 text-muted-foreground"
onClick={() => router.push("/store")}
>
<ArrowLeft className="h-3.5 w-3.5" />
</Button>
<Button onClick={startNewConversation} className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white" size="sm">
<Button
onClick={startNewConversation}
className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white"
size="sm"
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
@@ -453,7 +490,14 @@ export default function AgentUI({ app }: AgentUIProps) {
<div className="flex items-center gap-1">
{selectMode ? (
<>
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={selectAll}></Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={selectAll}
>
</Button>
<Button
variant="ghost"
size="sm"
@@ -463,12 +507,25 @@ export default function AgentUI({ app }: AgentUIProps) {
>
<Trash2 className="h-3 w-3 mr-0.5" />
</Button>
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={() => { setSelectMode(false); setSelectedIds(new Set()); }}>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() => {
setSelectMode(false);
setSelectedIds(new Set());
}}
>
<X className="h-3 w-3" />
</Button>
</>
) : (
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={() => setSelectMode(true)}>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() => setSelectMode(true)}
>
<CheckSquare className="h-3 w-3 mr-0.5" />
</Button>
)}
@@ -485,7 +542,11 @@ export default function AgentUI({ app }: AgentUIProps) {
<div key={conv.id} className="group flex items-center gap-1">
{selectMode && (
<button onClick={() => toggleSelect(conv.id)} className="shrink-0 p-0.5">
{selectedIds.has(conv.id) ? <CheckSquare className="h-3.5 w-3.5 text-primary" /> : <Square className="h-3.5 w-3.5 text-muted-foreground" />}
{selectedIds.has(conv.id) ? (
<CheckSquare className="h-3.5 w-3.5 text-primary" />
) : (
<Square className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
)}
{editingConvId === conv.id ? (
@@ -554,12 +615,18 @@ export default function AgentUI({ app }: AgentUIProps) {
>
<PanelLeftOpen className="h-4 w-4" />
</button>
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}>
<CategoryIcon className="h-4.5 w-4.5" />
<div
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
>
{CategoryIconComponent
? React.createElement(CategoryIconComponent, { className: "h-4.5 w-4.5" })
: null}
</div>
<div className="min-w-0">
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
<p className="text-xs text-muted-foreground truncate max-w-[150px] md:max-w-none">{app.description}</p>
<p className="text-xs text-muted-foreground truncate max-w-[150px] md:max-w-none">
{app.description}
</p>
</div>
<div className="ml-auto flex items-center gap-1 md:gap-2">
{messages.length > 1 && (
@@ -586,7 +653,16 @@ export default function AgentUI({ app }: AgentUIProps) {
{messages.length <= 1 && suggestedPrompts.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{suggestedPrompts.map((prompt: string, i: number) => (
<Button key={i} variant="outline" size="sm" className="text-xs" onClick={() => { setInput(prompt); textareaRef.current?.focus(); }}>
<Button
key={i}
variant="outline"
size="sm"
className="text-xs"
onClick={() => {
setInput(prompt);
textareaRef.current?.focus();
}}
>
{prompt}
</Button>
))}
@@ -608,8 +684,16 @@ export default function AgentUI({ app }: AgentUIProps) {
rows={1}
disabled={isStreaming}
/>
<Button onClick={sendMessage} disabled={!input.trim() || isStreaming} className="shrink-0 gap-1.5">
{isStreaming ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
<Button
onClick={sendMessage}
disabled={!input.trim() || isStreaming}
className="shrink-0 gap-1.5"
>
{isStreaming ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
{isStreaming ? "思考中" : "发送"}
</Button>
</div>