Files
zclaw_openfang/admin-v2/src/pages/Knowledge.tsx
iven c167ea4ea5 fix(v13): V13 审计 6 项修复 — TrajectoryRecorder注册 + industryStore接入 + 知识搜索 + webhook标注 + structured UI + persistent注释
FIX-01: TrajectoryRecorderMiddleware 注册到 create_middleware_chain() (@650优先级)
FIX-02: industryStore 接入 ButlerPanel 行业专长展示 + 自动拉取
FIX-03: 桌面端知识库搜索 saas-knowledge mixin + VikingPanel SaaS KB UI
FIX-04: webhook 迁移标注 deprecated + 添加 down migration 注释
FIX-05: Admin Knowledge 添加结构化数据 Tab (CRUD + 行浏览)
FIX-06: PersistentMemoryStore 精化 dead_code 标注 (完整迁移留后续)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 01:34:08 +08:00

881 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// 知识库管理
// ============================================================
import { useState, useMemo, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm,
Card, Statistic, Row, Col, Tabs, Tree, Typography, Empty, Spin, InputNumber,
Table, Tooltip,
} from 'antd'
import {
PlusOutlined, SearchOutlined, BookOutlined, FolderOutlined,
DeleteOutlined, EditOutlined, EyeOutlined, BarChartOutlined,
HistoryOutlined, RollbackOutlined,
WarningOutlined,
} from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { knowledgeService } from '@/services/knowledge'
import type { CategoryResponse, KnowledgeItem, SearchResult } from '@/services/knowledge'
import type { StructuredSource } from '@/services/knowledge'
import { TableOutlined } from '@ant-design/icons'
const { TextArea } = Input
const { Text, Title } = Typography
// === 分类树 + 条目列表 Tab ===
function CategoriesPanel() {
const queryClient = useQueryClient()
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<CategoryResponse | null>(null)
const [createForm] = Form.useForm()
const [editForm] = Form.useForm()
const { data: categories = [], isLoading } = useQuery({
queryKey: ['knowledge-categories'],
queryFn: ({ signal }) => knowledgeService.listCategories(signal),
})
const createMutation = useMutation({
mutationFn: (data: Parameters<typeof knowledgeService.createCategory>[0]) =>
knowledgeService.createCategory(data),
onSuccess: () => {
message.success('分类已创建')
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
setCreateOpen(false)
createForm.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => knowledgeService.deleteCategory(id),
onSuccess: () => {
message.success('分类已删除')
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, ...data }: { id: string } & Record<string, unknown>) =>
knowledgeService.updateCategory(id, data),
onSuccess: () => {
message.success('分类已更新')
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
setEditItem(null)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
// 编辑弹窗打开时同步表单值Ant Design Form initialValues 仅首次挂载生效)
useEffect(() => {
if (editItem) {
editForm.setFieldsValue({
name: editItem.name,
description: editItem.description,
parent_id: editItem.parent_id,
icon: editItem.icon,
})
}
}, [editItem, editForm])
// 获取当前编辑分类及其所有后代的 ID防止循环引用
const getDescendantIds = (id: string, cats: CategoryResponse[]): string[] => {
const ids: string[] = [id]
for (const c of cats) {
if (c.parent_id === id) {
ids.push(...getDescendantIds(c.id, cats))
}
}
return ids
}
const treeData = useMemo(
() => buildTreeData(categories, (id) => {
Modal.confirm({
title: '确认删除',
content: '删除后无法恢复,请确保分类下没有子分类和条目。',
okType: 'danger',
onOk: () => deleteMutation.mutate(id),
})
}, (id) => {
setEditItem(categories.find((c) => c.id === id) || null)
}),
[categories, deleteMutation],
)
return (
<div>
<div className="flex justify-between items-center mb-4">
<Title level={5} style={{ margin: 0 }}></Title>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
</Button>
</div>
{isLoading ? (
<div className="flex justify-center py-8"><Spin /></div>
) : categories.length === 0 ? (
<Empty description="暂无分类,请新建一个" />
) : (
<Tree
treeData={treeData}
defaultExpandAll
showLine={{ showLeafIcon: false }}
showIcon
/>
)}
{/* 新建分类弹窗 */}
<Modal
title="新建分类"
open={createOpen}
onCancel={() => { setCreateOpen(false); createForm.resetFields() }}
onOk={() => createForm.submit()}
confirmLoading={createMutation.isPending}
>
<Form form={createForm} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
<Form.Item name="name" label="分类名称" rules={[{ required: true, message: '请输入分类名称' }]}>
<Input placeholder="例如:产品知识、技术文档" />
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={2} placeholder="可选描述" />
</Form.Item>
<Form.Item name="parent_id" label="父分类">
<Select placeholder="无(顶级分类)" allowClear>
{flattenCategories(categories).map((c) => (
<Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="icon" label="图标">
<Input placeholder="可选,如 📚" />
</Form.Item>
</Form>
</Modal>
{/* 编辑分类弹窗 */}
<Modal
title="编辑分类"
open={!!editItem}
onCancel={() => { setEditItem(null); editForm.resetFields() }}
onOk={() => editForm.submit()}
confirmLoading={updateMutation.isPending}
>
<Form
form={editForm}
layout="vertical"
initialValues={editItem ? { name: editItem.name, description: editItem.description, parent_id: editItem.parent_id, icon: editItem.icon } : undefined}
onFinish={(v) => editItem && updateMutation.mutate({ id: editItem.id, ...v })}
>
<Form.Item name="name" label="分类名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="parent_id" label="父分类">
<Select placeholder="无(顶级分类)" allowClear>
{editItem && flattenCategories(categories)
.filter((c) => !getDescendantIds(editItem.id, categories).includes(c.id))
.map((c) => (
<Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="icon" label="图标">
<Input placeholder="如 📚" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
// === 条目列表 ===
function ItemsPanel() {
const queryClient = useQueryClient()
const [createOpen, setCreateOpen] = useState(false)
const [detailItem, setDetailItem] = useState<string | null>(null)
const [versionModalOpen, setVersionModalOpen] = useState(false)
const [rollingBackVersion, setRollingBackVersion] = useState<number | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [filters, setFilters] = useState<{ category_id?: string; status?: string; keyword?: string }>({})
const [form] = Form.useForm()
const { data: categories = [] } = useQuery({
queryKey: ['knowledge-categories'],
queryFn: ({ signal }) => knowledgeService.listCategories(signal),
})
const { data: detailData, isLoading: detailLoading } = useQuery({
queryKey: ['knowledge-item-detail', detailItem],
queryFn: ({ signal }) => knowledgeService.getItem(detailItem!, signal),
enabled: !!detailItem,
})
const { data: versions } = useQuery({
queryKey: ['knowledge-item-versions', detailItem],
queryFn: ({ signal }) => knowledgeService.getVersions(detailItem!, signal),
enabled: !!detailItem,
})
const { data, isLoading } = useQuery({
queryKey: ['knowledge-items', page, pageSize, filters],
queryFn: ({ signal }) =>
knowledgeService.listItems({ page, page_size: pageSize, ...filters }, signal),
})
const createMutation = useMutation({
mutationFn: (data: Parameters<typeof knowledgeService.createItem>[0]) =>
knowledgeService.createItem(data),
onSuccess: () => {
message.success('条目已创建')
queryClient.invalidateQueries({ queryKey: ['knowledge-items'] })
setCreateOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => knowledgeService.deleteItem(id),
onSuccess: () => {
message.success('已删除')
queryClient.invalidateQueries({ queryKey: ['knowledge-items'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const rollbackMutation = useMutation({
mutationFn: ({ itemId, version }: { itemId: string; version: number }) =>
knowledgeService.rollbackVersion(itemId, version),
onSuccess: () => {
message.success('已回滚')
queryClient.invalidateQueries({ queryKey: ['knowledge-items'] })
queryClient.invalidateQueries({ queryKey: ['knowledge-item-detail'] })
queryClient.invalidateQueries({ queryKey: ['knowledge-item-versions'] })
setVersionModalOpen(false)
setRollingBackVersion(null)
},
onError: (err: Error) => {
message.error(err.message || '回滚失败')
setRollingBackVersion(null)
},
})
const statusColors: Record<string, string> = { active: 'green', draft: 'orange', archived: 'default' }
const statusLabels: Record<string, string> = { active: '活跃', draft: '草稿', archived: '已归档' }
const columns: ProColumns<KnowledgeItem>[] = [
{
title: '标题',
dataIndex: 'keyword',
width: 250,
render: (_, r) => (
<Button type="link" size="small" onClick={() => setDetailItem(r.id)}>
{r.title}
</Button>
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
valueEnum: Object.fromEntries(
Object.entries(statusLabels).map(([k, v]) => [k, { text: v, status: statusColors[k] === 'green' ? 'Success' : statusColors[k] === 'orange' ? 'Warning' : 'Default' }]),
),
},
{ title: '版本', dataIndex: 'version', width: 60, search: false },
{ title: '优先级', dataIndex: 'priority', width: 70, search: false },
{
title: '标签',
dataIndex: 'tags',
width: 200,
search: false,
render: (_, r) => (
<Space size={[4, 4]} wrap>
{r.tags?.map((t) => <Tag key={t}>{t}</Tag>)}
</Space>
),
},
{ title: '更新时间', dataIndex: 'updated_at', width: 160, valueType: 'dateTime', search: false },
{
title: '操作',
width: 150,
search: false,
render: (_, r) => (
<Space>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setDetailItem(r.id)} />
<Tooltip title="版本历史">
<Button type="link" size="small" icon={<HistoryOutlined />} onClick={() => { setDetailItem(r.id); setVersionModalOpen(true) }} />
</Tooltip>
<Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
return (
<div>
<ProTable<KnowledgeItem>
columns={columns}
dataSource={data?.items || []}
loading={isLoading}
rowKey="id"
search={{
onReset: () => { setFilters({}); setPage(1) },
onSearch: (values) => { setFilters(values); setPage(1) },
}}
toolBarRender={() => [
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
</Button>,
]}
pagination={{
current: page,
pageSize,
total: data?.total || 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
}}
options={{ density: false, fullScreen: false, reload: () => queryClient.invalidateQueries({ queryKey: ['knowledge-items'] }) }}
/>
{/* 创建弹窗 */}
<Modal
title="新建知识条目"
open={createOpen}
onCancel={() => { setCreateOpen(false); form.resetFields() }}
onOk={() => form.submit()}
confirmLoading={createMutation.isPending}
width={640}
>
<Form form={form} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
<Form.Item name="category_id" label="分类" rules={[{ required: true, message: '请选择分类' }]}>
<Select placeholder="选择分类">
{flattenCategories(categories).map((c) => (
<Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
<Input placeholder="知识条目标题" />
</Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }]}>
<TextArea rows={8} placeholder="支持 Markdown 格式" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="keywords" label="关键词">
<Select mode="tags" placeholder="输入后回车添加" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="tags" label="标签">
<Select mode="tags" placeholder="输入后回车添加" />
</Form.Item>
</Col>
</Row>
<Form.Item name="priority" label="优先级" initialValue={0}>
<InputNumber min={0} max={100} />
</Form.Item>
</Form>
</Modal>
{/* 详情弹窗 */}
<Modal
title={detailData?.title || '条目详情'}
open={!!detailItem && !versionModalOpen}
onCancel={() => setDetailItem(null)}
footer={null}
width={720}
>
{detailData && (
<div>
<div className="mb-4 flex gap-2">
<Tag color={statusColors[detailData.status]}>{statusLabels[detailData.status] || detailData.status}</Tag>
<Tag> {detailData.version}</Tag>
<Tag> {detailData.priority}</Tag>
</div>
<div className="mb-4 whitespace-pre-wrap bg-neutral-50 dark:bg-neutral-900 p-4 rounded-lg max-h-96 overflow-y-auto text-sm">
{detailData.content}
</div>
<div className="flex gap-2 flex-wrap">
{detailData.tags?.map((t) => <Tag key={t} color="blue">{t}</Tag>)}
{detailData.keywords?.map((k) => <Tag key={k} color="cyan">{k}</Tag>)}
</div>
</div>
)}
</Modal>
{/* 版本历史弹窗 */}
<Modal
title={`版本历史 - ${detailData?.title || ''}`}
open={versionModalOpen}
onCancel={() => { setVersionModalOpen(false); setDetailItem(null) }}
footer={null}
width={720}
>
<Table
dataSource={versions?.versions || []}
rowKey="id"
loading={!versions}
size="small"
pagination={{ pageSize: 10 }}
columns={[
{ title: '版本', dataIndex: 'version', width: 70 },
{ title: '标题', dataIndex: 'title', ellipsis: true },
{ title: '摘要', dataIndex: 'change_summary', width: 200, ellipsis: true },
{ title: '创建者', dataIndex: 'created_by', width: 100 },
{ title: '创建时间', dataIndex: 'created_at', width: 160 },
{
title: '操作',
width: 80,
render: (_, r) => (
<Popconfirm
title={`确认回滚到版本 ${r.version}?`}
description="回滚将创建新版本,当前版本内容会被替换。"
onConfirm={() => {
setRollingBackVersion(r.version)
rollbackMutation.mutate({ itemId: detailItem!, version: r.version })
}}
>
<Button type="link" size="small" icon={<RollbackOutlined />} loading={rollingBackVersion === r.version}>
</Button>
</Popconfirm>
),
},
]}
/>
</Modal>
</div>
)
}
// === 搜索面板 ===
function SearchPanel() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [searching, setSearching] = useState(false)
const [hasSearched, setHasSearched] = useState(false)
const handleSearch = async () => {
if (!query.trim()) return
setSearching(true)
try {
const data = await knowledgeService.search({ query: query.trim(), limit: 10 })
setResults(data)
setHasSearched(true)
} catch {
message.error('搜索失败')
} finally {
setSearching(false)
}
}
return (
<div>
<Title level={5}></Title>
<Space.Compact className="w-full mb-4">
<Input
size="large"
placeholder="输入搜索关键词..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onPressEnter={handleSearch}
prefix={<SearchOutlined />}
/>
<Button size="large" type="primary" loading={searching} onClick={handleSearch}>
</Button>
</Space.Compact>
{results.length === 0 && !searching && !hasSearched && (
<Empty description="输入关键词搜索知识库" />
)}
{results.length === 0 && !searching && hasSearched && (
<Empty description="未找到匹配的知识条目" />
)}
<div className="space-y-3">
{results.map((r) => (
<Card key={r.chunk_id} size="small" hoverable>
<div className="flex justify-between items-start mb-2">
<Text strong>{r.item_title}</Text>
<Tag>{r.category_name}</Tag>
</div>
<div className="text-sm text-neutral-600 dark:text-neutral-400 line-clamp-3 mb-2">
{r.content}
</div>
<div className="flex gap-1 flex-wrap">
{r.keywords?.slice(0, 5).map((k) => (
<Tag key={k} color="cyan" style={{ fontSize: 11 }}>{k}</Tag>
))}
</div>
</Card>
))}
</div>
</div>
)
}
// === 分析看板 ===
function AnalyticsPanel() {
const { data: overview, isLoading: overviewLoading } = useQuery({
queryKey: ['knowledge-analytics'],
queryFn: ({ signal }) => knowledgeService.getOverview(signal),
})
const { data: trends } = useQuery({
queryKey: ['knowledge-trends'],
queryFn: ({ signal }) => knowledgeService.getTrends(signal),
})
const { data: topItems } = useQuery({
queryKey: ['knowledge-top-items'],
queryFn: ({ signal }) => knowledgeService.getTopItems(signal),
})
const { data: quality } = useQuery({
queryKey: ['knowledge-quality'],
queryFn: ({ signal }) => knowledgeService.getQuality(signal),
})
const { data: gaps } = useQuery({
queryKey: ['knowledge-gaps'],
queryFn: ({ signal }) => knowledgeService.getGaps(signal),
})
if (overviewLoading) return <div className="flex justify-center py-8"><Spin /></div>
return (
<div>
<Title level={5} className="mb-4"></Title>
<Row gutter={[16, 16]}>
<Col span={6}>
<Card><Statistic title="总条目数" value={overview?.total_items || 0} /></Card>
</Col>
<Col span={6}>
<Card><Statistic title="活跃条目" value={overview?.active_items || 0} valueStyle={{ color: '#52c41a' }} /></Card>
</Col>
<Col span={6}>
<Card><Statistic title="分类数" value={overview?.total_categories || 0} /></Card>
</Col>
<Col span={6}>
<Card><Statistic title="本周新增" value={overview?.weekly_new_items || 0} valueStyle={{ color: '#1890ff' }} /></Card>
</Col>
</Row>
<Row gutter={[16, 16]} className="mt-4">
<Col span={6}>
<Card><Statistic title="总引用次数" value={overview?.total_references || 0} /></Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="注入率" value={((overview?.injection_rate || 0) * 100).toFixed(1)} suffix="%" />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="正面反馈率" value={((overview?.positive_feedback_rate || 0) * 100).toFixed(1)} suffix="%" valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={6}>
<Card><Statistic title="过期条目" value={overview?.stale_items_count || 0} valueStyle={{ color: '#faad14' }} /></Card>
</Col>
</Row>
{/* 趋势数据表格 */}
<Card title="检索趋势近30天" className="mt-4" size="small">
<Table
dataSource={trends?.trends || []}
rowKey="date"
loading={!trends}
size="small"
pagination={{ pageSize: 10 }}
columns={[
{ title: '日期', dataIndex: 'date', width: 120 },
{ title: '检索次数', dataIndex: 'count', width: 100 },
{ title: '注入次数', dataIndex: 'injected_count', width: 100 },
]}
/>
</Card>
{/* Top Items 表格 */}
<Card title="高频引用 Top 20" className="mt-4" size="small">
<Table
dataSource={topItems?.items || []}
rowKey="id"
loading={!topItems}
size="small"
pagination={{ pageSize: 10 }}
columns={[
{ title: '标题', dataIndex: 'title', ellipsis: true },
{ title: '分类', dataIndex: 'category', width: 120 },
{ title: '引用次数', dataIndex: 'ref_count', width: 100 },
]}
/>
</Card>
{/* 质量指标 */}
{quality?.categories?.length > 0 && (
<Card title="分类质量指标" className="mt-4" size="small">
<Table
dataSource={quality.categories}
rowKey="category"
size="small"
pagination={false}
columns={[
{ title: '分类', dataIndex: 'category', width: 150 },
{ title: '总条目', dataIndex: 'total', width: 80 },
{ title: '活跃', dataIndex: 'active', width: 80 },
{ title: '有关键词', dataIndex: 'with_keywords', width: 100 },
{ title: '平均优先级', dataIndex: 'avg_priority', width: 100, render: (v: number) => v?.toFixed(1) },
]}
/>
</Card>
)}
{/* 知识缺口 */}
{gaps?.gaps?.length > 0 && (
<Card
title={
<Space>
<WarningOutlined style={{ color: '#faad14' }} />
<span></span>
</Space>
}
className="mt-4"
size="small"
>
<Table
dataSource={gaps.gaps}
rowKey="query"
size="small"
pagination={{ pageSize: 10 }}
columns={[
{ title: '查询', dataIndex: 'query', ellipsis: true },
{ title: '次数', dataIndex: 'count', width: 80 },
{ title: '平均分', dataIndex: 'avg_score', width: 100, render: (v: number) => v?.toFixed(2) },
]}
/>
</Card>
)}
</div>
)
}
// === 主页面 ===
export default function Knowledge() {
return (
<div className="p-6">
<Tabs
defaultActiveKey="items"
items={[
{
key: 'items',
label: '知识条目',
icon: <BookOutlined />,
children: <ItemsPanel />,
},
{
key: 'categories',
label: '分类管理',
icon: <FolderOutlined />,
children: <CategoriesPanel />,
},
{
key: 'search',
label: '搜索',
icon: <SearchOutlined />,
children: <SearchPanel />,
},
{
key: 'analytics',
label: '分析看板',
icon: <BarChartOutlined />,
children: <AnalyticsPanel />,
},
{
key: 'structured',
label: '结构化数据',
icon: <TableOutlined />,
children: <StructuredSourcesPanel />,
},
]}
/>
</div>
)
}
// === Structured Data Sources Panel ===
function StructuredSourcesPanel() {
const queryClient = useQueryClient()
const [viewingRows, setViewingRows] = useState<string | null>(null)
const { data: sources = [], isLoading } = useQuery({
queryKey: ['structured-sources'],
queryFn: ({ signal }) => knowledgeService.listStructuredSources(signal),
})
const { data: rows = [], isLoading: rowsLoading } = useQuery({
queryKey: ['structured-rows', viewingRows],
queryFn: ({ signal }) => knowledgeService.listStructuredRows(viewingRows!, signal),
enabled: !!viewingRows,
})
const deleteMutation = useMutation({
mutationFn: (id: string) => knowledgeService.deleteStructuredSource(id),
onSuccess: () => {
message.success('数据源已删除')
queryClient.invalidateQueries({ queryKey: ['structured-sources'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const columns: ProColumns<StructuredSource>[] = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 200 },
{ title: '类型', dataIndex: 'source_type', key: 'source_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
{ title: '行数', dataIndex: 'row_count', key: 'row_count', width: 80 },
{
title: '列',
dataIndex: 'columns',
key: 'columns',
width: 250,
render: (cols: string[]) => (
<Space size={[4, 4]} wrap>
{(cols ?? []).slice(0, 5).map((c) => (
<Tag key={c} color="blue">{c}</Tag>
))}
{(cols ?? []).length > 5 && <Tag>+{(cols as string[]).length - 5}</Tag>}
</Space>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
width: 140,
render: (_: unknown, record: StructuredSource) => (
<Space>
<Button type="link" size="small" onClick={() => setViewingRows(record.id)}>
</Button>
<Popconfirm title="确认删除此数据源?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
]
// Dynamically generate row columns from the first row's keys
const rowColumns = rows.length > 0
? Object.keys(rows[0].row_data).map((key) => ({
title: key,
dataIndex: ['row_data', key],
key,
ellipsis: true,
render: (v: unknown) => String(v ?? ''),
}))
: []
return (
<div className="space-y-4">
{viewingRows ? (
<Card
title="数据行"
extra={<Button onClick={() => setViewingRows(null)}></Button>}
>
{rowsLoading ? (
<Spin />
) : rows.length === 0 ? (
<Empty description="暂无数据" />
) : (
<Table
dataSource={rows}
columns={rowColumns}
rowKey="id"
size="small"
scroll={{ x: true }}
pagination={{ pageSize: 20 }}
/>
)}
</Card>
) : (
<ProTable<StructuredSource>
dataSource={sources}
columns={columns}
loading={isLoading}
rowKey="id"
search={false}
pagination={{ pageSize: 20 }}
toolBarRender={false}
/>
)}
</div>
)
}
// === 辅助函数 ===
// === 辅助函数 ===
function flattenCategories(cats: CategoryResponse[]): { id: string; name: string }[] {
const result: { id: string; name: string }[] = []
for (const c of cats) {
result.push({ id: c.id, name: c.name })
if (c.children?.length) {
result.push(...flattenCategories(c.children))
}
}
return result
}
interface TreeNode {
key: string
title: React.ReactNode
icon?: React.ReactNode
children?: TreeNode[]
}
function buildTreeData(cats: CategoryResponse[], onDelete: (id: string) => void, onEdit: (id: string) => void): TreeNode[] {
return cats.map((c) => ({
key: c.id,
title: (
<div className="flex items-center gap-2">
<span>{c.icon || '📁'} {c.name}</span>
<Tag>{c.item_count}</Tag>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => onEdit(c.id)} />
<Button type="link" size="small" danger onClick={() => onDelete(c.id)}>
<DeleteOutlined />
</Button>
</div>
),
children: c.children?.length ? buildTreeData(c.children, onDelete, onEdit) : undefined,
}))
}