Files
Claude-Code-Workflow/ccw/frontend/src/components/shared/JsonCardView.tsx
AXC00 12be252e8e feat(analysis): 添加分析查看器页面 (#122)
- 新增 AnalysisPage 页面查看 /workflow:analyze-with-file 分析结果
- 支持 Tab 分组展示:讨论记录、结论、代码探索、视角分析
- Markdown 内容富文本渲染,JSON 数据结构化卡片展示
- 添加后端 API 路由 /api/analysis
- 添加侧边栏导航入口和中英文翻译

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 14:04:41 +08:00

224 lines
5.8 KiB
TypeScript

// ========================================
// JsonCardView Component
// ========================================
// Renders JSON data as structured cards for better readability
import { useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
// ========== Types ==========
export interface JsonCardViewProps {
/** JSON data to render - accepts any object type */
data: object | unknown[] | null;
/** Additional CSS className */
className?: string;
/** Initial expanded state */
defaultExpanded?: boolean;
}
interface CardItemProps {
label: string;
value: unknown;
depth?: number;
}
// ========== Helper Functions ==========
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
function formatLabel(key: string): string {
return key
.replace(/_/g, ' ')
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
.trim();
}
// ========== Sub Components ==========
function PrimitiveValue({ value }: { value: unknown }) {
if (value === null || value === undefined) {
return <span className="text-muted-foreground italic">null</span>;
}
if (typeof value === 'boolean') {
return (
<Badge variant={value ? 'default' : 'secondary'}>
{value ? 'true' : 'false'}
</Badge>
);
}
if (typeof value === 'number') {
return <span className="text-blue-600 dark:text-blue-400 font-mono">{value}</span>;
}
if (typeof value === 'string') {
// Check if it's a URL
if (value.startsWith('http://') || value.startsWith('https://')) {
return (
<a
href={value}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline break-all"
>
{value}
</a>
);
}
// Long text
if (value.length > 100) {
return (
<div className="text-sm text-foreground bg-muted/50 p-2 rounded whitespace-pre-wrap break-words">
{value}
</div>
);
}
return <span className="text-foreground">{value}</span>;
}
return <span>{String(value)}</span>;
}
function ArrayView({ items }: { items: unknown[] }) {
const [expanded, setExpanded] = useState(true);
if (items.length === 0) {
return (
<div className="text-muted-foreground italic text-sm">Empty list</div>
);
}
// Simple array of primitives
const allPrimitives = items.every(
(item) => typeof item !== 'object' || item === null
);
if (allPrimitives) {
return (
<div className="flex flex-wrap gap-1.5">
{items.map((item, index) => (
<Badge key={index} variant="outline" className="font-normal">
{String(item)}
</Badge>
))}
</div>
);
}
return (
<div className="space-y-2">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
{items.length} items
</button>
{expanded && (
<div className="space-y-2 pl-4 border-l-2 border-border">
{items.map((item, index) => (
<Card key={index} className="p-3">
<div className="text-xs text-muted-foreground mb-2">#{index + 1}</div>
{isObject(item) ? (
<ObjectView data={item} />
) : (
<PrimitiveValue value={item} />
)}
</Card>
))}
</div>
)}
</div>
);
}
function ObjectView({ data, depth = 0 }: { data: Record<string, unknown>; depth?: number }) {
const entries = Object.entries(data);
if (entries.length === 0) {
return <div className="text-muted-foreground italic text-sm">Empty object</div>;
}
return (
<div className="space-y-3">
{entries.map(([key, value]) => (
<CardItem key={key} label={key} value={value} depth={depth} />
))}
</div>
);
}
function CardItem({ label, value, depth = 0 }: CardItemProps) {
const formattedLabel = formatLabel(label);
// Nested object
if (isObject(value)) {
return (
<div className="space-y-2">
<div className="font-medium text-sm text-foreground">{formattedLabel}</div>
<div className={cn('pl-3 border-l-2 border-border', depth > 1 && 'ml-2')}>
<ObjectView data={value} depth={depth + 1} />
</div>
</div>
);
}
// Array
if (isArray(value)) {
return (
<div className="space-y-2">
<div className="font-medium text-sm text-foreground">{formattedLabel}</div>
<ArrayView items={value} />
</div>
);
}
// Primitive value
return (
<div className="flex items-start gap-2">
<div className="font-medium text-sm text-muted-foreground min-w-[120px] shrink-0">
{formattedLabel}
</div>
<div className="flex-1 text-sm">
<PrimitiveValue value={value} />
</div>
</div>
);
}
// ========== Main Component ==========
export function JsonCardView({ data, className }: JsonCardViewProps) {
if (!data) {
return (
<div className="text-muted-foreground italic text-sm">No data available</div>
);
}
// Handle array at root level
if (isArray(data)) {
return (
<div className={cn('space-y-3', className)}>
<ArrayView items={data} />
</div>
);
}
// Handle object
return (
<div className={cn('space-y-4', className)}>
<ObjectView data={data as Record<string, unknown>} />
</div>
);
}
export default JsonCardView;