From cace26018a8e1a466200502753868d952a10db6e Mon Sep 17 00:00:00 2001 From: wwwwwwwww <526125649@qq.com> Date: Thu, 11 Jun 2026 11:59:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=95=B4=E4=BD=93=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E8=A7=84=E5=88=99=EF=BC=8C=E5=A2=9E=E5=8A=A0=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E8=B6=85=E6=97=B6=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=9C=8D=E5=8A=A1=E9=87=8D=E5=90=AF=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=EF=BC=8C=E5=A2=9E=E5=8A=A0=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E5=92=8C=E8=B6=85=E6=97=B6=E5=85=B3=E9=97=AD=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=EF=BC=8C=E5=88=A0=E9=99=A4=E5=89=8D=E5=8F=B0=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/AdminPaymentConfig.tsx | 42 ++- video-gen-api/app/api/v1/admin.py | 88 +---- video-gen-api/app/api/v1/payments.py | 12 + video-gen-api/app/main.py | 26 +- video-gen-api/app/services/payment.py | 210 ++++++++++- .../src/components/Layout/AppLayout.tsx | 67 +++- .../src/pages/admin/AdminCreditRatios.tsx | 175 --------- .../src/pages/admin/AdminCreditRecords.tsx | 143 -------- .../src/pages/admin/AdminDashboard.tsx | 116 ------ .../src/pages/admin/AdminIndustries.tsx | 336 ------------------ video-gen-app/src/pages/admin/AdminLayout.tsx | 163 --------- .../src/pages/admin/AdminLoginPage.tsx | 78 ---- video-gen-app/src/pages/admin/AdminModels.tsx | 222 ------------ .../pages/admin/AdminNotificationManager.tsx | 166 --------- .../src/pages/admin/AdminNotifications.tsx | 114 ------ .../src/pages/admin/AdminPaymentConfig.tsx | 153 -------- .../src/pages/admin/AdminSettings.tsx | 128 ------- video-gen-app/src/pages/admin/AdminUsers.tsx | 182 ---------- .../src/pages/admin/AdminVideoEngines.tsx | 214 ----------- 19 files changed, 341 insertions(+), 2294 deletions(-) delete mode 100644 video-gen-app/src/pages/admin/AdminCreditRatios.tsx delete mode 100644 video-gen-app/src/pages/admin/AdminCreditRecords.tsx delete mode 100644 video-gen-app/src/pages/admin/AdminDashboard.tsx delete mode 100644 video-gen-app/src/pages/admin/AdminIndustries.tsx delete mode 100644 video-gen-app/src/pages/admin/AdminLayout.tsx delete mode 100644 video-gen-app/src/pages/admin/AdminLoginPage.tsx delete mode 100644 video-gen-app/src/pages/admin/AdminModels.tsx delete mode 100644 video-gen-app/src/pages/admin/AdminNotificationManager.tsx delete mode 100644 video-gen-app/src/pages/admin/AdminNotifications.tsx delete mode 100644 video-gen-app/src/pages/admin/AdminPaymentConfig.tsx delete mode 100644 video-gen-app/src/pages/admin/AdminSettings.tsx delete mode 100644 video-gen-app/src/pages/admin/AdminUsers.tsx delete mode 100644 video-gen-app/src/pages/admin/AdminVideoEngines.tsx diff --git a/video-gen-admin/src/pages/AdminPaymentConfig.tsx b/video-gen-admin/src/pages/AdminPaymentConfig.tsx index 63b14202..c125f1c1 100644 --- a/video-gen-admin/src/pages/AdminPaymentConfig.tsx +++ b/video-gen-admin/src/pages/AdminPaymentConfig.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from 'react'; import { - Button, Card, Form, Input, message, Switch, Typography, + Button, Card, Form, Input, message, Switch, Typography, InputNumber, } from 'antd'; import { - SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined, + SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined, ClockCircleOutlined, } from '@ant-design/icons'; import { getPaymentConfigs, batchUpdatePaymentConfigs } from '../api'; @@ -12,6 +12,7 @@ const AdminPaymentConfig: React.FC = () => { const [wechatEnabled, setWechatEnabled] = useState(false); const [alipayEnabled, setAlipayEnabled] = useState(false); const [mockMode, setMockMode] = useState(false); + const [orderTimeout, setOrderTimeout] = useState(180); const [form] = Form.useForm(); const load = async () => { @@ -29,10 +30,12 @@ const AdminPaymentConfig: React.FC = () => { alipay_public_key: map['payment_alipay_public_key'] || '', alipay_notify_url: map['payment_alipay_notify_url'] || '', alipay_gateway: map['payment_alipay_gateway'] || '', + order_timeout: map['payment_order_timeout'] || '180', }); setWechatEnabled(map['payment_wechat_enabled'] === 'true'); setAlipayEnabled(map['payment_alipay_enabled'] === 'true'); setMockMode(map['payment_mock'] === 'true'); + setOrderTimeout(parseInt(map['payment_order_timeout'] || '180', 10)); } catch { message.error('加载支付配置失败'); } @@ -57,6 +60,7 @@ const AdminPaymentConfig: React.FC = () => { payment_alipay_public_key: values.alipay_public_key || '', payment_alipay_notify_url: values.alipay_notify_url || '', payment_alipay_gateway: values.alipay_gateway || '', + payment_order_timeout: String(values.order_timeout || 180), }); message.success('支付配置已保存'); load(); @@ -69,6 +73,40 @@ const AdminPaymentConfig: React.FC = () => { return (
+ {/* 通用设置 */} + +
+
+
+
+ 通用设置 + 订单超时和测试模式配置 +
+
+
+ +
+ 订单超时时间} + extra="订单创建后超过此时间未支付将自动取消(秒)" + > + + +
+
+ {/* Mock Mode Toggle */}
diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py index 1bd79e7f..c71df8da 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -458,7 +458,7 @@ async def get_payment_stats( ) by_status = {} for row in status_result.all(): - by_status[row.status] = {"count": row.count, "amount": float(row.amount)} + by_status[row.status] = {"count": row.count, "amount": round(float(row.amount), 2)} # Today's stats today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) @@ -485,20 +485,20 @@ async def get_payment_stats( "by_status": by_status, "today": { "paid_count": today_row.paid_count, - "paid_amount": float(today_row.paid_amount), + "paid_amount": round(float(today_row.paid_amount), 2), }, "recent": [ { "id": o.id, "order_no": o.order_no, "user_id": o.user_id, - "amount": o.amount, - "credits": o.credits, + "amount": round(o.amount, 2), + "credits": round(o.credits, 2), "payment_method": o.payment_method, "status": o.status, "trade_no": o.trade_no, - "paid_at": o.paid_at.isoformat() if o.paid_at else None, - "created_at": o.created_at.isoformat() if o.created_at else None, + "paid_at": _iso(o.paid_at), + "created_at": _iso(o.created_at), } for o in recent ], @@ -544,13 +544,13 @@ async def get_admin_payment_orders( "id": o.id, "order_no": o.order_no, "user_id": o.user_id, - "amount": o.amount, - "credits": o.credits, + "amount": round(o.amount, 2), + "credits": round(o.credits, 2), "payment_method": o.payment_method, "status": o.status, "trade_no": o.trade_no, - "paid_at": o.paid_at.isoformat() if o.paid_at else None, - "created_at": o.created_at.isoformat() if o.created_at else None, + "paid_at": _iso(o.paid_at), + "created_at": _iso(o.created_at), } for o in orders ], @@ -1371,72 +1371,4 @@ async def admin_generate_video( # ── Payment Stats ──────────────────────────────────────── -@router.get("/payment-stats") -async def get_payment_stats( - admin: User = Depends(get_admin_user), - db: AsyncSession = Depends(get_db), -): - """Payment statistics for admin dashboard.""" - from app.models.payment_order import PaymentOrder - from datetime import datetime - # Count and revenue by status - rows = (await db.execute( - select( - PaymentOrder.status, - PaymentOrder.payment_method, - func.count(PaymentOrder.id).label("count"), - func.coalesce(func.sum(PaymentOrder.amount), 0).label("total_amount"), - ).group_by(PaymentOrder.status, PaymentOrder.payment_method) - )).all() - - by_status: dict[str, dict] = {} - for r in rows: - s = r.status - if s not in by_status: - by_status[s] = {"count": 0, "amount": 0.0} - by_status[s]["count"] += r.count - by_status[s]["amount"] += float(r.total_amount) - - # Recent orders (last 50) - recent = (await db.execute( - select(PaymentOrder) - .order_by(PaymentOrder.created_at.desc()) - .limit(50) - )).scalars().all() - - # Today stats - today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - today_paid = (await db.execute( - select( - func.count(PaymentOrder.id), - func.coalesce(func.sum(PaymentOrder.amount), 0), - ).where( - PaymentOrder.status == "paid", - PaymentOrder.paid_at >= today_start, - ) - )).first() - today_count, today_amount = (today_paid or (0, 0)) - - return { - "by_status": by_status, - "today": { - "paid_count": int(today_count or 0), - "paid_amount": float(today_amount or 0), - }, - "recent": [ - { - "id": o.id, - "order_no": o.order_no, - "user_id": o.user_id, - "amount": o.amount, - "credits": o.credits, - "payment_method": o.payment_method, - "status": o.status, - "trade_no": o.trade_no, - "created_at": _iso(o.created_at), - "paid_at": _iso(o.paid_at), - } - for o in recent - ], - } diff --git a/video-gen-api/app/api/v1/payments.py b/video-gen-api/app/api/v1/payments.py index 273c76cb..aba271db 100644 --- a/video-gen-api/app/api/v1/payments.py +++ b/video-gen-api/app/api/v1/payments.py @@ -16,6 +16,9 @@ from app.services.payment import ( verify_wechat_callback, verify_alipay_callback, process_payment_success_by_order_no, + _get_payment_configs, + _close_alipay_order, + _get_order_expire_seconds, ) router = APIRouter(prefix="/payments", tags=["payments"]) @@ -148,6 +151,15 @@ async def cancel_order( raise HTTPException(status_code=404, detail="订单不存在") if order.status != "pending": raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消") + + # If it's an Alipay order, call close API first + if order.payment_method == "alipay": + db_configs = await _get_payment_configs(db) + try: + await _close_alipay_order(db, order, db_configs) + except Exception as e: + logger.exception(f"Failed to close Alipay order {order_no}: {e}") + order.status = "cancelled" await db.flush() logger.info( diff --git a/video-gen-api/app/main.py b/video-gen-api/app/main.py index 41a085f5..a9049055 100644 --- a/video-gen-api/app/main.py +++ b/video-gen-api/app/main.py @@ -36,14 +36,20 @@ async def lifespan(app: FastAPI): await task_queue.recover() queue_task = asyncio.create_task(task_queue.run()) - # Background task: auto-expire pending payment orders + # Background task: auto-expire pending payment orders and sync status async def _order_expiry_loop(): - from app.services.payment import expire_all_pending_orders + from app.services.payment import expire_all_pending_orders, sync_pending_orders from logging import getLogger bg_logger = getLogger("payment") while True: try: async with async_session() as db: + # 同步待支付订单状态(检查支付宝实际支付状态 + sync_count = await sync_pending_orders(db) + if sync_count > 0: + bg_logger.info(f"Synced {sync_count} pending payment order(s)") + + # 自动过期订单 n = await expire_all_pending_orders(db) if n > 0: bg_logger.info(f"Auto-expired {n} pending payment order(s)") @@ -52,6 +58,22 @@ async def lifespan(app: FastAPI): await asyncio.sleep(60) # check every minute expiry_task = asyncio.create_task(_order_expiry_loop()) + + # 启动时立即同步一次未支付订单 + asyncio.create_task(asyncio.sleep(5)) # 等待5秒后再同步,让系统完全启动 + async def startup_sync(): + await asyncio.sleep(5) + from app.services.payment import sync_pending_orders + from logging import getLogger + bg_logger = getLogger("payment") + try: + async with async_session() as db: + sync_count = await sync_pending_orders(db) + if sync_count > 0: + bg_logger.info(f"Startup: Synced {sync_count} pending payment order(s)") + except Exception as e: + bg_logger.error(f"Startup sync error: {e}") + asyncio.create_task(startup_sync()) app.state.db_session_factory = async_session diff --git a/video-gen-api/app/services/payment.py b/video-gen-api/app/services/payment.py index 03824350..a7fe5249 100644 --- a/video-gen-api/app/services/payment.py +++ b/video-gen-api/app/services/payment.py @@ -70,8 +70,17 @@ _handler.setFormatter(logging.Formatter( if not logger.handlers: logger.addHandler(_handler) -# Orders pending payment for longer than this are auto-cancelled -ORDER_EXPIRE_MINUTES = 5 +# Order expire time in seconds (configurable via payment_order_timeout setting, default 180 seconds) +DEFAULT_ORDER_EXPIRE_SECONDS = 180 + + +def _get_order_expire_seconds(db_configs: dict[str, str]) -> int: + """Get order expire time in seconds from config, with fallback to 180.""" + try: + val = db_configs.get("payment_order_timeout", str(DEFAULT_ORDER_EXPIRE_SECONDS)) + return int(val) if val.strip() else DEFAULT_ORDER_EXPIRE_SECONDS + except ValueError: + return DEFAULT_ORDER_EXPIRE_SECONDS # --------------------------------------------------------------------------- @@ -126,7 +135,9 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool """ if order.status != "pending": return False - expiry = order.created_at + timedelta(minutes=ORDER_EXPIRE_MINUTES) + db_configs = await _get_payment_configs(db) + expire_seconds = _get_order_expire_seconds(db_configs) + expiry = order.created_at + timedelta(seconds=expire_seconds) if datetime.now(order.created_at.tzinfo) >= expiry: order.status = "cancelled" await db.flush() @@ -134,6 +145,12 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} " f"amount={order.amount} created_at={order.created_at.isoformat()}" ) + # Also call Alipay close API if it was an Alipay order + if order.payment_method == "alipay": + try: + await _close_alipay_order(db, order, db_configs) + except Exception as e: + logger.exception(f"Failed to close Alipay order {order.order_no}: {e}") return True return False @@ -142,7 +159,9 @@ async def expire_all_pending_orders(db: AsyncSession) -> int: """Background task: mark all expired pending orders as cancelled. Returns the number of orders expired. """ - threshold = datetime.now() - timedelta(minutes=ORDER_EXPIRE_MINUTES) + db_configs = await _get_payment_configs(db) + expire_seconds = _get_order_expire_seconds(db_configs) + threshold = datetime.now() - timedelta(seconds=expire_seconds) result = await db.execute( select(PaymentOrder).where( PaymentOrder.status == "pending", @@ -150,14 +169,22 @@ async def expire_all_pending_orders(db: AsyncSession) -> int: ) ) orders = result.scalars().all() + expired_count = 0 for o in orders: o.status = "cancelled" + expired_count += 1 logger.info( f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}" ) + # Also call Alipay close API if it was an Alipay order + if o.payment_method == "alipay": + try: + await _close_alipay_order(db, o, db_configs) + except Exception as e: + logger.exception(f"Failed to close Alipay order {o.order_no}: {e}") if orders: await db.flush() - return len(orders) + return expired_count def _is_mock_mode(db_configs: dict[str, str]) -> bool: @@ -417,6 +444,175 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str return None +# --------------------------------------------------------------------------- +# Alipay order close +# --------------------------------------------------------------------------- + + +async def _close_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> bool: + """Call Alipay trade.close API to close an unpaid order. + Returns True if the order was closed successfully. + """ + app_id = db_configs.get("payment_alipay_app_id", "") + private_key = db_configs.get("payment_alipay_private_key", "") + public_key = db_configs.get("payment_alipay_public_key", "") + gateway = db_configs.get("payment_alipay_gateway", "") + + client = _get_alipay_client(app_id, private_key, public_key, gateway) + if client is None: + return False + + mock_mode = _is_mock_mode(db_configs) + if mock_mode: + logger.info(f"Mock mode: skipping close_alipay_order for {order.order_no}") + return True + + try: + from alipay.aop.api.domain.AlipayTradeCloseModel import AlipayTradeCloseModel + from alipay.aop.api.request.AlipayTradeCloseRequest import AlipayTradeCloseRequest + from alipay.aop.api.response.AlipayTradeCloseResponse import AlipayTradeCloseResponse + + model = AlipayTradeCloseModel() + model.out_trade_no = order.order_no + + request = AlipayTradeCloseRequest(biz_model=model) + + response_content = client.execute(request) + if not response_content: + logger.error(f"Alipay close failed: empty response, order_no={order.order_no}") + return False + + response = AlipayTradeCloseResponse() + response.parse_response_content(response_content) + + if response.is_success(): + logger.info(f"Alipay order closed: order_no={order.order_no}") + return True + else: + logger.error( + f"Alipay close failed: code={response.code}, " + f"msg={response.msg}, sub_code={response.sub_code}, " + f"sub_msg={response.sub_msg}, order_no={order.order_no}" + ) + return False + + except Exception as e: + if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)): + logger.error( + f"Alipay SDK TypeError (bytes/str issue) during close: order_no={order.order_no}, " + f"error={str(e)}" + ) + logger.exception(f"Alipay close exception: order_no={order.order_no}") + return False + + +# --------------------------------------------------------------------------- +# Alipay order query +# --------------------------------------------------------------------------- + + +async def _query_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> dict | None: + """Call Alipay trade.query API to check order status. + Returns the response data if successful, None otherwise. + """ + app_id = db_configs.get("payment_alipay_app_id", "") + private_key = db_configs.get("payment_alipay_private_key", "") + public_key = db_configs.get("payment_alipay_public_key", "") + gateway = db_configs.get("payment_alipay_gateway", "") + + client = _get_alipay_client(app_id, private_key, public_key, gateway) + if client is None: + return None + + mock_mode = _is_mock_mode(db_configs) + if mock_mode: + logger.info(f"Mock mode: skipping query_alipay_order for {order.order_no}") + return {"trade_status": "TRADE_FINISHED"} + + try: + from alipay.aop.api.domain.AlipayTradeQueryModel import AlipayTradeQueryModel + from alipay.aop.api.request.AlipayTradeQueryRequest import AlipayTradeQueryRequest + from alipay.aop.api.response.AlipayTradeQueryResponse import AlipayTradeQueryResponse + + model = AlipayTradeQueryModel() + model.out_trade_no = order.order_no + + request = AlipayTradeQueryRequest(biz_model=model) + + response_content = client.execute(request) + if not response_content: + logger.error(f"Alipay query failed: empty response, order_no={order.order_no}") + return None + + response = AlipayTradeQueryResponse() + response.parse_response_content(response_content) + + if response.is_success(): + logger.info(f"Alipay query succeeded: order_no={order.order_no}, trade_status={response.trade_status}") + return { + "trade_no": response.trade_no, + "trade_status": response.trade_status, + "total_amount": response.total_amount, + "receipt_amount": response.receipt_amount, + } + else: + logger.error( + f"Alipay query failed: code={response.code}, " + f"msg={response.msg}, sub_code={response.sub_code}, " + f"sub_msg={response.sub_msg}, order_no={order.order_no}" + ) + return None + + except Exception as e: + if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)): + logger.error( + f"Alipay SDK TypeError (bytes/str issue) during query: order_no={order.order_no}, " + f"error={str(e)}" + ) + logger.exception(f"Alipay query exception: order_no={order.order_no}") + return None + + +async def sync_pending_orders(db: AsyncSession) -> int: + """Check pending orders via Alipay query and update status. + Returns the number of orders updated. + """ + result = await db.execute( + select(PaymentOrder).where( + PaymentOrder.status == "pending", + ) + ) + orders = result.scalars().all() + updated_count = 0 + + db_configs = await _get_payment_configs(db) + + for order in orders: + if order.payment_method != "alipay": + continue + + try: + data = await _query_alipay_order(db, order, db_configs) + if data: + trade_status = data.get("trade_status") + if trade_status in ("TRADE_SUCCESS", "TRADE_FINISHED"): + # Order was paid but we missed the callback + trade_no = data.get("trade_no", "") + await process_payment_success_by_order_no(db, order.order_no, trade_no) + updated_count += 1 + elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"): + # Order was closed on Alipay side + order.status = "cancelled" + await db.flush() + updated_count += 1 + except Exception as e: + logger.exception(f"Failed to sync order {order.order_no}: {e}") + + if updated_count > 0: + await db.flush() + return updated_count + + # --------------------------------------------------------------------------- # Alipay callback verification # --------------------------------------------------------------------------- @@ -457,8 +653,8 @@ async def verify_alipay_callback(data: dict, db: AsyncSession) -> bool: f"{k}={v}" for k, v in sorted(verify_data.items()) ) - logger.info(f"Verifying Alipay callback sign_content: {sign_content[:100]}...") - logger.info(f"Sign type: {sign_type}") + # logger.info(f"Verifying Alipay callback sign_content: {sign_content[:100]}...") + # logger.info(f"Sign type: {sign_type}") # 实现 RSA2 签名验证 is_valid = _verify_alipay_sign(public_key, sign_content, sign, sign_type) diff --git a/video-gen-app/src/components/Layout/AppLayout.tsx b/video-gen-app/src/components/Layout/AppLayout.tsx index 5c32218e..eb0f58f9 100644 --- a/video-gen-app/src/components/Layout/AppLayout.tsx +++ b/video-gen-app/src/components/Layout/AppLayout.tsx @@ -92,7 +92,9 @@ const AppLayout: React.FC = () => { const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null); const [paymentMethod, setPaymentMethod] = useState('alipay'); const [paying, setPaying] = useState(false); + const [countdown, setCountdown] = useState(180); // 默认180秒超时 const pollingTimerRef = useRef | null>(null); + const countdownTimerRef = useRef | null>(null); const currentOrderNoRef = useRef(null); const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false }); @@ -190,25 +192,23 @@ const AppLayout: React.FC = () => { clearInterval(pollingTimerRef.current); pollingTimerRef.current = null; } + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } }, []); - const startPolling = useCallback((orderNo: string) => { + const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => { stopPolling(); - let attempts = 0; - const maxAttempts = 120; // 2 minutes at 1s interval - const timer = setInterval(async () => { - attempts++; - if (attempts > maxAttempts) { - clearInterval(timer); - pollingTimerRef.current = null; - return; - } + setCountdown(timeoutSeconds); + + // 订单状态轮询(每2秒查询一次,减少请求频率 + const pollingTimer = setInterval(async () => { try { const orders = await getPaymentOrders(); const order = orders.find((o: any) => o.orderNo === orderNo); if (order && order.status === 'paid') { - clearInterval(timer); - pollingTimerRef.current = null; + stopPolling(); currentOrderNoRef.current = null; message.success('支付成功!积分已到账'); useAuthStore.getState().refreshUser(); @@ -216,15 +216,35 @@ const AppLayout: React.FC = () => { setCurrentPaymentInfo(null); setSelectedPlan(null); } else if (order && order.status === 'cancelled') { - clearInterval(timer); - pollingTimerRef.current = null; + stopPolling(); currentOrderNoRef.current = null; } } catch { // ignore polling errors } + }, 2000); + pollingTimerRef.current = pollingTimer; + + // 倒计时 + const countdownTimer = setInterval(() => { + setCountdown(prev => { + if (prev <= 1) { + // 超时自动取消 + stopPolling(); + if (currentOrderNoRef.current) { + cancelPaymentOrder(currentOrderNoRef.current).catch(() => {}); + currentOrderNoRef.current = null; + } + message.warning('订单已超时,请重新充值'); + setQrCodeModalOpen(false); + setCurrentPaymentInfo(null); + setSelectedPlan(null); + return 0; + } + return prev - 1; + }); }, 1000); - pollingTimerRef.current = timer; + countdownTimerRef.current = countdownTimer; }, [stopPolling]); return ( @@ -744,6 +764,23 @@ const AppLayout: React.FC = () => { }}> 购买 {currentPaymentInfo?.credits || 0} 积分
+ {/* 倒计时显示 */} +
+ + 订单将在 {countdown} 秒后关闭 + +
diff --git a/video-gen-app/src/pages/admin/AdminCreditRatios.tsx b/video-gen-app/src/pages/admin/AdminCreditRatios.tsx deleted file mode 100644 index 0bf3beaf..00000000 --- a/video-gen-app/src/pages/admin/AdminCreditRatios.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import React, { useState } from 'react'; -import { - Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography, -} from 'antd'; -import { - CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined, -} from '@ant-design/icons'; - -interface CreditRatio { - id: string; - modelName: string; - resolution: string; - ratio: number; - baseCredits: number; - perSecondCredits: number; -} - -const MOCK_RATIOS: CreditRatio[] = [ - { id: 'cr-1', modelName: 'GPT-4o', resolution: '720p', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 }, - { id: 'cr-2', modelName: 'GPT-4o', resolution: '1080p', ratio: 1.5, baseCredits: 90, perSecondCredits: 3 }, - { id: 'cr-3', modelName: 'GPT-4o', resolution: '4K', ratio: 2.5, baseCredits: 150, perSecondCredits: 5 }, - { id: 'cr-4', modelName: 'DeepSeek-V3', resolution: '720p', ratio: 0.8, baseCredits: 48, perSecondCredits: 2 }, - { id: 'cr-5', modelName: 'DeepSeek-V3', resolution: '1080p', ratio: 1.2, baseCredits: 72, perSecondCredits: 3 }, - { id: 'cr-6', modelName: 'DeepSeek-V3', resolution: '4K', ratio: 2.0, baseCredits: 120, perSecondCredits: 4 }, - { id: 'cr-7', modelName: '通用', resolution: '720p', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 }, - { id: 'cr-8', modelName: '通用', resolution: '1080p', ratio: 1.5, baseCredits: 90, perSecondCredits: 3 }, - { id: 'cr-9', modelName: '通用', resolution: '4K', ratio: 2.5, baseCredits: 150, perSecondCredits: 5 }, -]; - -const AdminCreditRatios: React.FC = () => { - const [ratios, setRatios] = useState(MOCK_RATIOS); - const [modal, setModal] = useState<{ open: boolean; ratio: CreditRatio | null }>({ open: false, ratio: null }); - const [form] = Form.useForm(); - - const handleSave = async () => { - try { - const values = await form.validateFields(); - if (modal.ratio) { - setRatios(prev => prev.map(r => r.id === modal.ratio!.id ? { ...r, ...values } : r)); - message.success('已更新'); - } else { - setRatios(prev => [...prev, { id: `cr-${Date.now()}`, ...values }]); - message.success('已添加'); - } - setModal({ open: false, ratio: null }); - form.resetFields(); - } catch { /* validation */ } - }; - - const handleDelete = (id: string) => { - setRatios(prev => prev.filter(r => r.id !== id)); - message.success('已删除'); - }; - - const openEdit = (ratio?: CreditRatio) => { - setModal({ open: true, ratio: ratio || null }); - if (ratio) form.setFieldsValue(ratio); - else { form.resetFields(); form.setFieldsValue({ ratio: 1.0, baseCredits: 60, perSecondCredits: 2 }); } - }; - - const columns = [ - { - title: '模型', dataIndex: 'modelName', width: 150, - render: (v: string) => {v}, - }, - { - title: '分辨率', dataIndex: 'resolution', width: 100, - render: (v: string) => { - const colors: Record = { '720p': 'default', '1080p': 'blue', '4K': 'gold' }; - return {v}; - }, - }, - { - title: '倍率', dataIndex: 'ratio', width: 100, sorter: (a: CreditRatio, b: CreditRatio) => a.ratio - b.ratio, - render: (v: number) => ( - = 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}> - x{v} - - ), - }, - { - title: '基础积分', dataIndex: 'baseCredits', width: 100, - render: (v: number) => {v} 积分, - }, - { - title: '每秒积分', dataIndex: 'perSecondCredits', width: 100, - render: (v: number) => {v} 积分/秒, - }, - { - title: '示例计算 (15秒)', key: 'example', width: 120, - render: (_: any, r: CreditRatio) => { - const total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio); - return {total} 积分; - }, - }, - { - title: '操作', key: 'action', width: 150, fixed: 'right' as const, - render: (_: any, r: CreditRatio) => ( - - - handleDelete(r.id)}> - - - - ), - }, - ]; - - return ( -
- -
- - - 积分比例配置 - {ratios.length} 条规则 - - -
- - - 积分计算公式:(基础积分 + 每秒积分 x 视频时长) x 模型倍率 - - - - - - {modal.ratio ? '编辑比例' : '添加比例'}} - open={modal.open} - onOk={handleSave} - onCancel={() => { setModal({ open: false, ratio: null }); form.resetFields(); }} - okText="保存" cancelText="取消" width={480} - > -
- - - -
- - - - - - - - - -
- -
- - ); -}; - -export default AdminCreditRatios; diff --git a/video-gen-app/src/pages/admin/AdminCreditRecords.tsx b/video-gen-app/src/pages/admin/AdminCreditRecords.tsx deleted file mode 100644 index 85bcb708..00000000 --- a/video-gen-app/src/pages/admin/AdminCreditRecords.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - Button, Card, DatePicker, Select, Space, Table, Tag, Typography, -} from 'antd'; -import { - WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, SearchOutlined, -} from '@ant-design/icons'; - -interface CreditRecord { - id: string; - username: string; - type: 'recharge' | 'consume'; - amount: number; - balanceAfter: number; - description: string; - createdAt: string; -} - -const MOCK_RECORDS: CreditRecord[] = [ - { id: 'cr-1', username: 'videomaker', type: 'recharge', amount: 3000, balanceAfter: 3000, description: '会员充值赠送', createdAt: '2026-04-28 10:00:00' }, - { id: 'cr-2', username: 'videomaker', type: 'consume', amount: -120, balanceAfter: 2880, description: '提示词优化 - 电商广告视频', createdAt: '2026-04-29 14:22:00' }, - { id: 'cr-3', username: 'designer', type: 'recharge', amount: 2000, balanceAfter: 2000, description: '进阶包充值', createdAt: '2026-04-29 16:00:00' }, - { id: 'cr-4', username: 'videomaker', type: 'consume', amount: -80, balanceAfter: 2800, description: '提示词优化 - 教育课程视频', createdAt: '2026-04-30 09:15:00' }, - { id: 'cr-5', username: 'designer', type: 'consume', amount: -100, balanceAfter: 1900, description: '提示词优化 - 品牌故事视频', createdAt: '2026-05-01 11:30:00' }, - { id: 'cr-6', username: 'marketer', type: 'recharge', amount: 5000, balanceAfter: 5000, description: '专业包充值', createdAt: '2026-05-02 08:00:00' }, - { id: 'cr-7', username: 'videomaker', type: 'recharge', amount: 500, balanceAfter: 3300, description: '活动赠送积分', createdAt: '2026-05-02 11:00:00' }, - { id: 'cr-8', username: 'marketer', type: 'consume', amount: -120, balanceAfter: 4880, description: '提示词优化 - 产品宣传视频', createdAt: '2026-05-03 15:20:00' }, - { id: 'cr-9', username: 'editor', type: 'recharge', amount: 1500, balanceAfter: 1500, description: '体验包充值', createdAt: '2026-05-04 10:00:00' }, - { id: 'cr-10', username: 'designer', type: 'consume', amount: -200, balanceAfter: 1700, description: '提示词优化 - 游戏预告片', createdAt: '2026-05-05 14:45:00' }, -]; - -const AdminCreditRecords: React.FC = () => { - const [records, setRecords] = useState(MOCK_RECORDS); - const [typeFilter, setTypeFilter] = useState(''); - const [loading, setLoading] = useState(false); - - const filtered = typeFilter ? records.filter(r => r.type === typeFilter) : records; - - const totalRecharge = records.filter(r => r.type === 'recharge').reduce((s, r) => s + r.amount, 0); - const totalConsume = records.filter(r => r.type === 'consume').reduce((s, r) => s + Math.abs(r.amount), 0); - - const columns = [ - { - title: '用户', dataIndex: 'username', width: 120, - render: (v: string) => {v}, - }, - { - title: '类型', dataIndex: 'type', width: 100, - render: (v: string) => ( - : }> - {v === 'recharge' ? '充值' : '消费'} - - ), - filters: [ - { text: '充值', value: 'recharge' }, - { text: '消费', value: 'consume' }, - ], - onFilter: (value: any, record: CreditRecord) => record.type === value, - }, - { - title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: CreditRecord, b: CreditRecord) => a.amount - b.amount, - render: (v: number) => ( - 0 ? '#10b981' : '#ef4444', fontSize: 15 }}> - {v > 0 ? '+' : ''}{v.toLocaleString()} - - ), - }, - { - title: '变动后余额', dataIndex: 'balanceAfter', width: 120, - render: (v: number) => {v.toLocaleString()}, - }, - { - title: '说明', dataIndex: 'description', ellipsis: true, - }, - { - title: '时间', dataIndex: 'createdAt', width: 160, - render: (v: string) => {v}, - }, - ]; - - return ( -
- {/* Summary Cards */} -
- -
-
-
-
总充值
-
+{totalRecharge.toLocaleString()}
-
-
-
- -
-
-
-
总消费
-
-{totalConsume.toLocaleString()}
-
-
-
- -
-
-
-
交易笔数
-
{records.length}
-
-
-
-
- - -
`共 ${t} 条记录` }} - scroll={{ x: 800 }} - /> - - - ); -}; - -export default AdminCreditRecords; diff --git a/video-gen-app/src/pages/admin/AdminDashboard.tsx b/video-gen-app/src/pages/admin/AdminDashboard.tsx deleted file mode 100644 index acf23e54..00000000 --- a/video-gen-app/src/pages/admin/AdminDashboard.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Card, Col, Row, Statistic, Typography, Table, Tag } from 'antd'; -import { - UserOutlined, - ProjectOutlined, - PlayCircleOutlined, - DollarOutlined, - ThunderboltOutlined, - ArrowUpOutlined, -} from '@ant-design/icons'; -import { getAdminStats } from '../../api'; -import type { AdminStats } from '../../types'; - -const AdminDashboard: React.FC = () => { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const load = async () => { - setLoading(true); - const data = await getAdminStats(); - setStats(data); - setLoading(false); - }; - load(); - }, []); - - const statCards = stats ? [ - { title: '总用户数', value: stats.totalUsers, icon: , color: '#6366f1', bg: 'rgba(99,102,241,0.08)' }, - { title: '总项目数', value: stats.totalProjects, icon: , color: '#06b6d4', bg: 'rgba(6,182,212,0.08)' }, - { title: '总生成次数', value: stats.totalGenerations, icon: , color: '#10b981', bg: 'rgba(16,185,129,0.08)' }, - { title: '总收入(元)', value: stats.totalRevenue, icon: , color: '#f59e0b', bg: 'rgba(245,158,11,0.08)', prefix: '¥' }, - { title: '今日消耗积分', value: stats.creditsConsumedToday, icon: , color: '#ef4444', bg: 'rgba(239,68,68,0.08)' }, - ] : []; - - return ( -
- {/* Stats Cards */} - - {statCards.map((s, i) => ( -
- -
-
- {s.icon} -
-
-
{s.title}
-
- {s.prefix}{typeof s.value === 'number' ? s.value.toLocaleString() : s.value} -
-
-
-
- - ))} - - - {/* Quick Info */} - - - -
- {[ - { label: '平台名称', value: 'VideoGen.AI' }, - { label: 'API版本', value: 'v1.0.0' }, - { label: '数据库', value: 'SQLite (本地开发)' }, - { label: 'LLM模式', value: 'Mock (模拟)' }, - { label: '视频引擎', value: 'Seedance 2.0' }, - ].map(item => ( -
- {item.label} - {item.value} -
- ))} -
-
- - - -
- {[ - { name: '体验包', credits: 500, price: 49, color: '#f59e0b' }, - { name: '进阶包', credits: 2000, price: 168, color: '#6366f1', hot: true }, - { name: '专业包', credits: 5000, price: 388, color: '#06b6d4' }, - { name: '企业包', credits: 20000, price: 1280, color: '#10b981' }, - ].map(p => ( -
-
-
- {p.name} - {p.hot && 热门} -
-
- ¥{p.price} - {p.credits.toLocaleString()}积分 -
-
- ))} -
- - - -
- ); -}; - -export default AdminDashboard; diff --git a/video-gen-app/src/pages/admin/AdminIndustries.tsx b/video-gen-app/src/pages/admin/AdminIndustries.tsx deleted file mode 100644 index e03e3200..00000000 --- a/video-gen-app/src/pages/admin/AdminIndustries.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import React, { useState } from 'react'; -import { - Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography, -} from 'antd'; -import { - AppstoreOutlined, PlusOutlined, EditOutlined, DeleteOutlined, MinusCircleOutlined, -} from '@ant-design/icons'; - -interface OptionGroup { - name: string; - options: string[]; -} - -interface IndustryItem { - id: string; - key: string; - label: string; - description: string; - skills: string[]; - optionGroups: OptionGroup[]; - isActive: boolean; - sortOrder: number; -} - -const MOCK_INDUSTRIES: IndustryItem[] = [ - { - id: 'ind-1', key: 'ecommerce', label: '电商', description: '电商直播、产品展示、促销活动', - skills: ['你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言,注重画面节奏和消费者心理'], - optionGroups: [ - { name: '视频风格', options: ['实拍展示', '3D动画', '混剪快闪', '沉浸体验'] }, - { name: '目标受众', options: ['年轻女性', '家庭用户', '商务人士', '学生群体'] }, - ], - isActive: true, sortOrder: 1, - }, - { - id: 'ind-2', key: 'education', label: '教育', description: '在线课程、知识付费、培训', - skills: ['你是一位专业的教育视频策划专家,擅长将复杂知识点转化为生动易懂的视觉叙事'], - optionGroups: [ - { name: '课程类型', options: ['知识讲解', '操作演示', '故事叙事', '互动问答'] }, - ], - isActive: true, sortOrder: 2, - }, - { - id: 'ind-3', key: 'gaming', label: '游戏', description: '游戏预告、赛事宣传、角色展示', - skills: ['你是一位专业的游戏视频创意专家,擅长打造震撼视觉体验和沉浸式叙事'], - optionGroups: [ - { name: '游戏类型', options: ['RPG', 'FPS', 'MOBA', '休闲'] }, - { name: '视频类型', options: ['预告片', '宣传片', '教程', '赛事回顾'] }, - ], - isActive: true, sortOrder: 3, - }, - { - id: 'ind-4', key: 'medical', label: '医疗', description: '医疗健康、药品宣传、科普', - skills: ['你是一位专业的医疗健康视频文案专家,擅长将医学知识转化为通俗易懂的视觉内容'], - optionGroups: [], - isActive: true, sortOrder: 4, - }, - { - id: 'ind-5', key: 'finance', label: '金融', description: '理财产品、保险、银行服务', - skills: ['你是一位专业的金融视频文案专家,擅长将复杂的金融产品转化为易于理解的视觉表达'], - optionGroups: [], - isActive: true, sortOrder: 5, - }, - { - id: 'ind-6', key: 'realestate', label: '房产', description: '楼盘展示、户型介绍、周边配套', - skills: ['你是一位专业的房产视频策划专家,擅长通过镜头语言展现空间美感和生活场景'], - optionGroups: [ - { name: '展示方式', options: ['航拍全景', '室内漫游', '样板间', '周边实景'] }, - ], - isActive: true, sortOrder: 6, - }, - { - id: 'ind-7', key: 'food', label: '餐饮', description: '美食制作、餐厅宣传、食材展示', - skills: ['你是一位专业的美食视频创意专家,擅长用镜头捕捉食物的色香味,营造食欲感'], - optionGroups: [ - { name: '拍摄风格', options: ['特写慢放', '制作过程', '美食探店', '食材溯源'] }, - ], - isActive: true, sortOrder: 7, - }, - { - id: 'ind-8', key: 'travel', label: '旅游', description: '景点宣传、酒店推荐、旅行攻略', - skills: ['你是一位专业的旅游视频文案专家,擅长用镜头语言展现目的地魅力和旅行体验'], - optionGroups: [ - { name: '内容形式', options: ['Vlog', '攻略指南', '风景大片', '人文记录'] }, - ], - isActive: true, sortOrder: 8, - }, - { - id: 'ind-9', key: 'tech', label: '科技', description: '科技产品、SaaS服务、AI应用', - skills: ['你是一位专业的科技视频策划专家,擅长将技术概念转化为直观的视觉演示'], - optionGroups: [ - { name: '演示方式', options: ['产品演示', '对比评测', '概念解析', '场景模拟'] }, - ], - isActive: true, sortOrder: 9, - }, - { - id: 'ind-10', key: 'other', label: '其他', description: '通用行业', - skills: ['你是一位专业的视频导演和文案专家,擅长将主题转化为富有感染力的视觉叙事'], - optionGroups: [], - isActive: true, sortOrder: 10, - }, -]; - -const AdminIndustries: React.FC = () => { - const [industries, setIndustries] = useState(MOCK_INDUSTRIES); - const [saving, setSaving] = useState(false); - const [modal, setModal] = useState<{ open: boolean; item: IndustryItem | null }>({ open: false, item: null }); - const [form] = Form.useForm(); - - const handleSave = async () => { - try { - const values = await form.validateFields(); - setSaving(true); - const skills = values.skills_wentutujie?.trim() ? [values.skills_wentutujie.trim()] : []; - const optionGroups: OptionGroup[] = (values.optionGroups || []) - .filter((g: any) => g?.name?.trim()) - .map((g: any) => ({ - name: g.name.trim(), - options: (g.options || []).filter((o: string) => o?.trim()), - })) - .filter((g: OptionGroup) => g.options.length > 0); - - if (modal.item) { - setIndustries(prev => prev.map(i => i.id === modal.item!.id ? { ...i, ...values, skills, optionGroups } : i)); - message.success('已更新'); - } else { - const newItem: IndustryItem = { - id: `ind-${Date.now()}`, - key: values.key, - label: values.label, - description: values.description || '', - skills, - optionGroups, - isActive: values.isActive !== false, - sortOrder: industries.length + 1, - }; - setIndustries(prev => [...prev, newItem]); - message.success('已添加'); - } - setModal({ open: false, item: null }); - form.resetFields(); - } catch (e: any) { - if (e?.errorFields) return; - message.error(e?.message || '保存失败'); - } finally { - setSaving(false); - } - }; - - const handleDelete = (id: string) => { - setIndustries(prev => prev.filter(i => i.id !== id)); - message.success('已删除'); - }; - - const openEdit = (item?: IndustryItem) => { - setModal({ open: true, item: item || null }); - if (item) { - form.setFieldsValue({ - key: item.key, - label: item.label, - description: item.description, - skills_wentutujie: item.skills[0] || '', - optionGroups: item.optionGroups.length > 0 ? item.optionGroups : [{ name: '', options: [] }], - isActive: item.isActive, - }); - } else { - form.resetFields(); - form.setFieldsValue({ isActive: true, optionGroups: [{ name: '', options: [] }] }); - } - }; - - const columns = [ - { - title: '行业', key: 'industry', width: 160, - render: (_: any, r: IndustryItem) => ( -
- {r.label} -
{r.key}
-
- ), - }, - { - title: '描述', dataIndex: 'description', ellipsis: true, - }, - { - title: '选项配置', key: 'optionGroups', width: 260, - render: (_: any, r: IndustryItem) => { - if (!r.optionGroups || r.optionGroups.length === 0) { - return 未配置; - } - return ( -
- {r.optionGroups.map((g, i) => ( -
- {g.name} - - {g.options.slice(0, 3).join('、')}{g.options.length > 3 ? `...${g.options.length}项` : ''} - -
- ))} -
- ); - }, - }, - { - title: '文图理解提示词', dataIndex: 'skills', width: 200, - render: (skills: string[]) => ( - - {skills[0] || '-'} - - ), - }, - { - title: '状态', dataIndex: 'isActive', width: 80, - render: (v: boolean) => {v ? '启用' : '停用'}, - }, - { - title: '操作', key: 'action', width: 150, fixed: 'right' as const, - render: (_: any, r: IndustryItem) => ( - - - handleDelete(r.id)}> - - - - ), - }, - ]; - - return ( -
- -
- - - 行业与技能配置 - {industries.length} 个行业 - - -
- -
- - - {modal.item ? '编辑行业' : '添加行业'}} - open={modal.open} - onOk={handleSave} - onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }} - okText="保存" cancelText="取消" width={640} - confirmLoading={saving} - > -
-
- - - - - - -
- - - - - - - - {/* Option Groups */} -
- 行业选项配置 - - 添加选项组,每组包含名称和多个选项,前台将显示为下拉选择 - -
- - {(fields, { add, remove }) => ( -
- {fields.map(({ key, name, ...restField }) => ( -
-
- - - - - } /> - - - } /> - - - - - - -
- - 演示账号: admin / admin123 - -
- -
- ); -}; - -export default AdminLoginPage; diff --git a/video-gen-app/src/pages/admin/AdminModels.tsx b/video-gen-app/src/pages/admin/AdminModels.tsx deleted file mode 100644 index cba8d8b9..00000000 --- a/video-gen-app/src/pages/admin/AdminModels.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography, -} from 'antd'; -import { - RobotOutlined, PlusOutlined, EditOutlined, DeleteOutlined, -} from '@ant-design/icons'; -import { getModelConfigs, saveModelConfig, deleteModelConfig } from '../../api'; -import type { ModelConfig } from '../../types'; - -const AdminModels: React.FC = () => { - const [models, setModels] = useState([]); - const [loading, setLoading] = useState(true); - const [modal, setModal] = useState<{ open: boolean; model: ModelConfig | null }>({ open: false, model: null }); - const [form] = Form.useForm(); - - const load = async () => { - setLoading(true); - const data = await getModelConfigs(); - setModels(data); - setLoading(false); - }; - - useEffect(() => { load(); }, []); - - const handleSave = async () => { - try { - const values = await form.validateFields(); - await saveModelConfig({ - ...modal.model, - ...values, - id: modal.model?.id, - }); - message.success(modal.model?.id ? '模型配置已更新' : '模型配置已添加'); - setModal({ open: false, model: null }); - form.resetFields(); - load(); - } catch { /* validation */ } - }; - - const handleDelete = async (id: string) => { - await deleteModelConfig(id); - message.success('模型配置已删除'); - load(); - }; - - const openEdit = (model?: ModelConfig) => { - setModal({ open: true, model: model || null }); - if (model) { - form.setFieldsValue(model); - } else { - form.resetFields(); - form.setFieldsValue({ - provider: 'sdk', - weight: 1, - maxTokens: 4096, - temperature: 0.7, - isActive: true, - priority: 0, - }); - } - }; - - const columns = [ - { - title: '模型名称', dataIndex: 'name', width: 150, - render: (v: string, r: ModelConfig) => ( - -
- -
-
-
{v}
-
{r.modelName}
-
-
- ), - }, - { - title: '提供商', dataIndex: 'provider', width: 140, - render: (v: string) => { - const labelMap: Record = { - sdk: 'SDK模式', - openai_compatible: 'OpenAI兼容', - mock: 'Mock模式', - }; - return {labelMap[v] || v}; - }, - }, - { - title: 'API地址', dataIndex: 'apiBase', width: 200, - render: (v: string) => ( - - {v || '-'} - - ), - }, - { - title: '权重', dataIndex: 'weight', width: 80, sorter: (a: ModelConfig, b: ModelConfig) => a.weight - b.weight, - }, - { - title: 'Max Tokens', dataIndex: 'maxTokens', width: 100, - }, - { - title: 'Temperature', dataIndex: 'temperature', width: 100, - render: (v: number) => v.toFixed(1), - }, - { - title: '状态', dataIndex: 'isActive', width: 80, - render: (v: boolean) => ( - {v ? '启用' : '停用'} - ), - }, - { - title: '操作', key: 'action', width: 150, fixed: 'right' as const, - render: (_: any, r: ModelConfig) => ( - - - handleDelete(r.id)}> - - - - ), - }, - ]; - - return ( -
- -
- - 共 {models.length} 个模型配置,按权重进行加权随机调度 - - -
- -
- - - {/* Edit Modal */} - {modal.model?.id ? '编辑模型' : '添加模型'}} - open={modal.open} - onOk={handleSave} - onCancel={() => { setModal({ open: false, model: null }); form.resetFields(); }} - okText="确认" cancelText="取消" width={560} - > -
- - - -
- - - -
- - - - - - -
- - - - - - - - - -
-
- - - - - - -
- -
- - ); -}; - -export default AdminModels; diff --git a/video-gen-app/src/pages/admin/AdminNotificationManager.tsx b/video-gen-app/src/pages/admin/AdminNotificationManager.tsx deleted file mode 100644 index e6b64021..00000000 --- a/video-gen-app/src/pages/admin/AdminNotificationManager.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, -} from 'antd'; -import { - BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, -} from '@ant-design/icons'; - -interface NotificationRecord { - id: string; - title: string; - content: string; - type: string; - target: string; - createdAt: string; -} - -const MOCK_NOTIFICATIONS: NotificationRecord[] = [ - { id: 'n-1', title: '系统上线通知', content: 'VideoGen.AI 平台正式上线!', type: 'system', target: '全部用户', createdAt: '2026-05-01 09:00:00' }, - { id: 'n-2', title: '积分充值优惠', content: '限时活动:充值进阶包额外赠送200积分', type: 'credit', target: '全部用户', createdAt: '2026-05-03 10:00:00' }, - { id: 'n-3', title: '账户审核通过', content: '您的账户已通过实名审核', type: 'system', target: 'videomaker', createdAt: '2026-05-05 14:00:00' }, -]; - -const MOCK_USERS = [ - { id: 'u-001', username: 'videomaker' }, - { id: 'u-002', username: 'designer' }, - { id: 'u-003', username: 'marketer' }, - { id: 'u-004', username: 'editor' }, -]; - -const AdminNotificationManager: React.FC = () => { - const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS); - const [modalOpen, setModalOpen] = useState(false); - const [form] = Form.useForm(); - - const handleSend = async () => { - try { - const values = await form.validateFields(); - const newRecord: NotificationRecord = { - id: `n-${Date.now()}`, - title: values.title, - content: values.content, - type: values.type, - target: values.target_user_id - ? MOCK_USERS.find(u => u.id === values.target_user_id)?.username || '指定用户' - : '全部用户', - createdAt: new Date().toLocaleString('zh-CN'), - }; - setNotifications(prev => [newRecord, ...prev]); - message.success('消息已发送'); - setModalOpen(false); - form.resetFields(); - } catch { /* validation */ } - }; - - const handleDelete = (id: string) => { - setNotifications(prev => prev.filter(n => n.id !== id)); - message.success('已删除'); - }; - - const getTypeColor = (type: string) => { - switch (type) { - case 'system': return 'blue'; - case 'credit': return 'orange'; - case 'promo': return 'purple'; - default: return 'default'; - } - }; - - const columns = [ - { - title: '标题', dataIndex: 'title', width: 200, - render: (v: string) => {v}, - }, - { - title: '内容', dataIndex: 'content', ellipsis: true, - }, - { - title: '类型', dataIndex: 'type', width: 80, - render: (v: string) => { - const labels: Record = { system: '系统', credit: '积分', promo: '活动' }; - return {labels[v] || v}; - }, - }, - { - title: '发送目标', dataIndex: 'target', width: 120, - render: (v: string) => ( - {v} - ), - }, - { - title: '发送时间', dataIndex: 'createdAt', width: 160, - }, - { - title: '操作', key: 'action', width: 80, - render: (_: any, r: NotificationRecord) => ( - handleDelete(r.id)}> - - - ), - }, - ]; - - return ( -
- -
- - - 消息推送管理 - {notifications.length} 条消息 - - -
- -
`共 ${t} 条消息` }} - scroll={{ x: 800 }} - /> - - - {/* Send Notification Modal */} - 发送消息} - open={modalOpen} - onOk={handleSend} - onCancel={() => { setModalOpen(false); form.resetFields(); }} - okText="发送" cancelText="取消" width={520} - > -
- - - - - - -
- - ({ value: u.id, label: u.username }))} /> - -
- -
- - ); -}; - -export default AdminNotificationManager; diff --git a/video-gen-app/src/pages/admin/AdminNotifications.tsx b/video-gen-app/src/pages/admin/AdminNotifications.tsx deleted file mode 100644 index 1ebd4980..00000000 --- a/video-gen-app/src/pages/admin/AdminNotifications.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - Button, Card, Empty, Space, Tag, Typography, -} from 'antd'; -import { - BellOutlined, CheckOutlined, InfoCircleOutlined, CreditCardOutlined, ExclamationCircleOutlined, -} from '@ant-design/icons'; -import { getNotifications } from '../../api'; -import type { AdminNotification } from '../../types'; - -const AdminNotifications: React.FC = () => { - const [notifications, setNotifications] = useState([]); - const [loading, setLoading] = useState(true); - - const load = async () => { - setLoading(true); - const data = await getNotifications(); - setNotifications(data); - setLoading(false); - }; - - useEffect(() => { load(); }, []); - - const getTypeIcon = (type: string) => { - switch (type) { - case 'system': return ; - case 'credit': return ; - default: return ; - } - }; - - const getTypeLabel = (type: string) => { - switch (type) { - case 'system': return 系统; - case 'credit': return 积分; - default: return 其他; - } - }; - - return ( -
- -
- - - 消息通知 - {notifications.filter(n => !n.isRead).length} 条未读 - -
- - {notifications.length === 0 ? ( - - ) : ( -
- {notifications.map(n => ( - -
-
- {getTypeIcon(n.type)} -
-
-
- - {n.title} - - {getTypeLabel(n.type)} - {!n.isRead && ( - 未读 - )} -
- - {n.content} - - - {n.createdAt} - -
- {!n.isRead && ( - - )} -
-
- ))} -
- )} -
-
- ); -}; - -export default AdminNotifications; diff --git a/video-gen-app/src/pages/admin/AdminPaymentConfig.tsx b/video-gen-app/src/pages/admin/AdminPaymentConfig.tsx deleted file mode 100644 index 1de69e39..00000000 --- a/video-gen-app/src/pages/admin/AdminPaymentConfig.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import React, { useState } from 'react'; -import { - Button, Card, Form, Input, message, Switch, Typography, Divider, -} from 'antd'; -import { - SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined, -} from '@ant-design/icons'; - -interface PaymentSetting { - key: string; - value: string; - label: string; - description: string; - secret?: boolean; -} - -const AdminPaymentConfig: React.FC = () => { - const [saving, setSaving] = useState(false); - const [wechatEnabled, setWechatEnabled] = useState(false); - const [alipayEnabled, setAlipayEnabled] = useState(false); - const [form] = Form.useForm(); - - const handleSave = async () => { - try { - const values = await form.validateFields(); - setSaving(true); - await new Promise(r => setTimeout(r, 500)); - message.success('支付配置已保存'); - setSaving(false); - } catch { setSaving(false); } - }; - - return ( -
- {/* WeChat Pay */} - -
-
-
-
- 微信支付 - 微信商户号支付配置 -
-
- -
- -
- - - - - - - - - - - - - -
- - {/* Alipay */} - -
-
-
-
- 支付宝 - 支付宝应用支付配置 -
-
- -
- -
- - - - - - - - - - - - - - - - -
- - {/* Recharge Packages */} - 充值套餐}> - {[ - { name: '体验包', credits: 500, price: 49, color: '#f59e0b' }, - { name: '进阶包', credits: 2000, price: 168, color: '#6366f1' }, - { name: '专业包', credits: 5000, price: 388, color: '#06b6d4' }, - { name: '企业包', credits: 20000, price: 1280, color: '#10b981' }, - ].map(p => ( -
-
-
- {p.name} -
-
- ¥{p.price} - {p.credits.toLocaleString()} 积分 - ({(p.price / p.credits * 100).toFixed(1)}元/百积分) -
-
- ))} - - -
- -
-
- ); -}; - -export default AdminPaymentConfig; diff --git a/video-gen-app/src/pages/admin/AdminSettings.tsx b/video-gen-app/src/pages/admin/AdminSettings.tsx deleted file mode 100644 index 7ee42630..00000000 --- a/video-gen-app/src/pages/admin/AdminSettings.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - Button, Card, Form, Input, message, Space, Typography, -} from 'antd'; -import { - SettingOutlined, SaveOutlined, -} from '@ant-design/icons'; -import { getSystemConfigs, updateSystemConfig } from '../../api'; -import type { SystemConfig } from '../../types'; - -const AdminSettings: React.FC = () => { - const [configs, setConfigs] = useState([]); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [form] = Form.useForm(); - - useEffect(() => { - const load = async () => { - setLoading(true); - const data = await getSystemConfigs(); - setConfigs(data); - const formValues: Record = {}; - data.forEach(c => { formValues[c.key] = c.value; }); - form.setFieldsValue(formValues); - setLoading(false); - }; - load(); - }, []); - - const handleSave = async () => { - try { - const values = await form.validateFields(); - setSaving(true); - for (const config of configs) { - const newVal = values[config.key]; - if (newVal !== config.value) { - await updateSystemConfig(config.id, newVal); - } - } - message.success('系统配置已保存'); - const data = await getSystemConfigs(); - setConfigs(data); - setSaving(false); - } catch { - setSaving(false); - } - }; - - const groupedConfigs: Record = { - '站点信息': configs.filter(c => c.key.startsWith('site_')), - 'SEO 设置': configs.filter(c => c.key.startsWith('seo_')), - }; - - const getFieldDescription = (config: SystemConfig): string => { - const descMap: Record = { - site_name: '平台显示名称,将展示在页面标题和导航栏', - site_logo: '平台Logo图片URL,建议尺寸 200x40px', - seo_title: '搜索引擎结果中显示的标题', - seo_description: '搜索引擎结果中显示的描述文字,建议150字以内', - seo_keywords: '用逗号分隔的关键词列表', - }; - return descMap[config.key] || config.description || ''; - }; - - const getFieldComponent = (config: SystemConfig) => { - if (config.key === 'seo_description') { - return ; - } - if (config.key === 'seo_keywords') { - return ; - } - return ; - }; - - if (loading) { - return ; - } - - return ( -
- -
-
- -
-
- 系统设置 - 管理站点基础信息和SEO配置 -
-
- -
- {Object.entries(groupedConfigs).map(([group, items]) => ( -
- - {group} - - {items.map(config => ( - {config.description}} - extra={getFieldDescription(config)} - > - {getFieldComponent(config)} - - ))} -
- ))} - -
- -
- -
-
- ); -}; - -export default AdminSettings; diff --git a/video-gen-app/src/pages/admin/AdminUsers.tsx b/video-gen-app/src/pages/admin/AdminUsers.tsx deleted file mode 100644 index 04ea6986..00000000 --- a/video-gen-app/src/pages/admin/AdminUsers.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Space, Switch, Table, Tag, Typography, -} from 'antd'; -import { - UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, -} from '@ant-design/icons'; -import { getAdminUsers, adjustCredits, toggleUserStatus } from '../../api'; -import type { AdminUser } from '../../types'; - -const AdminUsers: React.FC = () => { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [search, setSearch] = useState(''); - const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null }); - const [form] = Form.useForm(); - - const load = async () => { - setLoading(true); - const data = await getAdminUsers(search || undefined); - setUsers(data); - setLoading(false); - }; - - useEffect(() => { load(); }, []); - - const handleSearch = () => load(); - - const handleAdjustCredits = async () => { - try { - const values = await form.validateFields(); - const { user } = creditModal; - if (!user) return; - await adjustCredits(user.id, values.amount, values.reason); - message.success(`已${values.amount > 0 ? '增加' : '扣除'} ${Math.abs(values.amount)} 积分`); - setCreditModal({ open: false, user: null }); - form.resetFields(); - load(); - } catch { /* validation */ } - }; - - const handleToggleStatus = async (user: AdminUser) => { - await toggleUserStatus(user.id, !user.isActive); - message.success(user.isActive ? '已禁用该用户' : '已启用该用户'); - load(); - }; - - const columns = [ - { - title: '用户', key: 'user', width: 200, - render: (_: any, r: AdminUser) => ( - -
- {r.username.charAt(0).toUpperCase()} -
-
-
- {r.username} - {r.isAdmin && 管理员} -
-
{r.email}
-
-
- ), - }, - { - title: '积分余额', dataIndex: 'credits', width: 120, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits, - render: (v: number) => ( - 0 ? '#10b981' : '#ef4444', fontSize: 15 }}> - {v.toLocaleString()} - - ), - }, - { - title: '手机号', dataIndex: 'phone', width: 130, - render: (v: string) => {v || '-'}, - }, - { - title: '状态', dataIndex: 'isActive', width: 80, - render: (v: boolean) => ( - {v ? '正常' : '禁用'} - ), - }, - { - title: '注册时间', dataIndex: 'createdAt', width: 120, - render: (v: string) => {v}, - }, - { - title: '最后登录', dataIndex: 'lastLoginAt', width: 140, - render: (v: string) => {v || '-'}, - }, - { - title: '操作', key: 'action', width: 200, fixed: 'right' as const, - render: (_: any, r: AdminUser) => ( - - - {!r.isAdmin && ( - handleToggleStatus(r)} - > - - - )} - - ), - }, - ]; - - return ( -
- - {/* Search bar */} -
- } - value={search} - onChange={e => setSearch(e.target.value)} - onPressEnter={handleSearch} - style={{ width: 280, borderRadius: 8 }} - allowClear - /> - -
- -
`共 ${t} 个用户` }} - scroll={{ x: 900 }} - /> - - - {/* Adjust Credits Modal */} - 调整积分 - {creditModal.user?.username}} - open={creditModal.open} - onOk={handleAdjustCredits} - onCancel={() => { setCreditModal({ open: false, user: null }); form.resetFields(); }} - okText="确认" cancelText="取消" width={440} - > -
- 当前积分: - - {creditModal.user?.credits.toLocaleString()} - -
-
- - `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} - /> - - - - - -
- - ); -}; - -export default AdminUsers; diff --git a/video-gen-app/src/pages/admin/AdminVideoEngines.tsx b/video-gen-app/src/pages/admin/AdminVideoEngines.tsx deleted file mode 100644 index 83e1dff2..00000000 --- a/video-gen-app/src/pages/admin/AdminVideoEngines.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import React, { useState } from 'react'; -import { - Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography, -} from 'antd'; -import { - PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined, -} from '@ant-design/icons'; - -interface VideoEngine { - id: string; - name: string; - provider: string; - apiBase: string; - apiKey: string; - modelName: string; - supportedRatios: string[]; - supportedResolutions: string[]; - maxDuration: number; - isActive: boolean; - priority: number; -} - -const MOCK_ENGINES: VideoEngine[] = [ - { - id: 've-1', name: 'Seedance 2.0', provider: 'seedance', - apiBase: 'https://ark.cn-beijing.volces.com/api/v3', - apiKey: 'sk-****', modelName: 'seedance-2.0', - supportedRatios: ['16:9', '9:16', '1:1', '4:3'], - supportedResolutions: ['720p', '1080p', '4K'], - maxDuration: 60, isActive: true, priority: 1, - }, -]; - -const AdminVideoEngines: React.FC = () => { - const [engines, setEngines] = useState(MOCK_ENGINES); - const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null }); - const [form] = Form.useForm(); - - const handleSave = async () => { - try { - const values = await form.validateFields(); - if (modal.engine) { - setEngines(prev => prev.map(e => e.id === modal.engine!.id ? { ...e, ...values } : e)); - message.success('已更新'); - } else { - const newEngine: VideoEngine = { - id: `ve-${Date.now()}`, - ...values, - }; - setEngines(prev => [...prev, newEngine]); - message.success('已添加'); - } - setModal({ open: false, engine: null }); - form.resetFields(); - } catch { /* validation */ } - }; - - const handleDelete = (id: string) => { - setEngines(prev => prev.filter(e => e.id !== id)); - message.success('已删除'); - }; - - const openEdit = (engine?: VideoEngine) => { - setModal({ open: true, engine: engine || null }); - if (engine) { - form.setFieldsValue(engine); - } else { - form.resetFields(); - form.setFieldsValue({ - isActive: true, priority: 0, maxDuration: 60, - supportedRatios: ['16:9', '9:16', '1:1'], - supportedResolutions: ['720p', '1080p'], - }); - } - }; - - const columns = [ - { - title: '引擎名称', key: 'name', width: 180, - render: (_: any, r: VideoEngine) => ( -
-
-
- {r.name} -
{r.provider}
-
-
- ), - }, - { - title: 'API地址', dataIndex: 'apiBase', width: 250, - render: (v: string) => {v}, - }, - { - title: '支持比例', dataIndex: 'supportedRatios', width: 180, - render: (ratios: string[]) => ratios.map(r => {r}), - }, - { - title: '支持分辨率', dataIndex: 'supportedResolutions', width: 150, - render: (res: string[]) => res.map(r => {r}), - }, - { - title: '最大时长', dataIndex: 'maxDuration', width: 80, - render: (v: number) => `${v}s`, - }, - { - title: '状态', dataIndex: 'isActive', width: 80, - render: (v: boolean) => {v ? '启用' : '停用'}, - }, - { - title: '操作', key: 'action', width: 150, fixed: 'right' as const, - render: (_: any, r: VideoEngine) => ( - - - handleDelete(r.id)}> - - - - ), - }, - ]; - - return ( -
- -
- - - 视频引擎配置 - {engines.length} 个引擎 - - -
- -
- - - {modal.engine ? '编辑引擎' : '添加引擎'}} - open={modal.open} - onOk={handleSave} - onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }} - okText="保存" cancelText="取消" width={560} - > -
-
- - - - - - - - - - - - -
- - - -
-
- - - - - - - - - -
- - -
- ); -}; - -export default AdminVideoEngines;