feat(web): Knowledge Base 前端頁面 + i18n
- 左側分類導航 (基礎設施/應用層/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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 中萃取案例,你也可以手動新增"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user