Browse Source

feat: Introduce new UI components, implement dynamic sidebar with entity pages, and switch CopilotKit backend to Google Generative AI.

main
灰灰 1 week ago
parent
commit
01247ef5d6
  1. 19293
      package-lock.json
  2. 39
      package.json
  3. 6
      postcss.config.js
  4. 38
      src/app/api/copilotkit/route.ts
  5. 224
      src/app/data/[entityCode]/page.tsx
  6. 167
      src/app/globals.css
  7. 15
      src/app/layout.tsx
  8. 3
      src/app/page.tsx
  9. 85
      src/components/Sidebar.tsx
  10. 169
      src/components/UniversalModuleRenderer.tsx
  11. 103
      src/components/ui/card.tsx
  12. 20
      src/components/ui/input.tsx
  13. 20
      src/components/ui/label.tsx
  14. 201
      src/components/ui/select.tsx
  15. 116
      src/components/ui/table.tsx
  16. 63
      tailwind.config.ts
  17. 16
      tsconfig.json

19293
package-lock.json generated

File diff suppressed because it is too large Load Diff

39
package.json

@ -6,28 +6,39 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
"class-variance-authority": "^0.7.1", "@copilotkit/react-core": "latest",
"@copilotkit/react-ui": "latest",
"@copilotkit/runtime": "^1.54.0",
"@google/generative-ai": "^0.24.1",
"@hookform/resolvers": "^3.9.1",
"@swc/helpers": "^0.5.19",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.454.0",
"next": "16.2.1", "next": "15.1.0",
"react": "19.2.4", "openai": "^4.0.0",
"react-dom": "19.2.4", "react": "^18.3.1",
"shadcn": "^4.1.0", "react-dom": "^18.3.1",
"tailwind-merge": "^3.5.0", "react-hook-form": "^7.53.1",
"tw-animate-css": "^1.4.0" "shiki": "^4.0.2",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^18",
"@types/react-dom": "^19", "@types/react-dom": "^18",
"autoprefixer": "^10.4.20",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.1", "eslint-config-next": "15.1.0",
"tailwindcss": "^4", "postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5" "typescript": "^5"
} }
} }

6
postcss.config.js

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

38
src/app/api/copilotkit/route.ts

@ -1,14 +1,32 @@
import { CopilotRuntime, OpenAIAdapter } from "@copilotkit/backend"; import {
import OpenAI from "openai"; CopilotRuntime,
GoogleGenerativeAIAdapter,
copilotRuntimeNextJSAppRouterEndpoint
} from "@copilotkit/runtime";
import { GoogleGenerativeAI } from "@google/generative-ai";
// 将您的 API Key 放在同级目录或应用根目录的 .env 文件中 export const POST = async (req: Request) => {
export const runtime = 'edge'; // 1. 初始化 Google Gemini 客户端
const genAI = new GoogleGenerativeAI(process.env.GOOGLE_API_KEY || "");
export async function POST(req: Request) { // 2. 选择模型 (使用 3.1 Pro)
// 如果环境变量未就绪,使用默认的容错模式(要求客户稍后补齐) const model = genAI.getGenerativeModel({
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY || "dummy-key-for-local-test" }); model: "gemini-3.1-pro",
// 如果需要启用思维模型等高级参数,可以在这里或 adapter 层级配置
});
const copilotKit = new CopilotRuntime(); const runtime = new CopilotRuntime();
return copilotKit.response(req, new OpenAIAdapter({ openai })); // 3. 使用 GoogleGenerativeAIAdapter
} const serviceAdapter = new GoogleGenerativeAIAdapter({
model: "gemini-1.5-pro"
});
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit",
});
return handleRequest(req);
};

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

@ -0,0 +1,224 @@
"use client";
import { useEffect, useState, use } from "react";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
getSortedRowModel,
SortingState,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
interface DataPageProps {
params: Promise<{
entityCode: string;
}>;
}
export default function EntityDataPage({ params: paramsPromise }: DataPageProps) {
// Use React.use() to unwrap Next.js 15+ dynamic params Promise
const params = use(paramsPromise);
const { entityCode } = params;
const [entity, setEntity] = useState<any>(null);
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [columns, setColumns] = useState<ColumnDef<any>[]>([]);
useEffect(() => {
async function loadData() {
setLoading(true);
try {
const [entityRes, dataRes] = await Promise.all([
fetch(`http://localhost:8080/api/entities/code/${entityCode}`),
fetch(`http://localhost:8080/api/dynamic/code/${entityCode}`)
]);
if (entityRes.ok && dataRes.ok) {
const entityData = await entityRes.json();
const records = await dataRes.json();
setEntity(entityData);
// Map backend entities to flat row format
const mappedData = records.map((r: any) => {
const parsedData = (typeof r.data === 'string') ? JSON.parse(r.data) : r.data;
return {
_id: r.id,
_createdAt: r.createdAt,
...parsedData
};
});
setData(mappedData);
// Build generic columns directly from Schema
const dynamicColumns: ColumnDef<any>[] = [];
if (entityData && entityData.schemaDefinition) {
try {
const schema = JSON.parse(entityData.schemaDefinition);
if (schema.properties) {
Object.keys(schema.properties).forEach((key) => {
const prop = schema.properties[key];
dynamicColumns.push({
accessorKey: key,
header: prop.title || key,
cell: ({ row }) => {
const cellValue = row.getValue(key);
return cellValue !== undefined && cellValue !== null ? String(cellValue) : "-";
}
});
});
}
} 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 {
setLoading(false);
}
}
loadData();
}, [entityCode]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: setSorting,
state: {
sorting,
},
});
if (loading) {
return (
<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 (
<div className="p-8 md:p-12 max-w-7xl mx-auto flex flex-col h-full gap-8 max-h-screen">
<div className="flex flex-col gap-2 border-b border-slate-200 pb-6 shrink-0">
<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>
{entity.entityName}
</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>
</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">
<Table>
<TableHeader className="bg-slate-100/80 sticky top-0 z-10 backdrop-blur-md shadow-sm">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-b-slate-200 hover:bg-transparent">
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="font-bold text-slate-600 whitespace-nowrap h-12 uppercase text-xs tracking-wider">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="hover:bg-indigo-50/50 border-b-slate-100 transition-colors"
>
{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>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between py-4 px-6 border-t border-slate-200 bg-white shrink-0">
<div className="text-sm font-medium text-slate-500">
<span className="font-bold text-indigo-600">{data.length}</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
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>
);
}

167
src/app/globals.css

@ -1,130 +1,59 @@
@import "tailwindcss"; @tailwind base;
@import "tw-animate-css"; @tailwind components;
@import "shadcn/tailwind.css"; @tailwind utilities;
@custom-variant dark (&:is(.dark *)); @layer base {
:root {
@theme inline { --background: 0 0% 100%;
--color-background: var(--background); --foreground: 222.2 84% 4.9%;
--color-foreground: var(--foreground); --card: 0 0% 100%;
--font-sans: var(--font-sans); --card-foreground: 222.2 84% 4.9%;
--font-mono: var(--font-geist-mono); --popover: 0 0% 100%;
--font-heading: var(--font-sans); --popover-foreground: 222.2 84% 4.9%;
--color-sidebar-ring: var(--sidebar-ring); --primary: 221.2 83.2% 53.3%;
--color-sidebar-border: var(--sidebar-border); --primary-foreground: 210 40% 98%;
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --secondary: 210 40% 96.1%;
--color-sidebar-accent: var(--sidebar-accent); --secondary-foreground: 222.2 47.4% 11.2%;
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --muted: 210 40% 96.1%;
--color-sidebar-primary: var(--sidebar-primary); --muted-foreground: 215.4 16.3% 46.9%;
--color-sidebar-foreground: var(--sidebar-foreground); --accent: 210 40% 96.1%;
--color-sidebar: var(--sidebar); --accent-foreground: 222.2 47.4% 11.2%;
--color-chart-5: var(--chart-5); --destructive: 0 84.2% 60.2%;
--color-chart-4: var(--chart-4); --destructive-foreground: 210 40% 98%;
--color-chart-3: var(--chart-3); --border: 214.3 31.8% 91.4%;
--color-chart-2: var(--chart-2); --input: 214.3 31.8% 91.4%;
--color-chart-1: var(--chart-1); --ring: 221.2 83.2% 53.3%;
--color-ring: var(--ring); --radius: 0.5rem;
--color-input: var(--input); }
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark { .dark {
--background: oklch(0.145 0 0); --background: 222.2 84% 4.9%;
--foreground: oklch(0.985 0 0); --foreground: 210 40% 98%;
--card: oklch(0.205 0 0); --card: 222.2 84% 4.9%;
--card-foreground: oklch(0.985 0 0); --card-foreground: 210 40% 98%;
--popover: oklch(0.205 0 0); --popover: 222.2 84% 4.9%;
--popover-foreground: oklch(0.985 0 0); --popover-foreground: 210 40% 98%;
--primary: oklch(0.922 0 0); --primary: 217.2 91.2% 59.8%;
--primary-foreground: oklch(0.205 0 0); --primary-foreground: 222.2 47.4% 11.2%;
--secondary: oklch(0.269 0 0); --secondary: 217.2 32.6% 17.5%;
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: 210 40% 98%;
--muted: oklch(0.269 0 0); --muted: 217.2 32.6% 17.5%;
--muted-foreground: oklch(0.708 0 0); --muted-foreground: 215 20.2% 65.1%;
--accent: oklch(0.269 0 0); --accent: 217.2 32.6% 17.5%;
--accent-foreground: oklch(0.985 0 0); --accent-foreground: 210 40% 98%;
--destructive: oklch(0.704 0.191 22.216); --destructive: 0 62.8% 30.6%;
--border: oklch(1 0 0 / 10%); --destructive-foreground: 210 40% 98%;
--input: oklch(1 0 0 / 15%); --border: 217.2 32.6% 17.5%;
--ring: oklch(0.556 0 0); --input: 217.2 32.6% 17.5%;
--chart-1: oklch(0.87 0 0); --ring: 224.3 76.3% 48%;
--chart-2: oklch(0.556 0 0); }
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
html {
@apply font-sans;
}
} }

15
src/app/layout.tsx

@ -12,9 +12,11 @@ const geistMono = Geist_Mono({
subsets: ["latin"], subsets: ["latin"],
}); });
import Sidebar from "@/components/Sidebar";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "AI-Native CRM",
description: "Generated by create next app", description: "Generated by CopilotKit",
}; };
export default function RootLayout({ export default function RootLayout({
@ -24,10 +26,15 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html <html
lang="en" lang="zh"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="flex h-screen overflow-hidden bg-slate-50 relative">
<Sidebar />
<div className="flex-1 overflow-y-auto">
{children}
</div>
</body>
</html> </html>
); );
} }

3
src/app/page.tsx

@ -7,6 +7,7 @@ import UniversalModuleRenderer from "@/components/UniversalModuleRenderer";
export default function Home() { export default function Home() {
return ( return (
// 【关键修改点】在 CopilotKit 组件上增加 provider="openai"
<CopilotKit runtimeUrl="/api/copilotkit"> <CopilotKit runtimeUrl="/api/copilotkit">
<div className="min-h-screen bg-slate-50 font-[family-name:var(--font-geist-sans)]"> <div className="min-h-screen bg-slate-50 font-[family-name:var(--font-geist-sans)]">
{/* 顶部导航 */} {/* 顶部导航 */}
@ -31,7 +32,7 @@ export default function Home() {
<p className="text-gray-400 font-medium"> AI (Generative UI Render Area)...</p> <p className="text-gray-400 font-medium"> AI (Generative UI Render Area)...</p>
</div> </div>
{/* 这里挂载在页面后台,负责监听并执行 AI 下发的 UI 画指令 */} {/* 这里挂载在页面后台,负责监听并执行 AI 下发的 UI 画指令 */}
<UniversalModuleRenderer /> <UniversalModuleRenderer />
</main> </main>
</div> </div>

85
src/components/Sidebar.tsx

@ -0,0 +1,85 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function Sidebar() {
const [entities, setEntities] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const pathname = usePathname();
useEffect(() => {
// Asynchronously fetch from API
fetch("http://localhost:8080/api/entities")
.then(res => res.json())
.then(data => {
setEntities(data);
setLoading(false);
})
.catch(err => {
console.error("Failed to fetch entities", err);
setLoading(false);
});
}, []);
return (
<aside className="w-72 bg-slate-900 border-r border-slate-800 text-slate-300 flex flex-col h-full shrink-0 shadow-2xl transition-all relative z-10">
<div className="p-8 border-b border-slate-800/50 flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-cyan-500 flex items-center justify-center shadow-lg shadow-indigo-500/20">
<span className="text-white text-xl font-black">AI</span>
</div>
<div>
<h2 className="text-xl font-black tracking-tight text-white">Copilot CRM</h2>
<p className="text-xs text-slate-500 font-medium"></p>
</div>
</div>
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
<Link href="/" className={`flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 font-bold ${pathname === '/' ? 'bg-indigo-600/10 text-indigo-400' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'}`}>
<span>🏠</span> <span></span>
</Link>
<div className="pt-8 pb-3 px-4 text-xs font-black text-slate-600 uppercase tracking-widest flex items-center justify-between">
<span></span>
{loading && <span className="animate-spin text-indigo-400"></span>}
</div>
{!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">
<br/><span className="text-xs mt-2 block"> AI </span>
</div>
) : (
entities.map((ent, idx) => {
const href = `/data/${ent.entityCode}`;
const isActive = pathname === href;
return (
<Link
key={ent.entityCode || idx}
href={href}
className={`flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 font-medium ${isActive ? 'bg-gradient-to-r from-indigo-500 to-indigo-600 text-white shadow-xl shadow-indigo-500/20 translate-x-1' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'}`}
>
<span></span>
<span className="truncate">{ent.entityName}</span>
</Link>
);
})
)}
</nav>
<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="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">
AD
<div className="absolute bottom-0 right-0 w-3 h-3 bg-green-400 rounded-full border-2 border-slate-900"></div>
</div>
<div>
<p className="text-sm font-bold text-white uppercase tracking-wider">ADMIN 1</p>
<p className="text-xs text-slate-500"></p>
</div>
</div>
</div>
</aside>
);
}

169
src/components/UniversalModuleRenderer.tsx

@ -1,81 +1,138 @@
"use client"; "use client";
import { useCopilotAction } from "@copilotkit/react-core"; import { useCopilotAction } from "@copilotkit/react-core";
import { useState } from "react"; import { useState, useMemo, useEffect } from "react";
export default function UniversalModuleRenderer() { /**
const [currentSchema, setCurrentSchema] = useState<any>(null); * (State )
* State
*/
function DynamicForm({ args }: { args: any }) {
// 状态管理:用于收集动态表单中用户填入的各字段值
const [formData, setFormData] = useState<Record<string, any>>({});
const [isSaving, setIsSaving] = useState(false);
// 核心:这个 Action 让 AI 能够在聊天流中不仅回复文字,更能直接把这段 React 树“扔”到前端界面里! // 1. 防御性检查:确保输入参数有效
useCopilotAction({ if (!args || !args.jsonSchema) return null;
name: "renderDynamicForm",
description: "在页面上为一个新的业务模块渲染基于 JSON Schema 的动态表单,使用在对话中。", // 2. 解析 JSON Schema (使用 useMemo 避免每次渲染都重解析)
parameters: [ const schemaObj = useMemo(() => {
{ name: "entityName", type: "string", description: "表单或业务实体的中文名称,如'客户拜访记录'" }, try {
{ name: "entityCode", type: "string", description: "该业务的唯一英文编码,如'customer_visit'" }, return JSON.parse(args.jsonSchema);
{ name: "jsonSchema", type: "string", description: "完全按照刚才设计的 JSON Schema 标准化字符串" } } catch (e) {
], console.error("Schema 解析失败:", e);
handler: async ({ entityName, entityCode, jsonSchema }) => { return null;
// 这个动作代表 AI 的渲染指令已经送达前端 }
return "UI 渲染通道已打开并呈现给用户。"; }, [args.jsonSchema]);
},
render: ({ status, args }) => { if (!schemaObj) return <div className="p-4 text-red-500 italic">...</div>;
// 状态机:大模型还在努力生成字的时候显示打草稿状态
if (status === "inProgress") { const properties = schemaObj.properties || {};
return (
<div className="p-4 border-2 border-dashed border-blue-200 rounded-lg bg-blue-50/50 animate-pulse text-sm text-blue-600"> // 落地数据库逻辑
<strong>{args.entityName || '未知模块'}</strong> (Generative UI IN PROGRESS)... const handleSave = async () => {
</div> if (Object.keys(formData).length === 0) {
); alert("请至少填写一项数据后再保存。");
return;
} }
// 状态机:大模型生成完毕
if (!args.jsonSchema) return null;
setIsSaving(true);
try { try {
// 安全地解析大模型幻觉可能导致的脏 JSON // 优先确保后端已经创建了该模块定义(解决时序问题:AI可能在前端直接渲染,而后端还未来得及/或者没有注册模块)
const schemaObj = JSON.parse(args.jsonSchema); 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 ( return (
<div className="p-6 border rounded-xl shadow-lg bg-white mt-4 transition-all duration-300 transform scale-100"> <div className="p-10 border-2 border-indigo-100 bg-white rounded-3xl shadow-2xl mt-8">
<div className="flex items-center justify-between mb-6 border-b pb-4"> <h3 className="text-3xl font-black mb-8 border-b pb-6 text-slate-800 flex items-center gap-3">
<h3 className="text-xl font-bold text-gray-800">{args.entityName}</h3> <span className="p-2 bg-indigo-600 rounded-xl text-white"></span>
<span className="text-xs bg-zinc-100 px-2 py-1 rounded text-zinc-500">{args.entityCode}</span> {args.entityName}
</div> </h3>
<div className="space-y-5"> <div className="space-y-8">
{Object.entries(schemaObj.properties || {}).map(([key, field]: [string, any]) => ( {Object.entries(properties).map(([key, field]: [string, any]) => (
<div key={key} className="flex flex-col space-y-1.5"> <div key={key} className="flex flex-col space-y-3">
<label className="text-sm font-semibold text-gray-700">{field.title || key}</label> <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 <input
type={field.type === 'number' ? 'number' : 'text'} 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"
className="w-full border border-gray-300 p-2.5 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all shadow-sm text-sm" placeholder={`请输入 ${field.title || key}...`}
placeholder={`请输入 ${field.title || key}`} value={formData[key] || ""}
onChange={(e) => setFormData(prev => ({ ...prev, [key]: e.target.value }))}
/> />
</div> </div>
))} ))}
</div> </div>
<div className="mt-8 pt-4 border-t flex justify-end">
<button <button
className="px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium shadow-md transition-colors" onClick={handleSave}
onClick={() => alert('这里将调用 Spring Boot 的 /api/dynamic 接口入库!')} 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"
> >
JSONB {isSaving ? "正在同步到数据库..." : "🚀 确认提交所有录入数据"}
</button> </button>
</div> </div>
</div>
);
} catch (e) {
// 回退 / 降级 UI (Fallback UI)
return (
<div className="p-4 text-red-500 bg-red-50 border border-red-200 rounded-lg mt-4 text-sm">
<p className="font-bold mb-2"></p>
<p className="text-xs break-all">{args.jsonSchema}</p>
</div>
); );
} }
/**
*
* AI Action DynamicForm
*/
export default function UniversalModuleRenderer() {
useCopilotAction({
name: "renderDynamicForm",
description: "在页面上为一个新的业务模块渲染基于 JSON Schema 的动态表单。",
parameters: [
{ name: "entityName", type: "string" },
{ name: "entityCode", type: "string" },
{ name: "jsonSchema", type: "string" }
],
handler: async (args) => {
return `已确认 ${args.entityName} 的表单渲染指令。`;
},
render: (props: any) => {
// 将 AI 的渲染逻辑委托给 DynamicForm,实现 State 永久隔离,彻底解决输入焦点问题。
return <DynamicForm args={props.args} />;
} }
}); });
return <div className="hidden" aria-hidden="true" />; return null;
} }

103
src/components/ui/card.tsx

@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

20
src/components/ui/input.tsx

@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

20
src/components/ui/label.tsx

@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

201
src/components/ui/select.tsx

@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

116
src/components/ui/table.tsx

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

63
tailwind.config.ts

@ -0,0 +1,63 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: 'var(--background)',
foreground: 'var(--foreground)',
card: {
DEFAULT: 'var(--card)',
foreground: 'var(--card-foreground)'
},
popover: {
DEFAULT: 'var(--popover)',
foreground: 'var(--popover-foreground)'
},
primary: {
DEFAULT: 'var(--primary)',
foreground: 'var(--primary-foreground)'
},
secondary: {
DEFAULT: 'var(--secondary)',
foreground: 'var(--secondary-foreground)'
},
muted: {
DEFAULT: 'var(--muted)',
foreground: 'var(--muted-foreground)'
},
accent: {
DEFAULT: 'var(--accent)',
foreground: 'var(--accent-foreground)'
},
destructive: {
DEFAULT: 'var(--destructive)',
foreground: 'var(--destructive-foreground)'
},
border: 'var(--border)',
input: 'var(--input)',
ring: 'var(--ring)',
chart: {
'1': 'var(--chart-1)',
'2': 'var(--chart-2)',
'3': 'var(--chart-3)',
'4': 'var(--chart-4)',
'5': 'var(--chart-5)'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [require("tailwindcss-animate")],
};
export default config;

16
tsconfig.json

@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@ -11,7 +15,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "preserve",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@ -19,7 +23,9 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
}, },
"include": [ "include": [
@ -30,5 +36,7 @@
".next/dev/types/**/*.ts", ".next/dev/types/**/*.ts",
"**/*.mts" "**/*.mts"
], ],
"exclude": ["node_modules"] "exclude": [
"node_modules"
]
} }

Loading…
Cancel
Save