feat: Introduce new UI components, implement dynamic sidebar with entity pages, and switch CopilotKit backend to Google Generative AI.
This commit is contained in:
Generated
+10234
-3481
File diff suppressed because it is too large
Load Diff
+25
-14
@@ -6,28 +6,39 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.2.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "15.1.0",
|
||||
"openai": "^4.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"shiki": "^4.0.2",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"tailwindcss": "^4",
|
||||
"eslint-config-next": "15.1.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,14 +1,32 @@
|
||||
import { CopilotRuntime, OpenAIAdapter } from "@copilotkit/backend";
|
||||
import OpenAI from "openai";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
GoogleGenerativeAIAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint
|
||||
} from "@copilotkit/runtime";
|
||||
import { GoogleGenerativeAI } from "@google/generative-ai";
|
||||
|
||||
// 将您的 API Key 放在同级目录或应用根目录的 .env 文件中
|
||||
export const runtime = 'edge';
|
||||
export const POST = async (req: Request) => {
|
||||
// 1. 初始化 Google Gemini 客户端
|
||||
const genAI = new GoogleGenerativeAI(process.env.GOOGLE_API_KEY || "");
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// 如果环境变量未就绪,使用默认的容错模式(要求客户稍后补齐)
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY || "dummy-key-for-local-test" });
|
||||
// 2. 选择模型 (使用 3.1 Pro)
|
||||
const model = genAI.getGenerativeModel({
|
||||
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);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+45
-116
@@ -1,130 +1,59 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--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);
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
: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);
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 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);
|
||||
--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);
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
+11
-4
@@ -12,9 +12,11 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "AI-Native CRM",
|
||||
description: "Generated by CopilotKit",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,10 +26,15 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang="zh"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ import UniversalModuleRenderer from "@/components/UniversalModuleRenderer";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
// 【关键修改点】在 CopilotKit 组件上增加 provider="openai"
|
||||
<CopilotKit runtimeUrl="/api/copilotkit">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 这里挂载在页面后台,负责监听并执行 AI 下发的 UI 画指令 */}
|
||||
{/* 这里挂载在页面后台,负责监听并执行 AI 下发的 UI 绘画指令 */}
|
||||
<UniversalModuleRenderer />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +1,138 @@
|
||||
"use client";
|
||||
|
||||
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 树“扔”到前端界面里!
|
||||
useCopilotAction({
|
||||
name: "renderDynamicForm",
|
||||
description: "在页面上为一个新的业务模块渲染基于 JSON Schema 的动态表单,使用在对话中。",
|
||||
parameters: [
|
||||
{ name: "entityName", type: "string", description: "表单或业务实体的中文名称,如'客户拜访记录'" },
|
||||
{ name: "entityCode", type: "string", description: "该业务的唯一英文编码,如'customer_visit'" },
|
||||
{ name: "jsonSchema", type: "string", description: "完全按照刚才设计的 JSON Schema 标准化字符串" }
|
||||
],
|
||||
handler: async ({ entityName, entityCode, jsonSchema }) => {
|
||||
// 这个动作代表 AI 的渲染指令已经送达前端
|
||||
return "UI 渲染通道已打开并呈现给用户。";
|
||||
},
|
||||
render: ({ status, args }) => {
|
||||
// 状态机:大模型还在努力生成字的时候显示打草稿状态
|
||||
if (status === "inProgress") {
|
||||
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)...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 状态机:大模型生成完毕
|
||||
if (!args.jsonSchema) return null;
|
||||
// 1. 防御性检查:确保输入参数有效
|
||||
if (!args || !args.jsonSchema) return null;
|
||||
|
||||
// 2. 解析 JSON Schema (使用 useMemo 避免每次渲染都重解析)
|
||||
const schemaObj = useMemo(() => {
|
||||
try {
|
||||
// 安全地解析大模型幻觉可能导致的脏 JSON
|
||||
const schemaObj = JSON.parse(args.jsonSchema);
|
||||
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-6 border rounded-xl shadow-lg bg-white mt-4 transition-all duration-300 transform scale-100">
|
||||
<div className="flex items-center justify-between mb-6 border-b pb-4">
|
||||
<h3 className="text-xl font-bold text-gray-800">{args.entityName}</h3>
|
||||
<span className="text-xs bg-zinc-100 px-2 py-1 rounded text-zinc-500">{args.entityCode}</span>
|
||||
</div>
|
||||
<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-5">
|
||||
{Object.entries(schemaObj.properties || {}).map(([key, field]: [string, any]) => (
|
||||
<div key={key} className="flex flex-col space-y-1.5">
|
||||
<label className="text-sm font-semibold text-gray-700">{field.title || key}</label>
|
||||
<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
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
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}`}
|
||||
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>
|
||||
<div className="mt-8 pt-4 border-t flex justify-end">
|
||||
|
||||
<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={() => alert('这里将调用 Spring Boot 的 /api/dynamic 接口入库!')}
|
||||
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"
|
||||
>
|
||||
保存数据到后端 JSONB
|
||||
{isSaving ? "正在同步到数据库..." : "🚀 确认提交所有录入数据"}
|
||||
</button>
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
+12
-4
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -11,7 +15,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
@@ -19,7 +23,9 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -30,5 +36,7 @@
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user