feat(a11y): apply ARIA roles, landmarks, focus management, touch targets and contrast fixes across components
This commit is contained in:
@@ -237,13 +237,15 @@ export function AuthPage() {
|
||||
disabled={isValidating}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? 'token-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/50 dark:text-red-400">
|
||||
<div id="token-error" role="alert" className="flex items-center gap-2 rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/50 dark:text-red-400">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" strokeWidth={2} fill="none" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function ChatTabBar({
|
||||
<div className="max-w-4xl mx-auto px-2 sm:px-4">
|
||||
<div className="flex items-center gap-1 overflow-x-auto py-1.5 scrollbar-thin">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
<button
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm whitespace-nowrap transition-colors cursor-pointer",
|
||||
@@ -32,6 +32,7 @@ export function ChatTabBar({
|
||||
? "bg-background shadow-sm border"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => onSwitch(tab.id)}
|
||||
>
|
||||
{tab.type === 'webui' ? (
|
||||
@@ -62,7 +63,7 @@ export function ChatTabBar({
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{/* 新建虚拟身份标签页按钮 */}
|
||||
<button
|
||||
|
||||
@@ -39,6 +39,7 @@ export function RenderMessageSegment({ segment }: { segment: MessageSegment }) {
|
||||
src={String(segment.data)}
|
||||
className="max-w-[200px] h-8"
|
||||
>
|
||||
<track kind="captions" src="" label="无字幕" default />
|
||||
您的浏览器不支持音频播放
|
||||
</audio>
|
||||
</div>
|
||||
@@ -51,6 +52,7 @@ export function RenderMessageSegment({ segment }: { segment: MessageSegment }) {
|
||||
src={String(segment.data)}
|
||||
className="rounded-lg max-w-full max-h-64"
|
||||
>
|
||||
<track kind="captions" src="" label="无字幕" default />
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
)
|
||||
|
||||
@@ -418,7 +418,7 @@ export function AdapterConfigPage() {
|
||||
</CardHeader>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-4" role="radiogroup" aria-label="部署模式选择">
|
||||
{/* 预设模式 */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-3 md:p-4 cursor-pointer transition-all ${
|
||||
@@ -426,7 +426,11 @@ export function AdapterConfigPage() {
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted hover:border-primary/50 active:border-primary/70'
|
||||
}`}
|
||||
role="radio"
|
||||
aria-checked={mode === 'preset'}
|
||||
tabIndex={0}
|
||||
onClick={() => handleModeChange('preset')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleModeChange('preset') } }}
|
||||
>
|
||||
<div className="flex items-start gap-2 md:gap-3">
|
||||
<Package className="h-4 w-4 md:h-5 md:w-5 mt-0.5 flex-shrink-0" />
|
||||
@@ -446,7 +450,11 @@ export function AdapterConfigPage() {
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted hover:border-primary/50 active:border-primary/70'
|
||||
}`}
|
||||
role="radio"
|
||||
aria-checked={mode === 'upload'}
|
||||
tabIndex={0}
|
||||
onClick={() => handleModeChange('upload')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleModeChange('upload') } }}
|
||||
>
|
||||
<div className="flex items-start gap-2 md:gap-3">
|
||||
<Upload className="h-4 w-4 md:h-5 md:w-5 mt-0.5 flex-shrink-0" />
|
||||
@@ -466,7 +474,11 @@ export function AdapterConfigPage() {
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted hover:border-primary/50 active:border-primary/70'
|
||||
}`}
|
||||
role="radio"
|
||||
aria-checked={mode === 'path'}
|
||||
tabIndex={0}
|
||||
onClick={() => handleModeChange('path')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleModeChange('path') } }}
|
||||
>
|
||||
<div className="flex items-start gap-2 md:gap-3">
|
||||
<FolderOpen className="h-4 w-4 md:h-5 md:w-5 mt-0.5 flex-shrink-0" />
|
||||
@@ -496,10 +508,14 @@ export function AdapterConfigPage() {
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted hover:border-primary/50'
|
||||
}`}
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
setSelectedPreset(key as PresetKey)
|
||||
handleLoadFromPreset(key as PresetKey)
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedPreset(key as PresetKey); handleLoadFromPreset(key as PresetKey) } }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className="h-5 w-5 mt-0.5 flex-shrink-0" />
|
||||
|
||||
@@ -54,7 +54,7 @@ export const ModelTable = React.memo(function ModelTable({
|
||||
return (
|
||||
<div className="hidden md:block rounded-lg border bg-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<Table aria-label="模型列表">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
|
||||
@@ -209,10 +209,12 @@ export function ProviderForm({
|
||||
}
|
||||
}}
|
||||
placeholder="例如: DeepSeek, SiliconFlow"
|
||||
aria-invalid={formErrors.name ? true : undefined}
|
||||
aria-describedby={formErrors.name ? 'name-error' : undefined}
|
||||
className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
/>
|
||||
{formErrors.name && (
|
||||
<p className="text-xs text-destructive">{formErrors.name}</p>
|
||||
<p id="name-error" role="alert" className="text-xs text-destructive">{formErrors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -249,10 +251,12 @@ export function ProviderForm({
|
||||
}}
|
||||
placeholder="https://api.example.com/v1"
|
||||
disabled={isUsingTemplate}
|
||||
aria-invalid={formErrors.base_url ? true : undefined}
|
||||
aria-describedby={formErrors.base_url ? 'base-url-error' : undefined}
|
||||
className={`${isUsingTemplate ? 'bg-muted cursor-not-allowed' : ''} ${formErrors.base_url ? 'border-destructive focus-visible:ring-destructive' : ''}`}
|
||||
/>
|
||||
{formErrors.base_url && (
|
||||
<p className="text-xs text-destructive">{formErrors.base_url}</p>
|
||||
<p id="base-url-error" role="alert" className="text-xs text-destructive">{formErrors.base_url}</p>
|
||||
)}
|
||||
{isUsingTemplate && !formErrors.base_url && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -295,6 +299,8 @@ export function ProviderForm({
|
||||
}
|
||||
}}
|
||||
placeholder="sk-..."
|
||||
aria-invalid={formErrors.api_key ? true : undefined}
|
||||
aria-describedby={formErrors.api_key ? 'api-key-error' : undefined}
|
||||
className={`flex-1 ${formErrors.api_key ? 'border-destructive focus-visible:ring-destructive' : ''}`}
|
||||
/>
|
||||
<Button
|
||||
@@ -321,7 +327,7 @@ export function ProviderForm({
|
||||
</Button>
|
||||
</div>
|
||||
{formErrors.api_key && (
|
||||
<p className="text-xs text-destructive">{formErrors.api_key}</p>
|
||||
<p id="api-key-error" role="alert" className="text-xs text-destructive">{formErrors.api_key}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ export function ProviderList({
|
||||
{/* 桌面端表格视图 */}
|
||||
<div className="hidden md:block rounded-lg border bg-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<Table aria-label="AI 模型提供商列表">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
|
||||
@@ -400,7 +400,7 @@ export default function PackDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<Table aria-label="API 提供商配置列表">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
@@ -435,7 +435,7 @@ export default function PackDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<Table aria-label="模型配置列表">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>模型名称</TableHead>
|
||||
|
||||
@@ -52,6 +52,7 @@ import { RestartProvider, useRestart } from '@/lib/restart-context'
|
||||
import { RestartOverlay } from '@/components/restart-overlay'
|
||||
import { ExpressionReviewer } from '@/components/expression-reviewer'
|
||||
import { getReviewStats } from '@/lib/expression-api'
|
||||
import { ZoomableChart } from '@/components/ui/zoomable-chart'
|
||||
|
||||
// 主导出组件:包装 RestartProvider
|
||||
export function IndexPage() {
|
||||
@@ -736,6 +737,7 @@ function IndexPageContent() {
|
||||
<CardDescription>最近{timeRange}小时的请求量变化</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ZoomableChart aria-label="每小时请求量趋势图,显示最近若干小时的请求次数变化">
|
||||
<ChartContainer config={chartConfig} className="h-[300px] sm:h-[400px] w-full aspect-auto">
|
||||
<LineChart data={hourly_data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
|
||||
@@ -760,6 +762,7 @@ function IndexPageContent() {
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</ZoomableChart>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -770,6 +773,7 @@ function IndexPageContent() {
|
||||
<CardDescription>API调用成本变化</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ZoomableChart aria-label="API花费趋势图,显示最近若干小时的API调用成本变化">
|
||||
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
|
||||
<BarChart data={hourly_data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
|
||||
@@ -789,6 +793,7 @@ function IndexPageContent() {
|
||||
<Bar dataKey="cost" fill="var(--color-cost)" />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</ZoomableChart>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -798,6 +803,7 @@ function IndexPageContent() {
|
||||
<CardDescription>Token使用量变化</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ZoomableChart aria-label="Token消耗趋势图,显示最近若干小时的Token使用量变化">
|
||||
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
|
||||
<BarChart data={hourly_data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
|
||||
@@ -817,6 +823,7 @@ function IndexPageContent() {
|
||||
<Bar dataKey="tokens" fill="var(--color-tokens)" />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</ZoomableChart>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -228,7 +228,10 @@ export function PlannerMonitor({ autoRefresh, refreshKey }: PlannerMonitorProps)
|
||||
<div
|
||||
key={chat.chat_id}
|
||||
className="border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleChatClick(chat)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleChatClick(chat) } }}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -327,7 +330,10 @@ export function PlannerMonitor({ autoRefresh, refreshKey }: PlannerMonitorProps)
|
||||
<div
|
||||
key={plan.filename}
|
||||
className="border rounded-lg p-3 hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleLogClick(plan.chat_id, plan.filename)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleLogClick(plan.chat_id, plan.filename) } }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
|
||||
@@ -228,7 +228,10 @@ export function ReplierMonitor({ autoRefresh, refreshKey }: ReplierMonitorProps)
|
||||
<div
|
||||
key={chat.chat_id}
|
||||
className="border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleChatClick(chat)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleChatClick(chat) } }}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -327,7 +330,10 @@ export function ReplierMonitor({ autoRefresh, refreshKey }: ReplierMonitorProps)
|
||||
<div
|
||||
key={reply.filename}
|
||||
className="border rounded-lg p-3 hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleLogClick(reply.chat_id, reply.filename)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleLogClick(reply.chat_id, reply.filename) } }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
|
||||
@@ -415,7 +415,7 @@ export function PersonManagementPage() {
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* 桌面端表格视图 */}
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<Table aria-label="人物信息列表">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
|
||||
@@ -924,7 +924,10 @@ function PluginConfigPageContent() {
|
||||
<div
|
||||
key={plugin.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedPlugin(plugin)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedPlugin(plugin) } }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
|
||||
@@ -302,7 +302,7 @@ export function PluginMirrorsPage() {
|
||||
<Card>
|
||||
{/* 桌面端表格 */}
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<Table aria-label="插件镜像源列表">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>状态</TableHead>
|
||||
|
||||
@@ -74,6 +74,7 @@ export function InstallDialog({ open, plugin, onOpenChange, onInstall }: Install
|
||||
{showAdvancedOptions && (
|
||||
<div className="space-y-4 p-4 border rounded-lg">
|
||||
<div className="space-y-2">
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- section heading above Tabs, not a form label */}
|
||||
<label className="text-sm font-medium">分支选择</label>
|
||||
|
||||
<Tabs value={branchInputMode} onValueChange={(value) => setBranchInputMode(value as 'preset' | 'custom')}>
|
||||
|
||||
@@ -773,6 +773,14 @@ export function EmojiUploadDialog({
|
||||
<div
|
||||
key={file.id}
|
||||
onClick={() => setSelectedFileId(file.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedFileId(file.id) } }}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all
|
||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||
${complete ? 'border-green-500 bg-green-50 dark:bg-green-950/20' : 'border-border hover:border-muted-foreground/50'}
|
||||
`}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all
|
||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||
|
||||
@@ -76,7 +76,10 @@ export function EmojiList({
|
||||
? 'ring-2 ring-primary bg-primary/5'
|
||||
: ''
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onToggleSelect(emoji.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleSelect(emoji.id) } }}
|
||||
>
|
||||
{/* 选中指示器 */}
|
||||
<div
|
||||
|
||||
@@ -74,7 +74,7 @@ export function ExpressionList({
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* 桌面端表格视图 */}
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<Table aria-label="表达方式列表">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
|
||||
@@ -74,7 +74,7 @@ export function JargonList({
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* 桁面端表格视图 */}
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<Table aria-label="黑话列表">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
|
||||
@@ -28,7 +28,7 @@ export function NodeDetailDialog({ open, onOpenChange, selectedNodeData }: NodeD
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">类型</label>
|
||||
<p className="text-sm font-medium text-muted-foreground">类型</p>
|
||||
<div className="mt-1">
|
||||
<Badge variant={selectedNodeData.type === 'entity' ? 'default' : 'secondary'}>
|
||||
{selectedNodeData.type === 'entity' ? '🏷️ 实体' : '📄 段落'}
|
||||
@@ -38,14 +38,14 @@ export function NodeDetailDialog({ open, onOpenChange, selectedNodeData }: NodeD
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">ID</label>
|
||||
<p className="text-sm font-medium text-muted-foreground">ID</p>
|
||||
<code className="mt-1 block p-2 bg-muted rounded text-xs break-all">
|
||||
{selectedNodeData.id}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">内容</label>
|
||||
<p className="text-sm font-medium text-muted-foreground">内容</p>
|
||||
<div className="mt-1 p-3 bg-muted rounded border">
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{selectedNodeData.content}</p>
|
||||
</div>
|
||||
@@ -106,7 +106,7 @@ export function EdgeDetailDialog({ open, onOpenChange, selectedEdgeData }: EdgeD
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">权重</label>
|
||||
<p className="text-sm font-medium text-muted-foreground">权重</p>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline" className="text-base font-mono">
|
||||
{selectedEdgeData.edge.weight.toFixed(4)}
|
||||
|
||||
@@ -130,53 +130,67 @@ export function GraphVisualization({ graphData, onNodeClick, onEdgeClick, loadin
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
minZoom={0.05}
|
||||
maxZoom={1.5}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
|
||||
elevateNodesOnSelect={nodeCount <= 500}
|
||||
nodesDraggable={nodeCount <= 1000}
|
||||
attributionPosition="bottom-left"
|
||||
<div
|
||||
style={{ touchAction: 'none' }}
|
||||
role="img"
|
||||
aria-label={`知识图谱可视化,共 ${nodeCount} 个节点,${edges.length} 条关系`}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||
<Controls />
|
||||
{nodeCount <= 500 && (
|
||||
<MiniMap
|
||||
nodeColor={miniMapNodeColor}
|
||||
nodeBorderRadius={8}
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{`知识图谱包含 ${nodeCount} 个节点和 ${edges.length} 条关系。`}
|
||||
</span>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
minZoom={0.05}
|
||||
maxZoom={1.5}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
|
||||
elevateNodesOnSelect={nodeCount <= 500}
|
||||
nodesDraggable={nodeCount <= 1000}
|
||||
attributionPosition="bottom-left"
|
||||
panOnScroll
|
||||
panOnScrollMode={undefined}
|
||||
panOnDrag
|
||||
zoomOnPinch
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||
<Controls />
|
||||
{nodeCount <= 500 && (
|
||||
<MiniMap
|
||||
nodeColor={miniMapNodeColor}
|
||||
nodeBorderRadius={8}
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
)}
|
||||
|
||||
<Panel position="top-right" className="bg-background/95 backdrop-blur-sm rounded-lg border p-3 shadow-lg">
|
||||
<div className="text-sm font-semibold mb-2">图例</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700" />
|
||||
<span>实体节点</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700" />
|
||||
<span>段落节点</span>
|
||||
</div>
|
||||
{nodeCount > 200 && (
|
||||
<div className="mt-2 pt-2 border-t text-yellow-600 dark:text-yellow-500">
|
||||
<div className="font-semibold">性能模式</div>
|
||||
<div>已禁用动画</div>
|
||||
{nodeCount > 500 && <div>已禁用缩略图</div>}
|
||||
<Panel position="top-right" className="bg-background/95 backdrop-blur-sm rounded-lg border p-3 shadow-lg">
|
||||
<div className="text-sm font-semibold mb-2">图例</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700" aria-hidden="true" />
|
||||
<span>实体节点</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700" aria-hidden="true" />
|
||||
<span>段落节点</span>
|
||||
</div>
|
||||
{nodeCount > 200 && (
|
||||
<div className="mt-2 pt-2 border-t text-yellow-600 dark:text-yellow-500">
|
||||
<div className="font-semibold">性能模式</div>
|
||||
<div>已禁用动画</div>
|
||||
{nodeCount > 500 && <div>已禁用缩略图</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user