dist/首页
This commit is contained in:
@@ -12,6 +12,11 @@ node_modules
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
+442
File diff suppressed because one or more lines are too long
-442
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -28,8 +28,8 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-C49YKZ--.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DyH2vHpW.css">
|
||||
<script type="module" crossorigin src="/assets/index-1RNaKjB1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-xCZbcxht.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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