Files
AiTool/frontend/components/historical-references.tsx
2026-03-16 18:07:01 +08:00

414 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.
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import {
listProjects,
projectsApi,
customersApi,
type ProjectRead,
type CustomerRead,
} from "@/lib/api/client";
import { Copy, Search, FileText, Eye, Pencil, Loader2 } from "lucide-react";
import { toast } from "sonner";
import ReactMarkdown from "react-markdown";
function parseTags(tagsStr: string | null | undefined): string[] {
if (!tagsStr?.trim()) return [];
return tagsStr
.split(",")
.map((t) => t.trim())
.filter(Boolean);
}
export function HistoricalReferences() {
const [projects, setProjects] = useState<ProjectRead[]>([]);
const [search, setSearch] = useState("");
const [selectedTag, setSelectedTag] = useState<string>("");
const [loading, setLoading] = useState(true);
const [previewProject, setPreviewProject] = useState<ProjectRead | null>(null);
const [editProject, setEditProject] = useState<ProjectRead | null>(null);
const [editRaw, setEditRaw] = useState("");
const [editMd, setEditMd] = useState("");
const [saving, setSaving] = useState(false);
const [editCustomer, setEditCustomer] = useState<CustomerRead | null>(null);
const [customerName, setCustomerName] = useState("");
const [customerContact, setCustomerContact] = useState("");
const [customerTags, setCustomerTags] = useState("");
const [savingCustomer, setSavingCustomer] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await listProjects(
selectedTag ? { customer_tag: selectedTag } : undefined
);
setProjects(data);
} catch (e) {
toast.error("加载历史项目失败");
setProjects([]);
} finally {
setLoading(false);
}
}, [selectedTag]);
useEffect(() => {
load();
}, [load]);
const allTags = useMemo(() => {
const set = new Set<string>();
projects.forEach((p) =>
parseTags(p.customer?.tags ?? null).forEach((t) => set.add(t))
);
return Array.from(set).sort();
}, [projects]);
const filtered = useMemo(() => {
let list = projects;
if (search.trim()) {
const q = search.toLowerCase();
list = list.filter(
(p) =>
p.raw_requirement.toLowerCase().includes(q) ||
(p.ai_solution_md || "").toLowerCase().includes(q)
);
}
return list.slice(0, 50);
}, [projects, search]);
const groupedByCustomer = useMemo(() => {
const map = new Map<string, ProjectRead[]>();
filtered.forEach((p) => {
const name = p.customer?.name || "未关联客户";
const key = name;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(p);
});
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b, "zh-CN"));
}, [filtered]);
const copySnippet = (text: string, label: string) => {
if (!text) return;
navigator.clipboard.writeText(text);
toast.success(`已复制 ${label}`);
};
const openPreview = (p: ProjectRead) => setPreviewProject(p);
const openEdit = (p: ProjectRead) => {
setEditProject(p);
setEditRaw(p.raw_requirement);
setEditMd(p.ai_solution_md || "");
};
const handleSaveEdit = async () => {
if (!editProject) return;
setSaving(true);
try {
await projectsApi.update(editProject.id, {
raw_requirement: editRaw,
ai_solution_md: editMd || null,
});
toast.success("已保存");
setEditProject(null);
load();
} catch (e) {
toast.error("保存失败");
} finally {
setSaving(false);
}
};
const openEditCustomer = (p: ProjectRead | null) => {
if (!p?.customer) return;
setEditCustomer(p.customer);
setCustomerName(p.customer.name);
setCustomerContact(p.customer.contact_info || "");
setCustomerTags(p.customer.tags || "");
};
const handleSaveCustomer = async () => {
if (!editCustomer) return;
setSavingCustomer(true);
try {
const updated = await customersApi.update(editCustomer.id, {
name: customerName.trim() || editCustomer.name,
contact_info: customerContact.trim() || null,
tags: customerTags.trim() || null,
});
toast.success("客户信息已保存");
// 更新本地 projects 中的 customer 信息
setProjects((prev) =>
prev.map((p) =>
p.customer_id === updated.id ? { ...p, customer: updated } : p
)
);
setEditCustomer(null);
} catch (e) {
toast.error("保存客户失败");
} finally {
setSavingCustomer(false);
}
};
return (
<div className="p-2">
<div className="space-y-2 mb-2">
<div className="flex gap-1 items-center">
<Select
value={selectedTag || "__all__"}
onValueChange={(v) => setSelectedTag(v === "__all__" ? "" : v)}
>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="按标签收纳" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__" className="text-xs">
</SelectItem>
{allTags.map((t) => (
<SelectItem key={t} value={t} className="text-xs">
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索项目..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
<div className="max-h-64 overflow-y-auto space-y-2 pr-1">
{loading ? (
<p className="text-xs text-muted-foreground px-2">...</p>
) : filtered.length === 0 ? (
<p className="text-xs text-muted-foreground px-2"></p>
) : (
groupedByCustomer.map(([customerName, items]) => (
<div key={customerName} className="space-y-1">
<p className="px-2 text-[11px] font-medium text-muted-foreground">
{customerName}
</p>
{items.map((p) => (
<div
key={p.id}
className={cn(
"rounded border bg-background/50 p-2 text-xs space-y-1 ml-1"
)}
>
<p className="font-medium text-foreground line-clamp-1 flex items-center justify-between gap-1">
<span> #{p.id}</span>
{p.customer?.tags && (
<span className="text-muted-foreground font-normal truncate max-w-[60%]">
{parseTags(p.customer.tags).slice(0, 2).join(", ")}
</span>
)}
</p>
<p className="text-muted-foreground line-clamp-2">
{p.raw_requirement.slice(0, 80)}
</p>
<div className="flex flex-wrap gap-1 pt-1">
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() => openPreview(p)}
title="预览"
>
<Eye className="h-3 w-3 mr-0.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() => openEdit(p)}
title="编辑"
>
<Pencil className="h-3 w-3 mr-0.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() => copySnippet(p.raw_requirement, "原始需求")}
>
<Copy className="h-3 w-3 mr-0.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() => copySnippet(p.ai_solution_md || "", "方案")}
>
<Copy className="h-3 w-3 mr-0.5" />
</Button>
</div>
</div>
))}
</div>
))
)}
</div>
{/* 预览弹窗 */}
<Dialog open={!!previewProject} onOpenChange={() => setPreviewProject(null)}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
#{previewProject?.id}
{previewProject?.customer?.name && (
<span className="text-sm font-normal text-muted-foreground ml-2 flex items-center gap-2">
{previewProject.customer.name}
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[11px]"
onClick={() => openEditCustomer(previewProject)}
>
</Button>
</span>
)}
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="requirement" className="flex-1 min-h-0 flex flex-col overflow-hidden">
<TabsList>
<TabsTrigger value="requirement"></TabsTrigger>
<TabsTrigger value="solution"></TabsTrigger>
</TabsList>
<TabsContent value="requirement" className="mt-2 overflow-auto flex-1 min-h-0">
<pre className="text-xs whitespace-pre-wrap bg-muted/50 p-3 rounded-md">
{previewProject?.raw_requirement ?? ""}
</pre>
</TabsContent>
<TabsContent value="solution" className="mt-2 overflow-auto flex-1 min-h-0">
<div className="prose prose-sm dark:prose-invert max-w-none p-2">
{previewProject?.ai_solution_md ? (
<ReactMarkdown>{previewProject.ai_solution_md}</ReactMarkdown>
) : (
<p className="text-muted-foreground"></p>
)}
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
{/* 二次编辑弹窗 */}
<Dialog open={!!editProject} onOpenChange={() => !saving && setEditProject(null)}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle> #{editProject?.id}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2 flex-1 min-h-0 overflow-auto">
<div className="grid gap-2">
<Label></Label>
<textarea
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none"
value={editRaw}
onChange={(e) => setEditRaw(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label> (Markdown)</Label>
<textarea
className="min-h-[180px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none"
value={editMd}
onChange={(e) => setEditMd(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditProject(null)} disabled={saving}>
</Button>
<Button onClick={handleSaveEdit} disabled={saving}>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 客户二次编辑弹窗 */}
<Dialog open={!!editCustomer} onOpenChange={() => !savingCustomer && setEditCustomer(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="hist-customer-name"></Label>
<Input
id="hist-customer-name"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="hist-customer-contact"></Label>
<Input
id="hist-customer-contact"
value={customerContact}
onChange={(e) => setCustomerContact(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="hist-customer-tags"></Label>
<Input
id="hist-customer-tags"
value={customerTags}
onChange={(e) => setCustomerTags(e.target.value)}
placeholder="如:重点客户, 已签约"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setEditCustomer(null)}
disabled={savingCustomer}
>
</Button>
<Button onClick={handleSaveCustomer} disabled={savingCustomer}>
{savingCustomer && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}