解决冲突问题

This commit is contained in:
Lrd
2026-06-30 09:16:07 +08:00
46 changed files with 2540 additions and 962 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ __pycache__/
.vscode/ .vscode/
.trae/ .trae/
# video-gen-app/dist/ # video-gen-app/dist/
video-gen-api/dist/ #video-gen-api/dist/
bak/ bak/
# 使用通配符 # 使用通配符
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-DSXie0ty.js"></script> <script type="module" crossorigin src="/assets/index-DNJhhUfW.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>
+1 -1
View File
@@ -7,7 +7,7 @@ import AdminAuthoriz from './pages/AdminAuthoriz';
import AdminConsume from './pages/AdminConsume'; import AdminConsume from './pages/AdminConsume';
import AdminLoginPage from './pages/AdminLoginPage'; import AdminLoginPage from './pages/AdminLoginPage';
import AdminDashboard from './pages/AdminDashboard'; import AdminDashboard from './pages/AdminDashboard';
import AdminPlatform from './pages/AdminPlatform'; import AdminPlatform from './pages/Adminplatform';
import AdminUsers from './pages/AdminUsers'; import AdminUsers from './pages/AdminUsers';
import AdminModels from './pages/AdminModels'; import AdminModels from './pages/AdminModels';
import AdminSettings from './pages/AdminSettings'; import AdminSettings from './pages/AdminSettings';
+29
View File
@@ -13,6 +13,7 @@ import type {
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload, VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut, VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
AdminCreditRecordListResponse, AdminCreditRecordQueryParams, AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
} from '../types'; } from '../types';
// ── Auth ────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────
@@ -149,6 +150,34 @@ 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 getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
return api.get('/admin/resource-capacity/global');
}
export async function saveGlobalResourceCapacity(payload: ResourceCapacityConfigPayload): Promise<ResourceCapacityConfigOut> {
return api.put('/admin/resource-capacity/global', {
enabled: payload.enabled,
limit_value: payload.limitValue ?? null,
limit_unit: payload.limitUnit ?? null,
});
}
export async function getUserResourceCapacity(userId: string): Promise<AdminUserResourceCapacityOut> {
return api.get(`/admin/users/${userId}/resource-capacity`);
}
export async function saveUserResourceCapacity(userId: string, payload: ResourceCapacityConfigPayload): Promise<AdminUserResourceCapacityOut> {
return api.put(`/admin/users/${userId}/resource-capacity`, {
enabled: payload.enabled,
limit_value: payload.limitValue ?? null,
limit_unit: payload.limitUnit ?? null,
});
}
export async function deleteUserResourceCapacity(userId: string): Promise<AdminUserResourceCapacityOut> {
return api.delete(`/admin/users/${userId}/resource-capacity`);
}
export async function uploadPdf(file: File, configKey: string): Promise<{ url: string }> { export async function uploadPdf(file: File, configKey: string): Promise<{ url: string }> {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
+137 -62
View File
@@ -1,12 +1,25 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Button, Card, Form, Input, message, Space, Switch, Typography, Upload, Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Typography, Upload,
} from 'antd'; } from 'antd';
import { import {
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { getSystemConfigs, updateSystemConfig, uploadPdf, uploadLogo } from '../api'; import {
import type { SystemConfig } from '../types'; getGlobalResourceCapacity,
getSystemConfigs,
saveGlobalResourceCapacity,
updateSystemConfig,
uploadLogo,
uploadPdf,
} from '../api';
import type { ResourceCapacityUnit, SystemConfig } from '../types';
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
{ value: 'MB', label: 'MB1024 × 1024 字节)' },
{ value: 'GB', label: 'GB1024 × 1024 × 1024 字节)' },
{ value: 'TB', label: 'TB1024 × 1024 × 1024 × 1024 字节)' },
];
const AdminSettings: React.FC = () => { const AdminSettings: React.FC = () => {
const [configs, setConfigs] = useState<SystemConfig[]>([]); const [configs, setConfigs] = useState<SystemConfig[]>([]);
@@ -14,7 +27,6 @@ const AdminSettings: React.FC = () => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(''); const [uploading, setUploading] = useState('');
const [form] = Form.useForm(); const [form] = Form.useForm();
const [logoPreview, setLogoPreview] = useState('');
useEffect(() => { useEffect(() => {
load(); load();
@@ -22,12 +34,23 @@ const AdminSettings: React.FC = () => {
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
const data = await getSystemConfigs(); try {
setConfigs(data); const [data, capacity] = await Promise.all([
const formValues: Record<string, string> = {}; getSystemConfigs(),
data.forEach(c => { formValues[c.key] = c.value; }); getGlobalResourceCapacity(),
form.setFieldsValue(formValues); ]);
setLoading(false); setConfigs(data);
const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; });
formValues.resource_capacity_enabled = capacity.enabled;
formValues.resource_capacity_limit_value = capacity.limitValue || '1.000';
formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB';
form.setFieldsValue(formValues);
} catch (e: any) {
message.error(e?.message || '加载配置失败');
} finally {
setLoading(false);
}
}; };
const handleSave = async () => { const handleSave = async () => {
@@ -36,17 +59,21 @@ const AdminSettings: React.FC = () => {
setSaving(true); setSaving(true);
for (const config of configs) { for (const config of configs) {
const newVal = values[config.key]; const newVal = values[config.key];
if (newVal !== undefined && newVal !== config.value) { if (newVal !== undefined && String(newVal) !== config.value) {
await updateSystemConfig(config.id, newVal ?? ''); await updateSystemConfig(config.id, String(newVal ?? ''));
} }
} }
await saveGlobalResourceCapacity({
enabled: !!values.resource_capacity_enabled,
limitValue: String(values.resource_capacity_limit_value ?? '1.000'),
limitUnit: values.resource_capacity_limit_unit || 'GB',
});
message.success('系统配置已保存'); message.success('系统配置已保存');
const data = await getSystemConfigs(); await load();
setConfigs(data);
setSaving(false);
} catch (e: any) { } catch (e: any) {
setSaving(false);
message.error(e?.message || '保存失败'); message.error(e?.message || '保存失败');
} finally {
setSaving(false);
} }
}; };
@@ -54,38 +81,42 @@ const AdminSettings: React.FC = () => {
setUploading(configKey); setUploading(configKey);
try { try {
const res = await uploadPdf(file, configKey); const res = await uploadPdf(file, configKey);
// Update local state
setConfigs(prev => prev.map(c => c.key === configKey ? { ...c, value: res.url } : c)); setConfigs(prev => prev.map(c => c.key === configKey ? { ...c, value: res.url } : c));
form.setFieldsValue({ [configKey]: res.url }); form.setFieldsValue({ [configKey]: res.url });
message.success('PDF上传成功');
// Find the config and update to database
const config = configs.find(c => c.key === configKey);
if (config) {
await updateSystemConfig(config.id, res.url);
}
message.success('PDF上传成功并已保存');
} catch { } catch {
message.error('上传失败'); message.error('上传失败');
} finally { } finally {
setUploading(''); setUploading('');
} }
return false; // prevent default upload return false;
}; };
const handleLogoUpload = async (file: File) => { const handleLogoUpload = async (file: File) => {
setUploading('site_logo'); setUploading('site_logo');
try { try {
const res = await uploadLogo(file); const res = await uploadLogo(file);
// Update local state
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c)); setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
form.setFieldsValue({ site_logo: res.url }); form.setFieldsValue({ site_logo: res.url });
setLogoPreview(res.url);
message.success('Logo上传成功'); message.success('Logo上传成功');
} catch { } catch {
message.error('上传失败'); message.error('上传失败');
} finally { } finally {
setUploading(''); setUploading('');
} }
return false; // prevent default upload return false;
}; };
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_url' || c.key === 'privacy_policy_url'), '协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')), 'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')), '用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
}; };
@@ -94,8 +125,8 @@ const AdminSettings: React.FC = () => {
const descMap: Record<string, string> = { const descMap: Record<string, string> = {
site_name: '平台显示名称,将展示在页面标题和导航栏', site_name: '平台显示名称,将展示在页面标题和导航栏',
site_logo: '平台Logo图片URL,建议尺寸 200x40px', site_logo: '平台Logo图片URL,建议尺寸 200x40px',
user_agreement_url: '用户注册/登录时需同意的用户协议PDF文件', site_copyright: '显示在前台登录页底部的版权信息,例如:© 2024 民众智创 版权所有',
privacy_policy_url: '用户注册/登录时需同意的隐私政策PDF文件', user_agreement_privacy_url: '用户登录时需同意的用户协议及隐私政策PDF文件',
seo_title: '搜索引擎结果中显示的标题', seo_title: '搜索引擎结果中显示的标题',
seo_description: '搜索引擎结果中显示的描述文字,建议150字以内', seo_description: '搜索引擎结果中显示的描述文字,建议150字以内',
seo_keywords: '用逗号分隔的关键词列表', seo_keywords: '用逗号分隔的关键词列表',
@@ -106,37 +137,11 @@ const AdminSettings: React.FC = () => {
return descMap[config.key] || config.description || ''; return descMap[config.key] || config.description || '';
}; };
const getFieldComponent = (config: SystemConfig) => {
if (config.key === 'site_logo') {
return <LogoUploadField config={config} />;
}
if (config.key === 'seo_description') {
return <Input.TextArea rows={3} placeholder={config.description} size="large" />;
}
if (config.key === 'seo_keywords') {
return <Input placeholder="关键词1, 关键词2, 关键词3" size="large" />;
}
if (config.key === 'user_login_credits_enabled') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Switch defaultChecked={config.value === 'true'} />
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{config.value === 'true' ? '已启用' : '已禁用'}
</Typography.Text>
</div>
);
}
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
return <Input type="number" min={0} placeholder={config.description} size="large" />;
}
return <Input placeholder={config.description} size="large" />;
};
const LogoUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => { const LogoUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => {
const hasLogo = config.value && config.value.startsWith('/uploads/'); const hasLogo = config.value && config.value.startsWith('/uploads/');
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const logoUrl = hasLogo ? `${baseUrl}${config.value}` : ''; const logoUrl = hasLogo ? `${baseUrl}${config.value}` : '';
const handleRemove = () => { const handleRemove = () => {
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: '' } : c)); setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: '' } : c));
form.setFieldsValue({ site_logo: '' }); form.setFieldsValue({ site_logo: '' });
@@ -172,17 +177,17 @@ const AdminSettings: React.FC = () => {
</div> </div>
{hasLogo ? ( {hasLogo ? (
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<img <img
src={logoUrl} src={logoUrl}
alt="Logo预览" alt="Logo预览"
style={{ style={{
maxWidth: 200, maxWidth: 200,
maxHeight: 80, maxHeight: 80,
objectFit: 'contain', objectFit: 'contain',
border: '1px solid #e2e8f0', border: '1px solid #e2e8f0',
borderRadius: 8, borderRadius: 8,
padding: 8, padding: 8,
}} }}
/> />
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}> <Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
200x40px PNGJPG 200x40px PNGJPG
@@ -198,8 +203,9 @@ const AdminSettings: React.FC = () => {
}; };
const PdfUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => { const PdfUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => {
const label = config.key === 'user_agreement_url' ? '用户协议' : '隐私政策'; const label = config.key === 'user_agreement_privacy_url' ? '用户协议及隐私政策' : '协议文件';
const hasFile = config.value && config.value.startsWith('/uploads/'); const hasFile = config.value && config.value.startsWith('/uploads/');
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
return ( return (
<div style={{ <div style={{
padding: '16px', borderRadius: 10, padding: '16px', borderRadius: 10,
@@ -214,7 +220,7 @@ const AdminSettings: React.FC = () => {
<Space> <Space>
{hasFile && ( {hasFile && (
<Button size="small" icon={<EyeOutlined />} <Button size="small" icon={<EyeOutlined />}
onClick={() => window.open(`http://localhost:8000${config.value}`, '_blank')}> onClick={() => window.open(`${baseUrl}${config.value}`, '_blank')}>
</Button> </Button>
)} )}
@@ -236,6 +242,32 @@ const AdminSettings: React.FC = () => {
); );
}; };
const getFieldComponent = (config: SystemConfig) => {
if (config.key === 'site_logo') {
return <LogoUploadField config={config} />;
}
if (config.key === 'seo_description') {
return <Input.TextArea rows={3} placeholder={config.description} size="large" />;
}
if (config.key === 'seo_keywords') {
return <Input placeholder="关键词1, 关键词2, 关键词3" size="large" />;
}
if (config.key === 'user_login_credits_enabled') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Switch defaultChecked={config.value === 'true'} />
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{config.value === 'true' ? '已启用' : '已禁用'}
</Typography.Text>
</div>
);
}
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
return <Input type="number" min={0} placeholder={config.description} size="large" />;
}
return <Input placeholder={config.description} size="large" />;
};
if (loading) { if (loading) {
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />; return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
} }
@@ -282,6 +314,49 @@ const AdminSettings: React.FC = () => {
)} )}
</div> </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> </Form>
</Card> </Card>
+222 -19
View File
@@ -1,14 +1,55 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, Tag, Typography, Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd'; } from 'antd';
import { import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { getAdminUsers, adjustCredits, toggleUserStatus, createUser, updateUserMenus, getMenuConfigs, resetUserPassword, getSystemConfigs, updateSystemConfig, updateFrontendUserKind } from '../api'; import {
import type { AdminUser, SystemConfig } from '../types'; adjustCredits,
createUser,
deleteUserResourceCapacity,
getAdminUsers,
getMenuConfigs,
getSystemConfigs,
getUserResourceCapacity,
resetUserPassword,
saveUserResourceCapacity,
toggleUserStatus,
updateFrontendUserKind,
updateSystemConfig,
updateUserMenus,
} from '../api';
import type { AdminUser, AdminUserResourceCapacityOut, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
{ value: 'MB', label: 'MB1024 × 1024 字节)' },
{ value: 'GB', label: 'GB1024 × 1024 × 1024 字节)' },
{ value: 'TB', label: 'TB1024 × 1024 × 1024 × 1024 字节)' },
];
function formatBytes(bytes?: number | null): string {
if (bytes === null || bytes === undefined) return '-';
const value = Number(bytes || 0);
if (value < 1024) return `${value} B`;
const units = ['KB', 'MB', 'GB', 'TB', 'PB'];
let size = value;
let unitIndex = -1;
do {
size /= 1024;
unitIndex += 1;
} while (size >= 1024 && unitIndex < units.length - 1);
return `${size.toFixed(size >= 100 ? 0 : size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
}
function capacitySourceLabel(capacity?: ResourceCapacityUsage | null): string {
if (!capacity || !capacity.enabled) return '未开启';
if (capacity.source === 'user') return '个人';
if (capacity.source === 'global') return '全局';
return '未开启';
}
const AdminUsers: React.FC = () => { const AdminUsers: React.FC = () => {
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -22,9 +63,13 @@ const AdminUsers: React.FC = () => {
const [allMenus, setAllMenus] = useState<any[]>([]); const [allMenus, setAllMenus] = useState<any[]>([]);
const [checkedMenus, setCheckedMenus] = useState<string[]>([]); const [checkedMenus, setCheckedMenus] = useState<string[]>([]);
const [resetPwdModal, setResetPwdModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null }); const [resetPwdModal, setResetPwdModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null });
const [capacityLoading, setCapacityLoading] = useState(false);
const [capacitySaving, setCapacitySaving] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [resetPwdForm] = Form.useForm(); const [resetPwdForm] = Form.useForm();
const [capacityForm] = Form.useForm();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20); const [pageSize, setPageSize] = useState(20);
@@ -66,17 +111,17 @@ const AdminUsers: React.FC = () => {
setConfigSaving(true); setConfigSaving(true);
for (const config of creditConfigs) { for (const config of creditConfigs) {
const newVal = values[config.key]; const newVal = values[config.key];
if (newVal !== undefined && newVal !== config.value) { if (newVal !== undefined && String(newVal) !== config.value) {
await updateSystemConfig(config.id, newVal ?? ''); await updateSystemConfig(config.id, String(newVal ?? ''));
} }
} }
message.success('积分配置已保存'); message.success('积分配置已保存');
const configs = await getSystemConfigs(); const configs = await getSystemConfigs();
setCreditConfigs(configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits'))); setCreditConfigs(configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')));
setConfigSaving(false);
} catch (e: any) { } catch (e: any) {
setConfigSaving(false);
message.error(e?.message || '保存失败'); message.error(e?.message || '保存失败');
} finally {
setConfigSaving(false);
} }
}; };
@@ -142,7 +187,65 @@ const AdminUsers: React.FC = () => {
} }
}; };
// Build structured menu display: groups with children, and top-level pages const openCapacityModal = async (user: AdminUser) => {
setCapacityLoading(true);
setCapacityModal({ open: true, user, detail: null });
try {
const detail = await getUserResourceCapacity(user.id);
const initialConfig = detail.userConfig;
capacityForm.setFieldsValue({
enabled: initialConfig?.enabled ?? false,
limitValue: initialConfig?.limitValue ?? detail.effective.limitValue ?? detail.globalConfig.limitValue ?? '1.000',
limitUnit: initialConfig?.limitUnit ?? detail.effective.limitUnit ?? detail.globalConfig.limitUnit ?? 'GB',
});
setCapacityModal({ open: true, user, detail });
} catch (e: any) {
message.error(e?.message || '加载容量配置失败');
setCapacityModal({ open: false, user: null, detail: null });
} finally {
setCapacityLoading(false);
}
};
const handleSaveCapacity = async () => {
const { user } = capacityModal;
if (!user) return;
try {
const values = await capacityForm.validateFields();
setCapacitySaving(true);
await saveUserResourceCapacity(user.id, {
enabled: !!values.enabled,
limitValue: String(values.limitValue ?? '1.000'),
limitUnit: values.limitUnit || 'GB',
});
message.success('用户容量配置已保存');
setCapacityModal({ open: false, user: null, detail: null });
capacityForm.resetFields();
load();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setCapacitySaving(false);
}
};
const handleRestoreGlobalCapacity = async () => {
const { user } = capacityModal;
if (!user) return;
try {
setCapacitySaving(true);
await deleteUserResourceCapacity(user.id);
message.success('已恢复为全局容量配置');
setCapacityModal({ open: false, user: null, detail: null });
capacityForm.resetFields();
load();
} catch (e: any) {
message.error(e?.message || '恢复失败');
} finally {
setCapacitySaving(false);
}
};
const menuGroups = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) === 'group'); const menuGroups = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) === 'group');
const menuPages = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) !== 'group'); const menuPages = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) !== 'group');
const childMap: Record<string, any[]> = {}; const childMap: Record<string, any[]> = {};
@@ -229,6 +332,36 @@ const AdminUsers: React.FC = () => {
title: '前台归类', dataIndex: 'frontendUserKind', width: 110, title: '前台归类', dataIndex: 'frontendUserKind', width: 110,
render: (v: string) => <Tag color={v === 'internal' ? 'geekblue' : 'default'}>{v === 'internal' ? '内部用户' : '外部用户'}</Tag>, render: (v: string) => <Tag color={v === 'internal' ? 'geekblue' : 'default'}>{v === 'internal' ? '内部用户' : '外部用户'}</Tag>,
}] : []), }] : []),
...(!isAdminTab ? [{
title: '资源容量', dataIndex: 'resourceCapacity', width: 230,
render: (capacity: ResourceCapacityUsage | null | undefined) => {
const usedText = formatBytes(capacity?.usedBytes || 0);
if (!capacity || !capacity.enabled) {
return (
<div>
<Space size={6} style={{ marginBottom: 4 }}>
<Tag></Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> {usedText}</Typography.Text>
</Space>
<Progress percent={0} size="small" showInfo={false} />
</div>
);
}
const percent = Math.min(Number(capacity.usagePercent || 0), 100);
return (
<div>
<Space size={6} style={{ marginBottom: 4 }}>
<Tag color={capacity.source === 'user' ? 'blue' : 'purple'}>{capacitySourceLabel(capacity)}</Tag>
{capacity.exceeded && <Tag color="red"></Tag>}
</Space>
<Progress percent={percent} size="small" status={capacity.exceeded ? 'exception' : 'active'} />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{usedText} / {formatBytes(capacity.totalBytes)} {formatBytes(capacity.availableBytes)}
</Typography.Text>
</div>
);
},
}] : []),
{ {
title: '状态', dataIndex: 'isActive', width: 80, title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => ( render: (v: boolean) => (
@@ -244,15 +377,21 @@ const AdminUsers: React.FC = () => {
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
}, },
{ {
title: '操作', key: 'action', width: 320, fixed: 'right' as const, title: '操作', key: 'action', width: 390, fixed: 'right' as const,
render: (_: any, r: AdminUser) => ( render: (_: any, r: AdminUser) => (
<Space size={4}> <Space size={4} wrap>
{!isAdminTab && ( {!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />} <Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}> onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
</Button> </Button>
)} )}
{!isAdminTab && (
<Button type="link" size="small" icon={<DatabaseOutlined />}
onClick={() => openCapacityModal(r)}>
</Button>
)}
{!isAdminTab && r.frontendUserKind !== 'internal' && ( {!isAdminTab && r.frontendUserKind !== 'internal' && (
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'internal')}></Button> <Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'internal')}></Button>
)} )}
@@ -338,7 +477,6 @@ const AdminUsers: React.FC = () => {
</Card> </Card>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}> <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
{/* Search bar */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 12 }}>
<Input <Input
@@ -393,11 +531,10 @@ const AdminUsers: React.FC = () => {
showSizeChanger: true, showSizeChanger: true,
showTotal: (t) => `${t} 个用户`, showTotal: (t) => `${t} 个用户`,
}} }}
scroll={{ x: 1000 }} scroll={{ x: 1280 }}
/> />
</Card> </Card>
{/* Adjust Credits Modal */}
<Modal <Modal
title={<Space><WalletOutlined /> - {creditModal.user?.username}</Space>} title={<Space><WalletOutlined /> - {creditModal.user?.username}</Space>}
open={creditModal.open} open={creditModal.open}
@@ -428,7 +565,77 @@ const AdminUsers: React.FC = () => {
</Form> </Form>
</Modal> </Modal>
{/* Create User Modal */} <Modal
title={<Space><DatabaseOutlined /> - {capacityModal.user?.username}</Space>}
open={capacityModal.open}
confirmLoading={capacitySaving}
onOk={handleSaveCapacity}
onCancel={() => { setCapacityModal({ open: false, user: null, detail: null }); capacityForm.resetFields(); }}
okText="保存个人配置" cancelText="取消" width={560}
>
<Card loading={capacityLoading} variant="outlined" style={{ marginBottom: 16 }}>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Typography.Text type="secondary">
{capacitySourceLabel(capacityModal.detail?.effective)}
{capacityModal.detail?.effective.hasUserConfig ? '(已单独设置)' : '(未单独设置)'}
</Typography.Text>
<Typography.Text>
{formatBytes(capacityModal.detail?.effective.usedBytes)}
{formatBytes(capacityModal.detail?.effective.totalBytes)}
{formatBytes(capacityModal.detail?.effective.availableBytes)}
</Typography.Text>
{capacityModal.detail?.effective.enabled && (
<Progress
percent={Math.min(Number(capacityModal.detail.effective.usagePercent || 0), 100)}
status={capacityModal.detail.effective.exceeded ? 'exception' : 'active'}
/>
)}
</Space>
</Card>
<Form form={capacityForm} layout="vertical">
<Form.Item
name="enabled"
label="启用个人容量限制"
valuePropName="checked"
extra="保存后会生成用户个人配置,优先级高于全局;关闭并保存表示该用户个人明确不限制,不再走全局。"
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 16 }}>
<Form.Item
name="limitValue"
label="容量数值"
rules={[{ required: true, message: '请输入容量数值' }]}
extra="最小为1,不能为负数,最多支持3位小数。"
>
<InputNumber min={1} precision={3} style={{ width: '100%' }} size="large" placeholder="例如 10.500" />
</Form.Item>
<Form.Item
name="limitUnit"
label="容量单位"
rules={[{ required: true, message: '请选择容量单位' }]}
extra="MB / GB / TB 固定枚举"
>
<Select size="large" options={capacityUnitOptions} />
</Form.Item>
</div>
</Form>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
<Popconfirm
title="确定恢复为全局容量配置?"
onConfirm={handleRestoreGlobalCapacity}
disabled={!capacityModal.detail?.hasUserConfig}
>
<Button disabled={!capacityModal.detail?.hasUserConfig} loading={capacitySaving}>
</Button>
</Popconfirm>
</div>
</Modal>
<Modal <Modal
title={<Space><UserOutlined /></Space>} title={<Space><UserOutlined /></Space>}
open={createModal} open={createModal}
@@ -476,7 +683,6 @@ const AdminUsers: React.FC = () => {
</Form> </Form>
</Modal> </Modal>
{/* Menu Permission Modal */}
<Modal <Modal
title={<Space><MenuOutlined /> - {menuModal.user?.username} ({menuModal.user?.userType === 'admin' ? '后台菜单' : '前台菜单'})</Space>} title={<Space><MenuOutlined /> - {menuModal.user?.username} ({menuModal.user?.userType === 'admin' ? '后台菜单' : '前台菜单'})</Space>}
open={menuModal.open} open={menuModal.open}
@@ -492,14 +698,12 @@ const AdminUsers: React.FC = () => {
<div style={{ padding: '12px 16px', background: '#f8fafc', borderRadius: 8, maxHeight: 400, overflow: 'auto' }}> <div style={{ padding: '12px 16px', background: '#f8fafc', borderRadius: 8, maxHeight: 400, overflow: 'auto' }}>
<Checkbox.Group value={checkedMenus} onChange={(vals) => setCheckedMenus(vals as string[])}> <Checkbox.Group value={checkedMenus} onChange={(vals) => setCheckedMenus(vals as string[])}>
<Space direction="vertical" size={8} style={{ width: '100%' }}> <Space direction="vertical" size={8} style={{ width: '100%' }}>
{/* Top-level pages */}
{topLevelPages.map((m: any) => ( {topLevelPages.map((m: any) => (
<Checkbox key={m.path} value={m.path} style={{ width: '100%' }}> <Checkbox key={m.path} value={m.path} style={{ width: '100%' }}>
{m.label} {m.label}
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{m.path}</Typography.Text> <Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{m.path}</Typography.Text>
</Checkbox> </Checkbox>
))} ))}
{/* Groups with their children */}
{menuGroups.map((g: any) => { {menuGroups.map((g: any) => {
const children = childMap[g.id] || []; const children = childMap[g.id] || [];
if (children.length === 0) return null; if (children.length === 0) return null;
@@ -524,7 +728,6 @@ const AdminUsers: React.FC = () => {
</div> </div>
</Modal> </Modal>
{/* Reset Password Modal */}
<Modal <Modal
title={<Space><LockOutlined /> - {resetPwdModal.user?.username}</Space>} title={<Space><LockOutlined /> - {resetPwdModal.user?.username}</Space>}
open={resetPwdModal.open} open={resetPwdModal.open}
+39
View File
@@ -7,6 +7,7 @@ export interface User {
isAdmin: boolean; isAdmin: boolean;
userType: string; userType: string;
allowedMenus?: string[] | null; allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null;
} }
export interface CreditRecord { export interface CreditRecord {
@@ -93,6 +94,43 @@ export interface LoginParams {
password: string; password: string;
} }
export type ResourceCapacityUnit = 'MB' | 'GB' | 'TB';
export type ResourceCapacitySource = 'user' | 'global' | 'disabled';
export interface ResourceCapacityUsage {
enabled: boolean;
source: ResourceCapacitySource;
hasUserConfig: boolean;
usedBytes: number;
availableBytes: number | null;
totalBytes: number | null;
usagePercent: number | null;
exceeded: boolean;
limitValue: string | null;
limitUnit: ResourceCapacityUnit | null;
}
export interface ResourceCapacityConfigOut {
enabled: boolean;
limitValue: string;
limitUnit: ResourceCapacityUnit;
limitBytes: number;
}
export interface ResourceCapacityConfigPayload {
enabled: boolean;
limitValue?: string | number | null;
limitUnit?: ResourceCapacityUnit | null;
}
export interface AdminUserResourceCapacityOut {
hasUserConfig: boolean;
userConfig: ResourceCapacityConfigOut | null;
globalConfig: ResourceCapacityConfigOut;
effective: ResourceCapacityUsage;
}
// ── Admin Types ────────────────────────────────────── // ── Admin Types ──────────────────────────────────────
export interface AdminUser { export interface AdminUser {
@@ -108,6 +146,7 @@ export interface AdminUser {
createdAt: string; createdAt: string;
lastLoginAt?: string; lastLoginAt?: string;
allowedMenus?: string[] | null; allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null;
} }
export interface AdminStats { export interface AdminStats {
+1 -1
View File
@@ -1,7 +1,7 @@
# App # App
APP_NAME=VideoGen API APP_NAME=VideoGen API
APP_VERSION=1.0.0 APP_VERSION=1.0.0
DEBUG=true DEBUG=false
SECRET_KEY=local-dev-secret-key-not-for-production SECRET_KEY=local-dev-secret-key-not-for-production
# Database (PostgreSQL) # Database (PostgreSQL)
@@ -0,0 +1,43 @@
"""add user resource capacity config
Revision ID: 78fb32c26a6e
Revises: n123456789ab
Create Date: 2026-06-29 13:05:45.888522
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '78fb32c26a6e'
down_revision: Union[str, None] = 'n123456789ab'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user_resource_capacity_configs',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False, comment='用户ID'),
sa.Column('enabled', sa.Boolean(), server_default='false', nullable=False, comment='是否启用该用户个人容量限制'),
sa.Column('limit_value', sa.Numeric(precision=18, scale=3), server_default='1', nullable=False, comment='容量数值,最小1,最多3位小数'),
sa.Column('limit_unit', sa.String(length=8), server_default='GB', nullable=False, comment='容量单位:MB/GB/TB'),
sa.Column('limit_bytes', sa.BigInteger(), server_default='1073741824', nullable=False, comment='换算后的容量字节数'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', name='uq_user_resource_capacity_configs_user')
)
op.create_index(op.f('ix_user_resource_capacity_configs_user_id'), 'user_resource_capacity_configs', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_user_resource_capacity_configs_user_id'), table_name='user_resource_capacity_configs')
op.drop_table('user_resource_capacity_configs')
# ### end Alembic commands ###
@@ -1,8 +1,8 @@
"""add notification indexes for query optimization """add core business indexes and contact submit_date
Revision ID: n123456789ab Revision ID: n123456789ab
Revises: c72a6f69e641 Revises: c72a6f69e641
Create Date: 2026-06-29 12:00:00.000000 Create Date: 2026-06-29 14:00:00.000000
""" """
from typing import Sequence, Union from typing import Sequence, Union
@@ -10,7 +10,6 @@ from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "n123456789ab" revision: str = "n123456789ab"
down_revision: Union[str, None] = "c72a6f69e641" down_revision: Union[str, None] = "c72a6f69e641"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
@@ -18,7 +17,120 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# Composite index for personal notifications: (user_id, is_read, created_at) # 1. Add submit_date column to contact_requests
op.add_column(
"contact_requests",
sa.Column("submit_date", sa.String(10), nullable=True)
)
# Backfill submit_date from created_at for existing records
op.execute(
"UPDATE contact_requests SET submit_date = TO_CHAR(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') WHERE submit_date IS NULL"
)
# Set NOT NULL after backfill
op.alter_column("contact_requests", "submit_date", nullable=False)
# Create index on submit_date
op.create_index(
"ix_contact_requests_submit_date",
"contact_requests",
["submit_date"],
unique=False,
)
# 2. Contact requests unique constraint: (user_id, submit_date)
op.create_unique_constraint(
"uq_contact_user_date",
"contact_requests",
["user_id", "submit_date"],
)
# 3. Contact requests composite indexes
op.create_index(
"idx_contact_handled_created",
"contact_requests",
["is_handled", "created_at"],
unique=False,
)
op.create_index(
"idx_contact_user_date_created",
"contact_requests",
["user_id", "submit_date", "created_at"],
unique=False,
)
# 4. Generation records composite indexes
op.create_index(
"idx_genrec_user_status_created",
"generation_records",
["user_id", "status", "created_at"],
unique=False,
)
op.create_index(
"idx_genrec_project_status",
"generation_records",
["project_id", "status"],
unique=False,
)
# 5. Payment orders composite indexes
op.create_index(
"idx_payorder_user_status_created",
"payment_orders",
["user_id", "status", "created_at"],
unique=False,
)
op.create_index(
"idx_payorder_status_created",
"payment_orders",
["status", "created_at"],
unique=False,
)
# 6. Upload task composite indexes
op.create_index(
"idx_upload_user_status_created",
"upload_task",
["user_id", "status", "created_at"],
unique=False,
)
op.create_index(
"idx_upload_oauth_status",
"upload_task",
["oauth_id", "status"],
unique=False,
)
# 7. Menu configs indexes
op.create_index(
op.f("ix_menu_configs_parent_id"),
"menu_configs",
["parent_id"],
unique=False,
)
op.create_index(
"idx_menu_target_active_sort",
"menu_configs",
["menu_target", "is_active", "sort_order"],
unique=False,
)
# 8. Operation logs composite indexes
op.create_index(
"idx_oplog_user_created",
"operation_logs",
["user_id", "created_at"],
unique=False,
)
op.create_index(
"idx_oplog_created",
"operation_logs",
["created_at"],
unique=False,
)
# 9. Notification indexes (from previous optimization)
op.create_index( op.create_index(
"idx_notif_user_isread_created", "idx_notif_user_isread_created",
"notifications", "notifications",
@@ -26,16 +138,13 @@ def upgrade() -> None:
unique=False, unique=False,
) )
# Composite index for notification_reads lookups # 10. Notification reads indexes
op.create_index( op.create_index(
"idx_notif_read_user_notif", "idx_notif_read_user_notif",
"notification_reads", "notification_reads",
["user_id", "notification_id"], ["user_id", "notification_id"],
unique=False, unique=False,
) )
# Unique constraint to prevent duplicate read records
# Check if constraint already exists first (in case of partial migration)
op.create_unique_constraint( op.create_unique_constraint(
"uq_notif_read_user", "uq_notif_read_user",
"notification_reads", "notification_reads",
@@ -47,3 +156,18 @@ def downgrade() -> None:
op.drop_constraint("uq_notif_read_user", "notification_reads", type_="unique") op.drop_constraint("uq_notif_read_user", "notification_reads", type_="unique")
op.drop_index("idx_notif_read_user_notif", table_name="notification_reads") op.drop_index("idx_notif_read_user_notif", table_name="notification_reads")
op.drop_index("idx_notif_user_isread_created", table_name="notifications") op.drop_index("idx_notif_user_isread_created", table_name="notifications")
op.drop_index("idx_oplog_created", table_name="operation_logs")
op.drop_index("idx_oplog_user_created", table_name="operation_logs")
op.drop_index("idx_menu_target_active_sort", table_name="menu_configs")
op.drop_index(op.f("ix_menu_configs_parent_id"), table_name="menu_configs")
op.drop_index("idx_upload_oauth_status", table_name="upload_task")
op.drop_index("idx_upload_user_status_created", table_name="upload_task")
op.drop_index("idx_payorder_status_created", table_name="payment_orders")
op.drop_index("idx_payorder_user_status_created", table_name="payment_orders")
op.drop_index("idx_genrec_project_status", table_name="generation_records")
op.drop_index("idx_genrec_user_status_created", table_name="generation_records")
op.drop_index("idx_contact_user_date_created", table_name="contact_requests")
op.drop_index("idx_contact_handled_created", table_name="contact_requests")
op.drop_constraint("uq_contact_user_date", "contact_requests", type_="unique")
op.drop_index("ix_contact_requests_submit_date", table_name="contact_requests")
op.drop_column("contact_requests", "submit_date")
+2
View File
@@ -1,6 +1,8 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
from app.api.admin.resource_capacity import router as resource_capacity_router
router = APIRouter() router = APIRouter()
router.include_router(video_prompt_schema_config_router) router.include_router(video_prompt_schema_config_router)
router.include_router(resource_capacity_router)
@@ -0,0 +1,175 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Path
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.enums.resource_capacity import ResourceCapacityOperationEnum
from app.models.user import User
from app.schemas.resource_capacity import (
AdminUserResourceCapacityOut,
ResourceCapacityConfigOut,
ResourceCapacityConfigUpdate,
)
from app.services.operation_log import log_operation
from app.services.resource_capacity_service import (
build_global_resource_capacity_operation_detail,
build_user_resource_capacity_operation_detail,
delete_user_resource_capacity_config,
get_admin_user_resource_capacity,
get_global_resource_capacity_config,
save_global_resource_capacity_config,
save_user_resource_capacity_config,
)
router = APIRouter(prefix="/admin", tags=["admin-resource-capacity"])
@router.get(
"/resource-capacity/global",
response_model=ResourceCapacityConfigOut,
summary="获取全局资源空间容量配置",
description=(
"获取管理后台全局生成资源空间容量管控配置。"
"配置最终存储在 system_configs 表,key=resource_capacity_limit_config。"
"enabled=false 表示全局容量管控关闭;enabled=true 表示按 limit_value + limit_unit 换算出的 limit_bytes 进行限制。"
"单位枚举:MB=1048576字节,GB=1073741824字节,TB=1099511627776字节。"
),
)
async def get_global_resource_capacity(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
_ = admin
return await get_global_resource_capacity_config(db)
@router.put(
"/resource-capacity/global",
response_model=ResourceCapacityConfigOut,
summary="保存全局资源空间容量配置",
description=(
"保存管理后台全局生成资源空间容量管控配置。"
"enabled=true 时,limit_value 和 limit_unit 必填。"
"limit_value 最小为1,不能为负数,最多支持3位小数。"
"limit_unit 枚举明细:MB=1048576字节,GB=1073741824字节,TB=1099511627776字节。"
"limit_bytes 不允许前端传入,由后端统一换算后保存。"
"本接口会写入后台操作日志,action=更新全局资源容量配置,detail记录修改前后配置快照。"
),
)
async def update_global_resource_capacity(
req: ResourceCapacityConfigUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
before = await get_global_resource_capacity_config(db)
result = await save_global_resource_capacity_config(db, req)
await log_operation(
db,
admin.id,
admin.username,
ResourceCapacityOperationEnum.UPDATE_GLOBAL_CONFIG.value,
"PUT",
"/admin/resource-capacity/global",
detail=build_global_resource_capacity_operation_detail(before, result),
)
await db.commit()
return result
@router.get(
"/users/{user_id}/resource-capacity",
response_model=AdminUserResourceCapacityOut,
summary="获取指定用户资源空间容量配置",
description=(
"获取指定用户的个人容量配置、全局容量配置以及最终生效容量数据。"
"优先级:用户个人配置存在时优先使用;用户个人配置不存在时使用全局配置。"
"注意:用户个人配置存在且 enabled=false,表示该用户个人明确关闭容量限制,不再回落到全局配置。"
),
)
async def get_user_resource_capacity(
user_id: str = Path(..., description="用户ID,用于查询该用户个人容量配置和最终生效容量数据"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
_ = admin
return await get_admin_user_resource_capacity(db, user_id)
@router.put(
"/users/{user_id}/resource-capacity",
response_model=AdminUserResourceCapacityOut,
summary="保存指定用户个人资源空间容量配置",
description=(
"新增或更新指定用户个人资源空间容量配置。"
"保存后该用户配置优先级高于全局配置。"
"enabled=true 表示启用该用户个人容量限制;enabled=false 表示该用户个人明确关闭限制,不再回落到全局。"
"limit_value 最小为1,不能为负数,最多3位小数。"
"limit_unit 枚举明细:MB=1048576字节,GB=1073741824字节,TB=1099511627776字节。"
"本接口会写入后台操作日志:首次创建 action=新增用户资源容量配置;已有配置更新 action=更新用户资源容量配置;detail记录修改前后配置快照。"
),
)
async def update_user_resource_capacity(
req: ResourceCapacityConfigUpdate,
user_id: str = Path(..., description="用户ID,用于新增或更新该用户个人容量配置"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
before = await get_admin_user_resource_capacity(db, user_id)
result = await save_user_resource_capacity_config(db, user_id, req)
is_create = not before.has_user_config
await log_operation(
db,
admin.id,
admin.username,
(
ResourceCapacityOperationEnum.CREATE_USER_CONFIG.value
if is_create
else ResourceCapacityOperationEnum.UPDATE_USER_CONFIG.value
),
"PUT",
f"/admin/users/{user_id}/resource-capacity",
detail=build_user_resource_capacity_operation_detail(
target_user_id=user_id,
operation="create" if is_create else "update",
before=before,
after=result,
),
)
await db.commit()
return result
@router.delete(
"/users/{user_id}/resource-capacity",
response_model=AdminUserResourceCapacityOut,
summary="删除指定用户个人资源空间容量配置",
description=(
"删除指定用户个人容量配置。删除后该用户不再有个人覆盖配置,后续容量限制重新回落到全局配置。"
"本接口会写入后台操作日志,action=删除用户资源容量配置,detail记录删除前个人配置、删除后最终生效配置。"
),
)
async def remove_user_resource_capacity(
user_id: str = Path(..., description="用户ID,用于删除该用户个人容量配置并恢复走全局配置"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
before = await get_admin_user_resource_capacity(db, user_id)
result = await delete_user_resource_capacity_config(db, user_id)
await log_operation(
db,
admin.id,
admin.username,
ResourceCapacityOperationEnum.DELETE_USER_CONFIG.value,
"DELETE",
f"/admin/users/{user_id}/resource-capacity",
detail=build_user_resource_capacity_operation_detail(
target_user_id=user_id,
operation="delete",
before=before,
after=result,
remark="删除用户个人容量配置,用户恢复使用全局资源容量配置。",
),
)
await db.commit()
return result
+15 -2
View File
@@ -49,6 +49,7 @@ from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation from app.services.operation_log import log_operation
from app.services.resource_signed_url_service import build_resource_signed_url from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import sync_pending_orders, process_refund from app.services.payment import sync_pending_orders, process_refund
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
from app.services.generation_billing_service import ( from app.services.generation_billing_service import (
OWNER_GENERATION_RECORD, OWNER_GENERATION_RECORD,
@@ -107,7 +108,16 @@ async def list_users(
total = (await db.execute(count_query)).scalar() or 0 total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size)) result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
items = result.scalars().all() items = result.scalars().all()
return {"items": [AdminUserOut.model_validate(u) for u in items], "total": total} capacity_map = await batch_get_user_resource_capacity_usage(db, [u.id for u in items])
return {
"items": [
AdminUserOut.model_validate(u)
.model_copy(update={"resource_capacity": capacity_map.get(u.id)})
.model_dump(mode="json")
for u in items
],
"total": total,
}
@router.post("/users", response_model=AdminUserOut) @router.post("/users", response_model=AdminUserOut)
@@ -183,7 +193,10 @@ async def get_user(
if not user: if not user:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
user.credits = round(user.credits, 2) user.credits = round(user.credits, 2)
return user resource_capacity = await get_user_resource_capacity_usage(db, user.id)
return AdminUserOut.model_validate(user).model_copy(
update={"resource_capacity": resource_capacity}
)
@router.post("/users/{user_id}/credits") @router.post("/users/{user_id}/credits")
+14 -6
View File
@@ -30,6 +30,7 @@ from app.services.auth import (
verify_password, verify_password,
) )
from app.services.sms import verify_sms_code from app.services.sms import verify_sms_code
from app.services.resource_capacity_service import get_user_resource_capacity_usage
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -248,9 +249,16 @@ async def logout(current_user: User = Depends(get_current_user_allow_password_pe
@router.get("/me", response_model=UserOut) @router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user_allow_password_pending)): async def get_me(
current_user: User = Depends(get_current_user_allow_password_pending),
db: AsyncSession = Depends(get_db),
):
current_user.username = "用户"+current_user.username[-4:] if current_user.username == current_user.phone else current_user.username
current_user.credits = round(current_user.credits, 2) current_user.credits = round(current_user.credits, 2)
return current_user resource_capacity = await get_user_resource_capacity_usage(db, current_user.id)
return UserOut.model_validate(current_user).model_copy(
update={"resource_capacity": resource_capacity}
)
@router.post( @router.post(
@@ -301,10 +309,10 @@ async def change_password(
@router.get("/site-info") @router.get("/site-info")
async def get_site_info(db: AsyncSession = Depends(get_db)): async def get_site_info(db: AsyncSession = Depends(get_db)):
"""Public endpoint returning site name and logo.""" """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_url", "privacy_policy_url" "site_name", "site_logo", "user_agreement_privacy_url", "site_copyright"
])) ]))
) )
configs = result.scalars().all() configs = result.scalars().all()
@@ -324,8 +332,8 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
return { return {
"site_name": info.get("site_name", "VideoGen.AI"), "site_name": info.get("site_name", "VideoGen.AI"),
"site_logo": to_full_url(info.get("site_logo")), "site_logo": to_full_url(info.get("site_logo")),
"user_agreement_url": to_full_url(info.get("user_agreement_url")), "user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")),
"privacy_policy_url": to_full_url(info.get("privacy_policy_url")), "site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"),
} }
+42 -27
View File
@@ -2,6 +2,7 @@ from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user from app.dependencies import get_db, get_current_user
@@ -19,37 +20,48 @@ async def create_contact_request(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
today_end = today_start + timedelta(days=1)
count = await db.execute( async with db.begin_nested():
select(func.count(ContactRequest.id)) user_result = await db.execute(
.where(ContactRequest.user_id == user.id) select(User).where(User.id == user.id).with_for_update().limit(1)
.where(ContactRequest.created_at >= today_start) )
.where(ContactRequest.created_at < today_end) locked_user = user_result.scalar_one_or_none()
) if not locked_user:
daily_count = count.scalar_one() raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
existing = await db.execute(
select(ContactRequest.id)
.where(ContactRequest.user_id == user.id)
.where(ContactRequest.submit_date == today_str)
.limit(1)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="每个账号每天只能提交一次联系我们"
)
if daily_count >= 1: try:
contact_request = ContactRequest(
id=generate_id(),
user_id=user.id,
phone=request.phone,
company_name=request.company_name,
industry=request.industry,
name=request.name,
message=request.message,
submit_date=today_str,
)
db.add(contact_request)
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="每个账号每天只能提交一次联系我们" detail="每个账号每天只能提交一次联系我们"
) )
contact_request = ContactRequest(
id=generate_id(),
user_id=user.id,
phone=request.phone,
company_name=request.company_name,
industry=request.industry,
name=request.name,
message=request.message,
)
db.add(contact_request)
await db.commit()
await db.refresh(contact_request)
return {"message": "提交成功,我们会尽快与您联系"} return {"message": "提交成功,我们会尽快与您联系"}
@@ -64,17 +76,20 @@ async def get_contact_requests(
if not user.is_admin: if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
query = select(ContactRequest).order_by(ContactRequest.created_at.desc()) query = select(ContactRequest)
count_query = select(func.count(ContactRequest.id))
if is_handled is not None: if is_handled is not None:
query = query.where(ContactRequest.is_handled == is_handled) query = query.where(ContactRequest.is_handled == is_handled)
count_query = count_query.where(ContactRequest.is_handled == is_handled)
query = query.order_by(ContactRequest.created_at.desc())
offset = (page - 1) * page_size offset = (page - 1) * page_size
result = await db.execute(query.offset(offset).limit(page_size)) result = await db.execute(query.offset(offset).limit(page_size))
items = result.scalars().all() items = result.scalars().all()
count_result = await db.execute(select(func.count(ContactRequest.id))) total = (await db.execute(count_query)).scalar_one()
total = count_result.scalar_one()
return {"items": items, "total": total} return {"items": items, "total": total}
+5
View File
@@ -34,6 +34,7 @@ from app.services.resource_accounting_service import (
safe_file_size, safe_file_size,
) )
from app.services.resource_signed_url_service import build_resource_signed_url from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.generation_billing_service import ( from app.services.generation_billing_service import (
CHARGE_TEXT_PROMPT, CHARGE_TEXT_PROMPT,
OWNER_GENERATION_RECORD, OWNER_GENERATION_RECORD,
@@ -398,6 +399,8 @@ async def generate(
if record.status not in ("prompt_optimized", "failed"): if record.status not in ("prompt_optimized", "failed"):
raise InvalidStatusError("当前状态不允许生成") raise InvalidStatusError("当前状态不允许生成")
await assert_user_resource_capacity_available(db, current_user.id)
attempt_no = await get_next_credit_attempt_no( attempt_no = await get_next_credit_attempt_no(
db, db,
owner_type=OWNER_GENERATION_RECORD, owner_type=OWNER_GENERATION_RECORD,
@@ -522,6 +525,8 @@ async def retry_generation(
if record.status != "failed": if record.status != "failed":
raise InvalidStatusError("只有失败的记录可以重试") raise InvalidStatusError("只有失败的记录可以重试")
await assert_user_resource_capacity_available(db, current_user.id)
attempt_no = await get_next_credit_attempt_no( attempt_no = await get_next_credit_attempt_no(
db, db,
owner_type=OWNER_GENERATION_RECORD, owner_type=OWNER_GENERATION_RECORD,
@@ -33,6 +33,7 @@ from app.services.generation_billing_service import (
) )
from app.services.generation_log_service import log_task_event from app.services.generation_log_service import log_task_event
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
router = APIRouter( router = APIRouter(
@@ -566,6 +567,8 @@ async def retry_task(
if task.status != "failed": if task.status != "failed":
raise HTTPException(status_code=400, detail="只有失败任务可以重试") raise HTTPException(status_code=400, detail="只有失败任务可以重试")
await assert_user_resource_capacity_available(db, current_user.id)
attempt_no = await get_next_credit_attempt_no( attempt_no = await get_next_credit_attempt_no(
db, db,
owner_type=OWNER_CHAT_GENERATION_TASK, owner_type=OWNER_CHAT_GENERATION_TASK,
+24 -22
View File
@@ -127,30 +127,32 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
signature = headers.get("wechatpay-signature", "") signature = headers.get("wechatpay-signature", "")
serial_no = headers.get("wechatpay-serial", "") serial_no = headers.get("wechatpay-serial", "")
# 安全要求:非mock模式下必须验证签名,配置缺失直接拒绝
if not public_key:
logger.error("WeChat platform public key not configured, cannot verify callback signature")
return {"code": "FAIL", "message": "Platform public key not configured"}
if not serial_no:
logger.error("Wechatpay-Serial header missing in callback")
return {"code": "FAIL", "message": "Missing Wechatpay-Serial header"}
if not timestamp or not nonce or not signature:
logger.error("WeChat callback missing required signature headers")
return {"code": "FAIL", "message": "Missing signature headers"}
# 验证签名:使用平台公钥验证 # 验证签名:使用平台公钥验证
if public_key and serial_no: try:
try: is_verified = rsa_verify(
# 构造签名串:timestamp + "\n" + nonce + "\n" + body + "\n" timestamp=timestamp,
# 符合微信支付官方文档规范:https://pay.weixin.qq.com/doc/v3/merchant/4013053249 nonce=nonce,
is_verified = rsa_verify( body=body_str,
timestamp=timestamp, signature=signature,
nonce=nonce, public_key=load_public_key(public_key)
body=body_str, )
signature=signature, if not is_verified:
public_key=load_public_key(public_key) logger.warning(f"WeChat callback signature verification failed: serial={serial_no}")
) return {"code": "FAIL", "message": "Signature verification failed"}
if not is_verified: except Exception as e:
logger.warning(f"WeChat callback signature verification failed: serial={serial_no}") logger.warning(f"WeChat signature verification error: {e}, serial={serial_no}")
raise HTTPException(status_code=400, detail="签名验证失败") return {"code": "FAIL", "message": "Signature verification error"}
except Exception as e:
logger.warning(f"WeChat signature verification error: {e}, serial={serial_no}")
raise HTTPException(status_code=400, detail="签名验证失败")
else:
if not public_key:
logger.warning("WeChat platform public key not configured, skipping signature verification")
if not serial_no:
logger.warning("Wechatpay-Serial header missing, skipping signature verification")
# 解密回调数据:使用 API v3 key # 解密回调数据:使用 API v3 key
# 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382 # 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
+1
View File
@@ -10,3 +10,4 @@ from app.enums.generation_task import *
from app.enums.generation_status import * from app.enums.generation_status import *
from app.enums.sms import * from app.enums.sms import *
from app.enums.notification import * from app.enums.notification import *
from app.enums.resource_capacity import *
@@ -0,0 +1,68 @@
from __future__ import annotations
from enum import Enum
class ResourceCapacityUnitEnum(str, Enum):
"""生成资源容量单位。"""
MB = "MB"
GB = "GB"
TB = "TB"
@property
def bytes_multiplier(self) -> int:
return RESOURCE_CAPACITY_UNIT_BYTES[self]
@property
def label(self) -> str:
return RESOURCE_CAPACITY_UNIT_LABELS[self]
MB_BYTES = 1048576
GB_BYTES = 1073741824
TB_BYTES = 1099511627776
RESOURCE_CAPACITY_UNIT_BYTES: dict[ResourceCapacityUnitEnum, int] = {
ResourceCapacityUnitEnum.MB: MB_BYTES,
ResourceCapacityUnitEnum.GB: GB_BYTES,
ResourceCapacityUnitEnum.TB: TB_BYTES,
}
RESOURCE_CAPACITY_UNIT_LABELS: dict[ResourceCapacityUnitEnum, str] = {
ResourceCapacityUnitEnum.MB: "MB1048576 字节)",
ResourceCapacityUnitEnum.GB: "GB1073741824 字节)",
ResourceCapacityUnitEnum.TB: "TB1099511627776 字节)",
}
class ResourceCapacitySourceEnum(str, Enum):
"""最终容量配置来源。"""
USER = "user"
GLOBAL = "global"
DISABLED = "disabled"
class ResourceCapacityConfigKeyEnum(str, Enum):
"""SystemConfig 中使用的固定配置 key。"""
RESOURCE_CAPACITY_LIMIT_CONFIG = "resource_capacity_limit_config"
class ResourceCapacityErrorCodeEnum(str, Enum):
"""资源容量管控错误码。"""
RESOURCE_CAPACITY_EXCEEDED = "RESOURCE_CAPACITY_EXCEEDED"
class ResourceCapacityOperationEnum(str, Enum):
"""管理后台资源容量配置操作日志动作。"""
UPDATE_GLOBAL_CONFIG = "更新全局资源容量配置"
CREATE_USER_CONFIG = "新增用户资源容量配置"
UPDATE_USER_CONFIG = "更新用户资源容量配置"
DELETE_USER_CONFIG = "删除用户资源容量配置"
RESOURCE_CAPACITY_EXCEEDED_MESSAGE = "您个人的空间容量已超额,请删除素材资源释放容量或购买更高额度的容量套餐"
+9 -4
View File
@@ -164,12 +164,12 @@ async def _seed_data():
configs = [ configs = [
("site_name", "民众智创", "网站名称"), ("site_name", "民众智创", "网站名称"),
("site_logo", "", "网站Logo URL"), ("site_logo", "", "网站Logo URL"),
("site_copyright", "© 2024 民众智创 版权所有", "网站底部版权信息"),
("seo_title", "民众智创 - AI视频生成平台", "SEO标题"), ("seo_title", "民众智创 - AI视频生成平台", "SEO标题"),
("seo_description", "专业的AI视频生成服务", "SEO描述"), ("seo_description", "专业的AI视频生成服务", "SEO描述"),
("seo_keywords", "AI视频,视频生成,人工智能", "SEO关键词"), ("seo_keywords", "AI视频,视频生成,人工智能", "SEO关键词"),
# Agreement configs # Agreement config
("user_agreement_url", "", "用户协议PDF"), ("user_agreement_privacy_url", "", "用户协议及隐私政策PDF"),
("privacy_policy_url", "", "隐私政策PDF"),
# Payment configs # Payment configs
("payment_wechat_enabled", "false", "微信支付启用"), ("payment_wechat_enabled", "false", "微信支付启用"),
("payment_wechat_mch_id", "", "微信商户号"), ("payment_wechat_mch_id", "", "微信商户号"),
@@ -188,12 +188,17 @@ async def _seed_data():
existing = await db.execute( existing = await db.execute(
select(SystemConfig).where(SystemConfig.key == key).limit(1) select(SystemConfig).where(SystemConfig.key == key).limit(1)
) )
if not existing.scalar_one_or_none(): existing_config = existing.scalar_one_or_none()
if not existing_config:
db.add( db.add(
SystemConfig( SystemConfig(
id=generate_id(), key=key, value=value, description=desc id=generate_id(), key=key, value=value, description=desc
) )
) )
elif not existing_config.description and desc:
# Update description if not set
existing_config.description = desc
db.add(existing_config)
# Seed video engine # Seed video engine
existing_engine = await db.execute( existing_engine = await db.execute(
+2
View File
@@ -21,6 +21,7 @@ from app.models.chat_provider_call_log import ChatProviderCallLog
from app.models.generated_resource import GeneratedResource from app.models.generated_resource import GeneratedResource
from app.models.user_resource_month_stat import UserResourceMonthStat from app.models.user_resource_month_stat import UserResourceMonthStat
from app.models.user_resource_total_stat import UserResourceTotalStat from app.models.user_resource_total_stat import UserResourceTotalStat
from app.models.user_resource_capacity_config import UserResourceCapacityConfig
from app.models.module_generation_project import ModuleGenerationProject from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_task_set import ShotReplicateTaskSet from app.models.shot_replicate_task_set import ShotReplicateTaskSet
@@ -38,6 +39,7 @@ __all__ = [
"MenuConfig", "RechargePackage", "OperationLog", "MenuConfig", "RechargePackage", "OperationLog",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog",
"GeneratedResource", "UserResourceMonthStat", "UserResourceTotalStat", "GeneratedResource", "UserResourceMonthStat", "UserResourceTotalStat",
"UserResourceCapacityConfig",
"ModuleGenerationProject", "ModuleGenerationStep", "ModuleGenerationProject", "ModuleGenerationStep",
"ShotReplicateTaskSet", "ShotReplicateSegment", "ShotReplicateTaskSet", "ShotReplicateSegment",
"UserOAuth", "UserOAuthAccount", "UserOAuthApp", "UserOAuth", "UserOAuthAccount", "UserOAuthApp",
+9 -2
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Boolean, ForeignKey, String, Text from sqlalchemy import Boolean, ForeignKey, String, Text, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -14,4 +14,11 @@ class ContactRequest(Base, TimestampMixin):
industry: Mapped[str] = mapped_column(String(64)) industry: Mapped[str] = mapped_column(String(64))
name: Mapped[str] = mapped_column(String(64)) name: Mapped[str] = mapped_column(String(64))
message: Mapped[str | None] = mapped_column(Text, nullable=True) message: Mapped[str | None] = mapped_column(Text, nullable=True)
is_handled: Mapped[bool] = mapped_column(Boolean, default=False) is_handled: Mapped[bool] = mapped_column(Boolean, default=False)
submit_date: Mapped[str] = mapped_column(String(10), index=True)
__table_args__ = (
UniqueConstraint('user_id', 'submit_date', name='uq_contact_user_date'),
Index('idx_contact_handled_created', 'is_handled', 'created_at'),
Index('idx_contact_user_date_created', 'user_id', 'submit_date', 'created_at'),
)
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, Float from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, Float, Index
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin from app.models.base import Base, TimestampMixin, SoftDeleteMixin
@@ -45,3 +45,8 @@ class GenerationRecord(Base, TimestampMixin, SoftDeleteMixin):
) )
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
__table_args__ = (
Index('idx_genrec_user_status_created', 'user_id', 'status', 'created_at'),
Index('idx_genrec_project_status', 'project_id', 'status'),
)
+9 -5
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Boolean, Integer, String, ForeignKey from sqlalchemy import Boolean, Integer, String, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -13,7 +13,11 @@ class MenuConfig(Base, TimestampMixin):
icon: Mapped[str] = mapped_column(String(64), default="") icon: Mapped[str] = mapped_column(String(64), default="")
sort_order: Mapped[int] = mapped_column(Integer, default=0) sort_order: Mapped[int] = mapped_column(Integer, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
parent_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("menu_configs.id"), nullable=True) parent_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("menu_configs.id"), nullable=True, index=True)
menu_type: Mapped[str] = mapped_column(String(16), default="page") # page / group menu_type: Mapped[str] = mapped_column(String(16), default="page")
menu_target: Mapped[str] = mapped_column(String(16), default="frontend") # frontend / admin / both menu_target: Mapped[str] = mapped_column(String(16), default="frontend")
is_default: Mapped[bool] = mapped_column(Boolean, default=False) # default show for new users is_default: Mapped[bool] = mapped_column(Boolean, default=False)
__table_args__ = (
Index('idx_menu_target_active_sort', 'menu_target', 'is_active', 'sort_order'),
)
+9 -4
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Integer, String, Text from sqlalchemy import Integer, String, Text, Index
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -10,8 +10,13 @@ class OperationLog(Base, TimestampMixin):
id: Mapped[str] = mapped_column(String(32), primary_key=True) id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), index=True) user_id: Mapped[str] = mapped_column(String(32), index=True)
username: Mapped[str] = mapped_column(String(64)) username: Mapped[str] = mapped_column(String(64))
action: Mapped[str] = mapped_column(String(128)) # e.g. "创建用户", "修改菜单" action: Mapped[str] = mapped_column(String(128))
method: Mapped[str] = mapped_column(String(10)) # POST/PUT/DELETE method: Mapped[str] = mapped_column(String(10))
path: Mapped[str] = mapped_column(String(256)) path: Mapped[str] = mapped_column(String(256))
detail: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON detail detail: Mapped[str | None] = mapped_column(Text, nullable=True)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True) ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
__table_args__ = (
Index('idx_oplog_user_created', 'user_id', 'created_at'),
Index('idx_oplog_created', 'created_at'),
)
+6 -2
View File
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Index
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -22,9 +22,13 @@ class PaymentOrder(Base, TimestampMixin):
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True) trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
# Refund fields
refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True) refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column( refunded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
refund_amount: Mapped[float | None] = mapped_column(Float, nullable=True) refund_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
__table_args__ = (
Index('idx_payorder_user_status_created', 'user_id', 'status', 'created_at'),
Index('idx_payorder_status_created', 'status', 'created_at'),
)
+6 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import String, Text, Integer from sqlalchemy import String, Text, Integer, Index
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin from app.models.base import Base, TimestampMixin, SoftDeleteMixin
@@ -31,3 +31,8 @@ class UploadTask(Base, TimestampMixin, SoftDeleteMixin):
other_info: Mapped[str | None] = mapped_column( other_info: Mapped[str | None] = mapped_column(
String(500), nullable=True, comment="其他信息" String(500), nullable=True, comment="其他信息"
) )
__table_args__ = (
Index('idx_upload_user_status_created', 'user_id', 'status', 'created_at'),
Index('idx_upload_oauth_status', 'oauth_id', 'status'),
)
@@ -0,0 +1,53 @@
from __future__ import annotations
from sqlalchemy import BigInteger, Boolean, ForeignKey, Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.resource_capacity import ResourceCapacityUnitEnum
from app.models.base import Base, TimestampMixin
class UserResourceCapacityConfig(Base, TimestampMixin):
"""用户个人生成资源容量配置。存在记录即代表用户配置已单独设置。"""
__tablename__ = "user_resource_capacity_configs"
__table_args__ = (
UniqueConstraint("user_id", name="uq_user_resource_capacity_configs_user"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32),
ForeignKey("users.id", ondelete="CASCADE"),
index=True,
nullable=False,
comment="用户ID",
)
enabled: Mapped[bool] = mapped_column(
Boolean,
default=False,
server_default="false",
nullable=False,
comment="是否启用该用户个人容量限制",
)
limit_value: Mapped[float] = mapped_column(
Numeric(18, 3),
default=1,
server_default="1",
nullable=False,
comment="容量数值,最小1,最多3位小数",
)
limit_unit: Mapped[str] = mapped_column(
String(8),
default=ResourceCapacityUnitEnum.GB.value,
server_default=ResourceCapacityUnitEnum.GB.value,
nullable=False,
comment="容量单位:MB/GB/TB",
)
limit_bytes: Mapped[int] = mapped_column(
BigInteger,
default=1024 * 1024 * 1024,
server_default=str(1024 * 1024 * 1024),
nullable=False,
comment="换算后的容量字节数",
)
+2
View File
@@ -1,6 +1,7 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime, NaiveDatetimeOptional from app.schemas.common import NaiveDatetime, NaiveDatetimeOptional
from app.schemas.resource_capacity import ResourceCapacityUsageOut
class CreditAdjustRequest(BaseModel): class CreditAdjustRequest(BaseModel):
@@ -54,6 +55,7 @@ class AdminUserOut(BaseModel):
created_at: NaiveDatetime created_at: NaiveDatetime
last_login_at: NaiveDatetimeOptional = None last_login_at: NaiveDatetimeOptional = None
allowed_menus: list | None = None allowed_menus: list | None = None
resource_capacity: ResourceCapacityUsageOut | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -0,0 +1,72 @@
from __future__ import annotations
from decimal import Decimal
from pydantic import BaseModel, Field, field_validator, model_validator
from app.enums.resource_capacity import ResourceCapacitySourceEnum, ResourceCapacityUnitEnum
class ResourceCapacityConfigUpdate(BaseModel):
enabled: bool = Field(
...,
description="是否开启容量管控。true=开启,false=关闭。全局配置关闭表示全局不限制;用户配置关闭表示该用户个人明确关闭限制,优先级高于全局。",
examples=[True],
)
limit_value: Decimal | None = Field(
None,
description="容量数值,最小1,不能为负数,最多3位小数。例如:1、10、10.5、10.500。enabled=true 时必填。",
examples=["10.500"],
)
limit_unit: ResourceCapacityUnitEnum | None = Field(
None,
description="容量单位枚举:MB=1024*1024字节,GB=1024*1024*1024字节,TB=1024*1024*1024*1024字节。enabled=true 时必填。",
examples=[ResourceCapacityUnitEnum.GB.value],
)
@field_validator("limit_value")
@classmethod
def validate_limit_value(cls, value: Decimal | None) -> Decimal | None:
if value is None:
return value
if value < Decimal("1"):
raise ValueError("容量数值不能小于1")
if value.as_tuple().exponent < -3:
raise ValueError("容量数值最多支持3位小数")
return value
@model_validator(mode="after")
def validate_enabled_payload(self) -> "ResourceCapacityConfigUpdate":
if self.enabled:
if self.limit_value is None:
raise ValueError("开启容量管控时必须填写容量数值")
if self.limit_unit is None:
raise ValueError("开启容量管控时必须选择容量单位:MB、GB、TB")
return self
class ResourceCapacityConfigOut(BaseModel):
enabled: bool = Field(..., description="是否开启容量管控")
limit_value: str = Field(..., description="容量数值字符串,最多3位小数")
limit_unit: ResourceCapacityUnitEnum = Field(..., description="容量单位枚举:MB、GB、TB")
limit_bytes: int = Field(..., ge=0, description="按单位换算后的字节数")
class ResourceCapacityUsageOut(BaseModel):
enabled: bool = Field(..., description="当前用户最终是否启用容量管控")
source: ResourceCapacitySourceEnum = Field(..., description="最终配置来源:user=用户个人配置,global=全局配置,disabled=容量管控未开启")
has_user_config: bool = Field(..., description="该用户是否存在个人容量配置记录。注意:存在且 enabled=false 表示用户个人明确关闭限制")
used_bytes: int = Field(..., ge=0, description="当前用户已用有效资源容量,来自 UserResourceTotalStat.active_size_bytes")
available_bytes: int | None = Field(None, description="当前用户可用容量字节数。未开启容量管控时为 null")
total_bytes: int | None = Field(None, description="当前用户总容量字节数。未开启容量管控时为 null")
usage_percent: float | None = Field(None, description="当前用户容量使用百分比,未开启容量管控时为 null")
exceeded: bool = Field(..., description="是否已超额。判断规则:enabled=true 且 used_bytes >= total_bytes")
limit_value: str | None = Field(None, description="最终生效容量数值。未开启容量管控时为 null")
limit_unit: ResourceCapacityUnitEnum | None = Field(None, description="最终生效容量单位。未开启容量管控时为 null")
class AdminUserResourceCapacityOut(BaseModel):
has_user_config: bool = Field(..., description="该用户是否已单独设置容量配置")
user_config: ResourceCapacityConfigOut | None = Field(None, description="用户个人容量配置。未单独设置时为 null")
global_config: ResourceCapacityConfigOut = Field(..., description="当前全局容量配置")
effective: ResourceCapacityUsageOut = Field(..., description="该用户最终生效的容量使用数据")
+3
View File
@@ -1,5 +1,7 @@
from pydantic import BaseModel from pydantic import BaseModel
from app.schemas.resource_capacity import ResourceCapacityUsageOut
class UserOut(BaseModel): class UserOut(BaseModel):
id: str id: str
@@ -12,5 +14,6 @@ class UserOut(BaseModel):
user_type: str = "frontend" user_type: str = "frontend"
allowed_menus: list | None = None allowed_menus: list | None = None
must_set_password: bool = False must_set_password: bool = False
resource_capacity: ResourceCapacityUsageOut | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -36,6 +36,7 @@ from app.services.resource_accounting_service import (
soft_delete_chat_task_resources, soft_delete_chat_task_resources,
) )
from app.services.resource_signed_url_service import build_resource_signed_url from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
IMAGE_DEFAULT_SIZE = "2K" IMAGE_DEFAULT_SIZE = "2K"
@@ -225,6 +226,8 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
task_id = generate_id() task_id = generate_id()
await assert_user_resource_capacity_available(db, current_user.id)
if gen_type == "image": if gen_type == "image":
engine = await _get_image_engine(db, req.engine_id) engine = await _get_image_engine(db, req.engine_id)
sizes = _image_supported_sizes(engine) sizes = _image_supported_sizes(engine)
@@ -26,6 +26,7 @@ from app.services.generation_ai_service import (
normalize_px, normalize_px,
) )
from app.services.generation_billing_service import OWNER_CHAT_GENERATION_TASK, charge_generation_media_by_params from app.services.generation_billing_service import OWNER_CHAT_GENERATION_TASK, charge_generation_media_by_params
from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
@@ -87,6 +88,8 @@ async def create_chat_generation_task_for_module(
task_id=task_id, task_id=task_id,
) )
await assert_user_resource_capacity_available(db, current_user.id)
if gen_type == "image": if gen_type == "image":
engine = await _get_image_engine(db, engine_id) engine = await _get_image_engine(db, engine_id)
sizes = _image_supported_sizes(engine) sizes = _image_supported_sizes(engine)
@@ -0,0 +1,440 @@
from __future__ import annotations
import json
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Iterable
from fastapi import HTTPException
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.resource_capacity import (
RESOURCE_CAPACITY_EXCEEDED_MESSAGE,
ResourceCapacityConfigKeyEnum,
ResourceCapacityErrorCodeEnum,
ResourceCapacitySourceEnum,
ResourceCapacityUnitEnum,
)
from app.models.system_config import SystemConfig
from app.models.user import User
from app.models.user_resource_capacity_config import UserResourceCapacityConfig
from app.models.user_resource_total_stat import UserResourceTotalStat
from app.schemas.resource_capacity import (
AdminUserResourceCapacityOut,
ResourceCapacityConfigOut,
ResourceCapacityConfigUpdate,
ResourceCapacityUsageOut,
)
from app.utils.id_gen import generate_id
DEFAULT_LIMIT_VALUE = Decimal("1.000")
DEFAULT_LIMIT_UNIT = ResourceCapacityUnitEnum.GB
DEFAULT_LIMIT_BYTES = DEFAULT_LIMIT_UNIT.bytes_multiplier
def _normalize_limit_value(value: Decimal | int | float | str | None) -> Decimal:
if value is None:
return DEFAULT_LIMIT_VALUE
decimal_value = Decimal(str(value))
return decimal_value.quantize(Decimal("0.001"), rounding=ROUND_HALF_UP)
def _limit_value_to_str(value: Decimal | int | float | str | None) -> str:
return format(_normalize_limit_value(value), "f")
def calculate_limit_bytes(
limit_value: Decimal | int | float | str | None,
limit_unit: ResourceCapacityUnitEnum | str | None,
) -> int:
unit = ResourceCapacityUnitEnum(limit_unit or DEFAULT_LIMIT_UNIT.value)
value = _normalize_limit_value(limit_value)
return int((value * Decimal(unit.bytes_multiplier)).to_integral_value(rounding=ROUND_HALF_UP))
def _default_config_out() -> ResourceCapacityConfigOut:
return ResourceCapacityConfigOut(
enabled=False,
limit_value=_limit_value_to_str(DEFAULT_LIMIT_VALUE),
limit_unit=DEFAULT_LIMIT_UNIT,
limit_bytes=DEFAULT_LIMIT_BYTES,
)
def _config_out(
*,
enabled: bool,
limit_value: Decimal | int | float | str | None,
limit_unit: ResourceCapacityUnitEnum | str | None,
limit_bytes: int | None = None,
) -> ResourceCapacityConfigOut:
unit = ResourceCapacityUnitEnum(limit_unit or DEFAULT_LIMIT_UNIT.value)
value = _normalize_limit_value(limit_value)
return ResourceCapacityConfigOut(
enabled=bool(enabled),
limit_value=_limit_value_to_str(value),
limit_unit=unit,
limit_bytes=int(limit_bytes if limit_bytes is not None else calculate_limit_bytes(value, unit)),
)
def _config_model_to_out(config: UserResourceCapacityConfig | None) -> ResourceCapacityConfigOut | None:
if config is None:
return None
return _config_out(
enabled=config.enabled,
limit_value=config.limit_value,
limit_unit=config.limit_unit,
limit_bytes=config.limit_bytes,
)
def _global_value_to_out(value: str | None) -> ResourceCapacityConfigOut:
if not value:
return _default_config_out()
try:
data = json.loads(value)
if not isinstance(data, dict):
return _default_config_out()
enabled = bool(data.get("enabled", False))
limit_unit = data.get("limit_unit") or DEFAULT_LIMIT_UNIT.value
limit_value = data.get("limit_value") or DEFAULT_LIMIT_VALUE
limit_bytes = data.get("limit_bytes")
return _config_out(
enabled=enabled,
limit_value=limit_value,
limit_unit=limit_unit,
limit_bytes=int(limit_bytes) if limit_bytes is not None else None,
)
except Exception:
return _default_config_out()
def _config_to_json(config: ResourceCapacityConfigOut) -> str:
return json.dumps(
{
"enabled": config.enabled,
"limit_value": config.limit_value,
"limit_unit": config.limit_unit.value,
"limit_bytes": config.limit_bytes,
},
ensure_ascii=False,
)
def _config_snapshot(config: ResourceCapacityConfigOut | None) -> dict[str, Any] | None:
if config is None:
return None
return {
"enabled": config.enabled,
"limit_value": config.limit_value,
"limit_unit": config.limit_unit.value,
"limit_bytes": config.limit_bytes,
}
def _usage_snapshot(usage: ResourceCapacityUsageOut | None) -> dict[str, Any] | None:
if usage is None:
return None
return {
"enabled": usage.enabled,
"source": usage.source.value,
"has_user_config": usage.has_user_config,
"used_bytes": usage.used_bytes,
"available_bytes": usage.available_bytes,
"total_bytes": usage.total_bytes,
"usage_percent": usage.usage_percent,
"exceeded": usage.exceeded,
"limit_value": usage.limit_value,
"limit_unit": usage.limit_unit.value if usage.limit_unit else None,
}
def build_global_resource_capacity_operation_detail(
before: ResourceCapacityConfigOut | None,
after: ResourceCapacityConfigOut | None,
) -> str:
"""构造全局容量配置操作日志详情。"""
return json.dumps(
{
"target": "global_resource_capacity",
"before": _config_snapshot(before),
"after": _config_snapshot(after),
},
ensure_ascii=False,
default=str,
)
def build_user_resource_capacity_operation_detail(
*,
target_user_id: str,
operation: str,
before: AdminUserResourceCapacityOut | None,
after: AdminUserResourceCapacityOut | None,
remark: str | None = None,
) -> str:
"""构造用户个人容量配置操作日志详情。"""
payload: dict[str, Any] = {
"target": "user_resource_capacity",
"target_user_id": target_user_id,
"operation": operation,
"before": {
"has_user_config": before.has_user_config if before else False,
"user_config": _config_snapshot(before.user_config) if before else None,
"effective": _usage_snapshot(before.effective) if before else None,
},
"after": {
"has_user_config": after.has_user_config if after else False,
"user_config": _config_snapshot(after.user_config) if after else None,
"effective": _usage_snapshot(after.effective) if after else None,
},
}
if remark:
payload["remark"] = remark
return json.dumps(payload, ensure_ascii=False, default=str)
def _build_usage_out(
*,
used_bytes: int,
has_user_config: bool,
source: ResourceCapacitySourceEnum,
config: ResourceCapacityConfigOut | None,
) -> ResourceCapacityUsageOut:
used = max(int(used_bytes or 0), 0)
if not config or not config.enabled:
return ResourceCapacityUsageOut(
enabled=False,
source=ResourceCapacitySourceEnum.DISABLED,
has_user_config=has_user_config,
used_bytes=used,
available_bytes=None,
total_bytes=None,
usage_percent=None,
exceeded=False,
limit_value=None,
limit_unit=None,
)
total = max(int(config.limit_bytes or 0), 0)
available = max(total - used, 0)
usage_percent = round((used / total) * 100, 2) if total > 0 else None
return ResourceCapacityUsageOut(
enabled=True,
source=source,
has_user_config=has_user_config,
used_bytes=used,
available_bytes=available,
total_bytes=total,
usage_percent=usage_percent,
exceeded=bool(total > 0 and used >= total),
limit_value=config.limit_value,
limit_unit=config.limit_unit,
)
async def get_global_resource_capacity_config(db: AsyncSession) -> ResourceCapacityConfigOut:
result = await db.execute(
select(SystemConfig.value)
.where(SystemConfig.key == ResourceCapacityConfigKeyEnum.RESOURCE_CAPACITY_LIMIT_CONFIG.value)
.limit(1)
)
return _global_value_to_out(result.scalar_one_or_none())
async def save_global_resource_capacity_config(
db: AsyncSession,
req: ResourceCapacityConfigUpdate,
) -> ResourceCapacityConfigOut:
limit_value = req.limit_value if req.limit_value is not None else DEFAULT_LIMIT_VALUE
limit_unit = req.limit_unit if req.limit_unit is not None else DEFAULT_LIMIT_UNIT
config_out = _config_out(
enabled=req.enabled,
limit_value=limit_value,
limit_unit=limit_unit,
)
result = await db.execute(
select(SystemConfig)
.where(SystemConfig.key == ResourceCapacityConfigKeyEnum.RESOURCE_CAPACITY_LIMIT_CONFIG.value)
.limit(1)
)
config = result.scalar_one_or_none()
if config:
config.value = _config_to_json(config_out)
config.description = "全局生成资源空间容量管控配置"
else:
db.add(
SystemConfig(
id=generate_id(),
key=ResourceCapacityConfigKeyEnum.RESOURCE_CAPACITY_LIMIT_CONFIG.value,
value=_config_to_json(config_out),
description="全局生成资源空间容量管控配置",
)
)
await db.flush()
return config_out
async def _get_user_config(db: AsyncSession, user_id: str) -> UserResourceCapacityConfig | None:
result = await db.execute(
select(UserResourceCapacityConfig)
.where(UserResourceCapacityConfig.user_id == user_id)
.limit(1)
)
return result.scalar_one_or_none()
async def _get_used_bytes(db: AsyncSession, user_id: str) -> int:
result = await db.execute(
select(UserResourceTotalStat.active_size_bytes)
.where(UserResourceTotalStat.user_id == user_id)
.limit(1)
)
return int(result.scalar_one_or_none() or 0)
async def get_user_resource_capacity_usage(
db: AsyncSession,
user_id: str,
) -> ResourceCapacityUsageOut:
global_config = await get_global_resource_capacity_config(db)
user_config = await _get_user_config(db, user_id)
used_bytes = await _get_used_bytes(db, user_id)
user_config_out = _config_model_to_out(user_config)
if user_config_out is not None:
return _build_usage_out(
used_bytes=used_bytes,
has_user_config=True,
source=ResourceCapacitySourceEnum.USER if user_config_out.enabled else ResourceCapacitySourceEnum.DISABLED,
config=user_config_out,
)
return _build_usage_out(
used_bytes=used_bytes,
has_user_config=False,
source=ResourceCapacitySourceEnum.GLOBAL if global_config.enabled else ResourceCapacitySourceEnum.DISABLED,
config=global_config,
)
async def batch_get_user_resource_capacity_usage(
db: AsyncSession,
user_ids: Iterable[str],
) -> dict[str, ResourceCapacityUsageOut]:
ids = [user_id for user_id in dict.fromkeys(user_ids) if user_id]
if not ids:
return {}
global_config = await get_global_resource_capacity_config(db)
config_result = await db.execute(
select(UserResourceCapacityConfig)
.where(UserResourceCapacityConfig.user_id.in_(ids))
)
user_config_map = {item.user_id: item for item in config_result.scalars().all()}
stat_result = await db.execute(
select(UserResourceTotalStat.user_id, UserResourceTotalStat.active_size_bytes)
.where(UserResourceTotalStat.user_id.in_(ids))
)
used_map = {row.user_id: int(row.active_size_bytes or 0) for row in stat_result.all()}
usage_map: dict[str, ResourceCapacityUsageOut] = {}
for user_id in ids:
user_config_out = _config_model_to_out(user_config_map.get(user_id))
if user_config_out is not None:
usage_map[user_id] = _build_usage_out(
used_bytes=used_map.get(user_id, 0),
has_user_config=True,
source=ResourceCapacitySourceEnum.USER if user_config_out.enabled else ResourceCapacitySourceEnum.DISABLED,
config=user_config_out,
)
else:
usage_map[user_id] = _build_usage_out(
used_bytes=used_map.get(user_id, 0),
has_user_config=False,
source=ResourceCapacitySourceEnum.GLOBAL if global_config.enabled else ResourceCapacitySourceEnum.DISABLED,
config=global_config,
)
return usage_map
async def assert_user_resource_capacity_available(db: AsyncSession, user_id: str) -> None:
usage = await get_user_resource_capacity_usage(db, user_id)
if usage.enabled and usage.exceeded:
raise HTTPException(
status_code=400,
detail=RESOURCE_CAPACITY_EXCEEDED_MESSAGE,
headers={"X-Error-Code": ResourceCapacityErrorCodeEnum.RESOURCE_CAPACITY_EXCEEDED.value},
)
async def ensure_user_exists(db: AsyncSession, user_id: str) -> User:
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return user
async def get_admin_user_resource_capacity(
db: AsyncSession,
user_id: str,
) -> AdminUserResourceCapacityOut:
await ensure_user_exists(db, user_id)
global_config = await get_global_resource_capacity_config(db)
user_config = await _get_user_config(db, user_id)
effective = await get_user_resource_capacity_usage(db, user_id)
return AdminUserResourceCapacityOut(
has_user_config=user_config is not None,
user_config=_config_model_to_out(user_config),
global_config=global_config,
effective=effective,
)
async def save_user_resource_capacity_config(
db: AsyncSession,
user_id: str,
req: ResourceCapacityConfigUpdate,
) -> AdminUserResourceCapacityOut:
await ensure_user_exists(db, user_id)
limit_value = req.limit_value if req.limit_value is not None else DEFAULT_LIMIT_VALUE
limit_unit = req.limit_unit if req.limit_unit is not None else DEFAULT_LIMIT_UNIT
limit_bytes = calculate_limit_bytes(limit_value, limit_unit)
config = await _get_user_config(db, user_id)
if config:
config.enabled = req.enabled
config.limit_value = _normalize_limit_value(limit_value)
config.limit_unit = ResourceCapacityUnitEnum(limit_unit).value
config.limit_bytes = limit_bytes
else:
db.add(
UserResourceCapacityConfig(
id=generate_id(),
user_id=user_id,
enabled=req.enabled,
limit_value=_normalize_limit_value(limit_value),
limit_unit=ResourceCapacityUnitEnum(limit_unit).value,
limit_bytes=limit_bytes,
)
)
await db.flush()
return await get_admin_user_resource_capacity(db, user_id)
async def delete_user_resource_capacity_config(
db: AsyncSession,
user_id: str,
) -> AdminUserResourceCapacityOut:
await ensure_user_exists(db, user_id)
await db.execute(
delete(UserResourceCapacityConfig).where(UserResourceCapacityConfig.user_id == user_id)
)
await db.flush()
return await get_admin_user_resource_capacity(db, user_id)
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
+40
View File
@@ -1,3 +1,4 @@
<<<<<<< HEAD
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
@@ -35,3 +36,42 @@
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </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-smITgEEu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3IaMBsq.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
>>>>>>> 152392e3d9966c24ab0219b02921bfea6e5b92a2
+2 -2
View File
@@ -174,8 +174,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; userAgreementUrl: string; privacyPolicyUrl: string }> { export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string }> {
if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementUrl: '', privacyPolicyUrl: '' }; if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2024 民众智创 版权所有' };
return api.get('/auth/site-info', false); return api.get('/auth/site-info', false);
} }
// ── Video Engines ───────────────────────────────────────── // ── Video Engines ─────────────────────────────────────────
@@ -85,8 +85,9 @@
.contact-button-wrapper { .contact-button-wrapper {
position: fixed; position: fixed;
right: 24px; right: 24px;
bottom: 24px; bottom: 25%;
z-index: 1000; z-index: 1000;
cursor: move;
} }
.contact-tooltip { .contact-tooltip {
@@ -94,7 +95,7 @@
right: 64px; right: 64px;
bottom: 8px; bottom: 8px;
padding: 8px 16px; padding: 8px 16px;
background: #1e293b; background: rgba(30, 41, 59, 0.9);
color: #ffffff; color: #ffffff;
border-radius: 8px; border-radius: 8px;
font-size: 13px; font-size: 13px;
@@ -108,19 +109,26 @@
height: 40px; height: 40px;
border-radius: 50%; border-radius: 50%;
border: none; border: none;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); background: linear-gradient(135deg, rgba(99, 102, 241, 0.8) 0%, rgba(139, 92, 246, 0.8) 100%);
color: #ffffff; color: #ffffff;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4); box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3);
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.contact-button:hover { .contact-button:hover {
transform: scale(1.05); transform: scale(1.05);
box-shadow: 0 6px 24px rgba(99, 102, 241, 0.5); background: linear-gradient(135deg, rgba(99, 102, 241, 0.95) 0%, rgba(139, 92, 246, 0.95) 100%);
box-shadow: 0 6px 24px rgba(99, 102, 241, 0.4);
}
.contact-button.dragging {
cursor: grabbing;
transform: scale(1.1);
box-shadow: 0 8px 30px rgba(99, 102, 241, 0.5);
} }
@media (max-width: 767px) { @media (max-width: 767px) {
@@ -136,12 +136,23 @@ const AppLayout: React.FC = () => {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const [pwdModalOpen, setPwdModalOpen] = useState(false); const [pwdModalOpen, setPwdModalOpen] = useState(false);
const [rechargeModalOpen, setRechargeModalOpen] = useState(false); const [rechargeModalOpen, setRechargeModalOpen] = useState(false);
const [contactModalOpen, setContactModalOpen] = useState(false);
const [pwdForm] = Form.useForm(); const [pwdForm] = Form.useForm();
const [selectedPlan, setSelectedPlan] = useState<number | null>(null); const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]); const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]); const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [contactHovered, setContactHovered] = useState(false);
const [contactForm] = Form.useForm();
const [submittingContact, setSubmittingContact] = useState(false);
const [contactPosition, setContactPosition] = useState<{ x: number; y: number }>(() => ({
x: 24,
y: window.innerHeight * 0.75
}));
const [isDragging, setIsDragging] = useState(false);
const hasMovedRef = useRef(false);
const dragStartRef = useRef({ x: 0, y: 0 });
const [mobileExpandedMenus, setMobileExpandedMenus] = useState<Record<string, boolean>>({}); const [mobileExpandedMenus, setMobileExpandedMenus] = useState<Record<string, boolean>>({});
const [siteName, setSiteName] = useState(() => { const [siteName, setSiteName] = useState(() => {
const cached = localStorage.getItem('siteInfo'); const cached = localStorage.getItem('siteInfo');
@@ -174,10 +185,6 @@ const AppLayout: React.FC = () => {
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const currentOrderNoRef = useRef<string | null>(null); const currentOrderNoRef = useRef<string | null>(null);
const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false }); const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false });
const [contactModalOpen, setContactModalOpen] = useState(false);
const [contactForm] = Form.useForm();
const [contactHovered, setContactHovered] = useState(false);
const [submittingContact, setSubmittingContact] = useState(false);
const PENDING_ORDER_KEY = 'pending_payment_order'; const PENDING_ORDER_KEY = 'pending_payment_order';
@@ -207,6 +214,53 @@ const AppLayout: React.FC = () => {
}).catch(() => { }); }).catch(() => { });
}, []); }, []);
const handleContactMouseDown = (e: React.MouseEvent) => {
if (e.button === 0) {
setIsDragging(true);
hasMovedRef.current = false;
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
};
}
};
const handleContactMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
const deltaX = Math.abs(e.clientX - dragStartRef.current.x);
const deltaY = Math.abs(e.clientY - dragStartRef.current.y);
if (deltaX > 5 || deltaY > 5) {
hasMovedRef.current = true;
}
const newY = Math.max(60, Math.min(window.innerHeight - 60, e.clientY - (dragStartRef.current.y - contactPosition.y)));
setContactPosition(prev => ({ x: prev.x, y: newY }));
};
const handleContactMouseUp = () => {
const moved = hasMovedRef.current;
setIsDragging(false);
hasMovedRef.current = false;
if (!moved) {
setContactModalOpen(true);
}
};
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleContactMouseMove);
document.addEventListener('mouseup', handleContactMouseUp);
return () => {
document.removeEventListener('mousemove', handleContactMouseMove);
document.removeEventListener('mouseup', handleContactMouseUp);
};
}
}, [isDragging]);
const loadUnreadCount = () => { const loadUnreadCount = () => {
getUnreadCount().then(count => { getUnreadCount().then(count => {
setUnreadCount(count); setUnreadCount(count);
@@ -520,7 +574,7 @@ const AppLayout: React.FC = () => {
}}> }}>
<div style={{ <div style={{
width: 42, height: 42, borderRadius: 14, flexShrink: 0, width: 42, height: 42, borderRadius: 14, flexShrink: 0,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)', //background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.35)', boxShadow: '0 4px 16px rgba(99, 102, 241, 0.35)',
overflow: 'hidden', overflow: 'hidden',
@@ -604,12 +658,12 @@ const AppLayout: React.FC = () => {
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
}} /> }} />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#1e293b', fontSize: 14, 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>
<div style={{ <div style={{
color: '#6366f1', color: '#6366f1',
fontSize: 12, fontSize: 16,
fontWeight: 500, fontWeight: 500,
letterSpacing: 0, letterSpacing: 0,
background: 'rgba(99, 102, 241, 0.08)', background: 'rgba(99, 102, 241, 0.08)',
@@ -917,8 +971,16 @@ const AppLayout: React.FC = () => {
gap: 8, gap: 8,
}}> }}>
<InfoOutlined style={{ color: '#6366f1', fontSize: 14 }} /> <InfoOutlined style={{ color: '#6366f1', fontSize: 14 }} />
<Typography.Text style={{ color: '#ff0000ff', fontSize: 13 }}> <Typography.Text style={{ color: '#64748b', fontSize: 13 }}>
/ /
<Typography.Text
style={{
color: '#ff0000ff',
cursor: 'pointer',
textDecoration: 'underline',
}}
onClick={() => { setRechargeModalOpen(false); setContactModalOpen(true); }}
></Typography.Text>
</Typography.Text> </Typography.Text>
</div> </div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
@@ -1193,7 +1255,13 @@ const AppLayout: React.FC = () => {
<NotificationPopup /> <NotificationPopup />
<div className="contact-button-wrapper"> <div
className="contact-button-wrapper"
style={{
right: `${contactPosition.x}px`,
bottom: `${window.innerHeight - contactPosition.y}px`,
}}
>
<div style={{ <div style={{
position: 'relative', position: 'relative',
}}> }}>
@@ -1206,10 +1274,11 @@ const AppLayout: React.FC = () => {
</div> </div>
<button <button
onClick={() => setContactModalOpen(true)} className={`contact-button ${isDragging ? 'dragging' : ''}`}
className="contact-button"
onMouseEnter={() => setContactHovered(true)} onMouseEnter={() => setContactHovered(true)}
onMouseLeave={() => setContactHovered(false)} onMouseLeave={() => setContactHovered(false)}
onMouseDown={handleContactMouseDown}
onMouseUp={handleContactMouseUp}
> >
<MessageOutlined style={{ fontSize: 20 }} /> <MessageOutlined style={{ fontSize: 20 }} />
</button> </button>
+10 -5
View File
@@ -43,6 +43,7 @@ import {
WarningOutlined, WarningOutlined,
SettingOutlined, SettingOutlined,
LayoutOutlined, LayoutOutlined,
ArrowUpOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -1558,17 +1559,21 @@ const AIChatPage: React.FC = () => {
<Button <Button
type="primary" type="primary"
shape="circle" shape="circle"
icon={<SendOutlined />} icon={<ArrowUpOutlined />}
onClick={handleSend} onClick={handleSend}
disabled={!inputValue.trim() && currentMedia.length === 0} disabled={!inputValue.trim() && currentMedia.length === 0}
loading={loading} loading={loading}
style={{ style={{
flexShrink: 0, flexShrink: 0,
width: 48, width: 40,
height: 48, height: 40,
borderRadius: 14, borderRadius: 14,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', background: (inputValue.trim() || currentMedia.length > 0)
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.4)', ? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'
: '#c7cfdaff',
boxShadow: (inputValue.trim() || currentMedia.length > 0)
? '0 4px 16px rgba(99, 102, 241, 0.4)'
: 'none',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
border: 'none', border: 'none',
}} }}
+29 -3
View File
@@ -1,5 +1,6 @@
.login-page { .login-page {
min-height: 100vh; height: 100vh;
max-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-image: url(/backimage.png); background-image: url(/backimage.png);
@@ -8,7 +9,7 @@
background-repeat: no-repeat; background-repeat: no-repeat;
background-attachment: fixed; background-attachment: fixed;
position: relative; position: relative;
overflow-x: hidden; overflow: hidden;
} }
@media (min-width: 900px) { @media (min-width: 900px) {
@@ -180,10 +181,35 @@
.login-right-section { .login-right-section {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1; z-index: 1;
padding: 16px 16px 32px; 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) { @media (min-width: 900px) {
+191 -179
View File
@@ -44,8 +44,8 @@ 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 [agreementUrl, setAgreementUrl] = useState(''); const [agreementPrivacyUrl, setAgreementPrivacyUrl] = useState('');
const [policyUrl, setPolicyUrl] = useState(''); const [siteCopyright, setSiteCopyright] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const { login } = useAuthStore(); const { login } = useAuthStore();
@@ -57,14 +57,14 @@ const LoginPage: React.FC = () => {
getSiteInfo().then(info => { getSiteInfo().then(info => {
setSiteName(info.siteName); setSiteName(info.siteName);
setSiteLogo(info.siteLogo); setSiteLogo(info.siteLogo);
setAgreementUrl(info.userAgreementUrl); setAgreementPrivacyUrl(info.userAgreementPrivacyUrl);
setPolicyUrl(info.privacyPolicyUrl); setSiteCopyright(info.siteCopyright);
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
const checkAgreed = (): boolean => { const checkAgreed = (): boolean => {
if (!agreed) { if (!agreed) {
message.warning('请先阅读并同意用户协议隐私政策'); message.warning('请先阅读并同意用户协议隐私政策');
return false; return false;
} }
return true; return true;
@@ -259,7 +259,15 @@ const LoginPage: React.FC = () => {
}; };
const openPdf = (url: string) => { const openPdf = (url: string) => {
if (url) window.open(`${API_BASE.replace(/\/api$/, '')}${url}`, '_blank'); if (!url) {
message.warning('暂未上传协议文件');
return;
}
if (url.startsWith('http://') || url.startsWith('https://')) {
window.open(url, '_blank');
} else {
window.open(`${API_BASE.replace(/\/api$/, '')}${url}`, '_blank');
}
}; };
return ( return (
@@ -310,182 +318,186 @@ const LoginPage: React.FC = () => {
</div> </div>
<div className="login-right-section"> <div className="login-right-section">
<Card className="login-card" styles={{ body: { padding: '36px 28px' } }}> <div className="login-right-section-inner">
<Typography.Title level={3} className="login-card-title"> <Card className="login-card" styles={{ body: { padding: '36px 28px' } }}>
{mode === 'register' ? '创建账号' : '欢迎回来'} <Typography.Title level={3} className="login-card-title">
</Typography.Title> {mode === 'register' ? '创建账号' : '欢迎回来'}
<Typography.Text className="login-card-subtitle"> </Typography.Title>
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'} <Typography.Text className="login-card-subtitle">
</Typography.Text> {mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'}
</Typography.Text>
{mode !== 'register' && ( {mode !== 'register' && (
<div className="login-tabs"> <div className="login-tabs">
{(['password', 'phone'] as const).map((t) => ( {(['password', 'phone'] as const).map((t) => (
<div key={t} onClick={() => switchTab(t)} <div key={t} onClick={() => switchTab(t)}
className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}> className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}>
{t === 'password' ? '密码登录' : '验证码登录'} {t === 'password' ? '密码登录' : '验证码登录'}
</div> </div>
))} ))}
</div> </div>
)}
{mode === 'password' && (
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" style={inputStyle} />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入密码" style={inputStyle} />
</Form.Item>
<Form.Item name="rememberMe" valuePropName="checked" style={{ marginBottom: 12 }}>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{mode === 'phone' && (
<Form form={phoneForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item>
<Form.Item name="code" rules={[{ required: true, message: '请输入验证码' }]}>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />}
placeholder="请输入验证码"
maxLength={6}
disabled={!loginSliderVerified}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={countdown > 0}
onClick={() => {
if (loginShowResend) {
setLoginSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else {
handleSendCode(phoneForm.getFieldValue('phone'));
}
}}
className="login-code-btn">
{countdown > 0 ? `${countdown}s` : (loginShowResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={loginSliderVerified}
/>
</Form.Item>
)}
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{mode === 'register' && (
<Form form={regForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item>
<Form.Item name="regCode" rules={[{ required: true, message: '请输入验证码' }]}>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />}
placeholder="请输入验证码"
maxLength={6}
disabled={!sliderVerified}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={regCountdown > 0}
onClick={() => {
if (showResend) {
setSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else {
handleSendCode(regForm.getFieldValue('phone'), true);
}
}}
className="login-code-btn">
{regCountdown > 0 ? `${regCountdown}s` : (showResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={sliderVerified}
/>
</Form.Item>
)}
<Form.Item name="password" rules={[{ required: true, message: '请设置密码' }, { min: 6, message: '密码至少6位' }]}>
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请设置密码(至少6位)" style={inputStyle} />
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handleRegister} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
<div className="login-agreement">
<Checkbox checked={agreed} onChange={e => setAgreed(e.target.checked)}>
<span className="login-agreement-text">
<span
onClick={e => { e.stopPropagation(); openPdf(agreementUrl); }}
className="login-link"
></span>
<span
onClick={e => { e.stopPropagation(); openPdf(policyUrl); }}
className="login-link"
></span>
</span>
</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>
)} )}
{mode === 'password' && (
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" style={inputStyle} />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入密码" style={inputStyle} />
</Form.Item>
<Form.Item name="rememberMe" valuePropName="checked" style={{ marginBottom: 12 }}>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{mode === 'phone' && (
<Form form={phoneForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item>
<Form.Item name="code" rules={[{ required: true, message: '请输入验证码' }]}>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />}
placeholder="请输入验证码"
maxLength={6}
disabled={!loginSliderVerified}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={countdown > 0}
onClick={() => {
if (loginShowResend) {
setLoginSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else {
handleSendCode(phoneForm.getFieldValue('phone'));
}
}}
className="login-code-btn">
{countdown > 0 ? `${countdown}s` : (loginShowResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={loginSliderVerified}
/>
</Form.Item>
)}
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{mode === 'register' && (
<Form form={regForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item>
<Form.Item name="regCode" rules={[{ required: true, message: '请输入验证码' }]}>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />}
placeholder="请输入验证码"
maxLength={6}
disabled={!sliderVerified}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={regCountdown > 0}
onClick={() => {
if (showResend) {
setSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else {
handleSendCode(regForm.getFieldValue('phone'), true);
}
}}
className="login-code-btn">
{regCountdown > 0 ? `${regCountdown}s` : (showResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={sliderVerified}
/>
</Form.Item>
)}
<Form.Item name="password" rules={[{ required: true, message: '请设置密码' }, { min: 6, message: '密码至少6位' }]}>
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请设置密码(至少6位)" style={inputStyle} />
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handleRegister} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
<div className="login-agreement">
<Checkbox checked={agreed} onChange={e => setAgreed(e.target.checked)}>
<span className="login-agreement-text">
<span
onClick={e => { e.stopPropagation(); openPdf(agreementPrivacyUrl); }}
className="login-link"
></span>
</span>
</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> </div>
</Card> )}
</div> </div>
</div> </div>
); );