fix:优化项目内容
This commit is contained in:
17
frontend_spa/Dockerfile
Normal file
17
frontend_spa/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --progress=false
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
13
frontend_spa/Dockerfile.dev
Normal file
13
frontend_spa/Dockerfile.dev
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM alpine:3.21
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 仅用于开发:容器内跑 Vite dev server
|
||||
# 依赖用 volume 缓存(frontend_spa_node_modules),首次启动会 npm install
|
||||
|
||||
RUN apk add --no-cache nodejs npm
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0 --port 3000"]
|
||||
|
||||
13
frontend_spa/index.html
Normal file
13
frontend_spa/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ops-Core</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
47
frontend_spa/nginx.conf
Normal file
47
frontend_spa/nginx.conf
Normal file
@@ -0,0 +1,47 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://backend:8000/;
|
||||
}
|
||||
|
||||
location /data/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://backend:8000/data/;
|
||||
}
|
||||
|
||||
location = /api/projects/analyze_stream {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_send_timeout 1h;
|
||||
chunked_transfer_encoding off;
|
||||
add_header X-Accel-Buffering no;
|
||||
|
||||
proxy_pass http://backend:8000/projects/analyze_stream;
|
||||
}
|
||||
}
|
||||
|
||||
5482
frontend_spa/package-lock.json
generated
Normal file
5482
frontend_spa/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
frontend_spa/package.json
Normal file
47
frontend_spa/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "ops-core-frontend-spa",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@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",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
|
||||
10
frontend_spa/postcss.config.mjs
Normal file
10
frontend_spa/postcss.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
217
frontend_spa/src/components/app-sidebar.tsx
Normal file
217
frontend_spa/src/components/app-sidebar.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useEffect, useState, useCallback, memo, useRef } from "react";
|
||||
import {
|
||||
FileText,
|
||||
FolderArchive,
|
||||
Settings,
|
||||
Globe,
|
||||
FileStack,
|
||||
Settings2,
|
||||
ChevronDown,
|
||||
} 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";
|
||||
import {
|
||||
cloudDocsApi,
|
||||
portalLinksApi,
|
||||
type CloudDocLinkRead,
|
||||
type PortalLinkRead,
|
||||
} from "@/lib/api/client";
|
||||
|
||||
const MemoHistoricalReferences = memo(HistoricalReferences);
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
function readCache<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
if (typeof parsed.t !== "number") return null;
|
||||
if (Date.now() - parsed.t > CACHE_TTL_MS) return null;
|
||||
return parsed.v as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function writeCache<T>(key: string, value: T) {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify({ t: Date.now(), v: value }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const { pathname } = useLocation();
|
||||
const [cloudDocs, setCloudDocs] = useState<CloudDocLinkRead[]>([]);
|
||||
const [portalLinks, setPortalLinks] = useState<PortalLinkRead[]>([]);
|
||||
const [projectArchiveOpen, setProjectArchiveOpen] = useState(false);
|
||||
const didInitRef = useRef(false);
|
||||
|
||||
const loadCloudDocs = useCallback(async () => {
|
||||
try {
|
||||
const list = await cloudDocsApi.list();
|
||||
setCloudDocs(list);
|
||||
writeCache("opc_cloud_docs", list);
|
||||
} catch {
|
||||
setCloudDocs([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadPortalLinks = useCallback(async () => {
|
||||
try {
|
||||
const list = await portalLinksApi.list();
|
||||
setPortalLinks(list);
|
||||
writeCache("opc_portal_links", list);
|
||||
} catch {
|
||||
setPortalLinks([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (didInitRef.current) return;
|
||||
didInitRef.current = true;
|
||||
|
||||
const cachedDocs = readCache<CloudDocLinkRead[]>("opc_cloud_docs");
|
||||
if (cachedDocs) setCloudDocs(cachedDocs);
|
||||
const cachedPortals = readCache<PortalLinkRead[]>("opc_portal_links");
|
||||
if (cachedPortals) setPortalLinks(cachedPortals);
|
||||
|
||||
void loadCloudDocs();
|
||||
void loadPortalLinks();
|
||||
}, [loadCloudDocs, loadPortalLinks]);
|
||||
|
||||
useEffect(() => {
|
||||
const onCloudDocsChanged = () => loadCloudDocs();
|
||||
window.addEventListener("cloud-docs-changed", onCloudDocsChanged);
|
||||
return () => window.removeEventListener("cloud-docs-changed", onCloudDocsChanged);
|
||||
}, [loadCloudDocs]);
|
||||
|
||||
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 },
|
||||
{ href: "/finance", label: "财务归档", icon: FolderArchive },
|
||||
{ href: "/settings", label: "设置", icon: Settings },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="flex w-56 flex-col border-r bg-card text-card-foreground">
|
||||
<div className="p-4">
|
||||
<Link to="/" className="font-semibold text-foreground">
|
||||
Ops-Core
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">自动化办公中台</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<nav className="flex flex-1 flex-col gap-1 p-2">
|
||||
{nav.map((item) => (
|
||||
<Link key={item.href} to={item.href}>
|
||||
<Button
|
||||
variant={pathname === item.href ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
{item.label}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<Separator />
|
||||
<div className="px-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProjectArchiveOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span>项目档案</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3 w-3 transition-transform",
|
||||
projectArchiveOpen ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{projectArchiveOpen && <MemoHistoricalReferences />}
|
||||
<Separator />
|
||||
<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
|
||||
to="/settings/cloud-docs"
|
||||
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">
|
||||
{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
|
||||
to="/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>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
384
frontend_spa/src/components/historical-references.tsx
Normal file
384
frontend_spa/src/components/historical-references.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
listProjects,
|
||||
projectsApi,
|
||||
customersApi,
|
||||
type ProjectRead,
|
||||
type CustomerRead,
|
||||
} from "@/lib/api/client";
|
||||
import { Copy, Search, Eye, Pencil, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function parseTags(tagsStr: string | null | undefined): string[] {
|
||||
if (!tagsStr?.trim()) return [];
|
||||
return tagsStr
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function HistoricalReferences() {
|
||||
const [projects, setProjects] = useState<ProjectRead[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedTag, setSelectedTag] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [previewProject, setPreviewProject] = useState<ProjectRead | null>(null);
|
||||
const [editProject, setEditProject] = useState<ProjectRead | null>(null);
|
||||
const [editRaw, setEditRaw] = useState("");
|
||||
const [editMd, setEditMd] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editCustomer, setEditCustomer] = useState<CustomerRead | null>(null);
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
const [customerContact, setCustomerContact] = useState("");
|
||||
const [customerTags, setCustomerTags] = useState("");
|
||||
const [savingCustomer, setSavingCustomer] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listProjects({
|
||||
customer_tag: selectedTag || undefined,
|
||||
limit: 30,
|
||||
});
|
||||
setProjects(data);
|
||||
} catch {
|
||||
toast.error("加载历史项目失败");
|
||||
setProjects([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedTag]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
projects.forEach((p) => parseTags(p.customer?.tags ?? null).forEach((t) => set.add(t)));
|
||||
return Array.from(set).sort();
|
||||
}, [projects]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = projects;
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
list = list.filter(
|
||||
(p) =>
|
||||
p.raw_requirement.toLowerCase().includes(q) ||
|
||||
(p.ai_solution_md || "").toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
return list.slice(0, 50);
|
||||
}, [projects, search]);
|
||||
|
||||
const groupedByCustomer = useMemo(() => {
|
||||
const map = new Map<string, ProjectRead[]>();
|
||||
filtered.forEach((p) => {
|
||||
const name = p.customer?.name || "未关联客户";
|
||||
if (!map.has(name)) map.set(name, []);
|
||||
map.get(name)!.push(p);
|
||||
});
|
||||
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b, "zh-CN"));
|
||||
}, [filtered]);
|
||||
|
||||
const copySnippet = (text: string, label: string) => {
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`已复制 ${label}`);
|
||||
};
|
||||
|
||||
const openPreview = (p: ProjectRead) => setPreviewProject(p);
|
||||
const openEdit = (p: ProjectRead) => {
|
||||
setEditProject(p);
|
||||
setEditRaw(p.raw_requirement);
|
||||
setEditMd(p.ai_solution_md || "");
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editProject) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await projectsApi.update(editProject.id, {
|
||||
raw_requirement: editRaw,
|
||||
ai_solution_md: editMd || null,
|
||||
});
|
||||
toast.success("已保存");
|
||||
setEditProject(null);
|
||||
await load();
|
||||
} catch {
|
||||
toast.error("保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditCustomer = (p: ProjectRead | null) => {
|
||||
if (!p?.customer) return;
|
||||
setEditCustomer(p.customer);
|
||||
setCustomerName(p.customer.name);
|
||||
setCustomerContact(p.customer.contact_info || "");
|
||||
setCustomerTags(p.customer.tags || "");
|
||||
};
|
||||
|
||||
const handleSaveCustomer = async () => {
|
||||
if (!editCustomer) return;
|
||||
setSavingCustomer(true);
|
||||
try {
|
||||
const updated = await customersApi.update(editCustomer.id, {
|
||||
name: customerName.trim() || editCustomer.name,
|
||||
contact_info: customerContact.trim() || null,
|
||||
tags: customerTags.trim() || null,
|
||||
});
|
||||
toast.success("客户信息已保存");
|
||||
setProjects((prev) =>
|
||||
prev.map((p) => (p.customer_id === updated.id ? { ...p, customer: updated } : p)),
|
||||
);
|
||||
setEditCustomer(null);
|
||||
} catch {
|
||||
toast.error("保存客户失败");
|
||||
} finally {
|
||||
setSavingCustomer(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div className="space-y-2 mb-2">
|
||||
<div className="flex gap-1 items-center">
|
||||
<Select value={selectedTag || "__all__"} onValueChange={(v) => setSelectedTag(v === "__all__" ? "" : v)}>
|
||||
<SelectTrigger className="h-8 text-xs flex-1">
|
||||
<SelectValue placeholder="按标签收纳" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__" className="text-xs">
|
||||
全部
|
||||
</SelectItem>
|
||||
{allTags.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-xs">
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索项目..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto space-y-2 pr-1">
|
||||
{loading ? (
|
||||
<p className="text-xs text-muted-foreground px-2">加载中...</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground px-2">暂无项目</p>
|
||||
) : (
|
||||
groupedByCustomer.map(([customerName2, items]) => (
|
||||
<div key={customerName2} className="space-y-1">
|
||||
<p className="px-2 text-[11px] font-medium text-muted-foreground">{customerName2}</p>
|
||||
{items.map((p) => (
|
||||
<div key={p.id} className={cn("rounded border bg-background/50 p-2 text-xs space-y-1 ml-1")}>
|
||||
<p className="font-medium text-foreground line-clamp-1 flex items-center justify-between gap-1">
|
||||
<span>项目 #{p.id}</span>
|
||||
{p.customer?.tags && (
|
||||
<span className="text-muted-foreground font-normal truncate max-w-[60%]">
|
||||
{parseTags(p.customer.tags).slice(0, 2).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground line-clamp-2">{p.raw_requirement.slice(0, 80)}…</p>
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => openPreview(p)}
|
||||
title="预览"
|
||||
>
|
||||
<Eye className="h-3 w-3 mr-0.5" />
|
||||
预览
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => openEdit(p)}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="h-3 w-3 mr-0.5" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => copySnippet(p.raw_requirement, "原始需求")}
|
||||
>
|
||||
<Copy className="h-3 w-3 mr-0.5" />
|
||||
需求
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => copySnippet(p.ai_solution_md || "", "方案")}
|
||||
>
|
||||
<Copy className="h-3 w-3 mr-0.5" />
|
||||
方案
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!previewProject} onOpenChange={() => setPreviewProject(null)}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
项目 #{previewProject?.id}
|
||||
{previewProject?.customer?.name && (
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2 flex items-center gap-2">
|
||||
{previewProject.customer.name}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[11px]"
|
||||
onClick={() => openEditCustomer(previewProject)}
|
||||
>
|
||||
编辑客户
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="requirement" className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<TabsList>
|
||||
<TabsTrigger value="requirement">原始需求</TabsTrigger>
|
||||
<TabsTrigger value="solution">方案</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="requirement" className="mt-2 overflow-auto flex-1 min-h-0">
|
||||
<pre className="text-xs whitespace-pre-wrap bg-muted/50 p-3 rounded-md">
|
||||
{previewProject?.raw_requirement ?? ""}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
<TabsContent value="solution" className="mt-2 overflow-auto flex-1 min-h-0">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none p-2">
|
||||
{previewProject?.ai_solution_md ? (
|
||||
<ReactMarkdown>{previewProject.ai_solution_md}</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-muted-foreground">暂无方案</p>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!editProject} onOpenChange={() => !saving && setEditProject(null)}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑 项目 #{editProject?.id}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2 flex-1 min-h-0 overflow-auto">
|
||||
<div className="grid gap-2">
|
||||
<Label>原始需求</Label>
|
||||
<textarea
|
||||
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none"
|
||||
value={editRaw}
|
||||
onChange={(e) => setEditRaw(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>方案 (Markdown)</Label>
|
||||
<textarea
|
||||
className="min-h-[180px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none"
|
||||
value={editMd}
|
||||
onChange={(e) => setEditMd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditProject(null)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSaveEdit} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!editCustomer} onOpenChange={() => !savingCustomer && setEditCustomer(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑客户</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="hist-customer-name">客户名称</Label>
|
||||
<Input id="hist-customer-name" value={customerName} onChange={(e) => setCustomerName(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="hist-customer-contact">联系方式</Label>
|
||||
<Input
|
||||
id="hist-customer-contact"
|
||||
value={customerContact}
|
||||
onChange={(e) => setCustomerContact(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="hist-customer-tags">标签(逗号分隔)</Label>
|
||||
<Input
|
||||
id="hist-customer-tags"
|
||||
value={customerTags}
|
||||
onChange={(e) => setCustomerTags(e.target.value)}
|
||||
placeholder="如:重点客户, 已签约"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditCustomer(null)} disabled={savingCustomer}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSaveCustomer} disabled={savingCustomer}>
|
||||
{savingCustomer && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
56
frontend_spa/src/components/ui/button.tsx
Normal file
56
frontend_spa/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
||||
60
frontend_spa/src/components/ui/card.tsx
Normal file
60
frontend_spa/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
|
||||
46
frontend_spa/src/components/ui/checkbox.tsx
Normal file
46
frontend_spa/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CheckboxProps
|
||||
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> {
|
||||
indeterminate?: boolean;
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
CheckboxProps
|
||||
>(({ className, indeterminate, checked, ...props }, ref) => {
|
||||
const resolvedChecked =
|
||||
indeterminate && !checked
|
||||
? "indeterminate"
|
||||
: (checked as CheckboxPrimitive.CheckedState);
|
||||
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-input bg-background",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
"data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
checked={resolvedChecked}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-3 w-3" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
});
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
|
||||
117
frontend_spa/src/components/ui/dialog.tsx
Normal file
117
frontend_spa/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"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,
|
||||
};
|
||||
|
||||
24
frontend_spa/src/components/ui/input.tsx
Normal file
24
frontend_spa/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
|
||||
26
frontend_spa/src/components/ui/label.tsx
Normal file
26
frontend_spa/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
|
||||
24
frontend_spa/src/components/ui/progress.tsx
Normal file
24
frontend_spa/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number; // 0-100
|
||||
}
|
||||
|
||||
export function Progress({ value = 0, className, ...props }: ProgressProps) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
return (
|
||||
<div
|
||||
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-muted", className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="h-full w-full flex-1 bg-primary transition-transform duration-300 ease-out"
|
||||
style={{ transform: `translateX(-${100 - v}%)` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
149
frontend_spa/src/components/ui/select.tsx
Normal file
149
frontend_spa/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
};
|
||||
|
||||
25
frontend_spa/src/components/ui/separator.tsx
Normal file
25
frontend_spa/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
65
frontend_spa/src/components/ui/table.tsx
Normal file
65
frontend_spa/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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 };
|
||||
|
||||
55
frontend_spa/src/components/ui/tabs.tsx
Normal file
55
frontend_spa/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
567
frontend_spa/src/lib/api/client.ts
Normal file
567
frontend_spa/src/lib/api/client.ts
Normal file
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* API client for the FastAPI backend (Ops-Core).
|
||||
*
|
||||
* In SPA mode we default to same-origin `/api` (nginx reverse proxy).
|
||||
* For local dev you can set VITE_API_BASE, e.g. `http://localhost:8000`.
|
||||
*/
|
||||
|
||||
const getBase = (): string => {
|
||||
const base = (import.meta.env.VITE_API_BASE as string | undefined) ?? "/api";
|
||||
return base.replace(/\/$/, "");
|
||||
};
|
||||
|
||||
export const apiBase = getBase;
|
||||
|
||||
// --------------- Types (mirror FastAPI schemas) ---------------
|
||||
|
||||
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 {
|
||||
customer_id: number;
|
||||
raw_text: string;
|
||||
}
|
||||
|
||||
export interface RequirementAnalyzeResponse {
|
||||
project_id: number;
|
||||
ai_solution_md: string;
|
||||
ai_solution_json: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QuoteGenerateResponse {
|
||||
quote_id: number;
|
||||
project_id: number;
|
||||
total_amount: number;
|
||||
excel_path: string;
|
||||
pdf_path: string;
|
||||
}
|
||||
|
||||
export interface ContractGenerateRequest {
|
||||
delivery_date: string;
|
||||
extra_placeholders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ContractGenerateResponse {
|
||||
project_id: number;
|
||||
contract_path: string;
|
||||
}
|
||||
|
||||
export interface FinanceSyncResult {
|
||||
id: number;
|
||||
month: string;
|
||||
type: string;
|
||||
file_name: string;
|
||||
file_path: string;
|
||||
}
|
||||
|
||||
export interface FinanceSyncResponse {
|
||||
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;
|
||||
tags?: string | null;
|
||||
meta_json?: string | null;
|
||||
}
|
||||
|
||||
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 {
|
||||
id: number;
|
||||
customer_id: number;
|
||||
raw_requirement: string;
|
||||
ai_solution_md: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
customer?: CustomerRead;
|
||||
quotes?: { id: number; total_amount: string; file_path: string }[];
|
||||
}
|
||||
|
||||
export interface ProjectUpdate {
|
||||
raw_requirement?: string | null;
|
||||
ai_solution_md?: string | null;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// --------------- Request helper ---------------
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit & { params?: Record<string, string> } = {},
|
||||
): Promise<T> {
|
||||
const { params, ...init } = options;
|
||||
const base = apiBase();
|
||||
let url = `${base}${path}`;
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
const search = new URLSearchParams(params).toString();
|
||||
url += (path.includes("?") ? "&" : "?") + search;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let detail: string = text;
|
||||
try {
|
||||
const j = JSON.parse(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
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
/** Download a file from the backend and return a Blob (for Excel/PDF/Zip). */
|
||||
export async function downloadBlob(path: string): Promise<Blob> {
|
||||
const base = apiBase();
|
||||
const url =
|
||||
path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||
const res = await fetch(url, { credentials: "include" });
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
export function downloadFile(path: string, filename: string): void {
|
||||
const base = apiBase();
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`;
|
||||
const url = `${base}${normalized}`;
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.rel = "noopener noreferrer";
|
||||
a.target = "_blank";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
export async function downloadFileAsBlob(
|
||||
path: string,
|
||||
filename: string,
|
||||
_mime?: string,
|
||||
): Promise<void> {
|
||||
const blob = await downloadBlob(path);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// --------------- Customers ---------------
|
||||
|
||||
export const customersApi = {
|
||||
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/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
update: (id: number, body: CustomerUpdate) =>
|
||||
request<CustomerRead>(`/customers/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
delete: (id: number) => request<void>(`/customers/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
// --------------- Projects ---------------
|
||||
|
||||
export const projectsApi = {
|
||||
analyze: (body: RequirementAnalyzeRequest) =>
|
||||
request<RequirementAnalyzeResponse>("/projects/analyze", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
update: (projectId: number, body: ProjectUpdate) =>
|
||||
request<ProjectRead>(`/projects/${projectId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
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>(`/projects/${projectId}/generate_contract`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
};
|
||||
|
||||
export async function listProjects(params?: {
|
||||
customer_tag?: string;
|
||||
limit?: number;
|
||||
}): Promise<ProjectRead[]> {
|
||||
const searchParams: Record<string, string> = {};
|
||||
if (params?.customer_tag?.trim()) searchParams.customer_tag = params.customer_tag.trim();
|
||||
if (params?.limit != null) searchParams.limit = String(params.limit);
|
||||
return request<ProjectRead[]>("/projects/", { params: searchParams });
|
||||
}
|
||||
|
||||
// --------------- 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,
|
||||
credentials: "include",
|
||||
});
|
||||
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 ---------------
|
||||
|
||||
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" }),
|
||||
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 ---------------
|
||||
|
||||
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: (body?: {
|
||||
mode?: "incremental" | "all" | "latest";
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
doc_types?: ("invoices" | "receipts" | "statements")[];
|
||||
}) =>
|
||||
request<FinanceSyncResponse>("/finance/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body ?? {}),
|
||||
}),
|
||||
|
||||
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,
|
||||
credentials: "include",
|
||||
});
|
||||
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();
|
||||
},
|
||||
|
||||
updateRecord: (id: number, body: { amount?: number | null; billing_date?: string | null }) =>
|
||||
request<FinanceRecordRead>(`/finance/records/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
listMonths: () => request<string[]>("/finance/months"),
|
||||
|
||||
listRecords: (month: string) =>
|
||||
request<FinanceRecordRead[]>(`/finance/records?month=${encodeURIComponent(month)}`),
|
||||
|
||||
downloadMonth: async (month: string): Promise<void> => {
|
||||
const path = `/finance/download/${month}`;
|
||||
await downloadFileAsBlob(path, `finance_${month}.zip`, "application/zip");
|
||||
},
|
||||
|
||||
deleteRecord: (id: number) =>
|
||||
request<{ status: string; id: number }>(`/finance/records/${id}`, { method: "DELETE" }),
|
||||
|
||||
batchDeleteRecords: (ids: number[]) =>
|
||||
request<{ status: string; deleted: number }>(`/finance/records/batch-delete`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
|
||||
downloadRangeInvoices: async (start_date: string, end_date: string): Promise<void> => {
|
||||
const path = `/finance/download-range?start_date=${encodeURIComponent(
|
||||
start_date,
|
||||
)}&end_date=${encodeURIComponent(end_date)}&only_invoices=true`;
|
||||
await downloadFileAsBlob(path, `invoices_${start_date}_${end_date}.zip`, "application/zip");
|
||||
},
|
||||
};
|
||||
|
||||
7
frontend_spa/src/lib/utils.ts
Normal file
7
frontend_spa/src/lib/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
14
frontend_spa/src/main.tsx
Normal file
14
frontend_spa/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./routes/App";
|
||||
import "./styles/globals.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
618
frontend_spa/src/pages/finance.tsx
Normal file
618
frontend_spa/src/pages/finance.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
"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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { 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";
|
||||
|
||||
function fileHref(filePath: string): string {
|
||||
if (!filePath) return "#";
|
||||
if (filePath.startsWith("http")) return filePath;
|
||||
if (filePath.startsWith("/")) return filePath;
|
||||
return `/${filePath}`;
|
||||
}
|
||||
|
||||
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 [syncMode, setSyncMode] = useState<"incremental" | "all" | "latest">("incremental");
|
||||
const [syncStart, setSyncStart] = useState<string>("");
|
||||
const [syncEnd, setSyncEnd] = useState<string>("");
|
||||
const [syncTypes, setSyncTypes] = useState<{ invoices: boolean; receipts: boolean; statements: boolean }>({
|
||||
invoices: true,
|
||||
receipts: true,
|
||||
statements: true,
|
||||
});
|
||||
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 [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
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(() => {
|
||||
void loadMonths();
|
||||
}, [loadMonths]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecords();
|
||||
setSelectedIds([]);
|
||||
}, [loadRecords]);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
toast.loading("正在同步邮箱…", { id: "finance-sync" });
|
||||
try {
|
||||
const res: FinanceSyncResponse = await financeApi.sync({
|
||||
mode: syncMode,
|
||||
start_date: syncStart || undefined,
|
||||
end_date: syncEnd || undefined,
|
||||
doc_types: (Object.entries(syncTypes).filter(([, v]) => v).map(([k]) => k) as any) || undefined,
|
||||
});
|
||||
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 handleDeleteSelected = async () => {
|
||||
if (selectedIds.length === 0) {
|
||||
toast.error("请先勾选要删除的文件");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`确定删除选中的 ${selectedIds.length} 个文件?该操作不可恢复。`)) return;
|
||||
try {
|
||||
if (selectedIds.length === 1) {
|
||||
await financeApi.deleteRecord(selectedIds[0]);
|
||||
} else {
|
||||
await financeApi.batchDeleteRecords(selectedIds);
|
||||
}
|
||||
toast.success("已删除选中文件");
|
||||
setSelectedIds([]);
|
||||
if (selectedMonth) await loadRecords();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadRangeInvoices = async () => {
|
||||
if (!syncStart || !syncEnd) {
|
||||
toast.error("请先选择开始和结束日期");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await financeApi.downloadRangeInvoices(syncStart, syncEnd);
|
||||
toast.success(`已下载 ${syncStart} 至 ${syncEnd} 的发票 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>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-xs text-muted-foreground">同步模式</Label>
|
||||
<Select value={syncMode} onValueChange={(v) => setSyncMode(v as any)}>
|
||||
<SelectTrigger className="w-[140px] h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="incremental">最新附件(增量)</SelectItem>
|
||||
<SelectItem value="all">全部附件</SelectItem>
|
||||
<SelectItem value="latest">仅最新一封</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-xs text-muted-foreground">开始日期</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={syncStart}
|
||||
onChange={(e) => setSyncStart(e.target.value)}
|
||||
className="h-9 w-[150px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-xs text-muted-foreground">结束日期</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={syncEnd}
|
||||
onChange={(e) => setSyncEnd(e.target.value)}
|
||||
className="h-9 w-[150px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-xs text-muted-foreground">同步内容</Label>
|
||||
<div className="flex items-center gap-3 h-9 px-2 rounded-md border bg-background">
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncTypes.invoices}
|
||||
onChange={(e) => setSyncTypes((s) => ({ ...s, invoices: e.target.checked }))}
|
||||
/>
|
||||
发票
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncTypes.receipts}
|
||||
onChange={(e) => setSyncTypes((s) => ({ ...s, receipts: e.target.checked }))}
|
||||
/>
|
||||
回执
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncTypes.statements}
|
||||
onChange={(e) => setSyncTypes((s) => ({ ...s, statements: e.target.checked }))}
|
||||
/>
|
||||
流水
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleDownloadRangeInvoices} disabled={!syncStart || !syncEnd}>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="ml-1.5">下载发票 Zip</span>
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<Button variant="outline" size="sm" onClick={handleDeleteSelected} disabled={selectedIds.length === 0}>
|
||||
删除选中
|
||||
</Button>
|
||||
</div>
|
||||
</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 className="w-[36px]">
|
||||
<Checkbox
|
||||
checked={records.length > 0 && selectedIds.length === records.length}
|
||||
indeterminate={selectedIds.length > 0 && selectedIds.length < records.length}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) setSelectedIds(records.map((r) => r.id));
|
||||
else setSelectedIds([]);
|
||||
}}
|
||||
aria-label="选择本月全部"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>文件名</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>开票/归档时间</TableHead>
|
||||
<TableHead className="w-[100px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(r.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedIds((prev) => (checked ? [...prev, r.id] : prev.filter((id) => id !== r.id)));
|
||||
}}
|
||||
aria-label="选择记录"
|
||||
/>
|
||||
</TableCell>
|
||||
<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={fileHref(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={3}>本月合计</TableCell>
|
||||
<TableCell>¥{monthlyTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}</TableCell>
|
||||
<TableCell colSpan={2} />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
357
frontend_spa/src/pages/settings/ai.tsx
Normal file
357
frontend_spa/src/pages/settings/ai.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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 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(() => {
|
||||
void 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();
|
||||
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 to="/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 inline-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={() => void handleActivate(item.id)}>
|
||||
选用
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => void openEdit(item.id)} title="编辑">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void 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={() => void handleDelete(item.id)}
|
||||
title="删除"
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? "编辑模型配置" : "添加模型配置"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ai-name">配置名称(便于区分)</Label>
|
||||
<Input
|
||||
id="ai-name"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="如:OpenAI 生产、DeepSeek 备用"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="provider">提供商</Label>
|
||||
<Select value={provider} onValueChange={setProvider}>
|
||||
<SelectTrigger id="provider">
|
||||
<SelectValue placeholder="选择提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROVIDERS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="api_key">API Key</Label>
|
||||
<Input
|
||||
id="api_key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={apiKeyConfigured ? "已配置,输入新值以修改" : "请输入 API Key"}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="base_url">Base URL(可选)</Label>
|
||||
<Input
|
||||
id="base_url"
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="model_name">模型名称</Label>
|
||||
<Input
|
||||
id="model_name"
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
placeholder="gpt-4o-mini / deepseek-chat 等"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="temperature">Temperature (0–2)</Label>
|
||||
<Input
|
||||
id="temperature"
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={(e) => setTemperature(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="system_prompt_override">系统提示词覆盖(可选)</Label>
|
||||
<textarea
|
||||
id="system_prompt_override"
|
||||
className="flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={systemPromptOverride}
|
||||
onChange={(e) => setSystemPromptOverride(e.target.value)}
|
||||
placeholder="留空则使用默认"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||
{editingId ? "保存" : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
173
frontend_spa/src/pages/settings/cloud-doc-config.tsx
Normal file
173
frontend_spa/src/pages/settings/cloud-doc-config.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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(() => {
|
||||
void 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 to="/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>
|
||||
);
|
||||
}
|
||||
|
||||
211
frontend_spa/src/pages/settings/cloud-docs.tsx
Normal file
211
frontend_spa/src/pages/settings/cloud-docs.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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(() => {
|
||||
void 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();
|
||||
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();
|
||||
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 to="/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={() => void 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>
|
||||
);
|
||||
}
|
||||
|
||||
344
frontend_spa/src/pages/settings/email.tsx
Normal file
344
frontend_spa/src/pages/settings/email.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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 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(() => {
|
||||
void loadConfigs();
|
||||
}, [loadConfigs]);
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
setFolders(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">
|
||||
<Link to="/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">
|
||||
<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={() => void handleDelete(c.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm">网易 163 邮箱配置说明</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground space-y-2 py-2">
|
||||
<p>
|
||||
<strong>IMAP 服务器:</strong> imap.163.com,端口 993(SSL)。需在网易邮箱网页端开启「IMAP/SMTP 服务」。
|
||||
</p>
|
||||
<p>
|
||||
<strong>密码:</strong> 使用「授权码」而非登录密码。在 设置 → POP3/SMTP/IMAP → 授权码管理 中生成。
|
||||
</p>
|
||||
<p>
|
||||
<strong>邮箱夹:</strong> 填 INBOX 或 收件箱;若同步失败,请编辑该账户并点击「获取邮箱列表」选择「收件箱」或目标标签。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? "编辑邮箱账户" : "添加邮箱账户"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>IMAP Host</Label>
|
||||
<Input
|
||||
value={form.host}
|
||||
onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))}
|
||||
placeholder="imap.163.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>端口</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={(e) => setForm((f) => ({ ...f, port: e.target.value }))}
|
||||
placeholder="993"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>邮箱 / 用户名</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.user}
|
||||
onChange={(e) => setForm((f) => ({ ...f, user: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>密码 / 授权码 {editingId && "(留空则不修改)"}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
placeholder={editingId ? "••••••••" : "请输入"}
|
||||
autoComplete="off"
|
||||
required={!editingId}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>邮箱夹 / 自定义标签 (Mailbox)</Label>
|
||||
{editingId && (
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void 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、收件箱或自定义标签"
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
53
frontend_spa/src/pages/settings/index.tsx
Normal file
53
frontend_spa/src/pages/settings/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileSpreadsheet, FileText, Mail, FileStack, Globe } from "lucide-react";
|
||||
|
||||
export default function SettingsIndexPage() {
|
||||
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 to="/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 to="/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 to="/settings/email" className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
邮箱账户(多账户同步)
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link to="/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 to="/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 to="/settings/portal-links" className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
快捷门户
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
209
frontend_spa/src/pages/settings/portal-links.tsx
Normal file
209
frontend_spa/src/pages/settings/portal-links.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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(() => {
|
||||
void 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();
|
||||
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();
|
||||
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 to="/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={() => void 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>
|
||||
);
|
||||
}
|
||||
|
||||
166
frontend_spa/src/pages/settings/templates.tsx
Normal file
166
frontend_spa/src/pages/settings/templates.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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(() => {
|
||||
void 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) void 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) void 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">
|
||||
<Link to="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← 设置
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
522
frontend_spa/src/pages/workspace.tsx
Normal file
522
frontend_spa/src/pages/workspace.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
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 { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Wand2,
|
||||
Save,
|
||||
FileSpreadsheet,
|
||||
FileDown,
|
||||
Loader2,
|
||||
Plus,
|
||||
Search,
|
||||
CloudUpload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
apiBase,
|
||||
customersApi,
|
||||
projectsApi,
|
||||
templatesApi,
|
||||
pushProjectToCloud,
|
||||
downloadFile,
|
||||
downloadFileAsBlob,
|
||||
type CustomerRead,
|
||||
type QuoteGenerateResponse,
|
||||
type TemplateInfo,
|
||||
} from "@/lib/api/client";
|
||||
|
||||
export default function WorkspacePage() {
|
||||
const [customers, setCustomers] = useState<CustomerRead[]>([]);
|
||||
const [customerId, setCustomerId] = useState<string>("");
|
||||
const [rawText, setRawText] = useState("");
|
||||
const [solutionMd, setSolutionMd] = useState("");
|
||||
const [projectId, setProjectId] = useState<number | null>(null);
|
||||
const [lastQuote, setLastQuote] = useState<QuoteGenerateResponse | null>(null);
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [analyzeProgress, setAnalyzeProgress] = useState(0);
|
||||
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 didInitRef = useRef(false);
|
||||
|
||||
const loadCustomers = useCallback(
|
||||
async (search?: string) => {
|
||||
try {
|
||||
const list = await customersApi.list(search?.trim() ? { q: search.trim() } : undefined);
|
||||
setCustomers(list);
|
||||
if (list.length > 0 && !customerId) setCustomerId(String(list[0].id));
|
||||
if (!search?.trim()) {
|
||||
try {
|
||||
sessionStorage.setItem("opc_customers", JSON.stringify({ t: Date.now(), v: list }));
|
||||
} catch {}
|
||||
}
|
||||
} catch {
|
||||
toast.error("加载客户列表失败");
|
||||
}
|
||||
},
|
||||
[customerId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!customerSearch.trim()) {
|
||||
if (!didInitRef.current) {
|
||||
didInitRef.current = true;
|
||||
try {
|
||||
const raw = sessionStorage.getItem("opc_customers");
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.v && Date.now() - (parsed.t || 0) < 60_000) setCustomers(parsed.v);
|
||||
}
|
||||
} catch {}
|
||||
void loadCustomers("");
|
||||
return;
|
||||
}
|
||||
}
|
||||
void loadCustomers(customerSearch);
|
||||
}, [loadCustomers, customerSearch]);
|
||||
|
||||
const loadQuoteTemplates = useCallback(async () => {
|
||||
try {
|
||||
const list = await templatesApi.list();
|
||||
setQuoteTemplates(list.filter((t) => t.type === "excel"));
|
||||
try {
|
||||
sessionStorage.setItem("opc_templates", JSON.stringify({ t: Date.now(), v: list }));
|
||||
} catch {}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem("opc_templates");
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.v && Date.now() - (parsed.t || 0) < 60_000) {
|
||||
setQuoteTemplates((parsed.v as TemplateInfo[]).filter((t) => t.type === "excel"));
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
void loadQuoteTemplates();
|
||||
}, [loadQuoteTemplates]);
|
||||
|
||||
const handleAddCustomer = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = newCustomerName.trim();
|
||||
if (!name) {
|
||||
toast.error("请填写客户名称");
|
||||
return;
|
||||
}
|
||||
setAddingCustomer(true);
|
||||
try {
|
||||
const created = await customersApi.create({
|
||||
name,
|
||||
contact_info: newCustomerContact.trim() || null,
|
||||
tags: newCustomerTags.trim() || null,
|
||||
});
|
||||
toast.success("客户已添加");
|
||||
setAddCustomerOpen(false);
|
||||
setNewCustomerName("");
|
||||
setNewCustomerContact("");
|
||||
setNewCustomerTags("");
|
||||
await loadCustomers(customerSearch);
|
||||
setCustomerId(String(created.id));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "添加失败";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setAddingCustomer(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!customerId || !rawText.trim()) {
|
||||
toast.error("请选择客户并输入原始需求");
|
||||
return;
|
||||
}
|
||||
setAnalyzing(true);
|
||||
setAnalyzeProgress(8);
|
||||
setSolutionMd("");
|
||||
try {
|
||||
const base = apiBase();
|
||||
const res = await fetch(`${base}/projects/analyze_stream`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
customer_id: Number(customerId),
|
||||
raw_text: rawText.trim(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || "AI 解析失败");
|
||||
}
|
||||
|
||||
let buf = "";
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
const progressTimer = window.setInterval(() => {
|
||||
setAnalyzeProgress((p) => (p < 90 ? p + Math.max(1, Math.round((90 - p) / 10)) : p));
|
||||
}, 400);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() || "";
|
||||
for (const chunk of parts) {
|
||||
const line = chunk.split("\n").find((l) => l.startsWith("data: "));
|
||||
if (!line) continue;
|
||||
const payload = line.slice(6);
|
||||
let evt: any;
|
||||
try {
|
||||
evt = JSON.parse(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (evt.type === "delta" && typeof evt.content === "string") {
|
||||
setSolutionMd((prev) => prev + evt.content);
|
||||
} else if (evt.type === "done" && typeof evt.project_id === "number") {
|
||||
setProjectId(evt.project_id);
|
||||
setLastQuote(null);
|
||||
setAnalyzeProgress(100);
|
||||
} else if (evt.type === "error") {
|
||||
throw new Error(evt.message || "AI 解析失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
window.clearInterval(progressTimer);
|
||||
}
|
||||
toast.success("方案已生成(流式),可在右侧编辑");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "AI 解析失败";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePushToCloud = async (platform: "feishu" | "yuque" | "tencent") => {
|
||||
if (projectId == null) return;
|
||||
const md = solutionMd?.trim() || "";
|
||||
if (!md) {
|
||||
toast.error("请先在编辑器中填写方案内容后再推送");
|
||||
return;
|
||||
}
|
||||
setPushToCloudLoading(true);
|
||||
try {
|
||||
const res = await pushProjectToCloud(projectId, { platform, body_md: md });
|
||||
toast.success("已推送到云文档", {
|
||||
action: res.url
|
||||
? {
|
||||
label: "打开链接",
|
||||
onClick: () => window.open(res.url, "_blank"),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "推送失败");
|
||||
} finally {
|
||||
setPushToCloudLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveToArchive = async () => {
|
||||
if (projectId == null) {
|
||||
toast.error("请先进行 AI 解析");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await projectsApi.update(projectId, { ai_solution_md: solutionMd });
|
||||
toast.success("已保存到项目档案");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "保存失败";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDraftQuote = async () => {
|
||||
if (projectId == null) {
|
||||
toast.error("请先进行 AI 解析并保存");
|
||||
return;
|
||||
}
|
||||
setGeneratingQuote(true);
|
||||
try {
|
||||
const res = await projectsApi.generateQuote(projectId, selectedQuoteTemplate || undefined);
|
||||
setLastQuote(res);
|
||||
toast.success("报价单已生成");
|
||||
downloadFile(res.excel_path, `quote_project_${projectId}.xlsx`);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "生成报价失败";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setGeneratingQuote(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPdf = async () => {
|
||||
if (lastQuote?.pdf_path) {
|
||||
downloadFile(lastQuote.pdf_path, `quote_project_${projectId}.pdf`);
|
||||
toast.success("PDF 已下载");
|
||||
return;
|
||||
}
|
||||
if (projectId == null) {
|
||||
toast.error("请先进行 AI 解析");
|
||||
return;
|
||||
}
|
||||
setGeneratingQuote(true);
|
||||
try {
|
||||
const res = await projectsApi.generateQuote(projectId, selectedQuoteTemplate || undefined);
|
||||
setLastQuote(res);
|
||||
await downloadFileAsBlob(res.pdf_path, `quote_project_${projectId}.pdf`);
|
||||
toast.success("PDF 已下载");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "生成 PDF 失败";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setGeneratingQuote(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="flex w-[40%] flex-col border-r">
|
||||
<Card className="rounded-none border-0 border-b h-full flex flex-col">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
原始需求
|
||||
<Button size="sm" onClick={handleAnalyze} disabled={analyzing || !rawText.trim() || !customerId}>
|
||||
{analyzing ? (
|
||||
<span className="w-[160px]">
|
||||
<Progress value={analyzeProgress} />
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
<span className="ml-1.5">AI 解析</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col gap-2 min-h-0 pt-0">
|
||||
<div className="space-y-1.5">
|
||||
<Label>客户</Label>
|
||||
<div className="flex gap-1 items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索客户名称/联系方式"
|
||||
value={customerSearch}
|
||||
onChange={(e) => setCustomerSearch(e.target.value)}
|
||||
className="pl-8 h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Select value={customerId} onValueChange={setCustomerId}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="选择客户" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
<span className="flex items-center gap-2">
|
||||
{c.name}
|
||||
{c.tags && <span className="text-muted-foreground text-xs">({c.tags})</span>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Dialog open={addCustomerOpen} onOpenChange={setAddCustomerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" size="icon" variant="outline" title="新建客户">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建客户</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleAddCustomer}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="customer-name">客户名称</Label>
|
||||
<Input
|
||||
id="customer-name"
|
||||
value={newCustomerName}
|
||||
onChange={(e) => setNewCustomerName(e.target.value)}
|
||||
placeholder="必填"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="customer-contact">联系方式</Label>
|
||||
<Input
|
||||
id="customer-contact"
|
||||
value={newCustomerContact}
|
||||
onChange={(e) => setNewCustomerContact(e.target.value)}
|
||||
placeholder="选填"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="customer-tags">标签(逗号分隔,用于收纳筛选)</Label>
|
||||
<Input
|
||||
id="customer-tags"
|
||||
value={newCustomerTags}
|
||||
onChange={(e) => setNewCustomerTags(e.target.value)}
|
||||
placeholder="如:重点客户, 已签约"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setAddCustomerOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={addingCustomer}>
|
||||
{addingCustomer ? <Loader2 className="h-4 w-4 animate-spin" /> : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<Label>微信/客户需求文本</Label>
|
||||
<textarea
|
||||
className="mt-1.5 flex-1 min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none"
|
||||
placeholder="粘贴或输入客户原始需求..."
|
||||
value={rawText}
|
||||
onChange={(e) => setRawText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<Card className="rounded-none border-0 h-full flex flex-col">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-base">方案草稿(可编辑)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<Tabs defaultValue="edit" className="flex-1 flex flex-col min-h-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="edit">编辑</TabsTrigger>
|
||||
<TabsTrigger value="preview">预览</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="edit" className="flex-1 min-h-0 mt-2">
|
||||
<textarea
|
||||
className="h-full min-h-[320px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none"
|
||||
placeholder="AI 解析后将在此显示 Markdown,可直接修改..."
|
||||
value={solutionMd}
|
||||
onChange={(e) => setSolutionMd(e.target.value)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="preview" className="flex-1 min-h-0 mt-2 overflow-auto">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none p-2">
|
||||
{solutionMd ? <ReactMarkdown>{solutionMd}</ReactMarkdown> : <p className="text-muted-foreground">暂无内容</p>}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t bg-card px-4 py-3 shadow-[0_-2px 10px rgba(0,0,0,0.05)] flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">报价模板</span>
|
||||
<Select value={selectedQuoteTemplate || "__latest__"} onValueChange={(v) => setSelectedQuoteTemplate(v === "__latest__" ? "" : v)}>
|
||||
<SelectTrigger className="w-[180px] h-8">
|
||||
<SelectValue placeholder="使用最新上传" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__latest__">使用最新上传</SelectItem>
|
||||
{quoteTemplates.map((t) => (
|
||||
<SelectItem key={t.name} value={t.name}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleSaveToArchive} disabled={saving || projectId == null}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="ml-1.5">保存到档案</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDraftQuote} disabled={generatingQuote || projectId == null}>
|
||||
{generatingQuote ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileSpreadsheet className="h-4 w-4" />}
|
||||
<span className="ml-1.5">生成报价单 (Excel)</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleExportPdf} disabled={generatingQuote || projectId == null}>
|
||||
{generatingQuote ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileDown className="h-4 w-4" />}
|
||||
<span className="ml-1.5">导出 PDF</span>
|
||||
</Button>
|
||||
<Select
|
||||
value="__none__"
|
||||
onValueChange={(v) => {
|
||||
if (v === "feishu" || v === "yuque" || v === "tencent") void handlePushToCloud(v);
|
||||
}}
|
||||
disabled={pushToCloudLoading || projectId == null}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-8">
|
||||
{pushToCloudLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudUpload className="h-4 w-4 mr-1" />}
|
||||
<SelectValue placeholder="推送到云文档" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" disabled>
|
||||
推送到云文档
|
||||
</SelectItem>
|
||||
<SelectItem value="feishu">飞书文档</SelectItem>
|
||||
<SelectItem value="yuque">语雀</SelectItem>
|
||||
<SelectItem value="tencent">腾讯文档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{projectId != null && <span className="text-xs text-muted-foreground ml-2">当前项目 #{projectId}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
32
frontend_spa/src/routes/App.tsx
Normal file
32
frontend_spa/src/routes/App.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import MainLayout from "./MainLayout";
|
||||
import WorkspacePage from "@/pages/workspace";
|
||||
import FinancePage from "@/pages/finance";
|
||||
import SettingsIndexPage from "@/pages/settings";
|
||||
import SettingsTemplatesPage from "@/pages/settings/templates";
|
||||
import SettingsAIPage from "@/pages/settings/ai";
|
||||
import SettingsEmailPage from "@/pages/settings/email";
|
||||
import SettingsCloudDocsPage from "@/pages/settings/cloud-docs";
|
||||
import SettingsCloudDocConfigPage from "@/pages/settings/cloud-doc-config";
|
||||
import SettingsPortalLinksPage from "@/pages/settings/portal-links";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/workspace" replace />} />
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/workspace" element={<WorkspacePage />} />
|
||||
<Route path="/finance" element={<FinancePage />} />
|
||||
<Route path="/settings" element={<SettingsIndexPage />} />
|
||||
<Route path="/settings/templates" element={<SettingsTemplatesPage />} />
|
||||
<Route path="/settings/ai" element={<SettingsAIPage />} />
|
||||
<Route path="/settings/email" element={<SettingsEmailPage />} />
|
||||
<Route path="/settings/cloud-docs" element={<SettingsCloudDocsPage />} />
|
||||
<Route path="/settings/cloud-doc-config" element={<SettingsCloudDocConfigPage />} />
|
||||
<Route path="/settings/portal-links" element={<SettingsPortalLinksPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<div className="p-6">Not Found</div>} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
16
frontend_spa/src/routes/MainLayout.tsx
Normal file
16
frontend_spa/src/routes/MainLayout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
|
||||
export default function MainLayout() {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<AppSidebar />
|
||||
<main className="flex-1 overflow-auto bg-background">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Toaster position="top-right" richColors closeButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
60
frontend_spa/src/styles/globals.css
Normal file
60
frontend_spa/src/styles/globals.css
Normal file
@@ -0,0 +1,60 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
54
frontend_spa/tailwind.config.ts
Normal file
54
frontend_spa/tailwind.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
22
frontend_spa/tsconfig.json
Normal file
22
frontend_spa/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
35
frontend_spa/vite.config.ts
Normal file
35
frontend_spa/vite.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.VITE_DEV_PROXY_TARGET ?? "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
// dev 对齐生产 Nginx:/api/* -> backend /*
|
||||
rewrite: (p) => p.replace(/^\/api/, ""),
|
||||
},
|
||||
"/data": {
|
||||
target: process.env.VITE_DEV_PROXY_TARGET ?? "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user