feat: add governed knowledge lifecycle
This commit is contained in:
133
frontend/src/features/knowledge/KnowledgeBaseListPage.test.tsx
Normal file
133
frontend/src/features/knowledge/KnowledgeBaseListPage.test.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { KnowledgeBaseListPage } from './KnowledgeBaseListPage'
|
||||
import type { KnowledgeBase, KnowledgeDocument } from './types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createKnowledgeBase: vi.fn(),
|
||||
listKnowledgeBases: vi.fn(),
|
||||
listKnowledgeDocuments: vi.fn(),
|
||||
uploadKnowledgeDocument: vi.fn(),
|
||||
retryKnowledgeDocument: vi.fn(),
|
||||
knowledgeDocumentDownloadUrl: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agent-workspace/AgentWorkspaceProvider', () => ({
|
||||
useAgentPageContext: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: mocks.toastSuccess,
|
||||
error: mocks.toastError
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
createKnowledgeBase: mocks.createKnowledgeBase,
|
||||
listKnowledgeBases: mocks.listKnowledgeBases,
|
||||
listKnowledgeDocuments: mocks.listKnowledgeDocuments,
|
||||
uploadKnowledgeDocument: mocks.uploadKnowledgeDocument,
|
||||
retryKnowledgeDocument: mocks.retryKnowledgeDocument,
|
||||
knowledgeDocumentDownloadUrl: mocks.knowledgeDocumentDownloadUrl
|
||||
}))
|
||||
|
||||
const knowledgeBase: KnowledgeBase = {
|
||||
id: 12,
|
||||
name: '税务支持知识库',
|
||||
description: '税务采集与申报支持',
|
||||
scope: '税务',
|
||||
enabled: true,
|
||||
created_at: '2026-07-27T08:00:00Z',
|
||||
updated_at: '2026-07-27T08:00:00Z'
|
||||
}
|
||||
|
||||
const failedDocument: KnowledgeDocument = {
|
||||
id: 31,
|
||||
public_id: '3cf05956-43ab-4be8-a5ce-8a0115f59e40',
|
||||
knowledge_base_id: knowledgeBase.id,
|
||||
file_name: '采集故障手册.pdf',
|
||||
file_type: 'application/pdf',
|
||||
extension: 'pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_size: 2048,
|
||||
sha256: 'a'.repeat(64),
|
||||
parse_status: 'failed',
|
||||
scan_status: 'clean',
|
||||
scan_result: { status: 'clean' },
|
||||
processing_attempts: 1,
|
||||
task_id: 'task-31',
|
||||
parse_error: '文档解析失败:文件内容损坏',
|
||||
has_original: true,
|
||||
indexed_at: null,
|
||||
created_at: '2026-07-27T08:00:00Z',
|
||||
updated_at: '2026-07-27T08:01:00Z'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.listKnowledgeBases.mockResolvedValue([knowledgeBase])
|
||||
mocks.listKnowledgeDocuments.mockResolvedValue([])
|
||||
mocks.uploadKnowledgeDocument.mockResolvedValue({
|
||||
...failedDocument,
|
||||
file_name: '操作说明.txt',
|
||||
extension: 'txt',
|
||||
mime_type: 'text/plain',
|
||||
parse_status: 'pending',
|
||||
scan_status: 'pending',
|
||||
parse_error: ''
|
||||
})
|
||||
mocks.retryKnowledgeDocument.mockResolvedValue({
|
||||
...failedDocument,
|
||||
parse_status: 'pending',
|
||||
scan_status: 'pending',
|
||||
parse_error: ''
|
||||
})
|
||||
mocks.knowledgeDocumentDownloadUrl.mockReturnValue('/api/knowledge/documents/download')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('KnowledgeBaseListPage 文档导入', () => {
|
||||
it('上传文档到默认知识库并进入异步处理队列', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<KnowledgeBaseListPage />)
|
||||
|
||||
expect(await screen.findByText('文档导入队列')).toBeInTheDocument()
|
||||
expect(await screen.findByText('税务支持知识库')).toBeInTheDocument()
|
||||
await user.click(screen.getAllByRole('button', { name: '导入文档' })[0])
|
||||
|
||||
expect(screen.getByRole('heading', { name: '导入知识文档' })).toBeInTheDocument()
|
||||
const file = new File(['故障处理步骤'], '操作说明.txt', { type: 'text/plain' })
|
||||
await user.upload(screen.getByLabelText(/选择文件/), file)
|
||||
const submit = screen.getByRole('button', { name: '上传并处理' })
|
||||
expect(submit).toBeEnabled()
|
||||
fireEvent.submit(submit.closest('form')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.uploadKnowledgeDocument).toHaveBeenCalledWith(knowledgeBase.id, file)
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('文档已进入安全扫描与索引队列')
|
||||
})
|
||||
|
||||
it('展示失败原因并允许重新处理非恶意文档', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.listKnowledgeDocuments.mockResolvedValue([failedDocument])
|
||||
render(<KnowledgeBaseListPage />)
|
||||
|
||||
expect(await screen.findByText('采集故障手册.pdf')).toBeInTheDocument()
|
||||
expect(screen.getByText('文档解析失败:文件内容损坏')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: '重试' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.retryKnowledgeDocument).toHaveBeenCalledWith(failedDocument.public_id)
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('文档已重新进入处理队列')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,17 @@
|
||||
import * as React from 'react'
|
||||
import { BookOpen, Grid2X2, List, Plus, Search } from 'lucide-react'
|
||||
import {
|
||||
BookOpen,
|
||||
Download,
|
||||
FileText,
|
||||
Grid2X2,
|
||||
List,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Upload
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
|
||||
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
|
||||
@@ -7,17 +19,26 @@ import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||
import { AppField } from '@/components/app/AppField'
|
||||
import { AppPage } from '@/components/app/AppPage'
|
||||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||
import { AppSection } from '@/components/app/AppSection'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useAgentPageContext } from '@/features/agent-workspace/AgentWorkspaceProvider'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
|
||||
import { createKnowledgeBase, listKnowledgeBases } from './api'
|
||||
import type { KnowledgeBase } from './types'
|
||||
import {
|
||||
createKnowledgeBase,
|
||||
knowledgeDocumentDownloadUrl,
|
||||
listKnowledgeBases,
|
||||
listKnowledgeDocuments,
|
||||
retryKnowledgeDocument,
|
||||
uploadKnowledgeDocument
|
||||
} from './api'
|
||||
import type { KnowledgeBase, KnowledgeDocument } from './types'
|
||||
|
||||
type KnowledgeBaseForm = {
|
||||
name: string
|
||||
@@ -31,6 +52,39 @@ const emptyForm: KnowledgeBaseForm = {
|
||||
scope: ''
|
||||
}
|
||||
|
||||
const activeDocumentStatuses = new Set(['pending', 'parsing', 'indexing'])
|
||||
|
||||
function formatFileSize(size: number) {
|
||||
if (size < 1024) return `${size} B`
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function documentStatus(document: KnowledgeDocument) {
|
||||
if (document.parse_status === 'success') {
|
||||
return <Badge variant="success">已索引</Badge>
|
||||
}
|
||||
if (document.parse_status === 'failed') {
|
||||
return <Badge variant="destructive">处理失败</Badge>
|
||||
}
|
||||
if (document.parse_status === 'parsing') {
|
||||
return <Badge variant="info">正在解析</Badge>
|
||||
}
|
||||
if (document.parse_status === 'indexing') {
|
||||
return <Badge variant="ai">正在索引</Badge>
|
||||
}
|
||||
return <Badge variant="secondary">等待处理</Badge>
|
||||
}
|
||||
|
||||
function scanStatus(document: KnowledgeDocument) {
|
||||
if (document.scan_status === 'clean') return <Badge variant="success">扫描通过</Badge>
|
||||
if (document.scan_status === 'infected') return <Badge variant="danger">已隔离</Badge>
|
||||
if (document.scan_status === 'failed') return <Badge variant="destructive">扫描失败</Badge>
|
||||
if (document.scan_status === 'scanning') return <Badge variant="info">扫描中</Badge>
|
||||
if (document.scan_status === 'not_applicable') return <Badge variant="outline">无需扫描</Badge>
|
||||
return <Badge variant="secondary">等待扫描</Badge>
|
||||
}
|
||||
|
||||
const columns: AppDataTableColumn<KnowledgeBase>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -67,10 +121,17 @@ const columns: AppDataTableColumn<KnowledgeBase>[] = [
|
||||
|
||||
export function KnowledgeBaseListPage() {
|
||||
const [bases, setBases] = React.useState<KnowledgeBase[]>([])
|
||||
const [documents, setDocuments] = React.useState<KnowledgeDocument[]>([])
|
||||
const [form, setForm] = React.useState<KnowledgeBaseForm>(emptyForm)
|
||||
const [createOpen, setCreateOpen] = React.useState(false)
|
||||
const [creating, setCreating] = React.useState(false)
|
||||
const [createError, setCreateError] = React.useState('')
|
||||
const [uploadOpen, setUploadOpen] = React.useState(false)
|
||||
const [uploading, setUploading] = React.useState(false)
|
||||
const [uploadError, setUploadError] = React.useState('')
|
||||
const [uploadBaseId, setUploadBaseId] = React.useState('')
|
||||
const [uploadFile, setUploadFile] = React.useState<File | null>(null)
|
||||
const [retryingDocumentId, setRetryingDocumentId] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
const [query, setQuery] = React.useState('')
|
||||
const [viewMode, setViewMode] = React.useState<'grid' | 'list'>('grid')
|
||||
@@ -78,21 +139,46 @@ export function KnowledgeBaseListPage() {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
return keyword ? bases.filter((base) => `${base.name} ${base.description} ${base.scope}`.toLowerCase().includes(keyword)) : bases
|
||||
}, [bases, query])
|
||||
const agentContext = React.useMemo(() => ({ routeKey: 'knowledge', entityType: 'knowledge-base-list', title: `知识库 · ${bases.length} 个`, status: error ? '加载异常' : '已同步页面数据', metadata: { 启用: String(bases.filter((base) => base.enabled).length), 当前显示: String(visibleBases.length) } }), [bases, error, visibleBases.length])
|
||||
const agentContext = React.useMemo(() => ({
|
||||
routeKey: 'knowledge',
|
||||
entityType: 'knowledge-base-list',
|
||||
title: `知识库 · ${bases.length} 个`,
|
||||
status: error ? '加载异常' : '已同步页面数据',
|
||||
metadata: {
|
||||
启用: String(bases.filter((base) => base.enabled).length),
|
||||
文档: String(documents.length),
|
||||
当前显示: String(visibleBases.length)
|
||||
}
|
||||
}), [bases, documents.length, error, visibleBases.length])
|
||||
useAgentPageContext(agentContext)
|
||||
|
||||
async function load() {
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
setBases(await listKnowledgeBases())
|
||||
const [nextBases, nextDocuments] = await Promise.all([
|
||||
listKnowledgeBases(),
|
||||
listKnowledgeDocuments()
|
||||
])
|
||||
setBases(nextBases)
|
||||
setDocuments(nextDocuments)
|
||||
setUploadBaseId((current) => current || String(nextBases[0]?.id ?? ''))
|
||||
setError('')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '加载知识库失败')
|
||||
setError(reason instanceof Error ? reason.message : '加载知识库与文档失败')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
}, [load])
|
||||
|
||||
React.useEffect(() => {
|
||||
const hasProcessingDocument = documents.some((document) => (
|
||||
activeDocumentStatuses.has(document.parse_status) || document.scan_status === 'scanning'
|
||||
))
|
||||
if (!hasProcessingDocument) return
|
||||
const timer = window.setTimeout(() => void load(), 1500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [documents, load])
|
||||
|
||||
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
@@ -110,6 +196,141 @@ export function KnowledgeBaseListPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!uploadBaseId || !uploadFile) {
|
||||
setUploadError('请选择知识库和待导入文件。')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
setUploadError('')
|
||||
try {
|
||||
await uploadKnowledgeDocument(Number(uploadBaseId), uploadFile)
|
||||
setUploadFile(null)
|
||||
setUploadOpen(false)
|
||||
toast.success('文档已进入安全扫描与索引队列')
|
||||
await load()
|
||||
} catch (reason) {
|
||||
setUploadError(reason instanceof Error ? reason.message : '文档上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetry = React.useCallback(async (document: KnowledgeDocument) => {
|
||||
setRetryingDocumentId(document.public_id)
|
||||
try {
|
||||
await retryKnowledgeDocument(document.public_id)
|
||||
toast.success('文档已重新进入处理队列')
|
||||
await load()
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : '重试失败')
|
||||
} finally {
|
||||
setRetryingDocumentId('')
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const baseNames = React.useMemo(
|
||||
() => new Map(bases.map((base) => [base.id, base.name])),
|
||||
[bases]
|
||||
)
|
||||
const documentColumns = React.useMemo<AppDataTableColumn<KnowledgeDocument>[]>(() => [
|
||||
{
|
||||
key: 'file',
|
||||
header: '原始文档',
|
||||
cell: (document) => (
|
||||
<div className="flex min-w-56 items-start gap-2.5">
|
||||
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<FileText className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground" title={document.file_name}>
|
||||
{document.file_name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{document.extension.toUpperCase()} · {formatFileSize(document.file_size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'base',
|
||||
header: '知识库',
|
||||
cell: (document) => (
|
||||
<span className="text-muted-foreground">
|
||||
{baseNames.get(document.knowledge_base_id) ?? `#${document.knowledge_base_id}`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'scan',
|
||||
header: '安全扫描',
|
||||
cell: scanStatus
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: '解析与索引',
|
||||
cell: (document) => (
|
||||
<div className="grid gap-1">
|
||||
{documentStatus(document)}
|
||||
{document.parse_error ? (
|
||||
<span className="max-w-52 truncate text-xs text-destructive" title={document.parse_error}>
|
||||
{document.parse_error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'updated',
|
||||
header: '更新时间',
|
||||
cell: (document) => (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>{formatDateTime(document.updated_at)}</p>
|
||||
<p className="mt-0.5 text-xs">处理 {document.processing_attempts} 次</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '操作',
|
||||
cell: (document) => (
|
||||
<div className="flex items-center gap-1">
|
||||
{document.has_original && document.scan_status !== 'infected' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`下载 ${document.file_name}`}
|
||||
title="下载原件"
|
||||
onClick={() => window.open(
|
||||
knowledgeDocumentDownloadUrl(document.public_id),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)}
|
||||
>
|
||||
<Download />
|
||||
</Button>
|
||||
) : null}
|
||||
{document.parse_status === 'failed' && document.scan_status !== 'infected' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={retryingDocumentId === document.public_id}
|
||||
onClick={() => void handleRetry(document)}
|
||||
>
|
||||
<RefreshCw
|
||||
data-icon="inline-start"
|
||||
className={retryingDocumentId === document.public_id ? 'animate-spin' : undefined}
|
||||
/>
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
], [baseNames, handleRetry, retryingDocumentId])
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
contentClassName="gap-5"
|
||||
@@ -117,7 +338,11 @@ export function KnowledgeBaseListPage() {
|
||||
title="知识库"
|
||||
description="集中管理支持知识、文档范围与检索入口"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => (window.location.hash = '/knowledge/governance')}>
|
||||
<ShieldCheck data-icon="inline-start" />
|
||||
知识治理
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => (window.location.hash = '/knowledge/search')}>
|
||||
<Search data-icon="inline-start" />
|
||||
知识检索
|
||||
@@ -126,6 +351,14 @@ export function KnowledgeBaseListPage() {
|
||||
<Plus data-icon="inline-start" />
|
||||
新建知识条目
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!bases.length}
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<Upload data-icon="inline-start" />
|
||||
导入文档
|
||||
</Button>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
新增知识库
|
||||
@@ -135,10 +368,65 @@ export function KnowledgeBaseListPage() {
|
||||
/>}
|
||||
>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
|
||||
{[['知识库总数', bases.length], ['已启用', bases.filter((base) => base.enabled).length], ['当前结果', visibleBases.length]].map(([label, value], index) => <Card key={String(label)} className={index === 2 ? 'hidden lg:flex' : ''}><CardContent className="flex items-center gap-3 px-4"><span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary"><BookOpen className="size-4" /></span><span><span className="block text-xs text-muted-foreground">{label}</span><strong className="mt-1 block text-2xl font-semibold tabular-nums">{value}</strong></span></CardContent></Card>)}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{[
|
||||
['知识库总数', bases.length],
|
||||
['已启用', bases.filter((base) => base.enabled).length],
|
||||
['已索引文档', documents.filter((document) => document.parse_status === 'success').length],
|
||||
['当前结果', visibleBases.length]
|
||||
].map(([label, value], index) => (
|
||||
<Card key={String(label)} className={index === 3 ? 'hidden lg:flex' : ''}>
|
||||
<CardContent className="flex items-center gap-3 px-4">
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{index === 2 ? <FileText className="size-4" /> : <BookOpen className="size-4" />}
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-xs text-muted-foreground">{label}</span>
|
||||
<strong className="mt-1 block text-2xl font-semibold tabular-nums">{value}</strong>
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!error ? (
|
||||
<AppSection
|
||||
title="文档导入队列"
|
||||
description="原件保留 → 安全扫描 → 文本解析 → 向量索引。单个文档失败不会影响其他材料,可下载原件后重试。"
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!bases.length}
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<Upload data-icon="inline-start" />
|
||||
导入文档
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<AppDataTable
|
||||
columns={documentColumns}
|
||||
rows={documents}
|
||||
getRowKey={(document) => document.public_id}
|
||||
emptyTitle="暂无导入文档"
|
||||
emptyDescription="支持 PDF、TXT、LOG、DOCX、XLSX 和 PPTX,上传后将自动扫描、解析并建立索引。"
|
||||
footer={documents.length ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||
<span>共 {documents.length} 份原始文档</span>
|
||||
<span>
|
||||
已索引 {documents.filter((document) => document.parse_status === 'success').length}
|
||||
{' · '}
|
||||
处理中 {documents.filter((document) => (
|
||||
activeDocumentStatuses.has(document.parse_status)
|
||||
|| document.scan_status === 'scanning'
|
||||
)).length}
|
||||
</span>
|
||||
</div>
|
||||
) : undefined}
|
||||
/>
|
||||
</AppSection>
|
||||
) : null}
|
||||
|
||||
<Card className="py-0"><CardContent className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center"><InputGroup className="flex-1"><InputGroupAddon><Search /></InputGroupAddon><InputGroupInput value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索知识库、范围或描述" /></InputGroup><div className="flex rounded-lg border bg-muted/30 p-0.5"><Button variant={viewMode === 'grid' ? 'secondary' : 'ghost'} size="icon-sm" aria-label="卡片视图" onClick={() => setViewMode('grid')}><Grid2X2 /></Button><Button variant={viewMode === 'list' ? 'secondary' : 'ghost'} size="icon-sm" aria-label="列表视图" onClick={() => setViewMode('list')}><List /></Button></div></CardContent></Card>
|
||||
|
||||
<AppCreateDialog
|
||||
@@ -181,6 +469,75 @@ export function KnowledgeBaseListPage() {
|
||||
</div>
|
||||
</AppCreateDialog>
|
||||
|
||||
<AppCreateDialog
|
||||
open={uploadOpen}
|
||||
onOpenChange={(open) => {
|
||||
setUploadOpen(open)
|
||||
if (!open) {
|
||||
setUploadError('')
|
||||
setUploadFile(null)
|
||||
}
|
||||
}}
|
||||
title="导入知识文档"
|
||||
description="原始文件会先保存,再异步完成安全扫描、文本提取和向量索引。失败时原件仍可追溯。"
|
||||
submitLabel="上传并处理"
|
||||
pendingLabel="正在上传…"
|
||||
onSubmit={handleUpload}
|
||||
pending={uploading}
|
||||
submitDisabled={!uploadBaseId || !uploadFile}
|
||||
error={uploadError}
|
||||
>
|
||||
<div className="grid gap-5">
|
||||
<AppField label="目标知识库" required>
|
||||
<Select value={uploadBaseId} onValueChange={setUploadBaseId}>
|
||||
<SelectTrigger className="w-full" aria-label="目标知识库">
|
||||
<SelectValue placeholder="请选择知识库" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{bases.map((base) => (
|
||||
<SelectItem key={base.id} value={String(base.id)}>
|
||||
{base.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</AppField>
|
||||
<AppField
|
||||
label="选择文件"
|
||||
htmlFor="knowledge-document-file"
|
||||
description="支持 PDF、TXT、LOG、DOCX、XLSX、PPTX;单文件不超过 20 MB。"
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id="knowledge-document-file"
|
||||
type="file"
|
||||
required
|
||||
accept=".pdf,.txt,.log,.docx,.xlsx,.pptx"
|
||||
onChange={(event) => {
|
||||
setUploadFile(event.target.files?.[0] ?? null)
|
||||
setUploadError('')
|
||||
}}
|
||||
/>
|
||||
</AppField>
|
||||
<div className="rounded-xl border border-border/70 bg-muted/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-success/10 text-success">
|
||||
<ShieldCheck className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">安全导入策略</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
系统会校验扩展名、MIME、文件签名和大小,并执行恶意文件扫描。
|
||||
扫描或解析失败只标记当前文档,不删除原件,也不会生成伪造索引。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppCreateDialog>
|
||||
|
||||
{error ? <AppEmptyState title="无法加载知识库" description={error} /> : null}
|
||||
{!error && viewMode === 'list' ? (
|
||||
<AppDataTable
|
||||
|
||||
539
frontend/src/features/knowledge/KnowledgeGovernancePage.tsx
Normal file
539
frontend/src/features/knowledge/KnowledgeGovernancePage.tsx
Normal file
@@ -0,0 +1,539 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Check,
|
||||
GitCompareArrows,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||
import { AppField } from '@/components/app/AppField'
|
||||
import { AppPage } from '@/components/app/AppPage'
|
||||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { JsonEditor, parseJsonObject } from '@/features/ai-platform/components/JsonEditor'
|
||||
import { StatusBadge } from '@/features/ai-platform/status'
|
||||
import { useAuth } from '@/features/auth/AuthProvider'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
compareGovernedAssetVersions,
|
||||
getGovernedAssetImpact,
|
||||
listGovernedAssets,
|
||||
reviewGovernedAssetVersion,
|
||||
rollbackGovernedAsset,
|
||||
submitGovernedAssetVersion,
|
||||
updateGovernedAssetVersion
|
||||
} from './api'
|
||||
import type {
|
||||
AssetImpact,
|
||||
AssetVersionComparison,
|
||||
GovernedAsset,
|
||||
GovernedAssetType
|
||||
} from './types'
|
||||
|
||||
const TYPE_LABELS: Record<GovernedAssetType, string> = {
|
||||
knowledge_item: '知识条目',
|
||||
troubleshooting_plan: '排查方案',
|
||||
solution: '解决方案'
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
['pending_review', '待审核'],
|
||||
['draft', '草稿'],
|
||||
['rejected', '已驳回'],
|
||||
['published', '已发布'],
|
||||
['offline', '已下线'],
|
||||
['all', '全部状态']
|
||||
] as const
|
||||
|
||||
function titleOf(asset: GovernedAsset) {
|
||||
const content = asset.versions[0]?.content
|
||||
return String(content?.title || `${TYPE_LABELS[asset.asset_type]} #${asset.object_id}`)
|
||||
}
|
||||
|
||||
function versionForStatus(asset: GovernedAsset, status: string) {
|
||||
return asset.versions.find((version) => status === 'all' || version.status === status)
|
||||
?? asset.versions[0]
|
||||
}
|
||||
|
||||
function valuePreview(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
if (typeof value === 'string') return value
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
export function KnowledgeGovernancePage() {
|
||||
const { user } = useAuth()
|
||||
const [status, setStatus] = React.useState('pending_review')
|
||||
const [assets, setAssets] = React.useState<GovernedAsset[]>([])
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null)
|
||||
const [editor, setEditor] = React.useState('{}')
|
||||
const [changeSummary, setChangeSummary] = React.useState('')
|
||||
const [reviewComment, setReviewComment] = React.useState('')
|
||||
const [impact, setImpact] = React.useState<AssetImpact | null>(null)
|
||||
const [comparison, setComparison] = React.useState<AssetVersionComparison | null>(null)
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [busy, setBusy] = React.useState(false)
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
const hasPermission = React.useCallback((permission: string) => Boolean(
|
||||
user?.is_superuser || user?.permissions.includes(permission)
|
||||
), [user])
|
||||
|
||||
const viewableTypes = React.useMemo<GovernedAssetType[]>(() => {
|
||||
const types: GovernedAssetType[] = []
|
||||
if (hasPermission('knowledge.view')) types.push('knowledge_item', 'troubleshooting_plan')
|
||||
if (hasPermission('solution.view')) types.push('solution')
|
||||
return types
|
||||
}, [hasPermission])
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const groups = await Promise.all(
|
||||
viewableTypes.map((assetType) => listGovernedAssets(assetType, status))
|
||||
)
|
||||
const items = groups.flat().sort((left, right) =>
|
||||
right.updated_at.localeCompare(left.updated_at)
|
||||
)
|
||||
setAssets(items)
|
||||
setSelectedId((current) =>
|
||||
current && items.some((item) => item.public_id === current)
|
||||
? current
|
||||
: items[0]?.public_id ?? null
|
||||
)
|
||||
setError('')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '加载知识治理数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [status, viewableTypes])
|
||||
|
||||
React.useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const selected = assets.find((asset) => asset.public_id === selectedId) ?? null
|
||||
const activeVersion = selected ? versionForStatus(selected, status) : null
|
||||
|
||||
React.useEffect(() => {
|
||||
setEditor(JSON.stringify(activeVersion?.content ?? {}, null, 2))
|
||||
setChangeSummary(activeVersion?.change_summary ?? '')
|
||||
setReviewComment('')
|
||||
setComparison(null)
|
||||
setImpact(null)
|
||||
if (selected) {
|
||||
getGovernedAssetImpact(selected.public_id)
|
||||
.then(setImpact)
|
||||
.catch(() => setImpact(null))
|
||||
}
|
||||
}, [activeVersion, selected])
|
||||
|
||||
function permissionFor(asset: GovernedAsset, action: 'manage' | 'submit' | 'review') {
|
||||
const module = asset.asset_type === 'solution' ? 'solution' : 'knowledge'
|
||||
return hasPermission(`${module}.${action}`)
|
||||
}
|
||||
|
||||
async function execute(action: () => Promise<unknown>, successMessage: string) {
|
||||
setBusy(true)
|
||||
try {
|
||||
await action()
|
||||
toast.success(successMessage)
|
||||
await load()
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : '操作失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!selected || !activeVersion) return
|
||||
const content = parseJsonObject(editor, '资产内容')
|
||||
await execute(
|
||||
() => updateGovernedAssetVersion(
|
||||
selected.public_id,
|
||||
activeVersion.version,
|
||||
content,
|
||||
changeSummary
|
||||
),
|
||||
'资产草稿已保存'
|
||||
)
|
||||
}
|
||||
|
||||
async function compareLatest() {
|
||||
if (!selected || selected.versions.length < 2) return
|
||||
const [right, left] = selected.versions
|
||||
try {
|
||||
setComparison(await compareGovernedAssetVersions(
|
||||
selected.public_id,
|
||||
left.version,
|
||||
right.version
|
||||
))
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : '版本比较失败')
|
||||
}
|
||||
}
|
||||
|
||||
const canEdit = Boolean(
|
||||
selected
|
||||
&& activeVersion
|
||||
&& ['draft', 'rejected'].includes(activeVersion.status)
|
||||
&& permissionFor(selected, 'manage')
|
||||
)
|
||||
const canSubmit = Boolean(
|
||||
selected
|
||||
&& activeVersion?.status === 'draft'
|
||||
&& permissionFor(selected, 'submit')
|
||||
)
|
||||
const canReview = Boolean(
|
||||
selected
|
||||
&& activeVersion?.status === 'pending_review'
|
||||
&& permissionFor(selected, 'review')
|
||||
)
|
||||
const isSelfReview = Boolean(
|
||||
activeVersion?.submitted_by_id
|
||||
&& activeVersion.submitted_by_id === user?.id
|
||||
)
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
maxWidth="7xl"
|
||||
contentClassName="gap-5"
|
||||
header={(
|
||||
<AppPageHeader
|
||||
title="知识治理中心"
|
||||
description="统一审核知识、排查方案与解决方案;发布版本保持不可变。"
|
||||
meta={<Badge variant="ai"><ShieldCheck />双人审核</Badge>}
|
||||
actions={(
|
||||
<Button variant="outline" onClick={() => void load()} disabled={loading}>
|
||||
<RefreshCw data-icon="inline-start" />
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<span className="text-xs text-muted-foreground">当前队列</span>
|
||||
<strong className="mt-1 block text-2xl tabular-nums">{assets.length}</strong>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<span className="text-xs text-muted-foreground">待审核</span>
|
||||
<strong className="mt-1 block text-2xl tabular-nums">
|
||||
{assets.filter((asset) => asset.versions.some((item) => item.status === 'pending_review')).length}
|
||||
</strong>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<span className="text-xs text-muted-foreground">已发布版本</span>
|
||||
<strong className="mt-1 block text-2xl tabular-nums">
|
||||
{assets.reduce(
|
||||
(total, asset) => total + asset.versions.filter((item) => item.status === 'published').length,
|
||||
0
|
||||
)}
|
||||
</strong>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>治理数据加载失败</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<Card className="min-h-[540px]">
|
||||
<CardHeader className="gap-3">
|
||||
<div>
|
||||
<CardTitle>资产队列</CardTitle>
|
||||
<CardDescription>按状态进入处理,选择一项查看完整版本链。</CardDescription>
|
||||
</div>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
{loading ? (
|
||||
<div className="h-40 animate-pulse rounded-lg bg-muted" />
|
||||
) : assets.length === 0 ? (
|
||||
<AppEmptyState
|
||||
title="当前没有匹配资产"
|
||||
description="切换状态查看其他版本,或等待新的知识草稿提交。"
|
||||
/>
|
||||
) : assets.map((asset) => {
|
||||
const version = versionForStatus(asset, status)
|
||||
return (
|
||||
<button
|
||||
key={asset.public_id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'grid gap-2 rounded-lg border p-3 text-left transition-colors hover:bg-muted/60',
|
||||
selectedId === asset.public_id && 'border-primary/40 bg-primary/5 ring-1 ring-primary/15'
|
||||
)}
|
||||
onClick={() => setSelectedId(asset.public_id)}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<Badge variant="outline">{TYPE_LABELS[asset.asset_type]}</Badge>
|
||||
{version ? <StatusBadge status={version.status} /> : null}
|
||||
</span>
|
||||
<strong className="line-clamp-2 text-sm">{titleOf(asset)}</strong>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{version ? `v${version.version} · ${formatDateTime(version.updated_at)}` : '暂无版本'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!selected || !activeVersion ? (
|
||||
<Card className="min-h-[540px]">
|
||||
<CardContent className="flex min-h-[540px] items-center justify-center">
|
||||
<AppEmptyState title="选择一个知识资产" description="查看内容、审核记录、影响范围与版本历史。" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{TYPE_LABELS[selected.asset_type]}</Badge>
|
||||
<StatusBadge status={activeVersion.status} />
|
||||
</div>
|
||||
<CardTitle>{titleOf(selected)}</CardTitle>
|
||||
<CardDescription>
|
||||
对象 #{selected.object_id} · 当前查看 v{activeVersion.version}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selected.versions.length > 1 ? (
|
||||
<Button variant="outline" onClick={() => void compareLatest()}>
|
||||
<GitCompareArrows data-icon="inline-start" />
|
||||
比较最近版本
|
||||
</Button>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
<Button variant="outline" onClick={() => void saveDraft()} disabled={busy}>
|
||||
<Save data-icon="inline-start" />
|
||||
保存草稿
|
||||
</Button>
|
||||
) : null}
|
||||
{canSubmit ? (
|
||||
<Button
|
||||
onClick={() => void execute(
|
||||
() => submitGovernedAssetVersion(selected.public_id, activeVersion.version),
|
||||
'已提交独立审核'
|
||||
)}
|
||||
disabled={busy}
|
||||
>
|
||||
<Send data-icon="inline-start" />
|
||||
提交审核
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
{activeVersion.review_comment ? (
|
||||
<Alert>
|
||||
<AlertTitle>审核意见</AlertTitle>
|
||||
<AlertDescription>{activeVersion.review_comment}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
<>
|
||||
<JsonEditor
|
||||
id="asset-content"
|
||||
label="资产版本内容"
|
||||
value={editor}
|
||||
onChange={setEditor}
|
||||
description="保存只更新当前草稿;发布版本不可直接修改。"
|
||||
rows={18}
|
||||
/>
|
||||
<AppField label="变更说明" htmlFor="asset-change-summary">
|
||||
<Textarea
|
||||
id="asset-change-summary"
|
||||
value={changeSummary}
|
||||
onChange={(event) => setChangeSummary(event.target.value)}
|
||||
placeholder="说明本次修改的范围和原因。"
|
||||
/>
|
||||
</AppField>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid gap-2 rounded-lg border bg-muted/25 p-4">
|
||||
{Object.entries(activeVersion.content).map(([field, value]) => (
|
||||
<div key={field} className="grid gap-1 border-b py-2 last:border-0 md:grid-cols-[180px_1fr]">
|
||||
<span className="text-xs font-medium text-muted-foreground">{field}</span>
|
||||
<span className="whitespace-pre-wrap text-sm">{valuePreview(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canReview ? (
|
||||
<div className="grid gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4">
|
||||
<AppField
|
||||
label="审核意见"
|
||||
htmlFor="asset-review-comment"
|
||||
description={isSelfReview ? '提交人不能审核自己的资产版本。' : '驳回时必须填写原因。'}
|
||||
>
|
||||
<Textarea
|
||||
id="asset-review-comment"
|
||||
value={reviewComment}
|
||||
onChange={(event) => setReviewComment(event.target.value)}
|
||||
placeholder="记录核对结论、风险与适用范围。"
|
||||
/>
|
||||
</AppField>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || isSelfReview || !reviewComment.trim()}
|
||||
onClick={() => void execute(
|
||||
() => reviewGovernedAssetVersion(
|
||||
selected.public_id,
|
||||
activeVersion.version,
|
||||
'reject',
|
||||
reviewComment
|
||||
),
|
||||
'资产版本已驳回'
|
||||
)}
|
||||
>
|
||||
<X data-icon="inline-start" />
|
||||
驳回
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || isSelfReview}
|
||||
onClick={() => void execute(
|
||||
() => reviewGovernedAssetVersion(
|
||||
selected.public_id,
|
||||
activeVersion.version,
|
||||
'approve',
|
||||
reviewComment
|
||||
),
|
||||
'资产版本已发布并进入索引队列'
|
||||
)}
|
||||
>
|
||||
<Check data-icon="inline-start" />
|
||||
通过并发布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>影响范围</CardTitle>
|
||||
<CardDescription>发布、下线或回滚前核对既有引用。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
['工单引用', impact?.ticket_reference_count ?? '—'],
|
||||
['方案采纳', impact?.ticket_adoption_count ?? '—'],
|
||||
['分析运行', impact?.support_analysis_count ?? '—']
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="rounded-lg bg-muted/60 p-3">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<strong className="mt-1 block text-xl tabular-nums">{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>版本记录</CardTitle>
|
||||
<CardDescription>回滚会从历史发布版本创建新草稿。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
{selected.versions.map((version) => (
|
||||
<div key={version.id} className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<span>
|
||||
<span className="flex items-center gap-2">
|
||||
<strong className="text-sm">v{version.version}</strong>
|
||||
<StatusBadge status={version.status} />
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{formatDateTime(version.updated_at)}
|
||||
</span>
|
||||
</span>
|
||||
{['published', 'offline'].includes(version.status)
|
||||
&& permissionFor(selected, 'manage')
|
||||
&& !selected.versions.some((item) => ['draft', 'rejected'].includes(item.status)) ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void execute(
|
||||
() => rollbackGovernedAsset(selected.public_id, version.version),
|
||||
`已从 v${version.version} 创建回滚草稿`
|
||||
)}
|
||||
>
|
||||
<RotateCcw data-icon="inline-start" />
|
||||
回滚
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{comparison ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>v{comparison.left_version} → v{comparison.right_version}</CardTitle>
|
||||
<CardDescription>{comparison.changes.length} 个字段发生变化。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
{comparison.changes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">两个版本内容一致。</p>
|
||||
) : comparison.changes.map((change) => (
|
||||
<div key={change.field} className="grid gap-2 rounded-lg border p-3">
|
||||
<strong className="text-sm">{change.field}</strong>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<pre className="whitespace-pre-wrap rounded-md bg-danger/5 p-2 text-xs">{valuePreview(change.before)}</pre>
|
||||
<pre className="whitespace-pre-wrap rounded-md bg-success/5 p-2 text-xs">{valuePreview(change.after)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppPage>
|
||||
)
|
||||
}
|
||||
@@ -7,9 +7,12 @@ import { AppPage } from '@/components/app/AppPage'
|
||||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||
import { AppSection } from '@/components/app/AppSection'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { TICKET_CATEGORIES } from '@/features/tickets/ticketCategories'
|
||||
import type { TicketCategoryCode } from '@/features/tickets/types'
|
||||
|
||||
import { createKnowledgeItem, listKnowledgeBases } from './api'
|
||||
import type { KnowledgeBase, KnowledgeItemInput } from './types'
|
||||
@@ -19,6 +22,9 @@ const initialForm: KnowledgeItemInput = {
|
||||
title: '',
|
||||
problem: '',
|
||||
applicable_scope: '',
|
||||
category_code: '',
|
||||
related_system: '',
|
||||
environment_scope: '',
|
||||
error_code: '',
|
||||
root_cause: '',
|
||||
troubleshooting_steps: '',
|
||||
@@ -76,7 +82,7 @@ export function KnowledgeItemEditorPage() {
|
||||
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<AppSection title="基础信息">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<FieldGroup className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="所属知识库" required>
|
||||
<Select
|
||||
value={form.knowledge_base_id ? String(form.knowledge_base_id) : ''}
|
||||
@@ -105,28 +111,15 @@ export function KnowledgeItemEditorPage() {
|
||||
placeholder="例如:税局采集进度查询"
|
||||
/>
|
||||
</AppField>
|
||||
<AppField label="发布状态" required>
|
||||
<Select
|
||||
value={form.status}
|
||||
onValueChange={(value) => updateField('status', value as KnowledgeItemInput['status'])}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="请选择状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="published">已发布</SelectItem>
|
||||
<SelectItem value="disabled">已停用</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</AppField>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
新条目将保存为草稿;提交审核后,必须由另一名审核人通过才能发布。
|
||||
</p>
|
||||
</AppSection>
|
||||
|
||||
<AppSection title="问题与范围">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FieldGroup>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="问题描述" htmlFor="knowledge-item-problem" required>
|
||||
<Textarea
|
||||
id="knowledge-item-problem"
|
||||
@@ -144,19 +137,62 @@ export function KnowledgeItemEditorPage() {
|
||||
placeholder="例如:生产环境、税务采集、指定系统版本。"
|
||||
/>
|
||||
</AppField>
|
||||
</div>
|
||||
<AppField label="错误码" htmlFor="knowledge-item-error-code">
|
||||
<Input
|
||||
id="knowledge-item-error-code"
|
||||
value={form.error_code}
|
||||
onChange={(event) => updateField('error_code', event.target.value)}
|
||||
placeholder="可选,例如:HTTP 500、TASK_TIMEOUT"
|
||||
/>
|
||||
</AppField>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<AppField label="适用分类">
|
||||
<Select
|
||||
value={form.category_code || 'all'}
|
||||
onValueChange={(value) => updateField(
|
||||
'category_code',
|
||||
value === 'all' ? '' : value as TicketCategoryCode
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">全部分类</SelectItem>
|
||||
{TICKET_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category.code} value={category.code}>
|
||||
{category.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</AppField>
|
||||
<AppField label="关联系统" htmlFor="knowledge-item-system">
|
||||
<Input
|
||||
id="knowledge-item-system"
|
||||
value={form.related_system}
|
||||
onChange={(event) => updateField('related_system', event.target.value)}
|
||||
placeholder="留空表示全部系统"
|
||||
/>
|
||||
</AppField>
|
||||
<AppField label="运行环境" htmlFor="knowledge-item-environment">
|
||||
<Input
|
||||
id="knowledge-item-environment"
|
||||
value={form.environment_scope}
|
||||
onChange={(event) => updateField('environment_scope', event.target.value)}
|
||||
placeholder="留空表示全部环境"
|
||||
/>
|
||||
</AppField>
|
||||
</div>
|
||||
<AppField label="错误码" htmlFor="knowledge-item-error-code">
|
||||
<Input
|
||||
id="knowledge-item-error-code"
|
||||
value={form.error_code}
|
||||
onChange={(event) => updateField('error_code', event.target.value)}
|
||||
placeholder="可选,例如:HTTP 500、TASK_TIMEOUT"
|
||||
/>
|
||||
</AppField>
|
||||
</FieldGroup>
|
||||
</AppSection>
|
||||
|
||||
<AppSection title="原因与处理">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FieldGroup>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="可能原因" htmlFor="knowledge-item-root-cause">
|
||||
<Textarea
|
||||
id="knowledge-item-root-cause"
|
||||
@@ -173,24 +209,25 @@ export function KnowledgeItemEditorPage() {
|
||||
placeholder="列出处理人员应检查的日志、配置或状态。"
|
||||
/>
|
||||
</AppField>
|
||||
</div>
|
||||
<AppField label="解决方案" htmlFor="knowledge-item-solution" required>
|
||||
<Textarea
|
||||
id="knowledge-item-solution"
|
||||
required
|
||||
value={form.solution}
|
||||
onChange={(event) => updateField('solution', event.target.value)}
|
||||
placeholder="写入可复用的标准处理方式或客户回复。"
|
||||
/>
|
||||
</AppField>
|
||||
<AppField label="风险提示" htmlFor="knowledge-item-risk-note">
|
||||
<Textarea
|
||||
id="knowledge-item-risk-note"
|
||||
value={form.risk_note}
|
||||
onChange={(event) => updateField('risk_note', event.target.value)}
|
||||
placeholder="说明不建议执行的动作、需要人工确认的风险。"
|
||||
/>
|
||||
</AppField>
|
||||
</div>
|
||||
<AppField label="解决方案" htmlFor="knowledge-item-solution" required>
|
||||
<Textarea
|
||||
id="knowledge-item-solution"
|
||||
required
|
||||
value={form.solution}
|
||||
onChange={(event) => updateField('solution', event.target.value)}
|
||||
placeholder="写入可复用的标准处理方式或客户回复。"
|
||||
/>
|
||||
</AppField>
|
||||
<AppField label="风险提示" htmlFor="knowledge-item-risk-note">
|
||||
<Textarea
|
||||
id="knowledge-item-risk-note"
|
||||
value={form.risk_note}
|
||||
onChange={(event) => updateField('risk_note', event.target.value)}
|
||||
placeholder="说明不建议执行的动作、需要人工确认的风险。"
|
||||
/>
|
||||
</AppField>
|
||||
</FieldGroup>
|
||||
</AppSection>
|
||||
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { apiRequest, apiUrl } from '@/lib/api'
|
||||
|
||||
import type { KnowledgeBase, KnowledgeItemInput, KnowledgeSearchResult } from './types'
|
||||
import type {
|
||||
AssetImpact,
|
||||
AssetVersionComparison,
|
||||
GovernedAsset,
|
||||
GovernedAssetType,
|
||||
GovernedAssetVersion,
|
||||
KnowledgeBase,
|
||||
KnowledgeDocument,
|
||||
KnowledgeItemInput,
|
||||
KnowledgeSearchResult
|
||||
} from './types'
|
||||
|
||||
export function listKnowledgeBases() {
|
||||
return apiRequest<KnowledgeBase[]>('/knowledge/bases')
|
||||
@@ -18,10 +28,37 @@ export function createKnowledgeBase(payload: {
|
||||
})
|
||||
}
|
||||
|
||||
export function listKnowledgeDocuments(knowledgeBaseId?: number, parseStatus?: string) {
|
||||
const query = new URLSearchParams()
|
||||
if (knowledgeBaseId) query.set('knowledge_base_id', String(knowledgeBaseId))
|
||||
if (parseStatus) query.set('parse_status', parseStatus)
|
||||
const suffix = query.size ? `?${query}` : ''
|
||||
return apiRequest<KnowledgeDocument[]>(`/knowledge/documents${suffix}`)
|
||||
}
|
||||
|
||||
export function uploadKnowledgeDocument(knowledgeBaseId: number, file: File) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
return apiRequest<KnowledgeDocument>(
|
||||
`/knowledge/documents/upload?knowledge_base_id=${knowledgeBaseId}`,
|
||||
{ method: 'POST', body }
|
||||
)
|
||||
}
|
||||
|
||||
export function retryKnowledgeDocument(publicId: string) {
|
||||
return apiRequest<KnowledgeDocument>(`/knowledge/documents/${publicId}/retry`, {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function knowledgeDocumentDownloadUrl(publicId: string) {
|
||||
return apiUrl(`/knowledge/documents/${publicId}/download`)
|
||||
}
|
||||
|
||||
export function createKnowledgeItem(payload: KnowledgeItemInput) {
|
||||
return apiRequest('/knowledge/items', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify({ ...payload, status: 'draft' })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,3 +72,70 @@ export function searchKnowledge(query: string, knowledgeBaseId?: number) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function listGovernedAssets(assetType: GovernedAssetType, status?: string) {
|
||||
const query = new URLSearchParams({ asset_type: assetType })
|
||||
if (status && status !== 'all') query.set('status', status)
|
||||
return apiRequest<GovernedAsset[]>(`/knowledge/assets?${query}`)
|
||||
}
|
||||
|
||||
export function updateGovernedAssetVersion(
|
||||
publicId: string,
|
||||
version: number,
|
||||
content: Record<string, unknown>,
|
||||
changeSummary: string
|
||||
) {
|
||||
return apiRequest<GovernedAssetVersion>(
|
||||
`/knowledge/assets/${publicId}/versions/${version}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ content, change_summary: changeSummary })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function submitGovernedAssetVersion(publicId: string, version: number) {
|
||||
return apiRequest<GovernedAssetVersion>(
|
||||
`/knowledge/assets/${publicId}/versions/${version}/submit`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
}
|
||||
|
||||
export function reviewGovernedAssetVersion(
|
||||
publicId: string,
|
||||
version: number,
|
||||
decision: 'approve' | 'reject',
|
||||
comment: string
|
||||
) {
|
||||
return apiRequest<GovernedAssetVersion>(
|
||||
`/knowledge/assets/${publicId}/versions/${version}/${decision}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ comment })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function rollbackGovernedAsset(publicId: string, sourceVersion: number) {
|
||||
return apiRequest<GovernedAssetVersion>(`/knowledge/assets/${publicId}/rollback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
source_version: sourceVersion,
|
||||
change_summary: `回滚自 v${sourceVersion}`
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function getGovernedAssetImpact(publicId: string) {
|
||||
return apiRequest<AssetImpact>(`/knowledge/assets/${publicId}/impact`)
|
||||
}
|
||||
|
||||
export function compareGovernedAssetVersions(
|
||||
publicId: string,
|
||||
left: number,
|
||||
right: number
|
||||
) {
|
||||
return apiRequest<AssetVersionComparison>(
|
||||
`/knowledge/assets/${publicId}/compare?left=${left}&right=${right}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TicketCategoryCode } from '@/features/tickets/types'
|
||||
|
||||
export type KnowledgeBase = {
|
||||
id: number
|
||||
name: string
|
||||
@@ -8,17 +10,42 @@ export type KnowledgeBase = {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type KnowledgeDocument = {
|
||||
id: number
|
||||
public_id: string
|
||||
knowledge_base_id: number
|
||||
file_name: string
|
||||
file_type: string
|
||||
extension: string
|
||||
mime_type: string
|
||||
file_size: number
|
||||
sha256: string
|
||||
parse_status: string
|
||||
scan_status: string
|
||||
scan_result: Record<string, unknown>
|
||||
processing_attempts: number
|
||||
task_id: string
|
||||
parse_error: string
|
||||
has_original: boolean
|
||||
indexed_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type KnowledgeItemInput = {
|
||||
knowledge_base_id: number
|
||||
title: string
|
||||
problem: string
|
||||
applicable_scope: string
|
||||
category_code: TicketCategoryCode | ''
|
||||
related_system: string
|
||||
environment_scope: string
|
||||
error_code: string
|
||||
root_cause: string
|
||||
troubleshooting_steps: string
|
||||
solution: string
|
||||
risk_note: string
|
||||
status: 'draft' | 'published' | 'disabled'
|
||||
status: 'draft'
|
||||
}
|
||||
|
||||
export type KnowledgeSearchResult = {
|
||||
@@ -30,3 +57,53 @@ export type KnowledgeSearchResult = {
|
||||
score: number
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type GovernedAssetType = 'knowledge_item' | 'troubleshooting_plan' | 'solution'
|
||||
export type GovernedAssetVersionStatus =
|
||||
| 'draft'
|
||||
| 'pending_review'
|
||||
| 'rejected'
|
||||
| 'published'
|
||||
| 'offline'
|
||||
|
||||
export type GovernedAssetVersion = {
|
||||
id: number
|
||||
version: number
|
||||
status: GovernedAssetVersionStatus
|
||||
content: Record<string, unknown>
|
||||
change_summary: string
|
||||
source_version: number | null
|
||||
created_by_id: number | null
|
||||
submitted_by_id: number | null
|
||||
reviewed_by_id: number | null
|
||||
review_comment: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
submitted_at: string | null
|
||||
reviewed_at: string | null
|
||||
published_at: string | null
|
||||
offline_at: string | null
|
||||
}
|
||||
|
||||
export type GovernedAsset = {
|
||||
public_id: string
|
||||
asset_type: GovernedAssetType
|
||||
object_id: number
|
||||
current_published_version: number | null
|
||||
versions: GovernedAssetVersion[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type AssetImpact = {
|
||||
ticket_reference_count: number
|
||||
ticket_adoption_count: number
|
||||
support_analysis_count: number
|
||||
agent_run_ids: string[]
|
||||
}
|
||||
|
||||
export type AssetVersionComparison = {
|
||||
left_version: number
|
||||
right_version: number
|
||||
changes: Array<{ field: string; before: unknown; after: unknown }>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user