PMFlow/frontend/src/pages/ai-steward/sections/CreateProjectTab.tsx

314 lines
14 KiB
TypeScript
Raw 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.

import { AlertCircle, AlertTriangle, CheckCircle2, Loader2, Sparkles } from "lucide-react";
import { formatProjectNameWithId, toZhPriority, toZhSeverity } from "@/lib/display";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { EXAMPLE_INPUTS } from "../constants";
import type { ProjectFromTextResponse } from "../types";
function formatCharterContent(
content: ProjectFromTextResponse["charter"]["content"] | string | null | undefined
): string {
if (typeof content === "string") {
return content.trim() || "-";
}
if (content && typeof content === "object") {
const record = content as Record<string, unknown>;
const text = typeof record.rationale === "string" ? record.rationale.trim() : "";
if (text) {
return text;
}
try {
return JSON.stringify(content, null, 2);
} catch {
return String(content);
}
}
return "-";
}
function groupSignalSources(projectResult: ProjectFromTextResponse | null) {
const sources = projectResult?.signal_sources ?? [];
const groups = new Map<string, typeof sources>();
for (const source of sources) {
const section = String(source.section || "其他").trim() || "其他";
const bucket = groups.get(section) || [];
bucket.push(source);
groups.set(section, bucket);
}
const preferredOrder = ["核心痛点", "MVP 功能", "其他"];
return [
...preferredOrder.filter((section) => groups.has(section)),
...Array.from(groups.keys()).filter((section) => !preferredOrder.includes(section)),
].map((section) => ({
section,
items: groups.get(section) || [],
}));
}
interface CreateProjectTabProps {
input: string;
loading: boolean;
error: string | null;
projectResult: ProjectFromTextResponse | null;
onInputChange: (value: string) => void;
onSubmit: () => void;
onResetProject: () => void;
}
export default function CreateProjectTab({
input,
loading,
error,
projectResult,
onInputChange,
onSubmit,
onResetProject,
}: CreateProjectTabProps) {
const techStack = Array.isArray(projectResult?.analysis.tech_stack) ? projectResult.analysis.tech_stack : [];
const tasks = Array.isArray(projectResult?.tasks) ? projectResult.tasks : [];
const risks = Array.isArray(projectResult?.risks) ? projectResult.risks : [];
const requirementItems = Array.isArray(projectResult?.analysis.requirement_items)
? projectResult.analysis.requirement_items
: [];
const signalSourceGroups = groupSignalSources(projectResult);
return (
<TabsContent value="create" className="mt-6">
{!projectResult ? (
<div className="max-w-2xl mx-auto space-y-4">
<div>
<label className="block text-sm font-medium mb-2">描述你的项目</label>
<Textarea
placeholder="例如:我们要做一个智能客服工具,基于大语言模型,对标同类竞品,3 周上线"
value={input}
onChange={(e) => onInputChange(e.target.value)}
rows={4}
className="resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">示例输入(点击填充)</label>
<div className="flex flex-wrap gap-2">
{EXAMPLE_INPUTS.map((example, index) => (
<button
key={index}
onClick={() => onInputChange(example)}
className="px-3 py-1.5 text-sm bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
>
{example.length > 30 ? `${example.substring(0, 30)}...` : example}
</button>
))}
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-red-500 text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
<Button onClick={onSubmit} disabled={loading || !input.trim()} className="w-full bg-violet-600 hover:bg-violet-700" size="lg">
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
AI 正在生成项目…
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
一键生成项目
</>
)}
</Button>
</div>
) : (
<div className="space-y-6">
<Card className="bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<CheckCircle2 className="w-6 h-6 text-green-500" />
<div>
<h3 className="font-semibold text-green-900 dark:text-green-100">项目创建成功!</h3>
<p className="text-sm text-green-700 dark:text-green-300">AI 已为您生成了完整的项目配置,包含信号分析、信号源、宪章、任务链和风险预警</p>
</div>
</div>
</CardContent>
</Card>
<Tabs defaultValue="project-overview">
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
<TabsTrigger value="project-overview" className="shrink-0 whitespace-nowrap">项目概览</TabsTrigger>
<TabsTrigger value="project-charter" className="shrink-0 whitespace-nowrap">项目宪章</TabsTrigger>
<TabsTrigger value="project-tasks" className="shrink-0 whitespace-nowrap">任务列表</TabsTrigger>
<TabsTrigger value="project-risks" className="shrink-0 whitespace-nowrap">风险预警</TabsTrigger>
</TabsList>
<TabsContent value="project-overview">
<Card>
<CardHeader>
<CardTitle>
{formatProjectNameWithId({
id: projectResult.project.id,
title: projectResult.project.title || projectResult.project.name,
})}
</CardTitle>
<CardDescription>{projectResult.project.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-slate-500">技术栈</label>
<div className="flex flex-wrap gap-1 mt-1">
{techStack.map((tech, i) => (
<Badge key={i} variant="secondary">{tech}</Badge>
))}
</div>
</div>
<div>
<label className="text-sm text-slate-500">时间周期</label>
<p className="mt-1">{projectResult.analysis.timeline}</p>
</div>
</div>
<Button onClick={onResetProject} variant="outline" className="w-full">
创建另一个项目
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="project-charter">
<Card>
<CardHeader>
<CardTitle>项目宪章</CardTitle>
</CardHeader>
<CardContent>
<h3 className="text-lg font-semibold mb-2">{projectResult.charter.title}</h3>
<pre className="whitespace-pre-wrap rounded-lg bg-slate-50 p-4 text-sm leading-6 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
{formatCharterContent(projectResult.charter.content)}
</pre>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="project-tasks">
<Card>
<CardHeader>
<CardTitle>AI 生成的任务链</CardTitle>
<CardDescription>
共 {tasks.length} 个任务
{requirementItems.length > 0 ? ` · 抽取 ${requirementItems.length} 个需求点` : ""}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{tasks.map((task, index) => (
<div key={task.id} className="flex items-start gap-3 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-violet-100 dark:bg-violet-900 flex items-center justify-center text-violet-600 dark:text-violet-400 text-xs font-semibold">
{index + 1}
</div>
<div className="flex-1">
<h4 className="font-semibold">{task.title}</h4>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline">{task.type}</Badge>
<Badge variant={String(task.priority || "").toLowerCase() === "high" ? "destructive" : "default"}>
{toZhPriority(task.priority)}
</Badge>
<span className="text-xs text-slate-500">{task.estimated_hours}h</span>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
{signalSourceGroups.length > 0 && (
<Card>
<CardHeader>
<CardTitle>信号源</CardTitle>
<CardDescription>项目创建时,AI 已把需求点直接落成了现有信号源注册项。</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{signalSourceGroups.map(({ section, items }) => (
<div key={section} className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-slate-800 dark:text-slate-200">{section}</h4>
<Badge variant="outline">{items.length}</Badge>
</div>
<div className="space-y-3">
{items.map((source) => {
const sourceKey = String(source.registry_id ?? source.id ?? source.source_identifier ?? source.source_name);
return (
<div key={sourceKey} className="rounded-xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-700 dark:bg-slate-900/40">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h5 className="font-semibold text-slate-900 dark:text-slate-100">
{source.title || source.source_name}
</h5>
<Badge variant="secondary">{source.source_kind}</Badge>
{source.origin && <Badge variant="outline">{source.origin}</Badge>}
</div>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{source.description || "暂无描述"}
</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
{source.requested_via && <span>入口:{source.requested_via}</span>}
{source.registry_id != null && <span>#{source.registry_id}</span>}
{source.source_identifier && (
<span className="font-mono">标识:{source.source_identifier}</span>
)}
</div>
</div>
<Badge variant={String(source.priority || "").toLowerCase() === "high" ? "destructive" : "outline"}>
{toZhPriority(source.priority)}
</Badge>
</div>
</div>
);
})}
</div>
</div>
))}
</CardContent>
</Card>
)}
<TabsContent value="project-risks">
<Card>
<CardHeader>
<CardTitle>AI 风险预警规则</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{risks.map((risk, index) => (
<div key={index} className="flex items-start gap-3 p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<AlertTriangle className={`w-5 h-5 ${String(risk.severity || "").toLowerCase() === "high" ? "text-red-500" : "text-orange-500"}`} />
<div>
<h4 className="font-semibold">{risk.description}</h4>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline">{risk.type}</Badge>
<Badge variant={String(risk.severity || "").toLowerCase() === "high" ? "destructive" : "default"}>
{toZhSeverity(risk.severity)}
</Badge>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)}
</TabsContent>
);
}