2113 lines
71 KiB
JavaScript
2113 lines
71 KiB
JavaScript
#!/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 JSON(SF-01 输出)
|
||
* --arch <path> Architecture JSON(SF-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">×</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 JSON(SF-01 输出)
|
||
--arch <path> Architecture JSON(SF-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();
|
||
}
|