feat:全屏功能

This commit is contained in:
肖应宇 2026-03-17 16:40:02 +08:00
parent a8d1c8367f
commit 32f581cf50
7 changed files with 194 additions and 22 deletions

View File

@ -24,6 +24,7 @@ import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
@ -36,6 +37,7 @@ export default function ChatPage() {
const { t } = useI18n(); const { t } = useI18n();
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const [showExitDialog, setShowExitDialog] = useState(false); const [showExitDialog, setShowExitDialog] = useState(false);
const { fullscreen } = useArtifacts();
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat(); const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
useSpecificChatMode(); useSpecificChatMode();
@ -85,7 +87,7 @@ export default function ChatPage() {
<div className="bg-background relative flex size-full min-h-0 justify-between"> <div className="bg-background relative flex size-full min-h-0 justify-between">
<header <header
className={cn( className={cn(
"absolute top-0 right-0 left-0 z-30 grid h-[58px] shrink-0 grid-cols-3 items-center rounded-t-[20px] px-[20px] py-[15px]", "absolute top-0 right-0 left-0 z-30 mx-[20px] grid h-[58px] shrink-0 grid-cols-3 items-center rounded-t-[20px] border-b py-[15px]",
isNewThread isNewThread
? "bg-background/0 backdrop-blur-none" ? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur", : "bg-background/80 shadow-xs backdrop-blur",
@ -130,7 +132,7 @@ export default function ChatPage() {
variant="ghost" variant="ghost"
className="h-full px-[10px] py-[5px] text-sm font-medium" className="h-full px-[10px] py-[5px] text-sm font-medium"
> >
<ListTodoIcon className="size-4" /> Todo <ListTodoIcon className="size-4" /> To-dos
</Button> </Button>
} }
/> />
@ -147,14 +149,19 @@ export default function ChatPage() {
</div> </div>
</main> </main>
</div> </div>
<div className="pointer-events-none fixed right-0 bottom-3 left-0 z-30 flex justify-center px-4"> <div
className={cn(
"pointer-events-none fixed right-0 bottom-3 left-0 z-30 flex justify-center px-4",
"transition-all duration-300 ease-in-out",
fullscreen
? "pointer-events-none translate-y-4 opacity-0"
: "translate-y-0 opacity-100",
)}
>
<div <div
className={cn( className={cn(
"pointer-events-auto relative w-full max-w-[720px]", "pointer-events-auto relative w-full max-w-[720px]",
isNewThread && "top-[-65px] -translate-y-[calc(50vh-96px)]", isNewThread && "top-[-65px] -translate-y-[calc(50vh-96px)]",
// isNewThread
// ? "max-w-(--container-width-sm)"
// : "max-w-(--container-width-md)",
)} )}
> >
<InputBox <InputBox

View File

@ -16,7 +16,7 @@ export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => ( export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div <div
className={cn( className={cn(
"bg-background flex flex-col overflow-hidden rounded-[20px] px-[20px] pt-[15px]", "bg-background flex min-w-(--container-width-sm) flex-col overflow-hidden rounded-[20px] px-[20px] pt-[15px]",
className, className,
)} )}
{...props} {...props}

View File

@ -15,6 +15,7 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
type HTMLAttributes, type HTMLAttributes,
} from "react"; } from "react";
@ -57,7 +58,8 @@ export function ArtifactFileDetail({
threadId: string; threadId: string;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select, fullscreen, setFullscreen } =
useArtifacts();
const isWriteFile = useMemo(() => { const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:"); return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]); }, [filepathFromProps]);
@ -105,6 +107,33 @@ export function ArtifactFileDetail({
const [isInstalling, setIsInstalling] = useState(false); const [isInstalling, setIsInstalling] = useState(false);
const [zoom, setZoom] = useState(100); const [zoom, setZoom] = useState(100);
const { isMock } = useThread(); const { isMock } = useThread();
// 全屏切换处理
const handleFullscreenToggle = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
console.error("无法进入全屏模式:", err);
});
setFullscreen(true);
} else {
document.exitFullscreen().catch((err) => {
console.error("无法退出全屏模式:", err);
});
setFullscreen(false);
}
}, [setFullscreen]);
// 监听全屏变化
useEffect(() => {
const handleFullscreenChange = () => {
setFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
}, [setFullscreen]);
useEffect(() => { useEffect(() => {
if (isSupportPreview) { if (isSupportPreview) {
setViewMode("preview"); setViewMode("preview");
@ -172,11 +201,10 @@ export function ArtifactFileDetail({
)} )}
</ArtifactTitle> </ArtifactTitle>
</div> </div>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end overflow-hidden">
{/* 放大缩小选择器 */} {/* 放大缩小选择器 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} /> <ArtifactZoomSelector value={zoom} onChange={setZoom} />
<ArtifactActions> <ArtifactActions>
{/* 新界面打开的按钮 */}
{/* {!isWriteFile && filepath.endsWith(".skill") && ( {/* {!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}> <Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction <ArtifactAction
@ -191,7 +219,8 @@ export function ArtifactFileDetail({
/> />
</Tooltip> </Tooltip>
)} */} )} */}
{!isWriteFile && ( {/* 新界面打开的按钮 */}
{/* {!isWriteFile && (
<a href={urlOfArtifact({ filepath, threadId })} target="_blank"> <a href={urlOfArtifact({ filepath, threadId })} target="_blank">
<ArtifactAction <ArtifactAction
icon={SquareArrowOutUpRightIcon} icon={SquareArrowOutUpRightIcon}
@ -199,10 +228,10 @@ export function ArtifactFileDetail({
tooltip={t.common.openInNewWindow} tooltip={t.common.openInNewWindow}
/> />
</a> </a>
)} )} */}
{/* 复制按钮 */}
{isCodeFile && ( {isCodeFile && (
<ArtifactAction <ArtifactAction
icon={CopyIcon}
label={t.clipboard.copyToClipboard} label={t.clipboard.copyToClipboard}
disabled={!content} disabled={!content}
onClick={async () => { onClick={async () => {
@ -215,26 +244,143 @@ export function ArtifactFileDetail({
} }
}} }}
tooltip={t.clipboard.copyToClipboard} tooltip={t.clipboard.copyToClipboard}
/> >
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 2H13C14.1046 2 15 2.89543 15 4V13"
stroke="#666666"
stroke-linecap="round"
stroke-linejoin="round"
/>
<rect
x="2.5"
y="4.5"
width="10"
height="11"
rx="1.5"
stroke="#666666"
/>
</svg>
</ArtifactAction>
)} )}
{/* 下载按钮 */}
{!isWriteFile && ( {!isWriteFile && (
<a <a
href={urlOfArtifact({ filepath, threadId, download: true })} href={urlOfArtifact({ filepath, threadId, download: true })}
target="_blank" target="_blank"
> >
<ArtifactAction <ArtifactAction
icon={DownloadIcon}
label={t.common.download} label={t.common.download}
tooltip={t.common.download} tooltip={t.common.download}
/> >
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 9V14C16 15.1046 15.1046 16 14 16H4C2.89543 16 2 15.1046 2 14V9"
stroke="#666666"
stroke-linecap="round"
/>
<path
d="M9 2V13M9 13L5 9M9 13L13 9"
stroke="#666666"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</ArtifactAction>
</a> </a>
)} )}
{/* 全屏按钮 */}
<ArtifactAction
label={
fullscreen ? t.common.closeFullScreen : t.common.fullScreen
}
onClick={handleFullscreenToggle}
tooltip={
fullscreen ? t.common.closeFullScreen : t.common.fullScreen
}
>
{fullscreen ? (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 2V4C6 5.10457 5.10457 6 4 6H2"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M6 16V14C6 12.8954 5.10457 12 4 12H2"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 2V4C12 5.10457 12.8954 6 14 6H16"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 16V14C12 12.8954 12.8954 12 14 12H16"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.75 15.5H4.5C3.39543 15.5 2.5 14.6046 2.5 13.5V12.25M2.5 5.75V4.5C2.5 3.39543 3.39543 2.5 4.5 2.5H5.75M12.25 2.5H13.5C14.6046 2.5 15.5 3.39543 15.5 4.5V5.75M15.5 12.25V13.5C15.5 14.6046 14.6046 15.5 13.5 15.5H12.25"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
)}
</ArtifactAction>
{/* 关闭按钮 */}
<ArtifactAction <ArtifactAction
icon={XIcon}
label={t.common.close} label={t.common.close}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
tooltip={t.common.close} tooltip={t.common.close}
/> >
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 14L14 4M4 4L14 14"
stroke="#666666"
stroke-linecap="round"
/>
</svg>
</ArtifactAction>
</ArtifactActions> </ArtifactActions>
</div> </div>
</ArtifactHeader> </ArtifactHeader>

View File

@ -21,6 +21,9 @@ export interface ArtifactsContextType {
open: boolean; open: boolean;
autoOpen: boolean; autoOpen: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
fullscreen: boolean;
setFullscreen: (fullscreen: boolean) => void;
} }
const ArtifactsContext = createContext<ArtifactsContextType | undefined>( const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
@ -39,6 +42,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
); );
const [autoOpen, setAutoOpen] = useState(true); const [autoOpen, setAutoOpen] = useState(true);
const [fullscreen, setFullscreen] = useState(false);
const { setOpen: setSidebarOpen } = useSidebar(); const { setOpen: setSidebarOpen } = useSidebar();
const select = useCallback( const select = useCallback(
@ -78,6 +82,9 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
selectedArtifact, selectedArtifact,
select, select,
deselect, deselect,
fullscreen,
setFullscreen,
}; };
return ( return (

View File

@ -21,6 +21,7 @@ import {
} from "../artifacts"; } from "../artifacts";
import { useThread } from "../messages/context"; import { useThread } from "../messages/context";
const FULLSCREEN_MODE = { chat: 0, artifacts: 100 };
const CLOSE_MODE = { chat: 100, artifacts: 0 }; const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 50, artifacts: 50 }; const OPEN_MODE = { chat: 50, artifacts: 50 };
@ -40,6 +41,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
select: selectArtifact, select: selectArtifact,
deselect, deselect,
selectedArtifact, selectedArtifact,
fullscreen,
} = useArtifacts(); } = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
@ -88,13 +90,15 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
useEffect(() => { useEffect(() => {
if (layoutRef.current) { if (layoutRef.current) {
if (artifactPanelOpen) { if (fullscreen) {
layoutRef.current.setLayout(FULLSCREEN_MODE);
} else if (artifactPanelOpen) {
layoutRef.current.setLayout(OPEN_MODE); layoutRef.current.setLayout(OPEN_MODE);
} else { } else {
layoutRef.current.setLayout(CLOSE_MODE); layoutRef.current.setLayout(CLOSE_MODE);
} }
} }
}, [artifactPanelOpen]); }, [artifactPanelOpen, fullscreen]);
return ( return (
<ResizablePanelGroup <ResizablePanelGroup
@ -103,7 +107,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
groupRef={layoutRef} groupRef={layoutRef}
> >
<ResizablePanel <ResizablePanel
className="relative overflow-hidden rounded-t-[20px]" className={cn(
"relative overflow-hidden rounded-t-[20px] transition-opacity duration-300 ease-in-out",
fullscreen && "pointer-events-none opacity-0",
)}
defaultSize={100} defaultSize={100}
id="chat" id="chat"
> >
@ -111,8 +118,9 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
</ResizablePanel> </ResizablePanel>
<ResizableHandle <ResizableHandle
className={cn( className={cn(
"opacity-33 hover:opacity-100", "opacity-33 transition-opacity duration-300 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0", !artifactPanelOpen && "pointer-events-none opacity-0",
fullscreen && "pointer-events-none opacity-0",
)} )}
/> />
<ResizablePanel <ResizablePanel

View File

@ -15,6 +15,8 @@ export interface Translations {
share: string; share: string;
openInNewWindow: string; openInNewWindow: string;
close: string; close: string;
fullScreen: string;
closeFullScreen: string;
more: string; more: string;
search: string; search: string;
download: string; download: string;

View File

@ -26,6 +26,8 @@ export const zhCN: Translations = {
share: "分享", share: "分享",
openInNewWindow: "在新窗口打开", openInNewWindow: "在新窗口打开",
close: "关闭", close: "关闭",
fullScreen: "全屏",
closeFullScreen: "关闭全屏",
more: "更多", more: "更多",
search: "搜索", search: "搜索",
download: "下载", download: "下载",