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>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<title>后台管理</title>
|
<title>后台管理</title>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var cached = localStorage.getItem('siteInfo');
|
var cached = localStorage.getItem('siteInfo');
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
var info = JSON.parse(cached);
|
var info = JSON.parse(cached);
|
||||||
if (info.siteName) {
|
if (info.siteName) {
|
||||||
document.title = info.siteName + ' - 管理后台';
|
document.title = info.siteName + ' - 管理后台';
|
||||||
}
|
}
|
||||||
if (info.siteLogo) {
|
if (info.siteLogo) {
|
||||||
var link = document.querySelector('link[rel="icon"]');
|
var link = document.querySelector('link[rel="icon"]');
|
||||||
if (link) {
|
if (link) {
|
||||||
link.href = info.siteLogo;
|
link.href = info.siteLogo;
|
||||||
link.type = 'image/png';
|
link.type = 'image/png';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-CKURqRU_.js"></script>
|
<script type="module" crossorigin src="/assets/index-DSi2SJq0.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>␍
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -237,6 +237,10 @@ export async function updateSystemConfig(id: string, value: string): Promise<voi
|
|||||||
await api.put(`/admin/system-configs/${id}`, { value });
|
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> {
|
export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
|
||||||
return api.get('/admin/resource-capacity/global');
|
return api.get('/admin/resource-capacity/global');
|
||||||
}
|
}
|
||||||
@@ -289,6 +293,22 @@ export async function uploadLogo(file: File): Promise<{ url: string }> {
|
|||||||
return { url: res.url };
|
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 {
|
function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
|
||||||
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value));
|
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
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';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined,
|
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
|
createSystemConfig,
|
||||||
getGlobalResourceCapacity,
|
getGlobalResourceCapacity,
|
||||||
getSystemConfigs,
|
getSystemConfigs,
|
||||||
saveGlobalResourceCapacity,
|
saveGlobalResourceCapacity,
|
||||||
updateSystemConfig,
|
updateSystemConfig,
|
||||||
uploadLogo,
|
uploadLogo,
|
||||||
uploadPdf,
|
uploadPdf,
|
||||||
|
uploadLoginVideo,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import type { ResourceCapacityUnit, SystemConfig } from '../types';
|
import type { ResourceCapacityUnit, SystemConfig } from '../types';
|
||||||
|
|
||||||
@@ -39,6 +41,10 @@ const AdminSettings: React.FC = () => {
|
|||||||
getSystemConfigs(),
|
getSystemConfigs(),
|
||||||
getGlobalResourceCapacity(),
|
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);
|
setConfigs(data);
|
||||||
const formValues: Record<string, any> = {};
|
const formValues: Record<string, any> = {};
|
||||||
data.forEach(c => { formValues[c.key] = c.value; });
|
data.forEach(c => { formValues[c.key] = c.value; });
|
||||||
@@ -118,6 +124,55 @@ const AdminSettings: React.FC = () => {
|
|||||||
return false;
|
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[]> = {
|
const groupedConfigs: Record<string, SystemConfig[]> = {
|
||||||
'站点信息': configs.filter(c => c.key.startsWith('site_')),
|
'站点信息': configs.filter(c => c.key.startsWith('site_')),
|
||||||
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
|
'协议配置': 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 }} />;
|
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 (
|
return (
|
||||||
<div style={{ maxWidth: 720 }}>
|
<div style={{ maxWidth: 720 }}>
|
||||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||||
@@ -292,78 +511,11 @@ const AdminSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>系统设置</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>系统设置</Typography.Title>
|
||||||
<Typography.Text type="secondary">管理站点基础信息、协议文件和SEO配置</Typography.Text>
|
<Typography.Text type="secondary">管理站点基础信息、用户积分和系统配置</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form form={form} layout="vertical">
|
<Tabs items={tabItems} defaultActiveKey="basic" />
|
||||||
{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>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from app.schemas.admin import (
|
|||||||
CreditAdjustRequest,
|
CreditAdjustRequest,
|
||||||
ModelConfigCreate,
|
ModelConfigCreate,
|
||||||
ModelConfigOut,
|
ModelConfigOut,
|
||||||
|
SystemConfigCreate,
|
||||||
SystemConfigUpdate,
|
SystemConfigUpdate,
|
||||||
SystemConfigOut,
|
SystemConfigOut,
|
||||||
AdminUserOut,
|
AdminUserOut,
|
||||||
@@ -1594,6 +1595,34 @@ async def list_system_configs(
|
|||||||
return result.scalars().all()
|
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)
|
@router.put("/system-configs/{config_id}", response_model=SystemConfigOut)
|
||||||
async def update_system_config(
|
async def update_system_config(
|
||||||
config_id: str,
|
config_id: str,
|
||||||
@@ -2310,6 +2339,58 @@ async def upload_logo(
|
|||||||
return {"url": url}
|
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 ────────────────────────────────────────
|
# ── 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."""
|
"""Public endpoint returning site name, logo, agreement and copyright info."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(SystemConfig).where(SystemConfig.key.in_([
|
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()
|
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")),
|
"user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")),
|
||||||
"site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"),
|
"site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"),
|
||||||
"operation_manual": info.get("operation_manual", ""),
|
"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_API_KEY: str = ""
|
||||||
LLM_MODEL: str = "gpt-4o"
|
LLM_MODEL: str = "gpt-4o"
|
||||||
LLM_MOCK: bool = True
|
LLM_MOCK: bool = True
|
||||||
|
LLM_MEDIA_AS_BASE64: bool = True
|
||||||
|
|
||||||
ENCRYPTION_KEY: str = "changeme-32bytes-base64-key-here!!"
|
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,
|
# Seed credit ratios - model_config_id is kept as a compatible field name,
|
||||||
# but now stores the actual engine id:
|
# but now stores the actual engine id:
|
||||||
# - gen_type=video -> video_engines.id
|
# - gen_type=video -> video_engines.id
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ class ModelConfigOut(ModelConfigCreate):
|
|||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfigCreate(BaseModel):
|
||||||
|
key: str
|
||||||
|
value: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SystemConfigUpdate(BaseModel):
|
class SystemConfigUpdate(BaseModel):
|
||||||
value: str | int | float
|
value: str | int | float
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ def _load_refs(record: ChatGenerationTask) -> list[dict]:
|
|||||||
return []
|
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":
|
if record.gen_type == "image":
|
||||||
params = f"图片参数:分辨率档位={record.image_size or '2K'},比例={record.image_proportion or '1:1'},像素={record.image_px or '2048x2048'}"
|
params = f"图片参数:分辨率档位={record.image_size or '2K'},比例={record.image_proportion or '1:1'},像素={record.image_px or '2048x2048'}"
|
||||||
else:
|
else:
|
||||||
@@ -54,7 +56,15 @@ def _build_user_content(record: ChatGenerationTask) -> list[dict[str, Any]]:
|
|||||||
ref_url = ref.get("url") or ""
|
ref_url = ref.get("url") or ""
|
||||||
if not ref_url:
|
if not ref_url:
|
||||||
continue
|
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":
|
if ref_type == "image":
|
||||||
parts.append({"type": "image_url", "image_url": {"url": url}})
|
parts.append({"type": "image_url", "image_url": {"url": url}})
|
||||||
elif ref_type == "video":
|
elif ref_type == "video":
|
||||||
@@ -94,7 +104,7 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
|
|||||||
"model": config.model_name,
|
"model": config.model_name,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": system_prompt},
|
{"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,
|
"max_tokens": config.max_tokens,
|
||||||
"temperature": config.temperature,
|
"temperature": config.temperature,
|
||||||
|
|||||||
@@ -1536,9 +1536,16 @@ async def optimize_hot_opening_video_prompt(
|
|||||||
trace_id: str | None = None,
|
trace_id: str | None = None,
|
||||||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||||||
duration = int(video_config["duration"])
|
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 = [
|
references = [
|
||||||
{"type": "video", "url": _build_file_url_or_data_uri(material_video_url)},
|
{"type": "video", "url": video_url_final},
|
||||||
{"type": "image", "url": _build_file_url_or_data_uri(generated_image_url)},
|
{"type": "image", "url": image_url_final},
|
||||||
]
|
]
|
||||||
client_schema = build_dynamic_schema(video_config, schema_config_snapshot)
|
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)
|
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":
|
if ref_type == "video":
|
||||||
video_urls.append(ref_url)
|
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.
|
Convert local upload path to base64 data URI.
|
||||||
Keep remote http/https/data URLs as-is.
|
Keep remote http/https/data URLs as-is.
|
||||||
@@ -268,54 +324,9 @@ async def _call_openai_compatible(
|
|||||||
return f"data:{mime};base64,{b64}"
|
return f"data:{mime};base64,{b64}"
|
||||||
|
|
||||||
if image_urls or video_urls:
|
if image_urls or video_urls:
|
||||||
content_parts = [{"type": "text", "text": user_content}]
|
user_message, log_user_message = await _build_multimodal_content(
|
||||||
|
user_content, image_urls, video_urls
|
||||||
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,
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
user_message = {
|
user_message = {
|
||||||
"role": "user",
|
"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}"
|
# return f"data:{mime};base64,{b64}"
|
||||||
|
|
||||||
|
|
||||||
def build_user_message(user_text: str, video_url: str) -> tuple[dict[str, Any], dict[str, Any], str]:
|
async def build_user_message(user_text: str, video_url: str, db=None) -> tuple[dict[str, Any], dict[str, Any], str]:
|
||||||
real_url = build_file_url_or_data_uri(video_url)
|
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 = [
|
content_parts = [
|
||||||
{
|
{
|
||||||
"type": "video_url",
|
"type": "video_url",
|
||||||
@@ -543,7 +547,7 @@ async def analyze_video_for_shot_split(
|
|||||||
|
|
||||||
system_prompt = build_video_analysis_system_prompt(mode=mode)
|
system_prompt = build_video_analysis_system_prompt(mode=mode)
|
||||||
user_text = build_video_analysis_user_text(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] = {
|
request_data: dict[str, Any] = {
|
||||||
"model": config.model_name,
|
"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>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<title>民众智创</title>
|
<title>民众智创</title>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var cached = localStorage.getItem('siteInfo');
|
var cached = localStorage.getItem('siteInfo');
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
var info = JSON.parse(cached);
|
var info = JSON.parse(cached);
|
||||||
if (info.siteName) {
|
if (info.siteName) {
|
||||||
document.title = info.siteName;
|
document.title = info.siteName;
|
||||||
}
|
}
|
||||||
if (info.siteLogo) {
|
if (info.siteLogo) {
|
||||||
var link = document.querySelector('link[rel="icon"]');
|
var link = document.querySelector('link[rel="icon"]');
|
||||||
if (link) {
|
if (link) {
|
||||||
link.href = info.siteLogo;
|
link.href = info.siteLogo;
|
||||||
link.type = 'image/png';
|
link.type = 'image/png';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-DN27BTjS.js"></script>
|
<script type="module" crossorigin src="/assets/index-DOSsz052.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DviWdElm.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CKeRPhR_.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>␍
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -295,8 +295,8 @@ export async function verifyCaptcha(captchaId: string, x: number): Promise<strin
|
|||||||
return res.token;
|
return res.token;
|
||||||
}
|
}
|
||||||
// ── Site Info ─────────────────────────────────────────────
|
// ── Site Info ─────────────────────────────────────────────
|
||||||
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string }> {
|
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: '' };
|
if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2024 民众智创 版权所有', operationManual: '', loginBgVideo: '' };
|
||||||
return api.get('/auth/site-info', false);
|
return api.get('/auth/site-info', false);
|
||||||
}
|
}
|
||||||
// ── Video Engines ─────────────────────────────────────────
|
// ── Video Engines ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
HeartOutlined,
|
HeartOutlined,
|
||||||
CameraOutlined,
|
CameraOutlined,
|
||||||
CalculatorOutlined,
|
CalculatorOutlined,
|
||||||
|
CaretDownOutlined,
|
||||||
DollarOutlined,
|
DollarOutlined,
|
||||||
FireOutlined,
|
FireOutlined,
|
||||||
CloudOutlined,
|
CloudOutlined,
|
||||||
@@ -126,6 +127,7 @@ const StorageCard: React.FC<{ data: ResourceCapacityData | null }> = ({ data })
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
// marginTop: 12,
|
// marginTop: 12,
|
||||||
|
marginBottom: 12,
|
||||||
padding: '0 12px',
|
padding: '0 12px',
|
||||||
paddingTop: 6,
|
paddingTop: 6,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
@@ -906,33 +908,59 @@ const AppLayout: React.FC = () => {
|
|||||||
|
|
||||||
<div style={{ padding: '16px 16px', flexShrink: 0, paddingTop: 0 }}>
|
<div style={{ padding: '16px 16px', flexShrink: 0, paddingTop: 0 }}>
|
||||||
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
|
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
|
||||||
<div style={{
|
<div className="user-card-container" style={{
|
||||||
|
padding: '10px 14px',
|
||||||
display: 'flex', alignItems: 'center',
|
display: 'flex', alignItems: 'center',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
// padding: '10px 14px',
|
|
||||||
borderRadius: 14, cursor: 'pointer',
|
borderRadius: 14, cursor: 'pointer',
|
||||||
transition: 'all 0.25s ease',
|
transition: 'all 0.35s cubic-bezier(0.4,0,0.2,1)',
|
||||||
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)',
|
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)',
|
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) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)';
|
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) => {
|
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.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)';
|
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 />}
|
<Avatar size={36} icon={<UserOutlined />}
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
|
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 }}>
|
<div style={{ color: '#1e293b', fontSize: 16, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: -0.01 }}>
|
||||||
{user?.username}
|
{user?.username}
|
||||||
</div>
|
</div>
|
||||||
@@ -941,12 +969,74 @@ const AppLayout: React.FC = () => {
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
// background: 'rgba(99, 102, 241, 0.08)',
|
|
||||||
// padding: '2px 8px',
|
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
|
animation: 'creditGlow 1.5s ease-in-out infinite',
|
||||||
}}>积分: {user?.credits || 0}</div>
|
}}>积分: {user?.credits || 0}</div>
|
||||||
</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>
|
</div>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ interface Props {
|
|||||||
const spanByCount = (count: number, index: number): number => {
|
const spanByCount = (count: number, index: number): number => {
|
||||||
if (count <= 1) return 6;
|
if (count <= 1) return 6;
|
||||||
if (count === 2 || count === 4) return 3;
|
if (count === 2 || count === 4) return 3;
|
||||||
if (count === 3) return index < 2 ? 3 : 6;
|
if (count === 3) return 3;
|
||||||
return index < 3 ? 2 : 3;
|
return 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusText = (item: GenerationTaskResourceItem): string => {
|
const statusText = (item: GenerationTaskResourceItem): string => {
|
||||||
@@ -65,12 +65,12 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
|
|||||||
})) : [task]);
|
})) : [task]);
|
||||||
|
|
||||||
return (
|
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) => {
|
{items.slice(0, 5).map((item, index) => {
|
||||||
const displayStatus = item.displayStatus || item.pipelineStage || item.status || 'generating';
|
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 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 isVideo = (item.genType || task.genType) === 'video';
|
||||||
const hasResource = isVideo ? !!videoUrl : !!imageUrl;
|
const hasResource = isVideo ? !!videoUrl : !!imageUrl;
|
||||||
return (
|
return (
|
||||||
@@ -83,11 +83,11 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
borderRadius: items.length === 1 ? 12 : 8,
|
borderRadius: items.length === 1 ? 12 : 8,
|
||||||
background: 'linear-gradient(135deg, #ffffff 0%, #FAFBFC 100%)',
|
background: '#ffffff',
|
||||||
border: items.length === 1 ? 'none' : '1px solid #E7EAF0',
|
border: '1px solid #E7EAF0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasResource && displayStatus !== 'deleted' ? (
|
{hasResource && displayStatus !== 'deleted' && displayStatus !== 'failed' && displayStatus !== 'download_failed' && !isPending(item) ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPreview(isVideo ? videoUrl : imageUrl, isVideo ? 'video' : 'image')}
|
onClick={() => onPreview(isVideo ? videoUrl : imageUrl, isVideo ? 'video' : 'image')}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
PauseOutlined,
|
PauseOutlined,
|
||||||
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
import { div } from 'three/tsl';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
// 解构Input组件
|
// 解构Input组件
|
||||||
@@ -92,6 +93,7 @@ interface MediaReference {
|
|||||||
private_asset_id?: string;
|
private_asset_id?: string;
|
||||||
remote_asset_id?: string;
|
remote_asset_id?: string;
|
||||||
upload_resource_id?: string;
|
upload_resource_id?: string;
|
||||||
|
fileSizeBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1345,8 +1347,9 @@ const AIChatPage: React.FC = () => {
|
|||||||
setLastFrame(mediaRef);
|
setLastFrame(mediaRef);
|
||||||
}
|
}
|
||||||
antdMessage.success('图片上传成功');
|
antdMessage.success('图片上传成功');
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
antdMessage.error('上传失败');
|
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
|
||||||
|
antdMessage.error(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
setUploadTarget(null);
|
setUploadTarget(null);
|
||||||
@@ -1494,7 +1497,8 @@ const AIChatPage: React.FC = () => {
|
|||||||
const labels = generateMediaLabels(newList);
|
const labels = generateMediaLabels(newList);
|
||||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
antdMessage.error('上传失败');
|
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
|
||||||
|
antdMessage.error(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
@@ -1502,7 +1506,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
return false;
|
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 isImage = file.type.startsWith('image/');
|
||||||
const isVideo = file.type.startsWith('video/');
|
const isVideo = file.type.startsWith('video/');
|
||||||
const isAudio = file.type.startsWith('audio/');
|
const isAudio = file.type.startsWith('audio/');
|
||||||
@@ -1512,12 +1516,24 @@ const AIChatPage: React.FC = () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
|
const maxMB = isVideo ? 100 : (isAudio ? 50 : 30);
|
||||||
if (file.size / 1024 / 1024 > maxMB) {
|
if (file.size / 1024 / 1024 > maxMB) {
|
||||||
antdMessage.error(`${isVideo ? '视频' : (isAudio ? '音频' : '图片')}大小不能超过${maxMB}MB`);
|
antdMessage.error(`${isVideo ? '视频' : (isAudio ? '音频' : '图片')}大小不能超过${maxMB}MB`);
|
||||||
return false;
|
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) {
|
if (isAudio) {
|
||||||
const audioExt = file.name.split('.').pop()?.toLowerCase();
|
const audioExt = file.name.split('.').pop()?.toLowerCase();
|
||||||
if (!['wav', 'mp3'].includes(audioExt || '')) {
|
if (!['wav', 'mp3'].includes(audioExt || '')) {
|
||||||
@@ -1613,10 +1629,12 @@ const AIChatPage: React.FC = () => {
|
|||||||
type: pendingMedia.type,
|
type: pendingMedia.type,
|
||||||
url: res.url,
|
url: res.url,
|
||||||
label: pendingMedia.label || '',
|
label: pendingMedia.label || '',
|
||||||
|
fileSizeBytes: file.size,
|
||||||
...(pendingMedia.duration !== undefined && { duration: pendingMedia.duration }),
|
...(pendingMedia.duration !== undefined && { duration: pendingMedia.duration }),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
antdMessage.error('上传失败');
|
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
|
||||||
|
antdMessage.error(errorMsg);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1895,12 +1913,18 @@ const AIChatPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = (e: React.MouseEvent) => {
|
const handleDownload = (e: any) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!previewUrl) return;
|
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');
|
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';
|
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
@@ -4441,21 +4465,23 @@ const AIChatPage: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
<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)' }}>
|
{multiGenerationEnabled && (
|
||||||
<Text style={{ fontSize: 13, color: '#667085', fontWeight: 600 }}>生成数量:</Text>
|
<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)' }}>
|
||||||
<Select
|
<Text style={{ fontSize: 13, color: '#667085', fontWeight: 600 }}>生成数量:</Text>
|
||||||
value={generationCount}
|
<Select
|
||||||
onChange={(value) => setGenerationCount(Number(value || 1))}
|
value={generationCount}
|
||||||
disabled={effectiveMaxGenerationCount <= 1}
|
onChange={(value) => setGenerationCount(Number(value || 1))}
|
||||||
size="small"
|
disabled={effectiveMaxGenerationCount <= 1}
|
||||||
variant="borderless"
|
size="small"
|
||||||
style={{ width: 68 }}
|
variant="borderless"
|
||||||
options={Array.from({ length: effectiveMaxGenerationCount }, (_, index) => ({
|
style={{ width: 68 }}
|
||||||
value: index + 1,
|
options={Array.from({ length: effectiveMaxGenerationCount }, (_, index) => ({
|
||||||
label: `${index + 1}份`,
|
value: index + 1,
|
||||||
}))}
|
label: `${index + 1}份`,
|
||||||
/>
|
}))}
|
||||||
</div>
|
/>
|
||||||
|
</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)' }}>
|
<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: '#667085', fontWeight: 600 }}>预估积分:</Text>
|
||||||
<Text style={{ fontSize: 13, color: '#2f3440', fontWeight: 800 }}>{getEstimatedCredits()}</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>
|
<p style={{ fontSize: 16, color: '#A45B5B', marginBottom: 16 }}>图片/视频资源已过期,请刷新重新加载~</p>
|
||||||
</div>
|
</div>
|
||||||
) : previewType === 'image' ? (
|
) : previewType === 'image' ? (
|
||||||
|
// <div>{previewUrl}</div>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${previewUrl}&w=300&q=50`}
|
src={`${previewUrl}`}
|
||||||
alt="预览"
|
alt="预览"
|
||||||
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
|
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
|
src={`${previewUrl}`}
|
||||||
controls
|
controls
|
||||||
style={{ maxWidth: '100%', maxHeight: '400px' }}
|
style={{ maxWidth: '100%', maxHeight: '400px' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined, WarningOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
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 VideoPromptSchemaEditor from '../components/VideoPromptSchemaEditor';
|
||||||
import { validateVideoPromptSchemaByConfig } from '../utils/videoPromptSchema';
|
import { validateVideoPromptSchemaByConfig } from '../utils/videoPromptSchema';
|
||||||
import './css/InitialInfo.css';
|
import './css/InitialInfo.css';
|
||||||
@@ -24,6 +25,7 @@ const buildMediaUrl = (url: string): string => {
|
|||||||
function InitialInfo() {
|
function InitialInfo() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { creatID } = useParams<{ creatID: string }>();
|
const { creatID } = useParams<{ creatID: string }>();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const [promptText, setPromptText] = useState('');
|
const [promptText, setPromptText] = useState('');
|
||||||
@@ -55,6 +57,9 @@ function InitialInfo() {
|
|||||||
durations: [5, 8, 10, 12, 15],
|
durations: [5, 8, 10, 12, 15],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [creditCalculationData, setCreditCalculationData] = useState<any[]>([]);
|
||||||
|
const [estimatedCredits, setEstimatedCredits] = useState<number>(0);
|
||||||
|
|
||||||
// 创作记录表格数据
|
// 创作记录表格数据
|
||||||
const [tableData, setTableData] = useState<any[]>([]);
|
const [tableData, setTableData] = useState<any[]>([]);
|
||||||
// 分页状态
|
// 分页状态
|
||||||
@@ -222,27 +227,89 @@ function InitialInfo() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getEngine()
|
getEngine()
|
||||||
.then((data: any) => {
|
.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);
|
||||||
if (data.engine?.video && data.engine.video.length > 0) {
|
|
||||||
setCountType(data.engine.video[0].id);
|
setEnginesele({
|
||||||
// 设置默认引擎参数
|
video: filteredVideoEngines,
|
||||||
const firstEngine = data.engine.video[0];
|
image: filteredImageEngines,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filteredVideoEngines.length > 0) {
|
||||||
|
setCountType(filteredVideoEngines[0].id);
|
||||||
|
const firstEngine = filteredVideoEngines[0];
|
||||||
setEngineOptions({
|
setEngineOptions({
|
||||||
ratios: firstEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
ratios: firstEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||||
resolutions: firstEngine.supportedResolutions || ['480p', '720p', '1080p'],
|
resolutions: firstEngine.supportedResolutions || ['480p', '720p', '1080p'],
|
||||||
durations: firstEngine.supportedDurations || [5, 8, 10, 12, 15],
|
durations: firstEngine.supportedDurations || [5, 8, 10, 12, 15],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 默认选中第一个图片引擎
|
if (filteredImageEngines.length > 0) {
|
||||||
if (data.engine?.image && data.engine.image.length > 0) {
|
setImageEngineId(filteredImageEngines[0].id);
|
||||||
setImageEngineId(data.engine.image[0].id);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error: any) => {
|
.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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1190,14 +1257,36 @@ function InitialInfo() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Tooltip
|
||||||
type="primary"
|
title={((user?.credits || 0) < estimatedCredits) ? '积分不足,请更换参数/充值积分' : ''}
|
||||||
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)' }}
|
placement="top"
|
||||||
onClick={() => { handleNextStep(step.id); }}
|
|
||||||
disabled={step.status !== 'completed'}
|
|
||||||
>
|
>
|
||||||
下一步:生成视频提示词
|
<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: 生成视频提示词 */}
|
{/* 步骤4: 生成视频提示词 */}
|
||||||
|
|||||||
@@ -1,260 +1,139 @@
|
|||||||
|
/* ========================================
|
||||||
|
登录页 — 视频全屏背景 + 居中卡片
|
||||||
|
======================================== */
|
||||||
|
|
||||||
.login-page {
|
.login-page {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: #1a1a2e;
|
||||||
background-image: url(/backimage.png);
|
background-image: url(/backimage.png);
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-attachment: fixed;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
/* ---- 视频全屏背景 ---- */
|
||||||
.login-page {
|
.login-bg-video {
|
||||||
flex-direction: row;
|
position: absolute;
|
||||||
}
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-bg-overlay {
|
.login-bg-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
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;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-decoration {
|
/* ---- 左上角 slogan ---- */
|
||||||
|
.login-slogan {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
top: 40px;
|
||||||
filter: blur(40px);
|
left: 48px;
|
||||||
z-index: 0;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-decoration-1 {
|
.login-slogan-text {
|
||||||
width: 500px;
|
font-size: 24px;
|
||||||
height: 500px;
|
font-weight: 800;
|
||||||
background: radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%);
|
letter-spacing: 1px;
|
||||||
top: -150px;
|
background: linear-gradient(90deg, #fff 0%, #c7d2fe 25%, #fff 50%, #c7d2fe 75%, #fff 100%);
|
||||||
right: -100px;
|
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 {
|
@keyframes shinySlide {
|
||||||
width: 400px;
|
0% { background-position: 0% center; }
|
||||||
height: 400px;
|
100% { background-position: 200% center; }
|
||||||
background: radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%);
|
|
||||||
bottom: -100px;
|
|
||||||
left: -80px;
|
|
||||||
filter: blur(50px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-left-section {
|
/* ---- 登录卡片靠右 ---- */
|
||||||
|
.login-center {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-end;
|
||||||
padding: 32px 16px;
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
padding: 24px 60px 24px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
/* ========================================
|
||||||
.login-left-section {
|
登录卡片
|
||||||
padding: 0 80px;
|
======================================== */
|
||||||
}
|
.login-card {
|
||||||
}
|
|
||||||
|
|
||||||
.login-left-content {
|
|
||||||
width: 100%;
|
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 {
|
.login-logo-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
justify-content: center;
|
||||||
margin-bottom: 20px;
|
gap: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.login-logo-row {
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-logo-img {
|
.login-logo-img {
|
||||||
width: 52px;
|
width: 40px;
|
||||||
height: 52px;
|
height: 40px;
|
||||||
border-radius: 14px;
|
border-radius: 12px;
|
||||||
objectFit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-logo-placeholder {
|
.login-logo-placeholder {
|
||||||
width: 52px;
|
width: 40px;
|
||||||
height: 52px;
|
height: 40px;
|
||||||
border-radius: 14px;
|
border-radius: 12px;
|
||||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.login-site-name {
|
||||||
color: #1e293b;
|
font-size: 22px;
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: -0.5px;
|
color: #1e293b;
|
||||||
}
|
letter-spacing: -0.3px;
|
||||||
|
|
||||||
@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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card-subtitle {
|
.login-card-subtitle {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 28px;
|
margin-bottom: 24px;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Tabs ---- */
|
||||||
.login-tabs {
|
.login-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 20px;
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
@@ -264,10 +143,10 @@
|
|||||||
.login-tab {
|
.login-tab {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 10px 0;
|
padding: 9px 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -284,9 +163,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-submit-btn {
|
.login-submit-btn {
|
||||||
height: 48px !important;
|
height: 46px !important;
|
||||||
border-radius: 10px !important;
|
border-radius: 10px !important;
|
||||||
font-size: 16px !important;
|
font-size: 15px !important;
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
@@ -294,19 +173,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-code-btn {
|
.login-code-btn {
|
||||||
height: 48px !important;
|
height: 46px !important;
|
||||||
border-radius: 10 !important;
|
border-radius: 10px !important;
|
||||||
border: 1.5px solid #e2e8f0 !important;
|
border: 1.5px solid #e2e8f0 !important;
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
min-width: 100px !important;
|
min-width: 100px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-agreement {
|
.login-agreement {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-agreement-text {
|
.login-agreement-text {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,21 +199,33 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
|
||||||
.login-footer {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-switch-btn {
|
.login-switch-btn {
|
||||||
font-size: 13px !important;
|
font-size: 13px !important;
|
||||||
color: #6366f1 !important;
|
color: #6366f1 !important;
|
||||||
cursor: pointer;
|
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 {
|
.login-page .ant-input-affix-wrapper {
|
||||||
padding: 0 11px !important;
|
padding: 0 11px !important;
|
||||||
height: 48px !important;
|
height: 46px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-page .ant-input-affix-wrapper .ant-input-prefix {
|
.login-page .ant-input-affix-wrapper .ant-input-prefix {
|
||||||
@@ -344,61 +235,28 @@
|
|||||||
|
|
||||||
.login-page .ant-input {
|
.login-page .ant-input {
|
||||||
padding-left: 11px !important;
|
padding-left: 11px !important;
|
||||||
height: 48px !important;
|
height: 46px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-code-btn {
|
/* ========================================
|
||||||
height: 48px !important;
|
响应式
|
||||||
}
|
======================================== */
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 899px) {
|
||||||
.login-left-section {
|
.login-slogan { top: 24px; left: 24px; }
|
||||||
padding: 28px 16px 12px;
|
.login-slogan-text { font-size: 18px; }
|
||||||
}
|
.login-center { padding: 80px 20px 24px; justify-content: center; }
|
||||||
|
|
||||||
.login-right-section {
|
|
||||||
padding: 12px 16px 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.login-left-section {
|
.login-slogan { top: 20px; left: 20px; }
|
||||||
padding: 24px 12px 8px;
|
.login-slogan-text { font-size: 15px; }
|
||||||
text-align: center;
|
.login-card .ant-card-body { padding: 24px 20px !important; }
|
||||||
}
|
.login-card { border-radius: 16px !important; }
|
||||||
|
|
||||||
.login-right-section {
|
|
||||||
padding: 8px 12px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shiny-text-container {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-page .ant-input,
|
.login-page .ant-input,
|
||||||
.login-page .ant-input-affix-wrapper {
|
.login-page .ant-input-affix-wrapper { height: 44px !important; }
|
||||||
height: 44px !important;
|
.login-code-btn { height: 44px !important; min-width: 90px !important; }
|
||||||
font-size: 14px !important;
|
.login-submit-btn { height: 44px !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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes sliderShake {
|
@keyframes sliderShake {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { Button, Form, Input, Card, Typography, message, Space, Checkbox } from 'antd';
|
import { Button, Form, Input, Card, Typography, message, Space, Checkbox } from 'antd';
|
||||||
import {
|
import {
|
||||||
LockOutlined, ThunderboltOutlined,
|
LockOutlined, ThunderboltOutlined,
|
||||||
PlayCircleOutlined, BulbOutlined, HistoryOutlined,
|
|
||||||
MobileOutlined, SafetyOutlined,
|
MobileOutlined, SafetyOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
@@ -48,9 +47,10 @@ const LoginPage: React.FC = () => {
|
|||||||
const initialInfo = getInitialSiteInfo();
|
const initialInfo = getInitialSiteInfo();
|
||||||
const [siteName, setSiteName] = useState(initialInfo.siteName);
|
const [siteName, setSiteName] = useState(initialInfo.siteName);
|
||||||
const [siteLogo, setSiteLogo] = useState(initialInfo.siteLogo);
|
const [siteLogo, setSiteLogo] = useState(initialInfo.siteLogo);
|
||||||
|
const [loginBgVideo, setLoginBgVideo] = useState('');
|
||||||
const [agreementPrivacyUrl, setAgreementPrivacyUrl] = useState('');
|
const [agreementPrivacyUrl, setAgreementPrivacyUrl] = useState('');
|
||||||
const [siteCopyright, setSiteCopyright] = useState('');
|
const [siteCopyright, setSiteCopyright] = useState('');
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login } = useAuthStore();
|
const { login } = useAuthStore();
|
||||||
const [pwdForm] = Form.useForm();
|
const [pwdForm] = Form.useForm();
|
||||||
@@ -61,6 +61,7 @@ const LoginPage: React.FC = () => {
|
|||||||
getSiteInfo().then(info => {
|
getSiteInfo().then(info => {
|
||||||
setSiteName(info.siteName);
|
setSiteName(info.siteName);
|
||||||
setSiteLogo(info.siteLogo);
|
setSiteLogo(info.siteLogo);
|
||||||
|
setLoginBgVideo(info.loginBgVideo || '');
|
||||||
setAgreementPrivacyUrl(info.userAgreementPrivacyUrl);
|
setAgreementPrivacyUrl(info.userAgreementPrivacyUrl);
|
||||||
setSiteCopyright(info.siteCopyright);
|
setSiteCopyright(info.siteCopyright);
|
||||||
}).catch(() => {});
|
}).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 = {
|
const inputStyle: React.CSSProperties = {
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
border: '1.5px solid #e2e8f0',
|
border: '1.5px solid #e2e8f0',
|
||||||
@@ -293,71 +288,50 @@ const LoginPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-page">
|
<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-bg-overlay" />
|
||||||
<div className="login-decoration login-decoration-1" />
|
|
||||||
<div className="login-decoration login-decoration-2" />
|
|
||||||
|
|
||||||
<div className="login-left-section">
|
{/* 左上角 slogan */}
|
||||||
<Space direction="vertical" size={36} className="login-left-content">
|
<div className="login-slogan">
|
||||||
<div>
|
<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">
|
<div className="login-logo-row">
|
||||||
{siteLogo ? (
|
{siteLogo ? (
|
||||||
<img src={siteLogo} alt="logo" className="login-logo-img" />
|
<img src={siteLogo} alt="logo" className="login-logo-img" />
|
||||||
) : (
|
) : (
|
||||||
<div className="login-logo-placeholder">
|
<div className="login-logo-placeholder">
|
||||||
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} />
|
<ThunderboltOutlined style={{ fontSize: 20, color: '#fff' }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="login-site-name">{siteName}</span>
|
<span className="login-site-name">{siteName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="shiny-text-container">
|
</div>
|
||||||
<span className="shiny-text">AI赋能创意,素材触手可及</span>
|
<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>
|
</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' && (
|
{mode === 'password' && (
|
||||||
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
|
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
|
||||||
@@ -485,49 +459,49 @@ const LoginPage: React.FC = () => {
|
|||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="login-footer">
|
<div className="login-footer">
|
||||||
{mode === 'register' ? (
|
{mode === 'register' ? (
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
className="login-switch-btn"
|
className="login-switch-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode('password');
|
setMode('password');
|
||||||
setTab('password');
|
setTab('password');
|
||||||
setShowResend(false);
|
setShowResend(false);
|
||||||
setLoginShowResend(false);
|
setLoginShowResend(false);
|
||||||
setSliderVerified(false);
|
setSliderVerified(false);
|
||||||
setShowSliderVerify(false);
|
setShowSliderVerify(false);
|
||||||
setSliderKey(prev => prev + 1);
|
setSliderKey(prev => prev + 1);
|
||||||
regForm.resetFields();
|
regForm.resetFields();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
已有账号?去登录
|
已有账号?去登录
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
className="login-switch-btn"
|
className="login-switch-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode('register');
|
setMode('register');
|
||||||
setShowResend(false);
|
setShowResend(false);
|
||||||
setLoginShowResend(false);
|
setLoginShowResend(false);
|
||||||
setSliderVerified(false);
|
setSliderVerified(false);
|
||||||
setShowSliderVerify(false);
|
setShowSliderVerify(false);
|
||||||
setSliderKey(prev => prev + 1);
|
setSliderKey(prev => prev + 1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
没有账号?立即注册
|
没有账号?立即注册
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
{siteCopyright && (
|
|
||||||
<div className="login-copyright-wrapper">
|
|
||||||
<div className="login-copyright">
|
|
||||||
{siteCopyright}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{siteCopyright && (
|
||||||
|
<div className="login-copyright-wrapper">
|
||||||
|
<div className="login-copyright">
|
||||||
|
{siteCopyright}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
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 VideoPromptSchemaEditor from '../components/VideoPromptSchemaEditor';
|
||||||
import { validateVideoPromptSchemaByConfig } from '../utils/videoPromptSchema';
|
import { validateVideoPromptSchemaByConfig } from '../utils/videoPromptSchema';
|
||||||
import './css/InitialInfo.css';
|
import './css/InitialInfo.css';
|
||||||
@@ -24,8 +25,11 @@ const buildMediaUrl = (url: string): string => {
|
|||||||
function InitialInfo() {
|
function InitialInfo() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { creatID } = useParams<{ creatID: string }>();
|
const { creatID } = useParams<{ creatID: string }>();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [creditCalculationData, setCreditCalculationData] = useState<any[]>([]);
|
||||||
|
const [estimatedCredits, setEstimatedCredits] = useState<number>(0);
|
||||||
const [promptText, setPromptText] = useState('');
|
const [promptText, setPromptText] = useState('');
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [currentType, setCurrentType] = useState<string>('image');
|
const [currentType, setCurrentType] = useState<string>('image');
|
||||||
@@ -128,7 +132,7 @@ function InitialInfo() {
|
|||||||
// 当apiSteps更新时,逆向遍历找到第一个已完成或失败的步骤并展开
|
// 当apiSteps更新时,逆向遍历找到第一个已完成或失败的步骤并展开
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const completedFailedKeys = new Set<string>();
|
const completedFailedKeys = new Set<string>();
|
||||||
|
|
||||||
activeKey.forEach(key => {
|
activeKey.forEach(key => {
|
||||||
const step = steps.find(s => String(s.childId) === key);
|
const step = steps.find(s => String(s.childId) === key);
|
||||||
if (step && (step.status === 'completed' || step.status === 'failed')) {
|
if (step && (step.status === 'completed' || step.status === 'failed')) {
|
||||||
@@ -190,12 +194,17 @@ function InitialInfo() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getEngine()
|
getEngine()
|
||||||
.then((data: any) => {
|
.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) {
|
if (filteredVideoEngines.length > 0) {
|
||||||
setCountType(data.engine.video[0].id);
|
setCountType(filteredVideoEngines[0].id);
|
||||||
// 设置默认引擎参数
|
// 设置默认引擎参数
|
||||||
const firstEngine = data.engine.video[0];
|
const firstEngine = filteredVideoEngines[0];
|
||||||
setEngineOptions({
|
setEngineOptions({
|
||||||
ratios: firstEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
ratios: firstEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||||
resolutions: firstEngine.supportedResolutions || ['480p', '720p', '1080p'],
|
resolutions: firstEngine.supportedResolutions || ['480p', '720p', '1080p'],
|
||||||
@@ -203,8 +212,8 @@ function InitialInfo() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 默认选中第一个图片引擎
|
// 默认选中第一个图片引擎
|
||||||
if (data.engine?.image && data.engine.image.length > 0) {
|
if (filteredImageEngines.length > 0) {
|
||||||
setImageEngineId(data.engine.image[0].id);
|
setImageEngineId(filteredImageEngines[0].id);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error: any) => {
|
.catch((error: any) => {
|
||||||
@@ -275,6 +284,57 @@ function InitialInfo() {
|
|||||||
}
|
}
|
||||||
}, [steps]);
|
}, [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) => {
|
const handleOpenModal = (prompt?: any, type?: string, stepId?: string | number, schemaConfigSnapshot?: any) => {
|
||||||
setCurrentType(type || 'image');
|
setCurrentType(type || 'image');
|
||||||
setEditingPromptStepId(stepId ? String(stepId) : '');
|
setEditingPromptStepId(stepId ? String(stepId) : '');
|
||||||
@@ -1188,14 +1248,36 @@ function InitialInfo() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Tooltip
|
||||||
type="primary"
|
title={((user?.credits || 0) < estimatedCredits) ? '积分不足,请更换参数/充值积分' : ''}
|
||||||
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)' }}
|
placement="top"
|
||||||
onClick={() => { handleNextStep(step.id); }}
|
|
||||||
disabled={step.status !== 'completed'}
|
|
||||||
>
|
>
|
||||||
下一步:生成视频提示词
|
<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: 生成视频提示词 */}
|
{/* 步骤4: 生成视频提示词 */}
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export interface MediaReference {
|
|||||||
displayUrl?: string;
|
displayUrl?: string;
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
upload_resource_id?: string;
|
upload_resource_id?: string;
|
||||||
|
fileSizeBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user