diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 06479db..e3f9899 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -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 } + if (route === '/knowledge') { + return + } + + if (route === '/knowledge/search') { + return + } + + if (route === '/knowledge/items/new') { + return + } + if (route === '/solutions') { return } diff --git a/frontend/src/components/app/AppSidebar.tsx b/frontend/src/components/app/AppSidebar.tsx index bd34ca8..389968f 100644 --- a/frontend/src/components/app/AppSidebar.tsx +++ b/frontend/src/components/app/AppSidebar.tsx @@ -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 ( <> diff --git a/frontend/src/features/knowledge/KnowledgeBaseListPage.tsx b/frontend/src/features/knowledge/KnowledgeBaseListPage.tsx new file mode 100644 index 0000000..3b2059c --- /dev/null +++ b/frontend/src/features/knowledge/KnowledgeBaseListPage.tsx @@ -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[] = [ + { + key: 'name', + header: '名称', + cell: (base) => ( + + {base.name} + + {base.description || '暂无描述'} + + + ) + }, + { + key: 'scope', + header: '范围', + cell: (base) => {base.scope || '-'} + }, + { + key: 'enabled', + header: '启用状态', + cell: (base) => ( + + {base.enabled ? '已启用' : '已停用'} + + ) + }, + { + key: 'updated_at', + header: '更新时间', + cell: (base) => {formatDateTime(base.updated_at)} + } +] + +export function KnowledgeBaseListPage() { + const [bases, setBases] = React.useState([]) + const [form, setForm] = React.useState(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) { + event.preventDefault() + await createKnowledgeBase(form) + setForm(emptyForm) + await load() + } + + return ( + + + (window.location.hash = '/knowledge/search')}> + + 知识检索 + + (window.location.hash = '/knowledge/items/new')}> + + 新建知识条目 + + + } + /> + + + + + setForm((current) => ({ ...current, name: event.target.value }))} + placeholder="例如:税务采集知识库" + /> + + + setForm((current) => ({ ...current, scope: event.target.value }))} + placeholder="例如:数据采集" + /> + + + setForm((current) => ({ ...current, description: event.target.value }))} + placeholder="知识库用途说明" + /> + + 创建知识库 + + + + {error ? : null} + {!error ? ( + base.id} + emptyTitle="暂无知识库" + emptyDescription="创建知识库后,可继续维护文档、知识条目和检索内容。" + footer={共 {bases.length} 个知识库} + /> + ) : null} + + ) +} diff --git a/frontend/src/features/knowledge/KnowledgeItemEditorPage.tsx b/frontend/src/features/knowledge/KnowledgeItemEditorPage.tsx new file mode 100644 index 0000000..012fdf6 --- /dev/null +++ b/frontend/src/features/knowledge/KnowledgeItemEditorPage.tsx @@ -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([]) + const [form, setForm] = React.useState(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(field: Key, value: KnowledgeItemInput[Key]) { + setForm((current) => ({ ...current, [field]: value })) + } + + async function handleSubmit(event: React.FormEvent) { + 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 ( + + + {error ? : null} + + + + + + updateField('knowledge_base_id', Number(value))} + > + + + + + + {bases.map((base) => ( + + {base.name} + + ))} + + + + + + updateField('title', event.target.value)} + placeholder="例如:税局采集进度查询" + /> + + + updateField('status', value as KnowledgeItemInput['status'])} + > + + + + + + 草稿 + 已发布 + 已停用 + + + + + + + + + + + updateField('problem', event.target.value)} + placeholder="描述问题表现、触发条件或常见问法。" + /> + + + updateField('applicable_scope', event.target.value)} + placeholder="例如:生产环境、税务采集、指定系统版本。" + /> + + + + updateField('error_code', event.target.value)} + placeholder="可选,例如:HTTP 500、TASK_TIMEOUT" + /> + + + + + + + updateField('root_cause', event.target.value)} + placeholder="说明常见原因或定位依据。" + /> + + + updateField('troubleshooting_steps', event.target.value)} + placeholder="列出处理人员应检查的日志、配置或状态。" + /> + + + + updateField('solution', event.target.value)} + placeholder="写入可复用的标准处理方式或客户回复。" + /> + + + updateField('risk_note', event.target.value)} + placeholder="说明不建议执行的动作、需要人工确认的风险。" + /> + + + + + + + {submitting ? '保存中...' : '保存知识条目'} + + + + + ) +} diff --git a/frontend/src/features/knowledge/KnowledgeSearchPage.tsx b/frontend/src/features/knowledge/KnowledgeSearchPage.tsx new file mode 100644 index 0000000..102157a --- /dev/null +++ b/frontend/src/features/knowledge/KnowledgeSearchPage.tsx @@ -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 = { + document: '文档', + knowledge_item: '知识条目', + solution: '解决方案' +} + +export function KnowledgeSearchPage() { + const [bases, setBases] = React.useState([]) + const [knowledgeBaseId, setKnowledgeBaseId] = React.useState('all') + const [query, setQuery] = React.useState('') + const [results, setResults] = React.useState([]) + 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) { + 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 ( + + (window.location.hash = '/knowledge/items/new')}> + 新建知识条目 + + } + /> + + + + + + + + + + + 全部知识库 + {bases.map((base) => ( + + {base.name} + + ))} + + + + + + setQuery(event.target.value)} + placeholder="输入问题、错误码、客户问法或处理场景" + /> + + + + + {loading ? '检索中...' : '检索知识'} + + + + + + {error ? : null} + + + {results.map((item) => ( + + + + {item.title} + {sourceLabels[item.source_type] ?? item.source_type} + 匹配度 {item.score.toFixed(2)} + + {item.content} + + + ))} + + + {searched && results.length === 0 && !error ? ( + + ) : null} + + ) +} diff --git a/frontend/src/features/knowledge/api.ts b/frontend/src/features/knowledge/api.ts new file mode 100644 index 0000000..19d18e4 --- /dev/null +++ b/frontend/src/features/knowledge/api.ts @@ -0,0 +1,37 @@ +import { apiRequest } from '@/lib/api' + +import type { KnowledgeBase, KnowledgeItemInput, KnowledgeSearchResult } from './types' + +export function listKnowledgeBases() { + return apiRequest('/knowledge/bases') +} + +export function createKnowledgeBase(payload: { + name: string + description: string + scope: string + enabled?: boolean +}) { + return apiRequest('/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('/knowledge/search', { + method: 'POST', + body: JSON.stringify({ + query, + knowledge_base_id: knowledgeBaseId, + limit: 8 + }) + }) +} diff --git a/frontend/src/features/knowledge/types.ts b/frontend/src/features/knowledge/types.ts new file mode 100644 index 0000000..228fb09 --- /dev/null +++ b/frontend/src/features/knowledge/types.ts @@ -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 +}
{base.name}
+ {base.description || '暂无描述'} +
共 {bases.length} 个知识库
{item.content}