feat: implement Document Management page with filterable table and batch actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,141 @@
|
||||
export function DocsPage() {
|
||||
return <div className="page-content"><p>Documents</p></div>;
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Upload, Search } from 'lucide-react';
|
||||
|
||||
interface Doc {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'ok' | 'warn' | 'risk' | 'info';
|
||||
uploadedAt: string;
|
||||
chunks: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const STATUS_FILTERS = ['All', 'Ready', 'Embedding', 'Failed', 'Pending'];
|
||||
const TYPE_OPTS = ['All types', 'EU Regulation', 'ISO Standard', 'National Draft', 'Internal Policy'];
|
||||
|
||||
const MOCK_DOCS: Doc[] = [
|
||||
{ id: '1', name: 'EU AI Act — Full text (EN)', status: 'ok', uploadedAt: '2025-11-10', chunks: 842, type: 'EU Regulation' },
|
||||
{ id: '2', name: 'MIIT Draft 2025-08 (ZH)', status: 'ok', uploadedAt: '2025-11-01', chunks: 320, type: 'National Draft' },
|
||||
{ id: '3', name: 'ISO/SAE 21434:2021', status: 'ok', uploadedAt: '2025-10-15', chunks: 614, type: 'ISO Standard' },
|
||||
{ id: '4', name: 'Vehicle AI Safety Manual v3.2', status: 'ok', uploadedAt: '2025-10-08', chunks: 198, type: 'Internal Policy' },
|
||||
{ id: '5', name: 'ADAS System Requirements', status: 'warn', uploadedAt: '2025-09-22', chunks: 0, type: 'Internal Policy' },
|
||||
{ id: '6', name: 'UNECE R155 Corrigendum', status: 'info', uploadedAt: '2025-09-12', chunks: 87, type: 'EU Regulation' },
|
||||
{ id: '7', name: 'GB/T 42118-2022', status: 'risk', uploadedAt: '2025-08-30', chunks: 0, type: 'National Draft' },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = { ok: 'Ready', warn: 'Embedding', risk: 'Failed', info: 'Pending' };
|
||||
const STATUS_MAP: Record<string, string> = { All: 'All', Ready: 'ok', Embedding: 'warn', Failed: 'risk', Pending: 'info' };
|
||||
|
||||
export function DocsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusF, setStatusF] = useState('All');
|
||||
const [typeF, setTypeF] = useState('All types');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [docs, setDocs] = useState<Doc[]>(MOCK_DOCS);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/documents')
|
||||
.then(r => r.json())
|
||||
.then(d => { if (Array.isArray(d?.documents)) setDocs(d.documents); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const filtered = docs.filter(d => {
|
||||
const matchSearch = !search || d.name.toLowerCase().includes(search.toLowerCase());
|
||||
const matchStatus = statusF === 'All' || d.status === STATUS_MAP[statusF];
|
||||
const matchType = typeF === 'All types' || d.type === typeF;
|
||||
return matchSearch && matchStatus && matchType;
|
||||
});
|
||||
|
||||
function toggleAll() {
|
||||
if (selected.size === filtered.length) setSelected(new Set());
|
||||
else setSelected(new Set(filtered.map(d => d.id)));
|
||||
}
|
||||
|
||||
function toggleOne(id: string) {
|
||||
const s = new Set(selected);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
setSelected(s);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="docs-page">
|
||||
<Topbar
|
||||
title="Document Management"
|
||||
actions={
|
||||
<>
|
||||
<div className="search-box">
|
||||
<Search size={13} />
|
||||
<input
|
||||
placeholder="Search documents..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn sm primary"><Upload size={13} />Upload document</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="page-content">
|
||||
<div className="docs-controls">
|
||||
<div className="chip-group">
|
||||
{STATUS_FILTERS.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
className={`chip${statusF === f ? ' active' : ''}`}
|
||||
onClick={() => setStatusF(f)}
|
||||
>{f}</button>
|
||||
))}
|
||||
</div>
|
||||
<select className="select-input" value={typeF} onChange={e => setTypeF(e.target.value)}>
|
||||
{TYPE_OPTS.map(o => <option key={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="batch-bar">
|
||||
<span>{selected.size} document{selected.size > 1 ? 's' : ''} selected</span>
|
||||
<button className="btn sm">Analyze selected</button>
|
||||
<button className="btn sm risk-btn">Delete selected</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="docs-table">
|
||||
<div className="table-header">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size === filtered.length && filtered.length > 0}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
<span>Document name</span>
|
||||
<span>Status</span>
|
||||
<span>Uploaded</span>
|
||||
<span>Chunks</span>
|
||||
<span>Type</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{filtered.map(d => (
|
||||
<div key={d.id} className={`table-row${selected.has(d.id) ? ' row-selected' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(d.id)}
|
||||
onChange={() => toggleOne(d.id)}
|
||||
/>
|
||||
<span className="doc-name-cell">{d.name}</span>
|
||||
<span><span className={`status ${d.status}`}>{STATUS_LABEL[d.status]}</span></span>
|
||||
<span className="cell-mono">{d.uploadedAt}</span>
|
||||
<span className="cell-mono">{d.chunks || '—'}</span>
|
||||
<span className="cell-muted">{d.type}</span>
|
||||
<span className="row-actions">
|
||||
<button className="text-link">Inspect</button>
|
||||
<button className="text-link">Analyze</button>
|
||||
{d.status === 'risk' && <button className="text-link danger-link">Resolve</button>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,157 +0,0 @@
|
||||
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: '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 ? theme.bgHover : 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` : '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>
|
||||
);
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts';
|
||||
import type { RetrievalData } from '../../types';
|
||||
|
||||
interface CitedAnswerProps {
|
||||
text: string;
|
||||
sources: RetrievalData[];
|
||||
onCiteClick: (index: number) => void;
|
||||
}
|
||||
|
||||
export const CitedAnswer: React.FC<CitedAnswerProps> = ({ text, sources, onCiteClick }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
// Split on [N] patterns, preserving delimiters
|
||||
const parts = text.split(/(\[\d+\])/g);
|
||||
|
||||
return (
|
||||
<span style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||
{parts.map((part, i) => {
|
||||
const match = part.match(/^\[(\d+)\]$/);
|
||||
if (!match) return <React.Fragment key={i}>{part}</React.Fragment>;
|
||||
|
||||
const idx = parseInt(match[1], 10);
|
||||
const source = sources[idx - 1];
|
||||
|
||||
return (
|
||||
<sup
|
||||
key={i}
|
||||
title={source ? `${source.file} · ${source.clause}` : `引用 ${idx}`}
|
||||
onClick={() => onCiteClick(idx)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 4px',
|
||||
marginLeft: 1,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
background: theme.gradientAccent,
|
||||
color: '#fff',
|
||||
borderRadius: 4,
|
||||
cursor: source ? 'pointer' : 'default',
|
||||
verticalAlign: 'super',
|
||||
lineHeight: 1,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{idx}
|
||||
</sup>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user