feat: 更改系统名的. 为 · ;调整结果布局大小;为侧边栏添加按键显示逻辑ctl+shift+l+d

This commit is contained in:
肖应宇 2026-03-30 12:56:39 +08:00
parent 7b3142c580
commit b6b373ffe2
4 changed files with 251 additions and 10 deletions

View File

@ -2,7 +2,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Toaster } from "@/components/ui/sonner";
@ -16,6 +16,9 @@ export default function WorkspaceLayout({
}: Readonly<{ children: React.ReactNode }>) {
const [settings, setSettings] = useLocalSettings();
const [open, setOpen] = useState(false); // SSR default: open (matches server render)
const [showWorkspaceSidebar, setShowWorkspaceSidebar] = useState(false);
const pressedKeysRef = useRef<Set<string>>(new Set());
const comboTriggeredRef = useRef(false);
const searchParams = useSearchParams();
// iframe 技能模式mode=skill时隐藏侧边栏
@ -28,6 +31,69 @@ export default function WorkspaceLayout({
useEffect(() => {
setOpen(!settings.layout.sidebar_collapsed);
}, [settings.layout.sidebar_collapsed]);
useEffect(() => {
const resetComboTrigger = () => {
comboTriggeredRef.current = false;
};
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target?.isContentEditable
) {
return;
}
pressedKeysRef.current.add(event.key.toLowerCase());
const hasCtrlOrMeta = event.ctrlKey || event.metaKey;
const hasShift = event.shiftKey;
const hasL = pressedKeysRef.current.has("l");
const hasD = pressedKeysRef.current.has("d");
if (
hasCtrlOrMeta &&
hasShift &&
hasL &&
hasD &&
!comboTriggeredRef.current
) {
event.preventDefault();
comboTriggeredRef.current = true;
setShowWorkspaceSidebar((prev) => !prev);
}
};
const handleKeyUp = (event: KeyboardEvent) => {
pressedKeysRef.current.delete(event.key.toLowerCase());
if (
!pressedKeysRef.current.has("l") ||
!pressedKeysRef.current.has("d") ||
(!event.ctrlKey && !event.metaKey) ||
!event.shiftKey
) {
resetComboTrigger();
}
};
const handleBlur = () => {
pressedKeysRef.current.clear();
resetComboTrigger();
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
};
}, []);
const handleOpenChange = useCallback(
(open: boolean) => {
setOpen(open);
@ -42,8 +108,7 @@ export default function WorkspaceLayout({
open={open}
onOpenChange={handleOpenChange}
>
{/* MARK: 生产环境下必须注释才能提交!!!! */}
{/* {!isSkillMode && <WorkspaceSidebar className="" />} */}
{!isSkillMode && showWorkspaceSidebar && <WorkspaceSidebar className="" />}
<SidebarInset className="min-w-0">{children}</SidebarInset>
</SidebarProvider>
<Toaster

View File

@ -144,6 +144,6 @@ export const ArtifactContent = ({
...props
}: ArtifactContentProps) => (
<div className="min-h-0 rounded-[10px] flex-1 overflow-auto">
<div className={cn("mb-[150px] p-4", className)} {...props} />
<div className={cn("mb-[150px] h-full p-4", className)} {...props} />
</div>
);

View File

@ -52,6 +52,7 @@ export function ArtifactFileDetail({
const { t } = useI18n();
const { artifacts, setOpen, select, fullscreen, setFullscreen } =
useArtifacts();
const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
@ -62,6 +63,8 @@ export function ArtifactFileDetail({
}
return filepathFromProps;
}, [filepathFromProps, isWriteFile]);
// 获取文件名(不含路径)
const fileName = useMemo(() => getFileName(filepath), [filepath]);
const isSkillFile = useMemo(() => {
return filepath.endsWith(".skill");
}, [filepath]);
@ -80,6 +83,19 @@ export function ArtifactFileDetail({
const previewable = useMemo(() => {
return (language === "html" && !isWriteFile) || language === "markdown";
}, [isWriteFile, language]);
const artifactUrl = useMemo(() => {
return urlOfArtifact({ filepath, threadId });
}, [filepath, threadId]);
const artifactPreviewKind = useMemo(() => {
return getArtifactPreviewKind(filepath);
}, [filepath]);
const artifactViewerSrcDoc = useMemo(() => {
return buildArtifactViewerSrcDoc({
artifactUrl,
fileName,
kind: artifactPreviewKind,
});
}, [artifactUrl, fileName, artifactPreviewKind]);
const { content } = useArtifactContent({
threadId,
filepath: filepathFromProps,
@ -99,8 +115,6 @@ export function ArtifactFileDetail({
const [isInstalling, setIsInstalling] = useState(false);
const [zoom, setZoom] = useState(80);
// 获取文件名(不含路径)
const fileName = useMemo(() => getFileName(filepath), [filepath]);
// 是否可以转换为docx/pdf仅markdown文件支持
const canConvertToDocxPdf = language === "markdown";
@ -444,7 +458,7 @@ export function ArtifactFileDetail({
</ArtifactHeader>
<ArtifactContent className=" rounded-b-[10px] bg-white p-0">
{/* 遮挡多余的滚动顶部 */}
<div className="absolute w-[calc(100%-40px)] bg-white z-20 h-5 rounded-t-[10px] top-[57px]"></div>
{/* <div className="absolute w-[calc(100%-40px)] bg-white z-20 h-5 rounded-t-[10px] top-[57px]"></div> */}
{previewable &&
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
@ -464,8 +478,10 @@ export function ArtifactFileDetail({
)}
{!isCodeFile && (
<iframe
className="size-full"
src={urlOfArtifact({ filepath, threadId })}
className="size-full border-0"
srcDoc={artifactViewerSrcDoc}
sandbox="allow-same-origin allow-scripts allow-downloads"
title={`Artifact preview: ${fileName}`}
/>
)}
</ArtifactContent>
@ -514,6 +530,166 @@ export function ArtifactFilePreview({
return null;
}
type ArtifactPreviewKind = "html" | "image" | "video" | "audio" | "pdf" | "other";
function getArtifactPreviewKind(filepath: string): ArtifactPreviewKind {
const lower = filepath.toLowerCase();
if (/\.(html?|xhtml)$/.test(lower)) return "html";
if (/\.(png|jpe?g|gif|webp|bmp|svg|ico|avif|tiff?)$/.test(lower)) return "image";
if (/\.(mp4|webm|ogg|mov|m4v)$/.test(lower)) return "video";
if (/\.(mp3|wav|ogg|m4a|aac|flac)$/.test(lower)) return "audio";
if (/\.pdf$/.test(lower)) return "pdf";
return "other";
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function buildArtifactViewerSrcDoc({
artifactUrl,
fileName,
kind,
}: {
artifactUrl: string;
fileName: string;
kind: ArtifactPreviewKind;
}) {
const safeUrl = escapeHtml(artifactUrl);
const safeName = escapeHtml(fileName);
const content = (() => {
if (kind === "image") {
return `<img class="preview image" src="${safeUrl}" alt="${safeName}" />`;
}
if (kind === "video") {
return `<video class="preview media" src="${safeUrl}" controls playsinline preload="metadata"></video>`;
}
if (kind === "audio") {
return `<div class="audio-wrap"><audio class="audio" src="${safeUrl}" controls preload="metadata"></audio></div>`;
}
if (kind === "pdf") {
return `<iframe class="preview frame" src="${safeUrl}#view=FitH"></iframe>`;
}
if (kind === "html") {
return `<iframe class="preview frame" src="${safeUrl}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads"></iframe>`;
}
return `<div class="fallback">
<p class="title">${safeName}</p>
<p class="desc">This file type is not previewable in the custom viewer.</p>
<a class="link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">Open in new tab</a>
</div>`;
})();
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
:root {
--bg: #f8f9fb;
--panel: #ffffff;
--text: #0f172a;
--muted: #667085;
--line: #e4e7ec;
--radius: 12px;
}
* { box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
body {
display: flex;
align-items: stretch;
justify-content: stretch;
overflow: hidden;
padding: 12px;
}
.preview {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
display: block;
}
.image {
object-fit: contain;
object-position: center;
background:
linear-gradient(45deg, #f4f4f5 25%, transparent 25%, transparent 75%, #f4f4f5 75%, #f4f4f5) 0 0/16px 16px,
linear-gradient(45deg, #f4f4f5 25%, transparent 25%, transparent 75%, #f4f4f5 75%, #f4f4f5) 8px 8px/16px 16px,
#fff;
}
.media {
object-fit: contain;
background: #000;
}
.frame {
border: 1px solid var(--line);
}
.audio-wrap {
width: 100%;
height: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.audio {
width: min(720px, 100%);
}
.fallback {
width: 100%;
height: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
display: grid;
place-content: center;
text-align: center;
gap: 10px;
padding: 24px;
}
.title {
margin: 0;
font-size: 16px;
font-weight: 600;
word-break: break-all;
}
.desc {
margin: 0;
font-size: 13px;
color: var(--muted);
}
.link {
color: #2563eb;
text-decoration: none;
font-weight: 600;
}
.link:hover { text-decoration: underline; }
</style>
</head>
<body>
${content}
</body>
</html>`;
}
// 缩放比例选项
const ZOOM_LEVELS = [50, 60, 70, 80, 90, 100, 110, 120, 130, 150, 175, 200];

View File

@ -53,7 +53,7 @@ export const zhCN: Translations = {
// Welcome
welcome: {
greeting: "轻办公.XClaw",
greeting: "轻办公 · XClaw",
description:
"欢迎使用 🦌 DeerFlow一个完全开源的超级智能体。通过内置和自定义的 Skills\nDeerFlow 可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n图片、视频、播客及网页等几乎可以做任何事情。",