314 lines
14 KiB
TypeScript
314 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|