项目生成模块选择器闪动修改

This commit is contained in:
sjy
2026-07-20 15:41:00 +08:00
parent 1aaef451b8
commit 40efcf55cf
4 changed files with 407 additions and 383 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-C9TClkR9.js"></script>
<script type="module" crossorigin src="/assets/index-iwDaDgKC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bsz_Xon-.css">
</head>
<body>
@@ -50,7 +50,7 @@ const isPending = (item: GenerationTaskResourceItem): boolean => {
return !status || ['pending', 'queued', 'preparing', 'generating', 'creating_provider_task', 'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading', 'retry_waiting'].includes(status);
};
const MAX_DURATION_SECONDS = 180;
const MAX_DURATION_SECONDS = 300;
interface ProgressItemProps {
item: GenerationTaskResourceItem;
+364 -340
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState, useCallback } from "react";
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { copyToClipboard } from "../utils/clipboard";
import { createPortal } from "react-dom";
import {
@@ -62,6 +62,322 @@ import { formatDate } from "../utils/formatDate";
import UploadSelector from "../components/UploadSelector";
import { generateUUID } from "../utils/uuid";
interface PortalDropdownProps {
label: string;
value: string;
options: string[];
suffix?: string;
expanded: boolean;
onToggle: () => void;
onSelect: (val: string) => void;
onClose: () => void;
}
const PortalDropdown: React.FC<PortalDropdownProps> = ({
label,
value,
options,
suffix,
expanded,
onToggle,
onSelect,
onClose,
}) => {
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const expandedRef = useRef(expanded);
const [pos, setPos] = useState<{
bottom: number;
left: number;
width: number;
} | null>(null);
expandedRef.current = expanded;
useEffect(() => {
if (expanded && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPos({
bottom: window.innerHeight - rect.top + 4,
left: rect.left,
width: Math.max(rect.width, 100),
});
} else {
setPos(null);
}
}, [expanded]);
useEffect(() => {
if (!expanded) return;
const handler = (e: MouseEvent) => {
if (triggerRef.current?.contains(e.target as Node)) return;
if (menuRef.current?.contains(e.target as Node)) return;
if (expandedRef.current) onClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [expanded, onClose]);
return (
<>
<div
ref={triggerRef}
onClick={onToggle}
translate="no"
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "5px 12px",
borderRadius: 8,
cursor: "pointer",
userSelect: "none",
transition: "background 0.2s, border-color 0.2s",
background: expanded ? "rgba(99,102,241,0.08)" : "#f8f9fc",
border: expanded ? "1px solid #6366f1" : "1px solid #e2e8f0",
minWidth: 70,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
<span
style={{
fontSize: 12,
color: "#6366f1",
fontWeight: 500,
lineHeight: "18px",
}}
>
{value}
{suffix}
</span>
<CaretDownOutlined
style={{
fontSize: 9,
color: "#94a3b8",
transition: "transform 0.2s",
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</div>
{pos &&
createPortal(
<div
ref={menuRef}
style={{
position: "fixed",
bottom: pos.bottom,
left: pos.left,
minWidth: pos.width,
maxHeight: 200,
overflowY: "auto",
background: "#fff",
borderRadius: 12,
padding: 6,
zIndex: 99999,
boxShadow: "0 8px 30px rgba(0,0,0,0.12)",
border: "1px solid #e2e8f0",
}}
>
<Typography.Text
style={{
fontSize: 10,
color: "#94a3b8",
display: "block",
padding: "2px 6px 4px",
}}
>
{label}
</Typography.Text>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{options.map((opt, oi) => {
const isSelected = value === opt;
return (
<div
key={oi}
onClick={() => {
onSelect(opt);
onClose();
}}
style={{
padding: "6px 10px",
borderRadius: 8,
fontSize: 13,
cursor: "pointer",
transition: "all 0.15s",
background: isSelected
? "linear-gradient(135deg, #6366f1, #8b5cf6)"
: "transparent",
color: isSelected ? "#fff" : "#1a1a2e",
fontWeight: isSelected ? 600 : 400,
}}
onMouseEnter={(e) => {
if (!isSelected)
e.currentTarget.style.background = "#f1f5f9";
}}
onMouseLeave={(e) => {
if (!isSelected)
e.currentTarget.style.background = "transparent";
}}
>
{opt}
</div>
);
})}
</div>
</div>,
document.body,
)}
</>
);
};
interface PortalOptionGroupProps {
group: OptionGroup;
groupName: string;
selected: string | undefined;
expanded: boolean;
onToggle: (groupName: string) => void;
onSelect: (opt: string, groupName: string) => void;
onClose: () => void;
}
const PortalOptionGroup: React.FC<PortalOptionGroupProps> = ({ group, groupName, selected, expanded, onToggle, onSelect, onClose }) => {
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const expandedRef = useRef(expanded);
const [pos, setPos] = useState<{ bottom: number; left: number } | null>(
null,
);
expandedRef.current = expanded;
useEffect(() => {
if (expanded && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPos({ bottom: window.innerHeight - rect.top + 4, left: rect.left });
} else {
setPos(null);
}
}, [expanded]);
useEffect(() => {
if (!expanded) return;
const handler = (e: MouseEvent) => {
if (triggerRef.current?.contains(e.target as Node)) return;
if (menuRef.current?.contains(e.target as Node)) return;
if (expandedRef.current) onClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [expanded, onClose]);
return (
<>
<div
ref={triggerRef}
onClick={() => onToggle(groupName)}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "5px 12px",
borderRadius: 8,
cursor: "pointer",
userSelect: "none",
transition: "all 0.2s",
background: selected ? "rgba(99,102,241,0.08)" : "#f8f9fc",
border: expanded ? "1px solid #6366f1" : "1px solid #e2e8f0",
}}
>
<Typography.Text
style={{
fontSize: 12,
color: selected ? "#6366f1" : "#64748b",
fontWeight: selected ? 500 : 400,
}}
>
{selected || group.name}
</Typography.Text>
<CaretDownOutlined
style={{
fontSize: 9,
color: "#94a3b8",
transition: "transform 0.2s",
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</div>
{pos &&
createPortal(
<div
ref={menuRef}
style={{
position: "fixed",
bottom: pos.bottom,
left: pos.left,
background: "#fff",
borderRadius: 12,
padding: 8,
minWidth: 160,
zIndex: 99999,
boxShadow: "0 8px 30px rgba(0,0,0,0.12)",
border: "1px solid #e2e8f0",
}}
>
<Typography.Text
style={{
fontSize: 10,
color: "#94a3b8",
display: "block",
padding: "2px 6px 4px",
}}
>
{group.name}
</Typography.Text>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{group.options.map((opt, oi) => {
const isSelected = selected === opt;
return (
<div
key={oi}
onClick={() => {
onSelect(opt, groupName);
onClose();
}}
style={{
padding: "6px 10px",
borderRadius: 8,
fontSize: 13,
cursor: "pointer",
transition: "all 0.15s",
background: isSelected
? "linear-gradient(135deg, #6366f1, #8b5cf6)"
: "transparent",
color: isSelected ? "#fff" : "#1a1a2e",
fontWeight: isSelected ? 600 : 400,
}}
onMouseEnter={(e) => {
if (!isSelected)
e.currentTarget.style.background = "#f1f5f9";
}}
onMouseLeave={(e) => {
if (!isSelected)
e.currentTarget.style.background = "transparent";
}}
>
{opt}
</div>
);
})}
</div>
</div>,
document.body,
)}
</>
);
};
// const calcVideoCredits = (duration: number, resolution: Resolution): number => {
// const configs: Record<
// string,
@@ -340,6 +656,40 @@ const GeneratePage: React.FC = () => {
durations: [5, 8, 10, 12, 15],
});
const durationOptions = useMemo(() => engineOptions.durations.map((d) => `${d}`), [engineOptions.durations]);
const handleDurationToggle = useCallback(() => {
setExpandedEngine(prev => prev === "duration" ? null : "duration");
}, []);
const handleDurationClose = useCallback(() => {
setExpandedEngine(null);
}, []);
const handleGroupToggle = useCallback((groupName: string) => {
setExpandedGroup(prev => prev === groupName ? null : groupName);
}, []);
const handleGroupClose = useCallback(() => {
setExpandedGroup(null);
}, []);
const handleDurationSelect = useCallback((v: string) => {
setVideoDuration(parseInt(v));
}, []);
const handleOptionGroupSelect = useCallback((opt: string, groupName: string) => {
setSelectedOptions((prev) => {
const next = { ...prev };
if (next[groupName] === opt) {
delete next[groupName];
} else {
next[groupName] = opt;
}
return next;
});
}, []);
// Track last text credits for display
const [lastTextCredits, setLastTextCredits] = useState(0);
const [lastTextTokens, setLastTextTokens] = useState(0);
@@ -883,9 +1233,9 @@ const GeneratePage: React.FC = () => {
const project = projects.find((p) => p.id === projectId);
const projectRecords = recordItems.filter((r) => r.projectId === projectId);
const projectName = project?.name ?? "项目";
const currentOptionGroups = project
? industryOptionGroups[project.industry] || []
: [];
const currentOptionGroups = useMemo(() => {
return project ? industryOptionGroups[project.industry] || [] : [];
}, [project, industryOptionGroups]);
const userCredits = user?.credits ?? 0;
// 根据图片分辨率从 cimage 获取积分
@@ -1320,318 +1670,6 @@ const GeneratePage: React.FC = () => {
},
failed: { color: "error", text: "失败", icon: <CloseCircleOutlined /> },
};
// Portal-based dropdown that renders menu at document.body level (above trigger)
const PortalDropdown: React.FC<{
label: string;
value: string;
options: string[];
suffix?: string;
expanded: boolean;
onToggle: () => void;
onSelect: (val: string) => void;
onClose: () => void;
}> = ({
label,
value,
options,
suffix,
expanded,
onToggle,
onSelect,
onClose,
}) => {
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const expandedRef = useRef(expanded);
const [pos, setPos] = useState<{
bottom: number;
left: number;
width: number;
} | null>(null);
expandedRef.current = expanded;
useEffect(() => {
if (expanded && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPos({
bottom: window.innerHeight - rect.top + 4,
left: rect.left,
width: Math.max(rect.width, 100),
});
} else {
setPos(null);
}
}, [expanded]);
useEffect(() => {
if (!expanded) return;
const handler = (e: MouseEvent) => {
if (triggerRef.current?.contains(e.target as Node)) return;
if (menuRef.current?.contains(e.target as Node)) return;
if (expandedRef.current) onClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [expanded, onClose]);
return (
<>
<div
ref={triggerRef}
onClick={onToggle}
translate="no"
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "5px 12px",
borderRadius: 8,
cursor: "pointer",
userSelect: "none",
transition: "background 0.2s, border-color 0.2s",
background: expanded ? "rgba(99,102,241,0.08)" : "#f8f9fc",
border: expanded ? "1px solid #6366f1" : "1px solid #e2e8f0",
minWidth: 70,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
<span
style={{
fontSize: 12,
color: "#6366f1",
fontWeight: 500,
lineHeight: "18px",
}}
>
{value}
{suffix}
</span>
<CaretDownOutlined
style={{
fontSize: 9,
color: "#94a3b8",
transition: "transform 0.2s",
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</div>
{pos &&
createPortal(
<div
ref={menuRef}
style={{
position: "fixed",
bottom: pos.bottom,
left: pos.left,
minWidth: pos.width,
background: "#fff",
borderRadius: 12,
padding: 6,
zIndex: 99999,
boxShadow: "0 8px 30px rgba(0,0,0,0.12)",
border: "1px solid #e2e8f0",
}}
>
<Typography.Text
style={{
fontSize: 10,
color: "#94a3b8",
display: "block",
padding: "2px 6px 4px",
}}
>
{label}
</Typography.Text>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{options.map((opt, oi) => {
const isSelected = value === opt;
return (
<div
key={oi}
onClick={() => {
onSelect(opt);
onClose();
}}
style={{
padding: "6px 10px",
borderRadius: 8,
fontSize: 13,
cursor: "pointer",
transition: "all 0.15s",
background: isSelected
? "linear-gradient(135deg, #6366f1, #8b5cf6)"
: "transparent",
color: isSelected ? "#fff" : "#1a1a2e",
fontWeight: isSelected ? 600 : 400,
}}
onMouseEnter={(e) => {
if (!isSelected)
e.currentTarget.style.background = "#f1f5f9";
}}
onMouseLeave={(e) => {
if (!isSelected)
e.currentTarget.style.background = "transparent";
}}
>
{opt}
</div>
);
})}
</div>
</div>,
document.body,
)}
</>
);
};
// Portal-based option group dropdown for industry options (above trigger)
const PortalOptionGroup: React.FC<{
group: OptionGroup;
selected: string | undefined;
expanded: boolean;
onToggle: () => void;
onSelect: (opt: string) => void;
onClose: () => void;
}> = ({ group, selected, expanded, onToggle, onSelect, onClose }) => {
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const expandedRef = useRef(expanded);
const [pos, setPos] = useState<{ bottom: number; left: number } | null>(
null,
);
expandedRef.current = expanded;
useEffect(() => {
if (expanded && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPos({ bottom: window.innerHeight - rect.top + 4, left: rect.left });
} else {
setPos(null);
}
}, [expanded]);
useEffect(() => {
if (!expanded) return;
const handler = (e: MouseEvent) => {
if (triggerRef.current?.contains(e.target as Node)) return;
if (menuRef.current?.contains(e.target as Node)) return;
if (expandedRef.current) onClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [expanded, onClose]);
return (
<>
<div
ref={triggerRef}
onClick={onToggle}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "5px 12px",
borderRadius: 8,
cursor: "pointer",
userSelect: "none",
transition: "all 0.2s",
background: selected ? "rgba(99,102,241,0.08)" : "#f8f9fc",
border: expanded ? "1px solid #6366f1" : "1px solid #e2e8f0",
}}
>
<Typography.Text
style={{
fontSize: 12,
color: selected ? "#6366f1" : "#64748b",
fontWeight: selected ? 600 : 400,
}}
>
{selected || group.name}
</Typography.Text>
<CaretDownOutlined
style={{
fontSize: 9,
color: selected ? "#6366f1" : "#94a3b8",
transition: "transform 0.2s",
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</div>
{pos &&
createPortal(
<div
ref={menuRef}
style={{
position: "fixed",
bottom: pos.bottom,
left: pos.left,
background: "#fff",
borderRadius: 12,
padding: 8,
minWidth: 160,
zIndex: 99999,
boxShadow: "0 8px 30px rgba(0,0,0,0.12)",
border: "1px solid #e2e8f0",
}}
>
<Typography.Text
style={{
fontSize: 10,
color: "#94a3b8",
display: "block",
padding: "2px 6px 4px",
}}
>
{group.name}
</Typography.Text>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{group.options.map((opt, oi) => {
const isSelected = selected === opt;
return (
<div
key={oi}
onClick={() => {
onSelect(opt);
onClose();
}}
style={{
padding: "6px 10px",
borderRadius: 8,
fontSize: 13,
cursor: "pointer",
transition: "all 0.15s",
background: isSelected
? "linear-gradient(135deg, #6366f1, #8b5cf6)"
: "transparent",
color: isSelected ? "#fff" : "#1a1a2e",
fontWeight: isSelected ? 600 : 400,
}}
onMouseEnter={(e) => {
if (!isSelected)
e.currentTarget.style.background = "#f1f5f9";
}}
onMouseLeave={(e) => {
if (!isSelected)
e.currentTarget.style.background = "transparent";
}}
>
{opt}
</div>
);
})}
</div>
</div>,
document.body,
)}
</>
);
};
return (
<div
style={{
@@ -1971,6 +2009,7 @@ const GeneratePage: React.FC = () => {
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
@@ -2127,7 +2166,7 @@ const GeneratePage: React.FC = () => {
rows={3}
placeholder="上传参考素材(只用做模型理解,不参与生成)、输入文字,自由组合图、文多元素。输入 @ 可引用参考内容..."
maxLength={500}
bordered={false}
variant="borderless"
autoSize={{ minRows: 2, maxRows: 6 }}
style={{
padding: "6px 2px",
@@ -2273,16 +2312,12 @@ const GeneratePage: React.FC = () => {
<PortalDropdown
label="时长"
value={`${videoDuration}`}
options={engineOptions.durations.map((d) => `${d}`)}
options={durationOptions}
suffix="秒"
expanded={expandedEngine === "duration"}
onToggle={() =>
setExpandedEngine(
expandedEngine === "duration" ? null : "duration",
)
}
onSelect={(v) => setVideoDuration(parseInt(v))}
onClose={() => setExpandedEngine(null)}
onToggle={handleDurationToggle}
onSelect={handleDurationSelect}
onClose={handleDurationClose}
/>
{currentOptionGroups.map((group, gi) => {
const isExpanded = expandedGroup === group.name;
@@ -2291,23 +2326,12 @@ const GeneratePage: React.FC = () => {
<PortalOptionGroup
key={`og-${gi}`}
group={group}
groupName={group.name}
selected={selected}
expanded={isExpanded}
onToggle={() =>
setExpandedGroup(isExpanded ? null : group.name)
}
onSelect={(opt) => {
setSelectedOptions((prev) => {
const next = { ...prev };
if (next[group.name] === opt) {
delete next[group.name];
} else {
next[group.name] = opt;
}
return next;
});
}}
onClose={() => setExpandedGroup(null)}
onToggle={handleGroupToggle}
onSelect={handleOptionGroupSelect}
onClose={handleGroupClose}
/>
);
})}