feat(assets): 文件夹主题 - 新增多主题皮肤并同步主线
This commit is contained in:
22
web/src/components/ai/generation-tool-card.tsx
Normal file
22
web/src/components/ai/generation-tool-card.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export type GenerationToolStatus = "running" | "completed" | "error" | "cancelled";
|
||||
|
||||
export function GenerationToolCard({ status, isBulk = false, heading, children }: { status: GenerationToolStatus; isBulk?: boolean; heading: ReactNode; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(status !== "completed" || !isBulk);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "completed") setOpen(true);
|
||||
}, [status]);
|
||||
|
||||
return <>
|
||||
<div className="creation-message-heading">
|
||||
{heading}
|
||||
<button type="button" className="ml-auto grid size-7 shrink-0 place-items-center rounded-md text-foreground/45 transition-colors hover:bg-surface-hover hover:text-foreground" aria-label={open ? "收起生成详情" : "展开生成详情"} aria-expanded={open} onClick={() => setOpen((value) => !value)}>
|
||||
<ChevronDown className={`size-4 transition-transform${open ? " rotate-180" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
{open ? children : null}
|
||||
</>;
|
||||
}
|
||||
41
web/src/components/ai/message-reasoning.tsx
Normal file
41
web/src/components/ai/message-reasoning.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Sparkles } from "lucide-react";
|
||||
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
|
||||
export function MessageReasoning({ reasoning, isStreaming }: { reasoning: string; isStreaming: boolean }) {
|
||||
const [open, setOpen] = useState(isStreaming);
|
||||
const [durationSeconds, setDurationSeconds] = useState<number>();
|
||||
const startedAtRef = useRef<number | undefined>(undefined);
|
||||
const autoClosedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreaming) return;
|
||||
startedAtRef.current ??= Date.now();
|
||||
setOpen(true);
|
||||
}, [isStreaming]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming || !startedAtRef.current) return;
|
||||
setDurationSeconds(Math.max(1, Math.ceil((Date.now() - startedAtRef.current) / 1000)));
|
||||
startedAtRef.current = undefined;
|
||||
}, [isStreaming]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming || !open || autoClosedRef.current || durationSeconds === undefined) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
setOpen(false);
|
||||
autoClosedRef.current = true;
|
||||
}, 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [durationSeconds, isStreaming, open]);
|
||||
|
||||
return <div className="mb-2 text-xs text-foreground/55">
|
||||
<button type="button" className="inline-flex min-h-8 items-center gap-1.5 rounded-md px-2 transition-colors hover:bg-surface-hover hover:text-foreground" aria-expanded={open} onClick={() => setOpen((value) => !value)}>
|
||||
<Sparkles className="size-3.5" />
|
||||
<span>{isStreaming ? "正在思考" : durationSeconds ? `已思考 ${durationSeconds} 秒` : "思考过程"}</span>
|
||||
<ChevronDown className={`size-3.5 transition-transform${open ? " rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open ? <div className="mt-2 rounded-md bg-surface-active p-3 text-foreground/70"><AIMessageMarkdown isStreaming={isStreaming}>{reasoning}</AIMessageMarkdown></div> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -2,11 +2,10 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Dropdown } from "antd";
|
||||
import { FileAudio, FileText, MoreHorizontal, Pencil, Plus, SlidersHorizontal, Sparkles, Video } from "lucide-react";
|
||||
|
||||
import type { CanvasFolderStyle, CanvasNodeData } from "@/types/canvas";
|
||||
import { CANVAS_FOLDER_THEME_OPTIONS, resolveCanvasFolderTheme, resolveCanvasFolderThemeCover } from "@/lib/canvas/canvas-folder-theme";
|
||||
import type { CanvasFolderStyle, CanvasFolderTheme, CanvasNodeData } from "@/types/canvas";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
const DEFAULT_FOLDER_COVER = "/images/canvas/folder-default-cover.png";
|
||||
|
||||
const FOLDER_STYLE_OPTIONS: Array<{ key: CanvasFolderStyle; label: string }> = [
|
||||
{ key: "glass", label: "流光玻璃" },
|
||||
{ key: "stacked", label: "内容陈列" },
|
||||
@@ -25,6 +24,7 @@ export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({
|
||||
onToggleCollapsed,
|
||||
onTitleChange,
|
||||
onStyleChange,
|
||||
onThemeChange,
|
||||
}: {
|
||||
data: CanvasNodeData;
|
||||
childNodes: CanvasNodeData[];
|
||||
@@ -34,13 +34,34 @@ export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({
|
||||
onToggleCollapsed: (nodeId: string) => void;
|
||||
onTitleChange: (nodeId: string, title: string) => void;
|
||||
onStyleChange: (nodeId: string, style: CanvasFolderStyle) => void;
|
||||
onThemeChange: (nodeId: string, theme: CanvasFolderTheme) => void;
|
||||
}) {
|
||||
const style = data.metadata?.folder?.style || "glass";
|
||||
const theme = resolveCanvasFolderTheme(data.metadata?.folder?.theme);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [title, setTitle] = useState(data.title);
|
||||
const previewNodes = useMemo(() => childNodes.filter(canPreviewFolderNode).slice(0, 3), [childNodes]);
|
||||
const themeCover = data.metadata?.folder?.themeCover || DEFAULT_FOLDER_COVER;
|
||||
const themeCover = resolveCanvasFolderThemeCover(theme, data.metadata?.folder?.themeCover);
|
||||
const showAdd = style === "glass" || childNodes.length === 0;
|
||||
const folderMenu = {
|
||||
selectedKeys: [`style:${style}`, `theme:${theme}`],
|
||||
items: [
|
||||
{
|
||||
key: "styles",
|
||||
label: "文件夹样式",
|
||||
children: FOLDER_STYLE_OPTIONS.map((item) => ({ key: `style:${item.key}`, label: item.label })),
|
||||
},
|
||||
{
|
||||
key: "themes",
|
||||
label: "主题皮肤",
|
||||
children: CANVAS_FOLDER_THEME_OPTIONS.map((item) => ({ key: `theme:${item.key}`, label: item.label })),
|
||||
},
|
||||
],
|
||||
onClick: ({ key }: { key: string }) => {
|
||||
if (key.startsWith("style:")) onStyleChange(data.id, key.slice(6) as CanvasFolderStyle);
|
||||
if (key.startsWith("theme:")) onThemeChange(data.id, key.slice(6) as CanvasFolderTheme);
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => setTitle(data.title), [data.title]);
|
||||
|
||||
@@ -130,17 +151,13 @@ export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({
|
||||
) : (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
selectedKeys: [style],
|
||||
items: FOLDER_STYLE_OPTIONS.map((item) => ({ key: item.key, label: item.label })),
|
||||
onClick: ({ key }) => onStyleChange(data.id, key as CanvasFolderStyle),
|
||||
}}
|
||||
menu={folderMenu}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-folder-action canvas-folder-options"
|
||||
aria-label={`文件夹选项,当前 ${childNodes.length} 项`}
|
||||
title="切换文件夹样式"
|
||||
title="切换文件夹样式与主题"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
@@ -153,17 +170,13 @@ export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({
|
||||
{!readOnly && showAdd ? (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
selectedKeys: [style],
|
||||
items: FOLDER_STYLE_OPTIONS.map((item) => ({ key: item.key, label: item.label })),
|
||||
onClick: ({ key }) => onStyleChange(data.id, key as CanvasFolderStyle),
|
||||
}}
|
||||
menu={folderMenu}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-folder-style-trigger"
|
||||
aria-label="切换文件夹样式"
|
||||
title="切换文件夹样式"
|
||||
aria-label="切换文件夹样式与主题"
|
||||
title="切换文件夹样式与主题"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
|
||||
import { FRAME_HEADER_HEIGHT, FRAME_PADDING, isCanvasFolderNode } from "@/lib/canvas/canvas-frame";
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasFolderStyle, type CanvasNodeData, type Position } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type Position } from "@/types/canvas";
|
||||
|
||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
|
||||
@@ -22,6 +22,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
|
||||
onResize,
|
||||
onToggleCollapsed,
|
||||
onFolderStyleChange,
|
||||
onFolderThemeChange = () => undefined,
|
||||
onTitleChange,
|
||||
onContextMenu,
|
||||
readOnly = false,
|
||||
@@ -38,6 +39,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
|
||||
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
|
||||
onToggleCollapsed: (nodeId: string) => void;
|
||||
onFolderStyleChange: (nodeId: string, style: CanvasFolderStyle) => void;
|
||||
onFolderThemeChange?: (nodeId: string, theme: CanvasFolderTheme) => void;
|
||||
onTitleChange: (nodeId: string, title: string) => void;
|
||||
onContextMenu: (event: ReactMouseEvent, nodeId: string) => void;
|
||||
readOnly?: boolean;
|
||||
@@ -194,6 +196,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
|
||||
onToggleCollapsed={onToggleCollapsed}
|
||||
onTitleChange={onTitleChange}
|
||||
onStyleChange={onFolderStyleChange}
|
||||
onThemeChange={onFolderThemeChange}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -255,6 +255,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
replaceEditableSelection(event.clipboardData.getData("text/plain"));
|
||||
}}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return;
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
@@ -339,7 +340,16 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
syncMention(next, event.target.selectionStart);
|
||||
reportContentSize(event.currentTarget);
|
||||
}}
|
||||
onCompositionStart={(event) => {
|
||||
composingRef.current = true;
|
||||
props.onCompositionStart?.(event);
|
||||
}}
|
||||
onCompositionEnd={(event) => {
|
||||
composingRef.current = false;
|
||||
props.onCompositionEnd?.(event);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return;
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -195,8 +195,8 @@ export function CanvasToolbar({
|
||||
|
||||
<AnimatePresence>
|
||||
{appearanceOpen ? (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: aceternityMotion.duration.instant }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[224px] max-w-[calc(100vw-24px)] -translate-x-1/2" style={{ left: panelX || "50%" }}>
|
||||
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ y: 6, scale: 0.97 }} animate={{ y: 0, scale: 1 }} exit={{ y: 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2.5 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.toolbar.item }} onWheel={(event) => event.stopPropagation()}>
|
||||
<motion.div initial={{ opacity: 0, scaleY: 0.9, y: 8 }} animate={{ opacity: 1, scaleY: 1, y: 0 }} exit={{ opacity: 0, scaleY: 0.92, y: 6 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[224px] max-w-[calc(100vw-24px)]" style={{ left: panelX || "50%", transformOrigin: "bottom center", x: "-50%" }}>
|
||||
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.97, transition: { duration: 0 } }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2.5 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.toolbar.item }} onWheel={(event) => event.stopPropagation()}>
|
||||
<PanelHeading icon={<Palette className="size-4" />} title="画布外观" subtitle="调整整个创作空间" theme={theme} />
|
||||
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">主题模式</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-1 rounded-[var(--dock-item-radius-labeled)] border p-1" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
|
||||
@@ -234,8 +234,8 @@ function AddNodeMenu({ x, theme, commands }: {
|
||||
commands: CanvasCreateCommand[];
|
||||
}) {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: aceternityMotion.duration.instant }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[260px] max-w-[calc(100vw-24px)] -translate-x-1/2" style={{ left: x || "50%" }}>
|
||||
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ y: 6, scale: 0.97 }} animate={{ y: 0, scale: 1 }} exit={{ y: 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
||||
<motion.div initial={{ opacity: 0, scaleY: 0.9, y: 8 }} animate={{ opacity: 1, scaleY: 1, y: 0 }} exit={{ opacity: 0, scaleY: 0.92, y: 6 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[260px] max-w-[calc(100vw-24px)]" style={{ left: x || "50%", transformOrigin: "bottom center", x: "-50%" }}>
|
||||
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.97, transition: { duration: 0 } }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
||||
<CanvasCreateMenu commands={commands} />
|
||||
</SpotlightSurface>
|
||||
</motion.div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "antd";
|
||||
|
||||
import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
@@ -21,7 +22,12 @@ export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClass
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
|
||||
const summary = `${videoResolutionLabel(config.vquality)} · ${videoSizeLabel(config.size)} · ${videoSecondsLabel(config.videoSeconds)}`;
|
||||
const resolutionSupported = Boolean(modelCapabilityConfigFor(config, config.model).video?.resolutions.length);
|
||||
const summary = [
|
||||
...(resolutionSupported ? [videoResolutionLabel(config.vquality)] : []),
|
||||
videoSizeLabel(config.size),
|
||||
videoSecondsLabel(config.videoSeconds),
|
||||
].join(" · ");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -85,7 +85,7 @@ export function UserOSSSettingsForm() {
|
||||
<Cloud className="size-4" />
|
||||
我的对象存储
|
||||
</div>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-5 text-foreground/55">支持阿里云 OSS 与腾讯云 COS。启用后,新上传和新生成的媒体优先写入你的存储桶;停用时回退到平台存储。历史资源固定使用创建时的配置版本。</p>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-5 text-foreground/55">支持阿里云 OSS 与腾讯云 COS。启用后,新上传和新生成的媒体优先写入你的存储桶;停用时回退到平台存储。历史资源继续使用创建时的存储与密钥配置,同一存储位置的 CDN 域名会跟随当前配置。</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Tag color={setting?.enabled ? "success" : "default"}>{setting?.enabled ? "已启用" : "未启用"}</Tag>
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { defaultImageCapabilityConfig, defaultModelCapabilityConfig, type ImageCapabilityConfig, type ModelCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import type { ModelProtocol } from "@/lib/model-protocols";
|
||||
import { VIDEO_RESOLUTION_CAPABILITY_OPTIONS } from "@/lib/video-generation-options";
|
||||
|
||||
const ratioOptions = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"];
|
||||
const resolutionOptions = ["480p", "720p", "1080p", "2160p"];
|
||||
const operationOptions = [
|
||||
{ label: "文生视频", value: "text_to_video" },
|
||||
{ label: "图生视频", value: "image_to_video" },
|
||||
@@ -35,6 +35,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
|
||||
const updateReferences = (patch: Partial<VideoCapabilityConfig["references"]>) => update({ references: { ...profile.references, ...patch } });
|
||||
const updateDuration = (patch: Partial<VideoCapabilityConfig["duration"]>) => update({ duration: { ...profile.duration, ...patch } });
|
||||
const durationValues = (profile.duration.values || []).join(",");
|
||||
const resolutionOptions = Array.from(new Set([...VIDEO_RESOLUTION_CAPABILITY_OPTIONS, ...profile.resolutions]));
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-border/70 bg-muted/10 p-3">
|
||||
@@ -46,7 +47,8 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
|
||||
<CapabilityGroup title="引用限制">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<NumberField label="提示词字符数" value={profile.references.promptMaxChars} min={1} disabled={disabled} onChange={(value) => updateReferences({ promptMaxChars: value || 1 })} />
|
||||
<NumberField label="最大图片引用" value={profile.references.maxImages} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxImages: value || 0 })} />
|
||||
<NumberField label="最少图片引用" value={profile.references.minImages} min={0} max={profile.references.maxImages} disabled={disabled} onChange={(value) => updateReferences({ minImages: value || 0 })} />
|
||||
<NumberField label="最大图片引用" value={profile.references.maxImages} min={0} disabled={disabled} onChange={(value) => { const maxImages = value || 0; updateReferences({ maxImages, minImages: Math.min(profile.references.minImages, maxImages) }); }} />
|
||||
<NumberField label="图片上限 MB" value={bytesToMB(profile.references.maxImageBytes)} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxImageBytes: mbToBytes(value) })} />
|
||||
<NumberField label="最大视频引用" value={profile.references.maxVideos} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxVideos: value || 0 })} />
|
||||
<NumberField label="视频上限 MB" value={bytesToMB(profile.references.maxVideoBytes)} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxVideoBytes: mbToBytes(value) })} />
|
||||
@@ -75,7 +77,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Field label="画面比例"><Select mode="multiple" className="w-full" disabled={disabled} value={profile.ratios} options={ratioOptions.map((item) => ({ label: item, value: item }))} onChange={(ratios) => update({ ratios, defaultRatio: ratios.includes(profile.defaultRatio) ? profile.defaultRatio : ratios[0] || "16:9" })} /></Field>
|
||||
<Field label="默认比例"><Select className="w-full" disabled={disabled} value={profile.defaultRatio} options={profile.ratios.map((item) => ({ label: item, value: item }))} onChange={(defaultRatio) => update({ defaultRatio })} /></Field>
|
||||
<Field label="输出分辨率"><Select mode="multiple" className="w-full" disabled={disabled} value={profile.resolutions} options={resolutionOptions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(resolutions) => update({ resolutions, defaultResolution: resolutions.includes(profile.defaultResolution) ? profile.defaultResolution : resolutions[0] || "720p" })} /></Field>
|
||||
<Field label="输出分辨率"><Select mode="tags" className="w-full" disabled={disabled} value={profile.resolutions} tokenSeparators={[","]} placeholder="选择标准档位或输入 768p 等模型专属值" options={resolutionOptions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(resolutions) => update({ resolutions, defaultResolution: resolutions.includes(profile.defaultResolution) ? profile.defaultResolution : resolutions[0] || "" })} /></Field>
|
||||
<Field label="默认分辨率"><Select className="w-full" disabled={disabled} value={profile.defaultResolution} options={profile.resolutions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(defaultResolution) => update({ defaultResolution })} /></Field>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
@@ -162,8 +164,8 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return <label className="block min-w-0"><span className="mb-1 block text-[var(--fs-tiny)] text-foreground/48">{label}</span>{children}</label>;
|
||||
}
|
||||
|
||||
function NumberField({ label, value, min, disabled, onChange }: { label: string; value?: number; min: number; disabled: boolean; onChange: (value: number | null) => void }) {
|
||||
return <Field label={label}><InputNumber className="w-full" disabled={disabled} min={min} precision={0} value={value} onChange={onChange} /></Field>;
|
||||
function NumberField({ label, value, min, max, disabled, onChange }: { label: string; value?: number; min: number; max?: number; disabled: boolean; onChange: (value: number | null) => void }) {
|
||||
return <Field label={label}><InputNumber className="w-full" disabled={disabled} min={min} max={max} precision={0} value={value} onChange={onChange} /></Field>;
|
||||
}
|
||||
|
||||
function BooleanField({ label, value, disabled, onChange }: { label: string; value: { supported: boolean; default: boolean }; disabled: boolean; onChange: (value: { supported: boolean; default: boolean }) => void }) {
|
||||
|
||||
@@ -40,9 +40,11 @@ type DockMetrics = {
|
||||
distance: number;
|
||||
};
|
||||
|
||||
// magnification 收敛:base 略增以适配圆角方块,magnified 幅度从 +14 降到 +8,
|
||||
// 避免 dock 整体跳动,同时保留 Aceternity 接近放大身份。
|
||||
const DOCK_METRICS: Record<NonNullable<FloatingDockProps["size"]>, DockMetrics> = {
|
||||
default: { base: 29, magnified: 43, icon: 14, iconMagnified: 19, distance: 108 },
|
||||
compact: { base: 25, magnified: 35, icon: 13, iconMagnified: 17, distance: 88 },
|
||||
default: { base: 30, magnified: 38, icon: 15, iconMagnified: 18, distance: 100 },
|
||||
compact: { base: 26, magnified: 32, icon: 13, iconMagnified: 16, distance: 84 },
|
||||
};
|
||||
|
||||
const TOUCH_DOCK_METRICS: Record<NonNullable<FloatingDockProps["size"]>, DockMetrics> = {
|
||||
@@ -228,7 +230,7 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
|
||||
aria-expanded={command.expands ? command.active || undefined : undefined}
|
||||
aria-pressed={command.expands ? undefined : command.active || undefined}
|
||||
disabled={command.disabled}
|
||||
className={cn("aceternity-dock-command group relative grid size-full place-items-center rounded-full border outline-none", command.quiet && "is-quiet", command.active && "is-active", command.danger && "is-danger")}
|
||||
className={cn("aceternity-dock-command group relative grid size-full place-items-center rounded-[var(--dock-item-radius)] border outline-none", command.quiet && "is-quiet", command.active && "is-active", command.danger && "is-danger")}
|
||||
whileTap={motionEnabled && !command.disabled ? { scale: 0.92 } : undefined}
|
||||
transition={aceternityMotion.spring.dock}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
<ImageSettingsTheme theme={theme}>
|
||||
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{showTitle ? <div className="text-sm font-semibold">视频设置</div> : null}
|
||||
<SettingGroup title="分辨率" color={theme.node.muted}>
|
||||
{configuredResolutions.length ? <SettingGroup title="分辨率" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{configuredResolutions.map((item) => (
|
||||
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||
@@ -58,7 +58,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
</OptionPill>
|
||||
))}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
</SettingGroup> : null}
|
||||
<SettingGroup title="尺寸" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1.5">
|
||||
<DimensionInput prefix="W" value={dimensions.width} disabled={size === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} />
|
||||
|
||||
Reference in New Issue
Block a user