625 lines
22 KiB
TypeScript
625 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useCallback } from "react";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardFooter,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
BookOpen,
|
||
Plus,
|
||
Trash2,
|
||
Search,
|
||
FileText,
|
||
Link,
|
||
Type,
|
||
AlertCircle,
|
||
Loader2,
|
||
} from "lucide-react";
|
||
import {
|
||
knowledgeApi,
|
||
type KnowledgeBase,
|
||
type KnowledgeDocument,
|
||
} from "@/lib/api";
|
||
import { useApi } from "@/lib/hooks/use-api";
|
||
import { LoadingState, ErrorState } from "@/components/ui/api-states";
|
||
|
||
// ── 状态 Badge ─────────────────────────────────────────────────────────────────
|
||
|
||
function StatusBadge({ status }: { status: string }) {
|
||
switch (status) {
|
||
case "processing":
|
||
return <Badge variant="warning">处理中</Badge>;
|
||
case "ready":
|
||
return <Badge variant="success">就绪</Badge>;
|
||
case "failed":
|
||
return <Badge variant="destructive">失败</Badge>;
|
||
case "active":
|
||
return <Badge variant="success">活跃</Badge>;
|
||
default:
|
||
return <Badge variant="secondary">{status}</Badge>;
|
||
}
|
||
}
|
||
|
||
function SourceTypeBadge({ type }: { type: string }) {
|
||
switch (type) {
|
||
case "text":
|
||
return (
|
||
<Badge variant="outline" className="gap-1">
|
||
<Type className="h-3 w-3" />
|
||
文本
|
||
</Badge>
|
||
);
|
||
case "url":
|
||
return (
|
||
<Badge variant="outline" className="gap-1">
|
||
<Link className="h-3 w-3" />
|
||
URL
|
||
</Badge>
|
||
);
|
||
case "markdown":
|
||
return (
|
||
<Badge variant="outline" className="gap-1">
|
||
<FileText className="h-3 w-3" />
|
||
Markdown
|
||
</Badge>
|
||
);
|
||
default:
|
||
return <Badge variant="outline">{type}</Badge>;
|
||
}
|
||
}
|
||
|
||
// ── 空状态组件 ─────────────────────────────────────────────────────────────────
|
||
|
||
function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
|
||
return (
|
||
<div className="text-center py-16">
|
||
<BookOpen className="h-12 w-12 mx-auto text-gray-300" />
|
||
<h3 className="mt-4 text-base font-semibold text-gray-900">还没有知识库</h3>
|
||
<p className="mt-2 text-sm text-gray-500 max-w-xs mx-auto">
|
||
创建您的第一个知识库,为AI内容生产提供精准的知识支撑
|
||
</p>
|
||
<Button variant="outline" className="mt-4" onClick={onCreateClick}>
|
||
<Plus className="h-4 w-4 mr-1.5" />
|
||
创建知识库
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 知识库卡片 + 展开文档列表 ────────────────────────────────────────────────────
|
||
|
||
function KnowledgeBaseCard({
|
||
kb,
|
||
isExpanded,
|
||
onToggle,
|
||
onDelete,
|
||
onUpload,
|
||
}: {
|
||
kb: KnowledgeBase;
|
||
isExpanded: boolean;
|
||
onToggle: () => void;
|
||
onDelete: (id: string) => void;
|
||
onUpload: (id: string) => void;
|
||
}) {
|
||
const docsUrl = isExpanded ? `/api/v1/knowledge/bases/${kb.id}/documents` : null;
|
||
const { data: documents = [], isLoading: docsLoading, error: docsApiError, refresh: refreshDocs } =
|
||
useApi<KnowledgeDocument[]>(docsUrl);
|
||
const docsError = docsApiError?.message ?? null;
|
||
|
||
const handleDeleteDoc = async (docId: string) => {
|
||
try {
|
||
await knowledgeApi.deleteDocument(undefined, kb.id, docId);
|
||
refreshDocs();
|
||
} catch (err) {
|
||
console.error("Delete doc error:", err);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<Card className="cursor-pointer rounded-xl border border-gray-200" onClick={onToggle}>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base text-gray-900">{kb.name}</CardTitle>
|
||
{kb.description && (
|
||
<CardDescription className="line-clamp-2 text-gray-500">{kb.description}</CardDescription>
|
||
)}
|
||
</CardHeader>
|
||
<CardFooter className="pt-0 flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="secondary" className="gap-1">
|
||
<FileText className="h-3 w-3" />
|
||
{kb.document_count} 文档
|
||
</Badge>
|
||
<StatusBadge status={kb.status} />
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onDelete(kb.id);
|
||
}}
|
||
>
|
||
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||
</Button>
|
||
</CardFooter>
|
||
</Card>
|
||
|
||
{isExpanded && (
|
||
<Card className="rounded-xl border border-gray-200">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h4 className="text-sm font-semibold">文档列表</h4>
|
||
<Button variant="outline" size="sm" onClick={() => onUpload(kb.id)}>
|
||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||
上传文档
|
||
</Button>
|
||
</div>
|
||
|
||
{docsLoading ? (
|
||
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground gap-2">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
加载中...
|
||
</div>
|
||
) : docsError ? (
|
||
<div className="flex items-center gap-2 py-4 text-xs text-red-600">
|
||
<AlertCircle className="h-3.5 w-3.5" />
|
||
{docsError}
|
||
</div>
|
||
) : documents.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground text-center py-6">
|
||
暂无文档,点击上方按钮上传
|
||
</p>
|
||
) : (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>标题</TableHead>
|
||
<TableHead>来源</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead className="text-right">分块数</TableHead>
|
||
<TableHead className="text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{documents.map((doc) => (
|
||
<TableRow key={doc.id}>
|
||
<TableCell className="font-medium">{doc.title}</TableCell>
|
||
<TableCell>
|
||
<SourceTypeBadge type={doc.source_type} />
|
||
</TableCell>
|
||
<TableCell>
|
||
<StatusBadge status={doc.status} />
|
||
</TableCell>
|
||
<TableCell className="text-right">{doc.chunk_count}</TableCell>
|
||
<TableCell className="text-right">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={() => handleDeleteDoc(doc.id)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 主页面 ─────────────────────────────────────────────────────────────────────
|
||
|
||
export default function KnowledgePage() {
|
||
const [activeTab, setActiveTab] = useState("enterprise");
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [expandedKbId, setExpandedKbId] = useState<string | null>(null);
|
||
|
||
// SWR data fetching
|
||
const {
|
||
data: enterpriseBases = [],
|
||
isLoading: enterpriseLoading,
|
||
error: enterpriseError,
|
||
mutate: mutateEnterprise,
|
||
} = useApi<KnowledgeBase[]>("/api/v1/knowledge/bases/?type=enterprise");
|
||
|
||
const {
|
||
data: industryBases = [],
|
||
isLoading: industryLoading,
|
||
error: industryError,
|
||
} = useApi<KnowledgeBase[]>("/api/v1/knowledge/bases/?type=industry");
|
||
|
||
const loading = enterpriseLoading || industryLoading;
|
||
const error = enterpriseError?.message || industryError?.message || null;
|
||
|
||
// Create KB dialog
|
||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||
const [newKbName, setNewKbName] = useState("");
|
||
const [newKbDescription, setNewKbDescription] = useState("");
|
||
const [createLoading, setCreateLoading] = useState(false);
|
||
const [createError, setCreateError] = useState<string | null>(null);
|
||
|
||
// Upload doc dialog
|
||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||
const [uploadKbId, setUploadKbId] = useState<string | null>(null);
|
||
const [docTitle, setDocTitle] = useState("");
|
||
const [docSourceType, setDocSourceType] = useState<"text" | "url" | "markdown">("text");
|
||
const [docContent, setDocContent] = useState("");
|
||
const [docUrl, setDocUrl] = useState("");
|
||
const [uploadLoading, setUploadLoading] = useState(false);
|
||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||
|
||
const fetchBases = useCallback(() => {
|
||
mutateEnterprise();
|
||
}, [mutateEnterprise]);
|
||
|
||
const currentBases = activeTab === "enterprise" ? enterpriseBases : industryBases;
|
||
const filteredBases = searchQuery
|
||
? currentBases.filter(
|
||
(kb) =>
|
||
kb.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
kb.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||
)
|
||
: currentBases;
|
||
|
||
const handleCreateKb = async () => {
|
||
if (!newKbName.trim()) return;
|
||
try {
|
||
setCreateLoading(true);
|
||
setCreateError(null);
|
||
await knowledgeApi.createBase(undefined, {
|
||
name: newKbName.trim(),
|
||
type: "enterprise",
|
||
description: newKbDescription.trim() || undefined,
|
||
});
|
||
mutateEnterprise();
|
||
setCreateDialogOpen(false);
|
||
setNewKbName("");
|
||
setNewKbDescription("");
|
||
} catch (err) {
|
||
setCreateError(err instanceof Error ? err.message : "创建失败");
|
||
} finally {
|
||
setCreateLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteKb = async (kbId: string) => {
|
||
try {
|
||
await knowledgeApi.deleteBase(undefined, kbId);
|
||
mutateEnterprise();
|
||
if (expandedKbId === kbId) setExpandedKbId(null);
|
||
} catch (err) {
|
||
console.error("Delete KB error:", err);
|
||
}
|
||
};
|
||
|
||
const handleUploadDoc = async () => {
|
||
if (!uploadKbId || !docTitle.trim()) return;
|
||
try {
|
||
setUploadLoading(true);
|
||
setUploadError(null);
|
||
await knowledgeApi.uploadDocument(undefined, uploadKbId, {
|
||
title: docTitle.trim(),
|
||
source_type: docSourceType,
|
||
content: docSourceType !== "url" ? docContent : undefined,
|
||
source_url: docSourceType === "url" ? docUrl : undefined,
|
||
});
|
||
mutateEnterprise();
|
||
setUploadDialogOpen(false);
|
||
setDocTitle("");
|
||
setDocContent("");
|
||
setDocUrl("");
|
||
setDocSourceType("text");
|
||
} catch (err) {
|
||
setUploadError(err instanceof Error ? err.message : "上传失败");
|
||
} finally {
|
||
setUploadLoading(false);
|
||
}
|
||
};
|
||
|
||
const openUploadDialog = (kbId: string) => {
|
||
setUploadKbId(kbId);
|
||
setUploadError(null);
|
||
setUploadDialogOpen(true);
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="mb-8">
|
||
<h1 className="text-2xl font-bold text-gray-900">知识库</h1>
|
||
<p className="mt-1 text-sm text-gray-500">管理行业和企业知识,为AI内容生产提供智能支撑</p>
|
||
</div>
|
||
<LoadingState rows={3} grid cols={3} rowHeight="h-40" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 页面标题 */}
|
||
<div className="mb-8">
|
||
<h1 className="text-2xl font-bold text-gray-900">知识库</h1>
|
||
<p className="mt-1 text-sm text-gray-500">
|
||
管理行业和企业知识,为AI内容生产提供智能支撑
|
||
</p>
|
||
</div>
|
||
|
||
{/* Error Banner */}
|
||
{error && (
|
||
<ErrorState error={error} onRetry={fetchBases} />
|
||
)}
|
||
|
||
{/* Tab 切换 */}
|
||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||
<TabsList>
|
||
<TabsTrigger value="enterprise">
|
||
企业知识库 ({enterpriseBases.length})
|
||
</TabsTrigger>
|
||
<TabsTrigger value="industry">
|
||
行业知识库 ({industryBases.length})
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* ── 企业知识库 ── */}
|
||
<TabsContent value="enterprise">
|
||
<div className="flex items-center justify-between gap-4 mb-6">
|
||
<div className="relative flex-1 max-w-sm">
|
||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
placeholder="搜索知识库..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="pl-9"
|
||
/>
|
||
</div>
|
||
<Button variant="outline" onClick={() => { setCreateError(null); setCreateDialogOpen(true); }}>
|
||
<Plus className="h-4 w-4 mr-1.5" />
|
||
创建知识库
|
||
</Button>
|
||
</div>
|
||
|
||
{filteredBases.length === 0 ? (
|
||
<EmptyState onCreateClick={() => setCreateDialogOpen(true)} />
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{filteredBases.map((kb) => (
|
||
<KnowledgeBaseCard
|
||
key={kb.id}
|
||
kb={kb}
|
||
isExpanded={expandedKbId === kb.id}
|
||
onToggle={() => setExpandedKbId(expandedKbId === kb.id ? null : kb.id)}
|
||
onDelete={handleDeleteKb}
|
||
onUpload={openUploadDialog}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
|
||
{/* ── 行业知识库 ── */}
|
||
<TabsContent value="industry">
|
||
<div className="mb-4">
|
||
<p className="text-sm text-muted-foreground">
|
||
行业知识库由平台统一维护,提供行业级知识支撑
|
||
</p>
|
||
</div>
|
||
|
||
{filteredBases.length === 0 ? (
|
||
<div className="text-center py-16">
|
||
<BookOpen className="h-12 w-12 mx-auto text-gray-300" />
|
||
<p className="mt-4 text-sm text-gray-500">暂无行业知识库</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{filteredBases.map((kb) => (
|
||
<Card key={kb.id} className="rounded-xl border border-gray-200">
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center gap-2">
|
||
<CardTitle className="text-base text-gray-900">{kb.name}</CardTitle>
|
||
<Badge variant="info" className="text-[10px]">平台维护</Badge>
|
||
</div>
|
||
{kb.description && (
|
||
<CardDescription className="line-clamp-2">{kb.description}</CardDescription>
|
||
)}
|
||
</CardHeader>
|
||
<CardFooter className="pt-0">
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="secondary" className="gap-1">
|
||
<FileText className="h-3 w-3" />
|
||
{kb.document_count} 文档
|
||
</Badge>
|
||
<StatusBadge status={kb.status} />
|
||
</div>
|
||
</CardFooter>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
{/* ── 创建知识库 Dialog ── */}
|
||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>创建知识库</DialogTitle>
|
||
<DialogDescription>
|
||
创建企业专属知识库,为AI内容生产提供精准知识支撑
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-2">
|
||
{createError && (
|
||
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
|
||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||
{createError}
|
||
</div>
|
||
)}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="kb-name">名称</Label>
|
||
<Input
|
||
id="kb-name"
|
||
placeholder="输入知识库名称"
|
||
value={newKbName}
|
||
onChange={(e) => setNewKbName(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="kb-desc">描述</Label>
|
||
<textarea
|
||
id="kb-desc"
|
||
className="flex min-h-[80px] 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-1 hover:border-primary/40 transition-colors resize-none"
|
||
placeholder="描述知识库的用途和内容范围(可选)"
|
||
value={newKbDescription}
|
||
onChange={(e) => setNewKbDescription(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={createLoading}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleCreateKb} disabled={!newKbName.trim() || createLoading}>
|
||
{createLoading ? (
|
||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />创建中...</>
|
||
) : (
|
||
"创建"
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* ── 上传文档 Dialog ── */}
|
||
<Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>上传文档</DialogTitle>
|
||
<DialogDescription>
|
||
向知识库添加文本内容或URL,系统将自动解析和分块
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-2">
|
||
{uploadError && (
|
||
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
|
||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||
{uploadError}
|
||
</div>
|
||
)}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="doc-title">标题</Label>
|
||
<Input
|
||
id="doc-title"
|
||
placeholder="输入文档标题"
|
||
value={docTitle}
|
||
onChange={(e) => setDocTitle(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>来源类型</Label>
|
||
<Select
|
||
value={docSourceType}
|
||
onValueChange={(v) => setDocSourceType(v as "text" | "url" | "markdown")}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="text">文本</SelectItem>
|
||
<SelectItem value="url">URL</SelectItem>
|
||
<SelectItem value="markdown">Markdown</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{docSourceType === "url" ? (
|
||
<div className="space-y-2">
|
||
<Label htmlFor="doc-url">URL 地址</Label>
|
||
<Input
|
||
id="doc-url"
|
||
placeholder="https://example.com/article"
|
||
value={docUrl}
|
||
onChange={(e) => setDocUrl(e.target.value)}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<Label htmlFor="doc-content">
|
||
{docSourceType === "markdown" ? "Markdown 内容" : "文本内容"}
|
||
</Label>
|
||
<textarea
|
||
id="doc-content"
|
||
className="flex min-h-[120px] 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-1 hover:border-primary/40 transition-colors resize-none"
|
||
placeholder={
|
||
docSourceType === "markdown"
|
||
? "输入 Markdown 格式的内容..."
|
||
: "输入纯文本内容..."
|
||
}
|
||
value={docContent}
|
||
onChange={(e) => setDocContent(e.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setUploadDialogOpen(false)} disabled={uploadLoading}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleUploadDoc}
|
||
disabled={
|
||
!docTitle.trim() ||
|
||
(docSourceType === "url" ? !docUrl.trim() : !docContent.trim()) ||
|
||
uploadLoading
|
||
}
|
||
>
|
||
{uploadLoading ? (
|
||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />上传中...</>
|
||
) : (
|
||
"上传"
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|