fix:优化数据

This commit is contained in:
丹尼尔
2026-03-15 16:38:59 +08:00
parent a609f81a36
commit 3aa1a586e5
43 changed files with 14565 additions and 294 deletions

4
frontend/.npmrc Normal file
View File

@@ -0,0 +1,4 @@
# 国内镜像加快安装、避免卡死Docker 内已单独配置,此文件供本地/CI 使用)
registry=https://registry.npmmirror.com
fetch-retries=5
fetch-timeout=60000

View File

@@ -1,10 +1,19 @@
# 分层构建:依赖与代码分离,仅代码变更时只重建 COPY 及以后层
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
# 使用国内镜像源,加快安装并减少网络卡死
RUN npm config set registry https://registry.npmmirror.com \
&& npm config set fetch-retries 5 \
&& npm config set fetch-timeout 60000 \
&& npm config set fetch-retry-mintimeout 10000
# 依赖层:只有 package.json / package-lock 变更时才重建
COPY package.json ./
RUN npm install --prefer-offline --no-audit --progress=false
# 代码层:业务代码变更只重建此层及后续 build
COPY . .
ENV NODE_ENV=production

View 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>
);
}

View 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 (02)</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>
);
}

View 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>
);
}

View 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>
);
}

View 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 993SSLIMAP/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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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}

View File

@@ -2,27 +2,63 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState, useCallback } from "react";
import {
FileText,
FolderArchive,
Settings,
Building2,
Globe,
PiggyBank,
FileStack,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { HistoricalReferences } from "@/components/historical-references";
const QUICK_LINKS = [
{ label: "国家税务总局门户", href: "https://www.chinatax.gov.cn", icon: Building2 },
{ label: "电子税务局", href: "https://etax.chinatax.gov.cn", icon: Globe },
{ label: "公积金管理中心", href: "https://www.12329.com.cn", icon: PiggyBank },
];
import { cloudDocsApi, portalLinksApi, type CloudDocLinkRead, type PortalLinkRead } from "@/lib/api/client";
export function AppSidebar() {
const pathname = usePathname();
const [cloudDocs, setCloudDocs] = useState<CloudDocLinkRead[]>([]);
const [portalLinks, setPortalLinks] = useState<PortalLinkRead[]>([]);
const loadCloudDocs = useCallback(async () => {
try {
const list = await cloudDocsApi.list();
setCloudDocs(list);
} catch {
setCloudDocs([]);
}
}, []);
useEffect(() => {
loadCloudDocs();
}, [loadCloudDocs]);
useEffect(() => {
const onCloudDocsChanged = () => loadCloudDocs();
window.addEventListener("cloud-docs-changed", onCloudDocsChanged);
return () => window.removeEventListener("cloud-docs-changed", onCloudDocsChanged);
}, [loadCloudDocs]);
const loadPortalLinks = useCallback(async () => {
try {
const list = await portalLinksApi.list();
setPortalLinks(list);
} catch {
setPortalLinks([]);
}
}, []);
useEffect(() => {
loadPortalLinks();
}, [loadPortalLinks]);
useEffect(() => {
const onPortalLinksChanged = () => loadPortalLinks();
window.addEventListener("portal-links-changed", onPortalLinksChanged);
return () => window.removeEventListener("portal-links-changed", onPortalLinksChanged);
}, [loadPortalLinks]);
const nav = [
{ href: "/workspace", label: "需求与方案", icon: FileText },
@@ -54,25 +90,70 @@ export function AppSidebar() {
))}
</nav>
<Separator />
<div className="p-2">
<p className="text-xs font-medium text-muted-foreground px-2 mb-2">
</p>
<div className="flex flex-col gap-1">
{QUICK_LINKS.map((link) => (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
<div className="p-2 shrink-0">
<div className="flex items-center justify-between px-2 mb-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<Link
href="/settings/cloud-docs"
className="text-xs text-muted-foreground hover:text-foreground"
title="管理云文档入口"
>
<link.icon className="h-4 w-4 shrink-0" />
{link.label}
</a>
))}
<Settings2 className="h-3.5 w-3.5" />
</Link>
</div>
<div className="flex flex-col gap-1">
{cloudDocs.length === 0 ? (
<p className="text-xs text-muted-foreground px-2"></p>
) : (
cloudDocs.map((doc) => (
<a
key={doc.id}
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<FileStack className="h-4 w-4 shrink-0" />
<span className="truncate">{doc.name}</span>
</a>
))
)}
</div>
</div>
<div className="p-2 shrink-0">
<div className="flex items-center justify-between px-2 mb-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<Link
href="/settings/portal-links"
className="text-xs text-muted-foreground hover:text-foreground"
title="管理快捷门户"
>
<Settings2 className="h-3.5 w-3.5" />
</Link>
</div>
<div className="flex flex-col gap-1">
{portalLinks.length === 0 ? (
<p className="text-xs text-muted-foreground px-2"></p>
) : (
portalLinks.map((link) => (
<a
key={link.id}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Globe className="h-4 w-4 shrink-0" />
<span className="truncate">{link.name}</span>
</a>
))
)}
</div>
</div>
</div>
{pathname === "/workspace" && <HistoricalReferences />}

View File

@@ -1,22 +1,59 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { listProjects, type ProjectRead } from "@/lib/api/client";
import { Copy, Search, FileText } from "lucide-react";
import {
listProjects,
projectsApi,
type ProjectRead,
} from "@/lib/api/client";
import { Copy, Search, FileText, Eye, Pencil, Loader2 } from "lucide-react";
import { toast } from "sonner";
import ReactMarkdown from "react-markdown";
function parseTags(tagsStr: string | null | undefined): string[] {
if (!tagsStr?.trim()) return [];
return tagsStr
.split(",")
.map((t) => t.trim())
.filter(Boolean);
}
export function HistoricalReferences() {
const [projects, setProjects] = useState<ProjectRead[]>([]);
const [search, setSearch] = useState("");
const [selectedTag, setSelectedTag] = useState<string>("");
const [loading, setLoading] = useState(true);
const [previewProject, setPreviewProject] = useState<ProjectRead | null>(null);
const [editProject, setEditProject] = useState<ProjectRead | null>(null);
const [editRaw, setEditRaw] = useState("");
const [editMd, setEditMd] = useState("");
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await listProjects();
const data = await listProjects(
selectedTag ? { customer_tag: selectedTag } : undefined
);
setProjects(data);
} catch (e) {
toast.error("加载历史项目失败");
@@ -24,19 +61,32 @@ export function HistoricalReferences() {
} finally {
setLoading(false);
}
}, []);
}, [selectedTag]);
useEffect(() => {
load();
}, [load]);
const filtered = search.trim()
? projects.filter(
const allTags = useMemo(() => {
const set = new Set<string>();
projects.forEach((p) =>
parseTags(p.customer?.tags ?? null).forEach((t) => set.add(t))
);
return Array.from(set).sort();
}, [projects]);
const filtered = useMemo(() => {
let list = projects;
if (search.trim()) {
const q = search.toLowerCase();
list = list.filter(
(p) =>
p.raw_requirement.toLowerCase().includes(search.toLowerCase()) ||
(p.ai_solution_md || "").toLowerCase().includes(search.toLowerCase())
)
: projects.slice(0, 10);
p.raw_requirement.toLowerCase().includes(q) ||
(p.ai_solution_md || "").toLowerCase().includes(q)
);
}
return list.slice(0, 20);
}, [projects, search]);
const copySnippet = (text: string, label: string) => {
if (!text) return;
@@ -44,20 +94,67 @@ export function HistoricalReferences() {
toast.success(`已复制 ${label}`);
};
const openPreview = (p: ProjectRead) => setPreviewProject(p);
const openEdit = (p: ProjectRead) => {
setEditProject(p);
setEditRaw(p.raw_requirement);
setEditMd(p.ai_solution_md || "");
};
const handleSaveEdit = async () => {
if (!editProject) return;
setSaving(true);
try {
await projectsApi.update(editProject.id, {
raw_requirement: editRaw,
ai_solution_md: editMd || null,
});
toast.success("已保存");
setEditProject(null);
load();
} catch (e) {
toast.error("保存失败");
} finally {
setSaving(false);
}
};
return (
<div className="border-t p-2">
<p className="text-xs font-medium text-muted-foreground px-2 mb-2 flex items-center gap-1">
<FileText className="h-3.5 w-3.5" />
</p>
<div className="relative mb-2">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索项目..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-7 text-xs"
/>
<div className="space-y-2 mb-2">
<div className="flex gap-1 items-center">
<Select
value={selectedTag || "__all__"}
onValueChange={(v) => setSelectedTag(v === "__all__" ? "" : v)}
>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="按标签收纳" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__" className="text-xs">
</SelectItem>
{allTags.map((t) => (
<SelectItem key={t} value={t} className="text-xs">
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索项目..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
<div className="max-h-48 overflow-y-auto space-y-1">
{loading ? (
@@ -72,20 +169,43 @@ export function HistoricalReferences() {
"rounded border bg-background/50 p-2 text-xs space-y-1"
)}
>
<p className="font-medium text-foreground line-clamp-1">
#{p.id}
<p className="font-medium text-foreground line-clamp-1 flex items-center justify-between gap-1">
<span> #{p.id}</span>
{p.customer?.tags && (
<span className="text-muted-foreground font-normal truncate max-w-[60%]">
{parseTags(p.customer.tags).slice(0, 2).join(", ")}
</span>
)}
</p>
<p className="text-muted-foreground line-clamp-2">
{p.raw_requirement.slice(0, 80)}
</p>
<div className="flex gap-1 pt-1">
<div className="flex flex-wrap gap-1 pt-1">
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() =>
copySnippet(p.raw_requirement, "原始需求")
}
onClick={() => openPreview(p)}
title="预览"
>
<Eye className="h-3 w-3 mr-0.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() => openEdit(p)}
title="编辑"
>
<Pencil className="h-3 w-3 mr-0.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() => copySnippet(p.raw_requirement, "原始需求")}
>
<Copy className="h-3 w-3 mr-0.5" />
@@ -94,9 +214,7 @@ export function HistoricalReferences() {
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() =>
copySnippet(p.ai_solution_md || "", "方案")
}
onClick={() => copySnippet(p.ai_solution_md || "", "方案")}
>
<Copy className="h-3 w-3 mr-0.5" />
@@ -106,6 +224,80 @@ export function HistoricalReferences() {
))
)}
</div>
{/* 预览弹窗 */}
<Dialog open={!!previewProject} onOpenChange={() => setPreviewProject(null)}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
#{previewProject?.id}
{previewProject?.customer?.name && (
<span className="text-sm font-normal text-muted-foreground ml-2">
{previewProject.customer.name}
</span>
)}
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="requirement" className="flex-1 min-h-0 flex flex-col overflow-hidden">
<TabsList>
<TabsTrigger value="requirement"></TabsTrigger>
<TabsTrigger value="solution"></TabsTrigger>
</TabsList>
<TabsContent value="requirement" className="mt-2 overflow-auto flex-1 min-h-0">
<pre className="text-xs whitespace-pre-wrap bg-muted/50 p-3 rounded-md">
{previewProject?.raw_requirement ?? ""}
</pre>
</TabsContent>
<TabsContent value="solution" className="mt-2 overflow-auto flex-1 min-h-0">
<div className="prose prose-sm dark:prose-invert max-w-none p-2">
{previewProject?.ai_solution_md ? (
<ReactMarkdown>{previewProject.ai_solution_md}</ReactMarkdown>
) : (
<p className="text-muted-foreground"></p>
)}
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
{/* 二次编辑弹窗 */}
<Dialog open={!!editProject} onOpenChange={() => !saving && setEditProject(null)}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle> #{editProject?.id}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2 flex-1 min-h-0 overflow-auto">
<div className="grid gap-2">
<Label></Label>
<textarea
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none"
value={editRaw}
onChange={(e) => setEditRaw(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label> (Markdown)</Label>
<textarea
className="min-h-[180px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none"
value={editMd}
onChange={(e) => setEditMd(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditProject(null)} disabled={saving}>
</Button>
<Button onClick={handleSaveEdit} disabled={saving}>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}
>(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,80 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };

View File

@@ -18,17 +18,20 @@ export interface CustomerRead {
id: number;
name: string;
contact_info: string | null;
tags: string | null;
created_at: string;
}
export interface CustomerCreate {
name: string;
contact_info?: string | null;
tags?: string | null;
}
export interface CustomerUpdate {
name?: string;
contact_info?: string | null;
tags?: string | null;
}
export interface RequirementAnalyzeRequest {
@@ -69,7 +72,68 @@ export interface FinanceSyncResult {
}
export interface FinanceSyncResponse {
items: FinanceSyncResult[];
status: string;
new_files: number;
details: FinanceSyncResult[];
}
export interface FinanceRecordRead {
id: number;
month: string;
type: string;
file_name: string;
file_path: string;
amount: number | null;
billing_date: string | null;
created_at: string;
}
export interface TemplateInfo {
name: string;
type: "excel" | "word";
size: number;
uploaded_at: number;
}
export interface AIConfig {
id?: string;
name?: string;
provider: string;
api_key: string;
base_url: string;
model_name: string;
temperature: number;
system_prompt_override: string;
}
export interface AIConfigListItem {
id: string;
name: string;
provider: string;
model_name: string;
base_url: string;
api_key_configured: boolean;
is_active: boolean;
}
export interface AIConfigCreate {
name?: string;
provider?: string;
api_key?: string;
base_url?: string;
model_name?: string;
temperature?: number;
system_prompt_override?: string;
}
export interface AIConfigUpdate {
name?: string;
provider?: string;
api_key?: string;
base_url?: string;
model_name?: string;
temperature?: number;
system_prompt_override?: string;
}
export interface ProjectRead {
@@ -84,6 +148,7 @@ export interface ProjectRead {
}
export interface ProjectUpdate {
raw_requirement?: string | null;
ai_solution_md?: string | null;
status?: string;
}
@@ -112,10 +177,15 @@ async function request<T>(
if (!res.ok) {
const text = await res.text();
let detail = text;
let detail: string = text;
try {
const j = JSON.parse(text);
detail = j.detail ?? text;
const raw = j.detail ?? text;
if (Array.isArray(raw) && raw.length > 0) {
detail = raw.map((x: { msg?: string }) => x.msg ?? JSON.stringify(x)).join("; ");
} else {
detail = typeof raw === "string" ? raw : JSON.stringify(raw);
}
} catch {
// keep text
}
@@ -176,7 +246,10 @@ export async function downloadFileAsBlob(
// --------------- Customers ---------------
export const customersApi = {
list: () => request<CustomerRead[]>("/customers/"),
list: (params?: { q?: string }) =>
request<CustomerRead[]>(
params?.q ? `/customers/?q=${encodeURIComponent(params.q)}` : "/customers/"
),
get: (id: number) => request<CustomerRead>(`/customers/${id}`),
create: (body: CustomerCreate) =>
request<CustomerRead>("/customers/", {
@@ -207,10 +280,11 @@ export const projectsApi = {
body: JSON.stringify(body),
}),
generateQuote: (projectId: number) =>
request<QuoteGenerateResponse>(`/projects/${projectId}/generate_quote`, {
method: "POST",
}),
generateQuote: (projectId: number, template?: string | null) =>
request<QuoteGenerateResponse>(
`/projects/${projectId}/generate_quote${template ? `?template=${encodeURIComponent(template)}` : ""}`,
{ method: "POST" }
),
generateContract: (projectId: number, body: ContractGenerateRequest) =>
request<ContractGenerateResponse>(
@@ -222,20 +296,285 @@ export const projectsApi = {
),
};
export async function listProjects(): Promise<ProjectRead[]> {
return request<ProjectRead[]>("/projects/");
export async function listProjects(params?: {
customer_tag?: string;
}): Promise<ProjectRead[]> {
const searchParams: Record<string, string> = {};
if (params?.customer_tag?.trim()) searchParams.customer_tag = params.customer_tag.trim();
return request<ProjectRead[]>("/projects/", {
params: searchParams,
});
}
export async function getProject(projectId: number): Promise<ProjectRead> {
return request<ProjectRead>(`/projects/${projectId}`);
}
// --------------- Settings / Templates ---------------
export const templatesApi = {
list: () => request<TemplateInfo[]>("/settings/templates"),
upload: async (file: File): Promise<{ name: string; path: string }> => {
const base = apiBase();
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${base}/settings/templates/upload`, {
method: "POST",
body: formData,
});
if (!res.ok) {
const text = await res.text();
let detail = text;
try {
const j = JSON.parse(text);
detail = j.detail ?? text;
} catch {
// keep text
}
throw new Error(detail);
}
return res.json();
},
};
// --------------- AI Settings ---------------
export const aiSettingsApi = {
get: () => request<AIConfig>("/settings/ai"),
list: () => request<AIConfigListItem[]>("/settings/ai/list"),
getById: (id: string) => request<AIConfig>(`/settings/ai/${id}`),
create: (body: AIConfigCreate) =>
request<AIConfig>("/settings/ai", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: string, body: AIConfigUpdate) =>
request<AIConfig>(`/settings/ai/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: (id: string) =>
request<void>(`/settings/ai/${id}`, { method: "DELETE" }),
activate: (id: string) =>
request<AIConfig>(`/settings/ai/${id}/activate`, { method: "POST" }),
test: (configId?: string) =>
request<{ status: string; message: string }>(
configId ? `/settings/ai/test?config_id=${encodeURIComponent(configId)}` : "/settings/ai/test",
{ method: "POST" }
),
};
// --------------- Email Configs (multi-account sync) ---------------
export interface EmailConfigRead {
id: string;
host: string;
port: number;
user: string;
mailbox: string;
active: boolean;
}
export interface EmailConfigCreate {
host: string;
port?: number;
user: string;
password: string;
mailbox?: string;
active?: boolean;
}
export interface EmailConfigUpdate {
host?: string;
port?: number;
user?: string;
password?: string;
mailbox?: string;
active?: boolean;
}
export interface EmailFolder {
raw: string;
decoded: string;
}
export const emailConfigsApi = {
list: () => request<EmailConfigRead[]>("/settings/email"),
create: (body: EmailConfigCreate) =>
request<EmailConfigRead>("/settings/email", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: string, body: EmailConfigUpdate) =>
request<EmailConfigRead>(`/settings/email/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: (id: string) =>
request<void>(`/settings/email/${id}`, { method: "DELETE" }),
/** List mailbox folders for an account (to pick custom label). Use decoded as mailbox value. */
listFolders: (configId: string) =>
request<{ folders: EmailFolder[] }>(`/settings/email/${configId}/folders`),
};
// --------------- Cloud Docs (快捷入口) ---------------
export interface CloudDocLinkRead {
id: string;
name: string;
url: string;
}
export interface CloudDocLinkCreate {
name: string;
url: string;
}
export interface CloudDocLinkUpdate {
name?: string;
url?: string;
}
export const cloudDocsApi = {
list: () => request<CloudDocLinkRead[]>("/settings/cloud-docs"),
create: (body: CloudDocLinkCreate) =>
request<CloudDocLinkRead>("/settings/cloud-docs", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: string, body: CloudDocLinkUpdate) =>
request<CloudDocLinkRead>(`/settings/cloud-docs/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: (id: string) =>
request<void>(`/settings/cloud-docs/${id}`, { method: "DELETE" }),
};
// --------------- Cloud Doc Config (API 凭证) ---------------
export interface CloudDocConfigRead {
feishu: { app_id: string; app_secret_configured: boolean };
yuque: { token_configured: boolean; default_repo: string };
tencent: { client_id: string; client_secret_configured: boolean };
}
export interface CloudDocConfigUpdate {
feishu?: { app_id?: string; app_secret?: string };
yuque?: { token?: string; default_repo?: string };
tencent?: { client_id?: string; client_secret?: string };
}
export const cloudDocConfigApi = {
get: () => request<CloudDocConfigRead>("/settings/cloud-doc-config"),
update: (body: CloudDocConfigUpdate) =>
request<CloudDocConfigRead>("/settings/cloud-doc-config", {
method: "PUT",
body: JSON.stringify(body),
}),
};
// --------------- Push to Cloud ---------------
export interface PushToCloudRequest {
platform: "feishu" | "yuque" | "tencent";
title?: string;
body_md?: string;
}
export interface PushToCloudResponse {
url: string;
cloud_doc_id: string;
}
export function pushProjectToCloud(
projectId: number,
body: PushToCloudRequest
): Promise<PushToCloudResponse> {
return request<PushToCloudResponse>(`/projects/${projectId}/push-to-cloud`, {
method: "POST",
body: JSON.stringify(body),
});
}
// --------------- Portal Links (快捷门户) ---------------
export interface PortalLinkRead {
id: string;
name: string;
url: string;
}
export interface PortalLinkCreate {
name: string;
url: string;
}
export interface PortalLinkUpdate {
name?: string;
url?: string;
}
export const portalLinksApi = {
list: () => request<PortalLinkRead[]>("/settings/portal-links"),
create: (body: PortalLinkCreate) =>
request<PortalLinkRead>("/settings/portal-links", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: string, body: PortalLinkUpdate) =>
request<PortalLinkRead>(`/settings/portal-links/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: (id: string) =>
request<void>(`/settings/portal-links/${id}`, { method: "DELETE" }),
};
// --------------- Finance ---------------
export const financeApi = {
sync: () =>
request<FinanceSyncResponse>("/finance/sync", { method: "POST" }),
/** Upload invoice (PDF/image); returns created record with AI-extracted amount/date. */
uploadInvoice: async (file: File): Promise<FinanceRecordRead> => {
const base = apiBase();
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${base}/finance/upload`, {
method: "POST",
body: formData,
});
if (!res.ok) {
const text = await res.text();
let detail = text;
try {
const j = JSON.parse(text);
detail = j.detail ?? text;
} catch {
// keep text
}
throw new Error(detail);
}
return res.json();
},
/** Update amount/billing_date of a record (e.g. after manual review). */
updateRecord: (id: number, body: { amount?: number | null; billing_date?: string | null }) =>
request<FinanceRecordRead>(`/finance/records/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
/** List distinct months with records (YYYY-MM). */
listMonths: () => request<string[]>("/finance/months"),
/** List records for a month (YYYY-MM). */
listRecords: (month: string) =>
request<FinanceRecordRead[]>(`/finance/records?month=${encodeURIComponent(month)}`),
/** Returns the URL to download the zip (or use downloadFile with the path). */
getDownloadUrl: (month: string) => `${apiBase()}/finance/download/${month}`,

View File

@@ -0,0 +1,22 @@
/**
* 快捷门户地址配置
* 通过环境变量 NEXT_PUBLIC_* 覆盖,未设置时使用默认值
*/
const DEFAULTS = {
/** 国家税务总局门户 */
TAX_GATEWAY_URL: "https://www.chinatax.gov.cn",
/** 电子税务局(如上海电子税务局) */
TAX_PORTAL_URL: "https://etax.shanghai.chinatax.gov.cn:8443/",
/** 公积金管理中心 */
HOUSING_FUND_PORTAL_URL: "https://www.shzfgjj.cn/static/unit/web/",
} as const;
export const portalConfig = {
taxGatewayUrl:
process.env.NEXT_PUBLIC_TAX_GATEWAY_URL ?? DEFAULTS.TAX_GATEWAY_URL,
taxPortalUrl:
process.env.NEXT_PUBLIC_TAX_PORTAL_URL ?? DEFAULTS.TAX_PORTAL_URL,
housingFundPortalUrl:
process.env.NEXT_PUBLIC_HOUSING_FUND_PORTAL_URL ??
DEFAULTS.HOUSING_FUND_PORTAL_URL,
} as const;

7626
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,28 +9,29 @@
"lint": "next lint"
},
"dependencies": {
"next": "14.2.18",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.454.0",
"tailwind-merge": "^2.5.4",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.3",
"next": "14.2.18",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"sonner": "^1.5.0"
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
@@ -39,8 +40,7 @@
"eslint-config-next": "14.2.18",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"tailwindcss-animate": "^1.0.7",
"@tailwindcss/typography": "^0.5.15"
"typescript": "^5.6.3"
}
}