dist/首页

This commit is contained in:
孙佳艺
2026-06-26 17:40:41 +08:00
100 changed files with 6422 additions and 3968 deletions
+76 -41
View File
@@ -1,21 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button, Table, Tag, Modal, Select, App, Input, Pagination, Typography } from 'antd';
import { PlusOutlined, LockOutlined } from '@ant-design/icons';
import { getOAuthList, requestOAuth } from '../api';
const OPEN_TYPE_MAP: Record<number, string> = {
1: '千川',
2: '广告',
3: '本地推',
4: '星图',
5: '快手代理商',
6: '巨量星图',
7: '巨量服务单',
8: '腾讯服务单',
9: '腾讯营销K2',
10: '腾讯营销K3',
};
import { getOAuthList, requestOAuth, getOpenTypeAll } from '../api';
const PORT_TYPE_MAP: Record<number, string> = {
1: '巨量',
@@ -25,7 +11,6 @@ const PORT_TYPE_MAP: Record<number, string> = {
5: '腾讯',
};
// 格式化时间 2026-06-12T03:47:28.542988Z -> 2026-06-12 03:47:28
const formatDateTime = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
@@ -38,6 +23,20 @@ const formatDateTime = (dateStr: string) => {
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
// 安全拼接URL,避免双斜杠
const buildUrl = (path: string): string => {
const baseUrl = import.meta.env.VITE_API_BASE || "http://localhost:8000";
// 如果已经是完整URL(以http://或https://开头),直接返回
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// 移除路径开头的斜杠(如果有)
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
// 移除baseUrl结尾的斜杠(如果有)
const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
return `${cleanBase}/${cleanPath}`;
};
interface AuthorizationData {
id: string;
status: string;
@@ -62,11 +61,33 @@ const AuthorizationPage: React.FC = () => {
open_type: undefined as number | undefined,
account_id: '',
});
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
const [openTypeOptions, setOpenTypeOptions] = useState<{ value: number; label: string }[]>([]);
const [openTypeList, setOpenTypeList] = useState<any[]>([]);
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);
setOpenTypeOptions(options);
setOpenTypeList(data);
} catch (error) {
console.error('加载开户方式列表失败:', error);
}
};
const loadOAuthList = async (page = 1, pageSize = 10, params = searchParams) => {
setListLoading(true);
try {
@@ -121,6 +142,7 @@ const AuthorizationPage: React.FC = () => {
setSelectedOpenType(undefined);
}
};
const columns = [
{
title: 'ID',
@@ -197,7 +219,7 @@ const AuthorizationPage: React.FC = () => {
title: '开户方式',
dataIndex: 'openType',
key: 'openType',
render: (text: number) => <span style={{ color: '#1e293b' }}>{OPEN_TYPE_MAP[text] || text}</span>,
render: (text: number) => <span style={{ color: '#1e293b' }}>{openTypeMap[text] || text}</span>,
},
{
title: '平台端口',
@@ -225,16 +247,6 @@ const AuthorizationPage: React.FC = () => {
width: 160,
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: unknown, record: AuthorizationData) => (
<Link to={`/consume?accountId=${record.id}`} style={{ color: '#6366f1' }}>
</Link>
),
},
];
const tableData = authorizations.map((item, index) => ({
@@ -264,10 +276,7 @@ const AuthorizationPage: React.FC = () => {
value={searchParams.open_type}
onChange={(value) => setSearchParams(prev => ({ ...prev, open_type: value }))}
style={{ width: 140 }}
options={Object.entries(OPEN_TYPE_MAP).map(([key, value]) => ({
value: Number(key),
label: value,
}))}
options={openTypeOptions}
/>
<Input
placeholder="账号ID"
@@ -348,17 +357,43 @@ const AuthorizationPage: React.FC = () => {
okText="确认授权"
cancelText="取消"
confirmLoading={loading}
width={700}
>
<Select
placeholder="请选择开户方式"
value={selectedOpenType}
onChange={(value) => setSelectedOpenType(value)}
style={{ width: '100%' }}
options={Object.entries(OPEN_TYPE_MAP).map(([key, value]) => ({
value: Number(key),
label: value,
}))}
/>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', maxHeight: 420, overflowY: 'auto' }}>
{openTypeList.map((item) => (
<div
key={item.id}
onClick={() => setSelectedOpenType(item.openType)}
style={{
width: 'calc(33.33% - 12px)',
cursor: 'pointer',
borderRadius: 12,
border: `2px solid ${selectedOpenType === item.openType ? '#6366f1' : '#e2e8f0'}`,
padding: 16,
transition: 'all 0.3s ease',
background: selectedOpenType === item.openType ? '#f0f1ff' : '#fff',
}}
>
<div style={{ width: '100%', height: 120, marginBottom: 12, borderRadius: 8, overflow: 'hidden' }}>
{item.thumb ? (
<img
src={buildUrl(item.thumb)}
alt={item.typeName}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<div style={{ width: '100%', height: '100%', background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography.Text type="secondary"></Typography.Text>
</div>
)}
</div>
<Typography.Text strong style={{ fontSize: 14, color: '#1e293b' }}>{item.typeName}</Typography.Text>
<p style={{ fontSize: 12, color: '#64748b', marginTop: 8, marginBottom: 0, lineHeight: 1.5 }}>
{item.description}
</p>
</div>
))}
</div>
</Modal>
</div>
);
@@ -0,0 +1,161 @@
import React, { useState, useEffect } from 'react';
import { Typography, Spin, Button, App } from 'antd';
import { ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { juliang_callback, getOAuthList } from '../api';
const AuthorizationWaitingPage: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { message } = App.useApp();
const [countdown, setCountdown] = useState(0);
const [isChecking, setIsChecking] = useState(true);
const [authSuccess, setAuthSuccess] = useState(false);
const [authError, setAuthError] = useState('');
useEffect(() => {
const authCode = searchParams.get('auth_code');
const state = searchParams.get('state');
if (!authCode || !state) {
setIsChecking(false);
setCountdown(10);
return;
}
const params = {
auth_code: authCode,
state: state,
app_id: searchParams.get('app_id') || undefined,
material_auth_status: searchParams.get('material_auth_status') || undefined,
scope: searchParams.get('scope') || undefined,
uid: searchParams.get('uid') || undefined,
};
const handleCallback = async () => {
try {
const res = await juliang_callback(params);
setAuthSuccess(true);
setIsChecking(false);
message.success(res?.message || '授权成功');
setTimeout(() => {
navigate('/authorization');
}, 1500);
} catch (error: any) {
const errorMsg = error?.message || '授权失败';
setAuthError(errorMsg);
setIsChecking(false);
message.error(errorMsg);
}
};
handleCallback();
const timer = setInterval(() => {
setCountdown(prev => {
if (prev >= 11) {
setIsChecking(false);
return prev;
}
return prev + 1;
});
}, 1000);
return () => {
clearInterval(timer);
};
}, [navigate, searchParams, message]);
const handleRetry = () => {
setCountdown(0);
setIsChecking(true);
setAuthSuccess(false);
setAuthError('');
window.location.href = '/authorization';
};
if (authSuccess) {
return (
<div style={{ minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center', background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)' }}>
<div style={{ textAlign: 'center', padding: '60px 80px', background: '#fff', borderRadius: 16, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' }}>
<div style={{ width: 80, height: 80, background: '#dcfce7', borderRadius: '50%', display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '0 auto 24px' }}>
<Typography.Text style={{ fontSize: 40, color: '#22c55e' }}></Typography.Text>
</div>
<Typography.Title level={3} style={{ marginBottom: 16, color: '#1e293b' }}></Typography.Title>
<Typography.Text style={{ color: '#64748b' }}>...</Typography.Text>
</div>
</div>
);
}
return (
<div style={{ minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center', background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)' }}>
<div style={{ textAlign: 'center', padding: '60px 80px', background: '#fff', borderRadius: 16, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' }}>
<div style={{ width: 80, height: 80, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: '50%', display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '0 auto 24px' }}>
{isChecking && countdown < 10 ? (
<Spin size="large" tip="加载中" style={{ color: '#fff' }} />
) : (
<ClockCircleOutlined style={{ fontSize: 40, color: '#fff' }} />
)}
</div>
{authError ? (
<>
<Typography.Title level={3} style={{ marginBottom: 16, color: '#ef4444' }}></Typography.Title>
<Typography.Text style={{ color: '#64748b', marginBottom: 24, display: 'block' }}>{authError}</Typography.Text>
<Button
type="primary"
size="large"
icon={<ReloadOutlined />}
onClick={handleRetry}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none',
fontWeight: 600,
padding: '12px 32px',
}}
>
</Button>
</>
) : countdown >= 10 ? (
<>
<Typography.Title level={3} style={{ marginBottom: 16, color: '#ef4444' }}></Typography.Title>
<Typography.Text style={{ color: '#64748b', marginBottom: 24, display: 'block' }}></Typography.Text>
<Button
type="primary"
size="large"
icon={<ReloadOutlined />}
onClick={handleRetry}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none',
fontWeight: 600,
padding: '12px 32px',
}}
>
</Button>
</>
) : (
<>
<Typography.Title level={3} style={{ marginBottom: 16, color: '#1e293b' }}></Typography.Title>
<Typography.Text style={{ color: '#64748b', marginBottom: 24, display: 'block' }}>
</Typography.Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<ClockCircleOutlined style={{ color: '#6366f1' }} />
<Typography.Text style={{ color: '#6366f1', fontWeight: 500, fontSize: 16 }}>
{countdown}s
</Typography.Text>
</div>
</>
)}
</div>
</div>
);
};
export default AuthorizationWaitingPage;
+4 -4
View File
@@ -41,12 +41,12 @@ const ConsumePage: React.FC = () => {
loadData();
}, []);
const loadData = async (page = 1, pageSizeNum = 10) => {
const loadData = async (page = 1, pageSizeNum = 10, consumeDate?: [string, string] | null, advertiserIdParam?: string | null) => {
setLoading(true);
try {
const res = await getMaterialConsumpList({
advertiser_id: advertiserId || undefined,
consume_date: consumeDateRange,
advertiser_id: advertiserIdParam != null ? advertiserIdParam : advertiserId,
consume_date: consumeDate != null ? consumeDate : consumeDateRange,
page,
page_size: pageSizeNum,
});
@@ -100,7 +100,7 @@ const ConsumePage: React.FC = () => {
setAdvertiserId('');
setConsumeDateRange(undefined);
setCurrentPage(1);
loadData(1, pageSize);
loadData(1, pageSize, null, '');
};
const handleSync = () => {
@@ -0,0 +1,134 @@
import React from 'react';
import { Button, Card, message, Typography } from 'antd';
import {
StarOutlined,
LockOutlined,
GiftOutlined,
ThunderboltOutlined,
PlayCircleOutlined,
CheckCircleOutlined,
HeartOutlined,
} from '@ant-design/icons';
const CreativePlazaPage: React.FC = () => {
const handleUnlock = () => {
message.info('请联系客服开通');
};
const features = [
{
icon: <ThunderboltOutlined style={{ fontSize: 28, color: '#8b5cf6' }} />,
title: 'AI智能生成',
description: '先进的AI技术,一键生成高质量视频内容',
},
{
icon: <PlayCircleOutlined style={{ fontSize: 28, color: '#ec4899' }} />,
title: '多格式输出',
description: '支持多种视频格式,满足不同场景需求',
},
{
icon: <CheckCircleOutlined style={{ fontSize: 28, color: '#10b981' }} />,
title: '品质保证',
description: '专业级画质,细节清晰,色彩鲜艳',
},
{
icon: <HeartOutlined style={{ fontSize: 28, color: '#f59e0b' }} />,
title: '创意无限',
description: '丰富的模板和风格,激发创作灵感',
},
];
return (
<div style={{ padding: '20px 0' }}>
<div style={{ marginBottom: 32 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{
width: 48,
height: 48,
borderRadius: 14,
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 16px rgba(139,92,246,0.3)',
}}>
<GiftOutlined style={{ fontSize: 22, color: '#fff' }} />
</div>
<div>
<Typography.Title level={2} style={{ margin: 0, color: '#1e293b', fontWeight: 700 }}>
<span style={{ color: '#6366f1', fontSize: 14, fontWeight: 400, marginLeft: 8 }}>
AI创作无限可能
</span>
</Typography.Title>
</div>
</div>
<Typography.Text style={{ color: '#64748b' }}>
AI生成的优秀素材案例AI创作的无限潜力与创意灵感
</Typography.Text>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 20, marginBottom: 32 }}>
{features.map((feature, index) => (
<Card
key={index}
bordered={false}
style={{
borderRadius: 16,
background: 'linear-gradient(135deg, rgba(139,92,246,0.04) 0%, rgba(99,102,241,0.04) 100%)',
border: '1px solid rgba(139,92,246,0.1)',
padding: 20,
}}
>
<div style={{ textAlign: 'center' }}>
<div style={{ marginBottom: 16 }}>{feature.icon}</div>
<Typography.Title level={4} style={{ marginBottom: 8, color: '#1e293b' }}>
{feature.title}
</Typography.Title>
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>
{feature.description}
</Typography.Text>
</div>
</Card>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<Typography.Title level={4} style={{ margin: 0, color: '#1e293b' }}>
<StarOutlined style={{ marginRight: 8, color: '#f59e0b' }} />
</Typography.Title>
<Button
type="primary"
size="middle"
onClick={handleUnlock}
style={{
borderRadius: 10,
background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)',
border: 'none',
}}
>
<LockOutlined style={{ marginRight: 6 }} />
</Button>
</div>
<Card
bordered={false}
style={{
borderRadius: 16,
background: '#fafafa',
padding: 40,
textAlign: 'center',
}}
>
<div style={{ color: '#94a3b8', fontSize: 14 }}>
</div>
</Card>
</div>
);
};
export default CreativePlazaPage;
File diff suppressed because it is too large Load Diff
+288 -211
View File
@@ -39,21 +39,21 @@ const GeneratedRecord: React.FC = () => {
// 上传配置弹窗相关状态
const [uploadConfigModalVisible, setUploadConfigModalVisible] = useState(false);
const [accountIdList, setAccountIdList] = useState<{
const [accountIdLists, setAccountIdLists] = useState<{
accountId: string;
}[]>([]);
const [accountIdInput, setAccountIdInput] = useState('');
}[][]>([[]]);
const [accountIdInputs, setAccountIdInputs] = useState<string[]>(['']);
const [oauthList, setOauthList] = useState<any[]>([]);
const [oauthLoading, setOauthLoading] = useState(false);
const [oauthTotal, setOauthTotal] = useState(0);
const [selectedOauthItems, setSelectedOauthItems] = useState<{ value: string; label: string } | undefined>(undefined);
const [selectedOauthItems, setSelectedOauthItems] = useState<({ value: string; label: string } | undefined)[]>([undefined]);
const [materialFileNames, setMaterialFileNames] = useState<Map<string, string>>(new Map());
const [unifiedFileName, setUnifiedFileName] = useState('');
const updateFilenameDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [oauthPage, setOauthPage] = useState(1);
const [oauthPageSize, setOauthPageSize] = useState(10);
const [oauthSelectOpen, setOauthSelectOpen] = useState(false);
const [oauthSelectOpens, setOauthSelectOpens] = useState<boolean[]>([false]);
// 上传任务历史弹窗相关状态
const [uploadHistoryModalVisible, setUploadHistoryModalVisible] = useState(false);
@@ -682,8 +682,21 @@ const GeneratedRecord: React.FC = () => {
message.warning('请先选择要上传的媒体');
return;
}
setAccountIdList([]);
setAccountIdInput('');
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setUploadConfigModalVisible(true);
};
const handleSinglePushToMedia = () => {
if (!previewItem) return;
const resourceId = getItemResourceId(previewItem);
setSelectedItems(new Set([resourceId]));
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setMaterialFileNames(new Map());
setUnifiedFileName('');
setUploadConfigModalVisible(true);
};
@@ -743,7 +756,8 @@ const GeneratedRecord: React.FC = () => {
// 批量上传素材
const handleStartBatchUpload = async () => {
if (!selectedOauthItems) {
const validOauthItems = selectedOauthItems.filter(item => item !== undefined);
if (validOauthItems.length === 0) {
message.warning('请先选择授权账户');
return;
}
@@ -753,14 +767,6 @@ const GeneratedRecord: React.FC = () => {
}
setUploading(true);
try {
const tasks: {
advertiser_ids: string[];
resource_ids: string[];
oauth_id: string;
source_model: string;
}[] = [];
const advertiserIds = accountIdList.map(account => account.accountId);
// 创建itemId到item对象的映射
const itemMap = new Map<string, any>();
recordlist.forEach((group: any) => {
group.items.forEach((item: any) => {
@@ -769,32 +775,64 @@ const GeneratedRecord: React.FC = () => {
});
});
for (const itemId of selectedItems) {
const item = itemMap.get(itemId);
// 根据item是否有generatedResourceId来决定source_model
let sourceModel: string;
if (item && hasGeneratedResourceId(item)) {
sourceModel = 'generated_resources';
} else {
sourceModel = filterType === 'project' ? 'generation_records' : 'chat_generation_tasks';
const tasks: {
advertiser_ids: string[];
resource_ids: string[];
oauth_id: string;
source_model: string;
}[] = [];
for (let i = 0; i < validOauthItems.length; i++) {
const oauthItem = validOauthItems[i];
const advertiserIds = accountIdLists[i]?.map(account => account.accountId) || [];
if (advertiserIds.length === 0) {
message.warning(`${i + 1} 组授权账户未设置账户ID,已跳过`);
continue;
}
tasks.push({
advertiser_ids: advertiserIds,
resource_ids: [itemId],
oauth_id: selectedOauthItems.value,
source_model: sourceModel,
const sourceModelMap = new Map<string, string[]>();
for (const itemId of selectedItems) {
const item = itemMap.get(itemId);
let sourceModel: string;
if (item && hasGeneratedResourceId(item)) {
sourceModel = 'generated_resources';
} else {
sourceModel = filterType === 'project' ? 'generation_records' : 'chat_generation_tasks';
}
if (!sourceModelMap.has(sourceModel)) {
sourceModelMap.set(sourceModel, []);
}
sourceModelMap.get(sourceModel)!.push(itemId);
}
sourceModelMap.forEach((resourceIds, sourceModel) => {
tasks.push({
advertiser_ids: advertiserIds,
resource_ids: resourceIds,
oauth_id: oauthItem.value,
source_model: sourceModel,
});
});
}
await asyncBatchUploadMaterial({ tasks });
message.success(`已提交 ${tasks.length} 个上传任务,后台异步处理中`);
const res = await asyncBatchUploadMaterial({ tasks });
if (res.errors?.length > 0) {
message.warning(res.message);
} else if (res.code === 0) {
message.success(res.message);
} else {
message.error(res.message);
}
setIsSelectionMode(false);
setSelectedItems(new Set());
// 关闭弹窗并清理状态
setUploadConfigModalVisible(false);
setAccountIdList([]);
setAccountIdInput('');
setSelectedOauthItems(undefined);
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setMaterialFileNames(new Map());
setUnifiedFileName('');
} catch (error: any) {
@@ -846,8 +884,8 @@ const GeneratedRecord: React.FC = () => {
// 批量更新 recordlist 中的文件名,使用 API 返回的 new_file_name
const resultsMap = new Map<string, string>();
response?.results?.forEach((r: any) => {
if (r.success && r.new_file_name) {
resultsMap.set(r.source_id, r.new_file_name);
if (r.success && r.newFileName) {
resultsMap.set(r.sourceId, r.newFileName);
}
});
setRecordList(prevList => {
@@ -864,7 +902,8 @@ const GeneratedRecord: React.FC = () => {
}),
}));
});
const successCount = response?.success_count || 0;
console.log(response);
const successCount = response?.successCount || 0;
message.success(`已更新 ${successCount} 个文件名`);
} catch (error: any) {
console.error('文件名更新失败:', error);
@@ -1322,13 +1361,13 @@ const GeneratedRecord: React.FC = () => {
{/* 上传配置弹窗 */}
<Modal
title="批量上传配置"
title={selectedItems.size === 1 ? '上传配置' : '批量上传配置'}
open={uploadConfigModalVisible}
onCancel={() => {
setUploadConfigModalVisible(false);
setAccountIdList([]);
setAccountIdInput('');
setSelectedOauthItems(undefined);
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setMaterialFileNames(new Map());
setUnifiedFileName('');
}}
@@ -1432,7 +1471,7 @@ const GeneratedRecord: React.FC = () => {
</Typography.Text>
</div>
<Input
value={materialFileNames.get(itemId) || item?.fileName || ''}
value={materialFileNames.has(itemId) ? materialFileNames.get(itemId)! : item?.fileName || ''}
onChange={(e) => {
const newName = e.target.value;
const newNames = new Map(materialFileNames);
@@ -1456,147 +1495,196 @@ const GeneratedRecord: React.FC = () => {
})()}
</div>
</div>
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block' }}>
</Typography.Text>
<Select
value={selectedOauthItems}
onChange={(value) => {
setSelectedOauthItems(value as { value: string; label: string } | undefined);
}}
placeholder="点击选择授权账户"
style={{ width: '100%', marginBottom: 16, borderRadius: 8 }}
popupRender={() => (
<div style={{ padding: 8, width: 800, maxHeight: 500, overflow: 'auto' }}>
<Table
dataSource={oauthList}
columns={[
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 120,
},
{
title: '授权账户名称',
dataIndex: 'accountName',
key: 'accountName',
width: 120,
},
{
title: '授权应用ID',
dataIndex: 'appid',
key: 'appid',
width: 120,
},
{
title: '授权用户ID',
dataIndex: 'accountUserid',
key: 'accountUserid',
width: 120,
},
{
title: '授权账户角色',
dataIndex: 'accountRole',
key: 'accountRole',
width: 200,
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: '授权账户用户名',
dataIndex: 'accountUsername',
key: 'accountUsername',
width: 120,
render: (text: string) => <span style={{ color: text ? '#1e293b' : '#94a3b8' }}>{text || '-'}</span>,
},
]}
loading={oauthLoading}
pagination={{
current: oauthPage,
pageSize: oauthPageSize,
total: oauthTotal,
showSizeChanger: true,
showTotal: (total) => `${total} 条记录`,
onChange: (page, size) => {
setOauthPage(page);
setOauthPageSize(size);
loadOAuthList(page, size);
},
}}
rowKey="id"
size="small"
onRow={(record) => ({
onClick: () => {
const id = String(record.id);
setSelectedOauthItems({ value: id, label: String(record.accountUserid) });
},
style: {
cursor: 'pointer',
backgroundColor: selectedOauthItems?.value === String(record.id) ? '#e6f7ff' : undefined,
},
})}
/>
{selectedOauthItems.map((oauthItem, index) => (
<div key={index} style={{ marginBottom: 16, padding: 12, border: '1px solid #e2e8f0', borderRadius: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 14, color: '#475569' }}>
{index + 1}
</Typography.Text>
{selectedOauthItems.length > 1 && (
<Button
type="text"
danger
onClick={() => {
const newOauthItems = [...selectedOauthItems];
const newAccountIdInputs = [...accountIdInputs];
const newAccountIdLists = [...accountIdLists];
const newOauthSelectOpens = [...oauthSelectOpens];
newOauthItems.splice(index, 1);
newAccountIdInputs.splice(index, 1);
newAccountIdLists.splice(index, 1);
newOauthSelectOpens.splice(index, 1);
setSelectedOauthItems(newOauthItems);
setAccountIdInputs(newAccountIdInputs);
setAccountIdLists(newAccountIdLists);
setOauthSelectOpens(newOauthSelectOpens);
}}
>
</Button>
)}
</div>
)}
open={oauthSelectOpen}
onOpenChange={(open) => {
setOauthSelectOpen(open);
if (open) {
loadOAuthList(1, oauthPageSize);
}
}}
labelInValue
fieldNames={{ label: 'accountUserid', value: 'id' }}
/>
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block', marginTop: 16 }}>
ID
</Typography.Text>
<Input.TextArea
value={accountIdInput}
onChange={(e) => {
const value = e.target.value;
setAccountIdInput(value);
const ids = value.split(/[\n,]/)
.map(line => line.trim())
.filter(line => line.length > 0);
const uniqueIds = [...new Set(ids)];
const textAccounts = uniqueIds.map(id => ({ accountId: id }));
const seen = new Set<string>();
const finalAccounts = textAccounts.filter(a => {
if (seen.has(a.accountId)) return false;
seen.add(a.accountId);
return true;
});
setAccountIdList(finalAccounts);
}}
placeholder="粘贴账户ID,每行一个或用逗号分隔,例如:
<Select
value={oauthItem}
onChange={(value) => {
const newOauthItems = [...selectedOauthItems];
newOauthItems[index] = value as { value: string; label: string } | undefined;
setSelectedOauthItems(newOauthItems);
}}
placeholder="点击选择授权账户"
style={{ width: '100%', marginBottom: 12, borderRadius: 8 }}
popupRender={() => (
<div style={{ padding: 8, width: 800, maxHeight: 500, overflow: 'auto' }}>
<Table
dataSource={oauthList}
columns={[
{
title: '授权账户ID',
dataIndex: 'accountId',
key: 'accountId',
width: 120,
},
{
title: '授权账户名称',
dataIndex: 'accountName',
key: 'accountName',
width: 120,
},
{
title: '授权账户角色',
dataIndex: 'accountRole',
key: 'accountRole',
width: 160,
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: '授权账户用户名',
dataIndex: 'accountUsername',
key: 'accountUsername',
width: 120,
render: (text: string) => <span style={{ color: text ? '#1e293b' : '#94a3b8' }}>{text || '-'}</span>,
},
{
title: '授权用户ID',
dataIndex: 'accountUserid',
key: 'accountUserid',
width: 120,
},
]}
loading={oauthLoading}
pagination={{
current: oauthPage,
pageSize: oauthPageSize,
total: oauthTotal,
showSizeChanger: true,
showTotal: (total) => `${total} 条记录`,
onChange: (page, size) => {
setOauthPage(page);
setOauthPageSize(size);
loadOAuthList(page, size);
},
}}
rowKey="id"
size="small"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
const id = String(record.id);
const newOauthItems = [...selectedOauthItems];
newOauthItems[index] = { value: id, label: String(record.accountId)+'-'+(record.accountName || '-') };
setSelectedOauthItems(newOauthItems);
const newOauthSelectOpens = [...oauthSelectOpens];
newOauthSelectOpens[index] = false;
setOauthSelectOpens(newOauthSelectOpens);
},
style: {
cursor: 'pointer',
backgroundColor: oauthItem?.value === String(record.id) ? '#e6f7ff' : undefined,
},
})}
/>
</div>
)}
open={oauthSelectOpens[index]}
onOpenChange={(open) => {
const newOauthSelectOpens = [...oauthSelectOpens];
newOauthSelectOpens[index] = open;
setOauthSelectOpens(newOauthSelectOpens);
if (open) {
loadOAuthList(1, oauthPageSize);
}
}}
labelInValue
fieldNames={{ label: 'accountUserid', value: 'id' }}
/>
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block' }}>
ID
</Typography.Text>
<Input.TextArea
value={accountIdInputs[index]}
onChange={(e) => {
const value = e.target.value;
const newAccountIdInputs = [...accountIdInputs];
newAccountIdInputs[index] = value;
setAccountIdInputs(newAccountIdInputs);
const ids = value.split(/[\n,]/)
.map(line => line.trim())
.filter(line => line.length > 0);
const uniqueIds = [...new Set(ids)];
const textAccounts = uniqueIds.map(id => ({ accountId: id }));
const seen = new Set<string>();
const finalAccounts = textAccounts.filter(a => {
if (seen.has(a.accountId)) return false;
seen.add(a.accountId);
return true;
});
const newAccountIdLists = [...accountIdLists];
newAccountIdLists[index] = finalAccounts;
setAccountIdLists(newAccountIdLists);
}}
placeholder="粘贴账户ID,每行一个或用逗号分隔,例如:
10001,10002,10003
10004"
rows={4}
rows={3}
style={{ borderRadius: 8 }}
/>
</div>
))}
<Button
type="dashed"
block
onClick={() => {
setSelectedOauthItems([...selectedOauthItems, undefined]);
setAccountIdInputs([...accountIdInputs, '']);
setAccountIdLists([...accountIdLists, []]);
setOauthSelectOpens([...oauthSelectOpens, false]);
}}
style={{ borderRadius: 8, marginBottom: 16 }}
/>
>
+
</Button>
{/* 操作按钮 */}
<div style={{
@@ -1607,9 +1695,9 @@ const GeneratedRecord: React.FC = () => {
<Button
onClick={() => {
setUploadConfigModalVisible(false);
setAccountIdList([]);
setAccountIdInput('');
setSelectedOauthItems(undefined);
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setMaterialFileNames(new Map());
setUnifiedFileName('');
}}
@@ -1621,7 +1709,7 @@ const GeneratedRecord: React.FC = () => {
type="primary"
onClick={handleStartBatchUpload}
loading={uploading}
disabled={uploading || accountIdList.length === 0}
disabled={uploading || accountIdLists.every(list => list.length === 0)}
style={{ borderRadius: 8 }}
>
{uploading ? '上传中...' : '开始上传'}
@@ -1677,25 +1765,6 @@ const GeneratedRecord: React.FC = () => {
key: 'advertiserId',
width: 180,
},
// {
// title: '状态',
// dataIndex: 'status',
// key: 'status',
// width: 100,
// render: (status: number, record: any) => {
// const statusColorMap: Record<number, string> = {
// 1: '#f59e0b',
// 2: '#6366f1',
// 3: '#10b981',
// 4: '#ef4444',
// };
// return (
// <Tag color={statusColorMap[status] || '#64748b'} style={{ borderRadius: 4 }}>
// {record.status_text || status}
// </Tag>
// );
// },
// },
{
title: '状态',
dataIndex: 'status',
@@ -1725,7 +1794,7 @@ const GeneratedRecord: React.FC = () => {
title: '备注',
dataIndex: 'note',
key: 'note',
width: 250,
width: 160,
ellipsis: true,
render: (note: string) => (
<span style={{ color: '#94a3b8' }}>
@@ -1735,10 +1804,17 @@ const GeneratedRecord: React.FC = () => {
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
render: (text: string) => formatDateTime(text),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (text: string) => formatDateTime(text),
},
]}
@@ -2069,15 +2145,16 @@ const GeneratedRecord: React.FC = () => {
</Button>
</div>
{/* <div>
<div>
<Button
style={{ width: '100%', borderRadius: 8, marginTop: 20, color: '#4c49cc' }}
type="primary"
onClick={handleSinglePushToMedia}
style={{ width: '100%', borderRadius: 8, marginTop: 20 }}
disabled={isMediaExpired(previewItem.videoUrl || previewItem.imageUrl)}
>
</Button>
</div> */}
</div>
</div>
</div>
</div>
+377
View File
@@ -0,0 +1,377 @@
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background-image: url(/backimage.png);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background-attachment: fixed;
position: relative;
overflow-x: hidden;
}
@media (min-width: 900px) {
.login-page {
flex-direction: row;
}
}
.login-bg-overlay {
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(240,244,255,0.9) 0%, rgba(232,236,248,0.85) 40%, rgba(240,240,255,0.9) 70%, rgba(248,249,255,0.95) 100%);
z-index: 0;
}
.login-decoration {
position: absolute;
border-radius: 50%;
filter: blur(40px);
z-index: 0;
}
.login-decoration-1 {
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%);
top: -150px;
right: -100px;
}
.login-decoration-2 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%);
bottom: -100px;
left: -80px;
filter: blur(50px);
}
.login-left-section {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 32px 16px;
z-index: 1;
}
@media (min-width: 900px) {
.login-left-section {
padding: 0 80px;
}
}
.login-left-content {
width: 100%;
}
.login-logo-row {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 20px;
}
@media (max-width: 480px) {
.login-logo-row {
justify-content: center;
margin-bottom: 16px;
}
}
.login-logo-img {
width: 52px;
height: 52px;
border-radius: 14px;
objectFit: contain;
}
.login-logo-placeholder {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(99,102,241,0.25);
}
.login-site-name {
color: #1e293b;
font-size: 28px;
font-weight: 800;
letter-spacing: -0.5px;
}
@media (max-width: 480px) {
.login-site-name {
font-size: 24px;
}
}
.login-desc {
color: #64748b !important;
font-size: 17px !important;
max-width: 480px;
line-height: 1.8 !important;
margin: 0 !important;
}
@media (max-width: 899px) {
.login-desc {
display: none !important;
}
}
.login-features-list {
display: none;
flex-direction: column;
gap: 20px;
}
@media (min-width: 900px) {
.login-features-list {
display: flex;
}
}
.login-feature-card {
position: relative;
display: flex;
gap: 16px;
align-items: flex-start;
padding: 18px 22px;
border-radius: 14px;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(12px);
overflow: hidden;
}
.login-feature-icon {
width: 44px;
height: 44px;
border-radius: 12px;
flex-shrink: 0;
background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15));
display: flex;
align-items: center;
justify-content: center;
color: #6366f1;
font-size: 20px;
}
.login-feature-title {
color: #1e293b !important;
font-size: 15px !important;
font-weight: 600 !important;
display: block !important;
margin-bottom: 4px !important;
}
.login-feature-desc {
color: #64748b !important;
font-size: 13px !important;
line-height: 1.6 !important;
}
.login-right-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
padding: 16px 16px 32px;
}
@media (min-width: 900px) {
.login-right-section {
padding: 40px 24px;
}
}
.login-card {
width: 100%;
max-width: 440px;
border-radius: 20px !important;
box-shadow: 0 20px 60px rgba(0,0,0,0.08) !important;
border: 1px solid #e2e8f0 !important;
background: #fff !important;
}
@media (max-width: 480px) {
.login-card {
border-radius: 16px !important;
}
.login-card .ant-card-body {
padding: 24px 20px !important;
}
}
.login-card-title {
text-align: center !important;
margin-bottom: 6px !important;
color: #1e293b !important;
font-weight: 700 !important;
}
.login-card-subtitle {
display: block;
text-align: center;
margin-bottom: 28px;
color: #94a3b8;
font-size: 14px;
}
.login-tabs {
display: flex;
gap: 0;
margin-bottom: 24px;
background: #f1f5f9;
border-radius: 10px;
padding: 4px;
border: 1px solid #e2e8f0;
}
.login-tab {
flex: 1;
text-align: center;
padding: 10px 0;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 400;
color: #64748b;
background: transparent;
border: 1px solid transparent;
transition: all 0.3s ease;
}
.login-tab-active {
font-weight: 600 !important;
color: #6366f1 !important;
background: #fff !important;
border: 1px solid rgba(99,102,241,0.2) !important;
box-shadow: 0 2px 8px rgba(99,102,241,0.1) !important;
}
.login-submit-btn {
height: 48px !important;
border-radius: 10px !important;
font-size: 16px !important;
font-weight: 600 !important;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
border: none !important;
box-shadow: 0 8px 24px rgba(99,102,241,0.25) !important;
}
.login-code-btn {
height: 48px !important;
border-radius: 0 10px 10px 0 !important;
border: 1.5px solid #e2e8f0 !important;
border-left: none !important;
font-weight: 600 !important;
min-width: 100px !important;
}
.login-agreement {
margin-bottom: 16px;
}
.login-agreement-text {
font-size: 13px;
color: #64748b;
}
.login-link {
color: #6366f1;
cursor: pointer;
}
.login-footer {
display: flex;
justify-content: center;
}
@media (min-width: 480px) {
.login-footer {
justify-content: flex-start;
}
}
.login-switch-btn {
font-size: 13px !important;
color: #6366f1 !important;
cursor: pointer;
}
.login-page .ant-input-affix-wrapper {
padding: 0 11px !important;
height: 48px !important;
}
.login-page .ant-input-affix-wrapper .ant-input-prefix {
margin-right: 10px !important;
margin-left: 0 !important;
}
.login-page .ant-input {
padding-left: 11px !important;
height: 48px !important;
}
.login-code-btn {
height: 48px !important;
}
@media (max-width: 767px) {
.login-left-section {
padding: 28px 16px 12px;
}
.login-right-section {
padding: 12px 16px 32px;
}
.login-card {
max-width: 100%;
}
}
@media (max-width: 480px) {
.login-left-section {
padding: 24px 12px 8px;
text-align: center;
}
.login-right-section {
padding: 8px 12px 24px;
}
.shiny-text-container {
text-align: center;
}
.login-page .ant-input,
.login-page .ant-input-affix-wrapper {
height: 44px !important;
font-size: 14px !important;
}
.login-page .ant-input-affix-wrapper .ant-input {
height: 100% !important;
}
.login-code-btn {
height: 44px !important;
min-width: 90px !important;
font-size: 13px !important;
}
.login-submit-btn {
height: 44px !important;
font-size: 15px !important;
}
}
+77 -174
View File
@@ -8,6 +8,7 @@ import {
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/useAuthStore';
import { sendSms,phonelogin, getSiteInfo, register } from '../api';
import './LoginPage.css';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
@@ -73,7 +74,6 @@ const LoginPage: React.FC = () => {
if (!checkAgreed()) return;
try {
const values = await pwdForm.validateFields();
// setLoading(true);
await login(values.phone, values.password, undefined, values.rememberMe);
message.success('登录成功,欢迎回来');
await checkAuth();
@@ -82,7 +82,6 @@ const LoginPage: React.FC = () => {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg);
} finally {
// setLoading(false);
}
};
@@ -131,7 +130,6 @@ const LoginPage: React.FC = () => {
if (c <= 1) {
clearInterval(timer);
setShowSliderVerify(false);
// 倒计时结束后,设置重新发送状态
if (isReg) {
setShowResend(true);
} else {
@@ -144,7 +142,6 @@ const LoginPage: React.FC = () => {
}, 1000);
};
// 登录模式倒计时结束后重置验证状态
useEffect(() => {
if (countdown === 0 && mode === 'phone') {
setLoginSliderVerified(false);
@@ -158,14 +155,12 @@ const LoginPage: React.FC = () => {
return;
}
// 注册时需要滑动验证
if (isReg) {
setShowSliderVerify(true);
setSliderVerified(false);
return;
}
// 登录时也需要滑动验证
if (!loginSliderVerified) {
setShowSliderVerify(true);
setLoginSliderVerified(false);
@@ -185,7 +180,6 @@ const LoginPage: React.FC = () => {
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg);
// 发送失败,重置验证状态、倒计时和滑块组件
if (mode === 'register') {
setSliderVerified(false);
setRegCountdown(0);
@@ -193,18 +187,13 @@ const LoginPage: React.FC = () => {
setLoginSliderVerified(false);
setCountdown(0);
}
// 通过更新 key 强制刷新滑块组件
setSliderKey(prev => prev + 1);
}
};
// 滑动验证成功后的回调
const handleSliderSuccess = async (isResend = false) => {
// 根据当前模式获取手机号
const phone = mode === 'register' ? regForm.getFieldValue('phone') : phoneForm.getFieldValue('phone');
try {
let mode2 = '';
if (mode === 'register') {
mode2 = 'register';
@@ -213,7 +202,6 @@ const LoginPage: React.FC = () => {
}
await sendSms(phone,mode2);
// 设置对应的验证状态
if (mode === 'register') {
setSliderVerified(true);
startCountdown(setRegCountdown, true);
@@ -225,7 +213,6 @@ const LoginPage: React.FC = () => {
} catch (error: any) {
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
message.error(errorMsg);
// 发送失败,重置验证状态、倒计时和滑块组件
if (mode === 'register') {
setSliderVerified(false);
setRegCountdown(0);
@@ -235,7 +222,6 @@ const LoginPage: React.FC = () => {
setCountdown(0);
setLoginShowResend(true);
}
// 通过更新 key 强制刷新滑块组件
setSliderKey(prev => prev + 1);
}
};
@@ -250,9 +236,7 @@ const LoginPage: React.FC = () => {
setShowSliderVerify(false);
setShowResend(false);
setLoginShowResend(false);
// 刷新滑动验证组件
setSliderKey(prev => prev + 1);
// 清空当前模式相关的表单
if (t === 'password') {
pwdForm.resetFields();
} else {
@@ -270,7 +254,6 @@ const LoginPage: React.FC = () => {
background: '#fff',
border: '1.5px solid #e2e8f0',
color: '#1e293b',
height: 48,
borderRadius: 10,
fontSize: 14,
};
@@ -280,93 +263,45 @@ const LoginPage: React.FC = () => {
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
backgroundImage: `url(/backimage.png)`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundAttachment: 'fixed',
position: 'relative',
overflow: 'hidden',
}}>
<div style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(135deg, rgba(240,244,255,0.9) 0%, rgba(232,236,248,0.85) 40%, rgba(240,240,255,0.9) 70%, rgba(248,249,255,0.95) 100%)',
}} />
<div style={{
position: 'absolute',
width: 500, height: 500, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%)',
top: -150, right: -100, filter: 'blur(40px)',
}} />
<div style={{
position: 'absolute',
width: 400, height: 400, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%)',
bottom: -100, left: -80, filter: 'blur(50px)',
}} />
<div className="login-page">
<div className="login-bg-overlay" />
<div className="login-decoration login-decoration-1" />
<div className="login-decoration login-decoration-2" />
{/* Left side - features */}
<div style={{
flex: 1, display: 'flex', flexDirection: 'column',
justifyContent: 'center', padding: '0 80px', zIndex: 1,
}}>
<Space direction="vertical" size={36}>
<div className="login-left-section">
<Space direction="vertical" size={36} className="login-left-content">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
<div className="login-logo-row">
{siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 52, height: 52, borderRadius: 14, objectFit: 'contain' }} />
<img src={siteLogo} alt="logo" className="login-logo-img" />
) : (
<div style={{
width: 52, height: 52, borderRadius: 14,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}>
<div className="login-logo-placeholder">
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} />
</div>
)}
<span style={{ color: '#1e293b', fontSize: 28, fontWeight: 800, letterSpacing: -0.5 }}>
{siteName}
</span>
<span className="login-site-name">{siteName}</span>
</div>
<div className="shiny-text-container">
<span className="shiny-text">AI赋能创意</span>
</div>
<Typography.Paragraph style={{ color: '#64748b', fontSize: 17, maxWidth: 480, lineHeight: 1.8 }}>
<Typography.Paragraph className="login-desc">
AI <br />
</Typography.Paragraph>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div className="login-features-list">
{features.map((f, i) => (
<div
key={i}
className="electric-border-card"
style={{
position: 'relative',
display: 'flex', gap: 16, alignItems: 'flex-start',
padding: '18px 22px', borderRadius: 14,
background: 'rgba(255,255,255,0.85)',
backdropFilter: 'blur(12px)',
overflow: 'hidden',
}}
className="electric-border-card login-feature-card"
>
<div className="electric-border" />
<div className="electric-border-inner" />
<div style={{ position: 'relative', zIndex: 1 }}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15))',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#6366f1', fontSize: 20,
}}>{f.icon}</div>
<div className="login-feature-icon">{f.icon}</div>
</div>
<div style={{ position: 'relative', zIndex: 1 }}>
<Typography.Text style={{ color: '#1e293b', fontSize: 15, fontWeight: 600, display: 'block', marginBottom: 4 }}>{f.title}</Typography.Text>
<Typography.Text style={{ color: '#64748b', fontSize: 13, lineHeight: 1.6 }}>{f.desc}</Typography.Text>
<Typography.Text className="login-feature-title">{f.title}</Typography.Text>
<Typography.Text className="login-feature-desc">{f.desc}</Typography.Text>
</div>
</div>
))}
@@ -374,48 +309,26 @@ const LoginPage: React.FC = () => {
</Space>
</div>
{/* Right side - login/register form */}
<div style={{
flex: 1, display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1, padding: '40px 24px',
}}>
<Card style={{
width: 440, maxWidth: '100%', borderRadius: 20,
boxShadow: '0 20px 60px rgba(0,0,0,0.08)',
border: '1px solid #e2e8f0', background: '#fff',
}} styles={{ body: { padding: '36px 28px' } }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 6, color: '#1e293b', fontWeight: 700 }}>
<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 style={{ display: 'block', textAlign: 'center', marginBottom: 28, color: '#94a3b8', fontSize: 14 }}>
<Typography.Text className="login-card-subtitle">
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'}
</Typography.Text>
{/* Tab switcher - only for login modes */}
{mode !== 'register' && (
<div style={{
display: 'flex', gap: 0, marginBottom: 24,
background: '#f1f5f9', borderRadius: 10, padding: 4,
border: '1px solid #e2e8f0',
}}>
<div className="login-tabs">
{(['password', 'phone'] as const).map((t) => (
<div key={t} onClick={() => switchTab(t)} style={{
flex: 1, textAlign: 'center', padding: '10px 0',
borderRadius: 8, cursor: 'pointer', fontSize: 14,
fontWeight: tab === t ? 600 : 400,
color: tab === t ? '#6366f1' : '#64748b',
background: tab === t ? '#fff' : 'transparent',
border: tab === t ? '1px solid rgba(99,102,241,0.2)' : '1px solid transparent',
boxShadow: tab === t ? '0 2px 8px rgba(99,102,241,0.1)' : 'none',
transition: 'all 0.3s ease',
}}>
<div key={t} onClick={() => switchTab(t)}
className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}>
{t === 'password' ? '密码登录' : '验证码登录'}
</div>
))}
</div>
)}
{/* Password Login */}
{mode === 'password' && (
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
@@ -428,16 +341,11 @@ const LoginPage: React.FC = () => {
<Checkbox></Checkbox>
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}> </Button>
<Button type="primary" htmlType="submit" loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{/* Phone Login */}
{mode === 'phone' && (
<Form form={phoneForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
@@ -454,27 +362,18 @@ const LoginPage: React.FC = () => {
<Button disabled={countdown > 0}
onClick={() => {
if (loginShowResend) {
// 重新发送时,只刷新滑块,不启动倒计时
setLoginSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else {
// 首次点击,调用发送验证码,等待滑块验证
handleSendCode(phoneForm.getFieldValue('phone'));
}
}}
style={{
height: 48, borderRadius: '0 10px 10px 0',
background: countdown > 0 ? '#f1f5f9' : (loginSliderVerified ? '#f1f5f9' : 'rgba(99,102,241,0.1)'),
border: '1.5px solid #e2e8f0', borderLeft: 'none',
color: countdown > 0 ? '#94a3b8' : '#6366f1',
fontWeight: 600, minWidth: 100,
}}>
className="login-code-btn">
{countdown > 0 ? `${countdown}s` : (loginShowResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{/* 滑动验证 - 获取验证码后显示 */}
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
@@ -485,16 +384,11 @@ const LoginPage: React.FC = () => {
)}
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}> </Button>
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{/* Register */}
{mode === 'register' && (
<Form form={regForm} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
@@ -511,27 +405,18 @@ const LoginPage: React.FC = () => {
<Button disabled={regCountdown > 0}
onClick={() => {
if (showResend) {
// 重新发送时,只刷新滑块,不启动倒计时
setSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else {
// 首次点击,调用发送验证码,等待滑块验证
handleSendCode(regForm.getFieldValue('phone'), true);
}
}}
style={{
height: 48, borderRadius: '0 10px 10px 0',
background: regCountdown > 0 ? '#f1f5f9' : (sliderVerified ? '#f1f5f9' : 'rgba(99,102,241,0.1)'),
border: '1.5px solid #e2e8f0', borderLeft: 'none',
color: regCountdown > 0 ? '#94a3b8' : '#6366f1',
fontWeight: 600, minWidth: 100,
}}>
className="login-code-btn">
{regCountdown > 0 ? `${regCountdown}s` : (showResend ? '重新发送' : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>
{/* 滑动验证 - 获取验证码后显示 */}
{showSliderVerify && (
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
<SliderVerify
@@ -544,41 +429,33 @@ const LoginPage: React.FC = () => {
<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 style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}> </Button>
<Button type="primary" onClick={handleRegister} loading={loading} block className="login-submit-btn"> </Button>
</Form.Item>
</Form>
)}
{/* Agreement checkbox */}
<div style={{ marginBottom: 16 }}>
<div className="login-agreement">
<Checkbox checked={agreed} onChange={e => setAgreed(e.target.checked)}>
<span style={{ fontSize: 13, color: '#64748b' }}>
<span className="login-agreement-text">
<span
onClick={e => { e.stopPropagation(); openPdf(agreementUrl); }}
style={{ color: '#6366f1', cursor: 'pointer' }}
className="login-link"
></span>
<span
onClick={e => { e.stopPropagation(); openPdf(policyUrl); }}
style={{ color: '#6366f1', cursor: 'pointer' }}
className="login-link"
></span>
</span>
</Checkbox>
</div>
{/* Bottom left: switch between login and register */}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div className="login-footer">
{mode === 'register' ? (
<Typography.Text
style={{ fontSize: 13, color: '#6366f1', cursor: 'pointer' }}
className="login-switch-btn"
onClick={() => {
setMode('password');
setTab('password');
@@ -594,7 +471,7 @@ const LoginPage: React.FC = () => {
</Typography.Text>
) : (
<Typography.Text
style={{ fontSize: 13, color: '#6366f1', cursor: 'pointer' }}
className="login-switch-btn"
onClick={() => {
setMode('register');
setShowResend(false);
@@ -614,7 +491,6 @@ const LoginPage: React.FC = () => {
);
};
// 滑动验证组件
const SliderVerify: React.FC<{
onSuccess: () => void;
isVerified: boolean;
@@ -623,13 +499,12 @@ const SliderVerify: React.FC<{
const sliderRef = React.useRef<HTMLDivElement>(null);
const trackRef = React.useRef<HTMLDivElement>(null);
const positionRef = React.useRef(0);
const successRef = React.useRef(false); // 防止重复触发成功回调
const successRef = React.useRef(false);
const [containerWidth, setContainerWidth] = React.useState(360); // 容器宽度,自适应
const sliderWidth = 50; // 滑块宽度
const [containerWidth, setContainerWidth] = React.useState(360);
const sliderWidth = 50;
const maxPosition = containerWidth - sliderWidth;
// 监听容器尺寸变化,使其自适应父容器宽度
React.useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
@@ -656,13 +531,11 @@ const SliderVerify: React.FC<{
}, []);
const updatePosition = (x: number) => {
// 如果已经成功,不再处理
if (successRef.current || isVerified) return;
const newPosition = Math.max(0, Math.min(x, maxPosition));
positionRef.current = newPosition;
// 直接操作DOM,避免React状态更新的开销
if (sliderRef.current) {
sliderRef.current.style.left = `${newPosition}px`;
}
@@ -670,12 +543,9 @@ const SliderVerify: React.FC<{
trackRef.current.style.width = `${newPosition + sliderWidth}px`;
}
// 验证成功
if (newPosition >= maxPosition - 5) {
// 设置成功标志,防止重复触发
successRef.current = true;
// 设置验证成功状态
if (sliderRef.current) {
sliderRef.current.style.background = '#22c55e';
sliderRef.current.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
@@ -685,7 +555,6 @@ const SliderVerify: React.FC<{
trackRef.current.style.background = '#dcfce7';
}
// 调用成功回调
onSuccess();
}
};
@@ -708,7 +577,6 @@ const SliderVerify: React.FC<{
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
// 如果没有滑到终点,重置位置
if (!successRef.current && positionRef.current < maxPosition - 5) {
positionRef.current = 0;
if (sliderRef.current) {
@@ -723,11 +591,49 @@ const SliderVerify: React.FC<{
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleTouchStart = (e: React.TouchEvent) => {
if (successRef.current || isVerified) return;
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const touch = e.touches[0];
const startX = touch.clientX - rect.left - sliderWidth / 2;
updatePosition(startX);
const handleTouchMove = (moveEvent: TouchEvent) => {
const moveTouch = moveEvent.touches[0];
const x = moveTouch.clientX - rect.left - sliderWidth / 2;
updatePosition(x);
};
const handleTouchEnd = () => {
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
if (!successRef.current && positionRef.current < maxPosition - 5) {
positionRef.current = 0;
if (sliderRef.current) {
sliderRef.current.style.left = '0px';
}
if (trackRef.current) {
trackRef.current.style.width = `${sliderWidth}px`;
}
}
};
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
};
return (
<div
ref={containerRef}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
style={{
width: '100%',
height: 50,
@@ -742,7 +648,6 @@ const SliderVerify: React.FC<{
touchAction: 'none',
}}
>
{/* 已滑动部分背景 - 包含阴影弧度 */}
<div
ref={trackRef}
style={{
@@ -762,7 +667,6 @@ const SliderVerify: React.FC<{
}}
/>
{/* 文字提示 */}
<div
style={{
position: 'absolute',
@@ -793,7 +697,6 @@ const SliderVerify: React.FC<{
)}
</div>
{/* 滑块 */}
<div
ref={sliderRef}
style={{
+20 -10
View File
@@ -1,7 +1,9 @@
import React, { useEffect, useState } from 'react';
import { Button, Table, Tag, Input, Pagination, Typography, Select, App } from 'antd';
import { Link } from 'react-router-dom';
import { FolderOpenOutlined, EyeOutlined } from '@ant-design/icons';
import { getResourcesMaterialList } from '../api';
import PreResultDisplay from '../components/PreResultDisplay';
// 格式化时间 2026-06-12T03:47:28.542988Z -> 2026-06-12 03:47:28
const formatDateTime = (dateStr: string) => {
@@ -33,10 +35,9 @@ const resourceTypeConfig: Record<string, { label: string; color: string }> = {
// 状态配置
const statusConfig: Record<string, { label: string; color: string }> = {
'1': { label: '待上传', color: 'orange' },
'2': { label: '上传中', color: 'processing' },
'3': { label: '上传成功', color: 'success' },
'4': { label: '上传失败', color: 'error' },
'FAILED': { label: '失败', color: 'error' },
'PENDING': { label: '处理中', color: 'processing' },
'SUCCESS': { label: '成功', color: 'success' },
};
interface MaterialResource {
@@ -231,18 +232,17 @@ const MaterialListPage: React.FC = () => {
dataIndex: 'status',
key: 'status',
width: 100,
render: (text: string) => <span style={{ color: '#64748b' }}>{text || '-'}</span>,
// render: (text: string) => {
// const config = statusConfig[text];
// return <Tag color={config?.color}>{config?.label || text}</Tag>;
// },
render: (text: string) => {
const config = statusConfig[text];
return <Tag color={config?.color}>{config?.label || text || '-'}</Tag>;
},
},
{
title: '前测结果',
dataIndex: 'preResult',
key: 'preResult',
width: 120,
render: (text: string) => <span style={{ color: '#64748b' }}>{text || '-'}</span>,
render: (text: string) => <PreResultDisplay preResult={text} />,
},
{
title: '备注',
@@ -267,6 +267,16 @@ const MaterialListPage: React.FC = () => {
width: 160,
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: unknown, record) => (
<Link to={`/consume?accountId=${record.advertiserId}`} style={{ color: '#6366f1' }}>
</Link>
),
},
// {
// title: '更新时间',
// dataIndex: 'updatedAt',
+115
View File
@@ -0,0 +1,115 @@
import React from 'react';
import { Button, Card, message, Typography } from 'antd';
import {
StarOutlined,
CrownOutlined,
LockOutlined,
ThunderboltOutlined,
GiftOutlined,
BarChartOutlined,
FireOutlined,
TrophyOutlined,
} from '@ant-design/icons';
const PopularPage: React.FC = () => {
const handleUnlock = () => {
message.info('请联系客服开通');
};
return (
<div style={{ padding: '20px 0' }}>
<div style={{ marginBottom: 32 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{
width: 48,
height: 48,
borderRadius: 14,
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 16px rgba(245,158,11,0.3)',
}}>
<BarChartOutlined style={{ fontSize: 22, color: '#fff' }} />
</div>
<div>
<Typography.Title level={2} style={{ margin: 0, color: '#1e293b', fontWeight: 700 }}>
<span style={{ color: '#6366f1', fontSize: 14, fontWeight: 400, marginLeft: 8 }}>
</span>
</Typography.Title>
</div>
</div>
<Typography.Text style={{ color: '#64748b' }}>
</Typography.Text>
</div>
<Card
bordered={false}
style={{
borderRadius: 20,
background: 'linear-gradient(135deg, rgba(245,158,11,0.04) 0%, rgba(217,119,6,0.04) 100%)',
border: '1px solid rgba(245,158,11,0.1)',
padding: 10,
marginBottom: 32,
}}
>
<Typography.Title level={4} style={{ marginBottom: 20, color: '#1e293b' }}>
<FireOutlined style={{ marginRight: 8, color: '#f59e0b' }} />
</Typography.Title>
<Card
bordered={false}
style={{
borderRadius: 16,
background: '#fafafa',
padding: 40,
textAlign: 'center',
}}
>
<div style={{ color: '#94a3b8', fontSize: 14 }}>
</div>
</Card>
</Card>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<Typography.Title level={4} style={{ margin: 0, color: '#1e293b' }}>
<TrophyOutlined style={{ marginRight: 8, color: '#6366f1' }} />
</Typography.Title>
<Button
type="primary"
size="middle"
onClick={handleUnlock}
style={{
borderRadius: 10,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none',
}}
>
<LockOutlined style={{ marginRight: 6 }} />
</Button>
</div>
<Card
bordered={false}
style={{
borderRadius: 16,
background: '#fafafa',
padding: 40,
textAlign: 'center',
}}
>
<div style={{ color: '#94a3b8', fontSize: 14 }}>
</div>
</Card>
</div>
);
};
export default PopularPage;
+1 -1
View File
@@ -436,7 +436,7 @@ const PreTest: React.FC = () => {
<div style={{ minHeight: 'calc(100vh - 80px)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<FileTextOutlined style={{ color: '#6366f1', fontSize: 16 }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginBottom: 16 }}>
<Button