fix:优化数据
This commit is contained in:
503
frontend/app/(main)/finance/page.tsx
Normal file
503
frontend/app/(main)/finance/page.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
apiBase,
|
||||
financeApi,
|
||||
type FinanceRecordRead,
|
||||
type FinanceSyncResponse,
|
||||
type FinanceSyncResult,
|
||||
} from "@/lib/api/client";
|
||||
import { Download, Inbox, Loader2, Mail, FileText, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function FinancePage() {
|
||||
const [months, setMonths] = useState<string[]>([]);
|
||||
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
||||
const [records, setRecords] = useState<FinanceRecordRead[]>([]);
|
||||
const [loadingMonths, setLoadingMonths] = useState(true);
|
||||
const [loadingRecords, setLoadingRecords] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [lastSync, setLastSync] = useState<FinanceSyncResponse | null>(null);
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [reviewRecord, setReviewRecord] = useState<FinanceRecordRead | null>(null);
|
||||
const [reviewAmount, setReviewAmount] = useState("");
|
||||
const [reviewDate, setReviewDate] = useState("");
|
||||
const [savingReview, setSavingReview] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const previewUrlRef = useRef<string | null>(null);
|
||||
|
||||
const loadMonths = useCallback(async () => {
|
||||
try {
|
||||
const list = await financeApi.listMonths();
|
||||
setMonths(list);
|
||||
if (list.length > 0 && !selectedMonth) setSelectedMonth(list[0]);
|
||||
} catch {
|
||||
toast.error("加载月份列表失败");
|
||||
} finally {
|
||||
setLoadingMonths(false);
|
||||
}
|
||||
}, [selectedMonth]);
|
||||
|
||||
const loadRecords = useCallback(async () => {
|
||||
if (!selectedMonth) {
|
||||
setRecords([]);
|
||||
return;
|
||||
}
|
||||
setLoadingRecords(true);
|
||||
try {
|
||||
const list = await financeApi.listRecords(selectedMonth);
|
||||
setRecords(list);
|
||||
} catch {
|
||||
toast.error("加载记录失败");
|
||||
} finally {
|
||||
setLoadingRecords(false);
|
||||
}
|
||||
}, [selectedMonth]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMonths();
|
||||
}, [loadMonths]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
}, [loadRecords]);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
toast.loading("正在同步邮箱…", { id: "finance-sync" });
|
||||
try {
|
||||
const res: FinanceSyncResponse = await financeApi.sync();
|
||||
setLastSync(res);
|
||||
toast.dismiss("finance-sync");
|
||||
if (res.new_files > 0) {
|
||||
toast.success(`发现 ${res.new_files} 个新文件`);
|
||||
await loadMonths();
|
||||
if (selectedMonth) await loadRecords();
|
||||
} else {
|
||||
toast.info("收件箱已是最新,无新文件");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.dismiss("finance-sync");
|
||||
toast.error(e instanceof Error ? e.message : "同步失败");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetUploadDialog = useCallback(() => {
|
||||
setUploadFile(null);
|
||||
if (previewUrlRef.current) {
|
||||
URL.revokeObjectURL(previewUrlRef.current);
|
||||
previewUrlRef.current = null;
|
||||
}
|
||||
setPreviewUrl(null);
|
||||
setReviewRecord(null);
|
||||
setReviewAmount("");
|
||||
setReviewDate("");
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"];
|
||||
if (!allowed.includes(file.type) && !file.name.match(/\.(pdf|jpg|jpeg|png|webp)$/i)) {
|
||||
toast.error("仅支持 PDF、JPG、PNG、WEBP");
|
||||
return;
|
||||
}
|
||||
if (previewUrlRef.current) {
|
||||
URL.revokeObjectURL(previewUrlRef.current);
|
||||
previewUrlRef.current = null;
|
||||
}
|
||||
setUploadFile(file);
|
||||
setReviewRecord(null);
|
||||
setReviewAmount("");
|
||||
setReviewDate("");
|
||||
if (file.type.startsWith("image/")) {
|
||||
const url = URL.createObjectURL(file);
|
||||
previewUrlRef.current = url;
|
||||
setPreviewUrl(url);
|
||||
} else {
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadSubmit = async () => {
|
||||
if (!uploadFile) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const record = await financeApi.uploadInvoice(uploadFile);
|
||||
setReviewRecord(record);
|
||||
setReviewAmount(record.amount != null ? String(record.amount) : "");
|
||||
setReviewDate(record.billing_date || "");
|
||||
toast.success("已上传,请核对金额与日期");
|
||||
await loadMonths();
|
||||
if (selectedMonth === record.month) await loadRecords();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "上传失败");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReviewSave = async () => {
|
||||
if (!reviewRecord) return;
|
||||
const amount = reviewAmount.trim() ? parseFloat(reviewAmount) : null;
|
||||
const billing_date = reviewDate.trim() || null;
|
||||
setSavingReview(true);
|
||||
try {
|
||||
await financeApi.updateRecord(reviewRecord.id, { amount, billing_date });
|
||||
toast.success("已保存");
|
||||
setUploadDialogOpen(false);
|
||||
resetUploadDialog();
|
||||
if (selectedMonth) await loadRecords();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSavingReview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadZip = async () => {
|
||||
if (!selectedMonth) {
|
||||
toast.error("请先选择月份");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await financeApi.downloadMonth(selectedMonth);
|
||||
toast.success(`已下载 ${selectedMonth}.zip`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (s: string) =>
|
||||
new Date(s).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
invoices: "发票",
|
||||
bank_records: "流水",
|
||||
statements: "流水",
|
||||
receipts: "回执",
|
||||
manual: "手动上传",
|
||||
others: "其他",
|
||||
};
|
||||
|
||||
const monthlyTotal = records.reduce((sum, r) => sum + (r.amount ?? 0), 0);
|
||||
const totalInvoicesThisMonth = records.filter(
|
||||
(r) => r.amount != null && (r.type === "manual" || r.type === "invoices")
|
||||
).reduce((s, r) => s + (r.amount ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">财务归档</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
从网易邮箱同步发票、回执、流水等附件,或手动上传发票
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{selectedMonth && (
|
||||
<Card className="py-2 px-4">
|
||||
<p className="text-xs text-muted-foreground">本月发票合计</p>
|
||||
<p className="text-lg font-semibold">
|
||||
¥{totalInvoicesThisMonth.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setUploadDialogOpen(true);
|
||||
resetUploadDialog();
|
||||
}}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
<span className="ml-2">上传发票</span>
|
||||
</Button>
|
||||
<Button onClick={handleSync} disabled={syncing} size="default">
|
||||
{syncing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-2">同步邮箱</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Invoice Dialog */}
|
||||
<Dialog open={uploadDialogOpen} onOpenChange={(open) => {
|
||||
setUploadDialogOpen(open);
|
||||
if (!open) resetUploadDialog();
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{reviewRecord ? "核对金额与日期" : "上传发票"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!reviewRecord ? (
|
||||
<>
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="预览" className="max-h-48 mx-auto object-contain" />
|
||||
) : uploadFile ? (
|
||||
<p className="text-sm font-medium">{uploadFile.name}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">点击或拖拽 PDF/图片到此处</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUploadDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleUploadSubmit} disabled={!uploadFile || uploading}>
|
||||
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<span className="ml-2">上传并识别</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{previewUrl && (
|
||||
<img src={previewUrl} alt="预览" className="max-h-32 rounded border object-contain" />
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label>金额</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={reviewAmount}
|
||||
onChange={(e) => setReviewAmount(e.target.value)}
|
||||
placeholder="可手动修改"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>开票日期</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={reviewDate}
|
||||
onChange={(e) => setReviewDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setReviewRecord(null); setReviewAmount(""); setReviewDate(""); }}>
|
||||
继续上传
|
||||
</Button>
|
||||
<Button onClick={handleReviewSave} disabled={savingReview}>
|
||||
{savingReview ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<span className="ml-2">保存并关闭</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Sync History / Last sync */}
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Inbox className="h-4 w-4" />
|
||||
同步记录
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
{lastSync !== null ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
最近一次同步:发现 <strong>{lastSync.new_files}</strong> 个新文件
|
||||
</p>
|
||||
{lastSync.details && lastSync.details.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{Object.entries(
|
||||
lastSync.details.reduce<Record<string, FinanceSyncResult[]>>(
|
||||
(acc, item) => {
|
||||
const t = item.type || "others";
|
||||
if (!acc[t]) acc[t] = [];
|
||||
acc[t].push(item);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
).map(([t, items]) => (
|
||||
<div key={t}>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{typeLabel[t] ?? t}({items.length})
|
||||
</p>
|
||||
<ul className="mt-1 ml-4 list-disc space-y-0.5 text-xs text-muted-foreground">
|
||||
{items.map((it) => (
|
||||
<li key={it.id}>
|
||||
{it.file_name}
|
||||
<span className="ml-1 text-[11px] text-muted-foreground/80">
|
||||
[{it.month}]
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
点击「同步邮箱」后,将显示本次同步结果
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Month + Download */}
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">按月份查看</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedMonth}
|
||||
onValueChange={setSelectedMonth}
|
||||
disabled={loadingMonths}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="选择月份" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{months.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadZip}
|
||||
disabled={!selectedMonth || records.length === 0}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="ml-1.5">下载本月全部 (.zip)</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingRecords ? (
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1 py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中…
|
||||
</p>
|
||||
) : records.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
{selectedMonth ? "该月份暂无归档文件" : "请选择月份或先同步邮箱"}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
类型:发票 / 回执 / 流水。同步的发票已按「日期_金额_原文件名」重命名。
|
||||
</p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>文件名</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>开票/归档时间</TableHead>
|
||||
<TableHead className="w-[100px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>
|
||||
<span className="text-muted-foreground">
|
||||
{typeLabel[r.type] ?? r.type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{r.file_name}</TableCell>
|
||||
<TableCell>
|
||||
{r.amount != null
|
||||
? `¥${Number(r.amount).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}`
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{r.billing_date || formatDate(r.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`${apiBase()}${r.file_path.startsWith("/") ? "" : "/"}${r.file_path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary text-sm hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
下载
|
||||
</a>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="bg-muted/30 font-medium">
|
||||
<TableCell colSpan={2}>本月合计</TableCell>
|
||||
<TableCell>
|
||||
¥{monthlyTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2} />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
391
frontend/app/(main)/settings/ai/page.tsx
Normal file
391
frontend/app/(main)/settings/ai/page.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
aiSettingsApi,
|
||||
type AIConfig,
|
||||
type AIConfigListItem,
|
||||
type AIConfigCreate,
|
||||
type AIConfigUpdate,
|
||||
} from "@/lib/api/client";
|
||||
import { Loader2, Zap, Plus, Pencil, Trash2, CheckCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const PROVIDERS = ["OpenAI", "DeepSeek", "Custom"];
|
||||
|
||||
export default function SettingsAIPage() {
|
||||
const [list, setList] = useState<AIConfigListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
|
||||
const [formName, setFormName] = useState("");
|
||||
const [provider, setProvider] = useState("OpenAI");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [apiKeyConfigured, setApiKeyConfigured] = useState(false);
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [modelName, setModelName] = useState("gpt-4o-mini");
|
||||
const [temperature, setTemperature] = useState("0.2");
|
||||
const [systemPromptOverride, setSystemPromptOverride] = useState("");
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
try {
|
||||
const data = await aiSettingsApi.list();
|
||||
setList(data);
|
||||
} catch {
|
||||
toast.error("加载模型列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadList();
|
||||
}, [loadList]);
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
setFormName("");
|
||||
setProvider("OpenAI");
|
||||
setApiKey("");
|
||||
setApiKeyConfigured(false);
|
||||
setBaseUrl("");
|
||||
setModelName("gpt-4o-mini");
|
||||
setTemperature("0.2");
|
||||
setSystemPromptOverride("");
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = async (id: string) => {
|
||||
try {
|
||||
const c = await aiSettingsApi.getById(id);
|
||||
setEditingId(id);
|
||||
setFormName(c.name || "");
|
||||
setProvider(c.provider || "OpenAI");
|
||||
setApiKey("");
|
||||
setApiKeyConfigured(!!(c.api_key && c.api_key.length > 0));
|
||||
setBaseUrl(c.base_url || "");
|
||||
setModelName(c.model_name || "gpt-4o-mini");
|
||||
setTemperature(String(c.temperature ?? 0.2));
|
||||
setSystemPromptOverride(c.system_prompt_override || "");
|
||||
setDialogOpen(true);
|
||||
} catch {
|
||||
toast.error("加载配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingId) {
|
||||
const payload: AIConfigUpdate = {
|
||||
name: formName.trim() || undefined,
|
||||
provider: provider || undefined,
|
||||
base_url: baseUrl.trim() || undefined,
|
||||
model_name: modelName.trim() || undefined,
|
||||
temperature: parseFloat(temperature),
|
||||
system_prompt_override: systemPromptOverride.trim() || undefined,
|
||||
};
|
||||
if (apiKey.trim()) payload.api_key = apiKey.trim();
|
||||
await aiSettingsApi.update(editingId, payload);
|
||||
toast.success("已更新");
|
||||
} else {
|
||||
const payload: AIConfigCreate = {
|
||||
name: formName.trim() || undefined,
|
||||
provider: provider || undefined,
|
||||
api_key: apiKey.trim() || undefined,
|
||||
base_url: baseUrl.trim() || undefined,
|
||||
model_name: modelName.trim() || undefined,
|
||||
temperature: parseFloat(temperature),
|
||||
system_prompt_override: systemPromptOverride.trim() || undefined,
|
||||
};
|
||||
await aiSettingsApi.create(payload);
|
||||
toast.success("已添加");
|
||||
}
|
||||
setDialogOpen(false);
|
||||
await loadList();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (id: string) => {
|
||||
try {
|
||||
await aiSettingsApi.activate(id);
|
||||
toast.success("已选用该模型");
|
||||
await loadList();
|
||||
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "选用失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("确定删除该模型配置?")) return;
|
||||
try {
|
||||
await aiSettingsApi.delete(id);
|
||||
toast.success("已删除");
|
||||
await loadList();
|
||||
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTesting(id);
|
||||
try {
|
||||
const res = await aiSettingsApi.test(id);
|
||||
toast.success(res.message ? `连接成功:${res.message}` : "连接成功");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "连接失败");
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl">
|
||||
<div className="mb-4">
|
||||
<Link href="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← 设置
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>已配置的模型</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
在此查看、选用或编辑多套 AI 模型配置;需求解析与测试将使用当前选用的配置。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openAdd}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="ml-2">添加模型</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{list.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
暂无模型配置。点击「添加模型」填写 API Key、模型名称等,保存后即可在列表中选用。
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>提供商</TableHead>
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead className="w-[200px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{list.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">
|
||||
{item.name || "未命名"}
|
||||
{item.is_active && (
|
||||
<span className="ml-2 text-xs text-primary flex items-center gap-0.5">
|
||||
<CheckCircle className="h-3.5 w-3.5" /> 当前选用
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{item.provider}</TableCell>
|
||||
<TableCell>{item.model_name || "—"}</TableCell>
|
||||
<TableCell>{item.api_key_configured ? "已配置" : "未配置"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{!item.is_active && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleActivate(item.id)}
|
||||
>
|
||||
选用
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEdit(item.id)}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTest(item.id)}
|
||||
disabled={testing === item.id}
|
||||
title="测试连接"
|
||||
>
|
||||
{testing === item.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Zap className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
title="删除"
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? "编辑模型配置" : "添加模型配置"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ai-name">配置名称(便于区分)</Label>
|
||||
<Input
|
||||
id="ai-name"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="如:OpenAI 生产、DeepSeek 备用"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="provider">提供商</Label>
|
||||
<Select value={provider} onValueChange={setProvider}>
|
||||
<SelectTrigger id="provider">
|
||||
<SelectValue placeholder="选择提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROVIDERS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="api_key">API Key</Label>
|
||||
<Input
|
||||
id="api_key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={apiKeyConfigured ? "已配置,输入新值以修改" : "请输入 API Key"}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="base_url">Base URL(可选)</Label>
|
||||
<Input
|
||||
id="base_url"
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="model_name">模型名称</Label>
|
||||
<Input
|
||||
id="model_name"
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
placeholder="gpt-4o-mini / deepseek-chat 等"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="temperature">Temperature (0–2)</Label>
|
||||
<Input
|
||||
id="temperature"
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={(e) => setTemperature(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="system_prompt_override">系统提示词覆盖(可选)</Label>
|
||||
<textarea
|
||||
id="system_prompt_override"
|
||||
className="flex min-h-[60px] 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"
|
||||
value={systemPromptOverride}
|
||||
onChange={(e) => setSystemPromptOverride(e.target.value)}
|
||||
placeholder="留空则使用默认"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||
{editingId ? "保存" : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
frontend/app/(main)/settings/cloud-doc-config/page.tsx
Normal file
209
frontend/app/(main)/settings/cloud-doc-config/page.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
cloudDocConfigApi,
|
||||
type CloudDocConfigRead,
|
||||
type CloudDocConfigUpdate,
|
||||
} from "@/lib/api/client";
|
||||
import { Loader2, Save, FileStack } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function SettingsCloudDocConfigPage() {
|
||||
const [config, setConfig] = useState<CloudDocConfigRead | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<CloudDocConfigUpdate>({
|
||||
feishu: { app_id: "", app_secret: "" },
|
||||
yuque: { token: "", default_repo: "" },
|
||||
tencent: { client_id: "", client_secret: "" },
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await cloudDocConfigApi.get();
|
||||
setConfig(data);
|
||||
setForm({
|
||||
feishu: { app_id: data.feishu.app_id, app_secret: "" },
|
||||
yuque: { token: "", default_repo: data.yuque.default_repo },
|
||||
tencent: { client_id: data.tencent.client_id, client_secret: "" },
|
||||
});
|
||||
} catch {
|
||||
toast.error("加载云文档配置失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: CloudDocConfigUpdate = {};
|
||||
if (form.feishu?.app_id !== undefined) payload.feishu = { app_id: form.feishu.app_id };
|
||||
if (form.feishu?.app_secret !== undefined && form.feishu.app_secret !== "")
|
||||
payload.feishu = { ...payload.feishu, app_secret: form.feishu.app_secret };
|
||||
if (form.yuque?.token !== undefined && form.yuque.token !== "")
|
||||
payload.yuque = { token: form.yuque.token };
|
||||
if (form.yuque?.default_repo !== undefined)
|
||||
payload.yuque = { ...payload.yuque, default_repo: form.yuque.default_repo };
|
||||
if (form.tencent?.client_id !== undefined) payload.tencent = { client_id: form.tencent.client_id };
|
||||
if (form.tencent?.client_secret !== undefined && form.tencent.client_secret !== "")
|
||||
payload.tencent = { ...payload.tencent, client_secret: form.tencent.client_secret };
|
||||
await cloudDocConfigApi.update(payload);
|
||||
toast.success("已保存");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 max-w-2xl flex items-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">加载中…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl">
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href="/settings"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← 设置
|
||||
</Link>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileStack className="h-5 w-5" />
|
||||
云文档配置
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
配置飞书、语雀、腾讯文档的 API 凭证,用于在工作台「推送到云文档」时创建/更新在线文档。
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 飞书 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">飞书 (Feishu)</h3>
|
||||
<div className="grid gap-2">
|
||||
<Label>App ID</Label>
|
||||
<Input
|
||||
value={form.feishu?.app_id ?? config?.feishu?.app_id ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
feishu: { ...f.feishu, app_id: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="在飞书开放平台创建应用后获取"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>App Secret</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.feishu?.app_secret ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
feishu: { ...f.feishu, app_secret: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder={config?.feishu?.app_secret_configured ? "已配置,留空不修改" : "必填"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 语雀 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">语雀 (Yuque)</h3>
|
||||
<div className="grid gap-2">
|
||||
<Label>Personal Access Token</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.yuque?.token ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
yuque: { ...f.yuque, token: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder={config?.yuque?.token_configured ? "已配置,留空不修改" : "在语雀 设置 → Token 中创建"}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>默认知识库 (namespace)</Label>
|
||||
<Input
|
||||
value={form.yuque?.default_repo ?? config?.yuque?.default_repo ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
yuque: { ...f.yuque, default_repo: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="如:your_username/repo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 腾讯文档 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">腾讯文档 (Tencent)</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
腾讯文档需 OAuth 用户授权,当前版本仅保留配置项,推送功能请先用飞书或语雀。
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
<Label>Client ID</Label>
|
||||
<Input
|
||||
value={form.tencent?.client_id ?? config?.tencent?.client_id ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
tencent: { ...f.tencent, client_id: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="开放平台应用 Client ID"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Client Secret</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.tencent?.client_secret ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
tencent: { ...f.tencent, client_secret: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder={config?.tencent?.client_secret_configured ? "已配置,留空不修改" : "选填"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
保存配置
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
261
frontend/app/(main)/settings/cloud-docs/page.tsx
Normal file
261
frontend/app/(main)/settings/cloud-docs/page.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
cloudDocsApi,
|
||||
type CloudDocLinkRead,
|
||||
type CloudDocLinkCreate,
|
||||
} from "@/lib/api/client";
|
||||
import { FileStack, Plus, Pencil, Trash2, Loader2, ExternalLink } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function SettingsCloudDocsPage() {
|
||||
const [links, setLinks] = useState<CloudDocLinkRead[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", url: "" });
|
||||
|
||||
const loadLinks = useCallback(async () => {
|
||||
try {
|
||||
const list = await cloudDocsApi.list();
|
||||
setLinks(list);
|
||||
} catch {
|
||||
toast.error("加载云文档列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadLinks();
|
||||
}, [loadLinks]);
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
setForm({ name: "", url: "" });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: CloudDocLinkRead) => {
|
||||
setEditingId(item.id);
|
||||
setForm({ name: item.name, url: item.url });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
toast.error("请填写名称和链接");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingId) {
|
||||
await cloudDocsApi.update(editingId, {
|
||||
name: form.name.trim(),
|
||||
url: form.url.trim(),
|
||||
});
|
||||
toast.success("已更新");
|
||||
} else {
|
||||
const payload: CloudDocLinkCreate = {
|
||||
name: form.name.trim(),
|
||||
url: form.url.trim(),
|
||||
};
|
||||
await cloudDocsApi.create(payload);
|
||||
toast.success("已添加");
|
||||
}
|
||||
setDialogOpen(false);
|
||||
await loadLinks();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("cloud-docs-changed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("确定删除该云文档入口?")) return;
|
||||
try {
|
||||
await cloudDocsApi.delete(id);
|
||||
toast.success("已删除");
|
||||
await loadLinks();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("cloud-docs-changed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl">
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href="/settings"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← 设置
|
||||
</Link>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileStack className="h-5 w-5" />
|
||||
云文档快捷入口
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
添加腾讯文档、飞书、语雀等云文档登录/入口链接,侧栏可快速打开。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openAdd}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="ml-2">添加入口</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中…
|
||||
</p>
|
||||
) : links.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
暂无云文档入口。添加后将显示在左侧边栏「云文档」区域,点击即可在新标签页打开。
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>链接</TableHead>
|
||||
<TableHead className="w-[120px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{links.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{item.name}</TableCell>
|
||||
<TableCell>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline truncate max-w-[280px] inline-block"
|
||||
>
|
||||
{item.url}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEdit(item)}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
title="打开"
|
||||
>
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingId ? "编辑云文档入口" : "添加云文档入口"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cloud-doc-name">显示名称</Label>
|
||||
<Input
|
||||
id="cloud-doc-name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="如:腾讯文档、飞书、语雀"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cloud-doc-url">登录/入口链接</Label>
|
||||
<Input
|
||||
id="cloud-doc-url"
|
||||
type="url"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||
{editingId ? "保存" : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
364
frontend/app/(main)/settings/email/page.tsx
Normal file
364
frontend/app/(main)/settings/email/page.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
emailConfigsApi,
|
||||
type EmailConfigRead,
|
||||
type EmailConfigCreate,
|
||||
type EmailConfigUpdate,
|
||||
type EmailFolder,
|
||||
} from "@/lib/api/client";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Loader2, Mail, Plus, Pencil, Trash2, FolderOpen } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function SettingsEmailPage() {
|
||||
const [configs, setConfigs] = useState<EmailConfigRead[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
host: "",
|
||||
port: "993",
|
||||
user: "",
|
||||
password: "",
|
||||
mailbox: "INBOX",
|
||||
active: true,
|
||||
});
|
||||
const [folders, setFolders] = useState<EmailFolder[] | null>(null);
|
||||
const [foldersLoading, setFoldersLoading] = useState(false);
|
||||
|
||||
const loadConfigs = useCallback(async () => {
|
||||
try {
|
||||
const list = await emailConfigsApi.list();
|
||||
setConfigs(list);
|
||||
} catch {
|
||||
toast.error("加载邮箱列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, [loadConfigs]);
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
host: "",
|
||||
port: "993",
|
||||
user: "",
|
||||
password: "",
|
||||
mailbox: "INBOX",
|
||||
active: true,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (c: EmailConfigRead) => {
|
||||
setEditingId(c.id);
|
||||
setFolders(null);
|
||||
setForm({
|
||||
host: c.host,
|
||||
port: String(c.port),
|
||||
user: c.user,
|
||||
password: "",
|
||||
mailbox: c.mailbox || "INBOX",
|
||||
active: c.active,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const loadFolders = async () => {
|
||||
if (!editingId) return;
|
||||
setFoldersLoading(true);
|
||||
try {
|
||||
const res = await emailConfigsApi.listFolders(editingId);
|
||||
setFolders(res.folders);
|
||||
if (res.folders.length > 0 && !form.mailbox) {
|
||||
const inbox = res.folders.find((f) => f.decoded === "INBOX" || f.decoded === "收件箱");
|
||||
if (inbox) setForm((f) => ({ ...f, mailbox: inbox.decoded }));
|
||||
}
|
||||
toast.success(`已加载 ${res.folders.length} 个邮箱夹`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "获取邮箱列表失败");
|
||||
} finally {
|
||||
setFoldersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingId) {
|
||||
const payload: EmailConfigUpdate = {
|
||||
host: form.host,
|
||||
port: parseInt(form.port, 10) || 993,
|
||||
user: form.user,
|
||||
mailbox: form.mailbox,
|
||||
active: form.active,
|
||||
};
|
||||
if (form.password) payload.password = form.password;
|
||||
await emailConfigsApi.update(editingId, payload);
|
||||
toast.success("已更新");
|
||||
} else {
|
||||
await emailConfigsApi.create({
|
||||
host: form.host,
|
||||
port: parseInt(form.port, 10) || 993,
|
||||
user: form.user,
|
||||
password: form.password,
|
||||
mailbox: form.mailbox,
|
||||
active: form.active,
|
||||
});
|
||||
toast.success("已添加");
|
||||
}
|
||||
setDialogOpen(false);
|
||||
await loadConfigs();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("确定删除该邮箱账户?")) return;
|
||||
try {
|
||||
await emailConfigsApi.delete(id);
|
||||
toast.success("已删除");
|
||||
await loadConfigs();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl">
|
||||
<div className="mb-4">
|
||||
<a href="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← 设置
|
||||
</a>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
邮箱账户
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
配置多个邮箱用于财务邮件同步(发票、回执、流水)。同步时将遍历所有已启用的账户。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openAdd}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="ml-2">添加账户</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中…
|
||||
</p>
|
||||
) : configs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
暂无邮箱账户。添加后将在「财务归档」同步时使用;未添加时使用环境变量 IMAP_*。
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Host</TableHead>
|
||||
<TableHead>端口</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>Mailbox</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="w-[120px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{configs.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.host}</TableCell>
|
||||
<TableCell>{c.port}</TableCell>
|
||||
<TableCell>{c.user}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.mailbox}</TableCell>
|
||||
<TableCell>
|
||||
{c.active ? (
|
||||
<span className="text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 px-2 py-0.5 rounded">
|
||||
启用
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">禁用</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(c.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm">网易 163 邮箱配置说明</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground space-y-2 py-2">
|
||||
<p><strong>IMAP 服务器:</strong> imap.163.com,端口 993(SSL)。需在网易邮箱网页端开启「IMAP/SMTP 服务」。</p>
|
||||
<p><strong>密码:</strong> 使用「授权码」而非登录密码。在 设置 → POP3/SMTP/IMAP → 授权码管理 中生成。</p>
|
||||
<p><strong>邮箱夹:</strong> 填 INBOX 或 收件箱;若同步失败,请编辑该账户并点击「获取邮箱列表」选择「收件箱」或目标标签。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? "编辑邮箱账户" : "添加邮箱账户"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>IMAP Host</Label>
|
||||
<Input
|
||||
value={form.host}
|
||||
onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))}
|
||||
placeholder="imap.163.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>端口</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={(e) => setForm((f) => ({ ...f, port: e.target.value }))}
|
||||
placeholder="993"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>邮箱 / 用户名</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.user}
|
||||
onChange={(e) => setForm((f) => ({ ...f, user: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>密码 / 授权码 {editingId && "(留空则不修改)"}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
placeholder={editingId ? "••••••••" : "请输入"}
|
||||
autoComplete="off"
|
||||
required={!editingId}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>邮箱夹 / 自定义标签 (Mailbox)</Label>
|
||||
{editingId && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadFolders}
|
||||
disabled={foldersLoading}
|
||||
>
|
||||
{foldersLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <FolderOpen className="h-4 w-4" />}
|
||||
<span className="ml-1">获取邮箱列表</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{folders && folders.length > 0 ? (
|
||||
<Select
|
||||
value={form.mailbox}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, mailbox: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择邮箱夹" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{folders.map((f) => (
|
||||
<SelectItem key={f.raw} value={f.decoded}>
|
||||
{f.decoded}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={form.mailbox}
|
||||
onChange={(e) => setForm((f) => ({ ...f, mailbox: e.target.value }))}
|
||||
placeholder="INBOX、收件箱或自定义标签(163 等若 INBOX 失败会自动尝试收件箱)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="active"
|
||||
checked={form.active}
|
||||
onChange={(e) => setForm((f) => ({ ...f, active: e.target.checked }))}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<Label htmlFor="active">启用(参与同步)</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<span className="ml-2">{editingId ? "保存" : "添加"}</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/app/(main)/settings/page.tsx
Normal file
50
frontend/app/(main)/settings/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileSpreadsheet, FileText, Mail, FileStack, Globe } from "lucide-react";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="p-6 max-w-2xl">
|
||||
<h1 className="text-xl font-semibold">设置</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">系统与业务配置</p>
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/settings/templates" className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
模板管理(报价 Excel / 合同 Word)
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/settings/ai" className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
AI 模型配置
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/settings/email" className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
邮箱账户(多账户同步)
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/settings/cloud-docs" className="flex items-center gap-2">
|
||||
<FileStack className="h-4 w-4" />
|
||||
云文档快捷入口
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/settings/cloud-doc-config" className="flex items-center gap-2">
|
||||
<FileStack className="h-4 w-4" />
|
||||
云文档配置(飞书 / 语雀 / 腾讯文档 API 凭证)
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/settings/portal-links" className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
快捷门户
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
frontend/app/(main)/settings/portal-links/page.tsx
Normal file
256
frontend/app/(main)/settings/portal-links/page.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
portalLinksApi,
|
||||
type PortalLinkRead,
|
||||
type PortalLinkCreate,
|
||||
} from "@/lib/api/client";
|
||||
import { Globe, Plus, Pencil, Trash2, Loader2, ExternalLink } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function SettingsPortalLinksPage() {
|
||||
const [links, setLinks] = useState<PortalLinkRead[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", url: "" });
|
||||
|
||||
const loadLinks = useCallback(async () => {
|
||||
try {
|
||||
const list = await portalLinksApi.list();
|
||||
setLinks(list);
|
||||
} catch {
|
||||
toast.error("加载快捷门户列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadLinks();
|
||||
}, [loadLinks]);
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
setForm({ name: "", url: "" });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: PortalLinkRead) => {
|
||||
setEditingId(item.id);
|
||||
setForm({ name: item.name, url: item.url });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
toast.error("请填写名称和链接");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingId) {
|
||||
await portalLinksApi.update(editingId, {
|
||||
name: form.name.trim(),
|
||||
url: form.url.trim(),
|
||||
});
|
||||
toast.success("已更新");
|
||||
} else {
|
||||
const payload: PortalLinkCreate = {
|
||||
name: form.name.trim(),
|
||||
url: form.url.trim(),
|
||||
};
|
||||
await portalLinksApi.create(payload);
|
||||
toast.success("已添加");
|
||||
}
|
||||
setDialogOpen(false);
|
||||
await loadLinks();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("portal-links-changed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("确定删除该快捷门户入口?")) return;
|
||||
try {
|
||||
await portalLinksApi.delete(id);
|
||||
toast.success("已删除");
|
||||
await loadLinks();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("portal-links-changed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl">
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href="/settings"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← 设置
|
||||
</Link>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
快捷门户
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
添加税务、公积金等门户链接,侧栏可快速打开。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openAdd}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="ml-2">添加入口</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中…
|
||||
</p>
|
||||
) : links.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
暂无快捷门户入口。添加后将显示在左侧边栏「快捷门户」区域。
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>链接</TableHead>
|
||||
<TableHead className="w-[120px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{links.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{item.name}</TableCell>
|
||||
<TableCell>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline truncate max-w-[280px] inline-block"
|
||||
>
|
||||
{item.url}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEdit(item)}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild title="打开">
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingId ? "编辑快捷门户入口" : "添加快捷门户入口"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="portal-name">显示名称</Label>
|
||||
<Input
|
||||
id="portal-name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="如:电子税务局、公积金"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="portal-url">门户链接</Label>
|
||||
<Input
|
||||
id="portal-url"
|
||||
type="url"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||
{editingId ? "保存" : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
frontend/app/(main)/settings/templates/page.tsx
Normal file
175
frontend/app/(main)/settings/templates/page.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { templatesApi, type TemplateInfo } from "@/lib/api/client";
|
||||
import { Upload, Loader2, FileSpreadsheet, FileText } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function SettingsTemplatesPage() {
|
||||
const [templates, setTemplates] = useState<TemplateInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
const list = await templatesApi.list();
|
||||
setTemplates(list);
|
||||
} catch {
|
||||
toast.error("加载模板列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf("."));
|
||||
if (![".xlsx", ".xltx", ".docx", ".dotx"].includes(ext)) {
|
||||
toast.error("仅支持 .xlsx、.xltx、.docx、.dotx 文件");
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
await templatesApi.upload(file);
|
||||
toast.success(`已上传:${file.name}`);
|
||||
await loadTemplates();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "上传失败");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
};
|
||||
|
||||
const onDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const onDragLeave = () => setDragOver(false);
|
||||
|
||||
const onSelectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const formatDate = (ts: number) =>
|
||||
new Date(ts * 1000).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl">
|
||||
<div className="mb-4">
|
||||
<a href="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← 设置
|
||||
</a>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>模板库</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
上传报价单 Excel(.xlsx / .xltx)或合同 Word(.docx / .dotx),生成报价/合同时可选择使用。
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xltx,.docx,.dotx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.spreadsheetml.template,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.wordprocessingml.template"
|
||||
onChange={onSelectFile}
|
||||
className="hidden"
|
||||
id="template-upload"
|
||||
/>
|
||||
<label htmlFor="template-upload" className="cursor-pointer block">
|
||||
<Upload className="h-10 w-10 mx-auto text-muted-foreground" />
|
||||
<p className="mt-2 text-sm font-medium">拖拽文件到此处,或点击选择</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">仅支持 .xlsx、.xltx、.docx、.dotx</p>
|
||||
</label>
|
||||
{uploading && (
|
||||
<p className="mt-2 text-sm text-muted-foreground flex items-center justify-center gap-1">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
上传中…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">已上传模板</h3>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中…
|
||||
</p>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无模板</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>文件名</TableHead>
|
||||
<TableHead>大小</TableHead>
|
||||
<TableHead>上传时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{templates.map((t) => (
|
||||
<TableRow key={t.name}>
|
||||
<TableCell>
|
||||
{t.type === "excel" ? (
|
||||
<FileSpreadsheet className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{(t.size / 1024).toFixed(1)} KB
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(t.uploaded_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -12,16 +13,27 @@ import {
|
||||
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 } from "lucide-react";
|
||||
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() {
|
||||
@@ -34,10 +46,19 @@ export default function WorkspacePage() {
|
||||
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 () => {
|
||||
const loadCustomers = useCallback(async (search?: string) => {
|
||||
try {
|
||||
const list = await customersApi.list();
|
||||
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) {
|
||||
@@ -46,8 +67,50 @@ export default function WorkspacePage() {
|
||||
}, [customerId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCustomers();
|
||||
}, [loadCustomers]);
|
||||
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()) {
|
||||
@@ -72,6 +135,34 @@ export default function WorkspacePage() {
|
||||
}
|
||||
};
|
||||
|
||||
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 解析");
|
||||
@@ -96,7 +187,10 @@ export default function WorkspacePage() {
|
||||
}
|
||||
setGeneratingQuote(true);
|
||||
try {
|
||||
const res = await projectsApi.generateQuote(projectId);
|
||||
const res = await projectsApi.generateQuote(
|
||||
projectId,
|
||||
selectedQuoteTemplate || undefined
|
||||
);
|
||||
setLastQuote(res);
|
||||
toast.success("报价单已生成");
|
||||
downloadFile(res.excel_path, `quote_project_${projectId}.xlsx`);
|
||||
@@ -120,7 +214,10 @@ export default function WorkspacePage() {
|
||||
}
|
||||
setGeneratingQuote(true);
|
||||
try {
|
||||
const res = await projectsApi.generateQuote(projectId);
|
||||
const res = await projectsApi.generateQuote(
|
||||
projectId,
|
||||
selectedQuoteTemplate || undefined
|
||||
);
|
||||
setLastQuote(res);
|
||||
await downloadFileAsBlob(res.pdf_path, `quote_project_${projectId}.pdf`);
|
||||
toast.success("PDF 已下载");
|
||||
@@ -158,18 +255,97 @@ export default function WorkspacePage() {
|
||||
<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 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>
|
||||
@@ -220,7 +396,26 @@ export default function WorkspacePage() {
|
||||
</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)]">
|
||||
<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"
|
||||
@@ -260,6 +455,30 @@ export default function WorkspacePage() {
|
||||
)}
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user