dist/首页

This commit is contained in:
孙佳艺
2026-06-26 17:40:41 +08:00
100 changed files with 6422 additions and 3968 deletions
+5
View File
@@ -12,6 +12,11 @@ node_modules
dist-ssr
*.local
# Environment files
.env
.env.local
.env.*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
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
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-C49YKZ--.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DyH2vHpW.css">
<script type="module" crossorigin src="/assets/index-1RNaKjB1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-xCZbcxht.css">
</head>
<body>
<div id="root"></div>
+6
View File
@@ -25,6 +25,9 @@ import RemoveRw from './pages/RemoveRw';
import HomePage from './pages/HomePage';
// import RemoveFenbu from './pages/RemoveFenbu';
import ConsumePage from './pages/ConsumePage';
import AuthorizationWaitingPage from './pages/AuthorizationWaitingPage';
import PopularPage from './pages/PopularPage';
import CreativePlazaPage from './pages/CreativePlazaPage';
import { useAuthStore } from './store/useAuthStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user, loading, checkAuth } = useAuthStore();
@@ -111,8 +114,11 @@ const App = () => {
<Route path="generated" element={<GeneratedRecord />} />
<Route path="pretest" element={<PreTest />} />
<Route path="authorization" element={<AuthorizationPage />} />
<Route path="authoriza-waiting" element={<AuthorizationWaitingPage />} />
<Route path="materials" element={<MaterialListPage />} />
<Route path="consume" element={<ConsumePage />} />
<Route path="popular" element={<PopularPage />} />
<Route path="creativeplaza" element={<CreativePlazaPage />} />
</Route>
<Route path="*" element={<Navigate to="/projects" replace />} />
</Routes>
+2 -2
View File
@@ -3,10 +3,10 @@
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
*/
import { encrypt, decrypt } from './crypto';
import { encrypt, decrypt, isCryptoAvailable } from './crypto';
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY;
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
interface RequestOptions {
method?: string;
+19 -1
View File
@@ -1,6 +1,7 @@
/**
* AES-256-GCM encryption/decryption for API request/response.
* Uses Web Crypto API with a shared symmetric key.
* Note: Web Crypto API is only available in secure contexts (HTTPS or localhost).
*/
const ALGO = 'AES-GCM';
@@ -9,10 +10,21 @@ const TAG_LENGTH = 128;
let cryptoKey: CryptoKey | null = null;
export function isCryptoAvailable(): boolean {
return typeof window !== 'undefined' &&
typeof crypto !== 'undefined' &&
typeof crypto.subtle !== 'undefined';
}
async function getCryptoKey(): Promise<CryptoKey> {
if (cryptoKey) return cryptoKey;
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
if (!isCryptoAvailable()) {
throw new Error('Web Crypto API not available (requires HTTPS or localhost)');
}
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
// AES-256 requires exactly 32 bytes — pad or truncate to match backend
if (keyBytes.length !== 32) {
@@ -25,6 +37,9 @@ async function getCryptoKey(): Promise<CryptoKey> {
}
export async function encrypt(plaintext: string): Promise<string> {
if (!isCryptoAvailable()) {
throw new Error('Encryption not available in non-secure context');
}
const key = await getCryptoKey();
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const encoded = new TextEncoder().encode(plaintext);
@@ -38,10 +53,13 @@ export async function encrypt(plaintext: string): Promise<string> {
}
export async function decrypt(cipherB64: string): Promise<string> {
if (!isCryptoAvailable()) {
throw new Error('Decryption not available in non-secure context');
}
const key = await getCryptoKey();
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
const iv = combined.slice(0, IV_LENGTH);
const cipherBytes = combined.slice(IV_LENGTH);
const plainBuf = await crypto.subtle.decrypt({ name: ALGO, iv, tagLength: TAG_LENGTH }, key, cipherBytes);
return new TextDecoder().decode(plainBuf);
}
}
+33 -2
View File
@@ -414,11 +414,19 @@ export async function requestOAuth(params: RequestOAuthParams): Promise<any> {
export interface JuliangCallbackParams {
auth_code: string;
state: string;
app_id?: string;
material_auth_status?: string;
scope?: string;
uid?: string;
}
export async function juliang_callback(params: JuliangCallbackParams): Promise<any> {
const query = new URLSearchParams();
query.set('auth_code', params.auth_code);
query.set('state', params.state);
if (params.app_id) query.set('app_id', params.app_id);
if (params.material_auth_status) query.set('material_auth_status', params.material_auth_status);
if (params.scope) query.set('scope', params.scope);
if (params.uid) query.set('uid', params.uid);
return api.get(`/user-oauth/juliang_callback?${query.toString()}`);
}
@@ -658,6 +666,19 @@ export async function getMaterialConsumptionFields(): Promise<any> {
return api.get('/material-consumption/fields');
}
// ── Contact ────────────────────────────────────────────────
export interface ContactRequestParams {
phone: string;
company_name: string;
industry: string;
name: string;
message?: string;
}
export async function createContactRequest(params: ContactRequestParams): Promise<any> {
return api.post('/contact/request', params);
}
// 查询上传素材列表
export interface ResourcesMaterialListParams {
advertiser_id?: string;
@@ -682,9 +703,19 @@ export async function getResourcesMaterialList(params: ResourcesMaterialListPara
// home 获取各模块媒体
export async function getmedit(limit:number): Promise<any> {
return api.get(`/recent-generations?limit=${limit}&modules=project&modules=chat_ai&modules=hot_opening_replicate&modules=shot_replicate`);
}
export interface OpenTypeItem {
id: string;
openType: number;
typeName: string;
description: string;
thumb?: string;
}
export async function getOpenTypeAll(): Promise<{ data: OpenTypeItem[] }> {
return api.get('/open-type/open_type_all');
}
@@ -0,0 +1,247 @@
.desktop-sidebar {
display: flex;
}
.desktop-content {
display: block;
}
.mobile-header {
display: none;
}
.mobile-content {
display: none;
}
.mobile-menu-drawer {
display: none;
}
.mobile-menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin: 2px 8px;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
min-height: 48px;
}
.mobile-menu-item:hover {
background: rgba(99, 102, 241, 0.05);
}
.mobile-menu-item-active {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%) !important;
}
.mobile-menu-group {
cursor: default;
}
.mobile-menu-group:hover {
background: transparent;
}
.mobile-menu-icon {
font-size: 18px;
flex-shrink: 0;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-menu-label {
flex: 1;
font-size: 15px;
}
.mobile-submenu {
padding-left: 12px;
background: rgba(248, 250, 252, 0.5);
margin: 0 8px;
border-radius: 8px;
}
.mobile-submenu-item {
padding: 10px 16px 10px 20px;
margin: 1px 0;
min-height: 44px;
}
.mobile-recharge-item {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4);
}
.mobile-recharge-item:hover {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%) !important;
}
.contact-button-wrapper {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 1000;
}
.contact-tooltip {
position: absolute;
right: 64px;
bottom: 8px;
padding: 8px 16px;
background: #1e293b;
color: #ffffff;
border-radius: 8px;
font-size: 13px;
white-space: nowrap;
transition: opacity 0.2s ease;
pointer-events: none;
}
.contact-button {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #ffffff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
transition: all 0.3s ease;
}
.contact-button:hover {
transform: scale(1.05);
box-shadow: 0 6px 24px rgba(99, 102, 241, 0.5);
}
@media (max-width: 767px) {
.desktop-sidebar {
display: none !important;
}
.desktop-content {
display: none !important;
}
.mobile-header {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.mobile-header-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 16px;
}
.mobile-menu-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
cursor: pointer;
color: #475569;
transition: all 0.2s ease;
}
.mobile-menu-btn:hover {
background: rgba(99, 102, 241, 0.08);
color: #6366f1;
}
.mobile-header-title {
font-size: 18px;
font-weight: 700;
color: #1e293b;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.mobile-header-right {
display: flex;
align-items: center;
gap: 12px;
}
.mobile-credits-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(99, 102, 241, 0.08);
border-radius: 20px;
color: #6366f1;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.mobile-credits-badge:hover {
background: rgba(99, 102, 241, 0.15);
}
.mobile-content {
display: block;
padding-top: 56px;
padding-bottom: 24px;
min-height: 100vh;
background: #f8fafc;
}
.mobile-content > div {
margin: 12px;
padding: 16px;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.04);
min-height: calc(100vh - 104px);
}
.contact-button-wrapper {
right: 16px;
bottom: 16px;
}
.contact-tooltip {
display: none;
}
.contact-button {
width: 44px;
height: 44px;
}
.recharge-modal .ant-modal {
max-width: calc(100vw - 32px) !important;
margin: 16px !important;
}
.contact-modal .ant-modal {
max-width: calc(100vw - 32px) !important;
margin: 16px !important;
}
}
+419 -146
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio } from 'antd';
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio, Drawer } from 'antd';
import { QRCodeSVG } from 'qrcode.react';
import {
PlayCircleOutlined,
@@ -53,11 +53,17 @@ import {
ApiOutlined,
DatabaseOutlined,
CloudServerOutlined,
MessageOutlined,
DownOutlined,
InfoOutlined,
MenuOutlined,
ArrowLeftOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount } from '../../api';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest } from '../../api';
import NotificationPopup from '../NotificationPopup';
import './AppLayout.css';
interface MenuConfig {
id: string;
@@ -134,8 +140,9 @@ const AppLayout: React.FC = () => {
const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const [unreadCount, setUnreadCount] = useState(0);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [mobileExpandedMenus, setMobileExpandedMenus] = useState<Record<string, boolean>>({});
const [siteName, setSiteName] = useState(() => {
const cached = localStorage.getItem('siteInfo');
const name = cached ? JSON.parse(cached).siteName || '' : '';
@@ -162,17 +169,18 @@ const AppLayout: React.FC = () => {
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null);
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
const [paying, setPaying] = useState(false);
const [countdown, setCountdown] = useState(180); // 默认180秒超时
const [countdown, setCountdown] = useState(180);
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
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);
// LocalStorage keys
const PENDING_ORDER_KEY = 'pending_payment_order';
useEffect(() => {
getSiteInfo().then(info => {
const name = info.siteName || '民众智创';
@@ -205,17 +213,14 @@ const AppLayout: React.FC = () => {
}).catch(() => { });
};
// 检查并恢复待处理的支付订单
useEffect(() => {
const checkPendingOrder = async () => {
const savedOrderStr = localStorage.getItem(PENDING_ORDER_KEY);
if (savedOrderStr) {
try {
const savedOrder = JSON.parse(savedOrderStr);
// 查询订单状态
const order = await getPaymentOrder(savedOrder.orderNo);
if (order.status === 'pending') {
// 订单仍然待支付,恢复弹窗
setCurrentPaymentInfo({
price: savedOrder.price,
credits: savedOrder.credits,
@@ -223,7 +228,6 @@ const AppLayout: React.FC = () => {
method: savedOrder.method,
});
currentOrderNoRef.current = savedOrder.orderNo;
// 计算剩余时间
const now = Date.now();
const createdAt = new Date(savedOrder.createdAt).getTime();
const timeoutSeconds = savedOrder.timeoutSeconds || 180;
@@ -234,20 +238,16 @@ const AppLayout: React.FC = () => {
setQrCodeModalOpen(true);
startPolling(savedOrder.orderNo, remainingSeconds);
} else {
// 已超时,清除
localStorage.removeItem(PENDING_ORDER_KEY);
}
} else if (order.status === 'paid') {
// 已支付
message.success('支付成功!积分已到账');
useAuthStore.getState().refreshUser();
localStorage.removeItem(PENDING_ORDER_KEY);
} else {
// 订单已取消或其他状态,清除
localStorage.removeItem(PENDING_ORDER_KEY);
}
} catch {
// 查询失败,清除
localStorage.removeItem(PENDING_ORDER_KEY);
}
}
@@ -258,21 +258,7 @@ const AppLayout: React.FC = () => {
useEffect(() => {
getMenuConfigs().then(data => {
let items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
// Filter by user's allowed menus if set
if (user?.allowedMenus && user.allowedMenus.length > 0) {
const allowed = new Set(user.allowedMenus);
const groupIds = new Set<string>();
items.forEach((m: any) => {
const pid = m.parent_id ?? m.parentId;
if (pid && allowed.has(m.path)) groupIds.add(pid);
});
items = items.filter((m: any) => {
const mt = m.menu_type ?? m.menuType;
if (mt === 'group' && groupIds.has(m.id)) return true;
return allowed.has(m.path);
});
}
const items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
setMenuItems(items);
}).catch(() => { });
getRechargePackages().then(data => {
@@ -280,7 +266,6 @@ const AppLayout: React.FC = () => {
}).catch(() => { });
getPaymentMethods().then(data => {
setEnabledMethods(data);
// Auto-select the first enabled method
if (data.alipay) setPaymentMethod('alipay');
else if (data.wechat) setPaymentMethod('wechat');
}).catch(() => { });
@@ -290,6 +275,47 @@ const AppLayout: React.FC = () => {
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
const sidebarW = SIDEBAR_W;
const childMap: Record<string, MenuConfig[]> = {};
menuItems.forEach(m => {
const pid = m.parent_id ?? m.parentId;
if (pid) {
if (!childMap[pid]) childMap[pid] = [];
childMap[pid].push(m);
}
});
const topLevelItems = menuItems.filter(m => !(m.parent_id ?? m.parentId));
topLevelItems.sort((a, b) => {
const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
return orderA - orderB;
});
Object.keys(childMap).forEach(key => {
childMap[key].sort((a, b) => {
const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
return orderA - orderB;
});
});
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const handleMobileMenuClick = (item: MenuConfig) => {
const menuType = item.menu_type ?? item.menuType;
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
if (menuType === 'group' || hasChildren) {
setMobileExpandedMenus(prev => ({
...prev,
[item.id]: !prev[item.id]
}));
} else if (item.path) {
navigate(item.path);
setMobileMenuOpen(false);
}
};
const userMenuItems = [
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
{ key: 'credits', icon: <WalletOutlined style={{ color: '#c9a96e' }} />, label: `积分: ${user?.credits ?? 0}`, disabled: true },
@@ -297,7 +323,6 @@ const AppLayout: React.FC = () => {
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
{ key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` },
// { key: 'recharge', icon: <PlusCircleOutlined />, label: '充值积分' },
{ type: 'divider' as const },
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
{ type: 'divider' as const },
@@ -318,7 +343,7 @@ const AppLayout: React.FC = () => {
await pwdForm.validateFields();
message.success('密码修改成功(演示)');
setPwdModalOpen(false); pwdForm.resetFields();
} catch { /* validation */ }
} catch { }
};
const handleLogout = () => {
@@ -330,6 +355,31 @@ const AppLayout: React.FC = () => {
setRechargeModalOpen(true);
};
const handleContactSubmit = async () => {
if (!user) {
message.warning('请先登录');
return;
}
try {
const values = await contactForm.validateFields();
setSubmittingContact(true);
await createContactRequest({
phone: values.phone,
company_name: values.companyName,
industry: values.industry,
name: values.name,
message: values.message,
});
message.success('提交成功,我们会尽快与您联系');
setContactModalOpen(false);
contactForm.resetFields();
} catch (err: any) {
message.error(err?.message || '提交失败');
} finally {
setSubmittingContact(false);
}
};
const stopPolling = useCallback(() => {
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
@@ -345,7 +395,6 @@ const AppLayout: React.FC = () => {
stopPolling();
setCountdown(timeoutSeconds);
// 订单状态轮询(每2秒查询一次,只查询当前订单
const pollingTimer = setInterval(async () => {
try {
const order = await getPaymentOrder(orderNo);
@@ -364,16 +413,13 @@ const AppLayout: React.FC = () => {
localStorage.removeItem(PENDING_ORDER_KEY);
}
} catch {
// ignore polling errors
}
}, 2000);
pollingTimerRef.current = pollingTimer;
// 倒计时
const countdownTimer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
// 超时自动取消
stopPolling();
if (currentOrderNoRef.current) {
cancelPaymentOrder(currentOrderNoRef.current).catch(() => { });
@@ -392,9 +438,82 @@ const AppLayout: React.FC = () => {
countdownTimerRef.current = countdownTimer;
}, [stopPolling]);
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
const isActive = item.path === selectedKey;
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
const menuType = item.menu_type ?? item.menuType;
const isGroup = menuType === 'group';
const isExpanded = collapsedGroups[item.id] !== true;
return (
<div key={item.id}>
<div
onClick={() => {
if (isGroup || hasChildren) {
setCollapsedGroups(prev => ({
...prev,
[item.id]: !prev[item.id]
}));
} else if (item.path) {
navigate(item.path);
}
}}
style={{
display: 'flex', alignItems: 'center',
justifyContent: 'flex-start',
gap: 10,
padding: depth > 0 ? '6px 12px 6px 32px' : '6px 14px',
borderRadius: 12, margin: '1px 4px', cursor: 'pointer',
fontSize: depth > 0 ? 13 : 14, fontWeight: isActive ? 600 : 400,
color: isActive ? '#4f46e5' : (isGroup ? '#94a3b8' : '#475569'),
background: isActive ? 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)' : 'transparent',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isGroup && !isActive) {
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.05)';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.background = 'transparent';
}
}}
>
{!isGroup && (
<span style={{
fontSize: depth > 0 ? 14 : 16,
flexShrink: 0,
color: isActive ? '#6366f1' : '#64748b',
}}>{menuIcon}</span>
)}
<span style={{ whiteSpace: 'nowrap', flex: 1, textAlign: 'left', fontWeight: isGroup ? 600 : (isActive ? 600 : 400), fontSize: isGroup ? 12 : (depth > 0 ? 13 : 14), textTransform: isGroup ? 'uppercase' : 'none', letterSpacing: isGroup ? 0.5 : 0 }}>
{item.label}
</span>
{(isGroup || hasChildren) && (
<span style={{
fontSize: isGroup ? 12 : 14,
color: '#cbd5e1',
transition: 'transform 0.2s ease',
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
}}>
<RightOutlined />
</span>
)}
</div>
{(isGroup || hasChildren) && isExpanded && (
<div style={{ overflow: 'hidden' }}>
{(childMap[item.id] || []).map(c => renderMenuItem(c, depth + 1))}
</div>
)}
</div>
);
};
return (
<Layout style={{ minHeight: '100vh' }}>
{/* Desktop Sidebar */}
<div className="desktop-sidebar" style={{
width: sidebarW, position: 'fixed', left: 16, top: 16, bottom: 16, zIndex: 100,
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)',
@@ -405,7 +524,6 @@ const AppLayout: React.FC = () => {
overflow: 'hidden',
border: '1px solid rgba(0, 0, 0, 0.06)',
}}>
{/* Logo */}
<div style={{
height: 80, display: 'flex', alignItems: 'center',
justifyContent: 'flex-start',
@@ -445,76 +563,10 @@ const AppLayout: React.FC = () => {
</div>
</div>
{/* Menu */}
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
{/* {!collapsed && (
<div style={{ color: 'rgba(0,0,0,0.3)', fontSize: 11, fontWeight: 600, padding: '8px 12px 6px', letterSpacing: 1 }}>
导航
</div>
)} */}
{(() => {
const groups = menuItems.filter(m => (m.menu_type ?? m.menuType) === 'group');
const pages = menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group');
const childMap: Record<string, MenuConfig[]> = {};
pages.filter(m => m.parent_id ?? m.parentId).forEach(m => {
const pid = (m.parent_id ?? m.parentId) as string;
if (!childMap[pid]) childMap[pid] = [];
childMap[pid].push(m);
});
const topLevel = pages.filter(m => !(m.parent_id ?? m.parentId));
const items: React.ReactNode[] = [];
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
const isActive = item.path === selectedKey;
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
return (
<div key={item.id} onClick={() => item.path && navigate(item.path)} style={{
display: 'flex', alignItems: 'center',
justifyContent: 'flex-start',
gap: 12,
padding: depth > 0 ? '8px 14px 8px 36px' : '10px 16px',
borderRadius: 12, margin: '2px 6px', cursor: 'pointer',
fontSize: depth > 0 ? 13 : 14, fontWeight: isActive ? 600 : 400,
color: isActive ? '#4f46e5' : '#475569',
background: isActive ? 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)' : 'transparent',
transition: 'all 0.2s ease',
}}>
<span style={{
fontSize: depth > 0 ? 14 : 16,
flexShrink: 0,
color: isActive ? '#6366f1' : '#64748b',
}}>{menuIcon}</span>
<span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>
</div>
);
};
// Render groups with children
groups.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(g => {
const children = (childMap[g.id] || []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
items.push(
<div key={g.id}>
<div style={{
color: '#94a3b8', fontSize: 12, fontWeight: 600,
padding: '12px 16px 6px', letterSpacing: 0.5, textTransform: 'uppercase',
}}>
{g.label}
</div>
{children.map(c => renderMenuItem(c, 1))}
</div>
);
});
// Render top-level pages
topLevel.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(m => {
items.push(renderMenuItem(m));
});
return items;
})()}
{topLevelItems.map(item => renderMenuItem(item))}
</div>
{/* Recharge button - opens modal */}
<div onClick={() => setRechargeModalOpen(true)} style={{
margin: '8px 12px',
padding: '12px 20px',
@@ -541,7 +593,6 @@ const AppLayout: React.FC = () => {
<span></span>
</div>
{/* User block at bottom-left */}
<div style={{ padding: '16px 16px', flexShrink: 0 }}>
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
<div style={{
@@ -589,14 +640,12 @@ const AppLayout: React.FC = () => {
</div>
</div>
{/* Main Content */}
<div className="desktop-content" style={{
marginLeft: sidebarW + 28,
marginRight: 12,
marginTop: 16,
marginBottom: 16,
flex: 1,
// height: '100%',
minHeight: 'calc(100vh - 32px)',
background: 'transparent',
padding: 0,
@@ -616,31 +665,154 @@ const AppLayout: React.FC = () => {
</div>
</div>
{/* Mobile Bottom Nav */}
<div className="mobile-bottom-nav">
{menuItems.filter((item) => (item.menu_type ?? item.menuType) !== 'group').map((item) => {
if (!item.path) return null;
const isActive = item.path === selectedKey;
return (
<div key={item.id}
className={`nav-item ${isActive ? 'active' : ''}`}
onClick={() => navigate(item.path)}>
<span className="nav-icon">{iconMap[item.icon] || <HomeOutlined />}</span>
<span>{item.label}</span>
<div className="mobile-header">
<div className="mobile-header-content">
<div className="mobile-menu-btn" onClick={() => setMobileMenuOpen(true)}>
<MenuOutlined style={{ fontSize: 20 }} />
</div>
<div className="mobile-header-title">{siteName}</div>
<div className="mobile-header-right">
<div className="mobile-credits-badge" onClick={() => setRechargeModalOpen(true)}>
<WalletOutlined />
<span>{user?.credits || 0}</span>
</div>
);
})}
<div className="nav-item" onClick={handleMobileRecharge}>
<span className="nav-icon"><PlusCircleOutlined /></span>
<span></span>
</div>
<div className="nav-item" onClick={handleLogout}>
<span className="nav-icon"><LogoutOutlined /></span>
<span>退</span>
</div>
</div>
</div>
{/* Change Password Modal */}
<div className="mobile-content">
<Outlet />
</div>
<Drawer
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.35)',
}}>
{siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 24, height: 24, objectFit: 'contain' }} />
) : (
<ThunderboltOutlined style={{ fontSize: 18, color: '#ffffff' }} />
)}
</div>
<span style={{ fontWeight: 700, fontSize: 16, color: '#1e293b' }}>{siteName}</span>
</div>
}
placement="left"
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
width={280}
closable={true}
className="mobile-menu-drawer"
styles={{
header: { borderBottom: '1px solid #f1f5f9', padding: '16px 20px' },
body: { padding: '12px 8px', display: 'flex', flexDirection: 'column' },
}}
>
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 12 }}>
{topLevelItems.map(item => {
const menuType = item.menu_type ?? item.menuType;
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
const isActive = item.path === selectedKey;
const isExpanded = mobileExpandedMenus[item.id];
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
return (
<div key={item.id}>
<div
className={`mobile-menu-item ${isActive && !hasChildren ? 'mobile-menu-item-active' : ''} ${menuType === 'group' ? 'mobile-menu-group' : ''}`}
onClick={() => handleMobileMenuClick(item)}
>
{menuType !== 'group' && (
<span className="mobile-menu-icon" style={{ color: isActive ? '#6366f1' : '#64748b' }}>
{menuIcon}
</span>
)}
<span className="mobile-menu-label" style={{
paddingLeft: menuType === 'group' ? 0 : 0,
color: menuType === 'group' ? '#94a3b8' : (isActive ? '#4f46e5' : '#475569'),
fontWeight: menuType === 'group' ? 600 : (isActive ? 600 : 400),
fontSize: menuType === 'group' ? 12 : 15,
textTransform: menuType === 'group' ? 'uppercase' : 'none',
letterSpacing: menuType === 'group' ? 0.5 : 0,
}}>
{item.label}
</span>
{(menuType === 'group' || hasChildren) && (
<span style={{
fontSize: 12,
color: '#cbd5e1',
transition: 'transform 0.2s ease',
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
}}>
<RightOutlined />
</span>
)}
</div>
{(menuType === 'group' || hasChildren) && isExpanded && (
<div className="mobile-submenu">
{(childMap[item.id] || []).map(child => {
const childActive = child.path === selectedKey;
const childIcon = iconMap[child.icon] || <HomeOutlined />;
return (
<div
key={child.id}
className={`mobile-menu-item mobile-submenu-item ${childActive ? 'mobile-menu-item-active' : ''}`}
onClick={() => handleMobileMenuClick(child)}
>
<span className="mobile-menu-icon" style={{ color: childActive ? '#6366f1' : '#94a3b8' }}>
{childIcon}
</span>
<span className="mobile-menu-label" style={{
color: childActive ? '#4f46e5' : '#64748b',
fontWeight: childActive ? 600 : 400,
fontSize: 14,
}}>
{child.label}
</span>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
<div style={{ padding: '8px 0', borderTop: '1px solid #f1f5f9' }}>
<div
className="mobile-menu-item mobile-recharge-item"
onClick={() => {
setRechargeModalOpen(true);
setMobileMenuOpen(false);
}}
>
<span className="mobile-menu-icon" style={{ color: '#fff' }}>
<PlusOutlined />
</span>
<span className="mobile-menu-label" style={{ color: '#fff', fontWeight: 600 }}></span>
</div>
<div
className="mobile-menu-item"
onClick={() => {
handleLogout();
setMobileMenuOpen(false);
}}
>
<span className="mobile-menu-icon" style={{ color: '#ef4444' }}>
<LogoutOutlined />
</span>
<span className="mobile-menu-label" style={{ color: '#ef4444' }}>退</span>
</div>
</div>
</Drawer>
<Modal title={<Space><LockOutlined /></Space>} open={pwdModalOpen}
onOk={handleChangePwd} onCancel={() => { setPwdModalOpen(false); pwdForm.resetFields(); }}
okText="确认修改" cancelText="取消" width={440}>
@@ -665,16 +837,31 @@ const AppLayout: React.FC = () => {
</Form>
</Modal>
{/* Recharge Modal */}
<Modal title={<Space><GiftOutlined /></Space>} open={rechargeModalOpen}
onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
footer={null} width={680}>
footer={null} width={680}
className="recharge-modal"
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}>
<div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 16 }}>
<WalletOutlined style={{ color: '#6366f1' }} />
<Typography.Text style={{ color: '#64748b', letterSpacing: 0 }}></Typography.Text>
<Typography.Text strong style={{ color: '#6366f1', fontSize: 20, fontWeight: 600 }}>{user?.credits ?? 0}</Typography.Text>
</Space>
<div style={{
padding: '12px 16px',
background: 'rgba(99, 102, 241, 0.06)',
borderRadius: 10,
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<InfoOutlined style={{ color: '#6366f1', fontSize: 14 }} />
<Typography.Text style={{ color: '#ff0000ff', fontSize: 13 }}>
/
</Typography.Text>
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{rechargeOptions.map((opt, idx) => {
const g = GRADIENTS[idx % GRADIENTS.length];
@@ -698,7 +885,7 @@ const AppLayout: React.FC = () => {
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<Typography.Text strong style={{ fontSize: 15 }}>{opt.name}</Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>{totalCredits.toLocaleString()} </Typography.Text>
<Typography.Text style={{ fontSize: 14, fontWeight: 600, color: '#6366f1' }}>{totalCredits.toLocaleString()} </Typography.Text>
</div>
<div style={{
fontSize: 22, fontWeight: 800, marginTop: 4,
@@ -711,7 +898,6 @@ const AppLayout: React.FC = () => {
})}
</div>
{/* Payment method selection */}
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
<div style={{ marginTop: 20, marginBottom: 8, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
@@ -757,10 +943,8 @@ const AppLayout: React.FC = () => {
try {
setPaying(true);
const order = await createRechargeOrder(plan.id, paymentMethod);
// 检查是否有二维码信息,支持支付宝和微信支付
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
// Alipay or WeChat Pay: show the QR code
const paymentInfo = {
price: plan.price,
credits: totalCredits,
@@ -772,7 +956,6 @@ const AppLayout: React.FC = () => {
setQrCodeModalOpen(true);
currentOrderNoRef.current = order.orderNo;
// 保存到 localStorage
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
orderNo: order.orderNo,
price: plan.price,
@@ -783,10 +966,8 @@ const AppLayout: React.FC = () => {
timeoutSeconds: 180,
}));
// Start polling for payment status
startPolling(order.orderNo);
} else {
// Mock mode (auto-completes, no QR needed)
message.success('充值成功!积分已到账');
useAuthStore.getState().refreshUser();
setRechargeModalOpen(false);
@@ -809,12 +990,10 @@ const AppLayout: React.FC = () => {
</div>
</Modal>
{/* QR Code Payment Modal */}
<Modal
open={qrCodeModalOpen}
onCancel={async () => {
stopPolling();
// Mark order as cancelled if it's still pending
if (currentOrderNoRef.current) {
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { }
currentOrderNoRef.current = null;
@@ -831,7 +1010,6 @@ const AppLayout: React.FC = () => {
}}
>
<div style={{ padding: '24px' }}>
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<div style={{
width: 48, height: 48,
@@ -856,7 +1034,6 @@ const AppLayout: React.FC = () => {
</Typography.Text>
</div>
{/* QR Code */}
<div style={{
background: '#fff',
borderRadius: 16,
@@ -900,7 +1077,6 @@ const AppLayout: React.FC = () => {
}}>
{currentPaymentInfo?.credits || 0}
</div>
{/* 倒计时显示 */}
<div style={{
marginTop: 12,
padding: '8px 16px',
@@ -920,7 +1096,6 @@ const AppLayout: React.FC = () => {
</div>
</div>
{/* Tips */}
<div style={{ marginTop: 20, padding: 16, background: '#fef3c7', borderRadius: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<div style={{ fontSize: 16, marginTop: -2 }}>💡</div>
@@ -934,7 +1109,6 @@ const AppLayout: React.FC = () => {
</div>
</div>
{/* Footer Buttons */}
<div style={{ marginTop: 20 }}>
<Button
size="large"
@@ -959,6 +1133,105 @@ const AppLayout: React.FC = () => {
</Modal>
<NotificationPopup />
<div className="contact-button-wrapper">
<div style={{
position: 'relative',
}}>
<div
className="contact-tooltip"
style={{
opacity: contactHovered ? 1 : 0,
}}
>
</div>
<button
onClick={() => setContactModalOpen(true)}
className="contact-button"
onMouseEnter={() => setContactHovered(true)}
onMouseLeave={() => setContactHovered(false)}
>
<MessageOutlined style={{ fontSize: 20 }} />
</button>
</div>
</div>
<Modal
title={<Space><MessageOutlined /></Space>}
open={contactModalOpen}
onCancel={() => { setContactModalOpen(false); contactForm.resetFields(); }}
footer={null}
width={480}
className="contact-modal"
>
<div style={{ marginTop: 8 }}>
<Form form={contactForm} layout="vertical">
<Form.Item
name="name"
label="姓名"
rules={[{ required: true, message: '请输入姓名' }]}
>
<Input placeholder="请输入您的姓名" size="large" />
</Form.Item>
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
]}
>
<Input placeholder="请输入您的手机号" size="large" />
</Form.Item>
<Form.Item
name="companyName"
label="公司名称"
rules={[{ required: true, message: '请输入公司名称' }]}
>
<Input placeholder="请输入公司名称" size="large" />
</Form.Item>
<Form.Item
name="industry"
label="您的行业"
rules={[{ required: true, message: '请输入您的行业' }]}
>
<Input placeholder="请输入您的行业" size="large" />
</Form.Item>
<Form.Item name="message" label="留言(选填)">
<Input.TextArea
placeholder="请输入您的需求或问题"
rows={3}
style={{ borderRadius: 10 }}
/>
</Form.Item>
</Form>
<div style={{ marginTop: 16, display: 'flex', gap: 12 }}>
<Button
size="large"
onClick={() => { setContactModalOpen(false); contactForm.resetFields(); }}
style={{ borderRadius: 10, flex: 1 }}
>
</Button>
<Button
type="primary"
size="large"
onClick={handleContactSubmit}
loading={submittingContact}
style={{
borderRadius: 10,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none',
flex: 1,
}}
>
</Button>
</div>
</div>
</Modal>
</Layout>
);
};
@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { Popover, Tag, List, Descriptions, Typography } from 'antd';
interface PreResultData {
video_id: string; //视频id
advertiser_id: number; //广告主id
material_id: string; //素材id
is_ad_high_quality_material: string; //是否优质素材
is_ecp_high_quality_material: string; //是否千川优质素材
is_inefficient_material: string; //是否低效素材
is_first_publish_material: string; //是否首发素材
not_ad_high_quality_reason: string[] | null; //AD非优质原因
not_ecp_high_quality_reason: string[] | null; //千川非优质原因
is_local_high_quality_material: string; //是否本地推优质素材
}
interface PreResultDisplayProps {
preResult: string;
}
const qualityConfig: Record<string, { color: string; label: string }> = {
YES: { color: 'green', label: '是' },
NO: { color: 'red', label: '否' },
UNKNOWN: { color: 'default', label: '未知' },
};
const PreResultDisplay: React.FC<PreResultDisplayProps> = ({ preResult }) => {
const [parsedData, setParsedData] = useState<PreResultData | null>(null);
const [hasError, setHasError] = useState(false);
React.useEffect(() => {
if (!preResult) {
setParsedData(null);
setHasError(false);
return;
}
try {
const data = JSON.parse(preResult);
setParsedData(data);
setHasError(false);
} catch {
setParsedData(null);
setHasError(true);
}
}, [preResult]);
if (!preResult || hasError || !parsedData) {
return <span style={{ color: '#64748b' }}>-</span>;
}
const allQualityFields = [
parsedData.is_ad_high_quality_material,
parsedData.is_ecp_high_quality_material,
parsedData.is_local_high_quality_material,
];
const hasNoQuality = allQualityFields.some((val) => val === 'NO');
const allYes = allQualityFields.every((val) => val === 'YES');
let statusTag;
if (hasNoQuality) {
statusTag = <Tag color="red"></Tag>;
} else if (allYes) {
statusTag = <Tag color="green"></Tag>;
} else {
statusTag = <Tag color="default"></Tag>;
}
const content = (
<div style={{ maxWidth: 500 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12 }}></Typography.Text>
<Descriptions column={1} size="small" style={{ marginBottom: 12 }}>
<Descriptions.Item label="视频ID">{parsedData.video_id}</Descriptions.Item>
<Descriptions.Item label="广告主ID">{parsedData.advertiser_id}</Descriptions.Item>
<Descriptions.Item label="素材ID">{parsedData.material_id}</Descriptions.Item>
</Descriptions>
<Typography.Text strong style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 8 }}></Typography.Text>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
<Tag color={qualityConfig[parsedData.is_ad_high_quality_material]?.color}>
AD优质素材: {qualityConfig[parsedData.is_ad_high_quality_material]?.label}
</Tag>
<Tag color={qualityConfig[parsedData.is_ecp_high_quality_material]?.color}>
: {qualityConfig[parsedData.is_ecp_high_quality_material]?.label}
</Tag>
<Tag color={qualityConfig[parsedData.is_local_high_quality_material]?.color}>
: {qualityConfig[parsedData.is_local_high_quality_material]?.label}
</Tag>
<Tag color={qualityConfig[parsedData.is_inefficient_material]?.color}>
: {qualityConfig[parsedData.is_inefficient_material]?.label}
</Tag>
<Tag color={qualityConfig[parsedData.is_first_publish_material]?.color}>
: {qualityConfig[parsedData.is_first_publish_material]?.label}
</Tag>
</div>
{parsedData.not_ad_high_quality_reason && parsedData.not_ad_high_quality_reason.length > 0 && (
<div style={{ marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 12, color: '#ef4444', display: 'block', marginBottom: 8 }}>
AD非优质原因
</Typography.Text>
<List
dataSource={parsedData.not_ad_high_quality_reason}
renderItem={(item, index) => (
<List.Item key={index} style={{ padding: '4px 0', fontSize: 12, color: '#64748b' }}>
{index + 1}. {item}
</List.Item>
)}
size="small"
/>
</div>
)}
{parsedData.not_ecp_high_quality_reason && parsedData.not_ecp_high_quality_reason.length > 0 && (
<div>
<Typography.Text strong style={{ fontSize: 12, color: '#ef4444', display: 'block', marginBottom: 8 }}>
</Typography.Text>
<List
dataSource={parsedData.not_ecp_high_quality_reason}
renderItem={(item, index) => (
<List.Item key={index} style={{ padding: '4px 0', fontSize: 12, color: '#64748b' }}>
{index + 1}. {item}
</List.Item>
)}
size="small"
/>
</div>
)}
</div>
);
return (
<Popover content={content} title={null} trigger="hover">
{statusTag}
</Popover>
);
};
export default PreResultDisplay;
+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