Initial commit: OmniAI Web Frontend

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:38:01 +08:00
commit bedee3ba8d
183 changed files with 94805 additions and 0 deletions
+312
View File
@@ -0,0 +1,312 @@
import {
CopyOutlined,
DeleteOutlined,
DownloadOutlined,
DownOutlined,
ExpandOutlined,
MutedOutlined,
PauseCircleOutlined,
PictureOutlined,
PlayCircleOutlined,
ReloadOutlined,
SaveOutlined,
SoundOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type MouseEvent } from "react";
import type { CanvasOption } from "./canvasTypes";
import { getOptionLabel } from "./canvasUtils";
import { formatCanvasVideoTime } from "./canvasWorkflowDeserialize";
export function CanvasSelectChip({
value,
options,
open,
onToggle,
onChange,
ariaLabel,
className,
compact = false,
}: {
value: string;
options: CanvasOption[];
open: boolean;
onToggle: () => void;
onChange: (value: string) => void;
ariaLabel: string;
className?: string;
compact?: boolean;
}) {
return (
<div className={`canvas-select-chip${open ? " is-open" : ""}${compact ? " canvas-select-chip--compact" : ""}${className ? ` ${className}` : ""}`}>
<button
type="button"
className="canvas-select-chip__trigger"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onToggle();
}}
>
<span className="canvas-select-chip__value">{getOptionLabel(options, value)}</span>
<DownOutlined className="canvas-select-chip__arrow" />
</button>
{open ? (
<div
className="canvas-select-chip__dropdown"
role="listbox"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
{options.map((option, index) => (
<button
key={option.value}
type="button"
className={`canvas-select-chip__option${option.value === value ? " is-active" : ""}`}
role="option"
aria-selected={option.value === value}
style={{ animationDelay: `${index * 18}ms` }}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onChange(option.value);
}}
>
<span className="canvas-select-chip__option-dot" />
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
);
}
export function CanvasNodeVideoPlayer({ src, title, onVideoMeta }: { src: string; title: string; onVideoMeta?: (width: number, height: number) => void }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
}, [src]);
const stopPlayerEvent = (event: MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
};
const syncVideoState = () => {
const video = videoRef.current;
if (!video) return;
setCurrentTime(video.currentTime || 0);
setDuration(Number.isFinite(video.duration) ? video.duration : 0);
setIsMuted(video.muted);
setIsPlaying(!video.paused && !video.ended);
};
const togglePlayback = async (event: MouseEvent<HTMLButtonElement>) => {
stopPlayerEvent(event);
const video = videoRef.current;
if (!video) return;
if (video.paused || video.ended) {
try {
await video.play();
} catch {
setIsPlaying(false);
}
} else {
video.pause();
}
syncVideoState();
};
const toggleMuted = (event: MouseEvent<HTMLButtonElement>) => {
stopPlayerEvent(event);
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(video.muted);
};
const handleSeek = (event: ChangeEvent<HTMLInputElement>) => {
event.stopPropagation();
const video = videoRef.current;
const nextTime = Number(event.target.value);
if (!video || !Number.isFinite(nextTime)) return;
video.currentTime = nextTime;
setCurrentTime(nextTime);
};
return (
<div className={`studio-canvas-video-player${isPlaying ? " is-playing" : ""}`}>
<video
ref={videoRef}
src={src}
playsInline
preload="metadata"
onLoadedMetadata={() => {
syncVideoState();
const video = videoRef.current;
if (video && video.videoWidth > 0 && video.videoHeight > 0 && onVideoMeta) {
onVideoMeta(video.videoWidth, video.videoHeight);
}
}}
onTimeUpdate={syncVideoState}
onPlay={syncVideoState}
onPause={syncVideoState}
onEnded={syncVideoState}
aria-label={title}
/>
<span className="studio-canvas-video-player__shade" aria-hidden="true" />
<button
type="button"
className="studio-canvas-video-player__center"
aria-label={isPlaying ? "Pause video preview" : "Play video preview"}
title={isPlaying ? "Pause" : "Play"}
onMouseDown={stopPlayerEvent}
onClick={togglePlayback}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div className="studio-canvas-video-player__controls" onMouseDown={stopPlayerEvent}>
<button
type="button"
className="studio-canvas-video-player__button"
aria-label={isPlaying ? "Pause video preview" : "Play video preview"}
onClick={togglePlayback}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<span className="studio-canvas-video-player__time">
{formatCanvasVideoTime(currentTime)} / {formatCanvasVideoTime(duration)}
</span>
<input
className="studio-canvas-video-player__seek"
type="range"
min="0"
max={Math.max(duration, 0)}
step="0.1"
value={Math.min(currentTime, duration || currentTime)}
aria-label="Video preview progress"
onMouseDown={stopPlayerEvent}
onChange={handleSeek}
/>
<button
type="button"
className="studio-canvas-video-player__button"
aria-label={isMuted ? "Unmute video preview" : "Mute video preview"}
onClick={toggleMuted}
>
{isMuted ? <MutedOutlined /> : <SoundOutlined />}
</button>
</div>
</div>
);
}
export interface CanvasNodeToolbarAction {
key: string;
label: string;
icon: React.ReactNode;
disabled?: boolean;
loading?: boolean;
}
export function CanvasNodeToolbar({
actions,
onAction,
moreActions,
onMoreAction,
}: {
actions: CanvasNodeToolbarAction[];
onAction: (key: string) => void;
moreActions?: CanvasNodeToolbarAction[];
onMoreAction?: (key: string) => void;
}) {
const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!moreOpen) return;
const handler = (e: globalThis.MouseEvent) => {
if (moreRef.current && !moreRef.current.contains(e.target as Node)) {
setMoreOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [moreOpen]);
return (
<div
className="studio-canvas-node-toolbar"
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{actions.map((action) => (
<button
key={action.key}
type="button"
className={`studio-canvas-node-toolbar__btn${action.disabled ? " is-disabled" : ""}${action.loading ? " is-loading" : ""}`}
disabled={action.disabled || action.loading}
title={action.label}
onClick={(e) => {
e.stopPropagation();
onAction(action.key);
}}
>
{action.icon}
<span>{action.label}</span>
</button>
))}
{moreActions && moreActions.length > 0 && (
<div className="studio-canvas-node-toolbar__more" ref={moreRef}>
<button
type="button"
className={`studio-canvas-node-toolbar__btn studio-canvas-node-toolbar__more-trigger${moreOpen ? " is-open" : ""}`}
title="更多操作"
onClick={(e) => {
e.stopPropagation();
setMoreOpen((v) => !v);
}}
>
<ExpandOutlined />
<span></span>
</button>
{moreOpen && (
<div className="studio-canvas-node-toolbar__dropdown">
{moreActions.map((action) => (
<button
key={action.key}
type="button"
className={`studio-canvas-node-toolbar__dropdown-item${action.disabled ? " is-disabled" : ""}`}
disabled={action.disabled}
onClick={(e) => {
e.stopPropagation();
setMoreOpen(false);
onMoreAction?.(action.key);
}}
>
{action.icon}
<span>{action.label}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}