feat(web): Knowledge Base 前端頁面 + i18n
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
E2E Health Check / e2e-health (push) Has been cancelled

- 左側分類導航 (基礎設施/應用層/AI系統/安全合規)
- 頂部搜尋欄 + 類型篩選 + 新增按鈕
- 知識條目列表 (type badge + status + tags + view count)
- 右側詳情 Panel (content + meta + approve/archive)
- 空態設計 (BookOpen icon + 說明文字)
- i18n: zh-TW + en knowledgeBase namespace (30+ keys)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-02 09:00:23 +08:00
parent d32d84efce
commit 502621e2f2
3 changed files with 620 additions and 14 deletions

View File

@@ -549,7 +549,7 @@
"loading": "Loading...",
"refresh": "Refresh",
"sentryDashboard": "Sentry Dashboard",
"footerInfo": "Data from Sentry Self-Hosted (192.168.0.110:9000) | AI Analysis: OpenClaw | Auto-refresh: 60s",
"footerInfo": "Data from Sentry Self-Hosted | AI Analysis: OpenClaw | Auto-refresh: 60s",
"timeAgo": {
"minutes": "{count}m ago",
"hours": "{count}h ago",
@@ -575,6 +575,127 @@
}
},
"alerts": {
"autoRefresh": "Auto-refresh every {seconds}s"
"autoRefresh": "Auto-refresh every {seconds}s",
"incidentCount": "{count, plural, one {# incident} other {# incidents}}"
},
"navSection": {
"aiCore": "AI Core",
"monitoring": "Monitoring & Security",
"ops": "Operations",
"knowledge": "Knowledge & Tools"
},
"sidebar": {
"expand": "Expand sidebar",
"collapse": "Collapse sidebar"
},
"settings": {
"title": "Settings",
"appearance": "Appearance",
"appearanceDesc": "Theme, fonts, density",
"appearanceSettings": "Appearance Settings",
"language": "Language",
"languageDesc": "Interface language",
"languageSettings": "Language Settings",
"notify": "Notifications",
"notifyDesc": "Telegram / browser notification preferences",
"notifySettings": "Notification Settings",
"system": "System Info",
"systemDesc": "Version & API endpoints",
"systemSettings": "System Info",
"compactMode": "Compact Mode",
"compactModeDesc": "Reduce spacing, show more content",
"designSystem": "Design System",
"designSystemValue": "Nothing.tech Pure White Industrial (fixed)",
"themeColor": "Theme Color",
"themeColorValue": "OpenClaw Blue + Orange Accent (fixed)",
"browserNotify": "Browser Push Notifications",
"browserNotifyDesc": "Show system notification on new Incident",
"p0Only": "P0 CRITICAL Only",
"p0OnlyDesc": "Filter low-severity alerts to reduce noise",
"telegramNotify": "Telegram Notifications",
"telegramNotifyDesc": "Pushed by OpenClaw Bot (requires backend config)",
"backendConfig": "Backend Config",
"frontendVersion": "Frontend Version",
"apiEndpoint": "API Endpoint",
"notConfigured": "(not configured)",
"phase": "Phase",
"save": "Save Settings",
"saved": "Saved",
"zhTW": "繁體中文",
"zhTWSub": "Traditional Chinese",
"en": "English",
"enSub": "English (US)"
},
"autoRepair": {
"subtitle": "High-quality Playbook auto-execution · Risk ≤ MEDIUM · Success ≥ 95%",
"approvedPlaybooks": "Approved Playbooks",
"highQualityPlaybooks": "High-Quality Playbooks",
"highQualitySub": "Success ≥ 95% · Runs ≥ 10",
"totalExecutions": "Total Executions",
"overallSuccessRate": "Overall Success Rate",
"eligible": "✓ Auto-repair available",
"notEligible": "No high-quality Playbook yet",
"ready": "Auto-repair Ready",
"notReady": "Auto-repair Not Ready",
"readyDesc": "{count} high-quality Playbooks available",
"notReadyDesc": "Need at least 1 high-quality Playbook (success ≥ 95%, runs ≥ 10)",
"incidentEval": "Active Incident Evaluation (P1/P2)",
"canAutoRepair": "Can auto-repair",
"notEligibleShort": "Not eligible",
"riskLevel": "Risk Level",
"successRate": "Success Rate",
"execCount": "Executions",
"decisionReason": "Decision Reason",
"execSuccess": "Success ({ms}ms)",
"execFailed": "Failed: {error}",
"executing": "Executing...",
"execute": "Execute Repair",
"noEligible": "No incidents eligible for auto-repair"
},
"openclawPanel": {
"patrolling": "[AGENT] patrolling...",
"intercepting": "[SYS] Intercepting anomaly...",
"analyzing": "[SYS] Analyzing blast radius...",
"generating": "[SYS] Generating proposed action...",
"complete": "[SYS] Analysis complete"
},
"knowledgeBase": {
"title": "Knowledge Base",
"searchPlaceholder": "Search knowledge entries...",
"allCategories": "All",
"noResults": "No knowledge entries found",
"createEntry": "New Entry",
"viewCount": "views",
"relatedPlaybook": "Related Playbook",
"relatedIncident": "Related Incident",
"approve": "Approve",
"archive": "Archive",
"status": {
"draft": "Draft",
"review": "In Review",
"approved": "Approved",
"archived": "Archived"
},
"type": {
"incident_case": "Incident Case",
"runbook": "Runbook",
"best_practice": "Best Practice",
"postmortem": "Postmortem"
},
"source": {
"ai_extracted": "AI Extracted",
"human": "Manual"
},
"category": {
"infrastructure": "Infrastructure",
"application": "Application",
"ai_system": "AI System",
"security": "Security / Compliance"
},
"filterByType": "Filter by type",
"filterByStatus": "Filter by status",
"entries": "entries",
"empty": "No knowledge entries yet",
"emptyDescription": "Entries will be auto-extracted from incidents, or you can create them manually"
}
}

View File

@@ -550,7 +550,7 @@
"loading": "載入中...",
"refresh": "重新整理",
"sentryDashboard": "Sentry 儀表板",
"footerInfo": "資料來源: Sentry Self-Hosted (192.168.0.110:9000) | AI 分析: OpenClaw | 自動刷新: 60 秒",
"footerInfo": "資料來源: Sentry Self-Hosted | AI 分析: OpenClaw | 自動刷新: 60 秒",
"timeAgo": {
"minutes": "{count} 分鐘前",
"hours": "{count} 小時前",
@@ -576,6 +576,127 @@
}
},
"alerts": {
"autoRefresh": "每 {seconds} 秒自動刷新"
"autoRefresh": "每 {seconds} 秒自動刷新",
"incidentCount": "{count, plural, one {# 個事件} other {# 個事件}}"
},
"navSection": {
"aiCore": "AI 核心",
"monitoring": "監控與安全",
"ops": "運維管理",
"knowledge": "知識與工具"
},
"sidebar": {
"expand": "展開側欄",
"collapse": "收合側欄"
},
"settings": {
"title": "系統設定",
"appearance": "外觀",
"appearanceDesc": "主題、字體、密度",
"appearanceSettings": "外觀設定",
"language": "語言",
"languageDesc": "介面語言設定",
"languageSettings": "語言設定",
"notify": "通知",
"notifyDesc": "Telegram / 瀏覽器通知偏好",
"notifySettings": "通知設定",
"system": "系統資訊",
"systemDesc": "版本與 API 端點",
"systemSettings": "系統資訊",
"compactMode": "緊湊模式",
"compactModeDesc": "減少各元件間距,顯示更多資訊",
"designSystem": "設計系統",
"designSystemValue": "Nothing.tech 純白工業風(固定)",
"themeColor": "主題色",
"themeColorValue": "OpenClaw Blue + 橘紅 Accent固定",
"browserNotify": "瀏覽器推播通知",
"browserNotifyDesc": "新 Incident 時顯示系統通知",
"p0Only": "僅 P0 CRITICAL 通知",
"p0OnlyDesc": "過濾低嚴重度告警,減少噪音",
"telegramNotify": "Telegram 通知",
"telegramNotifyDesc": "由 OpenClaw Bot 推送(需後端設定)",
"backendConfig": "後端設定",
"frontendVersion": "前端版本",
"apiEndpoint": "API 端點",
"notConfigured": "(未設定)",
"phase": "Phase",
"save": "儲存設定",
"saved": "已儲存",
"zhTW": "繁體中文",
"zhTWSub": "Traditional Chinese",
"en": "English",
"enSub": "English (US)"
},
"autoRepair": {
"subtitle": "高品質 Playbook 自動執行 · 風險 ≤ MEDIUM · 成功率 ≥ 95%",
"approvedPlaybooks": "已批准 Playbooks",
"highQualityPlaybooks": "高品質 Playbooks",
"highQualitySub": "成功率 ≥ 95% · 執行 ≥ 10 次",
"totalExecutions": "總執行次數",
"overallSuccessRate": "整體成功率",
"eligible": "✓ 可啟用自動修復",
"notEligible": "尚無高品質 Playbook",
"ready": "自動修復已就緒",
"notReady": "自動修復未就緒",
"readyDesc": "{count} 個高品質 Playbook 可用",
"notReadyDesc": "需要至少 1 個高品質 Playbook成功率 ≥ 95%、執行 ≥ 10 次)",
"incidentEval": "活躍 Incident 評估P1/P2",
"canAutoRepair": "可自動修復",
"notEligibleShort": "不符合條件",
"riskLevel": "風險等級",
"successRate": "成功率",
"execCount": "執行次數",
"decisionReason": "決策原因",
"execSuccess": "執行成功 ({ms}ms)",
"execFailed": "執行失敗: {error}",
"executing": "執行中...",
"execute": "執行修復",
"noEligible": "目前無符合自動修復條件的 Incident"
},
"openclawPanel": {
"patrolling": "[AGENT] patrolling...",
"intercepting": "[SYS] Intercepting anomaly...",
"analyzing": "[SYS] Analyzing blast radius...",
"generating": "[SYS] Generating proposed action...",
"complete": "[SYS] Analysis complete"
},
"knowledgeBase": {
"title": "知識殿堂",
"searchPlaceholder": "搜尋知識條目...",
"allCategories": "全部",
"noResults": "找不到相關知識條目",
"createEntry": "新增條目",
"viewCount": "瀏覽",
"relatedPlaybook": "相關 Playbook",
"relatedIncident": "相關事件",
"approve": "審核通過",
"archive": "封存",
"status": {
"draft": "草稿",
"review": "審核中",
"approved": "已批准",
"archived": "已封存"
},
"type": {
"incident_case": "案例分析",
"runbook": "操作手冊",
"best_practice": "最佳實踐",
"postmortem": "事後分析"
},
"source": {
"ai_extracted": "AI 萃取",
"human": "人工建立"
},
"category": {
"infrastructure": "基礎設施",
"application": "應用層",
"ai_system": "AI 系統",
"security": "安全 / 合規"
},
"filterByType": "篩選類型",
"filterByStatus": "篩選狀態",
"entries": "筆",
"empty": "尚未建立任何知識條目",
"emptyDescription": "知識庫將自動從 Incident 中萃取案例,你也可以手動新增"
}
}

View File

@@ -3,30 +3,394 @@
/**
* Knowledge Base Page - 知識殿堂
* ==============================
* Phase 7.0: 防禦性路由佔位
* Wave 3: i18n 國際化支援 (2026-03-31)
* KB Phase 1: 左側分類導航 + 搜尋 + 知識條目列表
*
* Nothing.tech 空態設計:
* - 畫面正中央極簡文字
* - 等寬字體 + 淺灰色
* 建立時間: 2026-04-02 (台北時區)
* 建立者: Claude Code (Knowledge Base Phase 1)
*
* API: /api/v1/knowledge
*/
import { useState, useEffect, useCallback } from 'react'
import { AppLayout } from '@/components/layout'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import {
Search, BookOpen, FileText, Shield, Cpu,
Server, Eye, Bot, ChevronRight, Plus,
} from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
interface KnowledgeEntry {
id: string
title: string
content: string
entry_type: 'incident_case' | 'runbook' | 'best_practice' | 'postmortem'
category: string
tags: string[]
source: 'ai_extracted' | 'human'
status: 'draft' | 'review' | 'approved' | 'archived'
related_incident_id: string | null
related_playbook_id: string | null
view_count: number
created_by: string | null
created_at: string
updated_at: string
}
interface CategoryCount {
category: string
count: number
}
interface ListResponse {
items: KnowledgeEntry[]
total: number
categories: CategoryCount[]
}
// =============================================================================
// Category Config
// =============================================================================
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
infrastructure: <Server className="w-4 h-4" />,
application: <FileText className="w-4 h-4" />,
ai_system: <Bot className="w-4 h-4" />,
security: <Shield className="w-4 h-4" />,
}
const CATEGORIES = ['infrastructure', 'application', 'ai_system', 'security'] as const
// =============================================================================
// Type Badge Colors
// =============================================================================
const TYPE_COLORS: Record<string, string> = {
incident_case: 'bg-status-warning/10 text-status-warning border-status-warning/20',
runbook: 'bg-claw-blue/10 text-claw-blue border-claw-blue/20',
best_practice: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20',
postmortem: 'bg-status-critical/10 text-status-critical border-status-critical/20',
}
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-nothing-gray-100 text-nothing-gray-500',
review: 'bg-status-warning/10 text-status-warning',
approved: 'bg-status-healthy/10 text-status-healthy',
}
// =============================================================================
// Component
// =============================================================================
export default function KnowledgeBasePage({
params,
}: {
params: { locale: string }
}) {
const t = useTranslations('placeholder')
const t = useTranslations('knowledgeBase')
const tCommon = useTranslations('common')
const [entries, setEntries] = useState<KnowledgeEntry[]>([])
const [total, setTotal] = useState(0)
const [categories, setCategories] = useState<CategoryCount[]>([])
const [loading, setLoading] = useState(true)
// Filters
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [selectedType, setSelectedType] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [selectedEntry, setSelectedEntry] = useState<KnowledgeEntry | null>(null)
const fetchEntries = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (selectedCategory) params.set('category', selectedCategory)
if (selectedType) params.set('entry_type', selectedType)
if (searchQuery) params.set('q', searchQuery)
params.set('limit', '50')
const res = await fetch(`/api/v1/knowledge?${params}`)
if (res.ok) {
const data: ListResponse = await res.json()
setEntries(data.items)
setTotal(data.total)
setCategories(data.categories)
}
} catch (err) {
console.error('Failed to fetch knowledge entries', err)
} finally {
setLoading(false)
}
}, [selectedCategory, selectedType, searchQuery])
useEffect(() => {
fetchEntries()
}, [fetchEntries])
const totalCount = categories.reduce((sum, c) => sum + c.count, 0)
return (
<AppLayout locale={params.locale}>
<div className="flex items-center justify-center min-h-[60vh]">
<p className="font-mono text-sm text-neutral-400 tracking-wider">
{t('knowledgeBase')}
</p>
<div className="flex h-[calc(100vh-64px)]">
{/* 左側分類導航 */}
<aside className="w-52 flex-shrink-0 border-r border-nothing-gray-200/50 bg-white/50 p-3">
<h2 className="font-heading text-xs font-semibold text-tertiary uppercase tracking-widest mb-3 px-2">
{t('title')}
</h2>
{/* All */}
<button
onClick={() => setSelectedCategory(null)}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm font-body transition-colors',
!selectedCategory
? 'bg-claw-blue/8 text-claw-blue font-medium'
: 'text-secondary hover:bg-nothing-gray-100'
)}
>
<BookOpen className="w-4 h-4" />
<span className="flex-1 text-left">{t('allCategories')}</span>
<span className="text-xs text-muted">{totalCount}</span>
</button>
{/* Category items */}
<div className="mt-1 space-y-0.5">
{CATEGORIES.map(cat => {
const count = categories.find(c => c.category === cat)?.count ?? 0
return (
<button
key={cat}
onClick={() => setSelectedCategory(cat === selectedCategory ? null : cat)}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm font-body transition-colors',
selectedCategory === cat
? 'bg-claw-blue/8 text-claw-blue font-medium'
: 'text-secondary hover:bg-nothing-gray-100'
)}
>
{CATEGORY_ICONS[cat]}
<span className="flex-1 text-left">{t(`category.${cat}`)}</span>
<span className="text-xs text-muted">{count}</span>
</button>
)
})}
</div>
</aside>
{/* 主區域 */}
<main className="flex-1 flex flex-col min-w-0">
{/* 頂部搜尋 + 篩選 */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-nothing-gray-200/50">
{/* Search */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
className="w-full pl-8 pr-3 py-1.5 text-sm font-body bg-nothing-gray-50 border border-nothing-gray-200 rounded-md focus:outline-none focus:border-claw-blue/50 transition-colors"
/>
</div>
{/* Type filter */}
<select
value={selectedType ?? ''}
onChange={(e) => setSelectedType(e.target.value || null)}
className="text-xs font-body bg-nothing-gray-50 border border-nothing-gray-200 rounded-md px-2 py-1.5 text-secondary focus:outline-none"
>
<option value="">{t('filterByType')}</option>
{['incident_case', 'runbook', 'best_practice', 'postmortem'].map(tp => (
<option key={tp} value={tp}>{t(`type.${tp}`)}</option>
))}
</select>
{/* Count */}
<span className="text-xs text-muted font-body">
{total} {t('entries')}
</span>
{/* Create button */}
<button className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-medium bg-claw-blue text-white rounded-md hover:bg-claw-blue/90 transition-colors">
<Plus className="w-3.5 h-3.5" />
{t('createEntry')}
</button>
</div>
{/* 結果列表 */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-40">
<span className="text-sm text-muted font-body">{tCommon('loading')}</span>
</div>
) : entries.length === 0 ? (
<div className="flex flex-col items-center justify-center h-60 gap-2">
<BookOpen className="w-10 h-10 text-nothing-gray-300" />
<p className="text-sm text-secondary font-body">{t('empty')}</p>
<p className="text-xs text-muted font-body">{t('emptyDescription')}</p>
</div>
) : (
<div className="divide-y divide-nothing-gray-100">
{entries.map(entry => (
<button
key={entry.id}
onClick={() => setSelectedEntry(entry)}
className={cn(
'w-full text-left px-4 py-3 hover:bg-nothing-gray-50/50 transition-colors',
selectedEntry?.id === entry.id && 'bg-claw-blue/4'
)}
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{/* Title row */}
<div className="flex items-center gap-2 mb-1">
<span className={cn(
'text-[10px] font-body px-1.5 py-0.5 rounded border',
TYPE_COLORS[entry.entry_type] ?? 'bg-nothing-gray-100 text-nothing-gray-500'
)}>
{t(`type.${entry.entry_type}`)}
</span>
<span className={cn(
'text-[10px] font-body px-1.5 py-0.5 rounded',
STATUS_COLORS[entry.status] ?? ''
)}>
{t(`status.${entry.status}`)}
</span>
<h3 className="text-sm font-body font-medium text-primary truncate">
{entry.title}
</h3>
</div>
{/* Meta row */}
<div className="flex items-center gap-2 text-xs text-muted font-body">
<span>{t(`category.${entry.category}`)}</span>
<span>·</span>
<span>{t(`source.${entry.source}`)}</span>
<span>·</span>
<span>{new Date(entry.created_at).toLocaleDateString()}</span>
</div>
{/* Tags */}
{entry.tags.length > 0 && (
<div className="flex gap-1 mt-1.5">
{entry.tags.slice(0, 4).map(tag => (
<span key={tag} className="text-[10px] font-body px-1.5 py-0.5 rounded bg-nothing-gray-100 text-muted">
{tag}
</span>
))}
</div>
)}
</div>
{/* Right: view count + chevron */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-1 text-xs text-muted">
<Eye className="w-3 h-3" />
<span>{entry.view_count}</span>
</div>
<ChevronRight className="w-4 h-4 text-nothing-gray-300" />
</div>
</div>
</button>
))}
</div>
)}
</div>
</main>
{/* 右側詳情 Panel */}
{selectedEntry && (
<aside className="w-96 flex-shrink-0 border-l border-nothing-gray-200/50 bg-white/50 overflow-y-auto">
<div className="p-4">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={cn(
'text-[10px] font-body px-1.5 py-0.5 rounded border',
TYPE_COLORS[selectedEntry.entry_type]
)}>
{t(`type.${selectedEntry.entry_type}`)}
</span>
<span className={cn(
'text-[10px] font-body px-1.5 py-0.5 rounded',
STATUS_COLORS[selectedEntry.status]
)}>
{t(`status.${selectedEntry.status}`)}
</span>
</div>
<h2 className="text-base font-heading font-semibold text-primary">
{selectedEntry.title}
</h2>
</div>
<button
onClick={() => setSelectedEntry(null)}
className="text-muted hover:text-primary text-sm"
>
×
</button>
</div>
{/* Meta */}
<div className="flex items-center gap-3 text-xs text-muted font-body mb-4 pb-3 border-b border-nothing-gray-100">
<span>{t(`category.${selectedEntry.category}`)}</span>
<span>·</span>
<span>{t(`source.${selectedEntry.source}`)}</span>
<span>·</span>
<span><Eye className="w-3 h-3 inline" /> {selectedEntry.view_count} {t('viewCount')}</span>
<span>·</span>
<span>{new Date(selectedEntry.updated_at).toLocaleDateString()}</span>
</div>
{/* Content (Markdown-like rendering) */}
<div className="prose prose-sm prose-neutral max-w-none font-body text-sm text-secondary whitespace-pre-wrap mb-4">
{selectedEntry.content}
</div>
{/* Tags */}
{selectedEntry.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-4">
{selectedEntry.tags.map(tag => (
<span key={tag} className="text-[10px] font-body px-2 py-0.5 rounded-full bg-nothing-gray-100 text-muted">
{tag}
</span>
))}
</div>
)}
{/* Related links */}
{selectedEntry.related_playbook_id && (
<div className="text-xs text-claw-blue font-body mb-2">
📋 {t('relatedPlaybook')}: {selectedEntry.related_playbook_id}
</div>
)}
{selectedEntry.related_incident_id && (
<div className="text-xs text-status-warning font-body mb-4">
{t('relatedIncident')}: {selectedEntry.related_incident_id}
</div>
)}
{/* Actions */}
{selectedEntry.status !== 'approved' && (
<div className="flex gap-2 pt-3 border-t border-nothing-gray-100">
<button className="flex-1 text-xs font-body font-medium py-1.5 rounded bg-status-healthy/10 text-status-healthy hover:bg-status-healthy/20 transition-colors">
{t('approve')}
</button>
<button className="flex-1 text-xs font-body font-medium py-1.5 rounded bg-nothing-gray-100 text-muted hover:bg-nothing-gray-200 transition-colors">
{t('archive')}
</button>
</div>
)}
</div>
</aside>
)}
</div>
</AppLayout>
)