diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dbd890b..abd53dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,11 +5,14 @@ import { CompliancePage } from './pages/Compliance'; import { DocsPage } from './pages/Docs'; import { StatusPage } from './pages/Status'; import { RagChatPage } from './pages/RagChat'; +import { PerceptionPage } from './pages/Perception'; const PageContent = () => { const { activeTab } = useApp(); switch (activeTab) { + case 'perception': + return ; case 'docs': return ; case 'compliance': @@ -19,7 +22,7 @@ const PageContent = () => { case 'rag': return ; default: - return ; + return ; } }; diff --git a/frontend/src/api/perception.ts b/frontend/src/api/perception.ts new file mode 100644 index 0000000..800f43f --- /dev/null +++ b/frontend/src/api/perception.ts @@ -0,0 +1,128 @@ +const PERCEPTION_API_BASE = '/api/v1'; + +export type ImpactLevel = 'high' | 'medium' | 'low'; +export type EventStatus = 'enacted' | 'draft' | 'consultation'; +export type EventSource = 'MIIT' | 'UN-ECE' | 'ISO' | '国标委' | 'EUR-Lex' | 'IATF'; + +export interface RegulationEvent { + id: string; + source: EventSource; + source_label: string; + standard_code: string; + title: string; + summary: string; + impact_level: ImpactLevel; + published_at: string; + effective_at: string | null; + category: string; + tags: string[]; + source_url: string; + status: EventStatus; +} + +export interface PerceptionStats { + total: number; + high_impact: number; + medium_impact: number; + low_impact: number; + recent_90d: number; +} + +export interface EventListResponse { + events: RegulationEvent[]; + total: number; +} + +export interface AffectedDoc { + doc_id: string; + doc_name: string; + score: number; + snippet: string; + clause: string; +} + +export interface AnalysisSSEMessage { + type: 'sources' | 'content' | 'done' | 'error'; + docs?: AffectedDoc[]; + text?: string; +} + +export async function getPerceptionStats(): Promise { + const res = await fetch(`${PERCEPTION_API_BASE}/perception/stats`); + if (!res.ok) throw new Error(`stats failed: ${res.status}`); + return res.json() as Promise; +} + +export async function listEvents(params?: { + source?: string; + impact_level?: string; + limit?: number; +}): Promise { + const query = new URLSearchParams(); + if (params?.source) query.set('source', params.source); + if (params?.impact_level) query.set('impact_level', params.impact_level); + if (params?.limit) query.set('limit', String(params.limit)); + const res = await fetch(`${PERCEPTION_API_BASE}/perception/events?${query.toString()}`); + if (!res.ok) throw new Error(`list events failed: ${res.status}`); + return res.json() as Promise; +} + +export async function analyzeEvent( + eventId: string, + onMessage: (msg: AnalysisSSEMessage) => void, + onComplete?: () => void, + signal?: AbortSignal, +): Promise { + try { + const res = await fetch(`${PERCEPTION_API_BASE}/perception/events/${eventId}/analyze`, { + method: 'POST', + headers: { Accept: 'text/event-stream' }, + signal, + }); + if (!res.ok || !res.body) throw new Error(`analyze failed: ${res.status}`); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split('\n\n'); + buffer = parts.pop() ?? ''; + for (const block of parts) { + if (!block.trim()) continue; + let eventName = 'message'; + const dataLines: string[] = []; + for (const line of block.split('\n')) { + if (line.startsWith('event:')) eventName = line.slice(6).trim(); + else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()); + } + const payload = dataLines.join('\n'); + if (!payload) continue; + + if (eventName === 'sources') { + try { + const docs = JSON.parse(payload) as AffectedDoc[]; + onMessage({ type: 'sources', docs }); + } catch { /* ignore */ } + } else if (eventName === 'content') { + onMessage({ type: 'content', text: payload }); + } else if (eventName === 'done') { + onMessage({ type: 'done' }); + } else if (eventName === 'error') { + onMessage({ type: 'error', text: payload }); + } + } + } + if (buffer.trim()) { + // flush remaining + } + onComplete?.(); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + onMessage({ type: 'error', text: err instanceof Error ? err.message : String(err) }); + onComplete?.(); + } +} diff --git a/frontend/src/components/layout/Tabs.tsx b/frontend/src/components/layout/Tabs.tsx index a62fc32..e2edc1f 100644 --- a/frontend/src/components/layout/Tabs.tsx +++ b/frontend/src/components/layout/Tabs.tsx @@ -3,6 +3,7 @@ import { useTheme, useApp } from '../../contexts'; import type { TabId } from '../../contexts'; const tabs: Array<{ id: TabId; label: string }> = [ + { id: 'perception', label: '智能感知' }, { id: 'docs', label: '文档管理' }, { id: 'compliance', label: '合规分析' }, { id: 'status', label: '系统状态' }, diff --git a/frontend/src/contexts/app-context.ts b/frontend/src/contexts/app-context.ts index 68d31da..28d7b0c 100644 --- a/frontend/src/contexts/app-context.ts +++ b/frontend/src/contexts/app-context.ts @@ -1,6 +1,6 @@ import { createContext } from 'react'; -export type TabId = 'docs' | 'compliance' | 'status' | 'rag'; +export type TabId = 'perception' | 'docs' | 'compliance' | 'status' | 'rag'; export interface AppContextValue { activeTab: TabId; diff --git a/frontend/src/pages/Perception/AnalysisPanel.tsx b/frontend/src/pages/Perception/AnalysisPanel.tsx new file mode 100644 index 0000000..a6fa528 --- /dev/null +++ b/frontend/src/pages/Perception/AnalysisPanel.tsx @@ -0,0 +1,207 @@ +import React, { useRef } from 'react'; +import { useTheme } from '../../contexts'; +import type { RegulationEvent, AffectedDoc } from '../../api/perception'; + +interface AnalysisPanelProps { + event: RegulationEvent | null; + analyzing: boolean; + analysisText: string; + affectedDocs: AffectedDoc[]; + onAnalyze: () => void; + onAbort: () => void; +} + +// Minimal markdown renderer — handles ##/### headings, **bold**, bullet lists +function MarkdownText({ text, textColor, accent }: { text: string; textColor: string; accent: string }) { + const lines = text.split('\n'); + return ( +
+ {lines.map((line, i) => { + if (line.startsWith('## ')) { + return
{line.slice(3)}
; + } + if (line.startsWith('### ')) { + return
{line.slice(4)}
; + } + if (line.startsWith('- ') || line.startsWith('* ')) { + const content = line.slice(2); + return ( +
+ · + $1') }} /> +
+ ); + } + if (/^\d+\./.test(line)) { + return ( +
+ $1') }} /> +
+ ); + } + if (!line.trim()) return
; + return ( +
+ $1') }} /> +
+ ); + })} +
+ ); +} + +const IMPACT_COLORS = { high: '#d64545', medium: '#ff8800', low: '#00d4aa' }; +const SOURCE_COLORS: Record = { + MIIT: '#e20074', 'UN-ECE': '#4a90d9', ISO: '#7b68ee', + '国标委': '#00b89c', 'EUR-Lex': '#f5a623', IATF: '#9b59b6', +}; +const STATUS_LABEL: Record = { enacted: '已生效', draft: '征求意见', consultation: '公众咨询' }; + +export const AnalysisPanel: React.FC = ({ + event, analyzing, analysisText, affectedDocs, onAnalyze, onAbort, +}) => { + const { theme, isDark } = useTheme(); + const analysisRef = useRef(null); + + if (!event) { + return ( +
+
+
选择左侧法规动态以查看智能影响分析
+
+ ); + } + + const impactColor = IMPACT_COLORS[event.impact_level]; + const srcColor = SOURCE_COLORS[event.source] || theme.accent; + + return ( +
+ {/* Event header */} +
+ {/* Source + status */} +
+ {event.source} + {event.standard_code} + + {STATUS_LABEL[event.status] ?? event.status} + +
+ + {/* Title */} +
+ {event.title} +
+ + {/* Summary */} +
+ {event.summary} +
+ + {/* Tags */} +
+ {event.tags.map(tag => ( + + {tag} + + ))} +
+ + {/* Dates + Analyze button */} +
+
+ 发布:{event.published_at} + {event.effective_at && 生效:{event.effective_at}} +
+ {analyzing ? ( + + ) : ( + + )} +
+
+ + {/* Affected documents */} + {affectedDocs.length > 0 && ( +
+
+ 关联文档({affectedDocs.length}) +
+
+ {affectedDocs.map(doc => ( +
+
+
+ {doc.doc_name} +
+ {doc.snippet && ( +
+ {doc.snippet} +
+ )} +
+ + {Math.round(doc.score * 100)}% + +
+ ))} +
+
+ )} + + {/* Streaming analysis output */} + {(analysisText || analyzing) && ( +
+
+ ANALYSIS {analyzing && } +
+ {analysisText && ( + + )} + {analyzing && !analysisText && ( +
正在分析法规影响...
+ )} +
+ )} + + {/* Empty analysis state */} + {!analysisText && !analyzing && ( +
+
+ 点击「触发智能分析」查看 AI 影响评估 +
+
+ )} +
+ ); +}; diff --git a/frontend/src/pages/Perception/EventFeed.tsx b/frontend/src/pages/Perception/EventFeed.tsx new file mode 100644 index 0000000..7cbc3c5 --- /dev/null +++ b/frontend/src/pages/Perception/EventFeed.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { useTheme } from '../../contexts'; +import type { RegulationEvent, ImpactLevel, EventSource } from '../../api/perception'; + +const IMPACT_CONFIG: Record = { + high: { color: '#d64545', label: '高影响', dot: '●' }, + medium: { color: '#ff8800', label: '中影响', dot: '●' }, + low: { color: '#00d4aa', label: '低影响', dot: '●' }, +}; + +const STATUS_LABEL: Record = { + enacted: '已生效', + draft: '征求意见', + consultation: '公众咨询', +}; + +const SOURCE_COLORS: Record = { + MIIT: '#e20074', + 'UN-ECE': '#4a90d9', + ISO: '#7b68ee', + '国标委': '#00b89c', + 'EUR-Lex': '#f5a623', + IATF: '#9b59b6', +}; + +interface EventFeedProps { + events: RegulationEvent[]; + selectedId: string | null; + onSelect: (id: string) => void; + filterSource: string; + filterImpact: string; + onFilterSource: (v: string) => void; + onFilterImpact: (v: string) => void; + stats: { total: number; high_impact: number; medium_impact: number; low_impact: number; recent_90d: number } | null; + loading: boolean; +} + +export const EventFeed: React.FC = ({ + events, selectedId, onSelect, + filterSource, filterImpact, onFilterSource, onFilterImpact, + stats, loading, +}) => { + const { theme, isDark } = useTheme(); + + const sources: EventSource[] = ['MIIT', 'UN-ECE', 'ISO', '国标委', 'EUR-Lex', 'IATF']; + const impacts: ImpactLevel[] = ['high', 'medium', 'low']; + + return ( +
+ + {/* KPI mini-cards */} + {stats && ( +
+ {[ + { label: '总计', value: stats.total, color: theme.text }, + { label: '高影响', value: stats.high_impact, color: '#d64545' }, + { label: '中影响', value: stats.medium_impact, color: '#ff8800' }, + { label: '近90天', value: stats.recent_90d, color: theme.accent }, + ].map(({ label, value, color }) => ( +
+
+
{label}
+
{value}
+
+ ))} +
+ )} + + {/* Filter row */} +
+ + {sources.map(s => ( + + ))} +
+
+ + {impacts.map(lvl => ( + + ))} +
+ + {/* Event list */} +
+ {loading && ( +
加载中...
+ )} + {!loading && events.length === 0 && ( +
暂无法规动态
+ )} + {events.map(evt => { + const cfg = IMPACT_CONFIG[evt.impact_level]; + const isSelected = evt.id === selectedId; + const srcColor = SOURCE_COLORS[evt.source] || theme.accent; + return ( +
onSelect(evt.id)} + style={{ + padding: '14px 16px', + background: isSelected ? (isDark ? '#1e1e35' : '#fdf0f7') : theme.bgCard, + borderRadius: 10, + border: `1px solid ${isSelected ? theme.accent : theme.border}`, + borderLeft: `4px solid ${cfg.color}`, + cursor: 'pointer', + transition: 'all 0.15s ease', + boxShadow: isSelected ? `0 0 0 1px ${theme.accent}40` : (!isDark ? '0 1px 4px rgba(0,0,0,0.04)' : 'none'), + }} + > + {/* Source + Status row */} +
+ {evt.source} + {evt.standard_code} + + {STATUS_LABEL[evt.status] ?? evt.status} + +
+ + {/* Title */} +
+ {evt.title} +
+ + {/* Date + impact */} +
+ + {evt.published_at}{evt.effective_at ? ` → ${evt.effective_at}` : ''} + + {cfg.dot} {cfg.label} +
+
+ ); + })} +
+
+ ); +}; diff --git a/frontend/src/pages/Perception/PerceptionPage.tsx b/frontend/src/pages/Perception/PerceptionPage.tsx new file mode 100644 index 0000000..a19d09f --- /dev/null +++ b/frontend/src/pages/Perception/PerceptionPage.tsx @@ -0,0 +1,144 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTheme } from '../../contexts'; +import { Content } from '../../components/layout/Content'; +import { TPattern } from '../../components/common/TPattern'; +import { + listEvents, + getPerceptionStats, + analyzeEvent, + type RegulationEvent, + type PerceptionStats, + type AffectedDoc, +} from '../../api/perception'; +import { EventFeed } from './EventFeed'; +import { AnalysisPanel } from './AnalysisPanel'; + +export const PerceptionPage: React.FC = () => { + const { theme } = useTheme(); + + // Feed state + const [events, setEvents] = useState([]); + const [stats, setStats] = useState(null); + const [feedLoading, setFeedLoading] = useState(true); + const [filterSource, setFilterSource] = useState(''); + const [filterImpact, setFilterImpact] = useState(''); + + // Selected event + const [selectedId, setSelectedId] = useState(null); + const selectedEvent = events.find(e => e.id === selectedId) ?? null; + + // Analysis state + const [analyzing, setAnalyzing] = useState(false); + const [analysisText, setAnalysisText] = useState(''); + const [affectedDocs, setAffectedDocs] = useState([]); + const abortRef = useRef(null); + + // Load events + stats + const loadFeed = useCallback(async () => { + setFeedLoading(true); + try { + const [evtRes, statsRes] = await Promise.all([ + listEvents({ + source: filterSource || undefined, + impact_level: filterImpact || undefined, + }), + getPerceptionStats(), + ]); + setEvents(evtRes.events); + setStats(statsRes); + } catch { + // silent + } finally { + setFeedLoading(false); + } + }, [filterSource, filterImpact]); + + useEffect(() => { void loadFeed(); }, [loadFeed]); + + // When selecting a new event, clear previous analysis + const handleSelectEvent = (id: string) => { + if (id === selectedId) return; + abortRef.current?.abort(); + setSelectedId(id); + setAnalysisText(''); + setAffectedDocs([]); + setAnalyzing(false); + }; + + const handleAnalyze = useCallback(() => { + if (!selectedId || analyzing) return; + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setAnalysisText(''); + setAffectedDocs([]); + setAnalyzing(true); + + void analyzeEvent( + selectedId, + (msg) => { + if (msg.type === 'sources' && msg.docs) { + setAffectedDocs(msg.docs); + } else if (msg.type === 'content' && msg.text) { + setAnalysisText(prev => prev + msg.text); + } else if (msg.type === 'error') { + setAnalysisText(prev => prev + `\n\n⚠ 分析出错:${msg.text ?? '未知错误'}`); + } + }, + () => setAnalyzing(false), + ctrl.signal, + ); + }, [selectedId, analyzing]); + + const handleAbort = () => { + abortRef.current?.abort(); + setAnalyzing(false); + }; + + return ( + + + + + {/* Page header */} +
+

智能感知

+ 法规动态实时追踪 · 知识库影响分析 +
+ + {/* Split layout */} +
+ {/* Left: Event feed */} + + + {/* Right: Analysis panel */} + +
+
+ ); +}; diff --git a/frontend/src/pages/Perception/index.ts b/frontend/src/pages/Perception/index.ts new file mode 100644 index 0000000..60a967e --- /dev/null +++ b/frontend/src/pages/Perception/index.ts @@ -0,0 +1 @@ +export { PerceptionPage } from './PerceptionPage';