feat(perception): 智能感知模块 - event feed, SSE impact analysis, tab registration

This commit is contained in:
2026-05-22 00:42:28 +08:00
parent f9ee644f25
commit 37f7a60b0a
8 changed files with 643 additions and 2 deletions

View File

@@ -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 (
<div style={{ fontSize: 14, lineHeight: 1.75, color: textColor }}>
{lines.map((line, i) => {
if (line.startsWith('## ')) {
return <div key={i} style={{ fontSize: 15, fontWeight: 700, color: accent, marginTop: 18, marginBottom: 6 }}>{line.slice(3)}</div>;
}
if (line.startsWith('### ')) {
return <div key={i} style={{ fontSize: 13, fontWeight: 700, marginTop: 12, marginBottom: 4 }}>{line.slice(4)}</div>;
}
if (line.startsWith('- ') || line.startsWith('* ')) {
const content = line.slice(2);
return (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 4, paddingLeft: 8 }}>
<span style={{ color: accent, flexShrink: 0 }}>·</span>
<span dangerouslySetInnerHTML={{ __html: content.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') }} />
</div>
);
}
if (/^\d+\./.test(line)) {
return (
<div key={i} style={{ marginBottom: 4, paddingLeft: 8 }}>
<span dangerouslySetInnerHTML={{ __html: line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') }} />
</div>
);
}
if (!line.trim()) return <div key={i} style={{ height: 8 }} />;
return (
<div key={i} style={{ marginBottom: 4 }}>
<span dangerouslySetInnerHTML={{ __html: line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') }} />
</div>
);
})}
</div>
);
}
const IMPACT_COLORS = { high: '#d64545', medium: '#ff8800', low: '#00d4aa' };
const SOURCE_COLORS: Record<string, string> = {
MIIT: '#e20074', 'UN-ECE': '#4a90d9', ISO: '#7b68ee',
'国标委': '#00b89c', 'EUR-Lex': '#f5a623', IATF: '#9b59b6',
};
const STATUS_LABEL: Record<string, string> = { enacted: '已生效', draft: '征求意见', consultation: '公众咨询' };
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
event, analyzing, analysisText, affectedDocs, onAnalyze, onAbort,
}) => {
const { theme, isDark } = useTheme();
const analysisRef = useRef<HTMLDivElement>(null);
if (!event) {
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<div style={{ fontSize: 48, opacity: 0.15 }}></div>
<div style={{ fontSize: 14, color: theme.text3 }}></div>
</div>
);
}
const impactColor = IMPACT_COLORS[event.impact_level];
const srcColor = SOURCE_COLORS[event.source] || theme.accent;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 0 }}>
{/* Event header */}
<div style={{
padding: '20px 24px',
background: theme.bgCard,
borderRadius: 12,
border: `1px solid ${theme.border}`,
borderLeft: `4px solid ${impactColor}`,
marginBottom: 16,
flexShrink: 0,
boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none',
}}>
{/* Source + status */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: srcColor, background: srcColor + '18', borderRadius: 4, padding: '3px 8px' }}>{event.source}</span>
<span className="mono" style={{ fontSize: 11, color: theme.text3 }}>{event.standard_code}</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: event.status === 'enacted' ? theme.green : '#ff8800', fontWeight: 600 }}>
{STATUS_LABEL[event.status] ?? event.status}
</span>
</div>
{/* Title */}
<div style={{ fontSize: 16, fontWeight: 700, color: theme.text, lineHeight: 1.4, marginBottom: 10 }}>
{event.title}
</div>
{/* Summary */}
<div style={{ fontSize: 13, color: theme.text2, lineHeight: 1.6, marginBottom: 12 }}>
{event.summary}
</div>
{/* Tags */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{event.tags.map(tag => (
<span key={tag} style={{ fontSize: 11, color: theme.text3, background: theme.bgHover, borderRadius: 4, padding: '2px 8px', border: `1px solid ${theme.border}` }}>
{tag}
</span>
))}
</div>
{/* Dates + Analyze button */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div className="mono" style={{ fontSize: 11, color: theme.text3 }}>
{event.published_at}
{event.effective_at && <span style={{ marginLeft: 12 }}><span style={{ color: impactColor }}>{event.effective_at}</span></span>}
</div>
{analyzing ? (
<button onClick={onAbort} style={{ padding: '7px 18px', borderRadius: 8, border: '1px solid #d64545', background: 'transparent', color: '#d64545', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
</button>
) : (
<button onClick={onAnalyze} style={{ padding: '7px 18px', borderRadius: 8, border: 'none', background: theme.gradientAccent, color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 600, boxShadow: '0 2px 8px rgba(226,0,116,0.3)' }}>
</button>
)}
</div>
</div>
{/* Affected documents */}
{affectedDocs.length > 0 && (
<div style={{ marginBottom: 16, flexShrink: 0 }}>
<div className="mono" style={{ fontSize: 11, color: theme.text3, letterSpacing: '1px', marginBottom: 8 }}>
{affectedDocs.length}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{affectedDocs.map(doc => (
<div key={doc.doc_id} style={{
padding: '10px 14px',
background: theme.bgCard,
border: `1px solid ${theme.border}`,
borderLeft: `3px solid ${theme.accent}`,
borderRadius: 8,
display: 'flex',
alignItems: 'flex-start',
gap: 10,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{doc.doc_name}
</div>
{doc.snippet && (
<div style={{ fontSize: 12, color: theme.text3, marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{doc.snippet}
</div>
)}
</div>
<span className="mono" style={{ fontSize: 11, color: theme.accent, flexShrink: 0 }}>
{Math.round(doc.score * 100)}%
</span>
</div>
))}
</div>
</div>
)}
{/* Streaming analysis output */}
{(analysisText || analyzing) && (
<div ref={analysisRef} style={{
flex: 1,
overflowY: 'auto',
padding: '20px 24px',
background: theme.bgCard,
border: `1px solid ${theme.border}`,
borderRadius: 12,
boxShadow: !isDark ? '0 2px 8px rgba(0,0,0,0.03)' : 'none',
}}>
<div className="mono" style={{ fontSize: 11, color: theme.accent, letterSpacing: '1px', marginBottom: 14 }}>
ANALYSIS {analyzing && <span style={{ animation: 'blink 1s step-end infinite' }}></span>}
</div>
{analysisText && (
<MarkdownText text={analysisText} textColor={theme.text2} accent={theme.accent} />
)}
{analyzing && !analysisText && (
<div style={{ color: theme.text3, fontSize: 13 }}>...</div>
)}
</div>
)}
{/* Empty analysis state */}
{!analysisText && !analyzing && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: theme.text3, fontSize: 13 }}>
AI
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,157 @@
import React from 'react';
import { useTheme } from '../../contexts';
import type { RegulationEvent, ImpactLevel, EventSource } from '../../api/perception';
const IMPACT_CONFIG: Record<ImpactLevel, { color: string; label: string; dot: string }> = {
high: { color: '#d64545', label: '高影响', dot: '●' },
medium: { color: '#ff8800', label: '中影响', dot: '●' },
low: { color: '#00d4aa', label: '低影响', dot: '●' },
};
const STATUS_LABEL: Record<string, string> = {
enacted: '已生效',
draft: '征求意见',
consultation: '公众咨询',
};
const SOURCE_COLORS: Record<string, string> = {
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<EventFeedProps> = ({
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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 16 }}>
{/* KPI mini-cards */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
{[
{ 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 }) => (
<div key={label} style={{
padding: '10px 12px',
background: theme.bgCard,
border: `1px solid ${theme.border}`,
borderRadius: 10,
position: 'relative',
overflow: 'hidden',
boxShadow: !isDark ? '0 2px 6px rgba(226,0,116,0.05)' : 'none',
}}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: color }} />
<div className="mono" style={{ fontSize: 10, color: theme.text3, letterSpacing: '0.5px' }}>{label}</div>
<div className="mono" style={{ fontSize: 22, fontWeight: 700, color }}>{value}</div>
</div>
))}
</div>
)}
{/* Filter row */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button
onClick={() => onFilterSource('')}
style={{ padding: '4px 10px', borderRadius: 20, border: `1px solid ${filterSource === '' ? theme.accent : theme.border}`, background: filterSource === '' ? theme.accent + '20' : 'transparent', color: filterSource === '' ? theme.accent : theme.text3, fontSize: 11, cursor: 'pointer' }}
></button>
{sources.map(s => (
<button key={s} onClick={() => onFilterSource(filterSource === s ? '' : s)}
style={{ padding: '4px 10px', borderRadius: 20, border: `1px solid ${filterSource === s ? (SOURCE_COLORS[s] || theme.accent) : theme.border}`, background: filterSource === s ? (SOURCE_COLORS[s] || theme.accent) + '20' : 'transparent', color: filterSource === s ? (SOURCE_COLORS[s] || theme.accent) : theme.text3, fontSize: 11, cursor: 'pointer' }}>
{s}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={() => onFilterImpact('')}
style={{ padding: '4px 10px', borderRadius: 20, border: `1px solid ${filterImpact === '' ? theme.accent : theme.border}`, background: filterImpact === '' ? theme.accent + '20' : 'transparent', color: filterImpact === '' ? theme.accent : theme.text3, fontSize: 11, cursor: 'pointer' }}>
</button>
{impacts.map(lvl => (
<button key={lvl} onClick={() => onFilterImpact(filterImpact === lvl ? '' : lvl)}
style={{ padding: '4px 10px', borderRadius: 20, border: `1px solid ${filterImpact === lvl ? IMPACT_CONFIG[lvl].color : theme.border}`, background: filterImpact === lvl ? IMPACT_CONFIG[lvl].color + '22' : 'transparent', color: filterImpact === lvl ? IMPACT_CONFIG[lvl].color : theme.text3, fontSize: 11, cursor: 'pointer' }}>
{IMPACT_CONFIG[lvl].dot} {IMPACT_CONFIG[lvl].label}
</button>
))}
</div>
{/* Event list */}
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{loading && (
<div className="mono" style={{ fontSize: 12, color: theme.text3, padding: '16px 0' }}>...</div>
)}
{!loading && events.length === 0 && (
<div style={{ fontSize: 13, color: theme.text3, padding: '32px 0', textAlign: 'center' }}></div>
)}
{events.map(evt => {
const cfg = IMPACT_CONFIG[evt.impact_level];
const isSelected = evt.id === selectedId;
const srcColor = SOURCE_COLORS[evt.source] || theme.accent;
return (
<div
key={evt.id}
onClick={() => 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 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<span style={{ fontSize: 10, fontWeight: 700, color: srcColor, background: srcColor + '18', borderRadius: 4, padding: '2px 7px' }}>{evt.source}</span>
<span className="mono" style={{ fontSize: 10, color: theme.text3 }}>{evt.standard_code}</span>
<span style={{ marginLeft: 'auto', fontSize: 10, color: evt.status === 'enacted' ? theme.green : '#ff8800', background: evt.status === 'enacted' ? theme.green + '18' : '#ff880018', borderRadius: 4, padding: '2px 6px', fontWeight: 600 }}>
{STATUS_LABEL[evt.status] ?? evt.status}
</span>
</div>
{/* Title */}
<div style={{ fontSize: 13, fontWeight: 600, color: theme.text, lineHeight: 1.4, marginBottom: 6, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{evt.title}
</div>
{/* Date + impact */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span className="mono" style={{ fontSize: 11, color: theme.text3 }}>
{evt.published_at}{evt.effective_at ? `${evt.effective_at}` : ''}
</span>
<span style={{ fontSize: 10, color: cfg.color, fontWeight: 700 }}>{cfg.dot} {cfg.label}</span>
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -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<RegulationEvent[]>([]);
const [stats, setStats] = useState<PerceptionStats | null>(null);
const [feedLoading, setFeedLoading] = useState(true);
const [filterSource, setFilterSource] = useState('');
const [filterImpact, setFilterImpact] = useState('');
// Selected event
const [selectedId, setSelectedId] = useState<string | null>(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<AffectedDoc[]>([]);
const abortRef = useRef<AbortController | null>(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 (
<Content wide>
<style>{`
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
`}</style>
<TPattern />
{/* Page header */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 16, marginBottom: 24 }}>
<h1 style={{ fontSize: 20, fontWeight: 700, color: theme.text, margin: 0 }}></h1>
<span style={{ fontSize: 13, color: theme.text3 }}> · </span>
</div>
{/* Split layout */}
<div style={{
display: 'grid',
gridTemplateColumns: '400px 1fr',
gap: 24,
height: 'calc(100vh - 220px)',
minHeight: 560,
}}>
{/* Left: Event feed */}
<EventFeed
events={events}
selectedId={selectedId}
onSelect={handleSelectEvent}
filterSource={filterSource}
filterImpact={filterImpact}
onFilterSource={setFilterSource}
onFilterImpact={setFilterImpact}
stats={stats}
loading={feedLoading}
/>
{/* Right: Analysis panel */}
<AnalysisPanel
event={selectedEvent}
analyzing={analyzing}
analysisText={analysisText}
affectedDocs={affectedDocs}
onAnalyze={handleAnalyze}
onAbort={handleAbort}
/>
</div>
</Content>
);
};

View File

@@ -0,0 +1 @@
export { PerceptionPage } from './PerceptionPage';