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