This commit is contained in:
2026-07-03 12:31:14 +08:00
9 changed files with 656 additions and 530 deletions
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-BhcMFup1.js"></script>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DQ1LXqt7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -258,8 +258,8 @@ const AdminGenerationAiRecords: React.FC = () => {
userId: queryUserId || undefined,
userName: queryUserName || undefined,
engineId: queryEngineId || undefined,
createdStart: queryCreatedRange?.[0]?.toISOString?.(),
createdEnd: queryCreatedRange?.[1]?.toISOString?.(),
createdStart: queryCreatedRange?.[0]?.format?.('YYYY-MM-DDTHH:mm:ss'),
createdEnd: queryCreatedRange?.[1]?.format?.('YYYY-MM-DDTHH:mm:ss'),
page,
pageSize: PAGE_SIZE,
});
@@ -1,5 +1,5 @@
import random
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy import select, func
@@ -136,8 +136,8 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
data = data.get("data", {})
access_token = data.get("access_token", "")
refresh_token = data.get("refresh_token", "")
expires_in = datetime.now() + timedelta(seconds=data.get("expires_in", 0))
refresh_token_expires_in = datetime.now() + timedelta(seconds=data.get("refresh_token_expires_in", 0))
expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 0))
refresh_token_expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
#2.获取已授权角色账户,一个授权可能有多个角色账户
url = "https://api.oceanengine.com/open_api/oauth2/advertiser/get/"
response = await client.get(
@@ -222,7 +222,7 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
# 1. 软删除已消失的账户
for account_id in old_account_ids - new_account_ids:
existing_accounts[account_id].deleted_at = datetime.now()
existing_accounts[account_id].deleted_at = datetime.now(timezone.utc)
# 2. 更新或新增账户
for account in account_list:
@@ -93,8 +93,8 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
data = data.get("data", {})
new_access_token = data.get("access_token", "")
new_refresh_token = data.get("refresh_token", "")
expires_in = datetime.now(tz=oauth.access_token_expired.tzinfo) + timedelta(seconds=data.get("expires_in", 0))
refresh_token_expires_in = datetime.now(tz=oauth.refresh_token_expired.tzinfo) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 0))
refresh_token_expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
from sqlalchemy import update
+1 -1
View File
@@ -51,7 +51,7 @@ class DouyinRequest:
token = cache.get("token")
expired_at_str = cache.get("expired_at")
if token and expired_at_str:
expired_at = datetime.fromisoformat(expired_at_str)
expired_at = datetime.fromisoformat(expired_at_str).replace(tzinfo=timezone.utc)
if expired_at > datetime.now(timezone.utc):
return token
except Exception as e:
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName;
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DxvrbZVm.js"></script>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName;
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-BRAajPcM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
+484 -358
View File
@@ -50,6 +50,7 @@ import {
ArrowUpOutlined,
DownloadOutlined,
ReloadOutlined,
AudioOutlined,
} from '@ant-design/icons';
@@ -274,7 +275,7 @@ const AIChatPage: React.FC = () => {
// 附件预览弹窗状态(独立弹窗)
const [attachmentPreviewVisible, setAttachmentPreviewVisible] = useState<boolean>(false);
const [attachmentPreviewUrl, setAttachmentPreviewUrl] = useState<string>('');
const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video'>('image');
const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video' | 'audio'>('image');
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
const attachmentPreviewVideoRef = useRef<HTMLVideoElement>(null);
@@ -292,6 +293,7 @@ const AIChatPage: React.FC = () => {
const [fblindex, setFBlindex] = useState<any>(0);
const [showEngineModal, setShowEngineModal] = useState(false);
const [showMediaTypeModal, setShowMediaTypeModal] = useState(false);
const [creditRatios, setCreditRatios] = useState<any[]>([]);
const [cimage, setCimage] = useState<any[]>([]);
@@ -428,6 +430,10 @@ const AIChatPage: React.FC = () => {
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// 关闭媒体类型选择弹窗
if (showMediaTypeModal && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) {
setShowMediaTypeModal(false);
}
// 关闭引擎选择弹窗
if (showEngineModal && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) {
setShowEngineModal(false);
@@ -446,7 +452,7 @@ const AIChatPage: React.FC = () => {
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showEngineModal, showImageSettingsModal, showVideoSettingsModal]);
}, [showMediaTypeModal, showEngineModal, showImageSettingsModal, showVideoSettingsModal]);
useEffect(() => {
if (mediaType !== 'video') return;
@@ -788,12 +794,8 @@ const AIChatPage: React.FC = () => {
const isFirstLastFrameMode = mediaType === 'video' && referenceMode === 'first_last_frame';
if (isFirstLastFrameMode) {
if (!inputValue.trim() && !firstFrame) {
message.warning('请输入内容或上传首帧图片');
return;
}
if (!firstFrame) {
message.warning('请上传首帧图片');
if (!inputValue.trim()) {
message.warning('请输入内容');
return;
}
} else {
@@ -2094,152 +2096,7 @@ const AIChatPage: React.FC = () => {
<div style={{ display: 'flex', gap: isFirstLastFrameComposer ? 18 : 18, alignItems: 'flex-end', marginBottom: 14 }}>
{/* 左侧附件区域 */}
<div style={{ width: isFirstLastFrameComposer ? 220 : 70, minWidth: isFirstLastFrameComposer ? 220 : 70, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-start', paddingTop: 2, gap: 8 }}>
{/* 层叠附件展示 - 在上传按钮上边 */}
{currentMedia.length > 0 && !(mediaType === 'video' && referenceMode === 'first_last_frame') && (
<div
style={{
width: 58,
flexShrink: 0,
position: 'relative',
}}
onMouseEnter={openMediaStackTray}
onMouseLeave={closeMediaStackTray}
>
<div style={{ position: 'relative', width: 58, height: 66 }}>
{currentMedia.slice(0, 3).map((media, idx) => {
const reversedIdx = Math.min(currentMedia.slice(0, 3).length, 3) - 1 - idx;
const offsetX = mediaStackHovered ? idx * 8 : idx * 4;
const offsetY = mediaStackHovered ? idx * 6 : idx * 3;
return (
<div
key={`stack-${idx}`}
style={{
position: 'absolute',
left: offsetX,
top: offsetY,
zIndex: idx + 1,
transition: 'all 0.28s cubic-bezier(0.4, 0, 0.2, 1)',
transform: `scale(${1 - reversedIdx * 0.06})`,
opacity: mediaStackHovered ? 1 : (1 - reversedIdx * 0.15),
}}
>
<div style={{ position: 'relative', width: 50, height: 58 }}>
{media.type === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
alt={media.name}
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('image');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: 50, height: 58, objectFit: 'cover', borderRadius: 12, cursor: 'pointer', border: '2px solid rgba(255,255,255,0.98)', boxShadow: '0 6px 16px rgba(31,41,55,0.2)' }}
/>
) : (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
muted
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('video');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: 50, height: 58, objectFit: 'cover', borderRadius: 12, cursor: 'pointer', border: '2px solid rgba(255,255,255,0.98)', boxShadow: '0 6px 16px rgba(31,41,55,0.2)' }}
/>
)}
</div>
</div>
);
})}
{currentMedia.length > 3 && (
<div style={{ position: 'absolute', right: 0, bottom: 2, zIndex: 10, background: '#8b5cf6', color: '#fff', fontSize: 9, fontWeight: 700, padding: '2px 6px', borderRadius: 999, boxShadow: '0 3px 8px rgba(31,41,55,0.25)' }}>
+{currentMedia.length - 3}
</div>
)}
</div>
{mediaStackHovered && currentMedia.length > 0 && (
<div
onMouseEnter={openMediaStackTray}
onMouseLeave={closeMediaStackTray}
style={{
position: 'absolute',
left: -10,
bottom: 70,
width: 280,
padding: '12px 12px 12px',
borderRadius: 16,
background: 'rgba(255,255,255,0.98)',
border: '1px solid #E7EAF0',
boxShadow: '0 20px 48px rgba(31,41,55,0.18)',
backdropFilter: 'blur(16px)',
zIndex: 60,
pointerEvents: 'auto',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<span style={{ fontSize: 12, color: '#344054', fontWeight: 700 }}></span>
<span style={{ fontSize: 11, color: '#98A2B3', fontWeight: 600 }}>{currentMedia.length}/{maxImageCount + maxVideoCount}</span>
</div>
<div style={{ display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 4 }}>
{currentMedia.map((media, idx) => (
<div key={`tray-${idx}`} style={{ position: 'relative', flex: '0 0 auto', width: 54 }}>
{media.type === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
alt={media.name}
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('image');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: 54, height: 58, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid #E7EAF0' }}
/>
) : (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
muted
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('video');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: 54, height: 58, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid #E7EAF0' }}
/>
)}
<button
onClick={() => handleRemoveMedia(idx)}
style={{
position: 'absolute',
top: 4,
right: 4,
width: 20,
height: 20,
border: 'none',
background: 'rgba(0,0,0,0.5)',
borderRadius: 50,
cursor: 'pointer',
color: '#fff',
fontSize: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(4px)',
zIndex: 10,
}}
>
<DeleteOutlined style={{ fontSize: 10 }} />
</button>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* 首尾帧模式 */}
{mediaType === 'video' && referenceMode === 'first_last_frame' ? (
@@ -2345,63 +2202,216 @@ const AIChatPage: React.FC = () => {
</div>) : (
<>
{/* 上传按钮 - 非首尾帧模式且还有上传空间时始终显示 */}
{/* 上传区域 - 图片堆叠展示,右下角圆形+按钮 */}
{!(mediaType === 'video' && referenceMode === 'first_last_frame') && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center', minHeight: 84 }}>
<Upload
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
showUploadList={false}
beforeUpload={handleUpload}
>
<Tooltip title={mediaType === 'image'
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
}>
<div
style={{
width: 54,
height: 74,
borderRadius: 7,
border: '1px solid rgba(231, 234, 240, 0.95)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.25s ease',
background: '#ffffff',
flexDirection: 'column',
gap: 5,
transform: 'rotate(-7deg)',
boxShadow: '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#D7DDE7';
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #F7F8FA 100%)';
e.currentTarget.style.transform = 'rotate(0deg) translateY(-2px)';
e.currentTarget.style.boxShadow = '0 14px 28px rgba(47, 52, 64, 0.15), inset 0 1px 0 rgba(255,255,255,0.98)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #FAFBFC 100%)';
e.currentTarget.style.transform = 'rotate(-7deg)';
e.currentTarget.style.boxShadow = '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)';
}}
>
{uploading ? (
<LoadingOutlined style={{ fontSize: 17, color: '#667085' }} />
) : (
<>
<PlusOutlined style={{ fontSize: 18, color: '#667085', lineHeight: 1 }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 14px)', columnGap: 2, justifyContent: 'center', color: '#344054', fontSize: 12, fontWeight: 700, lineHeight: 1.05, letterSpacing: 0 }}>
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span></span><span></span></span>
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span></span><span></span></span>
<div
style={{
position: 'relative',
width: 64,
height: 72,
marginTop: '-10',
}}
onMouseEnter={openMediaStackTray}
onMouseLeave={closeMediaStackTray}
>
{/* 没有上传时的卡片样式 */}
{currentMedia.length === 0 && (
<Upload
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
showUploadList={false}
beforeUpload={handleUpload}
>
<Tooltip title={mediaType === 'image'
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
}>
<div
style={{
width: 54,
height: 74,
borderRadius: 7,
border: '1px solid rgba(231, 234, 240, 0.95)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.25s ease',
background: '#ffffff',
flexDirection: 'column',
gap: 5,
transform: 'rotate(-7deg)',
boxShadow: '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#D7DDE7';
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #F7F8FA 100%)';
e.currentTarget.style.transform = 'rotate(0deg) translateY(-2px)';
e.currentTarget.style.boxShadow = '0 14px 28px rgba(47, 52, 64, 0.15), inset 0 1px 0 rgba(255,255,255,0.98)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #FAFBFC 100%)';
e.currentTarget.style.transform = 'rotate(-7deg)';
e.currentTarget.style.boxShadow = '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)';
}}
>
{uploading ? (
<LoadingOutlined style={{ fontSize: 17, color: '#667085' }} />
) : (
<>
<PlusOutlined style={{ fontSize: 18, color: '#667085', lineHeight: 1 }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 14px)', columnGap: 2, justifyContent: 'center', color: '#344054', fontSize: 12, fontWeight: 700, lineHeight: 1.05, letterSpacing: 0 }}>
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span></span><span></span></span>
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span></span><span></span></span>
</div>
</>
)}
</div>
</Tooltip>
</Upload>
)}
{/* 层叠附件展示 - 鼠标移入向右排列展开 */}
{currentMedia.length > 0 && (
<>
{currentMedia.map((media, idx) => {
const reversedIdx = Math.min(currentMedia.length, 3) - 1 - idx;
const offsetX = mediaStackHovered ? idx * 58 : (idx < 3 ? idx * 6 : 18);
const isVisible = !mediaStackHovered ? idx < 3 : true;
return (
<div
key={`stack-${idx}`}
style={{
position: 'absolute',
left: offsetX,
bottom: 0,
zIndex: idx + 1,
transition: 'all 0.28s cubic-bezier(0.4, 0, 0.2, 1)',
transform: mediaStackHovered ? `scale(1) rotate(0deg)` : `scale(${idx < 3 ? (1 - reversedIdx * 0.08) : 0}) rotate(${idx < 3 ? (-3 + idx * 1.5) : 0}deg)`,
opacity: isVisible ? (mediaStackHovered ? 1 : (1 - reversedIdx * 0.15)) : 0,
pointerEvents: isVisible ? 'auto' : 'none',
}}
>
<div style={{ position: 'relative', width: 52, height: 60 }}>
{media.type === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
alt={media.name}
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('image');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: 52, height: 60, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid rgba(255,255,255,0.98)', boxShadow: '0 4px 12px rgba(31,41,55,0.15)' }}
/>
) : media.type === 'video' ? (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
muted
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('video');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: 52, height: 60, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid rgba(255,255,255,0.98)', boxShadow: '0 4px 12px rgba(31,41,55,0.15)' }}
/>
) : (
<div
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('audio');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: 52, height: 60, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid rgba(255,255,255,0.98)', boxShadow: '0 4px 12px rgba(31,41,55,0.15)', background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<AudioOutlined style={{ fontSize: 20, color: '#fff' }} />
</div>
)}
{/* 右上角删除按钮 */}
<button
onClick={() => handleRemoveMedia(idx)}
style={{
position: 'absolute',
top: -6,
right: -6,
width: 20,
height: 20,
borderRadius: 50,
background: '#1f2937',
border: 'none',
color: '#fff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
zIndex: 10,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#dc2626';
e.currentTarget.style.transform = 'scale(1.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1f2937';
e.currentTarget.style.transform = 'scale(1)';
}}
>
<DeleteOutlined style={{ fontSize: 10 }} />
</button>
</div>
{/* <span style={{ fontSize: 9, color: '#98a2b3', fontWeight: 600, lineHeight: 1 }}>{currentMedia.length}/{maxImageCount + maxVideoCount}</span> */}
</>
)}
</div>
</Tooltip>
</Upload>
</div>
);
})}
</>
)}
{/* 右下角圆形+上传按钮 */}
{currentMedia.length > 0 && (
<Upload
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
showUploadList={false}
beforeUpload={handleUpload}
>
<Tooltip title={mediaType === 'image'
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
}>
<div
style={{
position: 'absolute',
right: 0,
bottom: 0,
width: 28,
height: 28,
borderRadius: 50,
background: '#ffffff',
border: '1px solid rgba(231, 234, 240, 0.95)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 8px rgba(47, 52, 64, 0.08)',
zIndex: 100,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#8b5cf6';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(139, 92, 246, 0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(47, 52, 64, 0.08)';
}}
>
<PlusOutlined style={{ fontSize: 14, color: '#8b5cf6', lineHeight: 1 }} />
</div>
</Tooltip>
</Upload>
)}
</div>
)}
</>
@@ -2521,36 +2531,144 @@ const AIChatPage: React.FC = () => {
创作类型:
</span> */}
{/* 文件类型选择器 */}
<Select
className="ai-composer-select"
value={mediaType}
onChange={(val) => setMediaType(val)}
style={{
minWidth: 118,
padding: '6px 14px',
height: 32,
outline: 'none',
border: '1px solid #E7EAF0',
backgroundColor: '#ffffff',
borderRadius: 10,
boxShadow: '0 1px 2px rgba(47, 52, 64, 0.025)',
}}
size="middle"
>
<Option value="video">
<VideoCameraOutlined style={{ marginRight: 6, fontSize: 14, color: '#8b5cf6' }} />
<span style={{ fontSize: 13, fontWeight: 650, color: '#475467' }}></span>
</Option>
<Option value="image">
<PictureOutlined style={{ marginRight: 6, fontSize: 14, color: '#8b5cf6' }} />
<span style={{ fontSize: 13, fontWeight: 650, color: '#475467' }}></span>
</Option>
</Select>
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => {
setShowEngineModal(false);
setShowImageSettingsModal(false);
setShowVideoSettingsModal(false);
setReferenceModeDropdownVisible(false);
setShowMediaTypeModal(prev => !prev);
}}
className="image-settings-trigger"
style={{
minWidth: 118,
padding: '5px 12px',
height: 32,
borderRadius: 10,
border: '1px solid #E7EAF0',
backgroundColor: '#ffffff',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.2s ease',
boxShadow: '0 1px 2px rgba(47, 52, 64, 0.025)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#FFFFFF';
e.currentTarget.style.borderColor = '#D7DDE7';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(47, 52, 64, 0.06)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#ffffff';
e.currentTarget.style.borderColor = '#E7EAF0';
e.currentTarget.style.boxShadow = '0 1px 2px rgba(47, 52, 64, 0.03)';
}}
>
{mediaType === 'video' ? (
<VideoCameraOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
) : (
<PictureOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
)}
<Text style={{
fontSize: 13,
fontWeight: 500,
color: '#2f3440',
}}>
{mediaType === 'video' ? '视频生成' : '图片生成'}
</Text>
<CaretDownOutlined style={{ fontSize: 10, color: '#8b5cf6', marginLeft: 'auto' }} />
</button>
{showMediaTypeModal && (
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
width: 300,
backgroundColor: '#ffffff',
backdropFilter: 'blur(16px)',
borderRadius: 16,
boxShadow: '0 20px 48px rgba(47, 52, 64, 0.12)',
padding: 16,
border: '1px solid #E7EAF0',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ marginBottom: 8 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 600,
color: '#475467',
}}>
</Text>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}}>
{[
{ value: 'video', label: '视频生成', icon: VideoCameraOutlined },
{ value: 'image', label: '图片生成', icon: PictureOutlined },
].map((item) => (
<button
key={item.value}
onClick={() => {
setMediaType(item.value);
setShowMediaTypeModal(false);
}}
style={{
flex: 1,
minHeight: 48,
borderRadius: 10,
border: mediaType === item.value
? '1px solid #8b5cf6'
: '1px solid #E7EAF0',
backgroundColor: mediaType === item.value
? '#f5f3ff'
: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: '8px 12px',
transition: 'all 0.2s',
textAlign: 'left',
gap: 8,
}}
>
<item.icon style={{ fontSize: 14, color: mediaType === item.value ? '#8b5cf6' : '#98a2b3' }} />
<span style={{
fontSize: 14,
fontWeight: mediaType === item.value ? 600 : 500,
color: mediaType === item.value ? '#8b5cf6' : '#475467',
}}>
{item.label}
</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* 引擎选择器 */}
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => setShowEngineModal(prev => !prev)}
onClick={() => {
setShowMediaTypeModal(false);
setShowImageSettingsModal(false);
setShowVideoSettingsModal(false);
setReferenceModeDropdownVisible(false);
setShowEngineModal(prev => !prev);
}}
className="image-settings-trigger"
style={{
minWidth: 174,
@@ -2692,7 +2810,13 @@ const AIChatPage: React.FC = () => {
{mediaType === 'video' && (supportsFirstLastFrame || supportsUniversalReference) && (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => setReferenceModeDropdownVisible(!referenceModeDropdownVisible)}
onClick={() => {
setShowMediaTypeModal(false);
setShowEngineModal(false);
setShowImageSettingsModal(false);
setShowVideoSettingsModal(false);
setReferenceModeDropdownVisible(!referenceModeDropdownVisible);
}}
className="image-settings-trigger"
style={{
minWidth: 118,
@@ -2731,127 +2855,117 @@ const AIChatPage: React.FC = () => {
</button>
{referenceModeDropdownVisible && (
<>
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9998,
}}
onClick={() => setReferenceModeDropdownVisible(false)}
/>
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
minWidth: 160,
backgroundColor: '#ffffff',
backdropFilter: 'blur(16px)',
borderRadius: 16,
boxShadow: '0 20px 48px rgba(47, 52, 64, 0.12)',
padding: 8,
border: '1px solid #E7EAF0',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => handleReferenceModeChange('universal')}
disabled={!supportsUniversalReference}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: 10,
border: referenceMode === 'universal'
? '1px solid rgba(139, 92, 246, 0.3)'
: '1px solid transparent',
backgroundColor: referenceMode === 'universal'
? 'rgba(139, 92, 246, 0.08)'
: 'transparent',
cursor: supportsUniversalReference ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
gap: 8,
transition: 'all 0.2s',
textAlign: 'left',
opacity: supportsUniversalReference ? 1 : 0.4,
}}
onMouseEnter={(e) => {
if (supportsUniversalReference && referenceMode !== 'universal') {
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.04)';
}
}}
onMouseLeave={(e) => {
if (referenceMode !== 'universal') {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<PictureOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
<span style={{
fontSize: 13,
fontWeight: referenceMode === 'universal' ? 600 : 500,
color: referenceMode === 'universal' ? '#8b5cf6' : '#475467',
flex: 1,
}}>
</span>
{!supportsUniversalReference && (
<span style={{ fontSize: 10, color: '#98a2b3' }}></span>
)}
</button>
<button
onClick={() => handleReferenceModeChange('first_last_frame')}
disabled={!supportsFirstLastFrame}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: 10,
border: referenceMode === 'first_last_frame'
? '1px solid rgba(139, 92, 246, 0.3)'
: '1px solid transparent',
backgroundColor: referenceMode === 'first_last_frame'
? 'rgba(139, 92, 246, 0.08)'
: 'transparent',
cursor: supportsFirstLastFrame ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
gap: 8,
transition: 'all 0.2s',
textAlign: 'left',
opacity: supportsFirstLastFrame ? 1 : 0.4,
}}
onMouseEnter={(e) => {
if (supportsFirstLastFrame && referenceMode !== 'first_last_frame') {
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.04)';
}
}}
onMouseLeave={(e) => {
if (referenceMode !== 'first_last_frame') {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<SwapOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
<span style={{
fontSize: 13,
fontWeight: referenceMode === 'first_last_frame' ? 600 : 500,
color: referenceMode === 'first_last_frame' ? '#8b5cf6' : '#475467',
flex: 1,
}}>
</span>
{!supportsFirstLastFrame && (
<span style={{ fontSize: 10, color: '#98a2b3' }}></span>
)}
</button>
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
width: 300,
backgroundColor: '#ffffff',
backdropFilter: 'blur(16px)',
borderRadius: 16,
boxShadow: '0 20px 48px rgba(47, 52, 64, 0.12)',
padding: 16,
border: '1px solid #E7EAF0',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ marginBottom: 8 }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 600,
color: '#475467',
}}>
</Text>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}}>
<button
onClick={() => {
handleReferenceModeChange('universal');
setReferenceModeDropdownVisible(false);
}}
disabled={!supportsUniversalReference}
style={{
flex: 1,
minHeight: 48,
borderRadius: 10,
border: referenceMode === 'universal'
? '1px solid #8b5cf6'
: '1px solid #E7EAF0',
backgroundColor: referenceMode === 'universal'
? '#f5f3ff'
: '#ffffff',
cursor: supportsUniversalReference ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
padding: '8px 12px',
transition: 'all 0.2s',
textAlign: 'left',
gap: 8,
opacity: supportsUniversalReference ? 1 : 0.4,
}}
>
<PictureOutlined style={{ fontSize: 14, color: referenceMode === 'universal' ? '#8b5cf6' : '#98a2b3' }} />
<span style={{
fontSize: 14,
fontWeight: referenceMode === 'universal' ? 600 : 500,
color: referenceMode === 'universal' ? '#8b5cf6' : '#475467',
}}>
</span>
{!supportsUniversalReference && (
<span style={{ fontSize: 10, color: '#98a2b3', marginLeft: 'auto' }}></span>
)}
</button>
<button
onClick={() => {
handleReferenceModeChange('first_last_frame');
setReferenceModeDropdownVisible(false);
}}
disabled={!supportsFirstLastFrame}
style={{
flex: 1,
minHeight: 48,
borderRadius: 10,
border: referenceMode === 'first_last_frame'
? '1px solid #8b5cf6'
: '1px solid #E7EAF0',
backgroundColor: referenceMode === 'first_last_frame'
? '#f5f3ff'
: '#ffffff',
cursor: supportsFirstLastFrame ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
padding: '8px 12px',
transition: 'all 0.2s',
textAlign: 'left',
gap: 8,
opacity: supportsFirstLastFrame ? 1 : 0.4,
}}
>
<SwapOutlined style={{ fontSize: 14, color: referenceMode === 'first_last_frame' ? '#8b5cf6' : '#98a2b3' }} />
<span style={{
fontSize: 14,
fontWeight: referenceMode === 'first_last_frame' ? 600 : 500,
color: referenceMode === 'first_last_frame' ? '#8b5cf6' : '#475467',
}}>
</span>
{!supportsFirstLastFrame && (
<span style={{ fontSize: 10, color: '#98a2b3', marginLeft: 'auto' }}></span>
)}
</button>
</div>
</div>
</>
</div>
)}
</div>
)}
@@ -2860,7 +2974,13 @@ const AIChatPage: React.FC = () => {
{mediaType === 'image' && (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => setShowImageSettingsModal(!showImageSettingsModal)}
onClick={() => {
setShowMediaTypeModal(false);
setShowEngineModal(false);
setShowVideoSettingsModal(false);
setReferenceModeDropdownVisible(false);
setShowImageSettingsModal(!showImageSettingsModal);
}}
className="image-settings-trigger"
style={{
minWidth: 156,
@@ -3164,7 +3284,13 @@ const AIChatPage: React.FC = () => {
{mediaType === 'video' && (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => setShowVideoSettingsModal(!showVideoSettingsModal)}
onClick={() => {
setShowMediaTypeModal(false);
setShowEngineModal(false);
setShowImageSettingsModal(false);
setReferenceModeDropdownVisible(false);
setShowVideoSettingsModal(!showVideoSettingsModal);
}}
className="image-settings-trigger"
style={{
minWidth: 156,