feat: add knowledge management pages

This commit is contained in:
hailin
2026-07-03 17:58:09 +08:00
parent 99dbb3277a
commit 94f0488341
7 changed files with 608 additions and 1 deletions

View File

@@ -1,6 +1,9 @@
import * as React from 'react'
import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview'
import { KnowledgeBaseListPage } from '@/features/knowledge/KnowledgeBaseListPage'
import { KnowledgeItemEditorPage } from '@/features/knowledge/KnowledgeItemEditorPage'
import { KnowledgeSearchPage } from '@/features/knowledge/KnowledgeSearchPage'
import { SolutionCreatePage } from '@/features/solutions/SolutionCreatePage'
import { SolutionListPage } from '@/features/solutions/SolutionListPage'
import { AuditLogPage } from '@/features/system/AuditLogPage'
@@ -54,6 +57,18 @@ export function Router() {
return <WorkbenchPage />
}
if (route === '/knowledge') {
return <KnowledgeBaseListPage />
}
if (route === '/knowledge/search') {
return <KnowledgeSearchPage />
}
if (route === '/knowledge/items/new') {
return <KnowledgeItemEditorPage />
}
if (route === '/solutions') {
return <SolutionListPage />
}

View File

@@ -1,6 +1,7 @@
import * as React from 'react'
import {
Bot,
BookOpen,
BriefcaseBusiness,
ClipboardList,
Database,
@@ -8,6 +9,7 @@ import {
Library,
Menu,
ScrollText,
Search,
Settings,
ShieldCheck,
Ticket,
@@ -23,6 +25,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh
const iconMap = {
audit: ScrollText,
book_open: BookOpen,
bot: Bot,
briefcase: BriefcaseBusiness,
database: Database,
@@ -30,6 +33,7 @@ const iconMap = {
library: Library,
menu: Menu,
roles: UserCog,
search: Search,
settings: Settings,
shield: ShieldCheck,
ticket: Ticket,
@@ -57,6 +61,41 @@ function visibleChildren(menu: AuthMenu) {
return menu.children.filter((item) => item.is_visible && item.is_enabled)
}
const knowledgeNavGroup: AuthMenu = {
id: -100,
code: 'knowledge_builtin',
title: '知识库',
path: '#/knowledge',
icon: 'book_open',
permission_code: 'knowledge.view',
is_visible: true,
is_enabled: true,
children: [
{
id: -101,
code: 'knowledge_bases',
title: '知识库',
path: '#/knowledge',
icon: 'book_open',
permission_code: 'knowledge.view',
is_visible: true,
is_enabled: true,
children: []
},
{
id: -102,
code: 'knowledge_search',
title: '知识检索',
path: '#/knowledge/search',
icon: 'search',
permission_code: 'knowledge.view',
is_visible: true,
is_enabled: true,
children: []
}
]
}
type SidebarNavContentProps = {
groups: AuthMenu[]
currentHash: string
@@ -120,7 +159,11 @@ type AppSidebarProps = {
export function AppSidebar({ mobileOpen = false, onMobileOpenChange }: AppSidebarProps) {
const { user } = useAuth()
const currentHash = useCurrentHash()
const groups = (user?.menus ?? []).filter((menu) => menu.is_visible && menu.is_enabled)
const menuGroups = (user?.menus ?? []).filter((menu) => menu.is_visible && menu.is_enabled)
const hasKnowledgeMenu = menuGroups.some(
(group) => group.code.startsWith('knowledge') || visibleChildren(group).some((item) => item.path.startsWith('#/knowledge'))
)
const groups = hasKnowledgeMenu ? menuGroups : [...menuGroups, knowledgeNavGroup]
return (
<>

View File

@@ -0,0 +1,151 @@
import * as React from 'react'
import { Plus, Search } from 'lucide-react'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
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 { Input } from '@/components/ui/input'
import { formatDateTime } from '@/lib/format'
import { createKnowledgeBase, listKnowledgeBases } from './api'
import type { KnowledgeBase } from './types'
type KnowledgeBaseForm = {
name: string
description: string
scope: string
}
const emptyForm: KnowledgeBaseForm = {
name: '',
description: '',
scope: ''
}
const columns: AppDataTableColumn<KnowledgeBase>[] = [
{
key: 'name',
header: '名称',
cell: (base) => (
<div>
<p className="font-medium text-foreground">{base.name}</p>
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
{base.description || '暂无描述'}
</p>
</div>
)
},
{
key: 'scope',
header: '范围',
cell: (base) => <span className="text-muted-foreground">{base.scope || '-'}</span>
},
{
key: 'enabled',
header: '启用状态',
cell: (base) => (
<Badge variant={base.enabled ? 'success' : 'secondary'}>
{base.enabled ? '已启用' : '已停用'}
</Badge>
)
},
{
key: 'updated_at',
header: '更新时间',
cell: (base) => <span className="text-muted-foreground">{formatDateTime(base.updated_at)}</span>
}
]
export function KnowledgeBaseListPage() {
const [bases, setBases] = React.useState<KnowledgeBase[]>([])
const [form, setForm] = React.useState<KnowledgeBaseForm>(emptyForm)
const [error, setError] = React.useState('')
async function load() {
try {
setBases(await listKnowledgeBases())
setError('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '加载知识库失败')
}
}
React.useEffect(() => {
void load()
}, [])
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
await createKnowledgeBase(form)
setForm(emptyForm)
await load()
}
return (
<AppPage contentClassName="gap-5">
<AppPageHeader
title="知识库"
actions={
<div className="flex gap-2">
<Button variant="outline" onClick={() => (window.location.hash = '/knowledge/search')}>
<Search data-icon="inline-start" />
知识检索
</Button>
<Button onClick={() => (window.location.hash = '/knowledge/items/new')}>
<Plus data-icon="inline-start" />
新建知识条目
</Button>
</div>
}
/>
<AppSection title="新建知识库">
<form className="grid gap-4 md:grid-cols-[1fr_1fr_1fr_auto] md:items-end" onSubmit={handleCreate}>
<AppField label="名称" htmlFor="knowledge-base-name" required>
<Input
id="knowledge-base-name"
required
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="例如:税务采集知识库"
/>
</AppField>
<AppField label="范围" htmlFor="knowledge-base-scope">
<Input
id="knowledge-base-scope"
value={form.scope}
onChange={(event) => setForm((current) => ({ ...current, scope: event.target.value }))}
placeholder="例如:数据采集"
/>
</AppField>
<AppField label="描述" htmlFor="knowledge-base-description">
<Input
id="knowledge-base-description"
value={form.description}
onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))}
placeholder="知识库用途说明"
/>
</AppField>
<Button type="submit">创建知识库</Button>
</form>
</AppSection>
{error ? <AppEmptyState title="无法加载知识库" description={error} /> : null}
{!error ? (
<AppDataTable
columns={columns}
rows={bases}
getRowKey={(base) => base.id}
emptyTitle="暂无知识库"
emptyDescription="创建知识库后,可继续维护文档、知识条目和检索内容。"
footer={<p className="text-sm text-muted-foreground">共 {bases.length} 个知识库</p>}
/>
) : null}
</AppPage>
)
}

View File

@@ -0,0 +1,202 @@
import * as React from 'react'
import { Save } from 'lucide-react'
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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { createKnowledgeItem, listKnowledgeBases } from './api'
import type { KnowledgeBase, KnowledgeItemInput } from './types'
const initialForm: KnowledgeItemInput = {
knowledge_base_id: 0,
title: '',
problem: '',
applicable_scope: '',
error_code: '',
root_cause: '',
troubleshooting_steps: '',
solution: '',
risk_note: '',
status: 'draft'
}
export function KnowledgeItemEditorPage() {
const [bases, setBases] = React.useState<KnowledgeBase[]>([])
const [form, setForm] = React.useState<KnowledgeItemInput>(initialForm)
const [error, setError] = React.useState('')
const [submitting, setSubmitting] = React.useState(false)
React.useEffect(() => {
listKnowledgeBases()
.then((items) => {
setBases(items)
setForm((current) => ({
...current,
knowledge_base_id: current.knowledge_base_id || items[0]?.id || 0
}))
})
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载知识库失败'))
}, [])
function updateField<Key extends keyof KnowledgeItemInput>(field: Key, value: KnowledgeItemInput[Key]) {
setForm((current) => ({ ...current, [field]: value }))
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!form.knowledge_base_id) {
setError('请先创建并选择知识库。')
return
}
setSubmitting(true)
try {
await createKnowledgeItem(form)
window.location.hash = '/knowledge/search'
} catch (reason) {
setError(reason instanceof Error ? reason.message : '保存知识条目失败')
} finally {
setSubmitting(false)
}
}
return (
<AppPage maxWidth="6xl" contentClassName="gap-5">
<AppPageHeader title="新建知识条目" />
{error ? <AppEmptyState title="知识条目操作失败" description={error} /> : null}
<form className="grid gap-4" onSubmit={handleSubmit}>
<AppSection title="基础信息">
<div className="grid gap-4 md:grid-cols-3">
<AppField label="所属知识库" required>
<Select
value={form.knowledge_base_id ? String(form.knowledge_base_id) : ''}
onValueChange={(value) => updateField('knowledge_base_id', Number(value))}
>
<SelectTrigger className="w-full">
<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-item-title" required>
<Input
id="knowledge-item-title"
required
value={form.title}
onChange={(event) => updateField('title', event.target.value)}
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>
</AppSection>
<AppSection title="问题与范围">
<div className="grid gap-4 md:grid-cols-2">
<AppField label="问题描述" htmlFor="knowledge-item-problem" required>
<Textarea
id="knowledge-item-problem"
required
value={form.problem}
onChange={(event) => updateField('problem', event.target.value)}
placeholder="描述问题表现、触发条件或常见问法。"
/>
</AppField>
<AppField label="适用范围" htmlFor="knowledge-item-scope">
<Textarea
id="knowledge-item-scope"
value={form.applicable_scope}
onChange={(event) => updateField('applicable_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>
</AppSection>
<AppSection title="原因与处理">
<div className="grid gap-4 md:grid-cols-2">
<AppField label="可能原因" htmlFor="knowledge-item-root-cause">
<Textarea
id="knowledge-item-root-cause"
value={form.root_cause}
onChange={(event) => updateField('root_cause', event.target.value)}
placeholder="说明常见原因或定位依据。"
/>
</AppField>
<AppField label="排查步骤" htmlFor="knowledge-item-steps">
<Textarea
id="knowledge-item-steps"
value={form.troubleshooting_steps}
onChange={(event) => updateField('troubleshooting_steps', 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>
</AppSection>
<div className="flex justify-end">
<Button type="submit" disabled={submitting}>
<Save data-icon="inline-start" />
{submitting ? '保存中...' : '保存知识条目'}
</Button>
</div>
</form>
</AppPage>
)
}

View File

@@ -0,0 +1,127 @@
import * as React from 'react'
import { Search } from 'lucide-react'
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 } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { listKnowledgeBases, searchKnowledge } from './api'
import type { KnowledgeBase, KnowledgeSearchResult } from './types'
const sourceLabels: Record<string, string> = {
document: '文档',
knowledge_item: '知识条目',
solution: '解决方案'
}
export function KnowledgeSearchPage() {
const [bases, setBases] = React.useState<KnowledgeBase[]>([])
const [knowledgeBaseId, setKnowledgeBaseId] = React.useState('all')
const [query, setQuery] = React.useState('')
const [results, setResults] = React.useState<KnowledgeSearchResult[]>([])
const [searched, setSearched] = React.useState(false)
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState('')
React.useEffect(() => {
listKnowledgeBases()
.then(setBases)
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载知识库失败'))
}, [])
async function handleSearch(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!query.trim()) {
setError('请输入需要检索的问题、错误码或处理场景。')
return
}
setError('')
setLoading(true)
try {
const items = await searchKnowledge(query.trim(), knowledgeBaseId === 'all' ? undefined : Number(knowledgeBaseId))
setResults(items)
setSearched(true)
} catch (reason) {
setError(reason instanceof Error ? reason.message : '知识检索失败')
} finally {
setLoading(false)
}
}
return (
<AppPage maxWidth="6xl" contentClassName="gap-5">
<AppPageHeader
title="知识检索"
actions={
<Button variant="outline" onClick={() => (window.location.hash = '/knowledge/items/new')}>
新建知识条目
</Button>
}
/>
<AppSection title="检索条件">
<form className="grid gap-4 md:grid-cols-[220px_1fr_auto]" onSubmit={handleSearch}>
<AppField label="知识库">
<Select value={knowledgeBaseId} onValueChange={setKnowledgeBaseId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择知识库" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部知识库</SelectItem>
{bases.map((base) => (
<SelectItem key={base.id} value={String(base.id)}>
{base.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</AppField>
<AppField label="检索内容" htmlFor="knowledge-query">
<Input
id="knowledge-query"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="输入问题、错误码、客户问法或处理场景"
/>
</AppField>
<div className="flex items-end">
<Button className="w-full md:w-auto" disabled={loading} type="submit">
<Search data-icon="inline-start" />
{loading ? '检索中...' : '检索知识'}
</Button>
</div>
</form>
</AppSection>
{error ? <AppEmptyState title="知识检索暂不可用" description={error} /> : null}
<div className="grid gap-3">
{results.map((item) => (
<Card key={item.id} className="shadow-sm">
<CardContent className="grid gap-3 p-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold text-foreground">{item.title}</h2>
<Badge variant="secondary">{sourceLabels[item.source_type] ?? item.source_type}</Badge>
<Badge variant="outline">匹配度 {item.score.toFixed(2)}</Badge>
</div>
<p className="line-clamp-4 whitespace-pre-line text-sm leading-6 text-muted-foreground">{item.content}</p>
</CardContent>
</Card>
))}
</div>
{searched && results.length === 0 && !error ? (
<AppEmptyState title="未找到匹配知识" description="可以调整关键词,或沉淀新的知识条目后再次检索。" />
) : null}
</AppPage>
)
}

View File

@@ -0,0 +1,37 @@
import { apiRequest } from '@/lib/api'
import type { KnowledgeBase, KnowledgeItemInput, KnowledgeSearchResult } from './types'
export function listKnowledgeBases() {
return apiRequest<KnowledgeBase[]>('/knowledge/bases')
}
export function createKnowledgeBase(payload: {
name: string
description: string
scope: string
enabled?: boolean
}) {
return apiRequest<KnowledgeBase>('/knowledge/bases', {
method: 'POST',
body: JSON.stringify({ ...payload, enabled: payload.enabled ?? true })
})
}
export function createKnowledgeItem(payload: KnowledgeItemInput) {
return apiRequest('/knowledge/items', {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function searchKnowledge(query: string, knowledgeBaseId?: number) {
return apiRequest<KnowledgeSearchResult[]>('/knowledge/search', {
method: 'POST',
body: JSON.stringify({
query,
knowledge_base_id: knowledgeBaseId,
limit: 8
})
})
}

View File

@@ -0,0 +1,32 @@
export type KnowledgeBase = {
id: number
name: string
description: string
scope: string
enabled: boolean
created_at: string
updated_at: string
}
export type KnowledgeItemInput = {
knowledge_base_id: number
title: string
problem: string
applicable_scope: string
error_code: string
root_cause: string
troubleshooting_steps: string
solution: string
risk_note: string
status: 'draft' | 'published' | 'disabled'
}
export type KnowledgeSearchResult = {
id: number
knowledge_base_id: number
title: string
source_type: string
content: string
score: number
metadata: Record<string, unknown>
}