Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
Vendored
+6
-6
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -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-CKURqRU_.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-DSi2SJq0.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>
|
||||
|
||||
@@ -237,6 +237,10 @@ export async function updateSystemConfig(id: string, value: string): Promise<voi
|
||||
await api.put(`/admin/system-configs/${id}`, { value });
|
||||
}
|
||||
|
||||
export async function createSystemConfig(key: string, value: string, description?: string): Promise<SystemConfig> {
|
||||
return api.post('/admin/system-configs', { key, value, description });
|
||||
}
|
||||
|
||||
export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
|
||||
return api.get('/admin/resource-capacity/global');
|
||||
}
|
||||
@@ -289,6 +293,22 @@ export async function uploadLogo(file: File): Promise<{ url: string }> {
|
||||
return { url: res.url };
|
||||
}
|
||||
|
||||
export async function uploadLoginVideo(file: File): Promise<{ url: string }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/admin/upload-login-video`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.detail || '上传失败');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
|
||||
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value));
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Typography, Upload,
|
||||
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Tabs, Typography, Upload,
|
||||
} from 'antd';
|
||||
import {
|
||||
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined,
|
||||
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
createSystemConfig,
|
||||
getGlobalResourceCapacity,
|
||||
getSystemConfigs,
|
||||
saveGlobalResourceCapacity,
|
||||
updateSystemConfig,
|
||||
uploadLogo,
|
||||
uploadPdf,
|
||||
uploadLoginVideo,
|
||||
} from '../api';
|
||||
import type { ResourceCapacityUnit, SystemConfig } from '../types';
|
||||
|
||||
@@ -39,6 +41,10 @@ const AdminSettings: React.FC = () => {
|
||||
getSystemConfigs(),
|
||||
getGlobalResourceCapacity(),
|
||||
]);
|
||||
// 确保 llm_media_as_base64 配置存在
|
||||
if (!data.some(c => c.key === 'llm_media_as_base64')) {
|
||||
data.push({ id: 'cfg_llm_media_as_base64', key: 'llm_media_as_base64', value: 'true', description: '文字模型请求时图片/视频使用 base64 编码' });
|
||||
}
|
||||
setConfigs(data);
|
||||
const formValues: Record<string, any> = {};
|
||||
data.forEach(c => { formValues[c.key] = c.value; });
|
||||
@@ -118,6 +124,55 @@ const AdminSettings: React.FC = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleLoginVideoUpload = async (file: File) => {
|
||||
setUploading('login_bg_video');
|
||||
try {
|
||||
const res = await uploadLoginVideo(file);
|
||||
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: res.url } : c));
|
||||
form.setFieldsValue({ login_bg_video: res.url });
|
||||
const config = configs.find(c => c.key === 'login_bg_video');
|
||||
if (config) {
|
||||
await updateSystemConfig(config.id, res.url);
|
||||
}
|
||||
message.success('登录背景视频上传成功并已保存');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '上传失败');
|
||||
} finally {
|
||||
setUploading('');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleRemoveLoginVideo = async () => {
|
||||
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: '' } : c));
|
||||
form.setFieldsValue({ login_bg_video: '' });
|
||||
const config = configs.find(c => c.key === 'login_bg_video');
|
||||
if (config) {
|
||||
await updateSystemConfig(config.id, '');
|
||||
}
|
||||
message.success('已移除登录背景视频');
|
||||
};
|
||||
|
||||
const handleToggleBase64 = async (checked: boolean) => {
|
||||
try {
|
||||
let config = configs.find(c => c.key === 'llm_media_as_base64');
|
||||
if (config && config.id && !config.id.startsWith('cfg_')) {
|
||||
await updateSystemConfig(config.id, checked ? 'true' : 'false');
|
||||
} else {
|
||||
const res = await createSystemConfig('llm_media_as_base64', checked ? 'true' : 'false', '文字模型请求时图片/视频使用 base64 编码');
|
||||
config = res;
|
||||
}
|
||||
setConfigs(prev => {
|
||||
const exists = prev.some(c => c.key === 'llm_media_as_base64');
|
||||
if (exists) return prev.map(c => c.key === 'llm_media_as_base64' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
|
||||
return [...prev, config!];
|
||||
});
|
||||
message.success(`已${checked ? '开启' : '关闭'}文字模型媒体 base64 编码`);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const groupedConfigs: Record<string, SystemConfig[]> = {
|
||||
'站点信息': configs.filter(c => c.key.startsWith('site_')),
|
||||
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
|
||||
@@ -278,6 +333,170 @@ const AdminSettings: React.FC = () => {
|
||||
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
|
||||
}
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'basic',
|
||||
label: '网站基础设置',
|
||||
children: (
|
||||
<Form form={form} layout="vertical">
|
||||
{['站点信息', '协议配置', 'SEO 设置'].map(group => (
|
||||
<div key={group} style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
{group}
|
||||
</Typography.Text>
|
||||
{group === '协议配置' ? (
|
||||
groupedConfigs[group]?.map(config => (
|
||||
<PdfUploadField key={config.id} config={config} />
|
||||
))
|
||||
) : (
|
||||
groupedConfigs[group]?.map(config => (
|
||||
<Form.Item
|
||||
key={config.id}
|
||||
name={config.key}
|
||||
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
|
||||
extra={getFieldDescription(config)}
|
||||
>
|
||||
{getFieldComponent(config)}
|
||||
</Form.Item>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'credits',
|
||||
label: '用户积分配置',
|
||||
children: (
|
||||
<Form form={form} layout="vertical">
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
用户积分配置
|
||||
</Typography.Text>
|
||||
{groupedConfigs['用户积分配置']?.map(config => (
|
||||
<Form.Item
|
||||
key={config.id}
|
||||
name={config.key}
|
||||
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
|
||||
extra={getFieldDescription(config)}
|
||||
>
|
||||
{getFieldComponent(config)}
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'other',
|
||||
label: '其他配置',
|
||||
children: (
|
||||
<Form form={form} layout="vertical">
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
其他配置
|
||||
</Typography.Text>
|
||||
{groupedConfigs['其他配置']?.map(config => (
|
||||
<Form.Item
|
||||
key={config.id}
|
||||
name={config.key}
|
||||
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
|
||||
extra={getFieldDescription(config)}
|
||||
>
|
||||
{getFieldComponent(config)}
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 登录背景视频 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
登录背景视频
|
||||
</Typography.Text>
|
||||
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Space>
|
||||
<VideoCameraOutlined style={{ color: '#6366f1', fontSize: 18 }} />
|
||||
<Typography.Text strong>背景视频</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
{form.getFieldValue('login_bg_video') && (
|
||||
<Button size="small" danger onClick={handleRemoveLoginVideo}>
|
||||
移除
|
||||
</Button>
|
||||
)}
|
||||
<Upload
|
||||
accept="video/*,image/gif,image/webp"
|
||||
showUploadList={false}
|
||||
beforeUpload={handleLoginVideoUpload}
|
||||
>
|
||||
<Button size="small" type="primary" icon={<UploadOutlined />} loading={uploading === 'login_bg_video'}>
|
||||
上传视频
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
</div>
|
||||
{(() => {
|
||||
const url = form.getFieldValue('login_bg_video');
|
||||
if (!url) {
|
||||
return (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
未设置,前台将默认使用 backimage.png
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
const fullUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${url}`;
|
||||
const isGif = url.toLowerCase().endsWith('.gif');
|
||||
return isGif ? (
|
||||
<img src={fullUrl} alt="预览" style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#f0f0f5', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<video
|
||||
src={fullUrl}
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#000' }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 6 }}>
|
||||
支持 MP4、WebM、MOV、GIF、WebP,最大 50MB
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文字模型媒体编码 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
文字模型媒体编码
|
||||
</Typography.Text>
|
||||
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
|
||||
<div>
|
||||
<Typography.Text strong>图片/视频 base64 编码</Typography.Text>
|
||||
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||
开启后文字模型请求时将媒体转 base64 发送,而非 URL 链接
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
<Switch
|
||||
checked={(configs.find(c => c.key === 'llm_media_as_base64') || {}).value === 'true'}
|
||||
onChange={handleToggleBase64}
|
||||
checkedChildren="base64"
|
||||
unCheckedChildren="链接"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
@@ -292,78 +511,11 @@ const AdminSettings: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>系统设置</Typography.Title>
|
||||
<Typography.Text type="secondary">管理站点基础信息、协议文件和SEO配置</Typography.Text>
|
||||
<Typography.Text type="secondary">管理站点基础信息、用户积分和系统配置</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
{Object.entries(groupedConfigs).map(([group, items]) => (
|
||||
<div key={group} style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
{group}
|
||||
</Typography.Text>
|
||||
{group === '协议配置' ? (
|
||||
items.map(config => (
|
||||
<PdfUploadField key={config.id} config={config} />
|
||||
))
|
||||
) : (
|
||||
items.map(config => (
|
||||
<Form.Item
|
||||
key={config.id}
|
||||
name={config.key}
|
||||
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
|
||||
extra={getFieldDescription(config)}
|
||||
>
|
||||
{getFieldComponent(config)}
|
||||
</Form.Item>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
资源空间管控
|
||||
</Typography.Text>
|
||||
<div style={{ padding: 16, border: '1px solid #f0f0f5', borderRadius: 10, background: '#fafbfc' }}>
|
||||
<Space align="start" style={{ marginBottom: 16 }}>
|
||||
<DatabaseOutlined style={{ color: '#6366f1', fontSize: 18, marginTop: 2 }} />
|
||||
<div>
|
||||
<Typography.Text strong>全局生成资源容量上限</Typography.Text>
|
||||
<div style={{ color: '#64748b', fontSize: 13, marginTop: 4 }}>
|
||||
开启后会按用户当前有效资源占用量进行提交前拦截;用户个人配置存在时优先级高于全局配置。
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
<Form.Item
|
||||
name="resource_capacity_enabled"
|
||||
label="启用全局容量管控"
|
||||
valuePropName="checked"
|
||||
extra="关闭时全局不限制;若用户设置了个人配置,则仍按用户个人配置优先判断。"
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 16 }}>
|
||||
<Form.Item
|
||||
name="resource_capacity_limit_value"
|
||||
label="容量数值"
|
||||
extra="最小为1,不能为负数,最多支持3位小数。"
|
||||
rules={[{ required: true, message: '请输入容量数值' }]}
|
||||
>
|
||||
<InputNumber min={1} precision={3} style={{ width: '100%' }} size="large" placeholder="例如 10.500" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="resource_capacity_limit_unit"
|
||||
label="容量单位"
|
||||
extra="MB / GB / TB 固定枚举"
|
||||
rules={[{ required: true, message: '请选择容量单位' }]}
|
||||
>
|
||||
<Select size="large" options={capacityUnitOptions} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
<Tabs items={tabItems} defaultActiveKey="basic" />
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.schemas.admin import (
|
||||
CreditAdjustRequest,
|
||||
ModelConfigCreate,
|
||||
ModelConfigOut,
|
||||
SystemConfigCreate,
|
||||
SystemConfigUpdate,
|
||||
SystemConfigOut,
|
||||
AdminUserOut,
|
||||
@@ -1594,6 +1595,34 @@ async def list_system_configs(
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/system-configs", response_model=SystemConfigOut)
|
||||
async def create_system_config(
|
||||
req: SystemConfigCreate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from app.utils.id_gen import generate_id
|
||||
config = SystemConfig(
|
||||
id=generate_id(),
|
||||
key=req.key,
|
||||
value=str(req.value),
|
||||
description=req.description or "",
|
||||
)
|
||||
db.add(config)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"创建系统配置: {config.key}",
|
||||
"POST",
|
||||
"/admin/system-configs",
|
||||
detail=json.dumps({"key": req.key, "value": req.value}, ensure_ascii=False),
|
||||
)
|
||||
await db.commit()
|
||||
return config
|
||||
|
||||
|
||||
@router.put("/system-configs/{config_id}", response_model=SystemConfigOut)
|
||||
async def update_system_config(
|
||||
config_id: str,
|
||||
@@ -2310,6 +2339,58 @@ async def upload_logo(
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@router.post("/upload-login-video")
|
||||
async def upload_login_video(
|
||||
file: UploadFile = File(...),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""上传登录页背景视频/动图,保存 URL 到 system config login_bg_video。"""
|
||||
from app.config import settings
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="请选择文件")
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > 50 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="文件大小不能超过50MB")
|
||||
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
safe_name = f"login_bg_{generate_id()}{ext}"
|
||||
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, safe_name)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
url = f"/uploads/{safe_name}"
|
||||
|
||||
result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key == "login_bg_video").limit(1)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config:
|
||||
config.value = url
|
||||
else:
|
||||
db.add(SystemConfig(
|
||||
id=generate_id(),
|
||||
key="login_bg_video",
|
||||
value=url,
|
||||
description="登录页背景视频",
|
||||
))
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"上传登录背景视频: {file.filename}",
|
||||
"POST",
|
||||
"/admin/upload-login-video",
|
||||
detail=json.dumps({"filename": file.filename, "url": url}, ensure_ascii=False),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"url": url}
|
||||
|
||||
|
||||
# ── Payment Stats ────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -342,7 +342,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
|
||||
"""Public endpoint returning site name, logo, agreement and copyright info."""
|
||||
result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key.in_([
|
||||
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual"
|
||||
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video"
|
||||
]))
|
||||
)
|
||||
configs = result.scalars().all()
|
||||
@@ -365,6 +365,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
|
||||
"user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")),
|
||||
"site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"),
|
||||
"operation_manual": info.get("operation_manual", ""),
|
||||
"login_bg_video": to_full_url(info.get("login_bg_video")) if info.get("login_bg_video") else "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class Settings(BaseSettings):
|
||||
LLM_API_KEY: str = ""
|
||||
LLM_MODEL: str = "gpt-4o"
|
||||
LLM_MOCK: bool = True
|
||||
LLM_MEDIA_AS_BASE64: bool = True
|
||||
|
||||
ENCRYPTION_KEY: str = "changeme-32bytes-base64-key-here!!"
|
||||
|
||||
|
||||
@@ -293,6 +293,20 @@ async def _seed_data():
|
||||
)
|
||||
)
|
||||
|
||||
# 文字模型媒体使用 base64 开关
|
||||
existing_media_format = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key == "llm_media_as_base64").limit(1)
|
||||
)
|
||||
if not existing_media_format.scalar_one_or_none():
|
||||
db.add(
|
||||
SystemConfig(
|
||||
id=generate_id(),
|
||||
key="llm_media_as_base64",
|
||||
value="true",
|
||||
description="文字模型请求时图片/视频使用 base64 编码(而非 URL 链接)",
|
||||
)
|
||||
)
|
||||
|
||||
# Seed credit ratios - model_config_id is kept as a compatible field name,
|
||||
# but now stores the actual engine id:
|
||||
# - gen_type=video -> video_engines.id
|
||||
|
||||
@@ -29,6 +29,12 @@ class ModelConfigOut(ModelConfigCreate):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SystemConfigCreate(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SystemConfigUpdate(BaseModel):
|
||||
value: str | int | float
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ def _load_refs(record: ChatGenerationTask) -> list[dict]:
|
||||
return []
|
||||
|
||||
|
||||
def _build_user_content(record: ChatGenerationTask) -> list[dict[str, Any]]:
|
||||
async def _build_user_content(record: ChatGenerationTask, db: AsyncSession | None = None) -> list[dict[str, Any]]:
|
||||
from app.utils.media import media_to_base64
|
||||
|
||||
if record.gen_type == "image":
|
||||
params = f"图片参数:分辨率档位={record.image_size or '2K'},比例={record.image_proportion or '1:1'},像素={record.image_px or '2048x2048'}"
|
||||
else:
|
||||
@@ -54,7 +56,15 @@ def _build_user_content(record: ChatGenerationTask) -> list[dict[str, Any]]:
|
||||
ref_url = ref.get("url") or ""
|
||||
if not ref_url:
|
||||
continue
|
||||
url = _absolute_url(ref_url)
|
||||
if db and await get_llm_media_as_base64(db):
|
||||
if ref_type == "image":
|
||||
url = await media_to_base64(ref_url, "image/png")
|
||||
elif ref_type == "video":
|
||||
url = await media_to_base64(ref_url, "video/mp4")
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
url = _absolute_url(ref_url)
|
||||
if ref_type == "image":
|
||||
parts.append({"type": "image_url", "image_url": {"url": url}})
|
||||
elif ref_type == "video":
|
||||
@@ -94,7 +104,7 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
|
||||
"model": config.model_name,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": _build_user_content(record)},
|
||||
{"role": "user", "content": await _build_user_content(record, db)},
|
||||
],
|
||||
"max_tokens": config.max_tokens,
|
||||
"temperature": config.temperature,
|
||||
|
||||
@@ -1536,9 +1536,16 @@ async def optimize_hot_opening_video_prompt(
|
||||
trace_id: str | None = None,
|
||||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||||
duration = int(video_config["duration"])
|
||||
from app.utils.media import media_to_base64, get_llm_media_as_base64
|
||||
if await get_llm_media_as_base64(db):
|
||||
video_url_final = await media_to_base64(material_video_url, "video/mp4")
|
||||
image_url_final = await media_to_base64(generated_image_url, "image/png")
|
||||
else:
|
||||
video_url_final = _build_file_url_or_data_uri(material_video_url)
|
||||
image_url_final = _build_file_url_or_data_uri(generated_image_url)
|
||||
references = [
|
||||
{"type": "video", "url": _build_file_url_or_data_uri(material_video_url)},
|
||||
{"type": "image", "url": _build_file_url_or_data_uri(generated_image_url)},
|
||||
{"type": "video", "url": video_url_final},
|
||||
{"type": "image", "url": image_url_final},
|
||||
]
|
||||
client_schema = build_dynamic_schema(video_config, schema_config_snapshot)
|
||||
reference_video_fps = int(video_config.get("reference_video_fps") or DEFAULT_REFERENCE_VIDEO_FPS)
|
||||
|
||||
@@ -248,7 +248,63 @@ async def _call_openai_compatible(
|
||||
if ref_type == "video":
|
||||
video_urls.append(ref_url)
|
||||
|
||||
def _build_file_url_or_data_uri(file_url: str, fallback_mime: str) -> str:
|
||||
async def _build_multimodal_content(
|
||||
user_content: str,
|
||||
image_urls: list[str],
|
||||
video_urls: list[str],
|
||||
) -> tuple[dict, dict | None]:
|
||||
"""构建多模态 user_message。返回 (actual_message, log_message)。"""
|
||||
from app.utils.media import media_to_base64
|
||||
|
||||
content_parts = [{"type": "text", "text": user_content}]
|
||||
|
||||
from app.utils.media import get_llm_media_as_base64
|
||||
as_base64 = await get_llm_media_as_base64(db)
|
||||
for img in image_urls:
|
||||
if as_base64:
|
||||
url = await media_to_base64(img, "image/png")
|
||||
else:
|
||||
url = _file_url_or_data_uri(img, "image/png")
|
||||
content_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": url},
|
||||
})
|
||||
|
||||
for video in video_urls:
|
||||
if as_base64:
|
||||
url = await media_to_base64(video, "video/mp4")
|
||||
else:
|
||||
url = _file_url_or_data_uri(video, "video/mp4")
|
||||
content_parts.append({
|
||||
"type": "video_url",
|
||||
"video_url": {"url": url},
|
||||
})
|
||||
|
||||
user_message = {
|
||||
"role": "user",
|
||||
"content": content_parts,
|
||||
}
|
||||
|
||||
# Log-friendly version: keep original paths instead of base64
|
||||
log_content_parts = [{"type": "text", "text": user_content}]
|
||||
for img in image_urls:
|
||||
log_content_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": img},
|
||||
})
|
||||
for video in video_urls:
|
||||
log_content_parts.append({
|
||||
"type": "video_url",
|
||||
"video_url": {"url": video},
|
||||
})
|
||||
|
||||
log_user_message = {
|
||||
"role": "user",
|
||||
"content": log_content_parts,
|
||||
}
|
||||
return user_message, log_user_message
|
||||
|
||||
def _file_url_or_data_uri(file_url: str, fallback_mime: str) -> str:
|
||||
"""
|
||||
Convert local upload path to base64 data URI.
|
||||
Keep remote http/https/data URLs as-is.
|
||||
@@ -268,54 +324,9 @@ async def _call_openai_compatible(
|
||||
return f"data:{mime};base64,{b64}"
|
||||
|
||||
if image_urls or video_urls:
|
||||
content_parts = [{"type": "text", "text": user_content}]
|
||||
|
||||
for img in image_urls:
|
||||
url = _build_file_url_or_data_uri(img, "image/png")
|
||||
content_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": url,
|
||||
},
|
||||
})
|
||||
|
||||
for video in video_urls:
|
||||
url = _build_file_url_or_data_uri(video, "video/mp4")
|
||||
content_parts.append({
|
||||
"type": "video_url",
|
||||
"video_url": {
|
||||
"url": url,
|
||||
},
|
||||
})
|
||||
|
||||
user_message = {
|
||||
"role": "user",
|
||||
"content": content_parts,
|
||||
}
|
||||
|
||||
# Log-friendly version: keep original paths instead of base64
|
||||
log_content_parts = [{"type": "text", "text": user_content}]
|
||||
|
||||
for img in image_urls:
|
||||
log_content_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": img,
|
||||
},
|
||||
})
|
||||
|
||||
for video in video_urls:
|
||||
log_content_parts.append({
|
||||
"type": "video_url",
|
||||
"video_url": {
|
||||
"url": video,
|
||||
},
|
||||
})
|
||||
|
||||
log_user_message = {
|
||||
"role": "user",
|
||||
"content": log_content_parts,
|
||||
}
|
||||
user_message, log_user_message = await _build_multimodal_content(
|
||||
user_content, image_urls, video_urls
|
||||
)
|
||||
else:
|
||||
user_message = {
|
||||
"role": "user",
|
||||
|
||||
@@ -77,8 +77,12 @@ def build_file_url_or_data_uri(file_url: str, fallback_mime: str = "video/mp4")
|
||||
# return f"data:{mime};base64,{b64}"
|
||||
|
||||
|
||||
def build_user_message(user_text: str, video_url: str) -> tuple[dict[str, Any], dict[str, Any], str]:
|
||||
real_url = build_file_url_or_data_uri(video_url)
|
||||
async def build_user_message(user_text: str, video_url: str, db=None) -> tuple[dict[str, Any], dict[str, Any], str]:
|
||||
from app.utils.media import media_to_base64, get_llm_media_as_base64
|
||||
if await get_llm_media_as_base64(db):
|
||||
real_url = await media_to_base64(video_url, "video/mp4")
|
||||
else:
|
||||
real_url = build_file_url_or_data_uri(video_url)
|
||||
content_parts = [
|
||||
{
|
||||
"type": "video_url",
|
||||
@@ -543,7 +547,7 @@ async def analyze_video_for_shot_split(
|
||||
|
||||
system_prompt = build_video_analysis_system_prompt(mode=mode)
|
||||
user_text = build_video_analysis_user_text(mode=mode)
|
||||
user_message, log_user_message, real_video_url = build_user_message(user_text, video_url)
|
||||
user_message, log_user_message, real_video_url = await build_user_message(user_text, video_url, db)
|
||||
|
||||
request_data: dict[str, Any] = {
|
||||
"model": config.model_name,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""媒体文件 → base64 data URI 转换工具。"""
|
||||
import base64
|
||||
import mimetypes
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.models.system_config import SystemConfig
|
||||
|
||||
|
||||
async def get_llm_media_as_base64(db=None) -> bool:
|
||||
"""读取 SystemConfig 中的 llm_media_as_base64 设置。无 DB 连接时回退到 env。"""
|
||||
if db is not None:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key == "llm_media_as_base64").limit(1)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config:
|
||||
return config.value.lower() in ("true", "1", "yes")
|
||||
except Exception:
|
||||
pass
|
||||
return settings.LLM_MEDIA_AS_BASE64
|
||||
|
||||
|
||||
async def media_to_base64(url: str, fallback_mime: str = "image/png", max_mb: int = 20) -> str:
|
||||
"""将任意媒体 URL/路径转为 base64 data URI。
|
||||
|
||||
- data: URI → 原样返回
|
||||
- http(s):// → 下载后编码(受 max_mb 限制)
|
||||
- /uploads/xxx 或本地路径 → 读盘编码
|
||||
"""
|
||||
if url.startswith("data:"):
|
||||
return url
|
||||
|
||||
if url.startswith(("http://", "https://")):
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.get(url)
|
||||
size_mb = len(resp.content) / (1024 * 1024)
|
||||
if size_mb > max_mb:
|
||||
raise ValueError(f"媒体文件过大 ({size_mb:.1f}MB > {max_mb}MB)")
|
||||
b64 = base64.b64encode(resp.content).decode()
|
||||
mime = resp.headers.get("content-type") or fallback_mime
|
||||
return f"data:{mime};base64,{b64}"
|
||||
|
||||
# 本地路径
|
||||
relative_path = url.replace("/uploads/", "", 1).lstrip("/")
|
||||
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, relative_path)
|
||||
mime = mimetypes.guess_type(file_path)[0] or fallback_mime
|
||||
with open(file_path, "rb") as f:
|
||||
b64 = base64.b64encode(f.read()).decode()
|
||||
return f"data:{mime};base64,{b64}"
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
-506
File diff suppressed because one or more lines are too long
+557
File diff suppressed because one or more lines are too long
Vendored
+37
-37
@@ -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-DN27BTjS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DviWdElm.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
<!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-DOSsz052.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CKeRPhR_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -295,8 +295,8 @@ export async function verifyCaptcha(captchaId: string, x: number): Promise<strin
|
||||
return res.token;
|
||||
}
|
||||
// ── Site Info ─────────────────────────────────────────────
|
||||
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string }> {
|
||||
if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2024 民众智创 版权所有', operationManual: '' };
|
||||
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string; loginBgVideo: string }> {
|
||||
if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2024 民众智创 版权所有', operationManual: '', loginBgVideo: '' };
|
||||
return api.get('/auth/site-info', false);
|
||||
}
|
||||
// ── Video Engines ─────────────────────────────────────────
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
HeartOutlined,
|
||||
CameraOutlined,
|
||||
CalculatorOutlined,
|
||||
CaretDownOutlined,
|
||||
DollarOutlined,
|
||||
FireOutlined,
|
||||
CloudOutlined,
|
||||
@@ -126,6 +127,7 @@ const StorageCard: React.FC<{ data: ResourceCapacityData | null }> = ({ data })
|
||||
<div
|
||||
style={{
|
||||
// marginTop: 12,
|
||||
marginBottom: 12,
|
||||
padding: '0 12px',
|
||||
paddingTop: 6,
|
||||
borderRadius: 10,
|
||||
@@ -906,33 +908,59 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
<div style={{ padding: '16px 16px', flexShrink: 0, paddingTop: 0 }}>
|
||||
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
|
||||
<div style={{
|
||||
<div className="user-card-container" style={{
|
||||
padding: '10px 14px',
|
||||
display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 12,
|
||||
// padding: '10px 14px',
|
||||
borderRadius: 14, cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)',
|
||||
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04)',
|
||||
transition: 'all 0.35s cubic-bezier(0.4,0,0.2,1)',
|
||||
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.95) 0%, rgba(241, 245, 249, 0.95) 100%)',
|
||||
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04), inset 0 1px 0 rgba(255,255,255,0.8)',
|
||||
position: 'relative',
|
||||
border: '1px solid rgba(99, 102, 241, 0.15)',
|
||||
animation: 'cardBreath 3s ease-in-out infinite',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.08)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 24px rgba(99, 102, 241, 0.25), inset 0 1px 0 rgba(255,255,255,0.9)';
|
||||
e.currentTarget.style.transform = 'translateY(-3px) scale(1.01)';
|
||||
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.4)';
|
||||
e.currentTarget.style.animation = 'none';
|
||||
e.currentTarget.querySelector('.user-card-arrow')?.classList.add('arrow-hover');
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 12px rgba(0, 0, 0, 0.04)';
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(248, 250, 252, 0.95) 0%, rgba(241, 245, 249, 0.95) 100%)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 12px rgba(0, 0, 0, 0.04), inset 0 1px 0 rgba(255,255,255,0.8)';
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.15)';
|
||||
e.currentTarget.style.animation = 'cardBreath 3s ease-in-out infinite';
|
||||
e.currentTarget.querySelector('.user-card-arrow')?.classList.remove('arrow-hover');
|
||||
}}
|
||||
>
|
||||
<div className="user-card-glow" style={{
|
||||
position: 'absolute',
|
||||
inset: -4,
|
||||
borderRadius: 18,
|
||||
background: 'linear-gradient(90deg, transparent 0%, rgba(99,102,241,0.15) 25%, rgba(139,92,246,0.15) 50%, rgba(99,102,241,0.15) 75%, transparent 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
opacity: 0.8,
|
||||
animation: 'borderShimmer 2.5s linear infinite',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
<Avatar size={36} icon={<UserOutlined />}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
|
||||
transition: 'all 0.35s cubic-bezier(0.4,0,0.2,1)',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
animation: 'avatarPulse 2s ease-in-out infinite',
|
||||
}} />
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, position: 'relative', zIndex: 1 }}>
|
||||
<div style={{ color: '#1e293b', fontSize: 16, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: -0.01 }}>
|
||||
{user?.username}
|
||||
</div>
|
||||
@@ -941,12 +969,74 @@ const AppLayout: React.FC = () => {
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
letterSpacing: 0,
|
||||
// background: 'rgba(99, 102, 241, 0.08)',
|
||||
// padding: '2px 8px',
|
||||
borderRadius: 6,
|
||||
display: 'inline-block',
|
||||
animation: 'creditGlow 1.5s ease-in-out infinite',
|
||||
}}>积分: {user?.credits || 0}</div>
|
||||
</div>
|
||||
|
||||
<div className="user-card-arrow" style={{
|
||||
fontSize: 12,
|
||||
color: '#6366f1',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<MenuOutlined />
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes arrowFlash {
|
||||
0%, 100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.6;
|
||||
filter: drop-shadow(0 0 4px rgba(99, 102, 241, 0.4));
|
||||
}
|
||||
50% {
|
||||
transform: translateY(4px) scale(1.1);
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 12px rgba(99, 102, 241, 0.7));
|
||||
}
|
||||
}
|
||||
@keyframes creditGlow {
|
||||
0%, 100% {
|
||||
text-shadow: 0 0 0 transparent;
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 15px rgba(99, 102, 241, 0.6), 0 0 30px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
}
|
||||
@keyframes borderShimmer {
|
||||
0% { background-position: 200% center; }
|
||||
100% { background-position: -200% center; }
|
||||
}
|
||||
@keyframes cardBreath {
|
||||
0%, 100% {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04), inset 0 1px 0 rgba(255,255,255,0.8);
|
||||
border-color: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.1), inset 0 1px 0 rgba(255,255,255,0.8);
|
||||
border-color: rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
}
|
||||
@keyframes avatarPulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
.user-card-arrow.arrow-hover {
|
||||
color: #8b5cf6 !important;
|
||||
transform: rotate(180deg) scale(1.15) !important;
|
||||
animation: none !important;
|
||||
filter: drop-shadow(0 0 15px rgba(139, 92, 246, 0.8)) !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ interface Props {
|
||||
const spanByCount = (count: number, index: number): number => {
|
||||
if (count <= 1) return 6;
|
||||
if (count === 2 || count === 4) return 3;
|
||||
if (count === 3) return index < 2 ? 3 : 6;
|
||||
return index < 3 ? 2 : 3;
|
||||
if (count === 3) return 3;
|
||||
return 2;
|
||||
};
|
||||
|
||||
const statusText = (item: GenerationTaskResourceItem): string => {
|
||||
@@ -65,12 +65,12 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
|
||||
})) : [task]);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gridAutoRows: 'minmax(0, 1fr)', gap: count > 1 ? 4 : 0 }}>
|
||||
<div style={{ width: '100%', height: '100%', display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gridAutoRows: 'minmax(0, 1fr)', gap: count > 1 ? 6 : 0 }}>
|
||||
{items.slice(0, 5).map((item, index) => {
|
||||
const displayStatus = item.displayStatus || item.pipelineStage || item.status || 'generating';
|
||||
const imageUrl = resolveUrl(item.imageUrl);
|
||||
const imageUrl = resolveUrl(`/static${item.imageUrl}&w=300&q=50`);
|
||||
const videoUrl = resolveUrl(item.videoUrl);
|
||||
const coverUrl = resolveUrl(item.videoCoverUrl);
|
||||
const coverUrl = resolveUrl(`/static${item.videoCoverUrl}&w=300&q=50`);
|
||||
const isVideo = (item.genType || task.genType) === 'video';
|
||||
const hasResource = isVideo ? !!videoUrl : !!imageUrl;
|
||||
return (
|
||||
@@ -83,11 +83,11 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: items.length === 1 ? 12 : 8,
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #FAFBFC 100%)',
|
||||
border: items.length === 1 ? 'none' : '1px solid #E7EAF0',
|
||||
background: '#ffffff',
|
||||
border: '1px solid #E7EAF0',
|
||||
}}
|
||||
>
|
||||
{hasResource && displayStatus !== 'deleted' ? (
|
||||
{hasResource && displayStatus !== 'deleted' && displayStatus !== 'failed' && displayStatus !== 'download_failed' && !isPending(item) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPreview(isVideo ? videoUrl : imageUrl, isVideo ? 'video' : 'image')}
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
PauseOutlined,
|
||||
|
||||
} from '@ant-design/icons';
|
||||
import { div } from 'three/tsl';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
// 解构Input组件
|
||||
@@ -92,6 +93,7 @@ interface MediaReference {
|
||||
private_asset_id?: string;
|
||||
remote_asset_id?: string;
|
||||
upload_resource_id?: string;
|
||||
fileSizeBytes?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1345,8 +1347,9 @@ const AIChatPage: React.FC = () => {
|
||||
setLastFrame(mediaRef);
|
||||
}
|
||||
antdMessage.success('图片上传成功');
|
||||
} catch (error) {
|
||||
antdMessage.error('上传失败');
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
|
||||
antdMessage.error(errorMsg);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadTarget(null);
|
||||
@@ -1494,7 +1497,8 @@ const AIChatPage: React.FC = () => {
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
} catch (error) {
|
||||
antdMessage.error('上传失败');
|
||||
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
|
||||
antdMessage.error(errorMsg);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -1502,7 +1506,7 @@ const AIChatPage: React.FC = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const doUpload = async (file: File): Promise<false | { name: string; type: 'image' | 'video' | 'audio'; url: string; label: string; duration?: number }> => {
|
||||
const doUpload = async (file: File): Promise<false | { name: string; type: 'image' | 'video' | 'audio'; url: string; label: string; duration?: number; fileSizeBytes: number }> => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const isAudio = file.type.startsWith('audio/');
|
||||
@@ -1512,12 +1516,24 @@ const AIChatPage: React.FC = () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
|
||||
const maxMB = isVideo ? 100 : (isAudio ? 50 : 30);
|
||||
if (file.size / 1024 / 1024 > maxMB) {
|
||||
antdMessage.error(`${isVideo ? '视频' : (isAudio ? '音频' : '图片')}大小不能超过${maxMB}MB`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isImage || isVideo) {
|
||||
const latestMedia = useAppStore.getState().currentMedia;
|
||||
const currentTotalSize = latestMedia
|
||||
.filter((m) => m.type === 'image' || m.type === 'video')
|
||||
.reduce((sum, m) => sum + (Number(m.fileSizeBytes) || 0), 0);
|
||||
const totalSizeMB = (currentTotalSize + file.size) / 1024 / 1024;
|
||||
if (totalSizeMB > 64) {
|
||||
antdMessage.error(`所有图片和视频总大小不能超过64MB,当前已${(currentTotalSize / 1024 / 1024).toFixed(1)}MB,加上此文件后${totalSizeMB.toFixed(1)}MB`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
const audioExt = file.name.split('.').pop()?.toLowerCase();
|
||||
if (!['wav', 'mp3'].includes(audioExt || '')) {
|
||||
@@ -1613,10 +1629,12 @@ const AIChatPage: React.FC = () => {
|
||||
type: pendingMedia.type,
|
||||
url: res.url,
|
||||
label: pendingMedia.label || '',
|
||||
fileSizeBytes: file.size,
|
||||
...(pendingMedia.duration !== undefined && { duration: pendingMedia.duration }),
|
||||
};
|
||||
} catch (error) {
|
||||
antdMessage.error('上传失败');
|
||||
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
|
||||
antdMessage.error(errorMsg);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1895,12 +1913,18 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
const handleDownload = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!previewUrl) return;
|
||||
let downloadUrl = previewUrl.replace('/static', '').replace(/&w=\d+/i, '').replace(/&q=\d+/i, '');
|
||||
if (!downloadUrl.includes('download=1')) {
|
||||
downloadUrl += '&download=1';
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}&download=1`;
|
||||
link.href = downloadUrl;
|
||||
console.log(downloadUrl);
|
||||
|
||||
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
@@ -4441,21 +4465,23 @@ const AIChatPage: React.FC = () => {
|
||||
</Space>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap', height: 34, padding: '0 8px 0 12px', borderRadius: 11, background: 'rgba(255, 255, 255, 0.92)', border: '1px solid rgba(231, 234, 240, 0.92)', boxShadow: '0 4px 12px rgba(47, 52, 64, 0.04)' }}>
|
||||
<Text style={{ fontSize: 13, color: '#667085', fontWeight: 600 }}>生成数量:</Text>
|
||||
<Select
|
||||
value={generationCount}
|
||||
onChange={(value) => setGenerationCount(Number(value || 1))}
|
||||
disabled={effectiveMaxGenerationCount <= 1}
|
||||
size="small"
|
||||
variant="borderless"
|
||||
style={{ width: 68 }}
|
||||
options={Array.from({ length: effectiveMaxGenerationCount }, (_, index) => ({
|
||||
value: index + 1,
|
||||
label: `${index + 1}份`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
{multiGenerationEnabled && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap', height: 34, padding: '0 8px 0 12px', borderRadius: 11, background: 'rgba(255, 255, 255, 0.92)', border: '1px solid rgba(231, 234, 240, 0.92)', boxShadow: '0 4px 12px rgba(47, 52, 64, 0.04)' }}>
|
||||
<Text style={{ fontSize: 13, color: '#667085', fontWeight: 600 }}>生成数量:</Text>
|
||||
<Select
|
||||
value={generationCount}
|
||||
onChange={(value) => setGenerationCount(Number(value || 1))}
|
||||
disabled={effectiveMaxGenerationCount <= 1}
|
||||
size="small"
|
||||
variant="borderless"
|
||||
style={{ width: 68 }}
|
||||
options={Array.from({ length: effectiveMaxGenerationCount }, (_, index) => ({
|
||||
value: index + 1,
|
||||
label: `${index + 1}份`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#667085', whiteSpace: 'nowrap', height: 34, padding: '0 12px', borderRadius: 11, background: 'rgba(255, 255, 255, 0.92)', border: '1px solid rgba(231, 234, 240, 0.92)', boxShadow: '0 4px 12px rgba(47, 52, 64, 0.04)' }}>
|
||||
<Text style={{ fontSize: 13, color: '#667085', fontWeight: 600 }}>预估积分:</Text>
|
||||
<Text style={{ fontSize: 13, color: '#2f3440', fontWeight: 800 }}>{getEstimatedCredits()}</Text>
|
||||
@@ -4600,15 +4626,17 @@ const AIChatPage: React.FC = () => {
|
||||
<p style={{ fontSize: 16, color: '#A45B5B', marginBottom: 16 }}>图片/视频资源已过期,请刷新重新加载~</p>
|
||||
</div>
|
||||
) : previewType === 'image' ? (
|
||||
// <div>{previewUrl}</div>
|
||||
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${previewUrl}&w=300&q=50`}
|
||||
src={`${previewUrl}`}
|
||||
alt="预览"
|
||||
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
|
||||
src={`${previewUrl}`}
|
||||
controls
|
||||
style={{ maxWidth: '100%', maxHeight: '400px' }}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button, Typography, Collapse, Space, Modal, Input, Table, message } from 'antd';
|
||||
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons';
|
||||
import { Button, Typography, Collapse, Space, Modal, Input, Table, message, Tooltip } from 'antd';
|
||||
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { getReplicationList, getReplicationDetail, gettwo, getthree, getfour, getEngine, updateHotOpeningVideoPromptSchema, updateImagePrompt } from '../api/index';
|
||||
import { getReplicationList, getReplicationDetail, gettwo, getthree, getfour, getEngine, updateHotOpeningVideoPromptSchema, updateImagePrompt, calculateCredits } from '../api/index';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import VideoPromptSchemaEditor from '../components/VideoPromptSchemaEditor';
|
||||
import { validateVideoPromptSchemaByConfig } from '../utils/videoPromptSchema';
|
||||
import './css/InitialInfo.css';
|
||||
@@ -24,6 +25,7 @@ const buildMediaUrl = (url: string): string => {
|
||||
function InitialInfo() {
|
||||
const navigate = useNavigate();
|
||||
const { creatID } = useParams<{ creatID: string }>();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [promptText, setPromptText] = useState('');
|
||||
@@ -55,6 +57,9 @@ function InitialInfo() {
|
||||
durations: [5, 8, 10, 12, 15],
|
||||
});
|
||||
|
||||
const [creditCalculationData, setCreditCalculationData] = useState<any[]>([]);
|
||||
const [estimatedCredits, setEstimatedCredits] = useState<number>(0);
|
||||
|
||||
// 创作记录表格数据
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
// 分页状态
|
||||
@@ -222,27 +227,89 @@ function InitialInfo() {
|
||||
useEffect(() => {
|
||||
getEngine()
|
||||
.then((data: any) => {
|
||||
setEnginesele(data.engine || {});
|
||||
// 默认选中第一个视频引擎
|
||||
if (data.engine?.video && data.engine.video.length > 0) {
|
||||
setCountType(data.engine.video[0].id);
|
||||
// 设置默认引擎参数
|
||||
const firstEngine = data.engine.video[0];
|
||||
const filteredVideoEngines = (data.engine?.video || []).filter((engine: any) => engine.supportsUniversalReference !== false);
|
||||
const filteredImageEngines = (data.engine?.image || []).filter((engine: any) => engine.supportsUniversalReference !== false);
|
||||
|
||||
setEnginesele({
|
||||
video: filteredVideoEngines,
|
||||
image: filteredImageEngines,
|
||||
});
|
||||
|
||||
if (filteredVideoEngines.length > 0) {
|
||||
setCountType(filteredVideoEngines[0].id);
|
||||
const firstEngine = filteredVideoEngines[0];
|
||||
setEngineOptions({
|
||||
ratios: firstEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
resolutions: firstEngine.supportedResolutions || ['480p', '720p', '1080p'],
|
||||
durations: firstEngine.supportedDurations || [5, 8, 10, 12, 15],
|
||||
});
|
||||
}
|
||||
// 默认选中第一个图片引擎
|
||||
if (data.engine?.image && data.engine.image.length > 0) {
|
||||
setImageEngineId(data.engine.image[0].id);
|
||||
if (filteredImageEngines.length > 0) {
|
||||
setImageEngineId(filteredImageEngines[0].id);
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
calculateCredits().then((data: any) => {
|
||||
setCreditCalculationData(data);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const calculateEstimatedCredits = () => {
|
||||
let config: any = {};
|
||||
config = creditCalculationData.find((item: any) =>
|
||||
item.modelConfigId === countType &&
|
||||
item.genType === 'video' &&
|
||||
item.resolution === videoResolution
|
||||
);
|
||||
|
||||
if (!config) {
|
||||
config = {
|
||||
perSecondCredits: 2,
|
||||
baseCredits: 60,
|
||||
ratio: 1.3,
|
||||
inputVideoRatio: 1.3,
|
||||
inputVideoBaseCredits: 0,
|
||||
inputVideoPerSecondCredits: 15,
|
||||
inputImageRatio: 1,
|
||||
inputImageBaseCredits: 0,
|
||||
inputImagePerImageCredits: 0.0,
|
||||
};
|
||||
if (videoResolution === '1080p') {
|
||||
config.ratio = 1.3;
|
||||
} else if (videoResolution === '720p') {
|
||||
config.ratio = 1.3;
|
||||
} else if (videoResolution === '480p') {
|
||||
config.ratio = 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
let total = (videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio;
|
||||
|
||||
const inputVideoDuration = taskDetail?.videoGeneration?.inputMedia?.video?.duration || 0;
|
||||
if (inputVideoDuration > 0) {
|
||||
const inputVideoCost = ((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1);
|
||||
total += inputVideoCost;
|
||||
}
|
||||
|
||||
const inputImageCount = taskDetail?.videoGeneration?.inputMedia?.image?.length || 0;
|
||||
if (inputImageCount > 0) {
|
||||
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
|
||||
total += inputImageCost;
|
||||
}
|
||||
|
||||
setEstimatedCredits(Number(total.toFixed(2)));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (creditCalculationData.length > 0 && countType) {
|
||||
calculateEstimatedCredits();
|
||||
}
|
||||
}, [creditCalculationData, countType, videoDuration, videoResolution, taskDetail]);
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1190,14 +1257,36 @@ function InitialInfo() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ width: '100%', borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
|
||||
onClick={() => { handleNextStep(step.id); }}
|
||||
disabled={step.status !== 'completed'}
|
||||
<Tooltip
|
||||
title={((user?.credits || 0) < estimatedCredits) ? '积分不足,请更换参数/充值积分' : ''}
|
||||
placement="top"
|
||||
>
|
||||
下一步:生成视频提示词
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 10,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
height: 36,
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)'
|
||||
}}
|
||||
onClick={() => {
|
||||
if ((user?.credits || 0) < estimatedCredits) {
|
||||
message.warning('积分不足,请更换参数/充值积分');
|
||||
return;
|
||||
}
|
||||
handleNextStep(step.id);
|
||||
}}
|
||||
disabled={step.status !== 'completed' || ((user?.credits || 0) < estimatedCredits)}
|
||||
>
|
||||
下一步:生成视频提示词
|
||||
<span style={{ color: '#fff', marginLeft: 8 }}>
|
||||
预估积分:{estimatedCredits}
|
||||
</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{/* 步骤4: 生成视频提示词 */}
|
||||
|
||||
@@ -1,260 +1,139 @@
|
||||
/* ========================================
|
||||
登录页 — 视频全屏背景 + 居中卡片
|
||||
======================================== */
|
||||
|
||||
.login-page {
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1a1a2e;
|
||||
background-image: url(/backimage.png);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.login-page {
|
||||
flex-direction: row;
|
||||
}
|
||||
/* ---- 视频全屏背景 ---- */
|
||||
.login-bg-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-bg-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(240,244,255,0.9) 0%, rgba(232,236,248,0.85) 40%, rgba(240,240,255,0.9) 70%, rgba(248,249,255,0.95) 100%);
|
||||
background: linear-gradient(135deg, rgba(15,15,30,0.75) 0%, rgba(20,20,50,0.65) 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-decoration {
|
||||
/* ---- 左上角 slogan ---- */
|
||||
.login-slogan {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(40px);
|
||||
z-index: 0;
|
||||
top: 40px;
|
||||
left: 48px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.login-decoration-1 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%);
|
||||
top: -150px;
|
||||
right: -100px;
|
||||
.login-slogan-text {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
background: linear-gradient(90deg, #fff 0%, #c7d2fe 25%, #fff 50%, #c7d2fe 75%, #fff 100%);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shinySlide 3s linear infinite;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.login-decoration-2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%);
|
||||
bottom: -100px;
|
||||
left: -80px;
|
||||
filter: blur(50px);
|
||||
@keyframes shinySlide {
|
||||
0% { background-position: 0% center; }
|
||||
100% { background-position: 200% center; }
|
||||
}
|
||||
|
||||
.login-left-section {
|
||||
/* ---- 登录卡片靠右 ---- */
|
||||
.login-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
z-index: 1;
|
||||
padding: 24px 60px 24px 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.login-left-section {
|
||||
padding: 0 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-left-content {
|
||||
/* ========================================
|
||||
登录卡片
|
||||
======================================== */
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 20px !important;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.25), 0 8px 32px rgba(99,102,241,0.1) !important;
|
||||
border: 1px solid rgba(255,255,255,0.15) !important;
|
||||
background: rgba(255,255,255,0.95) !important;
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
}
|
||||
|
||||
/* 卡片头部: logo + 站点名 */
|
||||
.login-card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-logo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-logo-row {
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-logo-img {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
objectFit: contain;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.login-logo-placeholder {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 24px rgba(99,102,241,0.25);
|
||||
box-shadow: 0 6px 20px rgba(99,102,241,0.3);
|
||||
}
|
||||
|
||||
.login-site-name {
|
||||
color: #1e293b;
|
||||
font-size: 28px;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-site-name {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-desc {
|
||||
color: #64748b !important;
|
||||
font-size: 17px !important;
|
||||
max-width: 480px;
|
||||
line-height: 1.8 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.login-desc {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.login-features-list {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.login-features-list {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.login-feature-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
padding: 18px 22px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-feature-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6366f1;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.login-feature-title {
|
||||
color: #1e293b !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
display: block !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
.login-feature-desc {
|
||||
color: #64748b !important;
|
||||
font-size: 13px !important;
|
||||
line-height: 1.6 !important;
|
||||
}
|
||||
|
||||
.login-right-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
padding: 16px 16px 60px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-right-section-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.login-copyright-wrapper {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-copyright {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.login-right-section {
|
||||
padding: 40px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
border-radius: 20px !important;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.08) !important;
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
.login-card .ant-card-body {
|
||||
padding: 24px 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.login-card-title {
|
||||
text-align: center !important;
|
||||
margin-bottom: 6px !important;
|
||||
color: #1e293b !important;
|
||||
font-weight: 700 !important;
|
||||
color: #1e293b;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.login-card-subtitle {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
margin-bottom: 24px;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- Tabs ---- */
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 20px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
@@ -264,10 +143,10 @@
|
||||
.login-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
padding: 9px 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #64748b;
|
||||
background: transparent;
|
||||
@@ -284,9 +163,9 @@
|
||||
}
|
||||
|
||||
.login-submit-btn {
|
||||
height: 48px !important;
|
||||
height: 46px !important;
|
||||
border-radius: 10px !important;
|
||||
font-size: 16px !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
|
||||
border: none !important;
|
||||
@@ -294,19 +173,19 @@
|
||||
}
|
||||
|
||||
.login-code-btn {
|
||||
height: 48px !important;
|
||||
border-radius: 10 !important;
|
||||
height: 46px !important;
|
||||
border-radius: 10px !important;
|
||||
border: 1.5px solid #e2e8f0 !important;
|
||||
font-weight: 600 !important;
|
||||
min-width: 100px !important;
|
||||
}
|
||||
|
||||
.login-agreement {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.login-agreement-text {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@@ -320,21 +199,33 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.login-footer {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.login-switch-btn {
|
||||
font-size: 13px !important;
|
||||
color: #6366f1 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---- Copyright ---- */
|
||||
.login-copyright-wrapper {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-copyright {
|
||||
color: rgba(255,255,255,0.5);
|
||||
font-size: 12px;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* ---- Ant Design overrides ---- */
|
||||
.login-page .ant-input-affix-wrapper {
|
||||
padding: 0 11px !important;
|
||||
height: 48px !important;
|
||||
height: 46px !important;
|
||||
}
|
||||
|
||||
.login-page .ant-input-affix-wrapper .ant-input-prefix {
|
||||
@@ -344,61 +235,28 @@
|
||||
|
||||
.login-page .ant-input {
|
||||
padding-left: 11px !important;
|
||||
height: 48px !important;
|
||||
height: 46px !important;
|
||||
}
|
||||
|
||||
.login-code-btn {
|
||||
height: 48px !important;
|
||||
}
|
||||
/* ========================================
|
||||
响应式
|
||||
======================================== */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.login-left-section {
|
||||
padding: 28px 16px 12px;
|
||||
}
|
||||
|
||||
.login-right-section {
|
||||
padding: 12px 16px 32px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.login-slogan { top: 24px; left: 24px; }
|
||||
.login-slogan-text { font-size: 18px; }
|
||||
.login-center { padding: 80px 20px 24px; justify-content: center; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-left-section {
|
||||
padding: 24px 12px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-right-section {
|
||||
padding: 8px 12px 24px;
|
||||
}
|
||||
|
||||
.shiny-text-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-slogan { top: 20px; left: 20px; }
|
||||
.login-slogan-text { font-size: 15px; }
|
||||
.login-card .ant-card-body { padding: 24px 20px !important; }
|
||||
.login-card { border-radius: 16px !important; }
|
||||
.login-page .ant-input,
|
||||
.login-page .ant-input-affix-wrapper {
|
||||
height: 44px !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.login-page .ant-input-affix-wrapper .ant-input {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.login-code-btn {
|
||||
height: 44px !important;
|
||||
min-width: 90px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.login-submit-btn {
|
||||
height: 44px !important;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
.login-page .ant-input-affix-wrapper { height: 44px !important; }
|
||||
.login-code-btn { height: 44px !important; min-width: 90px !important; }
|
||||
.login-submit-btn { height: 44px !important; }
|
||||
}
|
||||
|
||||
@keyframes sliderShake {
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Input, Card, Typography, message, Space, Checkbox } from 'antd';
|
||||
import {
|
||||
LockOutlined, ThunderboltOutlined,
|
||||
PlayCircleOutlined, BulbOutlined, HistoryOutlined,
|
||||
MobileOutlined, SafetyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
@@ -48,9 +47,10 @@ const LoginPage: React.FC = () => {
|
||||
const initialInfo = getInitialSiteInfo();
|
||||
const [siteName, setSiteName] = useState(initialInfo.siteName);
|
||||
const [siteLogo, setSiteLogo] = useState(initialInfo.siteLogo);
|
||||
const [loginBgVideo, setLoginBgVideo] = useState('');
|
||||
const [agreementPrivacyUrl, setAgreementPrivacyUrl] = useState('');
|
||||
const [siteCopyright, setSiteCopyright] = useState('');
|
||||
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuthStore();
|
||||
const [pwdForm] = Form.useForm();
|
||||
@@ -61,6 +61,7 @@ const LoginPage: React.FC = () => {
|
||||
getSiteInfo().then(info => {
|
||||
setSiteName(info.siteName);
|
||||
setSiteLogo(info.siteLogo);
|
||||
setLoginBgVideo(info.loginBgVideo || '');
|
||||
setAgreementPrivacyUrl(info.userAgreementPrivacyUrl);
|
||||
setSiteCopyright(info.siteCopyright);
|
||||
}).catch(() => {});
|
||||
@@ -265,12 +266,6 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const features = [
|
||||
{ icon: <BulbOutlined />, title: 'AI 智能优化', desc: '输入原始提示词,AI 自动为您生成专业级视频、图片描述' },
|
||||
{ icon: <PlayCircleOutlined />, title: '一键生成视频、图片', desc: '支持多种规格生成视频、图片' },
|
||||
{ icon: <HistoryOutlined />, title: '项目维度管理', desc: '按项目行业分类视频、图片,支持多种行业' },
|
||||
];
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: '#fff',
|
||||
border: '1.5px solid #e2e8f0',
|
||||
@@ -293,71 +288,50 @@ const LoginPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
{/* 背景视频/动图全屏铺满 */}
|
||||
{loginBgVideo && (loginBgVideo.toLowerCase().endsWith('.gif') || loginBgVideo.toLowerCase().endsWith('.webp')) ? (
|
||||
<img className="login-bg-video" src={loginBgVideo} alt="" />
|
||||
) : loginBgVideo ? (
|
||||
<video className="login-bg-video" autoPlay loop muted playsInline preload="auto">
|
||||
<source src={loginBgVideo} type={loginBgVideo.endsWith('.webm') ? 'video/webm' : loginBgVideo.endsWith('.mov') ? 'video/quicktime' : 'video/mp4'} />
|
||||
</video>
|
||||
) : null}
|
||||
<div className="login-bg-overlay" />
|
||||
<div className="login-decoration login-decoration-1" />
|
||||
<div className="login-decoration login-decoration-2" />
|
||||
|
||||
<div className="login-left-section">
|
||||
<Space direction="vertical" size={36} className="login-left-content">
|
||||
<div>
|
||||
{/* 左上角 slogan */}
|
||||
<div className="login-slogan">
|
||||
<span className="login-slogan-text">AI赋能创意,素材触手可及</span>
|
||||
</div>
|
||||
|
||||
{/* 居中登录卡片 */}
|
||||
<div className="login-center">
|
||||
<Card className="login-card" styles={{ body: { padding: '32px 32px' } }}>
|
||||
<div className="login-card-header">
|
||||
<div className="login-logo-row">
|
||||
{siteLogo ? (
|
||||
<img src={siteLogo} alt="logo" className="login-logo-img" />
|
||||
) : (
|
||||
<div className="login-logo-placeholder">
|
||||
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} />
|
||||
<ThunderboltOutlined style={{ fontSize: 20, color: '#fff' }} />
|
||||
</div>
|
||||
)}
|
||||
<span className="login-site-name">{siteName}</span>
|
||||
</div>
|
||||
<div className="shiny-text-container">
|
||||
<span className="shiny-text">AI赋能创意,素材触手可及</span>
|
||||
</div>
|
||||
<Typography.Text className="login-card-subtitle">
|
||||
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作'}
|
||||
</Typography.Text>
|
||||
|
||||
{mode !== 'register' && (
|
||||
<div className="login-tabs">
|
||||
{(['password', 'phone'] as const).map((t) => (
|
||||
<div key={t} onClick={() => switchTab(t)}
|
||||
className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}>
|
||||
{t === 'password' ? '密码登录' : '验证码登录'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Typography.Paragraph className="login-desc">
|
||||
专业的 AI 素材生成平台,通过智能提示词优化,<br />让您的创意快速转化为精美视频、图片
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div className="login-features-list">
|
||||
{features.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="electric-border-card login-feature-card"
|
||||
>
|
||||
<div className="electric-border" />
|
||||
<div className="electric-border-inner" />
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<div className="login-feature-icon">{f.icon}</div>
|
||||
</div>
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<Typography.Text className="login-feature-title">{f.title}</Typography.Text>
|
||||
<Typography.Text className="login-feature-desc">{f.desc}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="login-right-section">
|
||||
<div className="login-right-section-inner">
|
||||
<Card className="login-card" styles={{ body: { padding: '36px 28px' } }}>
|
||||
<Typography.Title level={3} className="login-card-title">
|
||||
{mode === 'register' ? '创建账号' : '欢迎回来'}
|
||||
</Typography.Title>
|
||||
<Typography.Text className="login-card-subtitle">
|
||||
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'}
|
||||
</Typography.Text>
|
||||
|
||||
{mode !== 'register' && (
|
||||
<div className="login-tabs">
|
||||
{(['password', 'phone'] as const).map((t) => (
|
||||
<div key={t} onClick={() => switchTab(t)}
|
||||
className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}>
|
||||
{t === 'password' ? '密码登录' : '验证码登录'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{mode === 'password' && (
|
||||
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
|
||||
@@ -485,49 +459,49 @@ const LoginPage: React.FC = () => {
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<div className="login-footer">
|
||||
{mode === 'register' ? (
|
||||
<Typography.Text
|
||||
className="login-switch-btn"
|
||||
onClick={() => {
|
||||
setMode('password');
|
||||
setTab('password');
|
||||
setShowResend(false);
|
||||
setLoginShowResend(false);
|
||||
setSliderVerified(false);
|
||||
setShowSliderVerify(false);
|
||||
setSliderKey(prev => prev + 1);
|
||||
regForm.resetFields();
|
||||
}}
|
||||
>
|
||||
已有账号?去登录
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text
|
||||
className="login-switch-btn"
|
||||
onClick={() => {
|
||||
setMode('register');
|
||||
setShowResend(false);
|
||||
setLoginShowResend(false);
|
||||
setSliderVerified(false);
|
||||
setShowSliderVerify(false);
|
||||
setSliderKey(prev => prev + 1);
|
||||
}}
|
||||
>
|
||||
没有账号?立即注册
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{siteCopyright && (
|
||||
<div className="login-copyright-wrapper">
|
||||
<div className="login-copyright">
|
||||
{siteCopyright}
|
||||
</div>
|
||||
<div className="login-footer">
|
||||
{mode === 'register' ? (
|
||||
<Typography.Text
|
||||
className="login-switch-btn"
|
||||
onClick={() => {
|
||||
setMode('password');
|
||||
setTab('password');
|
||||
setShowResend(false);
|
||||
setLoginShowResend(false);
|
||||
setSliderVerified(false);
|
||||
setShowSliderVerify(false);
|
||||
setSliderKey(prev => prev + 1);
|
||||
regForm.resetFields();
|
||||
}}
|
||||
>
|
||||
已有账号?去登录
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text
|
||||
className="login-switch-btn"
|
||||
onClick={() => {
|
||||
setMode('register');
|
||||
setShowResend(false);
|
||||
setLoginShowResend(false);
|
||||
setSliderVerified(false);
|
||||
setShowSliderVerify(false);
|
||||
setSliderKey(prev => prev + 1);
|
||||
}}
|
||||
>
|
||||
没有账号?立即注册
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{siteCopyright && (
|
||||
<div className="login-copyright-wrapper">
|
||||
<div className="login-copyright">
|
||||
{siteCopyright}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button, Typography, Collapse, Space, Modal, Input, Table, message } from 'antd';
|
||||
import { Button, Typography, Collapse, Space, Modal, Input, Table, message, Tooltip } from 'antd';
|
||||
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { getShotReplicationList, removeDetail, removeone, removetwo, removethree, removefour, getEngine, updateShotImagePrompt, updateShotVideoPromptSchema } from '../api/index';
|
||||
import { getShotReplicationList, removeDetail, removeone, removetwo, removethree, removefour, getEngine, updateShotImagePrompt, updateShotVideoPromptSchema, calculateCredits } from '../api/index';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import VideoPromptSchemaEditor from '../components/VideoPromptSchemaEditor';
|
||||
import { validateVideoPromptSchemaByConfig } from '../utils/videoPromptSchema';
|
||||
import './css/InitialInfo.css';
|
||||
@@ -24,8 +25,11 @@ const buildMediaUrl = (url: string): string => {
|
||||
function InitialInfo() {
|
||||
const navigate = useNavigate();
|
||||
const { creatID } = useParams<{ creatID: string }>();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [creditCalculationData, setCreditCalculationData] = useState<any[]>([]);
|
||||
const [estimatedCredits, setEstimatedCredits] = useState<number>(0);
|
||||
const [promptText, setPromptText] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [currentType, setCurrentType] = useState<string>('image');
|
||||
@@ -128,7 +132,7 @@ function InitialInfo() {
|
||||
// 当apiSteps更新时,逆向遍历找到第一个已完成或失败的步骤并展开
|
||||
useEffect(() => {
|
||||
const completedFailedKeys = new Set<string>();
|
||||
|
||||
|
||||
activeKey.forEach(key => {
|
||||
const step = steps.find(s => String(s.childId) === key);
|
||||
if (step && (step.status === 'completed' || step.status === 'failed')) {
|
||||
@@ -190,12 +194,17 @@ function InitialInfo() {
|
||||
useEffect(() => {
|
||||
getEngine()
|
||||
.then((data: any) => {
|
||||
setEnginesele(data.engine || {});
|
||||
const filteredVideoEngines = (data.engine?.video || []).filter((engine: any) => engine.supportsUniversalReference !== false);
|
||||
const filteredImageEngines = (data.engine?.image || []).filter((engine: any) => engine.supportsUniversalReference !== false);
|
||||
setEnginesele({
|
||||
video: filteredVideoEngines,
|
||||
image: filteredImageEngines,
|
||||
});
|
||||
// 默认选中第一个视频引擎
|
||||
if (data.engine?.video && data.engine.video.length > 0) {
|
||||
setCountType(data.engine.video[0].id);
|
||||
if (filteredVideoEngines.length > 0) {
|
||||
setCountType(filteredVideoEngines[0].id);
|
||||
// 设置默认引擎参数
|
||||
const firstEngine = data.engine.video[0];
|
||||
const firstEngine = filteredVideoEngines[0];
|
||||
setEngineOptions({
|
||||
ratios: firstEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
resolutions: firstEngine.supportedResolutions || ['480p', '720p', '1080p'],
|
||||
@@ -203,8 +212,8 @@ function InitialInfo() {
|
||||
});
|
||||
}
|
||||
// 默认选中第一个图片引擎
|
||||
if (data.engine?.image && data.engine.image.length > 0) {
|
||||
setImageEngineId(data.engine.image[0].id);
|
||||
if (filteredImageEngines.length > 0) {
|
||||
setImageEngineId(filteredImageEngines[0].id);
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
@@ -275,6 +284,57 @@ function InitialInfo() {
|
||||
}
|
||||
}, [steps]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateCredits().then((data: any) => {
|
||||
setCreditCalculationData(data);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const calculateEstimatedCredits = () => {
|
||||
let config: any = {};
|
||||
config = creditCalculationData.find((item: any) =>
|
||||
item.modelConfigId === countType &&
|
||||
item.genType === 'video' &&
|
||||
item.resolution === videoResolution
|
||||
);
|
||||
|
||||
if (!config) {
|
||||
config = {
|
||||
perSecondCredits: 2,
|
||||
baseCredits: 60,
|
||||
ratio: 1.3,
|
||||
inputVideoRatio: 1.3,
|
||||
inputVideoBaseCredits: 0,
|
||||
inputVideoPerSecondCredits: 15,
|
||||
inputImageRatio: 1,
|
||||
inputImageBaseCredits: 0,
|
||||
inputImagePerImageCredits: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
let total = (videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio;
|
||||
|
||||
const inputVideoDuration = taskDetail?.videoGeneration?.inputMedia?.video?.duration || 0;
|
||||
if (inputVideoDuration > 0) {
|
||||
const inputVideoCost = ((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1);
|
||||
total += inputVideoCost;
|
||||
}
|
||||
|
||||
const inputImageCount = taskDetail?.videoGeneration?.inputMedia?.image?.length || 0;
|
||||
if (inputImageCount > 0) {
|
||||
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
|
||||
total += inputImageCost;
|
||||
}
|
||||
|
||||
setEstimatedCredits(Number(total.toFixed(2)));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (creditCalculationData.length > 0 && countType) {
|
||||
calculateEstimatedCredits();
|
||||
}
|
||||
}, [creditCalculationData, countType, videoDuration, videoResolution, taskDetail]);
|
||||
|
||||
const handleOpenModal = (prompt?: any, type?: string, stepId?: string | number, schemaConfigSnapshot?: any) => {
|
||||
setCurrentType(type || 'image');
|
||||
setEditingPromptStepId(stepId ? String(stepId) : '');
|
||||
@@ -1188,14 +1248,36 @@ function InitialInfo() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ width: '100%', borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
|
||||
onClick={() => { handleNextStep(step.id); }}
|
||||
disabled={step.status !== 'completed'}
|
||||
<Tooltip
|
||||
title={((user?.credits || 0) < estimatedCredits) ? '积分不足,请更换参数/充值积分' : ''}
|
||||
placement="top"
|
||||
>
|
||||
下一步:生成视频提示词
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 10,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
height: 36,
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)'
|
||||
}}
|
||||
onClick={() => {
|
||||
if ((user?.credits || 0) < estimatedCredits) {
|
||||
message.warning('积分不足,请更换参数/充值积分');
|
||||
return;
|
||||
}
|
||||
handleNextStep(step.id);
|
||||
}}
|
||||
disabled={step.status !== 'completed' || ((user?.credits || 0) < estimatedCredits)}
|
||||
>
|
||||
下一步:生成视频提示词
|
||||
<span style={{ color: '#fff', marginLeft: 8 }}>
|
||||
预估积分:{estimatedCredits}
|
||||
</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{/* 步骤4: 生成视频提示词 */}
|
||||
|
||||
@@ -140,6 +140,7 @@ export interface MediaReference {
|
||||
displayUrl?: string;
|
||||
previewUrl?: string;
|
||||
upload_resource_id?: string;
|
||||
fileSizeBytes?: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user