文件预览支持图片显示

This commit is contained in:
beibeizi committed 2026-03-16 20:49:19 +08:00
1 parent e85d25199d
commit 17456095eb
1 file changed
+145 -30
+145 -30
View File
@@ -1,5 +1,5 @@
import React from "react";
import { getSystemOverview, readFile, writeFile, SystemStats, NetworkInfo, ProcessInfo, DiskInfo } from "../lib/api";
import { downloadFile, getSystemOverview, readFile, writeFile, SystemStats, NetworkInfo, ProcessInfo, DiskInfo } from "../lib/api";
import { useApp } from "../context/AppContext";
import type { NetworkProfile } from "../context/AppContext";
import { useToast } from "./Toast";
@@ -27,6 +27,16 @@ function formatSpeed(bytesPerSec: number): string {
return parseFloat((bytesPerSec / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "tif", "tiff"]);
function isImagePath(path: string): boolean {
const normalized = path.split("?")[0].split("#")[0];
const lastDot = normalized.lastIndexOf(".");
if (lastDot === -1) return false;
const ext = normalized.slice(lastDot + 1).toLowerCase();
return IMAGE_EXTENSIONS.has(ext);
}
type ProgressBarProps = {
percent: number;
isDark: boolean;
@@ -85,6 +95,8 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
const [disks, setDisks] = React.useState<DiskInfo[]>([]);
const [fileContent, setFileContent] = React.useState<string>("");
const [originalContent, setOriginalContent] = React.useState<string>("");
const [imageUrl, setImageUrl] = React.useState<string | null>(null);
const [imageError, setImageError] = React.useState<string | null>(null);
const [fileLoading, setFileLoading] = React.useState(false);
const [fileTooLarge, setFileTooLarge] = React.useState(false);
const [fileSize, setFileSize] = React.useState(0);
@@ -116,15 +128,83 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
}
}, [sessionId, t]);
// 读取文件内容
const isImageFile = React.useMemo(() => {
return selectedFilePath ? isImagePath(selectedFilePath) : false;
}, [selectedFilePath]);
const revokeImageUrl = React.useCallback((url: string | null) => {
if (url) {
URL.revokeObjectURL(url);
}
}, []);
// 读取文件内容或加载图片
React.useEffect(() => {
let cancelled = false;
if (!selectedFilePath) {
setFileContent("");
setOriginalContent("");
setFileTooLarge(false);
return;
setImageError(null);
setFileSize(0);
setImageUrl((prev) => {
revokeImageUrl(prev);
return null;
});
return () => {
cancelled = true;
};
}
if (isImageFile) {
setFileLoading(true);
setFileTooLarge(false);
setFileContent("");
setOriginalContent("");
setImageError(null);
const loadImage = async () => {
try {
const blob = await downloadFile(sessionId, selectedFilePath);
if (cancelled) {
return;
}
const url = URL.createObjectURL(blob);
setImageUrl((prev) => {
revokeImageUrl(prev);
return url;
});
setFileSize(blob.size);
} catch (err) {
if (cancelled) {
return;
}
setImageError(err instanceof Error ? err.message : t("图片加载失败", "Failed to load image"));
setImageUrl((prev) => {
revokeImageUrl(prev);
return null;
});
} finally {
if (!cancelled) {
setFileLoading(false);
}
}
};
loadImage();
return () => {
cancelled = true;
};
}
setImageError(null);
setImageUrl((prev) => {
revokeImageUrl(prev);
return null;
});
const loadFile = async (force = false) => {
setFileLoading(true);
try {
@@ -144,15 +224,28 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
setFileContent("");
setOriginalContent("");
} finally {
setFileLoading(false);
if (!cancelled) {
setFileLoading(false);
}
}
};
loadFile();
}, [sessionId, selectedFilePath, push, t]);
return () => {
cancelled = true;
};
}, [sessionId, selectedFilePath, isImageFile, push, t, revokeImageUrl, downloadFile]);
React.useEffect(() => {
return () => {
revokeImageUrl(imageUrl);
};
}, [imageUrl, revokeImageUrl]);
const handleLoadLargeFile = React.useCallback(async () => {
if (!selectedFilePath) return;
if (isImageFile) return;
setFileLoading(true);
try {
const result = await readFile(sessionId, selectedFilePath, true);
@@ -164,7 +257,7 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
} finally {
setFileLoading(false);
}
}, [sessionId, selectedFilePath, push, t]);
}, [sessionId, selectedFilePath, push, t, isImageFile]);
// 保存文件
const handleSaveFile = React.useCallback(async () => {
@@ -420,46 +513,68 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
{selectedFilePath && (
<div className={`pt-4 ${isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
{isCompact ? (
renderSectionHeader("file", t("文件预览", "File Preview"))
renderSectionHeader("file", isImageFile ? t("图片预览", "Image Preview") : t("文件预览", "File Preview"))
) : (
<div className="flex justify-between items-center mb-2">
<h3 className={`text-sm font-semibold ${isDark ? "text-slate-200" : "text-slate-700"}`}>
{t("文件预览", "File Preview")}
{isImageFile ? t("图片预览", "Image Preview") : t("文件预览", "File Preview")}
</h3>
<button
onClick={handleSaveFile}
disabled={fileContent === originalContent || fileTooLarge}
className={`px-2 py-1 text-xs rounded ${
fileContent === originalContent || fileTooLarge
? "opacity-50 cursor-not-allowed"
: (isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400")
} text-white`}
>
{t("保存", "Save")}
</button>
{!isImageFile ? (
<button
onClick={handleSaveFile}
disabled={fileContent === originalContent || fileTooLarge}
className={`px-2 py-1 text-xs rounded ${
fileContent === originalContent || fileTooLarge
? "opacity-50 cursor-not-allowed"
: (isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400")
} text-white`}
>
{t("保存", "Save")}
</button>
) : null}
</div>
)}
{!isCompact || !collapsedSections.file ? (
<>
{isCompact ? (
<div className="flex justify-end mb-2">
<button
onClick={handleSaveFile}
disabled={fileContent === originalContent || fileTooLarge}
className={`px-3 py-2 text-sm rounded ${
fileContent === originalContent || fileTooLarge
? "opacity-50 cursor-not-allowed"
: (isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400")
} text-white`}
>
{t("保存", "Save")}
</button>
{!isImageFile ? (
<button
onClick={handleSaveFile}
disabled={fileContent === originalContent || fileTooLarge}
className={`px-3 py-2 text-sm rounded ${
fileContent === originalContent || fileTooLarge
? "opacity-50 cursor-not-allowed"
: (isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400")
} text-white`}
>
{t("保存", "Save")}
</button>
) : null}
</div>
) : null}
{fileLoading ? (
<div className={`flex items-center justify-center text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`} style={{ height: "172px" }}>
<span className="animate-spin mr-2">⟳</span> {t("加载中...", "Loading...")}
</div>
) : isImageFile ? (
imageError ? (
<div className={`flex items-center justify-center text-xs ${isDark ? "text-rose-400" : "text-rose-500"}`} style={{ height: "172px" }}>
{imageError}
</div>
) : imageUrl ? (
<div className={`flex items-center justify-center rounded border ${isDark ? "border-slate-700 bg-slate-900/40" : "border-slate-200 bg-slate-50"}`} style={{ height: "172px" }}>
<img
src={imageUrl}
alt={selectedFilePath}
className="max-h-full max-w-full object-contain"
/>
</div>
) : (
<div className={`flex items-center justify-center text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`} style={{ height: "172px" }}>
{t("暂无图片内容", "No image preview")}
</div>
)
) : fileTooLarge ? (
<div className={`flex flex-col items-center justify-center text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`} style={{ height: "172px" }}>
<p className="mb-2">{t(`文件过大 (${(fileSize / 1024 / 1024).toFixed(2)} MB)`, `File too large (${(fileSize / 1024 / 1024).toFixed(2)} MB)`)}</p>