修正文件处理

main
mula.liu 2026-03-02 12:38:01 +08:00
parent d20bdc959f
commit a71b092ffb
2 changed files with 76 additions and 24 deletions

View File

@ -1395,6 +1395,20 @@
width: min(1080px, 95vw);
}
.modal-preview-fullscreen {
width: 100vw;
max-width: 100vw;
height: 100vh;
max-height: 100vh;
margin: 0;
border-radius: 0;
}
.modal-preview-fullscreen .workspace-preview-body {
min-height: calc(100vh - 170px);
max-height: calc(100vh - 170px);
}
.workspace-preview-body {
border: 1px solid var(--line);
border-radius: 10px;

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
import axios from 'axios';
import { Activity, Boxes, Check, Clock3, EllipsisVertical, Eye, EyeOff, FileText, FolderOpen, Hammer, MessageSquareText, Paperclip, Plus, Power, PowerOff, RefreshCw, Repeat2, Save, Settings2, SlidersHorizontal, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
import { Activity, Boxes, Check, Clock3, EllipsisVertical, Eye, EyeOff, FileText, FolderOpen, Hammer, Maximize2, MessageSquareText, Minimize2, Paperclip, Plus, Power, PowerOff, RefreshCw, Repeat2, Save, Settings2, SlidersHorizontal, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
@ -208,7 +208,7 @@ function normalizeRuntimeState(s?: string) {
function isPreviewableWorkspaceFile(node: WorkspaceNode) {
if (node.type !== 'file') return false;
const ext = (node.ext || '').toLowerCase();
return ['.md', '.json', '.log', '.txt', '.csv', '.pdf', '.png', '.jpg', '.jpeg', '.webp'].includes(ext);
return ['.md', '.json', '.log', '.txt', '.csv', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].includes(ext);
}
function isPdfPath(path: string) {
@ -220,9 +220,16 @@ function isImagePath(path: string) {
return normalized.endsWith('.png') || normalized.endsWith('.jpg') || normalized.endsWith('.jpeg') || normalized.endsWith('.webp');
}
function isOfficePath(path: string) {
const normalized = String(path || '').trim().toLowerCase();
return ['.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) =>
normalized.endsWith(ext),
);
}
function isPreviewableWorkspacePath(path: string) {
const normalized = String(path || '').trim().toLowerCase();
return ['.md', '.json', '.log', '.txt', '.csv', '.pdf', '.png', '.jpg', '.jpeg', '.webp'].some((ext) =>
return ['.md', '.json', '.log', '.txt', '.csv', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) =>
normalized.endsWith(ext),
);
}
@ -252,7 +259,7 @@ function decorateWorkspacePathsForMarkdown(text: string) {
'[$1]($2)',
);
const workspacePathPattern =
/\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp)\b/gi;
/\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b/gi;
return normalizedExistingLinks.replace(workspacePathPattern, (fullPath) => {
const normalized = normalizeDashboardAttachmentPath(fullPath);
if (!normalized) return fullPath;
@ -366,6 +373,7 @@ export function BotDashboardModule({
const [workspaceParentPath, setWorkspaceParentPath] = useState<string | null>(null);
const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false);
const [workspacePreview, setWorkspacePreview] = useState<WorkspacePreviewState | null>(null);
const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false);
const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(true);
const [pendingAttachments, setPendingAttachments] = useState<string[]>([]);
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
@ -396,9 +404,32 @@ export function BotDashboardModule({
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) =>
`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(filePath)}${forceDownload ? '&download=1' : ''}`;
const closeWorkspacePreview = () => {
setWorkspacePreview(null);
setWorkspacePreviewFullscreen(false);
};
const triggerWorkspaceFileDownload = (filePath: string) => {
if (!selectedBotId) return;
const normalized = String(filePath || '').trim();
if (!normalized) return;
const filename = normalized.split('/').pop() || 'workspace-file';
const link = document.createElement('a');
link.href = buildWorkspaceDownloadHref(normalized, true);
link.download = filename;
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
link.remove();
};
const openWorkspacePathFromChat = (path: string) => {
const normalized = String(path || '').trim();
if (!normalized) return;
if (isPdfPath(normalized) || isOfficePath(normalized)) {
triggerWorkspaceFileDownload(normalized);
return;
}
if (!isPreviewableWorkspacePath(normalized)) {
notify(fileNotPreviewableLabel, { tone: 'warning' });
return;
@ -409,7 +440,7 @@ export function BotDashboardModule({
const source = String(text || '');
if (!source) return [source];
const pattern =
/\[(\/root\/\.nanobot\/workspace\/[^\]]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp))\]\((https:\/\/workspace\.local\/open\/[^)\s]+)\)|\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp)\b|https:\/\/workspace\.local\/open\/[^\s)]+/gi;
/\[(\/root\/\.nanobot\/workspace\/[^\]]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps))\]\((https:\/\/workspace\.local\/open\/[^)\s]+)\)|\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b|https:\/\/workspace\.local\/open\/[^\s)]+/gi;
const nodes: ReactNode[] = [];
let lastIndex = 0;
let matchIndex = 0;
@ -485,11 +516,7 @@ export function BotDashboardModule({
href="#"
onClick={(event) => {
event.preventDefault();
if (!isPreviewableWorkspacePath(workspacePath)) {
notify(fileNotPreviewableLabel, { tone: 'warning' });
return;
}
void openWorkspaceFilePreview(workspacePath);
openWorkspacePathFromChat(workspacePath);
}}
{...props}
>
@ -696,17 +723,20 @@ export function BotDashboardModule({
<div className="ops-chat-attachments">
{(item.attachments || []).map((rawPath) => {
const filePath = normalizeDashboardAttachmentPath(rawPath);
const isPdf = isPdfPath(filePath);
const href = `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(filePath)}${isPdf ? '&download=1' : ''}`;
const filename = filePath.split('/').pop() || filePath;
return (
<a
key={`${item.ts}-${filePath}`}
className="ops-attach-link mono"
href={href}
target="_blank"
rel="noreferrer"
download={isPdf ? filename : undefined}
href="#"
onClick={(event) => {
event.preventDefault();
if (isPdfPath(filePath) || isOfficePath(filePath)) {
triggerWorkspaceFileDownload(filePath);
return;
}
openWorkspacePathFromChat(filePath);
}}
>
{filename}
</a>
@ -822,9 +852,9 @@ export function BotDashboardModule({
const openWorkspaceFilePreview = async (path: string) => {
if (!selectedBotId || !path) return;
const normalizedPath = String(path || '').trim();
if (isPdfPath(normalizedPath)) {
const href = `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(normalizedPath)}&download=1`;
window.open(href, '_blank', 'noopener,noreferrer');
setWorkspacePreviewFullscreen(false);
if (isPdfPath(normalizedPath) || isOfficePath(normalizedPath)) {
triggerWorkspaceFileDownload(normalizedPath);
return;
}
if (isImagePath(normalizedPath)) {
@ -1565,14 +1595,14 @@ export function BotDashboardModule({
}
const previewable = isPreviewableWorkspaceFile(node);
const pdfFile = String(node.ext || '').toLowerCase() === '.pdf';
const downloadOnlyFile = isPdfPath(node.path) || isOfficePath(node.path);
rendered.push(
<button
key={key}
className={`workspace-entry file ${previewable ? '' : 'disabled'}`}
disabled={!previewable || workspaceFileLoading}
onClick={() => void openWorkspaceFilePreview(node.path)}
title={previewable ? (pdfFile ? t.download : t.previewTitle) : t.fileNotPreviewable}
title={previewable ? (downloadOnlyFile ? t.download : t.previewTitle) : t.fileNotPreviewable}
>
<FileText size={14} />
<span className="workspace-entry-name">{node.name}</span>
@ -2499,8 +2529,8 @@ export function BotDashboardModule({
)}
{workspacePreview && (
<div className="modal-mask" onClick={() => setWorkspacePreview(null)}>
<div className="modal-card modal-preview" onClick={(e) => e.stopPropagation()}>
<div className="modal-mask" onClick={closeWorkspacePreview}>
<div className={`modal-card modal-preview ${workspacePreviewFullscreen ? 'modal-preview-fullscreen' : ''}`} onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row">
<h3>{t.filePreview}</h3>
<span className="modal-sub mono">{workspacePreview.path}</span>
@ -2532,6 +2562,14 @@ export function BotDashboardModule({
<div className="row-between">
<span className="workspace-preview-meta mono">{workspacePreview.ext || '-'}</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<button
className="btn btn-secondary"
onClick={() => setWorkspacePreviewFullscreen((v) => !v)}
title={workspacePreviewFullscreen ? (isZh ? '退出全屏' : 'Exit full screen') : (isZh ? '全屏预览' : 'Full screen')}
aria-label={workspacePreviewFullscreen ? (isZh ? '退出全屏' : 'Exit full screen') : (isZh ? '全屏预览' : 'Full screen')}
>
{workspacePreviewFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
<a
className="btn btn-secondary"
href={`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(workspacePreview.path)}&download=1`}
@ -2541,7 +2579,7 @@ export function BotDashboardModule({
>
{t.download}
</a>
<button className="btn btn-primary" onClick={() => setWorkspacePreview(null)}>{t.close}</button>
<button className="btn btn-primary" onClick={closeWorkspacePreview}>{t.close}</button>
</div>
</div>
</div>