Files
AiTool/frontend/app/(main)/workspace/page.tsx
2026-03-15 16:38:59 +08:00

491 lines
18 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 { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog";
import ReactMarkdown from "react-markdown";
import { Wand2, Save, FileSpreadsheet, FileDown, Loader2, Plus, Search, CloudUpload } from "lucide-react";
import { toast } from "sonner";
import {
customersApi,
projectsApi,
templatesApi,
pushProjectToCloud,
downloadFile,
downloadFileAsBlob,
type CustomerRead,
type QuoteGenerateResponse,
type TemplateInfo,
} 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 [addCustomerOpen, setAddCustomerOpen] = useState(false);
const [newCustomerName, setNewCustomerName] = useState("");
const [newCustomerContact, setNewCustomerContact] = useState("");
const [newCustomerTags, setNewCustomerTags] = useState("");
const [addingCustomer, setAddingCustomer] = useState(false);
const [quoteTemplates, setQuoteTemplates] = useState<TemplateInfo[]>([]);
const [selectedQuoteTemplate, setSelectedQuoteTemplate] = useState<string>("");
const [customerSearch, setCustomerSearch] = useState("");
const [pushToCloudLoading, setPushToCloudLoading] = useState(false);
const loadCustomers = useCallback(async (search?: string) => {
try {
const list = await customersApi.list(search?.trim() ? { q: search.trim() } : undefined);
setCustomers(list);
if (list.length > 0 && !customerId) setCustomerId(String(list[0].id));
} catch (e) {
toast.error("加载客户列表失败");
}
}, [customerId]);
useEffect(() => {
loadCustomers(customerSearch);
}, [loadCustomers, customerSearch]);
const loadQuoteTemplates = useCallback(async () => {
try {
const list = await templatesApi.list();
setQuoteTemplates(list.filter((t) => t.type === "excel"));
} catch {
// ignore
}
}, []);
useEffect(() => {
loadQuoteTemplates();
}, [loadQuoteTemplates]);
const handleAddCustomer = async (e: React.FormEvent) => {
e.preventDefault();
const name = newCustomerName.trim();
if (!name) {
toast.error("请填写客户名称");
return;
}
setAddingCustomer(true);
try {
const created = await customersApi.create({
name,
contact_info: newCustomerContact.trim() || null,
tags: newCustomerTags.trim() || null,
});
toast.success("客户已添加");
setAddCustomerOpen(false);
setNewCustomerName("");
setNewCustomerContact("");
setNewCustomerTags("");
await loadCustomers(customerSearch);
setCustomerId(String(created.id));
} catch (err) {
const msg = err instanceof Error ? err.message : "添加失败";
toast.error(msg);
} finally {
setAddingCustomer(false);
}
};
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 handlePushToCloud = async (platform: "feishu" | "yuque" | "tencent") => {
if (projectId == null) return;
const md = solutionMd?.trim() || "";
if (!md) {
toast.error("请先在编辑器中填写方案内容后再推送");
return;
}
setPushToCloudLoading(true);
try {
const res = await pushProjectToCloud(projectId, {
platform,
body_md: md,
});
toast.success("已推送到云文档", {
action: res.url
? {
label: "打开链接",
onClick: () => window.open(res.url, "_blank"),
}
: undefined,
});
} catch (e) {
toast.error(e instanceof Error ? e.message : "推送失败");
} finally {
setPushToCloudLoading(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,
selectedQuoteTemplate || undefined
);
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,
selectedQuoteTemplate || undefined
);
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>
<div className="flex gap-1 items-center">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索客户名称/联系方式"
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)}
className="pl-8 h-9"
/>
</div>
</div>
<div className="flex gap-1">
<Select value={customerId} onValueChange={setCustomerId}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="选择客户" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
<span className="flex items-center gap-2">
{c.name}
{c.tags && (
<span className="text-muted-foreground text-xs">
({c.tags})
</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Dialog open={addCustomerOpen} onOpenChange={setAddCustomerOpen}>
<DialogTrigger asChild>
<Button type="button" size="icon" variant="outline" title="新建客户">
<Plus className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form onSubmit={handleAddCustomer}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="customer-name"></Label>
<Input
id="customer-name"
value={newCustomerName}
onChange={(e) => setNewCustomerName(e.target.value)}
placeholder="必填"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="customer-contact"></Label>
<Input
id="customer-contact"
value={newCustomerContact}
onChange={(e) => setNewCustomerContact(e.target.value)}
placeholder="选填"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="customer-tags"></Label>
<Input
id="customer-tags"
value={newCustomerTags}
onChange={(e) => setNewCustomerTags(e.target.value)}
placeholder="如:重点客户, 已签约"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setAddCustomerOpen(false)}
>
</Button>
<Button type="submit" disabled={addingCustomer}>
{addingCustomer ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"添加"
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</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)] flex-wrap">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap"></span>
<Select
value={selectedQuoteTemplate || "__latest__"}
onValueChange={(v) => setSelectedQuoteTemplate(v === "__latest__" ? "" : v)}
>
<SelectTrigger className="w-[180px] h-8">
<SelectValue placeholder="使用最新上传" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__latest__">使</SelectItem>
{quoteTemplates.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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>
<Select
value="__none__"
onValueChange={(v) => {
if (v === "feishu" || v === "yuque" || v === "tencent") handlePushToCloud(v);
}}
disabled={pushToCloudLoading || projectId == null}
>
<SelectTrigger className="w-[140px] h-8">
{pushToCloudLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CloudUpload className="h-4 w-4 mr-1" />
)}
<SelectValue placeholder="推送到云文档" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" disabled>
</SelectItem>
<SelectItem value="feishu"></SelectItem>
<SelectItem value="yuque"></SelectItem>
<SelectItem value="tencent"></SelectItem>
</SelectContent>
</Select>
{projectId != null && (
<span className="text-xs text-muted-foreground ml-2">
#{projectId}
</span>
)}
</div>
</div>
);
}