feat: add all application source code
- apps/api: FastAPI backend with Dockerfile - apps/web: Next.js frontend with Dockerfile - apps/sensor: Signal collection agent - packages: shared packages Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
521
apps/web/src/app/[locale]/action-logs/page.tsx
Normal file
521
apps/web/src/app/[locale]/action-logs/page.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Action Log Page - K8s 操作稽核日誌
|
||||
* ==================================
|
||||
* Phase 4: 行動日誌介面
|
||||
*
|
||||
* Features:
|
||||
* - 真實 API 數據 (GET /api/v1/audit-logs)
|
||||
* - 分頁顯示
|
||||
* - 統計概覽
|
||||
* - 操作類型、狀態篩選
|
||||
* - 執行時間、耗時、簽核者資訊
|
||||
*
|
||||
* i18n: 100% next-intl,零硬編碼
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { DataPincerPanel, DataPincerCard } from '@/components/cyber'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Activity,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface AuditLog {
|
||||
id: string
|
||||
approval_id: string
|
||||
operation_type: string
|
||||
target_resource: string
|
||||
namespace: string
|
||||
success: boolean
|
||||
error_message: string | null
|
||||
k8s_response: Record<string, unknown> | null
|
||||
executed_by: string
|
||||
execution_duration_ms: number | null
|
||||
dry_run_passed: boolean
|
||||
dry_run_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface AuditLogListResponse {
|
||||
count: number
|
||||
logs: AuditLog[]
|
||||
page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
interface AuditStats {
|
||||
total_executions: number
|
||||
success_count: number
|
||||
failure_count: number
|
||||
success_rate: number
|
||||
avg_duration_ms: number | null
|
||||
by_operation_type: Record<string, number>
|
||||
by_namespace: Record<string, number>
|
||||
last_24h_count: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Helper
|
||||
// =============================================================================
|
||||
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
// 統帥鐵律: 禁止任何 Fallback IP
|
||||
const url = process.env.NEXT_PUBLIC_API_URL
|
||||
if (!url) {
|
||||
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL')
|
||||
return ''
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Stat Card Component
|
||||
// =============================================================================
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
variant = 'default',
|
||||
}: {
|
||||
icon: typeof Activity
|
||||
label: string
|
||||
value: string | number
|
||||
subValue?: string
|
||||
variant?: 'default' | 'success' | 'warning'
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4',
|
||||
'bg-white/50 backdrop-blur-sm',
|
||||
variant === 'success' && 'border-status-healthy/30',
|
||||
variant === 'warning' && 'border-status-warning/30',
|
||||
variant === 'default' && 'border-nothing-gray-200'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center',
|
||||
variant === 'success' && 'bg-status-healthy/10',
|
||||
variant === 'warning' && 'bg-status-warning/10',
|
||||
variant === 'default' && 'bg-nothing-gray-100'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-5 h-5',
|
||||
variant === 'success' && 'text-status-healthy',
|
||||
variant === 'warning' && 'text-status-warning',
|
||||
variant === 'default' && 'text-nothing-gray-600'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-nothing-gray-500 font-mono uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-xl font-bold text-nothing-black">{value}</p>
|
||||
{subValue && (
|
||||
<p className="text-[10px] text-nothing-gray-400 font-mono">
|
||||
{subValue}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export default function ActionLogPage({
|
||||
params,
|
||||
}: {
|
||||
params: { locale: string }
|
||||
}) {
|
||||
const t = useTranslations()
|
||||
const locale = params.locale
|
||||
|
||||
// State
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [stats, setStats] = useState<AuditStats | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
|
||||
// ==========================================================================
|
||||
// Fetch Audit Logs
|
||||
// ==========================================================================
|
||||
const fetchLogs = useCallback(async (pageNum: number) => {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
if (!apiBaseUrl) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl}/api/v1/audit-logs?page=${pageNum}&page_size=10`,
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`)
|
||||
}
|
||||
|
||||
const data: AuditLogListResponse = await response.json()
|
||||
setLogs(data.logs)
|
||||
setPage(data.page)
|
||||
setTotalPages(data.total_pages)
|
||||
setTotalCount(data.count)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
setError(message)
|
||||
console.error('[ActionLog] Fetch error:', message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ==========================================================================
|
||||
// Fetch Stats
|
||||
// ==========================================================================
|
||||
const fetchStats = useCallback(async () => {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
if (!apiBaseUrl) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/audit-logs/stats`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data: AuditStats = await response.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ActionLog] Stats fetch error:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ==========================================================================
|
||||
// Initial Fetch
|
||||
// ==========================================================================
|
||||
useEffect(() => {
|
||||
fetchLogs(1)
|
||||
fetchStats()
|
||||
}, [fetchLogs, fetchStats])
|
||||
|
||||
// ==========================================================================
|
||||
// Pagination Handlers
|
||||
// ==========================================================================
|
||||
const handlePrevPage = () => {
|
||||
if (page > 1) {
|
||||
fetchLogs(page - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (page < totalPages) {
|
||||
fetchLogs(page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Format Helpers
|
||||
// ==========================================================================
|
||||
const formatDate = (isoString: string) => {
|
||||
try {
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleString(locale === 'zh-TW' ? 'zh-TW' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return isoString
|
||||
}
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number | null) => {
|
||||
if (ms === null) return '-'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Render
|
||||
// ==========================================================================
|
||||
return (
|
||||
<AppLayout locale={locale}>
|
||||
{/* Page Title */}
|
||||
<div className="mb-6">
|
||||
<h2 className="font-heading text-2xl font-bold text-nothing-black">
|
||||
{t('actionLog.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-nothing-gray-500">
|
||||
{t('actionLog.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
{stats && (
|
||||
<div className="mb-6 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label={t('actionLog.stats.total')}
|
||||
value={stats.total_executions}
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
label={t('actionLog.stats.successRate')}
|
||||
value={`${stats.success_rate}%`}
|
||||
variant="success"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label={t('actionLog.stats.avgDuration')}
|
||||
value={stats.avg_duration_ms ? `${Math.round(stats.avg_duration_ms)}ms` : '-'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Zap}
|
||||
label={t('actionLog.stats.last24h')}
|
||||
value={stats.last_24h_count}
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<DataPincerPanel title={t('actionLog.title')} status="healthy">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<div className="text-sm text-nothing-gray-500 font-mono">
|
||||
{totalCount > 0
|
||||
? `${totalCount} ${t('actionLog.columns.operation').toLowerCase()}s`
|
||||
: ''}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
fetchLogs(page)
|
||||
fetchStats()
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-mono',
|
||||
'bg-nothing-gray-100 text-nothing-gray-600',
|
||||
'hover:bg-nothing-gray-200 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('w-3 h-3', isLoading && 'animate-spin')}
|
||||
/>
|
||||
{t('common.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-4 mb-4 rounded-lg bg-status-critical/10 border border-status-critical/20">
|
||||
<AlertCircle className="w-4 h-4 text-status-critical" />
|
||||
<span className="text-sm text-status-critical font-mono">
|
||||
{t('actionLog.fetchError')}: {error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && logs.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-6 h-6 text-nothing-gray-400 animate-spin" />
|
||||
<span className="ml-2 text-nothing-gray-400 font-mono">
|
||||
{t('actionLog.loading')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && logs.length === 0 && !error && (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-nothing-gray-300" />
|
||||
<p className="text-nothing-gray-400 font-mono">
|
||||
{t('actionLog.noLogs')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs Table */}
|
||||
{logs.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-nothing-gray-200">
|
||||
<th className="px-3 py-2 text-left font-mono text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.time')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-mono text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.operation')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-mono text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.target')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-mono text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.namespace')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-mono text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.status')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-mono text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.duration')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-mono text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.executor')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr
|
||||
key={log.id}
|
||||
className="border-b border-nothing-gray-100 hover:bg-nothing-gray-50/50 transition-colors"
|
||||
>
|
||||
<td className="px-3 py-3 font-mono text-xs text-nothing-gray-600">
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-[10px] font-mono font-bold uppercase',
|
||||
log.operation_type === 'DELETE_POD' &&
|
||||
'bg-status-critical/10 text-status-critical',
|
||||
log.operation_type === 'RESTART_DEPLOYMENT' &&
|
||||
'bg-status-warning/10 text-status-warning',
|
||||
log.operation_type === 'SCALE_DEPLOYMENT' &&
|
||||
'bg-claw-blue/10 text-claw-blue'
|
||||
)}
|
||||
>
|
||||
{t(`actionLog.operations.${log.operation_type}` as never) ||
|
||||
log.operation_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3 font-mono text-xs text-nothing-black max-w-[200px] truncate">
|
||||
{log.target_resource}
|
||||
</td>
|
||||
<td className="px-3 py-3 font-mono text-xs text-nothing-gray-600">
|
||||
{log.namespace}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
{log.success ? (
|
||||
<span className="inline-flex items-center gap-1 text-status-healthy">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-[10px] font-mono uppercase">
|
||||
{t('actionLog.status.success')}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-status-critical">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-[10px] font-mono uppercase">
|
||||
{t('actionLog.status.failure')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right font-mono text-xs text-nothing-gray-600">
|
||||
{formatDuration(log.execution_duration_ms)}
|
||||
</td>
|
||||
<td className="px-3 py-3 font-mono text-xs text-nothing-gray-600">
|
||||
{log.executed_by}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 px-1">
|
||||
<span className="text-xs text-nothing-gray-500 font-mono">
|
||||
{t('actionLog.pagination.page', {
|
||||
current: page,
|
||||
total: totalPages,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={page <= 1 || isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-mono',
|
||||
'border border-nothing-gray-200',
|
||||
'hover:bg-nothing-gray-50 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3" />
|
||||
{t('actionLog.pagination.prev')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={page >= totalPages || isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-mono',
|
||||
'border border-nothing-gray-200',
|
||||
'hover:bg-nothing-gray-50 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{t('actionLog.pagination.next')}
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DataPincerPanel>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-12 pt-6 border-t border-nothing-gray-200">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-nothing-gray-500">
|
||||
{t('footer.copyright')}
|
||||
</p>
|
||||
<p className="text-sm text-nothing-gray-400 font-mono">
|
||||
{t('footer.poweredBy')} v1.0.0
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
179
apps/web/src/app/[locale]/demo/page.tsx
Normal file
179
apps/web/src/app/[locale]/demo/page.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Demo Page - 戰情室
|
||||
* ==================
|
||||
* Phase 1: 視覺靈魂注入 - Lab-White Style
|
||||
*
|
||||
* - NemoClaw 3D 陶瓷機械爪視覺化
|
||||
* - VT323 點陣字體品牌識別
|
||||
* - LiveDashboard: SSE 串流真實主機數據
|
||||
* - HITLSection: AI 思考流 → 動態卡片對接
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { LiveDashboard } from '@/components/dashboard/live-dashboard'
|
||||
import { HITLSection } from '@/components/ai'
|
||||
|
||||
// =============================================================================
|
||||
// API Configuration (統帥鐵律: 禁止任何 Fallback IP)
|
||||
// =============================================================================
|
||||
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
const url = process.env.NEXT_PUBLIC_API_URL
|
||||
if (!url) {
|
||||
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL')
|
||||
return ''
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl()
|
||||
|
||||
// =============================================================================
|
||||
// Create Test Approval (i18n aware)
|
||||
// =============================================================================
|
||||
|
||||
interface CreateApprovalConfig {
|
||||
action: string
|
||||
description: string
|
||||
data_impact: string
|
||||
rbacLabel: string
|
||||
syntaxLabel: string
|
||||
backupLabel: string
|
||||
backupMessage: string
|
||||
okMessage: string
|
||||
}
|
||||
|
||||
async function createTestApprovalWithConfig(
|
||||
riskLevel: 'low' | 'medium' | 'critical',
|
||||
config: CreateApprovalConfig
|
||||
) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/approvals`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: config.action,
|
||||
description: config.description,
|
||||
risk_level: riskLevel,
|
||||
blast_radius: {
|
||||
affected_pods: riskLevel === 'critical' ? 0 : 3,
|
||||
estimated_downtime: riskLevel === 'low' ? '0' : '~2 min',
|
||||
related_services: ['auth-service', 'api-gateway', 'user-service'],
|
||||
data_impact: config.data_impact,
|
||||
},
|
||||
dry_run_checks: [
|
||||
{ name: config.rbacLabel, passed: true, message: 'cluster-admin' },
|
||||
{ name: config.syntaxLabel, passed: true },
|
||||
{ name: config.backupLabel, passed: riskLevel !== 'critical', message: riskLevel === 'critical' ? config.backupMessage : config.okMessage },
|
||||
],
|
||||
requested_by: 'OpenClaw',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Demo Page
|
||||
// =============================================================================
|
||||
|
||||
export default function DemoPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('demo')
|
||||
const tApproval = useTranslations('approval')
|
||||
const tMock = useTranslations('mockData')
|
||||
const tDryRun = useTranslations('dryRun')
|
||||
const locale = params.locale
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
// i18n-aware approval creation configs
|
||||
const approvalConfigs = {
|
||||
low: {
|
||||
action: tMock('testActions.lowAction'),
|
||||
description: tMock('testActions.lowDesc'),
|
||||
data_impact: 'none',
|
||||
rbacLabel: tDryRun('rbac'),
|
||||
syntaxLabel: tDryRun('syntax'),
|
||||
backupLabel: tDryRun('backupAvailable'),
|
||||
backupMessage: tDryRun('noRecentBackup'),
|
||||
okMessage: tDryRun('ok'),
|
||||
},
|
||||
medium: {
|
||||
action: tMock('testActions.mediumAction'),
|
||||
description: tMock('testActions.mediumDesc'),
|
||||
data_impact: 'none',
|
||||
rbacLabel: tDryRun('rbac'),
|
||||
syntaxLabel: tDryRun('syntax'),
|
||||
backupLabel: tDryRun('backupAvailable'),
|
||||
backupMessage: tDryRun('noRecentBackup'),
|
||||
okMessage: tDryRun('ok'),
|
||||
},
|
||||
critical: {
|
||||
action: tMock('testActions.criticalAction'),
|
||||
description: tMock('testActions.criticalDesc'),
|
||||
data_impact: 'destructive',
|
||||
rbacLabel: tDryRun('rbac'),
|
||||
syntaxLabel: tDryRun('syntax'),
|
||||
backupLabel: tDryRun('backupAvailable'),
|
||||
backupMessage: tDryRun('noRecentBackup'),
|
||||
okMessage: tDryRun('ok'),
|
||||
},
|
||||
}
|
||||
|
||||
const handleCreateApproval = useCallback(async (riskLevel: 'low' | 'medium' | 'critical') => {
|
||||
setIsCreating(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
const result = await createTestApprovalWithConfig(riskLevel, approvalConfigs[riskLevel])
|
||||
console.log('[Demo] Created approval:', result)
|
||||
} catch (err) {
|
||||
setCreateError(`${tApproval('fetchError')}: ${err}`)
|
||||
console.error('[Demo] Create approval failed:', err)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}, [approvalConfigs, tApproval])
|
||||
|
||||
return (
|
||||
<AppLayout locale={locale}>
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Page Title - Dot Matrix Style */}
|
||||
<header className="text-center py-6">
|
||||
<h1 className="font-dot-matrix text-4xl text-nothing-gray-800 tracking-wider mb-2">
|
||||
AWOOOI
|
||||
</h1>
|
||||
<p className="font-dot-matrix text-lg text-claw-blue">
|
||||
AI Sees. AI Acts. You Approve.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Live Dashboard - Real SSE Data */}
|
||||
<section>
|
||||
<h2 className="font-dot-matrix text-2xl text-nothing-gray-700 mb-4 tracking-wide">
|
||||
{t('liveDashboard')}
|
||||
</h2>
|
||||
<LiveDashboard locale={locale} />
|
||||
</section>
|
||||
|
||||
{/* HITL Section - NemoClaw + Approval Cards */}
|
||||
<HITLSection locale={locale} />
|
||||
|
||||
{/* Error Display */}
|
||||
{createError && (
|
||||
<div className="p-3 bg-status-critical/10 border border-status-critical/30 rounded-lg">
|
||||
<p className="text-sm text-status-critical font-mono">{createError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
71
apps/web/src/app/[locale]/layout.tsx
Normal file
71
apps/web/src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter, JetBrains_Mono, VT323 } from 'next/font/google'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { getMessages } from 'next-intl/server'
|
||||
import { routing, type Locale } from '@/i18n/routing'
|
||||
import '../globals.css'
|
||||
import { Providers } from '../providers'
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-inter',
|
||||
})
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
})
|
||||
|
||||
// VT323 點陣字體 - 品牌與 AI 狀態專用
|
||||
const vt323 = VT323({
|
||||
weight: '400',
|
||||
subsets: ['latin'],
|
||||
variable: '--font-dot-matrix',
|
||||
})
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: Locale }
|
||||
}): Promise<Metadata> {
|
||||
const messages = await getMessages()
|
||||
const metadata = messages.metadata as { title: string; description: string }
|
||||
|
||||
return {
|
||||
title: metadata?.title || 'AWOOOI',
|
||||
description: metadata?.description || 'AI-Powered Intelligent Operations Platform',
|
||||
}
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params: { locale },
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
params: { locale: string }
|
||||
}) {
|
||||
// 驗證語系
|
||||
if (!routing.locales.includes(locale as Locale)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// 取得翻譯訊息
|
||||
const messages = await getMessages()
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body
|
||||
className={`${inter.variable} ${jetbrainsMono.variable} ${vt323.variable} font-body bg-nothing-gray-50 text-nothing-black antialiased`}
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Providers>{children}</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
181
apps/web/src/app/[locale]/page.tsx
Normal file
181
apps/web/src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AWOOOI 全局戰情室 (Global War Room)
|
||||
* ====================================
|
||||
* Phase 2: 完整 AppLayout 整合
|
||||
*
|
||||
* 佈局結構:
|
||||
* - 左側側邊欄: 導航選單
|
||||
* - 主內容: 70/30 Grid (系統狀態 + AI 面板)
|
||||
*
|
||||
* 視覺規範:
|
||||
* - awoooi-glass 白玻璃毛玻璃
|
||||
* - DataPincer 數據鉗容器
|
||||
* - 點陣紋理背景
|
||||
*
|
||||
* i18n: 100% next-intl,零硬編碼
|
||||
*/
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { LiveDashboard } from '@/components/dashboard/live-dashboard'
|
||||
import { DataPincerCard, DataPincerPanel } from '@/components/cyber'
|
||||
import { OpenClawStateMachine } from '@/components/ai/openclaw-state-machine'
|
||||
import { GlobalPulseChart } from '@/components/charts/global-pulse-chart'
|
||||
import { useGlobalPulseMetrics } from '@/hooks/useGlobalPulseMetrics'
|
||||
import { useIncidents } from '@/hooks/useIncidents'
|
||||
import { IncidentCard, IncidentCardGrid, IncidentEmptyState, ThinkingTerminal, DEMO_DECISION_CHAIN } from '@/components/incident'
|
||||
import { Activity, AlertTriangle } from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// Main Page
|
||||
// =============================================================================
|
||||
|
||||
export default function Home({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations()
|
||||
const locale = params.locale
|
||||
|
||||
// 統帥鐵律: 使用真實數據 Hook,禁止假數據!
|
||||
const { metrics: pulseMetrics, isLoading: isPulseLoading, error: pulseError } = useGlobalPulseMetrics({
|
||||
pollInterval: 30000, // 30 秒輪詢
|
||||
enablePolling: true,
|
||||
})
|
||||
|
||||
// Phase 7: 真實 Incident 數據
|
||||
const {
|
||||
incidents,
|
||||
pendingApprovals,
|
||||
isLoading: isIncidentsLoading,
|
||||
error: incidentsError,
|
||||
} = useIncidents({
|
||||
pollInterval: 15000, // 15 秒輪詢
|
||||
enablePolling: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<AppLayout locale={locale}>
|
||||
{/* Page Title */}
|
||||
<div className="mb-6">
|
||||
<h2 className="font-heading text-2xl font-bold text-nothing-black">
|
||||
{t('dashboard.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-nothing-gray-500">
|
||||
{t('dashboard.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Grid: 左側 70% | 右側 30% */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_380px] gap-6">
|
||||
|
||||
{/* =========================================================== */}
|
||||
{/* Left Column (70%): 系統脈搏 + 系統狀態 + 事件流 */}
|
||||
{/* =========================================================== */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Global Pulse Chart - 系統心跳 (真實血脈) */}
|
||||
<DataPincerPanel
|
||||
title={t('dashboard.globalPulse')}
|
||||
status={pulseError ? 'critical' : 'healthy'}
|
||||
>
|
||||
{isPulseLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-5 h-5 border-2 border-nothing-gray-300 border-t-claw-blue rounded-full animate-spin" />
|
||||
<span className="ml-3 font-mono text-sm text-nothing-gray-400">
|
||||
{t('dashboard.loadingMetrics')}
|
||||
</span>
|
||||
</div>
|
||||
) : pulseError ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-status-critical">
|
||||
<span className="font-mono text-sm">{t('dashboard.metricsError')}</span>
|
||||
<span className="font-mono text-xs text-nothing-gray-400 mt-1">{pulseError}</span>
|
||||
</div>
|
||||
) : (
|
||||
<GlobalPulseChart metrics={pulseMetrics} />
|
||||
)}
|
||||
</DataPincerPanel>
|
||||
|
||||
{/* System Status Section */}
|
||||
<DataPincerPanel
|
||||
title={t('dashboard.systemStatus')}
|
||||
status="healthy"
|
||||
>
|
||||
<LiveDashboard locale={locale} />
|
||||
</DataPincerPanel>
|
||||
|
||||
{/* Active Incidents Section (Phase 7: 真實血脈) */}
|
||||
<DataPincerPanel
|
||||
title={t('incident.activeIncidents')}
|
||||
status={incidents.length > 0 ? 'critical' : 'healthy'}
|
||||
>
|
||||
{isIncidentsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-5 h-5 border-2 border-nothing-gray-300 border-t-claw-blue rounded-full animate-spin" />
|
||||
<span className="ml-3 font-mono text-sm text-nothing-gray-400">
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
</div>
|
||||
) : incidentsError ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-status-critical">
|
||||
<AlertTriangle className="w-5 h-5 mb-2" />
|
||||
<span className="font-mono text-sm">{incidentsError}</span>
|
||||
</div>
|
||||
) : incidents.length === 0 ? (
|
||||
<IncidentEmptyState />
|
||||
) : (
|
||||
<IncidentCardGrid>
|
||||
{incidents.map((incident) => (
|
||||
<IncidentCard
|
||||
key={incident.incident_id}
|
||||
incident={incident}
|
||||
/>
|
||||
))}
|
||||
</IncidentCardGrid>
|
||||
)}
|
||||
</DataPincerPanel>
|
||||
|
||||
{/* OpenClaw Thinking Terminal (Phase 7: 決策鏈視覺化) */}
|
||||
<DataPincerPanel
|
||||
title="OpenClaw Terminal"
|
||||
status="thinking"
|
||||
>
|
||||
<ThinkingTerminal
|
||||
decisionChain={incidents.length > 0 ? DEMO_DECISION_CHAIN : null}
|
||||
incidentId={incidents.length > 0 ? incidents[0].incident_id : undefined}
|
||||
autoPlay={incidents.length > 0}
|
||||
maxHeight="300px"
|
||||
/>
|
||||
</DataPincerPanel>
|
||||
|
||||
</div>
|
||||
|
||||
{/* =========================================================== */}
|
||||
{/* Right Column (30%): AI 狀態機 (OpenClaw + ThinkingStream + ApprovalCard) */}
|
||||
{/* =========================================================== */}
|
||||
<div className="lg:sticky lg:top-20 lg:self-start">
|
||||
<DataPincerPanel
|
||||
title={t('dashboard.aiAgent')}
|
||||
status="thinking"
|
||||
>
|
||||
<OpenClawStateMachine demoMode={false} />
|
||||
</DataPincerPanel>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* =========================================================== */}
|
||||
{/* Footer */}
|
||||
{/* =========================================================== */}
|
||||
<footer className="mt-12 pt-6 border-t border-nothing-gray-200">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-nothing-gray-500">
|
||||
{t('footer.copyright')}
|
||||
</p>
|
||||
<p className="text-sm text-nothing-gray-400 font-mono">
|
||||
{t('footer.poweredBy')} v1.0.0
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
281
apps/web/src/app/globals.css
Normal file
281
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,281 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ==================== CSS Properties for Animations ==================== */
|
||||
/* Border Beam Animation Variable */
|
||||
@property --start {
|
||||
syntax: '<number>';
|
||||
initial-value: 0;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
/* Shimmer Position */
|
||||
@property --shimmer-position {
|
||||
syntax: '<percentage>';
|
||||
initial-value: 0%;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-dot-matrix: 'DSEG7-Classic', 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ==================== Nothing Dot Matrix Font ==================== */
|
||||
/* DSEG7-Classic - Digital 7-Segment Display Font */
|
||||
@font-face {
|
||||
font-family: 'DSEG7-Classic';
|
||||
src: url('/fonts/DSEG7Classic-Bold.woff2') format('woff2'),
|
||||
url('/fonts/DSEG7Classic-Bold.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'DSEG7-Classic';
|
||||
src: url('/fonts/DSEG7Classic-Regular.woff2') format('woff2'),
|
||||
url('/fonts/DSEG7Classic-Regular.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ==================== Nothing.tech Light Theme Base ==================== */
|
||||
@layer base {
|
||||
html {
|
||||
@apply antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-nothing-gray-50 text-nothing-gray-900;
|
||||
font-feature-settings: 'liga' 1, 'calt' 1;
|
||||
}
|
||||
|
||||
/* 點陣紋理背景 (Dot Matrix Pattern) */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background-image: radial-gradient(
|
||||
circle at center,
|
||||
rgba(0, 0, 0, 0.04) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
/* 主內容層級 */
|
||||
#__next,
|
||||
main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 滾動條 - 極簡風格 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-nothing-gray-300 rounded-full;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-nothing-gray-400;
|
||||
}
|
||||
|
||||
/* 選取樣式 */
|
||||
::selection {
|
||||
@apply bg-nothing-gray-900 text-nothing-white;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Glass Components ==================== */
|
||||
@layer components {
|
||||
/* 主要玻璃卡片 */
|
||||
.glass-card {
|
||||
@apply bg-white/70 backdrop-blur-glass border border-black/[0.08] rounded-glass shadow-glass;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
@apply bg-white/80 shadow-glass-hover border-black/[0.12];
|
||||
}
|
||||
|
||||
/* 頂部導航 */
|
||||
.glass-header {
|
||||
@apply bg-white/85 backdrop-blur-[20px] border-b border-black/[0.06];
|
||||
}
|
||||
|
||||
/* AI Copilot 面板 */
|
||||
.glass-copilot {
|
||||
@apply bg-nothing-gray-50/90 backdrop-blur-[20px] border border-status-thinking/20 rounded-glass;
|
||||
box-shadow: 0 4px 24px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 狀態指示點 */
|
||||
.status-dot {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
|
||||
.status-dot-healthy {
|
||||
@apply status-dot bg-status-healthy;
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.status-dot-syncing {
|
||||
@apply status-dot bg-status-syncing animate-pulse;
|
||||
box-shadow: 0 0 8px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.status-dot-warning {
|
||||
@apply status-dot bg-status-warning;
|
||||
box-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.status-dot-critical {
|
||||
@apply status-dot bg-status-critical animate-breathe;
|
||||
box-shadow: 0 0 8px rgba(255, 51, 0, 0.5);
|
||||
}
|
||||
|
||||
.status-dot-idle {
|
||||
@apply status-dot bg-status-idle;
|
||||
}
|
||||
|
||||
.status-dot-thinking {
|
||||
@apply status-dot bg-status-thinking animate-breathe;
|
||||
box-shadow: 0 0 8px rgba(139, 92, 246, 0.5);
|
||||
}
|
||||
|
||||
/* 節點標籤 */
|
||||
.node-badge {
|
||||
@apply inline-flex items-center gap-1.5 px-2 py-0.5 rounded-button;
|
||||
@apply bg-nothing-gray-100 text-nothing-gray-700 font-mono text-xs;
|
||||
}
|
||||
|
||||
/* 服務列表項 */
|
||||
.service-item {
|
||||
@apply flex items-center justify-between py-2 px-3 rounded-button;
|
||||
@apply hover:bg-nothing-gray-100/50 transition-colors;
|
||||
}
|
||||
|
||||
/* 按鈕 - 主要 */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-button;
|
||||
@apply bg-nothing-gray-900 text-nothing-white font-medium text-sm;
|
||||
@apply hover:bg-nothing-gray-800 transition-colors;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-nothing-gray-400 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
/* 按鈕 - 次要 */
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-button;
|
||||
@apply bg-nothing-gray-100 text-nothing-gray-800 font-medium text-sm;
|
||||
@apply hover:bg-nothing-gray-200 transition-colors;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-nothing-gray-300 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
/* 按鈕 - 警告 */
|
||||
.btn-warning {
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-button;
|
||||
@apply bg-status-critical/10 text-status-critical font-medium text-sm;
|
||||
@apply hover:bg-status-critical/20 transition-colors;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-status-critical/30 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
/* AI 思考指示器 */
|
||||
.ai-indicator {
|
||||
@apply animate-breathe;
|
||||
}
|
||||
|
||||
/* 掃描線動畫 */
|
||||
.scan-line {
|
||||
@apply absolute inset-0 overflow-hidden;
|
||||
}
|
||||
|
||||
.scan-line::after {
|
||||
content: '';
|
||||
@apply absolute inset-y-0 w-1/3;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(139, 92, 246, 0.1),
|
||||
transparent
|
||||
);
|
||||
animation: scan 2s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Utilities ==================== */
|
||||
@layer utilities {
|
||||
/* Nothing.tech 高對比墨水色 (禁止 muted-foreground) */
|
||||
.text-ink {
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.text-ink-secondary {
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
/* Dot Matrix Display Numbers (靈魂注入) */
|
||||
.font-dot-matrix {
|
||||
font-family: var(--font-dot-matrix);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* 巨型數字顯示 (2x size) */
|
||||
.dot-matrix-display {
|
||||
font-family: var(--font-dot-matrix);
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #111111;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.dot-matrix-display-sm {
|
||||
font-family: var(--font-dot-matrix);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
color: #111111;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* 文字漸層 */
|
||||
.text-gradient {
|
||||
@apply bg-clip-text text-transparent;
|
||||
background-image: linear-gradient(135deg, #111111 0%, #525252 100%);
|
||||
}
|
||||
|
||||
/* 網格背景 (替代方案) */
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.03) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
/* 隱藏滾動條但保留功能 */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
28
apps/web/src/app/providers.tsx
Normal file
28
apps/web/src/app/providers.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { ToastProvider, ToastInitializer } from '@/components/ui/toast'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<ToastInitializer />
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user