feat: add knowledge management pages
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview'
|
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 { SolutionCreatePage } from '@/features/solutions/SolutionCreatePage'
|
||||||
import { SolutionListPage } from '@/features/solutions/SolutionListPage'
|
import { SolutionListPage } from '@/features/solutions/SolutionListPage'
|
||||||
import { AuditLogPage } from '@/features/system/AuditLogPage'
|
import { AuditLogPage } from '@/features/system/AuditLogPage'
|
||||||
@@ -54,6 +57,18 @@ export function Router() {
|
|||||||
return <WorkbenchPage />
|
return <WorkbenchPage />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route === '/knowledge') {
|
||||||
|
return <KnowledgeBaseListPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route === '/knowledge/search') {
|
||||||
|
return <KnowledgeSearchPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route === '/knowledge/items/new') {
|
||||||
|
return <KnowledgeItemEditorPage />
|
||||||
|
}
|
||||||
|
|
||||||
if (route === '/solutions') {
|
if (route === '/solutions') {
|
||||||
return <SolutionListPage />
|
return <SolutionListPage />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
|
BookOpen,
|
||||||
BriefcaseBusiness,
|
BriefcaseBusiness,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Database,
|
Database,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
Library,
|
Library,
|
||||||
Menu,
|
Menu,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Ticket,
|
Ticket,
|
||||||
@@ -23,6 +25,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh
|
|||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
audit: ScrollText,
|
audit: ScrollText,
|
||||||
|
book_open: BookOpen,
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
briefcase: BriefcaseBusiness,
|
briefcase: BriefcaseBusiness,
|
||||||
database: Database,
|
database: Database,
|
||||||
@@ -30,6 +33,7 @@ const iconMap = {
|
|||||||
library: Library,
|
library: Library,
|
||||||
menu: Menu,
|
menu: Menu,
|
||||||
roles: UserCog,
|
roles: UserCog,
|
||||||
|
search: Search,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
shield: ShieldCheck,
|
shield: ShieldCheck,
|
||||||
ticket: Ticket,
|
ticket: Ticket,
|
||||||
@@ -57,6 +61,41 @@ function visibleChildren(menu: AuthMenu) {
|
|||||||
return menu.children.filter((item) => item.is_visible && item.is_enabled)
|
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 = {
|
type SidebarNavContentProps = {
|
||||||
groups: AuthMenu[]
|
groups: AuthMenu[]
|
||||||
currentHash: string
|
currentHash: string
|
||||||
@@ -120,7 +159,11 @@ type AppSidebarProps = {
|
|||||||
export function AppSidebar({ mobileOpen = false, onMobileOpenChange }: AppSidebarProps) {
|
export function AppSidebar({ mobileOpen = false, onMobileOpenChange }: AppSidebarProps) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const currentHash = useCurrentHash()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
151
frontend/src/features/knowledge/KnowledgeBaseListPage.tsx
Normal file
151
frontend/src/features/knowledge/KnowledgeBaseListPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
202
frontend/src/features/knowledge/KnowledgeItemEditorPage.tsx
Normal file
202
frontend/src/features/knowledge/KnowledgeItemEditorPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
frontend/src/features/knowledge/KnowledgeSearchPage.tsx
Normal file
127
frontend/src/features/knowledge/KnowledgeSearchPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
frontend/src/features/knowledge/api.ts
Normal file
37
frontend/src/features/knowledge/api.ts
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
32
frontend/src/features/knowledge/types.ts
Normal file
32
frontend/src/features/knowledge/types.ts
Normal 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>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user