Files
AiTool/frontend/app/(main)/workspace/page.tsx
丹尼尔 ad96272ab6 fix:bug
2026-03-12 19:35:06 +08:00

272 lines
9.6 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 } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import ReactMarkdown from "react-markdown";
import { Wand2, Save, FileSpreadsheet, FileDown, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
customersApi,
projectsApi,
downloadFile,
downloadFileAsBlob,
type CustomerRead,
type QuoteGenerateResponse,
} from "@/lib/api/client";
export default function WorkspacePage() {
const [customers, setCustomers] = useState<CustomerRead[]>([]);
const [customerId, setCustomerId] = useState<string>("");
const [rawText, setRawText] = useState("");
const [solutionMd, setSolutionMd] = useState("");
const [projectId, setProjectId] = useState<number | null>(null);
const [lastQuote, setLastQuote] = useState<QuoteGenerateResponse | null>(null);
const [analyzing, setAnalyzing] = useState(false);
const [saving, setSaving] = useState(false);
const [generatingQuote, setGeneratingQuote] = useState(false);
const loadCustomers = useCallback(async () => {
try {
const list = await customersApi.list();
setCustomers(list);
if (list.length > 0 && !customerId) setCustomerId(String(list[0].id));
} catch (e) {
toast.error("加载客户列表失败");
}
}, [customerId]);
useEffect(() => {
loadCustomers();
}, [loadCustomers]);
const handleAnalyze = async () => {
if (!customerId || !rawText.trim()) {
toast.error("请选择客户并输入原始需求");
return;
}
setAnalyzing(true);
try {
const res = await projectsApi.analyze({
customer_id: Number(customerId),
raw_text: rawText.trim(),
});
setSolutionMd(res.ai_solution_md);
setProjectId(res.project_id);
setLastQuote(null);
toast.success("方案已生成,可在右侧编辑");
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "AI 解析失败";
toast.error(msg);
} finally {
setAnalyzing(false);
}
};
const handleSaveToArchive = async () => {
if (projectId == null) {
toast.error("请先进行 AI 解析");
return;
}
setSaving(true);
try {
await projectsApi.update(projectId, { ai_solution_md: solutionMd });
toast.success("已保存到项目档案");
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "保存失败";
toast.error(msg);
} finally {
setSaving(false);
}
};
const handleDraftQuote = async () => {
if (projectId == null) {
toast.error("请先进行 AI 解析并保存");
return;
}
setGeneratingQuote(true);
try {
const res = await projectsApi.generateQuote(projectId);
setLastQuote(res);
toast.success("报价单已生成");
downloadFile(res.excel_path, `quote_project_${projectId}.xlsx`);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "生成报价失败";
toast.error(msg);
} finally {
setGeneratingQuote(false);
}
};
const handleExportPdf = async () => {
if (lastQuote?.pdf_path) {
downloadFile(lastQuote.pdf_path, `quote_project_${projectId}.pdf`);
toast.success("PDF 已下载");
return;
}
if (projectId == null) {
toast.error("请先进行 AI 解析");
return;
}
setGeneratingQuote(true);
try {
const res = await projectsApi.generateQuote(projectId);
setLastQuote(res);
await downloadFileAsBlob(res.pdf_path, `quote_project_${projectId}.pdf`);
toast.success("PDF 已下载");
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "生成 PDF 失败";
toast.error(msg);
} finally {
setGeneratingQuote(false);
}
};
return (
<div className="flex h-full flex-col">
<div className="flex flex-1 min-h-0">
{/* Left Panel — 40% */}
<div className="flex w-[40%] flex-col border-r">
<Card className="rounded-none border-0 border-b h-full flex flex-col">
<CardHeader className="py-3">
<CardTitle className="text-base flex items-center justify-between">
<Button
size="sm"
onClick={handleAnalyze}
disabled={analyzing || !rawText.trim() || !customerId}
>
{analyzing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
<span className="ml-1.5">AI </span>
</Button>
</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-2 min-h-0 pt-0">
<div className="space-y-1.5">
<Label></Label>
<Select value={customerId} onValueChange={setCustomerId}>
<SelectTrigger>
<SelectValue placeholder="选择客户" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 flex flex-col min-h-0">
<Label>/</Label>
<textarea
className="mt-1.5 flex-1 min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none"
placeholder="粘贴或输入客户原始需求..."
value={rawText}
onChange={(e) => setRawText(e.target.value)}
/>
</div>
</CardContent>
</Card>
</div>
{/* Right Panel — 60% */}
<div className="flex flex-1 flex-col min-w-0">
<Card className="rounded-none border-0 h-full flex flex-col">
<CardHeader className="py-3">
<CardTitle className="text-base">稿</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<Tabs defaultValue="edit" className="flex-1 flex flex-col min-h-0">
<TabsList>
<TabsTrigger value="edit"></TabsTrigger>
<TabsTrigger value="preview"></TabsTrigger>
</TabsList>
<TabsContent value="edit" className="flex-1 min-h-0 mt-2">
<textarea
className="h-full min-h-[320px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none"
placeholder="AI 解析后将在此显示 Markdown可直接修改..."
value={solutionMd}
onChange={(e) => setSolutionMd(e.target.value)}
/>
</TabsContent>
<TabsContent value="preview" className="flex-1 min-h-0 mt-2 overflow-auto">
<div className="prose prose-sm dark:prose-invert max-w-none p-2">
{solutionMd ? (
<ReactMarkdown>{solutionMd}</ReactMarkdown>
) : (
<p className="text-muted-foreground"></p>
)}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
</div>
{/* Floating Action Bar */}
<div className="flex items-center gap-3 border-t bg-card px-4 py-3 shadow-[0_-2px 10px rgba(0,0,0,0.05)]">
<Button
variant="outline"
size="sm"
onClick={handleSaveToArchive}
disabled={saving || projectId == null}
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
<span className="ml-1.5"></span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDraftQuote}
disabled={generatingQuote || projectId == null}
>
{generatingQuote ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileSpreadsheet className="h-4 w-4" />
)}
<span className="ml-1.5"> (Excel)</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExportPdf}
disabled={generatingQuote || projectId == null}
>
{generatingQuote ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileDown className="h-4 w-4" />
)}
<span className="ml-1.5"> PDF</span>
</Button>
{projectId != null && (
<span className="text-xs text-muted-foreground ml-2">
#{projectId}
</span>
)}
</div>
</div>
);
}