dist/首页
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user