Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
562 changes: 560 additions & 2 deletions frontend/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
"i18next": "^25.8.0",
"jssha": "^3.3.1",
"lucide-react": "^0.539.0",
"mammoth": "^1.11.0",
"react": "^18.1.1",
"react-dom": "^18.1.1",
"react-force-graph-2d": "^1.29.0",
"react-force-graph-3d": "^1.29.0",
"react-i18next": "^16.5.4",
"react-markdown": "^10.1.0",
"react-pdf": "^10.4.1",
"react-redux": "^9.2.0",
"react-router": "^7.8.0",
"react-syntax-highlighter": "^16.1.0",
Expand Down
233 changes: 142 additions & 91 deletions frontend/src/components/DetailHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useLayoutEffect, useEffect, useRef, useState, useCallback, useMemo } from "react";
import { Database } from "lucide-react";
import { Card, Button, Tag, Tooltip, Popconfirm } from "antd";
import { Card, Button, Tag, Tooltip, Popconfirm, Popover } from "antd";
import type { ItemType } from "antd/es/menu/interface";
import AddTagPopover from "./AddTagPopover";
import ActionDropdown from "./ActionDropdown";
Expand Down Expand Up @@ -40,119 +40,170 @@ interface DetailHeaderProps<T> {

// 标签单行渲染组件
const TagsInline = ({ tags }: { tags: Array<{ id: number; name: string; color: string } | string> }) => {
const [visibleTags, setVisibleTags] = useState<any[]>([]);
const [hiddenCount, setHiddenCount] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const tagsAreaRef = useRef<HTMLDivElement>(null);
const [visibleTags, setVisibleTags] = useState<typeof tags>([]);
const [hiddenCount, setHiddenCount] = useState(0);
const [initialized, setInitialized] = useState(false);

useEffect(() => {
if (!tags || tags.length === 0) return;
const calculateVisibleTags = useCallback(() => {
if (!tags || tags.length === 0) {
setVisibleTags([]);
setHiddenCount(0);
return;
}

const calculateVisibleTags = () => {
if (!containerRef.current) return;
if (!containerRef.current) return;

const container = containerRef.current;
// 创建测量容器
const measureContainer = document.createElement("div");
measureContainer.style.position = "absolute";
measureContainer.style.visibility = "hidden";
measureContainer.style.pointerEvents = "none";
measureContainer.style.display = "inline-flex";
measureContainer.style.alignItems = "center";
measureContainer.style.gap = "4px";
measureContainer.style.whiteSpace = "nowrap";
measureContainer.style.zIndex = "-1";
document.body.appendChild(measureContainer);

// 创建一个隐藏的测量容器
const measureContainer = document.createElement("div");
measureContainer.style.position = "absolute";
measureContainer.style.visibility = "hidden";
measureContainer.style.pointerEvents = "none";
measureContainer.style.top = "0";
measureContainer.style.left = "0";
measureContainer.style.display = "inline-flex";
measureContainer.style.alignItems = "center";
measureContainer.style.gap = "4px";
measureContainer.style.whiteSpace = "nowrap";
measureContainer.style.flexWrap = "nowrap";
measureContainer.style.zIndex = "-1";
// 测量 "+n" 标签
const plusTag = document.createElement("span");
plusTag.className = "ant-tag ant-tag-default";
plusTag.textContent = "+99";
measureContainer.appendChild(plusTag);
const plusWidth = plusTag.offsetWidth;

// 创建 "+n" 标签来测量
const plusTag = document.createElement("span");
plusTag.className = "ant-tag ant-tag-default cursor-pointer bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200";
plusTag.textContent = "+99";
measureContainer.appendChild(plusTag);
const plusWidth = plusTag.offsetWidth;
// 总容器宽度
const totalWidth = 450;
// 预留"+n"标签的完整空间(使用更保守的估计)
// "+n"标签的实际宽度 ≈ 35-50px,预留 60px 确保安全
const availableWidth = totalWidth - 60;

// 暂时插入到 DOM 中测量
if (container.parentElement) {
container.parentElement.style.position = "relative";
container.parentElement.appendChild(measureContainer);
// 先计算所有标签的总宽度
let tagsTotalWidth = 0;
tags.forEach((tag) => {
const tagEl = document.createElement("span");
tagEl.className = "ant-tag ant-tag-default";
const tagName = typeof tag === "string" ? tag : tag.name;
tagEl.textContent = tagName;
measureContainer.appendChild(tagEl);
tagsTotalWidth = measureContainer.offsetWidth;
});

const containerWidth = container.offsetWidth;
const availableWidth = containerWidth - 8; // 安全边距
// 如果所有标签都能放下,直接显示全部
if (tagsTotalWidth <= availableWidth) {
setVisibleTags(tags);
setHiddenCount(0);
if (measureContainer.parentNode) {
measureContainer.parentNode.removeChild(measureContainer);
}
return;
}

let visibleCount = 0;
// 如果放不下,需要计算可见标签数量
while (measureContainer.firstChild) {
measureContainer.removeChild(measureContainer.firstChild);
}

tags.forEach((tag, index) => {
const tagEl = document.createElement("span");
tagEl.className = "ant-tag ant-tag-default shrink-0";
const tagName = typeof tag === "string" ? tag : tag.name;
const tagColor = typeof tag === "string" ? undefined : tag.color;
if (tagColor) tagEl.style.color = tagColor;
tagEl.textContent = tagName;
measureContainer.appendChild(tagEl);
let visibleCount = 0;

// 测量当前容器宽度
const currentWidth = measureContainer.offsetWidth;
const needsPlus = index < tags.length - 1;
const targetWidth = availableWidth - (needsPlus ? plusWidth : 0);
tags.forEach((tag, index) => {
const tagEl = document.createElement("span");
tagEl.className = "ant-tag ant-tag-default";
const tagName = typeof tag === "string" ? tag : tag.name;
tagEl.textContent = tagName;
measureContainer.appendChild(tagEl);

if (currentWidth <= targetWidth) {
visibleCount++;
} else {
// 移除这个标签,因为它放不下
measureContainer.removeChild(tagEl);
return false; // 停止循环
}
const currentWidth = measureContainer.offsetWidth;

return true;
});
if (currentWidth <= availableWidth) {
visibleCount++;
} else {
measureContainer.removeChild(tagEl);
return false;
}
return true;
});

// 移除测量容器
container.parentElement.removeChild(measureContainer);
if (measureContainer.parentNode) {
measureContainer.parentNode.removeChild(measureContainer);
}

setVisibleTags(tags.slice(0, visibleCount));
setHiddenCount(tags.length - visibleCount);
}
};
setVisibleTags(tags.slice(0, visibleCount));
setHiddenCount(tags.length - visibleCount);
}, [tags]);

const timer = setTimeout(calculateVisibleTags, 0);
const handleResize = () => calculateVisibleTags();
useLayoutEffect(() => {
calculateVisibleTags();
setInitialized(true);
}, [calculateVisibleTags]);

useLayoutEffect(() => {
const handleResize = () => calculateVisibleTags();
window.addEventListener("resize", handleResize);
return () => {
clearTimeout(timer);
window.removeEventListener("resize", handleResize);
};
}, [tags]);
return () => window.removeEventListener("resize", handleResize);
}, [calculateVisibleTags]);

if (!tags || tags.length === 0) return null;

const displayTags = initialized ? visibleTags : tags;

// 获取隐藏的标签名称
const hiddenTagNames = useMemo(() => {
if (!tags || hiddenCount === 0) return [];
const visibleSet = new Set(visibleTags.map(t => typeof t === 'string' ? t : t.name));
return tags.filter(t => {
const name = typeof t === 'string' ? t : t.name;
return !visibleSet.has(name);
}).map(t => typeof t === 'string' ? t : t.name);
}, [tags, visibleTags, hiddenCount]);

return (
<div
ref={containerRef}
className="inline-flex items-center gap-1 overflow-hidden"
style={{ whiteSpace: "nowrap", flexWrap: "nowrap" } }
>
{visibleTags.map((tag, index) => {
const tagName = typeof tag === "string" ? tag : tag.name;
const tagColor = typeof tag === "string" ? undefined : tag.color;
return (
<Tag
key={`${typeof tag === "string" ? tag : tag.id}-${index}`}
color={tagColor}
className="shrink-0"
>
{tagName}
</Tag>
);
})}
{hiddenCount > 0 && (
<Tooltip title={`还有 ${hiddenCount} 个标签`}>
<Tag className="cursor-pointer bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200 shrink-0">
<div ref={containerRef} className="inline-flex items-center" style={{ whiteSpace: "nowrap", maxWidth: 450 }}>
<div
ref={tagsAreaRef}
className="inline-flex items-center gap-1 flex-1"
style={{ overflow: "hidden", minWidth: 0 }}
>
{displayTags.map((tag, index) => {
const tagName = typeof tag === "string" ? tag : tag.name;
const tagColor = typeof tag === "string" ? undefined : tag.color;
return (
<Tag
key={`${typeof tag === "string" ? tag : tag.id}-${index}`}
color={tagColor}
className="shrink-0"
>
{tagName}
</Tag>
);
})}
</div>
{initialized && hiddenCount > 0 && (
<Popover
content={
<div className="max-w-xs">
<div className="flex flex-wrap gap-1">
{hiddenTagNames.map((name, i) => (
<Tag
key={i}
className="bg-gray-100 border-gray-300 text-gray-600"
>
{name}
</Tag>
))}
</div>
</div>
}
title="更多标签"
trigger="hover"
placement="topLeft"
>
<Tag className="cursor-pointer bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200 shrink-0 ml-1">
+{hiddenCount}
</Tag>
</Tooltip>
</Popover>
)}
</div>
);
Expand Down
111 changes: 111 additions & 0 deletions frontend/src/components/file-preview/CodePreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React, { useMemo } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';

export interface CodePreviewProps {
content?: string;
fileName?: string;
}

// 文件扩展名到 Prism 语言映射
const LANGUAGE_MAP: Record<string, string> = {
// JavaScript/TypeScript
'js': 'javascript',
'jsx': 'jsx',
'ts': 'typescript',
'tsx': 'tsx',
'mjs': 'javascript',

// Python
'py': 'python',
'pyw': 'python',

// Java
'java': 'java',

// C/C++
'c': 'c',
'cpp': 'cpp',
'cc': 'cpp',
'cxx': 'cpp',
'h': 'c',
'hpp': 'cpp',

// C#
'cs': 'csharp',

// Go
'go': 'go',

// Rust
'rs': 'rust',

// PHP
'php': 'php',

// Ruby
'rb': 'ruby',

// Shell
'sh': 'bash',
'bash': 'bash',
'zsh': 'bash',

// SQL
'sql': 'sql',

// HTML/CSS
'html': 'html',
'htm': 'html',
'css': 'css',
'scss': 'scss',
'less': 'less',

// XML
'xml': 'xml',

// YAML
'yaml': 'yaml',
'yml': 'yaml',

// 其他
'txt': 'clike', // 通用类 C 语言
};

export const CodePreview: React.FC<CodePreviewProps> = ({
content = '',
fileName
}) => {
// 根据文件扩展名检测语言
const language = useMemo(() => {
if (!fileName) return 'clike';
const ext = fileName.toLowerCase().split('.').pop() || '';
return LANGUAGE_MAP[ext] || 'clike';
}, [fileName]);

if (!content) {
return (
<div className="flex items-center justify-center h-full text-gray-400">
No content available
</div>
);
}

return (
<div className="h-full overflow-auto">
<SyntaxHighlighter
language={language}
style={vscDarkPlus}
showLineNumbers
customStyle={{
margin: 0,
borderRadius: 0,
fontSize: '0.875rem',
minHeight: '100%'
}}
>
{content}
</SyntaxHighlighter>
</div>
);
};
Loading
Loading