This commit is contained in:
2026-06-05 10:24:19 +08:00
9 changed files with 904 additions and 261 deletions
File diff suppressed because one or more lines are too long
+14 -13
View File
@@ -1,13 +1,14 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoGen.AI 管理后台</title> <title>VideoGen.AI 管理后台</title>
<script type="module" crossorigin src="/assets/index-CllfooUi.js"></script> <script type="module" crossorigin src="/assets/index-BBz1j0L-.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body>
</html> </body>
</html>
@@ -3,7 +3,7 @@ import {
Button, Card, Select, Space, Table, Tag, Typography, message, Button, Card, Select, Space, Table, Tag, Typography, message,
} from 'antd'; } from 'antd';
import { import {
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, RollbackOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { getCreditRecords } from '../api'; import { getCreditRecords } from '../api';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
@@ -18,6 +18,12 @@ interface CreditRecord {
createdAt: string; createdAt: string;
} }
const CREDIT_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
refund: { text: '退回', color: 'blue', icon: <RollbackOutlined /> },
};
const AdminCreditRecords: React.FC = () => { const AdminCreditRecords: React.FC = () => {
const [records, setRecords] = useState<CreditRecord[]>([]); const [records, setRecords] = useState<CreditRecord[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -54,11 +60,14 @@ const AdminCreditRecords: React.FC = () => {
}, },
{ {
title: '类型', dataIndex: 'type', width: 100, title: '类型', dataIndex: 'type', width: 100,
render: (v: string) => ( render: (v: string) => {
<Tag color={v === 'recharge' ? 'green' : 'red'} icon={v === 'recharge' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}> const cfg = CREDIT_TYPE_MAP[v] || { text: v || '-', color: 'default', icon: null };
{v === 'recharge' ? '充值' : '消费'} return (
</Tag> <Tag color={cfg.color} icon={cfg.icon}>
), {cfg.text}
</Tag>
);
},
}, },
{ {
title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: CreditRecord, b: CreditRecord) => a.amount - b.amount, title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: CreditRecord, b: CreditRecord) => a.amount - b.amount,
@@ -140,6 +149,7 @@ const AdminCreditRecords: React.FC = () => {
{ value: '', label: '全部类型' }, { value: '', label: '全部类型' },
{ value: 'recharge', label: '充值' }, { value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' }, { value: 'consume', label: '消费' },
{ value: 'refund', label: '退回' },
]} ]}
/> />
</Space> </Space>
@@ -29,7 +29,6 @@ import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
// const RAW_API_BASE = 'http://ceshi.apiforeign.minzhong.cn';
// 资源 URL 由后端返回相对路径,例如 /generate/images/xxx.png?exp=xxx&sign=xxx。 // 资源 URL 由后端返回相对路径,例如 /generate/images/xxx.png?exp=xxx&sign=xxx。
// 如果 VITE_API_BASE 被配置成 http://host/api 或 /api,这里会去掉 /api,避免拼成 /api/generate/xxx 导致 404。 // 如果 VITE_API_BASE 被配置成 http://host/api 或 /api,这里会去掉 /api,避免拼成 /api/generate/xxx 导致 404。
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, ''); const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
@@ -323,6 +322,25 @@ const AdminGenerationAiRecords: React.FC = () => {
}, 0); }, 0);
}; };
const handleOpenExternalResource = (url?: string | null, type = '素材') => {
if (!url) {
message.warning(`${type}链接为空,暂无法查看`);
return;
}
if (isBlobUrl(url)) {
message.warning('本地临时素材已失效,暂无法查看');
return;
}
if (isUrlExpired(url)) {
message.warning(`${type}链接已超时,请刷新列表或重新搜索后再查看`);
return;
}
window.open(apiUrl(url), '_blank', 'noopener,noreferrer');
};
const columns = useMemo(() => [ const columns = useMemo(() => [
{ {
title: '用户', key: 'user', width: 150, title: '用户', key: 'user', width: 150,
@@ -649,6 +667,13 @@ const AdminGenerationAiRecords: React.FC = () => {
return ( return (
<div key={refKey} style={{ width: 94 }}> <div key={refKey} style={{ width: 94 }}>
<div <div
role="button"
tabIndex={0}
title="点击新页面查看素材"
onClick={() => handleOpenExternalResource(refUrl, isVideo ? '视频素材' : isImage ? '图片素材' : '素材')}
onKeyDown={(e) => {
if (e.key === 'Enter') handleOpenExternalResource(refUrl, isVideo ? '视频素材' : isImage ? '图片素材' : '素材');
}}
style={{ style={{
width: 94, width: 94,
height: 94, height: 94,
@@ -660,6 +685,7 @@ const AdminGenerationAiRecords: React.FC = () => {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
cursor: 'pointer',
}} }}
> >
{isImage ? ( {isImage ? (
@@ -687,14 +713,18 @@ const AdminGenerationAiRecords: React.FC = () => {
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
<div style={{ color: '#64748b', fontSize: 12, textAlign: 'center' }}> <div style={{ color: '#64748b', fontSize: 12, textAlign: 'center' }}>
<VideoCameraOutlined /> <PlayCircleOutlined style={{ fontSize: 18 }} />
<div></div> <div></div>
<div style={{ fontSize: 10, color: '#94a3b8', marginTop: 2 }}></div>
</div> </div>
</> </>
) : null} ) : null}
{!isImage && !isVideo ? ( {!isImage && !isVideo ? (
<div style={{ color: '#64748b', fontSize: 12, textAlign: 'center' }}></div> <div style={{ color: '#64748b', fontSize: 12, textAlign: 'center' }}>
<div style={{ fontSize: 10, color: '#94a3b8', marginTop: 2 }}></div>
</div>
) : null} ) : null}
{state === 'checking' && isImage ? ( {state === 'checking' && isImage ? (
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -11,7 +11,7 @@ export interface User {
export interface CreditRecord { export interface CreditRecord {
id: string; id: string;
type: 'consume' | 'recharge'; type: 'consume' | 'recharge' | 'refund';
amount: number; amount: number;
description: string; description: string;
createdAt: string; createdAt: string;
@@ -208,9 +208,10 @@ export interface AdminGenerationRecord {
duration?: number; duration?: number;
aspectRatio?: string; aspectRatio?: string;
resolution?: string; resolution?: string;
status: 'prompt_optimized' | 'generating' | 'completed' | 'failed'; status: 'optimizing' | 'prompt_optimized' | 'generating' | 'completed' | 'failed' | string;
videoUrl?: string; videoUrl?: string;
references?: { url: string; type: string; name: string }[]; videoCoverUrl?: string;
references?: GenerationAIMediaReference[] | null;
creditsCost: number; creditsCost: number;
textCreditsCost: number; textCreditsCost: number;
textTokensUsed: number; textTokensUsed: number;
@@ -218,12 +219,12 @@ export interface AdminGenerationRecord {
errorMessage?: string; errorMessage?: string;
createdAt: string; createdAt: string;
generatedAt?: string; generatedAt?: string;
genType?: string, genType?: string;
imageSize?: string, imageSize?: string;
imageUrl?: string, imageUrl?: string;
imageTokensUsed?: number, imageTokensUsed?: number;
imageProportion?: string, imageProportion?: string;
imagePx?: string, imagePx?: string;
} }
export type GenerationAITaskStatus = 'pending' | 'generating' | 'completed' | 'failed' | string; export type GenerationAITaskStatus = 'pending' | 'generating' | 'completed' | 'failed' | string;
+1 -1
View File
@@ -44,7 +44,7 @@ CORS_ORIGINS=["*"]
# RESOURCE # RESOURCE
RESOURCE_SIGN_SECRET=EOTpDZsEgkaYWPxgtIedOO0lDlH1moTS2rnSIemjzmO3 RESOURCE_SIGN_SECRET=EOTpDZsEgkaYWPxgtIedOO0lDlH1moTS2rnSIemjzmO3
RESOURCE_SIGN_EXPIRE_SECONDS=30 RESOURCE_SIGN_EXPIRE_SECONDS=86400
RESOURCE_SIGN_ARG_EXPIRE=exp RESOURCE_SIGN_ARG_EXPIRE=exp
RESOURCE_SIGN_ARG_SIGNATURE=sign RESOURCE_SIGN_ARG_SIGNATURE=sign
+4 -2
View File
@@ -36,7 +36,7 @@ async def send_sms_code(req: SmsSendRequest):
_validate_captcha_token(req.captcha_token) _validate_captcha_token(req.captcha_token)
try: try:
ok = await generate_and_send_sms(req.phone, req.scene.value) ok, code = await generate_and_send_sms(req.phone, req.scene.value)
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -48,7 +48,9 @@ async def send_sms_code(req: SmsSendRequest):
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="短信发送失败,请稍后重试", detail="短信发送失败,请稍后重试",
) )
return SmsResponse(message="验证码已发送", success=True)
message = f"验证码已发送:{code}" if settings.SMS_MOCK else "验证码已发送"
return SmsResponse(message=message, success=True)
@router.post( @router.post(
+3 -3
View File
@@ -4,7 +4,7 @@ import logging
import random import random
import time import time
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any, Tuple
from app.config import settings from app.config import settings
from app.utils.redis import get_redis from app.utils.redis import get_redis
@@ -156,14 +156,14 @@ async def store_sms_code(phone: str, code: str, scene: str = "login", ttl: int |
_sms_code_store[key] = (code, time.time() + ttl_seconds) _sms_code_store[key] = (code, time.time() + ttl_seconds)
async def generate_and_send_sms(phone: str, scene: str = "login") -> bool: async def generate_and_send_sms(phone: str, scene: str = "login") -> Tuple[bool, str]:
scene = _normalize_scene(scene) scene = _normalize_scene(scene)
await _check_send_limit(phone, scene) await _check_send_limit(phone, scene)
code = _generate_code() code = _generate_code()
ok = await send_sms(phone, code, scene) ok = await send_sms(phone, code, scene)
if ok: if ok:
await store_sms_code(phone, code, scene) await store_sms_code(phone, code, scene)
return ok return ok, code
async def verify_sms_code(phone: str, code: str, scene: str = "login") -> bool: async def verify_sms_code(phone: str, code: str, scene: str = "login") -> bool: