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> <!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>
+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 }); 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));
} }
+223 -71
View File
@@ -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 }}>
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 ( 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' }}>
+81
View File
@@ -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 ────────────────────────────────────────
+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.""" """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 "",
} }
+1
View File
@@ -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!!"
+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, # 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
+6
View File
@@ -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)
+60 -49
View File
@@ -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,
+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> <!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>
+2 -2
View File
@@ -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 ─────────────────────────────────────────
+101 -11
View File
@@ -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')}
+53 -25
View File
@@ -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' }}
/> />
+108 -19
View File
@@ -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: 生成视频提示词 */}
+115 -257
View File
@@ -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 {
+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 { 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>
); );
}; };
+98 -16
View File
@@ -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: 生成视频提示词 */}
+1
View File
@@ -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;
} }