- {[['知识库总数', bases.length], ['已启用', bases.filter((base) => base.enabled).length], ['当前结果', visibleBases.length]].map(([label, value], index) =>
{label}{value})}
+
+ {[
+ ['知识库总数', bases.length],
+ ['已启用', bases.filter((base) => base.enabled).length],
+ ['已索引文档', documents.filter((document) => document.parse_status === 'success').length],
+ ['当前结果', visibleBases.length]
+ ].map(([label, value], index) => (
+
+
+
+ {index === 2 ? : }
+
+
+ {label}
+ {value}
+
+
+
+ ))}
+ {!error ? (
+
setUploadOpen(true)}
+ >
+
+ 导入文档
+
+ }
+ >
+ document.public_id}
+ emptyTitle="暂无导入文档"
+ emptyDescription="支持 PDF、TXT、LOG、DOCX、XLSX 和 PPTX,上传后将自动扫描、解析并建立索引。"
+ footer={documents.length ? (
+
+ 共 {documents.length} 份原始文档
+
+ 已索引 {documents.filter((document) => document.parse_status === 'success').length}
+ {' · '}
+ 处理中 {documents.filter((document) => (
+ activeDocumentStatuses.has(document.parse_status)
+ || document.scan_status === 'scanning'
+ )).length}
+
+
+ ) : undefined}
+ />
+
+ ) : null}
+
setQuery(event.target.value)} placeholder="搜索知识库、范围或描述" />
+
{
+ setUploadOpen(open)
+ if (!open) {
+ setUploadError('')
+ setUploadFile(null)
+ }
+ }}
+ title="导入知识文档"
+ description="原始文件会先保存,再异步完成安全扫描、文本提取和向量索引。失败时原件仍可追溯。"
+ submitLabel="上传并处理"
+ pendingLabel="正在上传…"
+ onSubmit={handleUpload}
+ pending={uploading}
+ submitDisabled={!uploadBaseId || !uploadFile}
+ error={uploadError}
+ >
+
+
+
+
+
+ {
+ setUploadFile(event.target.files?.[0] ?? null)
+ setUploadError('')
+ }}
+ />
+
+
+
+
+
+
+
+
安全导入策略
+
+ 系统会校验扩展名、MIME、文件签名和大小,并执行恶意文件扫描。
+ 扫描或解析失败只标记当前文档,不删除原件,也不会生成伪造索引。
+
+
+
+
+
+
+
{error ?
: null}
{!error && viewMode === 'list' ? (
= {
+ 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([])
+ const [selectedId, setSelectedId] = React.useState(null)
+ const [editor, setEditor] = React.useState('{}')
+ const [changeSummary, setChangeSummary] = React.useState('')
+ const [reviewComment, setReviewComment] = React.useState('')
+ const [impact, setImpact] = React.useState(null)
+ const [comparison, setComparison] = React.useState(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(() => {
+ 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, 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 (
+ 双人审核}
+ actions={(
+
+ )}
+ />
+ )}
+ >
+
+
+
+ 当前队列
+ {assets.length}
+
+
+
+
+ 待审核
+
+ {assets.filter((asset) => asset.versions.some((item) => item.status === 'pending_review')).length}
+
+
+
+
+
+ 已发布版本
+
+ {assets.reduce(
+ (total, asset) => total + asset.versions.filter((item) => item.status === 'published').length,
+ 0
+ )}
+
+
+
+
+
+ {error ? (
+
+ 治理数据加载失败
+ {error}
+
+ ) : null}
+
+
+
+
+
+ 资产队列
+ 按状态进入处理,选择一项查看完整版本链。
+
+
+
+
+ {loading ? (
+
+ ) : assets.length === 0 ? (
+
+ ) : assets.map((asset) => {
+ const version = versionForStatus(asset, status)
+ return (
+
+ )
+ })}
+
+
+
+ {!selected || !activeVersion ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ {TYPE_LABELS[selected.asset_type]}
+
+
+
{titleOf(selected)}
+
+ 对象 #{selected.object_id} · 当前查看 v{activeVersion.version}
+
+
+
+ {selected.versions.length > 1 ? (
+
+ ) : null}
+ {canEdit ? (
+
+ ) : null}
+ {canSubmit ? (
+
+ ) : null}
+
+
+
+
+ {activeVersion.review_comment ? (
+
+ 审核意见
+ {activeVersion.review_comment}
+
+ ) : null}
+ {canEdit ? (
+ <>
+
+
+
+ >
+ ) : (
+
+ {Object.entries(activeVersion.content).map(([field, value]) => (
+
+ {field}
+ {valuePreview(value)}
+
+ ))}
+
+ )}
+
+ {canReview ? (
+
+
+
+
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+ 影响范围
+ 发布、下线或回滚前核对既有引用。
+
+
+ {[
+ ['工单引用', impact?.ticket_reference_count ?? '—'],
+ ['方案采纳', impact?.ticket_adoption_count ?? '—'],
+ ['分析运行', impact?.support_analysis_count ?? '—']
+ ].map(([label, value]) => (
+
+ {label}
+ {value}
+
+ ))}
+
+
+
+
+
+ 版本记录
+ 回滚会从历史发布版本创建新草稿。
+
+
+ {selected.versions.map((version) => (
+
+
+
+ v{version.version}
+
+
+
+ {formatDateTime(version.updated_at)}
+
+
+ {['published', 'offline'].includes(version.status)
+ && permissionFor(selected, 'manage')
+ && !selected.versions.some((item) => ['draft', 'rejected'].includes(item.status)) ? (
+
+ ) : null}
+
+ ))}
+
+
+
+
+ {comparison ? (
+
+
+ v{comparison.left_version} → v{comparison.right_version}
+ {comparison.changes.length} 个字段发生变化。
+
+
+ {comparison.changes.length === 0 ? (
+ 两个版本内容一致。
+ ) : comparison.changes.map((change) => (
+
+
{change.field}
+
+
{valuePreview(change.before)}
+
{valuePreview(change.after)}
+
+
+ ))}
+
+
+ ) : null}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/features/knowledge/KnowledgeItemEditorPage.tsx b/frontend/src/features/knowledge/KnowledgeItemEditorPage.tsx
index aa73a5c..7134944 100644
--- a/frontend/src/features/knowledge/KnowledgeItemEditorPage.tsx
+++ b/frontend/src/features/knowledge/KnowledgeItemEditorPage.tsx
@@ -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() {