Files
16gagent/scripts/frontend-builder-agent.mjs
2026-06-06 10:40:48 +08:00

2113 lines
71 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Frontend Builder Agent — SF-03
*
* 根据 PRD (SF-01) + Architecture (SF-02) 自动生成前端项目。
*
* 技术栈:Next.js 15 App Router + TypeScript + Tailwind CSS
*
* 生成内容:
* - 页面组件(非 stub,包含真实 JSX/state/hooks
* - Root Layout + 导航
* - 共享组件(Card, Button, Input, Modal, 领域组件)
* - Custom Hooks(数据获取/表单/状态)
* - API Services(类型安全的 API 调用层)
* - TypeScript 类型定义
* - 项目配置(package.json, tsconfig, tailwind, next.config
*
* Usage:
* node scripts/frontend-builder-agent.mjs --prd <path> --arch <path> [options]
* node scripts/frontend-builder-agent.mjs --input-text "<需求>" [options]
*
* Options:
* --prd <path> PRD JSONSF-01 输出)
* --arch <path> Architecture JSONSF-02 输出)
* --input-text <text> 直接传入需求(自动调 SF-01 → SF-02 → SF-03
* --output <dir> 输出目录(default: frontend/
* --verbose 详细输出
* --help 显示帮助
*
* @module frontend-builder-agent
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createContract, toCamel, tsType as contractTsType, createInputFields } from "./model-contract.mjs";
const __dirname = dirname(resolve(fileURLToPath(import.meta.url)));
const WORKSPACE = resolve(__dirname, "..");
const DEFAULT_OUTPUT = resolve(WORKSPACE, "frontend");
// ═══════════════════════════════════════════════════════
// 1. TypeScript Type Generator
// ═══════════════════════════════════════════════════════
/**
* Map domain to TypeScript interfaces.
*/
function generateTypes(prd, arch, contract) {
const domain = prd.domain || "generic";
const interfaces = [`// Auto-generated types for ${prd.projectName}`];
interfaces.push("\n// ─── Base ───────────────────────────────────────────\n");
// Base types
interfaces.push(`
// ─── Base ───────────────────────────────────────────
export interface User {
id: string;
phone: string;
nickname: string;
avatarUrl: string;
createdAt: string;
updatedAt: string;
}`);
// ─── Entity types from Contract ───
for (const entity of contract.entities.filter(e => e.table !== "users")) {
const name = entity.name;
interfaces.push(`export interface ${name} {`);
for (const f of entity.fields) {
const fname = toCamel(contract, f.name);
const ft = contractTsType(f);
const optional = f.required ? "" : "?";
interfaces.push(` ${fname}${optional}: ${ft};`);
}
interfaces.push("}\n");
}
// Domain-specific extra types (legacy — for pages that need them)
const domainTypes = {
pet: `
export interface Pet {
id: string;
ownerId: string;
name: string;
species: string;
breed: string;
birthDate: string;
weightKg: number;
avatarUrl: string;
createdAt: string;
}
export interface Schedule {
id: string;
petId: string;
type: "vaccine" | "deworming" | "checkup" | "grooming";
title: string;
scheduledAt: string;
completed: boolean;
}
export interface DailyLog {
id: string;
petId: string;
category: "food" | "exercise" | "excretion" | "weight";
value: string;
loggedAt: string;
}
export interface Hospital {
id: string;
name: string;
address: string;
phone: string;
rating: number;
distance: number;
services: string[];
}`,
ecommerce: `
export interface Product {
id: string;
title: string;
price: number;
stock: number;
images: string[];
categoryId: string;
createdAt: string;
}
export interface Order {
id: string;
userId: string;
status: "pending" | "paid" | "shipped" | "delivered" | "cancelled";
totalAmount: number;
addressId: string;
createdAt: string;
}
export interface Logistics {
id: string;
orderId: string;
carrier: string;
trackingNumber: string;
status: string;
events: LogisticsEvent[];
}
export interface LogisticsEvent {
timestamp: string;
location: string;
description: string;
}`,
education: `
export interface Course {
id: string;
title: string;
instructorId: string;
price: number;
coverUrl: string;
createdAt: string;
}
export interface Lesson {
id: string;
courseId: string;
title: string;
videoUrl: string;
durationSec: number;
sortOrder: number;
}
export interface Exercise {
id: string;
lessonId: string;
question: string;
options: string[];
answer: string;
}
export interface Progress {
id: string;
userId: string;
lessonId: string;
completed: boolean;
watchedSec: number;
}`,
enterprise: `
export interface Department {
id: string;
name: string;
parentId: string | null;
managerId: string;
}
export interface Approval {
id: string;
applicantId: string;
type: "leave" | "expense" | "overtime" | "travel";
status: "pending" | "approved" | "rejected";
formData: Record<string, unknown>;
createdAt: string;
}
export interface Attendance {
id: string;
userId: string;
checkInAt: string;
checkOutAt: string | null;
location: { lat: number; lng: number; address: string };
type: "normal" | "remote" | "overtime";
}`,
fitness: `
export interface CheckIn {
id: string;
userId: string;
type: string;
durationMin: number;
calories: number;
note: string;
checkedAt: string;
}
export interface TrainingPlan {
id: string;
userId: string;
title: string;
schedule: Record<string, unknown>;
active: boolean;
}`,
note: `
export interface Note {
id: string;
userId: string;
title: string;
content: string;
isMarkdown: boolean;
folderId: string | null;
createdAt: string;
updatedAt: string;
}
export interface Tag {
id: string;
name: string;
color: string;
}`,
};
// (Entity types now generated from contract above)
// API response types
interfaces.push(`
// ─── API Responses ──────────────────────────────────
export interface ApiResponse<T> {
data: T;
message: string;
}
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
total: number;
page: number;
pageSize: number;
}
export interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}`);
return interfaces.join("\n");
}
// ═══════════════════════════════════════════════════════
// 2. API Service Generator
// ═══════════════════════════════════════════════════════
function generateApiServices(prd, arch) {
const apiDesign = arch.apiDesign || [];
const files = {};
// Base API client
files["services/api.ts"] = `// Auto-generated API client for ${prd.projectName}
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api";
interface RequestOptions extends RequestInit {
params?: Record<string, string | number>;
}
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const { params, ...init } = options;
let url = \`\${API_BASE}\${endpoint}\`;
if (params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => searchParams.set(k, String(v)));
url += \`?\${searchParams.toString()}\`;
}
const res = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...init.headers,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || \`API Error: \${res.status}\`);
}
return res.json();
}
export const api = {
get: <T>(url: string, params?: Record<string, string | number>) =>
request<T>(url, { method: "GET", params }),
post: <T>(url: string, data?: unknown) =>
request<T>(url, { method: "POST", body: JSON.stringify(data) }),
put: <T>(url: string, data?: unknown) =>
request<T>(url, { method: "PUT", body: JSON.stringify(data) }),
delete: <T>(url: string) =>
request<T>(url, { method: "DELETE" }),
};
`;
// Resource-specific services
for (const group of apiDesign) {
const resource = group.resource;
const endpoints = group.endpoints;
// Build service methods
const methods = [];
for (const ep of endpoints) {
const fnName = ep.method.toLowerCase() + ep.path
.replace(`/api/${resource}`, "")
.replace(/[\/:]/g, "_")
.replace(/^_/, "") || "list";
let fnBody;
let fnParams = "";
if (ep.method === "GET") {
if (ep.path.includes(":id")) {
fnParams = "id: string";
fnBody = `api.get(\`/api/${resource}/\${id}\`)`;
} else {
fnBody = `api.get("/api/${resource}")`;
}
} else if (ep.method === "POST") {
fnBody = `api.post("/api/${resource}", data)`;
fnParams = `data: Record<string, unknown>`;
} else if (ep.method === "PUT") {
fnParams = `id: string, data: Record<string, unknown>`;
fnBody = `api.put(\`/api/${resource}/\${id}\`, data)`;
} else if (ep.method === "DELETE") {
fnParams = "id: string";
fnBody = `api.delete(\`/api/${resource}/\${id}\`)`;
}
// Clean up function name
const cleanName = fnName.replace(/_{2,}/g, "_").replace(/_$/, "") || "list";
methods.push(`export function ${cleanName}(${fnParams}) {\n return ${fnBody};\n}`);
}
files[`services/${resource}.ts`] = `// Auto-generated ${resource} service
import { api } from "./api";
${methods.join("\n\n")}
`;
}
return files;
}
function capitalize(s) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
// ═══════════════════════════════════════════════════════
// 3. Custom Hooks Generator
// ═══════════════════════════════════════════════════════
function generateHooks(prd, arch) {
const apiDesign = arch.apiDesign || [];
const files = {};
for (const group of apiDesign) {
const resource = group.resource;
// Sanitize: "daily-logs" → "DailyLogs", remove any non-alphanumeric except underscore
const sanitized = capitalize(resource).replace(/[^a-zA-Z0-9_]/g, "");
const name = sanitized || "Item";
// Singularize type name: Albums → Album, Pets → Pet
const singularName = name.replace(/ies$/, "y").replace(/ses$/, "s").replace(/xes$/, "x").replace(/ches$/, "ch").replace(/shes$/, "sh").replace(/s$/i, "");
const typeNameForHook = singularName || name;
files[`hooks/use${name}.ts`] = `"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for ${resource}
type ${typeNameForHook} = Record<string, unknown>;
/**
* Fetch and manage ${resource} data.
*/
export function use${name}() {
const [data, setData] = useState<${typeNameForHook}[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(\`/api/${resource}\`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
`;
}
// Generic hooks
files["hooks/useForm.ts"] = `"use client";
import { useState, useCallback } from "react";
interface UseFormOptions<T> {
initialValues: T;
onSubmit: (values: T) => Promise<void>;
}
export function useForm<T extends Record<string, unknown>>({ initialValues, onSubmit }: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const setField = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
setValues(prev => ({ ...prev, [field]: value }));
}, []);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
try {
setSubmitting(true);
setError(null);
await onSubmit(values);
} catch (err) {
setError(err instanceof Error ? err.message : "Submit failed");
} finally {
setSubmitting(false);
}
}, [values, onSubmit]);
return { values, setField, submitting, error, handleSubmit };
}
`;
files["hooks/useDebounce.ts"] = `"use client";
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
`;
return files;
}
// ═══════════════════════════════════════════════════════
// 4. Component Generator
// ═══════════════════════════════════════════════════════
function generateComponents(prd, arch) {
const files = {};
const domain = prd.domain || "generic";
const projectName = prd.projectName || "App";
// ── Shared UI Components ──────────────────────
files["components/ui/Button.tsx"] = `"use client";
import { type ButtonHTMLAttributes } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
loading?: boolean;
}
export function Button({ variant = "primary", size = "md", loading, children, className = "", disabled, ...props }: ButtonProps) {
const base = "inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
const variants = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
secondary: "bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-400",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
ghost: "text-gray-600 hover:bg-gray-100 focus:ring-gray-400",
};
const sizes = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
return (
<button className={\`\${base} \${variants[variant]} \${sizes[size]} \${className}\`} disabled={disabled || loading} {...props}>
{loading && <svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>}
{children}
</button>
);
}
`;
files["components/ui/Card.tsx"] = `import type { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
onClick?: () => void;
}
export function Card({ children, className = "", onClick }: CardProps) {
return (
<div
className={\`bg-white rounded-xl shadow-sm border border-gray-100 p-4 \${onClick ? "cursor-pointer hover:shadow-md transition-shadow" : ""} \${className}\`}
onClick={onClick}
>
{children}
</div>
);
}
`;
files["components/ui/Input.tsx"] = `"use client";
import { type InputHTMLAttributes } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export function Input({ label, error, className = "", id, ...props }: InputProps) {
return (
<div className="w-full">
{label && <label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
<input
id={id}
className={\`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent \${error ? "border-red-300" : "border-gray-300"} \${className}\`}
{...props}
/>
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
</div>
);
}
`;
files["components/ui/Modal.tsx"] = `"use client";
import { useEffect, type ReactNode } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
export function Modal({ open, onClose, title, children }: ModalProps) {
useEffect(() => {
if (open) document.body.style.overflow = "hidden";
else document.body.style.overflow = "";
return () => { document.body.style.overflow = ""; };
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white rounded-2xl shadow-xl max-w-lg w-full mx-4 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
</div>
{children}
</div>
</div>
);
}
`;
files["components/ui/EmptyState.tsx"] = `import type { ReactNode } from "react";
interface EmptyStateProps {
icon?: string;
title: string;
description?: string;
action?: ReactNode;
}
export function EmptyState({ icon = "📭", title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<span className="text-5xl mb-4">{icon}</span>
<h3 className="text-lg font-medium text-gray-700 mb-1">{title}</h3>
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
{action}
</div>
);
}
`;
// ── Domain-specific components ──────────────────
if (domain === "pet") {
files["components/pet/PetCard.tsx"] = `import type { Pet } from "@/types";
import { Card } from "@/components/ui/Card";
interface PetCardProps {
pet: Pet;
onClick?: () => void;
}
export function PetCard({ pet, onClick }: PetCardProps) {
return (
<Card onClick={onClick} className="flex items-center gap-4">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-amber-100 to-orange-200 flex items-center justify-center text-2xl overflow-hidden flex-shrink-0">
{pet.avatarUrl ? (
<img src={pet.avatarUrl} alt={pet.name} className="w-full h-full object-cover" />
) : (
"🐾"
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-800 truncate">{pet.name}</h3>
<p className="text-sm text-gray-500">{pet.breed || pet.species} · {pet.weightKg}kg</p>
</div>
<svg className="w-5 h-5 text-gray-300 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Card>
);
}
`;
files["components/pet/ScheduleCard.tsx"] = `import type { Schedule } from "@/types";
interface ScheduleCardProps {
schedule: Schedule;
onToggle?: () => void;
}
export function ScheduleCard({ schedule, onToggle }: ScheduleCardProps) {
const typeIcons: Record<string, string> = {
vaccine: "💉",
deworming: "💊",
checkup: "🩺",
grooming: "✂️",
};
const date = new Date(schedule.scheduledAt);
const isOverdue = date < new Date() && !schedule.completed;
return (
<div className={\`flex items-center gap-3 p-3 rounded-lg border \${schedule.completed ? "bg-gray-50 border-gray-100" : isOverdue ? "bg-red-50 border-red-200" : "bg-white border-gray-100"}\`}>
<span className="text-2xl">{typeIcons[schedule.type] || "📅"}</span>
<div className="flex-1 min-w-0">
<p className={\`text-sm font-medium \${schedule.completed ? "text-gray-400 line-through" : "text-gray-800"}\`}>{schedule.title}</p>
<p className="text-xs text-gray-500">{date.toLocaleDateString("zh-CN", { month: "short", day: "numeric" })}</p>
</div>
<button
onClick={onToggle}
className={\`w-6 h-6 rounded-full border-2 flex items-center justify-center flex-shrink-0 \${schedule.completed ? "bg-green-500 border-green-500" : "border-gray-300"}\`}
>
{schedule.completed && <svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>}
</button>
</div>
);
}
`;
}
if (domain === "ecommerce") {
files["components/ecommerce/ProductCard.tsx"] = `import type { Product } from "@/types";
import { Card } from "@/components/ui/Card";
interface ProductCardProps {
product: Product;
onClick?: () => void;
}
export function ProductCard({ product, onClick }: ProductCardProps) {
return (
<Card onClick={onClick} className="overflow-hidden p-0">
<div className="aspect-square bg-gray-100 flex items-center justify-center text-4xl">
{product.images?.[0] ? <img src={product.images[0]} alt={product.title} className="w-full h-full object-cover" /> : "📦"}
</div>
<div className="p-3">
<h3 className="font-medium text-sm text-gray-800 truncate">{product.title}</h3>
<div className="flex items-center justify-between mt-1">
<span className="text-red-500 font-bold">¥{product.price}</span>
<span className="text-xs text-gray-400">库存 {product.stock}</span>
</div>
</div>
</Card>
);
}
`;
}
if (domain === "note") {
files["components/note/NoteCard.tsx"] = `import type { Note } from "@/types";
import { Card } from "@/components/ui/Card";
interface NoteCardProps {
note: Note;
onClick?: () => void;
}
export function NoteCard({ note, onClick }: NoteCardProps) {
return (
<Card onClick={onClick}>
<h3 className="font-medium text-gray-800 truncate">{note.title || "无标题"}</h3>
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{note.content?.slice(0, 120) || "空笔记"}</p>
<div className="flex items-center gap-2 mt-2">
{note.isMarkdown && <span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-600 rounded">MD</span>}
<span className="text-xs text-gray-400">{new Date(note.updatedAt).toLocaleDateString("zh-CN")}</span>
</div>
</Card>
);
}
`;
}
return files;
}
// ═══════════════════════════════════════════════════════
// 5. Page Generator
// ═══════════════════════════════════════════════════════
function generatePages(prd, arch) {
const pages = prd.pages || [];
const apiDesign = arch.apiDesign || [];
const domain = prd.domain || "generic";
const projectName = prd.projectName || "App";
const files = {};
// ── Home Page ──────────────────────────────────
const homePage = pages.find(p => p.route === "/home" || p.name === "首页" || p.name === "主页");
if (homePage || pages.length > 0) {
const features = prd.features?.slice(0, 4).map(f => f.name) || [];
files["app/page.tsx"] = generateHomePage(projectName, domain, homePage || pages[0], features);
}
// ── Dynamic pages ──────────────────────────────
for (const page of pages) {
const route = page.route;
if (!route || route === "/home") continue;
// Clean route: /pet/:id → pet/[id], /daily-log → daily-log
const dirPath = route
.replace(/^\//, "")
.replace(/:(\w+)/g, "[$1]")
.replace(/\/$/, "");
const pageContent = generatePageComponent(page, domain, apiDesign, prd.features);
files[`app/${dirPath}/page.tsx`] = pageContent;
}
return files;
}
/**
* Generate a full, meaningful home page component.
*/
function generateHomePage(projectName, domain, homePage, features) {
const name = homePage?.name || "首页";
const desc = homePage?.description || `${projectName} 首页`;
return `import Link from "next/link";
export default function HomePage() {
return (
<div className="space-y-6">
{/* Hero Section */}
<section className="bg-gradient-to-br from-blue-500 to-blue-700 rounded-2xl p-6 text-white">
<h1 className="text-2xl font-bold">${projectName}</h1>
<p className="mt-2 text-blue-100 text-sm">${desc}</p>
</section>
{/* Quick Actions */}
<section>
<h2 className="text-lg font-semibold text-gray-800 mb-3">快捷入口</h2>
<div className="grid grid-cols-2 gap-3">
${features.map((f, i) => {
const icons = ["📋", "📅", "📝", "📸", "🏥", "📚", "💬"];
return `<Link key="${i}" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
<span className="text-2xl block mb-1">${icons[i] || "📌"}</span>
<span className="text-sm text-gray-600">${f}</span>
</Link>`;
}).join("\n ")}
</div>
</section>
{/* Recent Activity / Stats */}
<section>
<h2 className="text-lg font-semibold text-gray-800 mb-3">今日概览</h2>
<div className="bg-white rounded-xl border border-gray-100 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">欢迎使用 ${projectName}</p>
<p className="text-xs text-gray-400 mt-1">开始探索功能吧</p>
</div>
<span className="text-3xl">👋</span>
</div>
</div>
</section>
</div>
);
}
`;
}
/**
* Generate a real page component (not a stub).
*/
function generatePageComponent(page, domain, apiDesign, features) {
const name = page.name;
const route = page.route;
const desc = page.description;
// Resource name from route: /pet/:id → pets, /schedule → schedules
const resourceSeg = route.replace(/^\//, "").split("/")[0].replace(/:.*/, "");
// Find matching API group
const apiGroup = apiDesign.find(g =>
g.resource === resourceSeg ||
g.resource === resourceSeg + "s" ||
g.resource + "s" === resourceSeg
);
const hookNameRaw = apiGroup ? capitalize(apiGroup.resource) : null;
// Singularize for type references: albums → Album, pets → Pet
const rawTypeName = apiGroup ? capitalize(apiGroup.resource) : null;
const typeName = rawTypeName ? rawTypeName.replace(/ies$/, "y").replace(/ses$/, "s").replace(/xes$/, "x").replace(/ches$/, "ch").replace(/shes$/, "sh").replace(/s$/i, "") : null;
// Generate realistic page content
switch (domain) {
case "pet":
if (route.includes("pet")) return generatePetDetailPage(name, desc);
if (route.includes("schedule")) return generateSchedulePage(name, desc);
if (route.includes("daily-log")) return generateDailyLogPage(name, desc);
if (route.includes("album")) return generateAlbumPage(name, desc);
if (route.includes("hospital")) return generateHospitalPage(name, desc);
if (route.includes("profile")) return generateProfilePage(name, desc, "pet");
break;
case "ecommerce":
if (route.includes("product")) return generateProductDetailPage(name, desc);
if (route.includes("cart")) return generateCartPage(name, desc);
if (route.includes("order")) return generateOrderPage(name, desc);
if (route.includes("profile")) return generateProfilePage(name, desc, "ecommerce");
break;
case "note":
if (route.includes("note")) return generateNoteEditorPage(name, desc);
if (route.includes("tag")) return generateTagPage(name, desc);
if (route.includes("settings")) return generateSettingsPage(name, desc);
break;
case "enterprise":
if (route.includes("attendance")) return generateAttendancePage(name, desc);
if (route.includes("approval")) return generateApprovalPage(name, desc);
if (route.includes("contact")) return generateContactPage(name, desc);
break;
case "education":
if (route.includes("course")) return generateCoursePage(name, desc);
if (route.includes("learn")) return generateLearnPage(name, desc);
if (route.includes("exercise")) return generateExercisePage(name, desc);
if (route.includes("live")) return generateLivePage(name, desc);
break;
case "fitness":
if (route.includes("checkin")) return generateCheckinPage(name, desc);
if (route.includes("stat")) return generateStatsPage(name, desc);
break;
}
// Generic fallback page
return `"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
${hookNameRaw ? `import { use${hookNameRaw} } from "@/hooks/use${hookNameRaw}";` : ""}
export default function ${sanitizeComponentName(name)}Page() {
${hookNameRaw ? `const { data, loading, error, refetch } = use${hookNameRaw}();` : ""}
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800">${name}</h1>
<p className="text-sm text-gray-500 mt-1">${desc}</p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm">新增</Button>
</div>
{${hookNameRaw ? `loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : error ? (
<Card className="text-center py-8">
<p className="text-red-500 text-sm">{error}</p>
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2">重试</Button>
</Card>
) : data.length === 0 ? (
<EmptyState
icon="📭"
title="暂无数据"
description="还没有任何记录,点击上方按钮开始"
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm">创建第一条</Button>}
/>
) : (
<div className="space-y-3">
{data.map((item: any) => (
<Card key={item.id}>
<h3 className="font-medium text-gray-800">{item.title || item.name || \`Item \${item.id.slice(0, 8)}\`}</h3>
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
</Card>
))}
</div>
)}` : `<Card>
<p className="text-gray-500 text-sm">${name} 页面内容</p>
<p className="text-xs text-gray-400 mt-1">路由: ${route}</p>
</Card>}`}
</div>
);
}
`;
}
// ── Domain-specific page generators ──────────────
function generatePetDetailPage(name, desc) {
return `"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import type { Pet, Schedule } from "@/types";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { PetCard } from "@/components/pet/PetCard";
import { ScheduleCard } from "@/components/pet/ScheduleCard";
import { EmptyState } from "@/components/ui/EmptyState";
export default function PetDetailPage() {
const params = useParams();
const [pet, setPet] = useState<Pet | null>(null);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// TODO: Replace with actual API call
const loadData = async () => {
setLoading(true);
// Simulated data
setPet({
id: params.id as string,
ownerId: "u1",
name: "毛球",
species: "猫",
breed: "英短",
birthDate: "2023-03-15",
weightKg: 4.5,
avatarUrl: "",
createdAt: new Date().toISOString(),
});
setSchedules([
{ id: "s1", petId: params.id as string, type: "vaccine", title: "年度疫苗", scheduledAt: "2026-07-01T09:00:00Z", completed: false },
{ id: "s2", petId: params.id as string, type: "deworming", title: "季度驱虫", scheduledAt: "2026-06-15T10:00:00Z", completed: false },
{ id: "s3", petId: params.id as string, type: "checkup", title: "体检", scheduledAt: "2026-05-20T14:00:00Z", completed: true },
]);
setLoading(false);
};
loadData();
}, [params.id]);
if (loading) return <div className="flex justify-center py-16"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" /></div>;
if (!pet) return <EmptyState icon="🐾" title="未找到宠物" description="该宠物可能已被删除" />;
return (
<div className="space-y-6">
{/* Pet Profile */}
<div className="bg-gradient-to-br from-amber-100 to-orange-100 rounded-2xl p-6 text-center">
<div className="w-24 h-24 mx-auto rounded-full bg-white shadow flex items-center justify-center text-5xl">
{pet.avatarUrl ? <img src={pet.avatarUrl} alt={pet.name} className="w-full h-full rounded-full object-cover" /> : "🐱"}
</div>
<h1 className="text-2xl font-bold text-gray-800 mt-3">{pet.name}</h1>
<p className="text-sm text-gray-500">{pet.breed} · {pet.weightKg}kg · {pet.species}</p>
</div>
{/* Health Schedule */}
<section>
<h2 className="text-lg font-semibold text-gray-800 mb-3">健康日程</h2>
<div className="space-y-2">
{schedules.length > 0 ? schedules.map(s => <ScheduleCard key={s.id} schedule={s} />) : <EmptyState icon="📅" title="暂无日程" />}
</div>
</section>
{/* Quick Info */}
<div className="grid grid-cols-2 gap-3">
<Card className="text-center">
<p className="text-2xl font-bold text-gray-800">{pet.weightKg}</p>
<p className="text-xs text-gray-500">体重 (kg)</p>
</Card>
<Card className="text-center">
<p className="text-2xl font-bold text-gray-800">{new Date().getFullYear() - new Date(pet.birthDate).getFullYear()}岁</p>
<p className="text-xs text-gray-500">年龄</p>
</Card>
</div>
</div>
);
}
`;
}
function generateSchedulePage(name, desc) {
return `"use client";
import { useState } from "react";
import type { Schedule } from "@/types";
import { Button } from "@/components/ui/Button";
import { ScheduleCard } from "@/components/pet/ScheduleCard";
import { Modal } from "@/components/ui/Modal";
import { Input } from "@/components/ui/Input";
import { EmptyState } from "@/components/ui/EmptyState";
const mockSchedules: Schedule[] = [
{ id: "s1", petId: "p1", type: "vaccine", title: "年度疫苗加强针", scheduledAt: "2026-07-01T09:00:00Z", completed: false },
{ id: "s2", petId: "p1", type: "deworming", title: "季度驱虫", scheduledAt: "2026-06-15T10:00:00Z", completed: false },
{ id: "s3", petId: "p1", type: "checkup", title: "半年度体检", scheduledAt: "2026-05-20T14:00:00Z", completed: true },
{ id: "s4", petId: "p1", type: "grooming", title: "美容护理", scheduledAt: "2026-06-10T11:00:00Z", completed: false },
];
export default function SchedulePage() {
const [schedules, setSchedules] = useState<Schedule[]>(mockSchedules);
const [showModal, setShowModal] = useState(false);
const toggleComplete = (id: string) => {
setSchedules(prev => prev.map(s => s.id === id ? { ...s, completed: !s.completed } : s));
};
const pending = schedules.filter(s => !s.completed);
const done = schedules.filter(s => s.completed);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800">健康日程</h1>
<p className="text-sm text-gray-500 mt-1">管理宠物的疫苗、驱虫、体检安排</p>
</div>
<Button onClick={() => setShowModal(true)} variant="primary" size="sm">+ 添加日程</Button>
</div>
{/* Pending */}
<section>
<h2 className="text-sm font-semibold text-gray-500 uppercase mb-2">待完成 ({pending.length})</h2>
<div className="space-y-2">
{pending.length > 0 ? pending.map(s => <ScheduleCard key={s.id} schedule={s} onToggle={() => toggleComplete(s.id)} />) : <EmptyState icon="✅" title="全部完成" />}
</div>
</section>
{/* Completed */}
{done.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-gray-500 uppercase mb-2">已完成 ({done.length})</h2>
<div className="space-y-2">
{done.map(s => <ScheduleCard key={s.id} schedule={s} />)}
</div>
</section>
)}
<Modal open={showModal} onClose={() => setShowModal(false)} title="添加日程">
<div className="space-y-3">
<Input label="标题" placeholder="如:年度疫苗" />
<Input label="日期" type="date" />
<div className="flex gap-2 justify-end">
<Button variant="secondary" onClick={() => setShowModal(false)}>取消</Button>
<Button variant="primary" onClick={() => setShowModal(false)}>保存</Button>
</div>
</div>
</Modal>
</div>
);
}
`;
}
function generateDailyLogPage(name, desc) {
return `"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { EmptyState } from "@/components/ui/EmptyState";
const CATEGORIES = [
{ key: "food", icon: "🍖", label: "饮食" },
{ key: "exercise", icon: "🏃", label: "运动" },
{ key: "excretion", icon: "💩", label: "排泄" },
{ key: "weight", icon: "⚖️", label: "体重" },
];
interface LogEntry {
id: string;
category: string;
icon: string;
value: string;
time: string;
}
export default function DailyLogPage() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [category, setCategory] = useState("food");
const [value, setValue] = useState("");
const addLog = () => {
if (!value.trim()) return;
const cat = CATEGORIES.find(c => c.key === category)!;
setLogs(prev => [{
id: Date.now().toString(),
category: cat.key,
icon: cat.icon,
value,
time: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
}, ...prev]);
setValue("");
};
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-bold text-gray-800">日常记录</h1>
<p className="text-sm text-gray-500 mt-1">记录宠物的饮食、运动、排泄和体重变化</p>
</div>
{/* Quick Record */}
<Card>
<div className="space-y-3">
<div className="flex gap-2">
{CATEGORIES.map(c => (
<button key={c.key} onClick={() => setCategory(c.key)}
className={\`flex-1 py-2 rounded-lg text-sm text-center transition-colors \${category === c.key ? "bg-blue-100 text-blue-700 font-medium" : "bg-gray-50 text-gray-500"}\`}>
{c.icon} {c.label}
</button>
))}
</div>
<div className="flex gap-2">
<Input placeholder="输入记录内容..." value={value} onChange={e => setValue(e.target.value)} onKeyDown={e => e.key === "Enter" && addLog()} />
<Button onClick={addLog} size="sm">记录</Button>
</div>
</div>
</Card>
{/* Log Timeline */}
<div className="space-y-2">
{logs.length > 0 ? logs.map(log => (
<div key={log.id} className="flex items-center gap-3 px-4 py-2 bg-white rounded-lg border border-gray-100">
<span className="text-xl">{log.icon}</span>
<span className="flex-1 text-sm text-gray-700">{log.value}</span>
<span className="text-xs text-gray-400">{log.time}</span>
</div>
)) : <EmptyState icon="📝" title="暂无记录" description="使用上方表单记录宠物日常" />}
</div>
</div>
);
}
`;
}
function generateAlbumPage(name, desc) {
return `"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
const mockPhotos = Array.from({ length: 8 }, (_, i) => ({
id: \`p\${i}\`,
color: ["#FEE2E2", "#FEF3C7", "#D1FAE5", "#DBEAFE", "#EDE9FE", "#FCE7F3", "#E0E7FF", "#FFEDD5"][i],
emoji: ["🐱", "🐶", "🐱", "🐶", "🐱", "🐱", "🐶", "🐱"][i],
date: new Date(2026, 5, i + 1).toISOString(),
}));
export default function AlbumPage() {
const [photos] = useState(mockPhotos);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800">成长相册</h1>
<p className="text-sm text-gray-500 mt-1">记录宠物的成长瞬间</p>
</div>
<Button variant="primary" size="sm">+ 上传照片</Button>
</div>
<div className="grid grid-cols-3 gap-2">
{photos.map(photo => (
<div key={photo.id} className="aspect-square rounded-xl flex items-center justify-center text-4xl" style={{ backgroundColor: photo.color }}>
{photo.emoji}
</div>
))}
</div>
<p className="text-center text-xs text-gray-400">下拉加载更多照片</p>
</div>
);
}
`;
}
function generateHospitalPage(name, desc) {
return `"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
const mockHospitals = [
{ id: "h1", name: "宠颐生动物医院", address: "朝阳区建国路88号", phone: "010-88886666", rating: 4.8, distance: 1.2, services: ["疫苗", "体检", "手术", "美容"] },
{ id: "h2", name: "瑞鹏宠物医院", address: "海淀区中关村大街1号", phone: "010-66668888", rating: 4.6, distance: 2.8, services: ["急诊", "住院", "化验"] },
{ id: "h3", name: "佳雯宠物诊所", address: "西城区金融街16号", phone: "010-55556666", rating: 4.5, distance: 3.5, services: ["疫苗", "驱虫", "体检"] },
];
export default function HospitalPage() {
const [search, setSearch] = useState("");
const filtered = mockHospitals.filter(h =>
h.name.includes(search) || h.address.includes(search)
);
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-bold text-gray-800">附近医院</h1>
<p className="text-sm text-gray-500 mt-1">查找附近的宠物医院</p>
</div>
<Input placeholder="搜索医院名称或地址..." value={search} onChange={e => setSearch(e.target.value)} />
<div className="space-y-3">
{filtered.map(h => (
<Card key={h.id}>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-blue-500 flex-shrink-0">🏥</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-800 truncate">{h.name}</h3>
<span className="text-xs px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded flex-shrink-0">⭐ {h.rating}</span>
</div>
<p className="text-xs text-gray-500 mt-1">{h.address}</p>
<p className="text-xs text-gray-400 mt-0.5">{h.distance}km · {h.phone}</p>
<div className="flex gap-1 mt-2">
{h.services.map(s => <span key={s} className="text-xs px-2 py-0.5 bg-gray-100 text-gray-600 rounded">{s}</span>)}
</div>
</div>
</div>
</Card>
))}
</div>
</div>
);
}
`;
}
function generateProfilePage(name, desc, domain) {
return `"use client";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
const menuItems = [
{ icon: "⚙️", label: "设置", href: "#" },
{ icon: "🔔", label: "通知", href: "#" },
{ icon: "❓", label: "帮助与反馈", href: "#" },
{ icon: "📄", label: "关于我们", href: "#" },
];
export default function ProfilePage() {
return (
<div className="space-y-6">
{/* User Info */}
<div className="bg-gradient-to-br from-blue-500 to-blue-700 rounded-2xl p-6 text-white text-center">
<div className="w-20 h-20 mx-auto rounded-full bg-white/20 flex items-center justify-center text-3xl backdrop-blur">😊</div>
<h1 className="text-xl font-bold mt-3">用户昵称</h1>
<p className="text-sm text-blue-100">138****1234</p>
<Button variant="ghost" className="mt-3 bg-white/10 text-white hover:bg-white/20" size="sm">编辑资料</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
${domain === "pet" ? `<Card className="text-center"><p className="text-xl font-bold text-gray-800">3</p><p className="text-xs text-gray-500">我的宠物</p></Card>
<Card className="text-center"><p className="text-xl font-bold text-gray-800">12</p><p className="text-xs text-gray-500">健康日程</p></Card>
<Card className="text-center"><p className="text-xl font-bold text-gray-800">56</p><p className="text-xs text-gray-500">日常记录</p></Card>` : `<Card className="text-center"><p className="text-xl font-bold text-gray-800">0</p><p className="text-xs text-gray-500">收藏</p></Card>
<Card className="text-center"><p className="text-xl font-bold text-gray-800">0</p><p className="text-xs text-gray-500">订单</p></Card>
<Card className="text-center"><p className="text-xl font-bold text-gray-800">0</p><p className="text-xs text-gray-500">足迹</p></Card>`}
</div>
{/* Menu */}
<Card className="divide-y divide-gray-50 p-0">
{menuItems.map((item, i) => (
<a key={i} href={item.href} className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors">
<span className="text-lg">{item.icon}</span>
<span className="flex-1 text-sm text-gray-700">{item.label}</span>
<svg className="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
</a>
))}
</Card>
</div>
);
}
`;
}
// More domain-specific page generators... (abbreviated for space)
function generateProductDetailPage(name, desc) {
return `"use client";
import { Button } from "@/components/ui/Button";
export default function ProductDetailPage() {
return (
<div className="space-y-4">
<div className="aspect-square bg-gray-100 rounded-2xl flex items-center justify-center text-6xl">📦</div>
<h1 className="text-xl font-bold">商品名称</h1>
<p className="text-2xl font-bold text-red-500">¥99.00</p>
<p className="text-sm text-gray-500">库存充足 · 24小时内发货</p>
<Button variant="primary" className="w-full" size="lg">加入购物车</Button>
<div className="space-y-2">
<h3 className="font-semibold text-gray-700">商品详情</h3>
<p className="text-sm text-gray-500 leading-relaxed">商品详细描述信息。这里会展示商品的全部规格参数、使用方法、注意事项等内容。</p>
</div>
</div>
);
}`;
}
function generateCartPage(name, desc) {
return `"use client";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
export default function CartPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">购物车</h1>
<Card><p className="text-sm text-gray-500">购物车功能开发中...</p></Card>
<Button variant="primary" className="w-full" size="lg">去结算</Button>
</div>
);
}`;
}
function generateOrderPage(name, desc) {
return `"use client";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
export default function OrderPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">我的订单</h1>
<EmptyState icon="📦" title="暂无订单" description="去逛逛吧" />
</div>
);
}`;
}
function generateNoteEditorPage(name, desc) {
return `"use client";
import { useState } from "react";
import { Input } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
export default function NoteEditorPage() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800">编辑笔记</h1>
<Button variant="primary" size="sm">保存</Button>
</div>
<Input value={title} onChange={e => setTitle(e.target.value)} placeholder="笔记标题" className="text-lg font-medium border-0 px-0" />
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder="开始记录..."
className="w-full min-h-[300px] text-sm text-gray-700 resize-none focus:outline-none"
/>
</div>
);
}`;
}
function generateTagPage(name, desc) {
return `"use client";
import { Card } from "@/components/ui/Card";
const tags = ["工作", "个人", "灵感", "读书笔记", "代码片段", "日记"];
export default function TagPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">标签管理</h1>
<div className="flex flex-wrap gap-2">
{tags.map(tag => <span key={tag} className="px-3 py-1.5 bg-gray-100 rounded-full text-sm text-gray-600 cursor-pointer hover:bg-gray-200">{tag}</span>)}
</div>
</div>
);
}`;
}
function generateAttendancePage(name, desc) {
return `"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
export default function AttendancePage() {
const [checkedIn, setCheckedIn] = useState(false);
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">考勤打卡</h1>
<Card className="text-center py-8">
<p className="text-4xl mb-2">{checkedIn ? "✅" : "🕐"}</p>
<p className="text-sm text-gray-500">{new Date().toLocaleString("zh-CN")}</p>
<Button onClick={() => setCheckedIn(!checkedIn)} variant={checkedIn ? "secondary" : "primary"} className="mt-4" size="lg" disabled={checkedIn}>
{checkedIn ? "已打卡" : "打卡签到"}
</Button>
</Card>
</div>
);
}`;
}
function generateApprovalPage(name, desc) {
return `"use client";
import { Card } from "@/components/ui/Card";
const approvals = [
{ id: "a1", type: "请假", applicant: "张三", status: "pending", date: "2026-06-05" },
{ id: "a2", type: "报销", applicant: "李四", status: "approved", date: "2026-06-04" },
{ id: "a3", type: "加班", applicant: "王五", status: "rejected", date: "2026-06-03" },
];
const statusMap: Record<string, { label: string; color: string }> = {
pending: { label: "待审批", color: "bg-yellow-100 text-yellow-700" },
approved: { label: "已通过", color: "bg-green-100 text-green-700" },
rejected: { label: "已驳回", color: "bg-red-100 text-red-700" },
};
export default function ApprovalPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">我的审批</h1>
{approvals.map(a => {
const s = statusMap[a.status];
return (
<Card key={a.id}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-800">{a.type}申请 — {a.applicant}</p>
<p className="text-xs text-gray-400 mt-1">{a.date}</p>
</div>
<span className={\`text-xs px-2 py-1 rounded \${s.color}\`}>{s.label}</span>
</div>
</Card>
);
})}
</div>
);
}`;
}
function generateCoursePage(name, desc) {
return `"use client";
import { Card } from "@/components/ui/Card";
const courses = [
{ id: "c1", title: "React 19 完全指南", instructor: "张老师", price: 199, cover: "📘" },
{ id: "c2", title: "TypeScript 高级编程", instructor: "李老师", price: 149, cover: "📙" },
{ id: "c3", title: "Node.js 后端实战", instructor: "王老师", price: 249, cover: "📗" },
];
export default function CoursePage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">课程中心</h1>
{courses.map(c => (
<Card key={c.id} className="flex gap-3">
<div className="w-20 h-20 rounded-lg bg-gray-100 flex items-center justify-center text-3xl flex-shrink-0">{c.cover}</div>
<div>
<h3 className="font-medium text-gray-800">{c.title}</h3>
<p className="text-xs text-gray-500 mt-1">{c.instructor}</p>
<p className="text-sm font-bold text-red-500 mt-2">¥{c.price}</p>
</div>
</Card>
))}
</div>
);
}`;
}
function generateExercisePage(name, desc) {
return `"use client";
export default function ExercisePage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">题库练习</h1>
<p className="text-sm text-gray-500">章节练习和模拟考试功能</p>
</div>
);
}`;
}
function generateLearnPage(name, desc) {
return `"use client";
export default function LearnPage() {
return (
<div className="space-y-4">
<div className="aspect-video bg-black rounded-xl flex items-center justify-center">
<span className="text-white text-6xl">▶️</span>
</div>
<h1 className="text-lg font-bold text-gray-800">课程标题</h1>
<p className="text-sm text-gray-500">第1节 · 基础知识</p>
</div>
);
}`;
}
function generateLivePage(name, desc) {
return `"use client";
export default function LivePage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">直播课堂</h1>
<p className="text-sm text-gray-500">课程直播功能</p>
</div>
);
}`;
}
function generateCheckinPage(name, desc) {
return `"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
const exercises = ["跑步", "游泳", "骑行", "跳绳", "瑜伽", "力量训练"];
export default function CheckinPage() {
const [selected, setSelected] = useState("");
const [duration, setDuration] = useState("");
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">运动打卡</h1>
<div className="grid grid-cols-3 gap-2">
{exercises.map(e => <Button key={e} variant={selected === e ? "primary" : "secondary"} size="sm" onClick={() => setSelected(e)}>{e}</Button>)}
</div>
<Card>
<input type="number" value={duration} onChange={e => setDuration(e.target.value)} placeholder="运动时长(分钟)" className="w-full px-3 py-2 border rounded-lg text-sm" />
<Button variant="primary" className="w-full mt-3" disabled={!selected || !duration}>打卡</Button>
</Card>
</div>
);
}`;
}
function generateStatsPage(name, desc) {
return `"use client";
import { Card } from "@/components/ui/Card";
export default function StatsPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">运动统计</h1>
<div className="grid grid-cols-2 gap-3">
<Card className="text-center"><p className="text-2xl font-bold text-blue-500">12</p><p className="text-xs text-gray-500">本月打卡</p></Card>
<Card className="text-center"><p className="text-2xl font-bold text-green-500">420</p><p className="text-xs text-gray-500">总运动时长</p></Card>
</div>
</div>
);
}`;
}
function generateContactPage(name, desc) {
return `"use client";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
export default function ContactPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">通讯录</h1>
<Input placeholder="搜索同事..." />
<div className="space-y-2">
{["张三 · 技术部", "李四 · 产品部", "王五 · 设计部"].map((n, i) => (
<Card key={i}><p className="text-sm text-gray-700">{n}</p></Card>
))}
</div>
</div>
);
}`;
}
function generateSettingsPage(name, desc) {
return `"use client";
import { Card } from "@/components/ui/Card";
const settings = ["账户安全", "通知设置", "主题偏好", "数据管理", "关于应用"];
export default function SettingsPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">设置</h1>
<Card className="divide-y p-0">
{settings.map((s, i) => <div key={i} className="px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer flex items-center justify-between">{s} <span className="text-gray-300"></span></div>)}
</Card>
</div>
);
}`;
}
// ═══════════════════════════════════════════════════════
// 6. Layout & Config Generators
// ═══════════════════════════════════════════════════════
function generateLayout(prd) {
const pages = prd.pages || [];
const domain = prd.domain || "generic";
const projectName = prd.projectName || "App";
// Build navigation items from pages
const navItems = pages.slice(0, 5).map((p, i) => {
const route = p.route;
const dirPath = route.replace(/^\//, "").replace(/:(\w+)/g, "[$1]").replace(/\/$/, "");
const icons = ["🏠", "📋", "📅", "✏️", "📸", "🏥", "👤", "📚", "💬"];
return { name: p.name, href: `/${dirPath}`, icon: icons[i] || "📌" };
});
return `import type { Metadata } from "next";
import "./globals.css";
import { Sidebar } from "@/components/layout/Sidebar";
import { BottomNav } from "@/components/layout/BottomNav";
export const metadata: Metadata = {
title: "${projectName}",
description: "${prd.summary || projectName}",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body className="bg-gray-50 min-h-screen">
<div className="flex">
{/* Desktop Sidebar */}
<Sidebar items={${JSON.stringify(navItems.map(({ name, href, icon }) => ({ name, href, icon })))} } projectName="${projectName}" />
{/* Main Content */}
<main className="flex-1 lg:pl-64 pb-20 lg:pb-0">
<div className="max-w-2xl mx-auto px-4 py-6">
{children}
</div>
</main>
</div>
{/* Mobile Bottom Nav */}
<BottomNav items={${JSON.stringify(navItems.slice(0, 4).map(({ name, href, icon }) => ({ name, href, icon })))} } />
</body>
</html>
);
}
`;
}
function generateSidebar(prd) {
return `"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface NavItem {
name: string;
href: string;
icon: string;
}
interface SidebarProps {
items: NavItem[];
projectName: string;
}
export function Sidebar({ items, projectName }: SidebarProps) {
const pathname = usePathname();
return (
<aside className="hidden lg:flex lg:flex-col lg:w-64 lg:fixed lg:inset-y-0 bg-white border-r border-gray-100">
<div className="px-6 py-5 border-b border-gray-50">
<h1 className="text-lg font-bold text-gray-800">{projectName}</h1>
</div>
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{items.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={\`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors \${
active ? "bg-blue-50 text-blue-700 font-medium" : "text-gray-600 hover:bg-gray-50"
}\`}
>
<span className="text-lg">{item.icon}</span>
<span>{item.name}</span>
</Link>
);
})}
</nav>
</aside>
);
}
`;
}
function generateBottomNav(prd) {
return `"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface NavItem {
name: string;
href: string;
icon: string;
}
export function BottomNav({ items }: { items: NavItem[] }) {
const pathname = usePathname();
if (!items.length) return null;
return (
<nav className="lg:hidden fixed bottom-0 inset-x-0 bg-white border-t border-gray-100 z-40">
<div className="flex items-center justify-around h-16">
{items.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={\`flex flex-col items-center gap-0.5 px-3 py-1 \${
active ? "text-blue-600" : "text-gray-400"
}\`}
>
<span className="text-xl">{item.icon}</span>
<span className="text-[10px]">{item.name}</span>
</Link>
);
})}
</div>
</nav>
);
}
`;
}
function generateConfigFiles(prd) {
return {
"package.json": JSON.stringify({
name: (prd.projectName || "app").toLowerCase(),
version: "0.1.0",
private: true,
scripts: {
dev: "next dev",
build: "next build",
start: "next start",
lint: "next lint",
"type-check": "tsc --noEmit",
},
dependencies: {
next: "^15.0.0",
react: "^19.0.0",
"react-dom": "^19.0.0",
},
devDependencies: {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
typescript: "^5.6.0",
tailwindcss: "^3.4.0",
postcss: "^8.4.0",
autoprefixer: "^10.4.0",
},
}, null, 2),
"tsconfig.json": JSON.stringify({
compilerOptions: {
target: "ES2017",
lib: ["dom", "dom.iterable", "esnext"],
allowJs: true,
skipLibCheck: true,
strict: true,
noEmit: true,
esModuleInterop: true,
module: "esnext",
moduleResolution: "bundler",
resolveJsonModule: true,
isolatedModules: true,
jsx: "preserve",
incremental: true,
plugins: [{ name: "next" }],
paths: { "@/*": ["./*"] },
},
include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
exclude: ["node_modules"],
}, null, 2),
"next.config.ts": `import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
`,
"tailwind.config.ts": `import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./hooks/**/*.{js,ts,jsx,tsx,mdx}", "./services/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {},
},
plugins: [],
};
export default config;
`,
"postcss.config.js": `module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
`,
"app/globals.css": `@tailwind base;
@tailwind components;
@tailwind utilities;
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
`,
};
}
// ═══════════════════════════════════════════════════════
// 7. Main Builder Pipeline
// ═══════════════════════════════════════════════════════
/**
* Sanitize component name
*/
function sanitizeComponentName(name) {
// Remove non-ASCII letters/digits and ensure valid JS identifier
const clean = name.replace(/[^a-zA-Z0-9_]/g, "") || "Page";
return clean.charAt(0).toUpperCase() + clean.slice(1);
}
/**
* Build the complete frontend project.
*
* @param {object} prd — PRD from SF-01
* @param {object} arch — Architecture from SF-02
* @returns {{ files: Record<string, string>, stats: object }}
*/
export function buildFrontend(prd, arch) {
if (!prd || prd.error) {
return { error: "INVALID_PRD", message: prd?.message || "Invalid PRD input" };
}
if (!arch || arch.error) {
return { error: "INVALID_ARCH", message: arch?.message || "Invalid Architecture input" };
}
// Create contract from arch (single source of truth)
const contract = arch.contract || createContract(prd, { databaseSchema: arch.databaseSchema || [], domain: arch.domain });
const files = {};
// 1. Types (from contract)
const types = generateTypes(prd, arch, contract);
files["types/index.ts"] = types;
// 2. Config files
const configs = generateConfigFiles(prd);
Object.assign(files, configs);
// 3. Layout
const layout = generateLayout(prd);
files["app/layout.tsx"] = layout;
// 4. Layout components
files["components/layout/Sidebar.tsx"] = generateSidebar(prd);
files["components/layout/BottomNav.tsx"] = generateBottomNav(prd);
// 5. UI components
const components = generateComponents(prd, arch);
Object.assign(files, components);
// 6. Pages
const pages = generatePages(prd, arch);
Object.assign(files, pages);
// 7. Hooks
const hooks = generateHooks(prd, arch);
Object.assign(files, hooks);
// 8. API services
const services = generateApiServices(prd, arch);
Object.assign(files, services);
const fileList = Object.keys(files);
const pageFiles = fileList.filter(f => f.includes("/page.tsx"));
const componentFiles = fileList.filter(f => f.includes("components/"));
const hookFiles = fileList.filter(f => f.includes("hooks/"));
const serviceFiles = fileList.filter(f => f.includes("services/"));
return {
files,
stats: {
totalFiles: fileList.length,
pages: pageFiles.length,
components: componentFiles.length,
hooks: hookFiles.length,
services: serviceFiles.length,
pageFiles,
componentFiles,
},
};
}
// ═══════════════════════════════════════════════════════
// 8. File I/O
// ═══════════════════════════════════════════════════════
export function loadJSON(path) {
try {
if (!existsSync(path)) return { data: null, error: `File not found: ${path}` };
return { data: JSON.parse(readFileSync(path, "utf-8")), error: null };
} catch (e) {
return { data: null, error: `Failed to load: ${e.message}` };
}
}
export function writeFrontend(result, outputDir) {
mkdirSync(outputDir, { recursive: true });
for (const [relPath, content] of Object.entries(result.files)) {
const fullPath = resolve(outputDir, relPath);
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, content);
}
}
// ═══════════════════════════════════════════════════════
// 9. CLI Entry
// ═══════════════════════════════════════════════════════
function parseArgs() {
const args = process.argv.slice(2);
const opts = { prd: null, arch: null, inputText: null, output: null, verbose: false, help: false };
for (let i = 0; i < args.length; i++) {
if (args[i] === "--prd" && args[i + 1]) opts.prd = args[++i];
else if (args[i] === "--arch" && args[i + 1]) opts.arch = args[++i];
else if (args[i] === "--input-text" && args[i + 1]) opts.inputText = args[++i];
else if (args[i] === "--output" && args[i + 1]) opts.output = args[++i];
else if (args[i] === "--verbose") opts.verbose = true;
else if (args[i] === "--help" || args[i] === "-h") opts.help = true;
}
return opts;
}
async function main() {
const opts = parseArgs();
if (opts.help) {
console.log(`
Frontend Builder Agent — SF-03
Usage:
node scripts/frontend-builder-agent.mjs --prd <path> --arch <path> [options]
node scripts/frontend-builder-agent.mjs --input-text "<需求>" [options]
Options:
--prd <path> PRD JSONSF-01 输出)
--arch <path> Architecture JSONSF-02 输出)
--input-text <text> 直接传入需求(自动调 SF-01 → SF-02 → SF-03
--output <dir> 输出目录(default: frontend/
--verbose 详细输出
--help 显示帮助
`);
return;
}
let prd = null, arch = null;
if (opts.prd && opts.arch) {
const prdRes = loadJSON(opts.prd);
const archRes = loadJSON(opts.arch);
if (prdRes.error) { console.error(prdRes.error); process.exit(1); }
if (archRes.error) { console.error(archRes.error); process.exit(1); }
prd = prdRes.data;
arch = archRes.data;
} else if (opts.inputText) {
try {
const sf01 = await import("./project-intake-agent.mjs");
const sf02 = await import("./architecture-agent.mjs");
prd = sf01.generatePRD(opts.inputText);
if (prd.error) { console.error(`SF-01: ${prd.message}`); process.exit(1); }
arch = sf02.generateArchitecture(prd);
if (arch.error) { console.error(`SF-02: ${arch.message}`); process.exit(1); }
} catch (e) {
console.error(`Pipeline error: ${e.message}`);
process.exit(1);
}
}
if (!prd || !arch) {
console.error("Error: --prd + --arch or --input-text is required. Use --help.");
process.exit(1);
}
const result = buildFrontend(prd, arch);
if (result.error) { console.error(result.message); process.exit(1); }
const outputDir = opts.output ? resolve(opts.output) : DEFAULT_OUTPUT;
writeFrontend(result, outputDir);
if (opts.verbose) {
console.error(`Project: ${prd.projectName}`);
console.error(`Files: ${result.stats.totalFiles}`);
console.error(`Pages: ${result.stats.pages}, Components: ${result.stats.components}, Hooks: ${result.stats.hooks}, Services: ${result.stats.services}`);
}
console.log(JSON.stringify({
projectName: prd.projectName,
outputDir,
stats: result.stats,
}, null, 2));
}
if (process.argv[1] && (import.meta.url === `file://${process.argv[1]}` || import.meta.url.endsWith(process.argv[1]))) {
main();
}