修改整体支付规则,增加后台超时配置,增加服务重启查询订单,增加关闭和超时关闭订单,删除前台应用管理页面

This commit is contained in:
2026-06-11 11:59:14 +08:00
parent 2876c03665
commit cace26018a
19 changed files with 341 additions and 2294 deletions
@@ -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 (
<div style={{ maxWidth: 720 }}>
{/* 通用设置 */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, color: '#6366f1',
}}><ClockCircleOutlined /></div>
<div>
<Typography.Title level={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
</div>
</div>
</div>
<Form form={form} layout="vertical">
<Form.Item
name="order_timeout"
label={<span style={{ fontWeight: 500 }}></span>}
extra="订单创建后超过此时间未支付将自动取消(秒)"
>
<InputNumber
min={30}
max={86400}
placeholder="180"
style={{ width: '100%' }}
size="large"
/>
</Form.Item>
</Form>
</Card>
{/* Mock Mode Toggle */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16, background: mockMode ? '#fff7e6' : '#fafbff' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
+10 -78
View File
@@ -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
],
}
+12
View File
@@ -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(
+24 -2
View File
@@ -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
+203 -7
View File
@@ -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)
@@ -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<string>('alipay');
const [paying, setPaying] = useState(false);
const [countdown, setCountdown] = useState(180); // 默认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 });
@@ -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}
</div>
{/* 倒计时显示 */}
<div style={{
marginTop: 12,
padding: '8px 16px',
background: countdown <= 30 ? '#fef2f2' : '#f0f9ff',
borderRadius: 8,
border: countdown <= 30 ? '1px solid #fecaca' : '1px solid #bae6fd',
display: 'inline-block',
}}>
<span style={{
fontSize: 14,
fontWeight: 600,
color: countdown <= 30 ? '#dc2626' : '#0284c7',
}}>
<span style={{ fontSize: 16, fontWeight: 800 }}>{countdown}</span>
</span>
</div>
</div>
</div>
@@ -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<CreditRatio[]>(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) => <Tag color="purple">{v}</Tag>,
},
{
title: '分辨率', dataIndex: 'resolution', width: 100,
render: (v: string) => {
const colors: Record<string, string> = { '720p': 'default', '1080p': 'blue', '4K': 'gold' };
return <Tag color={colors[v] || 'default'}>{v}</Tag>;
},
},
{
title: '倍率', dataIndex: 'ratio', width: 100, sorter: (a: CreditRatio, b: CreditRatio) => a.ratio - b.ratio,
render: (v: number) => (
<Typography.Text strong style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>
x{v}
</Typography.Text>
),
},
{
title: '基础积分', dataIndex: 'baseCredits', width: 100,
render: (v: number) => <Typography.Text>{v} </Typography.Text>,
},
{
title: '每秒积分', dataIndex: 'perSecondCredits', width: 100,
render: (v: number) => <Typography.Text>{v} /</Typography.Text>,
},
{
title: '示例计算 (15秒)', key: 'example', width: 120,
render: (_: any, r: CreditRatio) => {
const total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} </Typography.Text>;
},
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: CreditRatio) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<CalculatorOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{ratios.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
( + x ) x
</Typography.Text>
<Table
columns={columns}
dataSource={ratios}
rowKey="id"
pagination={false}
scroll={{ x: 800 }}
/>
</Card>
<Modal
title={<Space><CalculatorOutlined />{modal.ratio ? '编辑比例' : '添加比例'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, ratio: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={480}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="modelName" label="模型" rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'GPT-4o', label: 'GPT-4o' },
{ value: 'DeepSeek-V3', label: 'DeepSeek-V3' },
{ value: '通用', label: '通用 (默认)' },
]} />
</Form.Item>
<Form.Item name="resolution" label="分辨率" rules={[{ required: true }]}>
<Select size="large" options={[
{ value: '720p', label: '720p' },
{ value: '1080p', label: '1080p' },
{ value: '4K', label: '4K' },
]} />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="ratio" label="倍率" style={{ flex: 1 }} rules={[{ required: true }]}>
<InputNumber min={0.1} max={10} step={0.1} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="baseCredits" label="基础积分" style={{ flex: 1 }} rules={[{ required: true }]}>
<InputNumber min={0} max={1000} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="perSecondCredits" label="每秒积分" style={{ flex: 1 }} rules={[{ required: true }]}>
<InputNumber min={0} max={100} style={{ width: '100%' }} size="large" />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminCreditRatios;
@@ -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<CreditRecord[]>(MOCK_RECORDS);
const [typeFilter, setTypeFilter] = useState<string>('');
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) => <Typography.Text strong>{v}</Typography.Text>,
},
{
title: '类型', dataIndex: 'type', width: 100,
render: (v: string) => (
<Tag color={v === 'recharge' ? 'green' : 'red'} icon={v === 'recharge' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}>
{v === 'recharge' ? '充值' : '消费'}
</Tag>
),
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) => (
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
{v > 0 ? '+' : ''}{v.toLocaleString()}
</Typography.Text>
),
},
{
title: '变动后余额', dataIndex: 'balanceAfter', width: 120,
render: (v: number) => <Typography.Text type="secondary">{v.toLocaleString()}</Typography.Text>,
},
{
title: '说明', dataIndex: 'description', ellipsis: true,
},
{
title: '时间', dataIndex: 'createdAt', width: 160,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
},
];
return (
<div>
{/* Summary Cards */}
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(16,185,129,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#10b981',
}}><ArrowUpOutlined /></div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{totalRecharge.toLocaleString()}</div>
</div>
</div>
</Card>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(239,68,68,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#ef4444',
}}><ArrowDownOutlined /></div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{totalConsume.toLocaleString()}</div>
</div>
</div>
</Card>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}><WalletOutlined /></div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>{records.length}</div>
</div>
</div>
</Card>
</div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Table
columns={columns}
dataSource={filtered}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, showTotal: (t) => `${t} 条记录` }}
scroll={{ x: 800 }}
/>
</Card>
</div>
);
};
export default AdminCreditRecords;
@@ -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<AdminStats | null>(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: <UserOutlined />, color: '#6366f1', bg: 'rgba(99,102,241,0.08)' },
{ title: '总项目数', value: stats.totalProjects, icon: <ProjectOutlined />, color: '#06b6d4', bg: 'rgba(6,182,212,0.08)' },
{ title: '总生成次数', value: stats.totalGenerations, icon: <PlayCircleOutlined />, color: '#10b981', bg: 'rgba(16,185,129,0.08)' },
{ title: '总收入(元)', value: stats.totalRevenue, icon: <DollarOutlined />, color: '#f59e0b', bg: 'rgba(245,158,11,0.08)', prefix: '¥' },
{ title: '今日消耗积分', value: stats.creditsConsumedToday, icon: <ThunderboltOutlined />, color: '#ef4444', bg: 'rgba(239,68,68,0.08)' },
] : [];
return (
<div>
{/* Stats Cards */}
<Row gutter={[16, 16]}>
{statCards.map((s, i) => (
<Col xs={12} sm={8} lg={i < 4 ? 6 : 24} key={s.title}>
<Card bordered={false} loading={loading}
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: s.bg, display: 'flex',
alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: s.color, flexShrink: 0,
}}>
{s.icon}
</div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12, marginBottom: 2 }}>{s.title}</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>
{s.prefix}{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}
</div>
</div>
</div>
</Card>
</Col>
))}
</Row>
{/* Quick Info */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={12}>
<Card title="系统信息" bordered={false}
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[
{ label: '平台名称', value: 'VideoGen.AI' },
{ label: 'API版本', value: 'v1.0.0' },
{ label: '数据库', value: 'SQLite (本地开发)' },
{ label: 'LLM模式', value: 'Mock (模拟)' },
{ label: '视频引擎', value: 'Seedance 2.0' },
].map(item => (
<div key={item.label} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #f5f6fa' }}>
<Typography.Text type="secondary">{item.label}</Typography.Text>
<Typography.Text strong>{item.value}</Typography.Text>
</div>
))}
</div>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="充值套餐" bordered={false}
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[
{ 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 => (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #f5f6fa' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: p.color }} />
<Typography.Text strong>{p.name}</Typography.Text>
{p.hot && <Tag color="purple" style={{ fontSize: 10, lineHeight: '16px' }}></Tag>}
</div>
<div>
<Typography.Text strong style={{ color: p.color }}>¥{p.price}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{p.credits.toLocaleString()}</Typography.Text>
</div>
</div>
))}
</div>
</Card>
</Col>
</Row>
</div>
);
};
export default AdminDashboard;
@@ -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<IndustryItem[]>(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) => (
<div>
<Typography.Text strong>{r.label}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.key}</div>
</div>
),
},
{
title: '描述', dataIndex: 'description', ellipsis: true,
},
{
title: '选项配置', key: 'optionGroups', width: 260,
render: (_: any, r: IndustryItem) => {
if (!r.optionGroups || r.optionGroups.length === 0) {
return <Typography.Text style={{ fontSize: 12, color: '#cbd5e1' }}></Typography.Text>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{r.optionGroups.map((g, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>{g.name}</Tag>
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
{g.options.slice(0, 3).join('、')}{g.options.length > 3 ? `...${g.options.length}` : ''}
</Typography.Text>
</div>
))}
</div>
);
},
},
{
title: '文图理解提示词', dataIndex: 'skills', width: 200,
render: (skills: string[]) => (
<Typography.Text ellipsis style={{ fontSize: 12 }}>
{skills[0] || '-'}
</Typography.Text>
),
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: IndustryItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<AppstoreOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{industries.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={industries}
rowKey="id"
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><AppstoreOutlined />{modal.item ? '编辑行业' : '添加行业'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={640}
confirmLoading={saving}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="key" label="行业标识" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入标识' }]}>
<Input placeholder="例如:ecommerce" size="large" />
</Form.Item>
<Form.Item name="label" label="行业名称" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如:电商" size="large" />
</Form.Item>
</div>
<Form.Item name="description" label="行业描述">
<Input.TextArea rows={2} placeholder="行业描述" size="large" />
</Form.Item>
<Form.Item name="skills_wentutujie" label="文图理解提示词" extra="用于LLM优化提示词的系统指令,根据行业特点引导AI理解文案与画面的关系">
<Input.TextArea rows={3} placeholder="请输入文图理解提示词,例如:&#10;你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" size="large" />
</Form.Item>
{/* Option Groups */}
<div style={{ marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 13 }}></Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
</Typography.Text>
</div>
<Form.List name="optionGroups">
{(fields, { add, remove }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{
display: 'flex', gap: 8, alignItems: 'flex-start',
padding: '10px 12px', borderRadius: 10,
background: '#f8f9fc', border: '1px solid #f0f0f5',
}}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
<Form.Item {...restField} name={[name, 'name']} label="选项名称" style={{ marginBottom: 0 }}
rules={[{ required: true, message: '请输入选项名称' }]}>
<Input placeholder="例如:视频风格" size="middle" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item {...restField} name={[name, 'options']} label="选项内容" style={{ marginBottom: 0 }}>
<Select
mode="tags"
size="middle"
placeholder="输入选项后回车添加"
style={{ borderRadius: 8 }}
tokenSeparators={[',', '', '、']}
/>
</Form.Item>
</div>
<MinusCircleOutlined
onClick={() => remove(name)}
style={{ color: '#ef4444', fontSize: 16, marginTop: 34, cursor: 'pointer', flexShrink: 0 }}
/>
</div>
))}
<Button
type="dashed" onClick={() => add()} block
icon={<PlusOutlined />}
style={{ borderRadius: 8, height: 36 }}
>
</Button>
</div>
)}
</Form.List>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AdminIndustries;
@@ -1,163 +0,0 @@
import React, { useState } from 'react';
import { Layout, Menu, Avatar, Typography, Space, Dropdown, Spin } from 'antd';
import {
DashboardOutlined,
UserOutlined,
RobotOutlined,
SettingOutlined,
BellOutlined,
ThunderboltOutlined,
LogoutOutlined,
LeftOutlined,
RightOutlined,
WalletOutlined,
CalculatorOutlined,
DollarOutlined,
AppstoreOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
const { Sider, Content } = Layout;
const AdminLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, loading, logout } = useAuthStore();
const [collapsed, setCollapsed] = useState(false);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!user) {
return <Navigate to="/admin/login" replace />;
}
const menuItems = [
{ key: '/admin', icon: <DashboardOutlined />, label: '数据概览' },
{ key: '/admin/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/admin/credit-records', icon: <WalletOutlined />, label: '交易流水' },
{ key: '/admin/models', icon: <RobotOutlined />, label: '模型配置' },
{ key: '/admin/credit-ratios', icon: <CalculatorOutlined />, label: '积分比例' },
{ key: '/admin/video-engines', icon: <PlayCircleOutlined />, label: '视频引擎' },
{ key: '/admin/industries', icon: <AppstoreOutlined />, label: '行业配置' },
{ key: '/admin/payment', icon: <DollarOutlined />, label: '支付配置' },
{ key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/admin/notifications', icon: <BellOutlined />, label: '消息推送' },
];
const selectedKey = location.pathname;
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={220}
theme="dark"
style={{
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)',
}}
>
{/* Logo */}
<div style={{
height: 64, display: 'flex', alignItems: 'center',
justifyContent: 'center', gap: 10,
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
{!collapsed && (
<span style={{ color: '#f1f5f9', fontSize: 15, fontWeight: 700 }}>
</span>
)}
</div>
{/* Menu */}
<Menu
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => navigate(key)}
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
theme="dark"
/>
{/* User block */}
<div style={{
position: 'absolute', bottom: 48, left: 0, right: 0,
padding: collapsed ? '12px 8px' : '12px 16px',
borderTop: '1px solid rgba(255,255,255,0.06)',
}}>
<Dropdown menu={{
items: [
{ key: 'front', icon: <ThunderboltOutlined />, label: '返回前台' },
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
],
onClick: ({ key }) => {
if (key === 'front') navigate('/projects');
else if (key === 'logout') { logout(); navigate('/admin/login'); }
},
}} placement="topRight" arrow>
<div style={{
display: 'flex', alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
gap: 10, padding: '8px 10px', borderRadius: 10,
cursor: 'pointer', background: 'rgba(255,255,255,0.04)',
transition: 'background 0.2s',
}}>
<Avatar size={28} icon={<UserOutlined />}
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
{!collapsed && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#e2e8f0', fontSize: 12, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{user?.username}
</div>
<div style={{ color: 'rgba(148,163,184,0.5)', fontSize: 10 }}></div>
</div>
)}
</div>
</Dropdown>
</div>
</Sider>
<Layout>
{/* Header */}
<div style={{
height: 56, background: '#fff', borderBottom: '1px solid #f0f0f5',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px',
}}>
<Typography.Text strong style={{ fontSize: 16 }}>
{menuItems.find(m => m.key === selectedKey)?.label || '管理后台'}
</Typography.Text>
<Space>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{user?.username}
</Typography.Text>
</Space>
</div>
{/* Content */}
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default AdminLayout;
@@ -1,78 +0,0 @@
import { useState } from 'react';
import { Button, Card, Form, Input, message, Typography } from 'antd';
import { UserOutlined, LockOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
const AdminLoginPage = () => {
const navigate = useNavigate();
const { login } = useAuthStore();
const [loading, setLoading] = useState(false);
const handleLogin = async (values: { username: string; password: string }) => {
setLoading(true);
try {
await login(values.username, values.password);
message.success('登录成功');
navigate('/admin');
} catch {
message.error('登录失败');
} finally {
setLoading(false);
}
};
return (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a35 50%, #0f0f23 100%)',
}}>
<Card bordered={false} style={{
width: 420, borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}}>
{/* Logo */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{
width: 56, height: 56, borderRadius: 14, margin: '0 auto 16px',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 8px 24px rgba(99,102,241,0.3)',
}}>
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} />
</div>
<Typography.Title level={3} style={{ margin: 0 }}>
VideoGen<span style={{ color: '#6366f1' }}>.AI</span>
</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Form onFinish={handleLogin} layout="vertical" initialValues={{ username: 'admin', password: 'admin123' }}>
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="用户名" size="large" prefix={<UserOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="密码" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
<Form.Item style={{ marginBottom: 8 }}>
<Button type="primary" htmlType="submit" loading={loading} block size="large"
style={{
borderRadius: 10, fontWeight: 600, height: 44,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
border: 'none',
}}>
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
演示账号: admin / admin123
</Typography.Text>
</div>
</Card>
</div>
);
};
export default AdminLoginPage;
@@ -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<ModelConfig[]>([]);
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) => (
<Space>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: r.isActive
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 14,
}}>
<RobotOutlined />
</div>
<div>
<div style={{ fontWeight: 600 }}>{v}</div>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.modelName}</div>
</div>
</Space>
),
},
{
title: '提供商', dataIndex: 'provider', width: 140,
render: (v: string) => {
const labelMap: Record<string, string> = {
sdk: 'SDK模式',
openai_compatible: 'OpenAI兼容',
mock: 'Mock模式',
};
return <Tag color={v === 'mock' ? 'default' : 'blue'}>{labelMap[v] || v}</Tag>;
},
},
{
title: 'API地址', dataIndex: 'apiBase', width: 200,
render: (v: string) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{v || '-'}
</Typography.Text>
),
},
{
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) => (
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>
),
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: ModelConfig) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />}
onClick={() => openEdit(r)}>
</Button>
<Popconfirm title="确定删除该模型配置?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Text type="secondary">
{models.length}
</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}
style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={models}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
{/* Edit Modal */}
<Modal
title={<Space><RobotOutlined />{modal.model?.id ? '编辑模型' : '添加模型'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, model: null }); form.resetFields(); }}
okText="确认" cancelText="取消" width={560}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="显示名称"
rules={[{ required: true, message: '请输入模型名称' }]}>
<Input placeholder="例如:GPT-4o" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'sdk', label: 'SDK模式' },
{ value: 'openai_compatible', label: 'OpenAI兼容' },
{ value: 'mock', label: 'Mock模式' },
]} />
</Form.Item>
<Form.Item name="modelName" label="模型标识" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入模型标识' }]}>
<Input placeholder="例如:gpt-4o" size="large" />
</Form.Item>
</div>
<Form.Item name="apiBase" label="API地址">
<Input placeholder="https://api.openai.com/v1" size="large" />
</Form.Item>
<Form.Item name="apiKey" label="API Key">
<Input.Password placeholder="sk-****" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="weight" label="权重" style={{ flex: 1 }}
rules={[{ required: true }]}>
<InputNumber min={0} max={10} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="maxTokens" label="Max Tokens" style={{ flex: 1 }}
rules={[{ required: true }]}>
<InputNumber min={256} max={128000} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="temperature" label="Temperature" style={{ flex: 1 }}
rules={[{ required: true }]}>
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} size="large" />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}
rules={[{ required: true }]}>
<InputNumber min={0} max={10} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="isActive" label="启用" valuePropName="checked" style={{ flex: 1, paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminModels;
@@ -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<NotificationRecord[]>(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) => <Typography.Text strong>{v}</Typography.Text>,
},
{
title: '内容', dataIndex: 'content', ellipsis: true,
},
{
title: '类型', dataIndex: 'type', width: 80,
render: (v: string) => {
const labels: Record<string, string> = { system: '系统', credit: '积分', promo: '活动' };
return <Tag color={getTypeColor(v)}>{labels[v] || v}</Tag>;
},
},
{
title: '发送目标', dataIndex: 'target', width: 120,
render: (v: string) => (
<Tag color={v === '全部用户' ? 'green' : 'blue'}>{v}</Tag>
),
},
{
title: '发送时间', dataIndex: 'createdAt', width: 160,
},
{
title: '操作', key: 'action', width: 80,
render: (_: any, r: NotificationRecord) => (
<Popconfirm title="确定删除该消息?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" danger size="small" icon={<DeleteOutlined />}></Button>
</Popconfirm>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{notifications.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={notifications}
rowKey="id"
pagination={{ pageSize: 10, showTotal: (t) => `${t} 条消息` }}
scroll={{ x: 800 }}
/>
</Card>
{/* Send Notification Modal */}
<Modal
title={<Space><SendOutlined /></Space>}
open={modalOpen}
onOk={handleSend}
onCancel={() => { setModalOpen(false); form.resetFields(); }}
okText="发送" cancelText="取消" width={520}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="title" label="消息标题"
rules={[{ required: true, message: '请输入标题' }]}>
<Input placeholder="请输入消息标题" size="large" />
</Form.Item>
<Form.Item name="content" label="消息内容"
rules={[{ required: true, message: '请输入内容' }]}>
<Input.TextArea rows={4} placeholder="请输入消息内容" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
initialValue="system" rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'system', label: '系统通知' },
{ value: 'credit', label: '积分通知' },
{ value: 'promo', label: '活动通知' },
]} />
</Form.Item>
<Form.Item name="target_user_id" label="发送目标" style={{ flex: 1 }}
extra="留空则发送给全部用户">
<Select size="large" allowClear placeholder="全部用户"
options={MOCK_USERS.map(u => ({ value: u.id, label: u.username }))} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminNotificationManager;
@@ -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<AdminNotification[]>([]);
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 <InfoCircleOutlined style={{ color: '#6366f1' }} />;
case 'credit': return <CreditCardOutlined style={{ color: '#f59e0b' }} />;
default: return <ExclamationCircleOutlined style={{ color: '#94a3b8' }} />;
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'system': return <Tag color="blue"></Tag>;
case 'credit': return <Tag color="orange"></Tag>;
default: return <Tag></Tag>;
}
};
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Space>
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{notifications.filter(n => !n.isRead).length} </Tag>
</Space>
</div>
{notifications.length === 0 ? (
<Empty description="暂无通知" />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{notifications.map(n => (
<Card
key={n.id}
size="small"
bordered
style={{
borderRadius: 10,
borderColor: n.isRead ? '#f0f0f5' : '#e0e7ff',
background: n.isRead ? '#fff' : '#fafbff',
transition: 'all 0.2s',
}}
>
<div style={{ display: 'flex', gap: 14 }}>
<div style={{
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
background: n.isRead ? '#f8fafc' : 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18,
}}>
{getTypeIcon(n.type)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Typography.Text strong style={{ fontSize: 14 }}>
{n.title}
</Typography.Text>
{getTypeLabel(n.type)}
{!n.isRead && (
<Tag color="red" style={{ fontSize: 10 }}></Tag>
)}
</div>
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginBottom: 4, lineHeight: 1.6 }}>
{n.content}
</Typography.Paragraph>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{n.createdAt}
</Typography.Text>
</div>
{!n.isRead && (
<Button type="text" size="small" icon={<CheckOutlined />}
style={{ flexShrink: 0, color: '#6366f1' }}
onClick={() => {
setNotifications(prev =>
prev.map(item => item.id === n.id ? { ...item, isRead: true } : item)
);
}}>
</Button>
)}
</div>
</Card>
))}
</div>
)}
</Card>
</div>
);
};
export default AdminNotifications;
@@ -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 (
<div style={{ maxWidth: 720 }}>
{/* WeChat Pay */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(7,193,96,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, color: '#07c160',
}}><WechatOutlined /></div>
<div>
<Typography.Title level={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
</div>
</div>
<Switch checked={wechatEnabled} onChange={setWechatEnabled} checkedChildren="已启用" unCheckedChildren="未启用" />
</div>
<Form form={form} layout="vertical" initialValues={{
wechat_mch_id: '',
wechat_api_key: '',
wechat_cert_path: '',
wechat_notify_url: '',
}}>
<Form.Item name="wechat_mch_id" label="商户号 (MchID)">
<Input placeholder="微信支付商户号" size="large" disabled={!wechatEnabled} />
</Form.Item>
<Form.Item name="wechat_api_key" label="API密钥">
<Input.Password placeholder="微信支付API密钥" size="large" disabled={!wechatEnabled} />
</Form.Item>
<Form.Item name="wechat_cert_path" label="证书路径">
<Input placeholder="apiclient_cert.pem 路径" size="large" disabled={!wechatEnabled} />
</Form.Item>
<Form.Item name="wechat_notify_url" label="回调地址">
<Input placeholder="https://yourdomain.com/api/payments/wechat/callback" size="large" disabled={!wechatEnabled} />
</Form.Item>
</Form>
</Card>
{/* Alipay */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(0,122,255,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, color: '#007aff',
}}><AlipayCircleOutlined /></div>
<div>
<Typography.Title level={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
</div>
</div>
<Switch checked={alipayEnabled} onChange={setAlipayEnabled} checkedChildren="已启用" unCheckedChildren="未启用" />
</div>
<Form form={form} layout="vertical" initialValues={{
alipay_app_id: '',
alipay_private_key: '',
alipay_public_key: '',
alipay_gateway: '',
alipay_notify_url: '',
}}>
<Form.Item name="alipay_app_id" label="AppID">
<Input placeholder="支付宝应用AppID" size="large" disabled={!alipayEnabled} />
</Form.Item>
<Form.Item name="alipay_private_key" label="应用私钥">
<Input.TextArea rows={3} placeholder="支付宝应用私钥 (PKCS8格式)" disabled={!alipayEnabled} />
</Form.Item>
<Form.Item name="alipay_public_key" label="支付宝公钥">
<Input.TextArea rows={3} placeholder="支付宝公钥" disabled={!alipayEnabled} />
</Form.Item>
<Form.Item name="alipay_gateway" label="网关地址" extra="正式环境: https://openapi.alipay.com/gateway.do 沙箱环境: https://openapi-sandbox.dl.alipaydev.com/gateway.do">
<Input placeholder="https://openapi.alipay.com/gateway.do" size="large" disabled={!alipayEnabled} />
</Form.Item>
<Form.Item name="alipay_notify_url" label="回调地址" extra="用户支付成功后,支付宝会主动通知此地址,服务器收到通知后给用户加积分">
<Input placeholder="https://yourdomain.com/api/payments/alipay/callback" size="large" disabled={!alipayEnabled} />
</Form.Item>
</Form>
</Card>
{/* Recharge Packages */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
title={<span><DollarOutlined style={{ marginRight: 8 }} /></span>}>
{[
{ 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 => (
<div key={p.name} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 0', borderBottom: '1px solid #f5f6fa',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: p.color }} />
<Typography.Text strong>{p.name}</Typography.Text>
</div>
<div>
<Typography.Text strong style={{ color: p.color, fontSize: 16 }}>¥{p.price}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{p.credits.toLocaleString()} </Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>({(p.price / p.credits * 100).toFixed(1)}/)</Typography.Text>
</div>
</div>
))}
</Card>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}
size="large" style={{ borderRadius: 8, minWidth: 140 }}>
</Button>
</div>
</div>
);
};
export default AdminPaymentConfig;
@@ -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<SystemConfig[]>([]);
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<string, string> = {};
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<string, SystemConfig[]> = {
'站点信息': configs.filter(c => c.key.startsWith('site_')),
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
};
const getFieldDescription = (config: SystemConfig): string => {
const descMap: Record<string, string> = {
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 <Input.TextArea rows={3} placeholder={config.description} size="large" />;
}
if (config.key === 'seo_keywords') {
return <Input placeholder="关键词1, 关键词2, 关键词3" size="large" />;
}
return <Input placeholder={config.description} size="large" />;
};
if (loading) {
return <Card loading bordered={false} style={{ borderRadius: 12 }} />;
}
return (
<div style={{ maxWidth: 720 }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}>
<SettingOutlined />
</div>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">SEO配置</Typography.Text>
</div>
</div>
<Form form={form} layout="vertical">
{Object.entries(groupedConfigs).map(([group, items]) => (
<div key={group} style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
{group}
</Typography.Text>
{items.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))}
</div>
))}
</Form>
</Card>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}
size="large" style={{ borderRadius: 8, minWidth: 140 }}>
</Button>
</div>
</div>
);
};
export default AdminSettings;
@@ -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<AdminUser[]>([]);
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) => (
<Space>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: r.isAdmin ? 'linear-gradient(135deg, #f59e0b, #f97316)' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 13, fontWeight: 700,
}}>
{r.username.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 600 }}>
{r.username}
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}></Tag>}
</div>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
</div>
</Space>
),
},
{
title: '积分余额', dataIndex: 'credits', width: 120, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number) => (
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
{v.toLocaleString()}
</Typography.Text>
),
},
{
title: '手机号', dataIndex: 'phone', width: 130,
render: (v: string) => <Typography.Text type="secondary">{v || '-'}</Typography.Text>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'red'}>{v ? '正常' : '禁用'}</Tag>
),
},
{
title: '注册时间', dataIndex: 'createdAt', width: 120,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
},
{
title: '最后登录', dataIndex: 'lastLoginAt', width: 140,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v || '-'}</Typography.Text>,
},
{
title: '操作', key: 'action', width: 200, fixed: 'right' as const,
render: (_: any, r: AdminUser) => (
<Space size={4}>
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
</Button>
{!r.isAdmin && (
<Popconfirm
title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'}
onConfirm={() => handleToggleStatus(r)}
>
<Button type="link" size="small" danger={r.isActive}
icon={r.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
{r.isActive ? '禁用' : '启用'}
</Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
{/* Search bar */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<Input
placeholder="搜索用户名或邮箱"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={search}
onChange={e => setSearch(e.target.value)}
onPressEnter={handleSearch}
style={{ width: 280, borderRadius: 8 }}
allowClear
/>
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}></Button>
</div>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, showTotal: (t) => `${t} 个用户` }}
scroll={{ x: 900 }}
/>
</Card>
{/* Adjust Credits Modal */}
<Modal
title={<Space><WalletOutlined /> - {creditModal.user?.username}</Space>}
open={creditModal.open}
onOk={handleAdjustCredits}
onCancel={() => { setCreditModal({ open: false, user: null }); form.resetFields(); }}
okText="确认" cancelText="取消" width={440}
>
<div style={{ marginBottom: 16, padding: '12px 16px', background: '#f8fafc', borderRadius: 8 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ fontWeight: 800, fontSize: 18, color: '#6366f1' }}>
{creditModal.user?.credits.toLocaleString()}
</span>
</div>
<Form form={form} layout="vertical">
<Form.Item name="amount" label="积分变动"
rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber
style={{ width: '100%' }}
size="large"
placeholder="正数增加,负数扣除"
formatter={v => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
/>
</Form.Item>
<Form.Item name="reason" label="原因"
rules={[{ required: true, message: '请输入调整原因' }]}>
<Input.TextArea rows={2} placeholder="请输入调整原因" size="large" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AdminUsers;
@@ -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<VideoEngine[]>(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) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: r.isActive
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 16,
}}><PlayCircleOutlined /></div>
<div>
<Typography.Text strong>{r.name}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.provider}</div>
</div>
</div>
),
},
{
title: 'API地址', dataIndex: 'apiBase', width: 250,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>{v}</Typography.Text>,
},
{
title: '支持比例', dataIndex: 'supportedRatios', width: 180,
render: (ratios: string[]) => ratios.map(r => <Tag key={r}>{r}</Tag>),
},
{
title: '支持分辨率', dataIndex: 'supportedResolutions', width: 150,
render: (res: string[]) => res.map(r => <Tag key={r} color="blue">{r}</Tag>),
},
{
title: '最大时长', dataIndex: 'maxDuration', width: 80,
render: (v: number) => `${v}s`,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: VideoEngine) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{engines.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={engines}
rowKey="id"
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><PlayCircleOutlined />{modal.engine ? '编辑引擎' : '添加引擎'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={560}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="name" label="引擎名称" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Input placeholder="Seedance 2.0" size="large" />
</Form.Item>
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'seedance', label: 'Seedance (火山引擎)' },
{ value: 'kling', label: 'Kling (快手)' },
{ value: 'runway', label: 'Runway' },
{ value: 'pika', label: 'Pika' },
]} />
</Form.Item>
</div>
<Form.Item name="apiBase" label="API地址" rules={[{ required: true }]}>
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3" size="large" />
</Form.Item>
<Form.Item name="apiKey" label="API Key">
<Input.Password placeholder="sk-****" size="large" />
</Form.Item>
<Form.Item name="modelName" label="模型名称">
<Input placeholder="seedance-2.0" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="supportedRatios" label="支持比例" style={{ flex: 1 }}>
<Select mode="multiple" size="large" options={[
{ value: '16:9' }, { value: '9:16' }, { value: '1:1' }, { value: '4:3' },
]} />
</Form.Item>
<Form.Item name="supportedResolutions" label="支持分辨率" style={{ flex: 1 }}>
<Select mode="multiple" size="large" options={[
{ value: '720p' }, { value: '1080p' }, { value: '4K' },
]} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="maxDuration" label="最大时长(秒)" style={{ flex: 1 }}>
<InputNumber min={5} max={300} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
<InputNumber min={0} max={10} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="isActive" label="启用" valuePropName="checked" style={{ paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminVideoEngines;