Merge remote-tracking branch 'origin/main'

This commit is contained in:
孙佳艺
2026-06-30 09:22:42 +08:00
43 changed files with 2592 additions and 922 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ __pycache__/
.vscode/
.trae/
# video-gen-app/dist/
video-gen-api/dist/
#video-gen-api/dist/
bak/
# 使用通配符
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DSXie0ty.js"></script>
<script type="module" crossorigin src="/assets/index-D7aUeB8G.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
+1 -1
View File
@@ -7,7 +7,7 @@ import AdminAuthoriz from './pages/AdminAuthoriz';
import AdminConsume from './pages/AdminConsume';
import AdminLoginPage from './pages/AdminLoginPage';
import AdminDashboard from './pages/AdminDashboard';
import AdminPlatform from './pages/AdminPlatform';
import AdminPlatform from './pages/Adminplatform';
import AdminUsers from './pages/AdminUsers';
import AdminModels from './pages/AdminModels';
import AdminSettings from './pages/AdminSettings';
+29
View File
@@ -13,6 +13,7 @@ import type {
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
} from '../types';
// ── Auth ──────────────────────────────────────────────────
@@ -149,6 +150,34 @@ export async function updateSystemConfig(id: string, value: string): Promise<voi
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 }> {
const formData = new FormData();
formData.append('file', file);
+129 -54
View File
@@ -1,12 +1,25 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Space, Switch, Typography, Upload,
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Typography, Upload,
} from 'antd';
import {
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined,
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined,
} from '@ant-design/icons';
import { getSystemConfigs, updateSystemConfig, uploadPdf, uploadLogo } from '../api';
import type { SystemConfig } from '../types';
import {
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 [configs, setConfigs] = useState<SystemConfig[]>([]);
@@ -14,7 +27,6 @@ const AdminSettings: React.FC = () => {
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState('');
const [form] = Form.useForm();
const [logoPreview, setLogoPreview] = useState('');
useEffect(() => {
load();
@@ -22,12 +34,23 @@ const AdminSettings: React.FC = () => {
const load = async () => {
setLoading(true);
const data = await getSystemConfigs();
setConfigs(data);
const formValues: Record<string, string> = {};
data.forEach(c => { formValues[c.key] = c.value; });
form.setFieldsValue(formValues);
setLoading(false);
try {
const [data, capacity] = await Promise.all([
getSystemConfigs(),
getGlobalResourceCapacity(),
]);
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 () => {
@@ -36,17 +59,21 @@ const AdminSettings: React.FC = () => {
setSaving(true);
for (const config of configs) {
const newVal = values[config.key];
if (newVal !== undefined && newVal !== config.value) {
await updateSystemConfig(config.id, newVal ?? '');
if (newVal !== undefined && String(newVal) !== config.value) {
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('系统配置已保存');
const data = await getSystemConfigs();
setConfigs(data);
setSaving(false);
await load();
} catch (e: any) {
setSaving(false);
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
@@ -54,38 +81,42 @@ const AdminSettings: React.FC = () => {
setUploading(configKey);
try {
const res = await uploadPdf(file, configKey);
// Update local state
setConfigs(prev => prev.map(c => c.key === configKey ? { ...c, value: res.url } : c));
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 {
message.error('上传失败');
} finally {
setUploading('');
}
return false; // prevent default upload
return false;
};
const handleLogoUpload = async (file: File) => {
setUploading('site_logo');
try {
const res = await uploadLogo(file);
// Update local state
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
form.setFieldsValue({ site_logo: res.url });
setLogoPreview(res.url);
message.success('Logo上传成功');
} catch {
message.error('上传失败');
} finally {
setUploading('');
}
return false; // prevent default upload
return false;
};
const groupedConfigs: Record<string, SystemConfig[]> = {
'站点信息': 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_')),
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
};
@@ -94,8 +125,8 @@ const AdminSettings: React.FC = () => {
const descMap: Record<string, string> = {
site_name: '平台显示名称,将展示在页面标题和导航栏',
site_logo: '平台Logo图片URL,建议尺寸 200x40px',
user_agreement_url: '用户注册/登录时需同意的用户协议PDF文件',
privacy_policy_url: '用户注册/登录时需同意的隐私政策PDF文件',
site_copyright: '显示在前台登录页底部的版权信息,例如:© 2024 民众智创 版权所有',
user_agreement_privacy_url: '用户登录时需同意的用户协议及隐私政策PDF文件',
seo_title: '搜索引擎结果中显示的标题',
seo_description: '搜索引擎结果中显示的描述文字,建议150字以内',
seo_keywords: '用逗号分隔的关键词列表',
@@ -106,32 +137,6 @@ const AdminSettings: React.FC = () => {
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 hasLogo = config.value && config.value.startsWith('/uploads/');
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
@@ -198,8 +203,9 @@ const AdminSettings: React.FC = () => {
};
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 baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
return (
<div style={{
padding: '16px', borderRadius: 10,
@@ -214,7 +220,7 @@ const AdminSettings: React.FC = () => {
<Space>
{hasFile && (
<Button size="small" icon={<EyeOutlined />}
onClick={() => window.open(`http://localhost:8000${config.value}`, '_blank')}>
onClick={() => window.open(`${baseUrl}${config.value}`, '_blank')}>
</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) {
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
}
@@ -282,6 +314,49 @@ const AdminSettings: React.FC = () => {
)}
</div>
))}
<div style={{ marginBottom: 4 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, border: '1px solid #f0f0f5', borderRadius: 10, background: '#fafbfc' }}>
<Space align="start" style={{ marginBottom: 16 }}>
<DatabaseOutlined style={{ color: '#6366f1', fontSize: 18, marginTop: 2 }} />
<div>
<Typography.Text strong></Typography.Text>
<div style={{ color: '#64748b', fontSize: 13, marginTop: 4 }}>
</div>
</div>
</Space>
<Form.Item
name="resource_capacity_enabled"
label="启用全局容量管控"
valuePropName="checked"
extra="关闭时全局不限制;若用户设置了个人配置,则仍按用户个人配置优先判断。"
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 16 }}>
<Form.Item
name="resource_capacity_limit_value"
label="容量数值"
extra="最小为1,不能为负数,最多支持3位小数。"
rules={[{ required: true, message: '请输入容量数值' }]}
>
<InputNumber min={1} precision={3} style={{ width: '100%' }} size="large" placeholder="例如 10.500" />
</Form.Item>
<Form.Item
name="resource_capacity_limit_unit"
label="容量单位"
extra="MB / GB / TB 固定枚举"
rules={[{ required: true, message: '请选择容量单位' }]}
>
<Select size="large" options={capacityUnitOptions} />
</Form.Item>
</div>
</div>
</div>
</Form>
</Card>
+224 -21
View File
@@ -1,14 +1,55 @@
import React, { useEffect, useState } from 'react';
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';
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';
import { getAdminUsers, adjustCredits, toggleUserStatus, createUser, updateUserMenus, getMenuConfigs, resetUserPassword, getSystemConfigs, updateSystemConfig, updateFrontendUserKind } from '../api';
import type { AdminUser, SystemConfig } from '../types';
import {
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';
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 [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
@@ -22,9 +63,13 @@ const AdminUsers: React.FC = () => {
const [allMenus, setAllMenus] = useState<any[]>([]);
const [checkedMenus, setCheckedMenus] = useState<string[]>([]);
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 [createForm] = Form.useForm();
const [resetPwdForm] = Form.useForm();
const [capacityForm] = Form.useForm();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
@@ -66,17 +111,17 @@ const AdminUsers: React.FC = () => {
setConfigSaving(true);
for (const config of creditConfigs) {
const newVal = values[config.key];
if (newVal !== undefined && newVal !== config.value) {
await updateSystemConfig(config.id, newVal ?? '');
if (newVal !== undefined && String(newVal) !== config.value) {
await updateSystemConfig(config.id, String(newVal ?? ''));
}
}
message.success('积分配置已保存');
const configs = await getSystemConfigs();
setCreditConfigs(configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')));
setConfigSaving(false);
} catch (e: any) {
setConfigSaving(false);
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 menuPages = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) !== 'group');
const childMap: Record<string, any[]> = {};
@@ -229,6 +332,36 @@ const AdminUsers: React.FC = () => {
title: '前台归类', dataIndex: 'frontendUserKind', width: 110,
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,
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>,
},
{
title: '操作', key: 'action', width: 320, fixed: 'right' as const,
title: '操作', key: 'action', width: 390, fixed: 'right' as const,
render: (_: any, r: AdminUser) => (
<Space size={4}>
<Space size={4} wrap>
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<DatabaseOutlined />}
onClick={() => openCapacityModal(r)}>
</Button>
)}
{!isAdminTab && r.frontendUserKind !== 'internal' && (
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'internal')}></Button>
)}
@@ -283,7 +422,7 @@ const AdminUsers: React.FC = () => {
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
{/* <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
@@ -335,10 +474,9 @@ const AdminUsers: React.FC = () => {
</Button>
</div>
</Form>
</Card>
</Card> */}
<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', gap: 12 }}>
<Input
@@ -393,11 +531,10 @@ const AdminUsers: React.FC = () => {
showSizeChanger: true,
showTotal: (t) => `${t} 个用户`,
}}
scroll={{ x: 1000 }}
scroll={{ x: 1280 }}
/>
</Card>
{/* Adjust Credits Modal */}
<Modal
title={<Space><WalletOutlined /> - {creditModal.user?.username}</Space>}
open={creditModal.open}
@@ -428,7 +565,77 @@ const AdminUsers: React.FC = () => {
</Form>
</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
title={<Space><UserOutlined /></Space>}
open={createModal}
@@ -476,7 +683,6 @@ const AdminUsers: React.FC = () => {
</Form>
</Modal>
{/* Menu Permission Modal */}
<Modal
title={<Space><MenuOutlined /> - {menuModal.user?.username} ({menuModal.user?.userType === 'admin' ? '后台菜单' : '前台菜单'})</Space>}
open={menuModal.open}
@@ -492,14 +698,12 @@ const AdminUsers: React.FC = () => {
<div style={{ padding: '12px 16px', background: '#f8fafc', borderRadius: 8, maxHeight: 400, overflow: 'auto' }}>
<Checkbox.Group value={checkedMenus} onChange={(vals) => setCheckedMenus(vals as string[])}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{/* Top-level pages */}
{topLevelPages.map((m: any) => (
<Checkbox key={m.path} value={m.path} style={{ width: '100%' }}>
{m.label}
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{m.path}</Typography.Text>
</Checkbox>
))}
{/* Groups with their children */}
{menuGroups.map((g: any) => {
const children = childMap[g.id] || [];
if (children.length === 0) return null;
@@ -524,7 +728,6 @@ const AdminUsers: React.FC = () => {
</div>
</Modal>
{/* Reset Password Modal */}
<Modal
title={<Space><LockOutlined /> - {resetPwdModal.user?.username}</Space>}
open={resetPwdModal.open}
+39
View File
@@ -7,6 +7,7 @@ export interface User {
isAdmin: boolean;
userType: string;
allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null;
}
export interface CreditRecord {
@@ -93,6 +94,43 @@ export interface LoginParams {
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 ──────────────────────────────────────
export interface AdminUser {
@@ -108,6 +146,7 @@ export interface AdminUser {
createdAt: string;
lastLoginAt?: string;
allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null;
}
export interface AdminStats {
+1 -1
View File
@@ -1,7 +1,7 @@
# App
APP_NAME=VideoGen API
APP_VERSION=1.0.0
DEBUG=true
DEBUG=false
SECRET_KEY=local-dev-secret-key-not-for-production
# 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 ###
+2
View File
@@ -1,6 +1,8 @@
from fastapi import APIRouter
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.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.resource_signed_url_service import build_resource_signed_url
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 (
OWNER_GENERATION_RECORD,
@@ -107,7 +108,16 @@ async def list_users(
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
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)
@@ -183,7 +193,10 @@ async def get_user(
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
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")
+14 -6
View File
@@ -30,6 +30,7 @@ from app.services.auth import (
verify_password,
)
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
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)
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)
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(
@@ -301,10 +309,10 @@ async def change_password(
@router.get("/site-info")
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(
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()
@@ -324,8 +332,8 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
return {
"site_name": info.get("site_name", "VideoGen.AI"),
"site_logo": to_full_url(info.get("site_logo")),
"user_agreement_url": to_full_url(info.get("user_agreement_url")),
"privacy_policy_url": to_full_url(info.get("privacy_policy_url")),
"user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")),
"site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"),
}
+5
View File
@@ -34,6 +34,7 @@ from app.services.resource_accounting_service import (
safe_file_size,
)
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 (
CHARGE_TEXT_PROMPT,
OWNER_GENERATION_RECORD,
@@ -398,6 +399,8 @@ async def generate(
if record.status not in ("prompt_optimized", "failed"):
raise InvalidStatusError("当前状态不允许生成")
await assert_user_resource_capacity_available(db, current_user.id)
attempt_no = await get_next_credit_attempt_no(
db,
owner_type=OWNER_GENERATION_RECORD,
@@ -522,6 +525,8 @@ async def retry_generation(
if record.status != "failed":
raise InvalidStatusError("只有失败的记录可以重试")
await assert_user_resource_capacity_available(db, current_user.id)
attempt_no = await get_next_credit_attempt_no(
db,
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_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
router = APIRouter(
@@ -566,6 +567,8 @@ async def retry_task(
if task.status != "failed":
raise HTTPException(status_code=400, detail="只有失败任务可以重试")
await assert_user_resource_capacity_available(db, current_user.id)
attempt_no = await get_next_credit_attempt_no(
db,
owner_type=OWNER_CHAT_GENERATION_TASK,
+1
View File
@@ -10,3 +10,4 @@ from app.enums.generation_task import *
from app.enums.generation_status import *
from app.enums.sms 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 = [
("site_name", "民众智创", "网站名称"),
("site_logo", "", "网站Logo URL"),
("site_copyright", "© 2024 民众智创 版权所有", "网站底部版权信息"),
("seo_title", "民众智创 - AI视频生成平台", "SEO标题"),
("seo_description", "专业的AI视频生成服务", "SEO描述"),
("seo_keywords", "AI视频,视频生成,人工智能", "SEO关键词"),
# Agreement configs
("user_agreement_url", "", "用户协议PDF"),
("privacy_policy_url", "", "隐私政策PDF"),
# Agreement config
("user_agreement_privacy_url", "", "用户协议及隐私政策PDF"),
# Payment configs
("payment_wechat_enabled", "false", "微信支付启用"),
("payment_wechat_mch_id", "", "微信商户号"),
@@ -188,12 +188,17 @@ async def _seed_data():
existing = await db.execute(
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(
SystemConfig(
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
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.user_resource_month_stat import UserResourceMonthStat
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_step import ModuleGenerationStep
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
@@ -38,6 +39,7 @@ __all__ = [
"MenuConfig", "RechargePackage", "OperationLog",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog",
"GeneratedResource", "UserResourceMonthStat", "UserResourceTotalStat",
"UserResourceCapacityConfig",
"ModuleGenerationProject", "ModuleGenerationStep",
"ShotReplicateTaskSet", "ShotReplicateSegment",
"UserOAuth", "UserOAuthAccount", "UserOAuthApp",
@@ -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 app.schemas.common import NaiveDatetime, NaiveDatetimeOptional
from app.schemas.resource_capacity import ResourceCapacityUsageOut
class CreditAdjustRequest(BaseModel):
@@ -54,6 +55,7 @@ class AdminUserOut(BaseModel):
created_at: NaiveDatetime
last_login_at: NaiveDatetimeOptional = None
allowed_menus: list | None = None
resource_capacity: ResourceCapacityUsageOut | None = None
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 app.schemas.resource_capacity import ResourceCapacityUsageOut
class UserOut(BaseModel):
id: str
@@ -12,5 +14,6 @@ class UserOut(BaseModel):
user_type: str = "frontend"
allowed_menus: list | None = None
must_set_password: bool = False
resource_capacity: ResourceCapacityUsageOut | None = None
model_config = {"from_attributes": True}
@@ -36,6 +36,7 @@ from app.services.resource_accounting_service import (
soft_delete_chat_task_resources,
)
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
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)
task_id = generate_id()
await assert_user_resource_capacity_available(db, current_user.id)
if gen_type == "image":
engine = await _get_image_engine(db, req.engine_id)
sizes = _image_supported_sizes(engine)
@@ -26,6 +26,7 @@ from app.services.generation_ai_service import (
normalize_px,
)
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
@@ -87,6 +88,8 @@ async def create_chat_generation_task_for_module(
task_id=task_id,
)
await assert_user_resource_capacity_available(db, current_user.id)
if gen_type == "image":
engine = await _get_image_engine(db, engine_id)
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
+2 -2
View File
@@ -28,8 +28,8 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-BHOoZIhk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-xCZbcxht.css">
<script type="module" crossorigin src="/assets/index-qlqE3vB0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3IaMBsq.css">
</head>
<body>
<div id="root"></div>
+2
View File
@@ -19,6 +19,7 @@ import RemoveLens from './pages/RemoveLens';
import GeneratedRecord from './pages/GeneratedRecord';
import PreTest from './pages/PreTest';
import AuthorizationPage from './pages/AuthorizationPage';
import AuthAccountPage from './pages/AuthAccountPage';
import MaterialListPage from './pages/MaterialListPage';
import RemoveInfo from './pages/RemoveInfo';
import RemoveRw from './pages/RemoveRw';
@@ -118,6 +119,7 @@ const App = () => {
<Route path="materials" element={<MaterialListPage />} />
<Route path="consume" element={<ConsumePage />} />
<Route path="popular" element={<PopularPage />} />
<Route path="authacc" element={<AuthAccountPage />} />
<Route path="creativeplaza" element={<CreativePlazaPage />} />
</Route>
<Route path="*" element={<Navigate to="/projects" replace />} />
+2 -2
View File
@@ -174,8 +174,8 @@ export async function verifyCaptcha(captchaId: string, x: number): Promise<strin
return res.token;
}
// ── Site Info ─────────────────────────────────────────────
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementUrl: string; privacyPolicyUrl: string }> {
if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementUrl: '', privacyPolicyUrl: '' };
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string }> {
if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2024 民众智创 版权所有' };
return api.get('/auth/site-info', false);
}
// ── Video Engines ─────────────────────────────────────────
@@ -85,8 +85,9 @@
.contact-button-wrapper {
position: fixed;
right: 24px;
bottom: 24px;
bottom: 25%;
z-index: 1000;
cursor: move;
}
.contact-tooltip {
@@ -94,7 +95,7 @@
right: 64px;
bottom: 8px;
padding: 8px 16px;
background: #1e293b;
background: rgba(30, 41, 59, 0.9);
color: #ffffff;
border-radius: 8px;
font-size: 13px;
@@ -108,19 +109,26 @@
height: 40px;
border-radius: 50%;
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;
cursor: pointer;
display: flex;
align-items: 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;
}
.contact-button:hover {
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) {
@@ -272,12 +272,23 @@ const AppLayout: React.FC = () => {
const { user, logout } = useAuthStore();
const [pwdModalOpen, setPwdModalOpen] = useState(false);
const [rechargeModalOpen, setRechargeModalOpen] = useState(false);
const [contactModalOpen, setContactModalOpen] = useState(false);
const [pwdForm] = Form.useForm();
const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
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 [siteName, setSiteName] = useState(() => {
const cached = localStorage.getItem('siteInfo');
@@ -310,10 +321,6 @@ const AppLayout: React.FC = () => {
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const currentOrderNoRef = useRef<string | null>(null);
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);
// 资源存储容量(从 getUser().resource_capacity 获取)
const [resourceCapacity, setResourceCapacity] = useState<{
@@ -355,30 +362,52 @@ const AppLayout: React.FC = () => {
}).catch(() => { });
}, []);
// 拉取用户资源容量信息
useEffect(() => {
getUser().then((res: any) => {
console.log('[Storage] getUser 返回:', res);
const handleContactMouseDown = (e: React.MouseEvent) => {
if (e.button === 0) {
setIsDragging(true);
hasMovedRef.current = false;
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
};
}
};
const rc = res?.resourceCapacity;
if (rc) {
setResourceCapacity({
enabled: !!rc.enabled,
usedBytes: Number(rc.usedBytes) || 0,
totalBytes: Number(rc.totalBytes) || 0,
availableBytes: Number(rc.availableBytes) || 0,
usagePercent: Number(rc.usagePercent) || 0,
exceeded: !!rc.exceeded,
limitValue: rc.limitValue ?? '',
limitUnit: rc.limitUnit || 'GB',
});
}
// 没数据时不显示
}).catch((err: any) => {
console.error('[Storage] getUser 失败:', err);
// 接口失败也不显示
});
}, []);
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 = () => {
getUnreadCount().then(count => {
@@ -693,7 +722,7 @@ const AppLayout: React.FC = () => {
}}>
<div style={{
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',
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.35)',
overflow: 'hidden',
@@ -777,12 +806,12 @@ const AppLayout: React.FC = () => {
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
}} />
<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}
</div>
<div style={{
color: '#6366f1',
fontSize: 12,
fontSize: 16,
fontWeight: 500,
letterSpacing: 0,
background: 'rgba(99, 102, 241, 0.08)',
@@ -1096,8 +1125,16 @@ const AppLayout: React.FC = () => {
gap: 8,
}}>
<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>
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
@@ -1372,7 +1409,13 @@ const AppLayout: React.FC = () => {
<NotificationPopup />
<div className="contact-button-wrapper">
<div
className="contact-button-wrapper"
style={{
right: `${contactPosition.x}px`,
bottom: `${window.innerHeight - contactPosition.y}px`,
}}
>
<div style={{
position: 'relative',
}}>
@@ -1385,10 +1428,11 @@ const AppLayout: React.FC = () => {
</div>
<button
onClick={() => setContactModalOpen(true)}
className="contact-button"
className={`contact-button ${isDragging ? 'dragging' : ''}`}
onMouseEnter={() => setContactHovered(true)}
onMouseLeave={() => setContactHovered(false)}
onMouseDown={handleContactMouseDown}
onMouseUp={handleContactMouseUp}
>
<MessageOutlined style={{ fontSize: 20 }} />
</button>
+302
View File
@@ -0,0 +1,302 @@
import React, { useEffect, useState } from 'react';
import { Button, Table, Modal, App, Input, Pagination, Typography, Space } from 'antd';
import { LockOutlined } from '@ant-design/icons';
import { getOAuthAccountList, deleteOAuthAccount, getOpenTypeAll } from '../api';
const formatDateTime = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
interface AuthorizationData {
id: string;
status: string;
description: string;
account_userid?: string;
open_type?: number;
account_id?: string;
}
const AuthAccountPage: React.FC = () => {
const { message } = App.useApp();
const [authorizations, setAuthorizations] = useState<AuthorizationData[]>([]);
const [listLoading, setListLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [searchParams, setSearchParams] = useState({
advertiser_id: '',
oauth_id: '',
advertiser_name: '',
});
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
useEffect(() => {
loadOAuthList();
loadOpenTypeList();
}, []);
const loadOpenTypeList = async () => {
try {
const res = await getOpenTypeAll();
const data = res.data || [];
const map: Record<number, string> = {};
const options: { value: number; label: string }[] = [];
data.forEach(item => {
map[item.openType] = item.typeName;
options.push({ value: item.openType, label: item.typeName });
});
setOpenTypeMap(map);
} catch (error) {
console.error('加载开户方式列表失败:', error);
}
};
const loadOAuthList = async (page = 1, pageSize = 10, params = searchParams) => {
setListLoading(true);
try {
const response = await getOAuthAccountList({
advertiser_id: params.advertiser_id || '',
oauth_id: params.oauth_id || '',
advertiser_name: params.advertiser_name || '',
page,
page_size: pageSize,
});
if (response) {
if (response.data) {
setAuthorizations(response.data.data || response.data);
}
if (response.pagination) {
setTotal(response.pagination.total || 0);
setCurrentPage(response.pagination.page || 1);
setPageSize(response.pagination.pageSize || 10);
}
} else {
setAuthorizations(response || []);
}
} catch (error) {
message.error('获取授权列表失败');
} finally {
setListLoading(false);
}
};
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
},
{
title: '广告主账户ID',
dataIndex: 'advertiserId',
key: 'advertiserId',
width: 160,
},
{
title: '广告主账户名称',
dataIndex: 'advertiserName',
key: 'advertiserName',
},
{
title: '广告主账户角色',
dataIndex: 'advertiserRole',
key: 'advertiserRole',
},
{
title: '授权账户ID',
dataIndex: 'accountId',
key: 'accountId',
width: 160,
},
{
title: '授权账户名称',
dataIndex: 'accountName',
key: 'accountName',
},
{
title: '授权账户角色',
dataIndex: 'accountRole',
key: 'accountRole',
render: (role: string) => {
const roleMap: Record<string, string> = {
ADVERTISER: '客户',
CUSTOMER_ADMIN: '普通版工作台-管理员',
CUSTOMER_OPERATOR: '普通版工作台-协作者',
AGENT: '代理商',
CHILD_AGENT: '二级代理商',
PLATFORM_ROLE_STAR: '星图账户',
PLATFORM_ROLE_SHOP_ACCOUNT: '抖音店铺账户',
PLATFORM_ROLE_QIANCHUAN_AGENT: '千川代理商',
PLATFORM_ROLE_STAR_AGENT: '星图代理商',
PLATFORM_ROLE_AWEME: '抖音号',
PLATFORM_ROLE_STAR_MCN: '星图MCN机构',
PLATFORM_ROLE_STAR_ISV: '星图服务商',
AGENT_SYSTEM_ACCOUNT: '代理商系统账户',
PLATFORM_ROLE_LOCAL_AGENT: '本地推代理商',
PLATFORM_ROLE_YUNTU_BRAND_ISV_ADMIN: '云图品牌服务商管理员',
PLATFORM_ROLE_LIFE: '抖音来客账户',
PLATFORM_ROLE_ENTERPRISE_BP_ADMIN: '升级版工作台管理员',
PLATFORM_ROLE_ENTERPRISE_BP_OPERATOR: '升级版工作台协作者',
};
return roleMap[role] || role;
},
},
{
title: '授权账户登录账号ID',
dataIndex: 'accountUserid',
key: 'accountUserid',
},
{
title: '授权账户登录账号名称',
dataIndex: 'accountUsername',
key: 'accountUsername',
},
{
title: '授权ID',
dataIndex: 'oauthId',
key: 'oauthId',
},
{
title: '开户方式',
dataIndex: 'openType',
key: 'openType',
render: (text: number) => <span style={{ color: '#1e293b' }}>{openTypeMap[text] || text}</span>,
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
{
title: '操作',
fixed: 'right' as const,
dataIndex: 'action',
key: 'action',
render: (_: string, record: AuthorizationData) => (
<Space>
<Button
size="small"
danger
onClick={() => {
Modal.confirm({
title: '确认删除',
content: '确定要删除该授权账户吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await deleteOAuthAccount({ id: record.id });
message.success('删除成功');
loadOAuthList(currentPage, pageSize);
} catch (error) {
message.error('删除失败');
}
},
});
}}
>
</Button>
</Space>
),
},
];
const tableData = authorizations.map((item, index) => ({
...item,
index: index + 1,
key: item.id,
}));
return (
<div style={{ minHeight: '94vh' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<LockOutlined style={{ color: '#6366f1', fontSize: 16 }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 12 }}>
<Input
placeholder="广告主账户ID"
value={searchParams.advertiser_id}
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_id: e.target.value }))}
style={{ width: 180 }}
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
/>
<Input
placeholder="授权ID"
value={searchParams.oauth_id}
onChange={(e) => setSearchParams(prev => ({ ...prev, oauth_id: e.target.value }))}
style={{ width: 180 }}
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
/>
<Input
placeholder="广告主账户名称"
value={searchParams.advertiser_name}
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_name: e.target.value }))}
style={{ width: 180 }}
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
/>
<Button
type="primary"
size="medium"
onClick={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
>
</Button>
<Button
size="medium"
onClick={() => {
setSearchParams({ advertiser_id: '', oauth_id: '', advertiser_name: '' });
setCurrentPage(1);
loadOAuthList(1, pageSize);
}}
>
</Button>
</div>
</div>
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
<Table
dataSource={tableData}
columns={columns}
loading={listLoading}
pagination={false}
rowKey="id"
bordered={false}
scroll={{ x: 'max-content' }}
/>
<div style={{ padding: '16px', textAlign: 'right' }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
showSizeChanger
showTotal={(total) => `${total} 条记录`}
onChange={(page, size) => {
setCurrentPage(page);
setPageSize(size);
loadOAuthList(page, size);
}}
size="small"
/>
</div>
</div>
</div>
);
};
export default AuthAccountPage;
+23 -9
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Button, Table, Tag, Modal, Select, App, Input, Pagination, Typography, Space } from 'antd';
import { useNavigate } from 'react-router-dom';
import { PlusOutlined, LockOutlined } from '@ant-design/icons';
import { getOAuthList, requestOAuth, getOpenTypeAll } from '../api';
@@ -41,13 +42,23 @@ interface AuthorizationData {
id: string;
status: string;
description: string;
account_userid?: string;
open_type?: number;
account_id?: string;
advertiserId?: string;
advertiserName?: string;
accountRole?: string;
accountUserid?: string;
accountUsername?: string;
appid?: string;
materialAuthStatus?: boolean;
openType?: number;
portType?: number;
userId?: string;
createdAt?: string;
updatedAt?: string;
}
const AuthorizationPage: React.FC = () => {
const { message } = App.useApp();
const navigate = useNavigate();
const [authorizations, setAuthorizations] = useState<AuthorizationData[]>([]);
const [loading, setLoading] = useState(false);
const [listLoading, setListLoading] = useState(false);
@@ -250,14 +261,17 @@ const AuthorizationPage: React.FC = () => {
{
title: '操作',
dataIndex: 'action',
fixed: 'right' as const,
key: 'action',
width: 140,
render: (text: string) => (
<Space>
<Button type="primary" size="small" >
</Button>
</Space>
render: (_: unknown, record) => (
<Button
type="link"
onClick={() => navigate(`/consume?advertiserId=${record.advertiserId}`)}
style={{ color: '#6366f1', padding: 0 }}
>
</Button>
),
},
];
+7 -2
View File
@@ -31,7 +31,7 @@ const ConsumePage: React.FC = () => {
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [advertiserId, setAdvertiserId] = useState(searchParams.get('accountId') || '');
const [advertiserId, setAdvertiserId] = useState(searchParams.get('advertiserId') || '');
const [consumeDateRange, setConsumeDateRange] = useState<[string, string] | undefined>();
const [syncDate, setSyncDate] = useState<string>(dayjs().subtract(1, 'day').format('YYYY-MM-DD'));
const [syncAdvertiserId, setSyncAdvertiserId] = useState<string>('');
@@ -88,7 +88,12 @@ const ConsumePage: React.FC = () => {
}));
const handleBack = () => {
navigate('/authorization');
const referrer = document.referrer;
if (referrer.includes(window.location.origin)) {
navigate(-1);
} else {
navigate('/authorization');
}
};
const handleSearch = () => {
+10 -5
View File
@@ -43,6 +43,7 @@ import {
WarningOutlined,
SettingOutlined,
LayoutOutlined,
ArrowUpOutlined,
} from '@ant-design/icons';
@@ -1558,17 +1559,21 @@ const AIChatPage: React.FC = () => {
<Button
type="primary"
shape="circle"
icon={<SendOutlined />}
icon={<ArrowUpOutlined />}
onClick={handleSend}
disabled={!inputValue.trim() && currentMedia.length === 0}
loading={loading}
style={{
flexShrink: 0,
width: 48,
height: 48,
width: 40,
height: 40,
borderRadius: 14,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.4)',
background: (inputValue.trim() || currentMedia.length > 0)
? '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',
border: 'none',
}}
+24 -24
View File
@@ -37,7 +37,7 @@ const GeneratedRecord: React.FC = () => {
const [uploading, setUploading] = useState(false);
// 上传配置弹窗相关状态
// 推送配置弹窗相关状态
const [uploadConfigModalVisible, setUploadConfigModalVisible] = useState(false);
const [accountIdLists, setAccountIdLists] = useState<{
accountId: string;
@@ -68,7 +68,7 @@ const GeneratedRecord: React.FC = () => {
const [selectedHistoryAccounts, setSelectedHistoryAccounts] = useState<any[]>([]);
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
// 上传任务历史弹窗相关状态
// 推送任务历史弹窗相关状态
const [uploadHistoryModalVisible, setUploadHistoryModalVisible] = useState(false);
const [uploadHistoryList, setUploadHistoryList] = useState<any[]>([]);
const [uploadHistoryTotal, setUploadHistoryTotal] = useState(0);
@@ -671,7 +671,7 @@ const GeneratedRecord: React.FC = () => {
};
const handleBatchUploadSelected = () => {
if (selectedItems.size === 0) {
message.warning('请先选择要上传的媒体');
message.warning('请先选择要推送的媒体');
return;
}
setAccountIdLists([[]]);
@@ -741,7 +741,7 @@ const GeneratedRecord: React.FC = () => {
setUploadHistoryList(data || []);
setUploadHistoryTotal(res.pagination?.total || res.total || 0);
} catch (error) {
console.error('加载上传历史失败:', error);
console.error('加载推送历史失败:', error);
setUploadHistoryList([]);
setUploadHistoryTotal(0);
} finally {
@@ -763,10 +763,10 @@ const GeneratedRecord: React.FC = () => {
setUploadHistoryPageSize(pageSize);
loadUploadHistory();
};
// 批量上传素材
// 批量推送素材
const handleStartBatchUpload = async () => {
if (selectedItems.size === 0) {
message.warning('请先选择要上传的媒体');
message.warning('请先选择要推送的媒体');
return;
}
const itemMap = new Map<string, any>();
@@ -869,8 +869,8 @@ const GeneratedRecord: React.FC = () => {
setMaterialFileNames(new Map());
setUnifiedFileName('');
} catch (error: any) {
console.error('批量上传失败:', error);
message.error(error.message || '批量上传失败');
console.error('批量推送失败:', error);
message.error(error.message || '批量推送失败');
} finally {
setUploading(false);
}
@@ -1056,7 +1056,7 @@ const GeneratedRecord: React.FC = () => {
}, [filterType, filterMedia]);
return (
<div style={{ minHeight: 'calc(100vh - 90px)', background: '#ffffffff', overflowY: 'auto' }} >
{/* 操作栏:筛选 + 上传按钮 */}
{/* 操作栏:筛选 + 推送按钮 */}
<div style={{
display: 'flex',
alignItems: 'center',
@@ -1167,7 +1167,7 @@ const GeneratedRecord: React.FC = () => {
fontWeight: 600,
}}
>
{uploading ? '上传中...' : `推送至账户 (${selectedItems.size})`}
{uploading ? '推送中...' : `推送至账户 (${selectedItems.size})`}
</Button>
</Space>
) : (
@@ -1284,7 +1284,7 @@ const GeneratedRecord: React.FC = () => {
e.currentTarget.style.boxShadow = '0 4px 15px rgba(102, 126, 234, 0.4)';
}}
>
</Button>
</div>
{/* Content area */}
@@ -1369,9 +1369,9 @@ const GeneratedRecord: React.FC = () => {
)}
</div>
)}
{/* 上传配置弹窗 */}
{/* 推送配置弹窗 */}
<Modal
title={selectedItems.size === 1 ? '上传配置' : '批量上传配置'}
title={selectedItems.size === 1 ? '推送配置' : '批量推送配置'}
open={uploadConfigModalVisible}
onCancel={() => {
setUploadConfigModalVisible(false);
@@ -1845,15 +1845,15 @@ const GeneratedRecord: React.FC = () => {
disabled={uploading || (accountTab === 'new' ? accountIdLists.every(list => list.length === 0) : selectedHistoryAccounts.length === 0)}
style={{ borderRadius: 8 }}
>
{uploading ? '上传中...' : '开始上传'}
{uploading ? '推送中...' : '开始推送'}
</Button>
</div>
</div>
</Modal>
{/* 上传任务历史弹窗 */}
{/* 推送任务历史弹窗 */}
<Modal
title="上传任务历史"
title="推送任务历史"
open={uploadHistoryModalVisible}
onCancel={() => setUploadHistoryModalVisible(false)}
footer={null}
@@ -1868,10 +1868,10 @@ const GeneratedRecord: React.FC = () => {
placeholder="选择状态"
style={{ width: 200, marginRight: 12 }}
options={[
{ value: '1', label: '待上传' },
{ value: '2', label: '上传中' },
{ value: '3', label: '上传成功' },
{ value: '4', label: '上传失败' },
{ value: '1', label: '待推送' },
{ value: '2', label: '推送中' },
{ value: '3', label: '推送成功' },
{ value: '4', label: '推送失败' },
]}
allowClear
/>
@@ -1905,10 +1905,10 @@ const GeneratedRecord: React.FC = () => {
width: 100,
render: (status: string) => {
const statusMap: Record<string, string> = {
'1': '待上传',
'2': '上传中',
'3': '上传成功',
'4': '上传失败',
'1': '待推送',
'2': '推送中',
'3': '推送成功',
'4': '推送失败',
};
const statusColorMap: Record<string, string> = {
'1': '#f59e0b',
+29 -3
View File
@@ -1,5 +1,6 @@
.login-page {
min-height: 100vh;
height: 100vh;
max-height: 100vh;
display: flex;
flex-direction: column;
background-image: url(/backimage.png);
@@ -8,7 +9,7 @@
background-repeat: no-repeat;
background-attachment: fixed;
position: relative;
overflow-x: hidden;
overflow: hidden;
}
@media (min-width: 900px) {
@@ -180,10 +181,35 @@
.login-right-section {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
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) {
+191 -179
View File
@@ -44,8 +44,8 @@ const LoginPage: React.FC = () => {
const initialInfo = getInitialSiteInfo();
const [siteName, setSiteName] = useState(initialInfo.siteName);
const [siteLogo, setSiteLogo] = useState(initialInfo.siteLogo);
const [agreementUrl, setAgreementUrl] = useState('');
const [policyUrl, setPolicyUrl] = useState('');
const [agreementPrivacyUrl, setAgreementPrivacyUrl] = useState('');
const [siteCopyright, setSiteCopyright] = useState('');
const navigate = useNavigate();
const { login } = useAuthStore();
@@ -57,14 +57,14 @@ const LoginPage: React.FC = () => {
getSiteInfo().then(info => {
setSiteName(info.siteName);
setSiteLogo(info.siteLogo);
setAgreementUrl(info.userAgreementUrl);
setPolicyUrl(info.privacyPolicyUrl);
setAgreementPrivacyUrl(info.userAgreementPrivacyUrl);
setSiteCopyright(info.siteCopyright);
}).catch(() => {});
}, []);
const checkAgreed = (): boolean => {
if (!agreed) {
message.warning('请先阅读并同意用户协议隐私政策');
message.warning('请先阅读并同意用户协议隐私政策');
return false;
}
return true;
@@ -259,7 +259,15 @@ const LoginPage: React.FC = () => {
};
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 (
@@ -310,182 +318,186 @@ const LoginPage: React.FC = () => {
</div>
<div className="login-right-section">
<Card className="login-card" styles={{ body: { padding: '36px 28px' } }}>
<Typography.Title level={3} className="login-card-title">
{mode === 'register' ? '创建账号' : '欢迎回来'}
</Typography.Title>
<Typography.Text className="login-card-subtitle">
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'}
</Typography.Text>
<div className="login-right-section-inner">
<Card className="login-card" styles={{ body: { padding: '36px 28px' } }}>
<Typography.Title level={3} className="login-card-title">
{mode === 'register' ? '创建账号' : '欢迎回来'}
</Typography.Title>
<Typography.Text className="login-card-subtitle">
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'}
</Typography.Text>
{mode !== 'register' && (
<div className="login-tabs">
{(['password', 'phone'] as const).map((t) => (
<div key={t} onClick={() => switchTab(t)}
className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}>
{t === 'password' ? '密码登录' : '验证码登录'}
</div>
))}
</div>
)}
{mode === 'password' && (
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
<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 !== 'register' && (
<div className="login-tabs">
{(['password', 'phone'] as const).map((t) => (
<div key={t} onClick={() => switchTab(t)}
className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}>
{t === 'password' ? '密码登录' : '验证码登录'}
</div>
))}
</div>
)}
{mode === 'password' && (
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
<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>
</Card>
)}
</div>
</div>
);
+9 -4
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Button, Table, Tag, Input, Pagination, Typography, Select, App } from 'antd';
import { Link } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { FolderOpenOutlined, EyeOutlined } from '@ant-design/icons';
import { getResourcesMaterialList } from '../api';
import PreResultDisplay from '../components/PreResultDisplay';
@@ -81,6 +81,7 @@ interface MaterialData {
const MaterialListPage: React.FC = () => {
const { message } = App.useApp();
const navigate = useNavigate();
const [materials, setMaterials] = useState<MaterialData[]>([]);
const [listLoading, setListLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
@@ -269,12 +270,16 @@ const MaterialListPage: React.FC = () => {
},
{
title: '操作',
fixed: 'right' as const,
key: 'action',
width: 120,
render: (_: unknown, record) => (
<Link to={`/consume?accountId=${record.advertiserId}`} style={{ color: '#6366f1' }}>
<Button
type="link"
onClick={() => navigate(`/consume?advertiserId=${record.advertiserId}`)}
style={{ color: '#6366f1', padding: 0 }}
>
</Link>
</Button>
),
},
// {