Browse Source

feat: Establish core application structure, authentication, and dynamic entity data management.

main
Boom 1 week ago
parent
commit
a633d67bcd
  1. 9
      next.config.ts
  2. 309
      src/app/data/[entityCode]/page.tsx
  3. 14
      src/app/layout.tsx
  4. 94
      src/app/login/page.tsx
  5. 62
      src/app/page.tsx
  6. 35
      src/components/AuthGuard.tsx
  7. 21
      src/components/CopilotProvider.tsx
  8. 180
      src/components/DynamicForm.tsx
  9. 52
      src/components/Sidebar.tsx
  10. 163
      src/components/UniversalModuleRenderer.tsx

9
next.config.ts

@ -1,7 +1,14 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://localhost:8080/api/:path*',
},
];
},
}; };
export default nextConfig; export default nextConfig;

309
src/app/data/[entityCode]/page.tsx

@ -19,6 +19,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import DynamicForm from "@/components/DynamicForm";
interface DataPageProps { interface DataPageProps {
params: Promise<{ params: Promise<{
@ -27,7 +28,6 @@ interface DataPageProps {
} }
export default function EntityDataPage({ params: paramsPromise }: DataPageProps) { export default function EntityDataPage({ params: paramsPromise }: DataPageProps) {
// Use React.use() to unwrap Next.js 15+ dynamic params Promise
const params = use(paramsPromise); const params = use(paramsPromise);
const { entityCode } = params; const { entityCode } = params;
@ -37,78 +37,116 @@ export default function EntityDataPage({ params: paramsPromise }: DataPageProps)
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [columns, setColumns] = useState<ColumnDef<any>[]>([]); const [columns, setColumns] = useState<ColumnDef<any>[]>([]);
useEffect(() => { // Modal 状态
async function loadData() { const [isModalOpen, setIsModalOpen] = useState(false);
setLoading(true); const [editingData, setEditingData] = useState<any>(null);
try {
const [entityRes, dataRes] = await Promise.all([ const loadData = async () => {
fetch(`http://localhost:8080/api/entities/code/${entityCode}`), setLoading(true);
fetch(`http://localhost:8080/api/dynamic/code/${entityCode}`) try {
]); const userId = localStorage.getItem("crm_user_id") || "anonymous";
const [entityRes, dataRes] = await Promise.all([
if (entityRes.ok && dataRes.ok) { fetch(`/api/entities/code/${entityCode}`, { headers: { 'X-Creator-Id': userId } }),
const entityData = await entityRes.json(); fetch(`/api/dynamic/code/${entityCode}`, { headers: { 'X-Creator-Id': userId } })
const records = await dataRes.json(); ]);
setEntity(entityData);
if (entityRes.ok && dataRes.ok) {
// Map backend entities to flat row format const entityData = await entityRes.json();
const mappedData = records.map((r: any) => { const records = await dataRes.json();
const parsedData = (typeof r.data === 'string') ? JSON.parse(r.data) : r.data; setEntity(entityData);
return {
_id: r.id, const mappedData = records.map((r: any) => {
_createdAt: r.createdAt, const parsedData = (typeof r.data === 'string') ? JSON.parse(r.data) : r.data;
...parsedData return {
}; _id: r.id,
}); _createdAt: r.createdAt,
setData(mappedData); ...parsedData
};
// Build generic columns directly from Schema });
const dynamicColumns: ColumnDef<any>[] = []; setData(mappedData);
if (entityData && entityData.schemaDefinition) { const dynamicColumns: ColumnDef<any>[] = [];
try { if (entityData && entityData.schemaDefinition) {
const schema = JSON.parse(entityData.schemaDefinition); const schema = JSON.parse(entityData.schemaDefinition);
if (schema.properties) { if (schema.properties) {
Object.keys(schema.properties).forEach((key) => { Object.keys(schema.properties).forEach((key) => {
const prop = schema.properties[key]; const prop = schema.properties[key];
dynamicColumns.push({ dynamicColumns.push({
accessorKey: key, accessorKey: key,
header: prop.title || key, header: prop.title || key,
cell: ({ row }) => { cell: ({ row }) => {
const cellValue = row.getValue(key); const val = row.getValue(key);
return cellValue !== undefined && cellValue !== null ? String(cellValue) : "-"; return val !== undefined && val !== null ? String(val) : "-";
} }
}); });
}); });
}
} catch(e) {
console.error("Schema Parsing Error:", e);
}
} }
// System column created at
dynamicColumns.push({
accessorKey: "_createdAt",
header: "创建时间",
cell: ({ row }) => {
const val: string = row.getValue("_createdAt");
return val ? new Date(val).toLocaleString() : "-";
}
});
setColumns(dynamicColumns);
} else {
console.error("Failed to fetch entity or data");
} }
} catch (err) {
console.error("Error loading table data:", err); // 添加操作列
} finally { dynamicColumns.push({
setLoading(false); id: "actions",
header: "操作",
cell: ({ row }) => (
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
className="text-indigo-600 hover:text-indigo-700 hover:bg-indigo-50 font-bold"
onClick={() => handleEdit(row.original)}
>
</Button>
<Button
variant="ghost"
size="sm"
className="text-rose-600 hover:text-rose-700 hover:bg-rose-50 font-bold"
onClick={() => handleDelete(row.original._id)}
>
</Button>
</div>
)
});
setColumns(dynamicColumns);
} }
} catch (err) {
console.error("Load error:", err);
} finally {
setLoading(false);
} }
};
useEffect(() => {
loadData(); loadData();
}, [entityCode]); }, [entityCode]);
const handleDelete = async (id: string) => {
if (!confirm("确定要物理删除这条数据吗?该操作不可撤销,将由 AI 执行。")) return;
try {
const userId = localStorage.getItem("crm_user_id") || "anonymous";
const res = await fetch(`/api/agent/chat?sessionId=${userId}_OP`, {
method: 'POST',
headers: { 'X-Creator-Id': userId },
body: `请执行核心物理删除指令:删除记录 ID 为 ${id} 的数据。`
});
if (res.ok) {
alert("✅ AI 已同步执行删除操作。");
loadData();
}
} catch (err) {
alert("删除失败");
}
};
const handleEdit = (row: any) => {
// 排除系统字段
const { _id, _createdAt, ...businessData } = row;
setEditingData(businessData);
setIsModalOpen(true);
};
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
@ -116,109 +154,90 @@ export default function EntityDataPage({ params: paramsPromise }: DataPageProps)
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
onSortingChange: setSorting, onSortingChange: setSorting,
state: { state: { sorting },
sorting,
},
}); });
if (loading) { if (loading && !data.length) {
return ( return <div className="flex h-full w-full items-center justify-center">...</div>;
<div className="flex h-full w-full items-center justify-center bg-slate-50">
<div className="flex flex-col items-center gap-4">
<span className="animate-spin text-4xl text-indigo-500"></span>
<p className="text-slate-500 font-bold tracking-widest animate-pulse">LOADING DYNAMIC RECORDS</p>
</div>
</div>
);
} }
if (!entity) return <div className="p-8 text-rose-500 font-bold"></div>;
return ( return (
<div className="p-8 md:p-12 max-w-7xl mx-auto flex flex-col h-full gap-8 max-h-screen"> <div className="p-8 md:p-12 max-w-7xl mx-auto flex flex-col h-full gap-8 relative">
<div className="flex flex-col gap-2 border-b border-slate-200 pb-6 shrink-0"> <div className="flex items-center justify-between border-b pb-8">
<div className="flex flex-col gap-2">
<h1 className="text-4xl font-black text-slate-800 tracking-tight flex items-center gap-3"> <h1 className="text-4xl font-black text-slate-800 tracking-tight flex items-center gap-3">
<span className="p-3 bg-gradient-to-br from-indigo-50 to-indigo-100/50 text-indigo-600 rounded-2xl shadow-sm border border-indigo-100">📋</span> <span className="p-3 bg-indigo-50 text-indigo-600 rounded-2xl">📋</span>
{entity.entityName} {entity?.entityName}
</h1> </h1>
<p className="text-slate-500 font-medium">: <code className="bg-slate-100 border border-slate-200 px-2 py-0.5 rounded text-sm text-slate-700 mx-2">{entity.entityCode}</code> <span className="text-sm"> JSONB </span></p> <p className="text-slate-500 font-medium">: {entityCode}</p>
</div>
<Button
className="bg-indigo-600 hover:bg-indigo-700 text-white font-black px-8 py-6 rounded-2xl shadow-xl shadow-indigo-200"
onClick={() => { setEditingData(null); setIsModalOpen(true); }}
>
+
</Button>
</div> </div>
<div className="rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-200/40 flex-1 flex flex-col overflow-hidden relative"> {/* 数据表格 */}
<div className="flex-1 overflow-auto bg-slate-50/30"> <div className="rounded-3xl border border-slate-200 bg-white shadow-2xl overflow-hidden flex flex-col flex-1">
<div className="overflow-auto flex-1">
<Table> <Table>
<TableHeader className="bg-slate-100/80 sticky top-0 z-10 backdrop-blur-md shadow-sm"> <TableHeader className="bg-slate-50 sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map(hg => (
<TableRow key={headerGroup.id} className="border-b-slate-200 hover:bg-transparent"> <TableRow key={hg.id}>
{headerGroup.headers.map((header) => ( {hg.headers.map(h => (
<TableHead key={header.id} className="font-bold text-slate-600 whitespace-nowrap h-12 uppercase text-xs tracking-wider"> <TableHead key={h.id} className="font-bold text-slate-600 uppercase text-xs p-6">
{header.isPlaceholder {flexRender(h.column.columnDef.header, h.getContext())}
? null </TableHead>
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))} ))}
</TableRow> </TableRow>
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows.map(row => (
table.getRowModel().rows.map((row) => ( <TableRow key={row.id} className="hover:bg-slate-50/50 transition-colors">
<TableRow {row.getVisibleCells().map(cell => (
key={row.id} <TableCell key={cell.id} className="p-6 font-medium text-slate-700">
data-state={row.getIsSelected() && "selected"} {flexRender(cell.column.columnDef.cell, cell.getContext())}
className="hover:bg-indigo-50/50 border-b-slate-100 transition-colors" </TableCell>
> ))}
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="whitespace-nowrap py-4 font-medium text-slate-800">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-64 text-center">
<div className="flex flex-col items-center justify-center gap-2 opacity-60">
<span className="text-4xl mb-2">🎈</span>
<p className="font-bold text-slate-500 text-lg"></p>
<p className="text-sm text-slate-400"> JSONB AI </p>
</div>
</TableCell>
</TableRow> </TableRow>
)} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<div className="flex items-center justify-between py-4 px-6 border-t border-slate-200 bg-white shrink-0"> <div className="p-6 border-t flex justify-between items-center bg-slate-50/50">
<div className="text-sm font-medium text-slate-500"> <span className="text-sm text-slate-500 font-bold tracking-widest"> {data.length} </span>
<span className="font-bold text-indigo-600">{data.length}</span>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}></Button>
variant="outline" <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}></Button>
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="rounded-xl font-bold shadow-sm"
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="rounded-xl font-bold shadow-sm"
>
</Button>
</div> </div>
</div> </div>
</div> </div>
{/* 弹窗容器 */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-auto relative animate-in fade-in zoom-in duration-300">
<button
onClick={() => setIsModalOpen(false)}
className="absolute top-6 right-6 w-10 h-10 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 transition-colors z-20"
>
</button>
<DynamicForm
entityName={entity.entityName}
entityCode={entityCode}
jsonSchema={entity.schemaDefinition}
initialData={editingData}
onSuccess={() => { setIsModalOpen(false); loadData(); }}
/>
</div>
</div>
)}
</div> </div>
); );
} }

14
src/app/layout.tsx

@ -13,6 +13,8 @@ const geistMono = Geist_Mono({
}); });
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import CopilotProvider from "@/components/CopilotProvider";
import AuthGuard from "@/components/AuthGuard";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "AI-Native CRM", title: "AI-Native CRM",
@ -30,10 +32,14 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="flex h-screen overflow-hidden bg-slate-50 relative"> <body className="flex h-screen overflow-hidden bg-slate-50 relative">
<Sidebar /> <AuthGuard>
<div className="flex-1 overflow-y-auto"> <CopilotProvider>
{children} <Sidebar />
</div> <div className="flex-1 overflow-y-auto">
{children}
</div>
</CopilotProvider>
</AuthGuard>
</body> </body>
</html> </html>
); );

94
src/app/login/page.tsx

@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim()) {
alert("请输入用户名");
return;
}
setIsLoading(true);
// 模拟登录:直接存入 localStorage
setTimeout(() => {
localStorage.setItem("crm_user_id", username);
router.push("/");
}, 800);
};
return (
<div className="min-h-screen w-full flex items-center justify-center bg-[#0f172a] relative overflow-hidden">
{/* 背景装饰 */}
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo-500/10 rounded-full blur-[120px] animate-pulse"></div>
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-500/10 rounded-full blur-[120px] animate-pulse delay-700"></div>
<div className="w-full max-w-md p-8 relative z-10">
<div className="bg-white/5 backdrop-blur-xl border border-white/10 rounded-[2.5rem] p-10 shadow-2xl overflow-hidden group">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-indigo-500 to-transparent opacity-50"></div>
<div className="flex flex-col items-center mb-10">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500 to-blue-600 flex items-center justify-center shadow-lg shadow-indigo-500/20 mb-6 group-hover:scale-110 transition-transform duration-500">
<span className="text-white text-3xl font-black italic">AI</span>
</div>
<h1 className="text-3xl font-black text-white tracking-tight mb-2">AI-Native CRM</h1>
<p className="text-slate-400 font-medium"> · </p>
</div>
<form onSubmit={handleLogin} className="space-y-6">
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 uppercase tracking-widest ml-1"></label>
<input
type="text"
placeholder="随便输入一个用户名..."
className="w-full bg-white/5 border border-white/10 rounded-2xl px-6 py-4 text-white outline-none focus:border-indigo-500/50 focus:bg-white/10 transition-all font-medium placeholder:text-slate-600"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 uppercase tracking-widest ml-1"> ()</label>
<input
type="password"
placeholder="••••••••"
className="w-full bg-white/5 border border-white/10 rounded-2xl px-6 py-4 text-white outline-none focus:border-indigo-500/50 focus:bg-white/10 transition-all font-medium placeholder:text-slate-600"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 text-white font-black py-5 rounded-2xl shadow-xl shadow-indigo-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-3 disabled:opacity-50"
>
{isLoading ? (
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
) : (
<>
<span></span>
<span className="text-xl"></span>
</>
)}
</button>
</form>
<div className="mt-10 pt-8 border-t border-white/5 text-center">
<p className="text-xs text-slate-500 font-medium leading-relaxed">
提示: 当前为会话隔离模式<br />
</p>
</div>
</div>
</div>
</div>
);
}

62
src/app/page.tsx

@ -1,50 +1,32 @@
"use client"; "use client";
import { CopilotKit } from "@copilotkit/react-core";
import { CopilotPopup } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import UniversalModuleRenderer from "@/components/UniversalModuleRenderer"; import UniversalModuleRenderer from "@/components/UniversalModuleRenderer";
export default function Home() { export default function Home() {
return ( return (
// 【关键修改点】在 CopilotKit 组件上增加 provider="openai" <div className="min-h-screen bg-slate-50 font-[family-name:var(--font-geist-sans)]">
<CopilotKit runtimeUrl="/api/copilotkit"> {/* 顶部导航 */}
<div className="min-h-screen bg-slate-50 font-[family-name:var(--font-geist-sans)]"> <header className="bg-white shadow-sm border-b px-8 py-4 flex items-center justify-between">
{/* 顶部导航 */} <h1 className="text-2xl font-black text-gray-800 tracking-tight">AI-Native <span className="text-blue-600">CRM</span></h1>
<header className="bg-white shadow-sm border-b px-8 py-4 flex items-center justify-between"> <div className="flex items-center space-x-4">
<h1 className="text-2xl font-black text-gray-800 tracking-tight">AI-Native <span className="text-blue-600">Copilot CRM</span></h1> <span className="text-sm font-medium text-gray-500"> ID: <span className="text-gray-900 bg-gray-100 px-2 py-1 rounded font-mono">{(typeof window !== 'undefined' ? localStorage.getItem("crm_user_id") : "UNKNOWN") || "UNKNOWN"}</span></span>
<div className="flex items-center space-x-4"> </div>
<span className="text-sm font-medium text-gray-500">: <span className="text-gray-900 bg-gray-100 px-2 py-1 rounded">ADMIN_TENANT_1</span></span> </header>
</div>
</header>
<main className="p-8 max-w-5xl mx-auto mt-8 flex flex-col gap-6"> <main className="p-8 max-w-5xl mx-auto mt-8 flex flex-col gap-6">
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 relative overflow-hidden"> <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-50 rounded-bl-full -z-10 opacity-50"></div> <div className="absolute top-0 right-0 w-32 h-32 bg-blue-50 rounded-bl-full -z-10 opacity-50"></div>
<h2 className="text-xl font-bold mb-2">使</h2> <h2 className="text-xl font-bold mb-2">👋 AI </h2>
<p className="text-gray-600 text-sm leading-relaxed max-w-2xl"> <h2 className="text-xl font-bold mb-2"> </h2>
<b></b> <p className="text-gray-600 text-sm leading-relaxed max-w-2xl">
</p> AI <span className="text-blue-600 font-bold">Copilot </span>
</div> <b></b>
</p>
</div>
<div className="w-full bg-white border border-gray-200 border-dashed p-10 rounded-xl min-h-[400px] flex flex-col items-center justify-center relative shadow-inner"> {/* 这里挂载在页面后台,负责监听并执行 AI 下发的 UI 绘画指令 */}
<svg className="w-12 h-12 text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg> <UniversalModuleRenderer />
<p className="text-gray-400 font-medium"> AI (Generative UI Render Area)...</p> </main>
</div> </div>
{/* 这里挂载在页面后台,负责监听并执行 AI 下发的 UI 绘画指令 */}
<UniversalModuleRenderer />
</main>
</div>
<CopilotPopup
instructions="你是一个嵌入在高级企业 CRM 系统中的智能架构师。当用户需要新建任何功能时,请调用 renderDynamicForm 工具响应用户并渲染出一个 JSON Schema 定义的表单。"
labels={{
title: "🤖 CRM 架构探求者",
initial: "您好,刘总。您今天想为公司内部门添加什么新功能模块?",
}}
defaultOpen={true}
/>
</CopilotKit>
); );
} }

35
src/components/AuthGuard.tsx

@ -0,0 +1,35 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const [isAuthorized, setIsAuthorized] = useState(false);
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
const userId = localStorage.getItem("crm_user_id");
if (!userId && pathname !== "/login") {
router.push("/login");
} else if (userId && pathname === "/login") {
router.push("/");
} else {
setIsAuthorized(true);
}
}, [pathname, router]);
if (!isAuthorized && pathname !== "/login") {
return (
<div className="min-h-screen w-full flex items-center justify-center bg-slate-50">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-indigo-600/20 border-t-indigo-600 rounded-full animate-spin"></div>
<p className="text-slate-500 font-bold animate-pulse">...</p>
</div>
</div>
);
}
return <>{children}</>;
}

21
src/components/CopilotProvider.tsx

@ -0,0 +1,21 @@
"use client";
import { CopilotKit } from "@copilotkit/react-core";
import { CopilotPopup } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
export default function CopilotProvider({ children }: { children: React.ReactNode }) {
return (
<CopilotKit runtimeUrl="/api/copilotkit">
{children}
<CopilotPopup
instructions="你是一个嵌入在高级企业 CRM 系统中的智能架构师。你可以通过 renderDynamicForm 为用户渲染新模块。当用户询问系统中有哪些模块或需要分析现有 schema 时,你可以调用 analyzeSystemModules 获取当前系统中已定义的业务模块及其 JSON Schema 进行分析并回答用户。"
labels={{
title: "🤖 AI 业务助理",
initial: "您好,汪总。您今天想为公司内部门添加什么新功能模块?",
}}
defaultOpen={true}
/>
</CopilotKit>
);
}

180
src/components/DynamicForm.tsx

@ -0,0 +1,180 @@
"use client";
import { useState, useMemo, useEffect } from "react";
/**
* ()
*/
export function EntitySelect({ targetCode, value, onChange }: { targetCode: string, value: any, onChange: (v: any) => void }) {
const [options, setOptions] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchOptions = async () => {
const userId = localStorage.getItem("crm_user_id") || "anonymous";
try {
const res = await fetch(`/api/dynamic/code/${targetCode}`, {
headers: { 'X-Creator-Id': userId }
});
if (res.ok) {
const data = await res.json();
setOptions(data);
}
} catch (err) {
console.error("加载关联实体数据失败:", err);
} finally {
setLoading(false);
}
};
fetchOptions();
}, [targetCode]);
return (
<select
className="w-full px-6 py-5 bg-slate-50 border-2 border-transparent rounded-2xl focus:bg-white focus:border-indigo-400 outline-none text-slate-800 font-medium shadow-sm transition-all"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{loading ? "📦 正在拉取关联数据..." : "--- 请选择 ---"}</option>
{options.map((opt: any) => {
const detail = typeof opt.data === 'string' ? JSON.parse(opt.data) : opt.data;
const label = detail.name || detail.title || detail.entityName || `记录 #${opt.id.slice(-6)}`;
return <option key={opt.id} value={opt.id}>{label}</option>;
})}
</select>
);
}
interface DynamicFormProps {
entityName: string;
entityCode: string;
jsonSchema: string;
initialData?: Record<string, any>;
onSuccess?: () => void;
}
/**
* ()
*/
export default function DynamicForm({ entityName, entityCode, jsonSchema, initialData, onSuccess }: DynamicFormProps) {
const [formData, setFormData] = useState<Record<string, any>>(initialData || {});
const [isSaving, setIsSaving] = useState(false);
const schemaObj = useMemo(() => {
try {
return JSON.parse(jsonSchema);
} catch (e) {
console.error("Schema 解析失败:", e);
return null;
}
}, [jsonSchema]);
if (!schemaObj) return <div className="p-4 text-red-500 italic">...</div>;
const properties = schemaObj.properties || {};
const handleSave = async () => {
if (Object.keys(formData).length === 0) {
alert("请至少填写一项数据后再保存。");
return;
}
setIsSaving(true);
try {
const userId = localStorage.getItem("crm_user_id") || "anonymous";
const aiMessage = `【业务提交报文】\n` +
`当前模块:${entityName} (${entityCode})\n` +
`Schema 结构:${jsonSchema}\n` +
`录入事实数据:${JSON.stringify(formData)}\n\n` +
`请 AI 自动调度工具完成此模块的校验注册与数据入库工作。`;
const response = await fetch(`/api/agent/chat?sessionId=${userId}_SESSION_${entityCode}`, {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
'X-Creator-Id': userId
},
body: aiMessage
});
if (response.ok) {
const resultText = await response.text();
alert(`✅ AI 已成功处理!\n反馈: ${resultText}`);
window.dispatchEvent(new CustomEvent('ENTITY_CREATED'));
if (onSuccess) onSuccess();
setFormData({});
} else {
const errorMsg = await response.text();
alert(`❌ 处理失败: ${errorMsg}`);
}
} catch (err) {
alert("⚠ 网络故障,请确保后端服务正常。");
} finally {
setIsSaving(false);
}
};
return (
<div className="p-8 bg-white rounded-3xl">
<h3 className="text-2xl font-black mb-8 border-b pb-6 text-slate-800 flex items-center gap-3">
<span className="p-2 bg-indigo-600 rounded-xl text-white"></span>
: {entityName}
</h3>
<div className="space-y-6">
{Object.entries(properties).map(([key, field]: [string, any]) => {
const targetEntity = field['x-link-entity'];
const enumOptions = field.enum;
const isRequired = schemaObj.required?.includes(key) || field.required === true;
return (
<div key={key} className="flex flex-col space-y-2">
<label className="text-sm font-bold text-slate-500 uppercase flex items-center gap-2">
{field.title || key}
{isRequired && (
<span className="inline-flex items-center justify-center bg-gradient-to-r from-red-500 via-pink-500 to-rose-600 bg-clip-text text-transparent font-black text-xl leading-none select-none animate-pulse">
*
</span>
)}
</label>
{targetEntity ? (
<EntitySelect
targetCode={targetEntity}
value={formData[key]}
onChange={(v) => setFormData(prev => ({ ...prev, [key]: v }))}
/>
) : enumOptions ? (
<select
className="w-full px-6 py-4 bg-slate-50 border-2 border-transparent rounded-2xl focus:bg-white focus:border-indigo-400 outline-none text-slate-800 font-medium"
value={formData[key] || ""}
onChange={(e) => setFormData(prev => ({ ...prev, [key]: e.target.value }))}
>
<option value="">...</option>
{enumOptions.map((opt: any) => <option key={opt} value={opt}>{opt}</option>)}
</select>
) : (
<input
className="w-full px-6 py-4 bg-slate-50 border-2 border-transparent rounded-2xl focus:bg-white focus:border-indigo-400 outline-none text-slate-800 font-medium"
type={field.format === 'date' ? 'date' : (field.type === 'number' ? 'number' : 'text')}
placeholder={`请输入 ${field.title || key}...`}
value={formData[key] || ""}
onChange={(e) => setFormData(prev => ({ ...prev, [key]: e.target.value }))}
/>
)}
</div>
);
})}
</div>
<button
onClick={handleSave}
disabled={isSaving}
className="w-full mt-10 p-5 bg-gradient-to-br from-indigo-600 to-blue-700 text-white rounded-2xl font-black text-lg shadow-xl"
>
{isSaving ? "AI 处理中..." : "🚀 提交业务记录"}
</button>
</div>
);
}

52
src/components/Sidebar.tsx

@ -3,15 +3,27 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useCopilotReadable } from "@copilotkit/react-core";
export default function Sidebar() { export default function Sidebar() {
const [entities, setEntities] = useState<any[]>([]); const [entities, setEntities] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => { // 暴露当前系统中的模块列表给 Copilot 智能体,使其能够回答“系统里有什么模块”这类问题
useCopilotReadable({
description: "当前系统中已定义的业务模块列表(包括 entityCode, entityName, schemaDefinition)",
value: entities,
});
const fetchEntities = () => {
const userId = localStorage.getItem("crm_user_id") || "anonymous";
// Asynchronously fetch from API // Asynchronously fetch from API
fetch("http://localhost:8080/api/entities") fetch("/api/entities", {
headers: {
'X-Creator-Id': userId
}
})
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
setEntities(data); setEntities(data);
@ -21,6 +33,21 @@ export default function Sidebar() {
console.error("Failed to fetch entities", err); console.error("Failed to fetch entities", err);
setLoading(false); setLoading(false);
}); });
};
useEffect(() => {
fetchEntities();
// 1. 监听自定义事件触发即时刷新 (AI 生成或手动提交后)
window.addEventListener('ENTITY_CREATED', fetchEntities);
// 2. 兜底方案:每 10 秒主动拉取一次,确保其他 Session 的变动可见
const timer = setInterval(fetchEntities, 30000);
return () => {
window.removeEventListener('ENTITY_CREATED', fetchEntities);
clearInterval(timer);
};
}, []); }, []);
return ( return (
@ -30,7 +57,7 @@ export default function Sidebar() {
<span className="text-white text-xl font-black">AI</span> <span className="text-white text-xl font-black">AI</span>
</div> </div>
<div> <div>
<h2 className="text-xl font-black tracking-tight text-white">Copilot CRM</h2> <h2 className="text-xl font-black tracking-tight text-white">AI-Native CRM</h2>
<p className="text-xs text-slate-500 font-medium"></p> <p className="text-xs text-slate-500 font-medium"></p>
</div> </div>
</div> </div>
@ -47,7 +74,7 @@ export default function Sidebar() {
{!loading && entities.length === 0 ? ( {!loading && entities.length === 0 ? (
<div className="px-4 py-8 text-sm text-slate-500 text-center bg-slate-800/20 rounded-2xl border border-slate-800/50 border-dashed"> <div className="px-4 py-8 text-sm text-slate-500 text-center bg-slate-800/20 rounded-2xl border border-slate-800/50 border-dashed">
<br/><span className="text-xs mt-2 block"> AI </span> <br /><span className="text-xs mt-2 block"> AI </span>
</div> </div>
) : ( ) : (
entities.map((ent, idx) => { entities.map((ent, idx) => {
@ -70,14 +97,23 @@ export default function Sidebar() {
<div className="p-6 border-t border-slate-800/50 relative overflow-hidden"> <div className="p-6 border-t border-slate-800/50 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-indigo-500/10 rounded-bl-full -z-10 blur-xl"></div> <div className="absolute top-0 right-0 w-32 h-32 bg-indigo-500/10 rounded-bl-full -z-10 blur-xl"></div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-800 border-2 border-slate-700 flex items-center justify-center font-bold text-white relative"> <div className="w-10 h-10 rounded-full bg-slate-800 border-2 border-slate-700 flex items-center justify-center font-bold text-white relative uppercase">
AD {(typeof window !== 'undefined' ? localStorage.getItem("crm_user_id") : "AD")?.slice(0, 2) || "AD"}
<div className="absolute bottom-0 right-0 w-3 h-3 bg-green-400 rounded-full border-2 border-slate-900"></div> <div className="absolute bottom-0 right-0 w-3 h-3 bg-green-400 rounded-full border-2 border-slate-900"></div>
</div> </div>
<div> <div>
<p className="text-sm font-bold text-white uppercase tracking-wider">ADMIN 1</p> <p className="text-sm font-bold text-white uppercase tracking-wider truncate max-w-[120px]">
<p className="text-xs text-slate-500"></p> {typeof window !== 'undefined' ? localStorage.getItem("crm_user_id") : "ADMIN 1"}
</p>
<p className="text-xs text-slate-500"></p>
</div> </div>
<button
onClick={() => { localStorage.removeItem("crm_user_id"); window.location.reload(); }}
className="ml-auto text-slate-600 hover:text-rose-400 p-2 transition-colors"
title="退出登录"
>
</button>
</div> </div>
</div> </div>
</aside> </aside>

163
src/components/UniversalModuleRenderer.tsx

@ -7,132 +7,71 @@ import { useState, useMemo, useEffect } from "react";
* (State ) * (State )
* State * State
*/ */
function DynamicForm({ args }: { args: any }) { import DynamicForm from "./DynamicForm";
// 状态管理:用于收集动态表单中用户填入的各字段值
const [formData, setFormData] = useState<Record<string, any>>({});
const [isSaving, setIsSaving] = useState(false);
// 1. 防御性检查:确保输入参数有效
if (!args || !args.jsonSchema) return null;
// 2. 解析 JSON Schema (使用 useMemo 避免每次渲染都重解析)
const schemaObj = useMemo(() => {
try {
return JSON.parse(args.jsonSchema);
} catch (e) {
console.error("Schema 解析失败:", e);
return null;
}
}, [args.jsonSchema]);
if (!schemaObj) return <div className="p-4 text-red-500 italic">...</div>;
const properties = schemaObj.properties || {};
// 落地数据库逻辑
const handleSave = async () => {
if (Object.keys(formData).length === 0) {
alert("请至少填写一项数据后再保存。");
return;
}
setIsSaving(true);
try {
// 优先确保后端已经创建了该模块定义(解决时序问题:AI可能在前端直接渲染,而后端还未来得及/或者没有注册模块)
try {
await fetch(`http://localhost:8080/api/dynamic/schema`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// 注意:后端接受属性包括 entityCode, entityName, schemaDefinition
body: JSON.stringify({
entityCode: args.entityCode,
entityName: args.entityName,
schemaDefinition: args.jsonSchema
})
});
} catch (err) {
console.warn("提前注册模块时发生网络异常:", err);
}
const response = await fetch(`http://localhost:8080/api/dynamic/code/${args.entityCode}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Creator-Id': 'ADMIN_001'
},
body: JSON.stringify(formData)
});
if (response.ok) {
alert(`✅ 数据落库成功!已存入 PostgreSQL。\n模块: ${args.entityName}`);
setFormData({}); // 清空表单
} else {
const errorMsg = await response.text();
alert(`❌ 虽然表单生成了,但保存失败: ${errorMsg}`);
}
} catch (err) {
alert("⚠ 网络故障,请确保后端 8080 服务正在运行。");
} finally {
setIsSaving(false);
}
};
return (
<div className="p-10 border-2 border-indigo-100 bg-white rounded-3xl shadow-2xl mt-8">
<h3 className="text-3xl font-black mb-8 border-b pb-6 text-slate-800 flex items-center gap-3">
<span className="p-2 bg-indigo-600 rounded-xl text-white"></span>
{args.entityName}
</h3>
<div className="space-y-8">
{Object.entries(properties).map(([key, field]: [string, any]) => (
<div key={key} className="flex flex-col space-y-3">
<label className="text-sm font-bold text-slate-500 uppercase flex items-center gap-2">
{field.title || key}
{field.type === 'number' && <span className="text-[10px] bg-slate-100 text-slate-400 px-1.5 py-0.5 rounded"></span>}
</label>
<input
className="w-full px-6 py-5 bg-slate-50 border-2 border-transparent rounded-2xl focus:bg-white focus:border-indigo-400 focus:ring-8 focus:ring-indigo-50 transition-all outline-none text-slate-800 font-medium shadow-sm"
placeholder={`请输入 ${field.title || key}...`}
value={formData[key] || ""}
onChange={(e) => setFormData(prev => ({ ...prev, [key]: e.target.value }))}
/>
</div>
))}
</div>
<button
onClick={handleSave}
disabled={isSaving}
className="w-full mt-12 p-6 bg-gradient-to-br from-indigo-600 to-blue-700 text-white rounded-2xl font-black text-xl hover:shadow-2xl hover:translate-y-[-2px] active:translate-y-[1px] transition-all disabled:opacity-50"
>
{isSaving ? "正在同步到数据库..." : "🚀 确认提交所有录入数据"}
</button>
</div>
);
}
/** /**
* *
* AI Action DynamicForm * AI Action DynamicForm
*/ */
export default function UniversalModuleRenderer() { export default function UniversalModuleRenderer() {
const [activeForm, setActiveForm] = useState<{ entityName: string, entityCode: string, jsonSchema: string } | null>(null);
useCopilotAction({ useCopilotAction({
name: "renderDynamicForm", name: "renderDynamicForm",
description: "在页面上为一个新的业务模块渲染基于 JSON Schema 的动态表单。", description: "在页面上方展示并渲染基于 JSON Schema 的动态表单。",
parameters: [ parameters: [
{ name: "entityName", type: "string" }, { name: "entityName", type: "string", description: "模块名称" },
{ name: "entityCode", type: "string" }, { name: "entityCode", type: "string", description: "模块编码" },
{ name: "jsonSchema", type: "string" } { name: "jsonSchema", type: "string", description: "JSON Schema 定义" }
], ],
handler: async (args) => { handler: async (args) => {
// 通过 AI 下发指令时,设置状态让页面渲染
setActiveForm({
entityName: args.entityName,
entityCode: args.entityCode,
jsonSchema: args.jsonSchema,
});
window.dispatchEvent(new CustomEvent('ENTITY_CREATED'));
return `已确认 ${args.entityName} 的表单渲染指令。`; return `已确认 ${args.entityName} 的表单渲染指令。`;
}, },
render: (props: any) => { });
// 将 AI 的渲染逻辑委托给 DynamicForm,实现 State 永久隔离,彻底解决输入焦点问题。
return <DynamicForm args={props.args} />; // 动作 2:主动拉取并分析系统中已有的模块
useCopilotAction({
name: "analyzeSystemModules",
description: "从后端接口获取当前系统中已有的所有业务模块及其 JSON Schema 定义进行分析。",
handler: async () => {
try {
const res = await fetch("/api/entities");
const data = await res.json();
if (data.length === 0) {
return "系统中当前没有任何业务模块。";
}
return `系统中目前有 ${data.length} 个模块:\n` +
data.map((m: any) => `- ${m.entityName} (${m.entityCode}): ${m.schemaDefinition}`).join("\n");
} catch (err) {
return "从后端获取模块列表失败。";
}
} }
}); });
return null; if (!activeForm) {
return (
<div className="w-full bg-white border border-gray-200 border-dashed p-10 rounded-xl min-h-[400px] flex flex-col items-center justify-center relative shadow-inner">
<svg className="w-12 h-12 text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
<p className="text-gray-400 font-medium italic"> AI ...</p>
</div>
);
}
return (
<div className="mt-8 border-2 border-indigo-100 rounded-3xl shadow-2xl overflow-hidden bg-white animate-in fade-in slide-in-from-bottom-5 duration-700">
<DynamicForm
entityName={activeForm.entityName}
entityCode={activeForm.entityCode}
jsonSchema={activeForm.jsonSchema}
/>
</div>
);
} }

Loading…
Cancel
Save