Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
+548
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-3RDqkBKt.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CbFNd7rH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -217,6 +217,7 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
username: item.username,
|
||||
projectId: item.projectId,
|
||||
projectName: item.projectName,
|
||||
industry: item.industry,
|
||||
originalPrompt: item.originalPrompt,
|
||||
optimizedPrompt: item.optimizedPrompt,
|
||||
duration: item.duration,
|
||||
@@ -390,6 +391,10 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
title: '项目', dataIndex: 'projectName', width: 120, ellipsis: true,
|
||||
render: (v: string) => <Typography.Text style={{ fontSize: 13 }}>{v || '-'}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '行业', dataIndex: 'industry', width: 100, ellipsis: true,
|
||||
render: (v: string) => <Tag color="cyan">{v || '-'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'genType', width: 90, ellipsis: true,
|
||||
render: (v: string) => {
|
||||
|
||||
@@ -319,6 +319,7 @@ export interface AdminGenerationRecord {
|
||||
username: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
industry?: string;
|
||||
originalPrompt: string;
|
||||
optimizedPrompt: string;
|
||||
duration?: number;
|
||||
|
||||
@@ -1400,9 +1400,10 @@ async def admin_list_generation_records(
|
||||
):
|
||||
"""List all generation records across all users, with optional filters."""
|
||||
query = (
|
||||
select(GenerationRecord, User.username, Project.name)
|
||||
select(GenerationRecord, User.username, Project.name, Project.industry, IndustryConfig.label)
|
||||
.join(User, GenerationRecord.user_id == User.id)
|
||||
.join(Project, GenerationRecord.project_id == Project.id)
|
||||
.outerjoin(IndustryConfig, Project.industry == IndustryConfig.key)
|
||||
.where(GenerationRecord.deleted_at.is_(None), Project.deleted_at.is_(None))
|
||||
.order_by(GenerationRecord.created_at.desc())
|
||||
)
|
||||
@@ -1427,7 +1428,7 @@ async def admin_list_generation_records(
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for record, username, project_name in rows:
|
||||
for record, username, project_name, industry, industry_label in rows:
|
||||
refs = None
|
||||
if record.media_references:
|
||||
try:
|
||||
@@ -1440,6 +1441,7 @@ async def admin_list_generation_records(
|
||||
"username": username,
|
||||
"project_id": record.project_id,
|
||||
"project_name": project_name,
|
||||
"industry": industry_label or industry,
|
||||
"original_prompt": record.original_prompt,
|
||||
"optimized_prompt": record.optimized_prompt,
|
||||
"duration": record.duration,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Query, Body
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.resources_material import ResourcesMaterial
|
||||
from app.models.pre_test_template import PreTestTemplate
|
||||
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.schemas.resources_material import ResourcesMaterialListResponse
|
||||
from app.schemas.resources_material import ResourcesMaterialListResponse, PreTestMaterialRequest
|
||||
from app.services.resources_material_service import get_resources_material_list
|
||||
from app.services.pre_test_queue import pre_test_queue
|
||||
from app.utils.id_gen import generate_id
|
||||
@@ -30,6 +32,7 @@ async def get_resources_material_list_api(
|
||||
page: int = Query(1, description="页码"),
|
||||
page_size: int = Query(20, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> Any | dict:
|
||||
items, total = await get_resources_material_list(
|
||||
db=db,
|
||||
@@ -40,6 +43,7 @@ async def get_resources_material_list_api(
|
||||
resource_type=resource_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -56,8 +60,7 @@ async def get_resources_material_list_api(
|
||||
description="通过素材列表提交未前测的素材,异步处理,立即返回任务ID,结果稍后通过列表查询",
|
||||
)
|
||||
async def pre_test_material(
|
||||
resources_material_ids: list[str] = Query(..., description="资源素材表id"),
|
||||
pre_test_template_id: str = Query(..., description="前测模板id"),
|
||||
req: PreTestMaterialRequest = Body(..., description="前测请求体"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> Any | dict:
|
||||
@@ -70,16 +73,16 @@ async def pre_test_material(
|
||||
pre_test_template = await db.execute(
|
||||
select(PreTestTemplate)
|
||||
.where(
|
||||
PreTestTemplate.id == pre_test_template_id,
|
||||
PreTestTemplate.id == req.pre_test_template_id,
|
||||
PreTestTemplate.user_id == current_user.id,
|
||||
PreTestTemplate.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
pre_test_template = pre_test_template.scalar_one_or_none()
|
||||
if not pre_test_template:
|
||||
return {"code": 1, "message": f"前测模板{pre_test_template_id}不存在或不属于当前用户"}
|
||||
return {"code": 1, "message": f"前测模板{req.pre_test_template_id}不存在或不属于当前用户"}
|
||||
|
||||
for resource_material_id in resources_material_ids:
|
||||
for resource_material_id in req.resources_material_ids:
|
||||
resource_material = await db.execute(
|
||||
select(ResourcesMaterial)
|
||||
.where(
|
||||
@@ -115,7 +118,6 @@ async def pre_test_material(
|
||||
})
|
||||
|
||||
if invalid_ids:
|
||||
logger.error(f"前测素材提交失败,无效素材ID: {'; '.join(invalid_ids)}")
|
||||
return {"code": 1, "message": "; ".join(invalid_ids)}
|
||||
|
||||
if not grouped_videos:
|
||||
@@ -126,7 +128,7 @@ async def pre_test_material(
|
||||
await pre_test_queue.enqueue({
|
||||
"task_id": task_id,
|
||||
"grouped_videos": grouped_videos,
|
||||
"pre_test_template_id": pre_test_template_id,
|
||||
"pre_test_template_id": req.pre_test_template_id,
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -357,51 +357,25 @@ async def batch_update_filename(
|
||||
.where(GeneratedResource.file_name.is_not(None))
|
||||
)
|
||||
result = await db.execute(query)
|
||||
db_existing_names = set(row[0] for row in result.all())
|
||||
|
||||
name_counters = {}
|
||||
existing_names = set(row[0] for row in result.all())
|
||||
|
||||
for item in valid_items:
|
||||
file_name = item["file_name"]
|
||||
resource = item["resource"]
|
||||
base_name, ext = os.path.splitext(file_name)
|
||||
|
||||
existing_names = db_existing_names.copy()
|
||||
|
||||
if resource.file_name and resource.file_name in existing_names:
|
||||
existing_names.remove(resource.file_name)
|
||||
|
||||
if file_name not in name_counters:
|
||||
counter = 1
|
||||
new_file_name = file_name
|
||||
|
||||
while new_file_name in existing_names:
|
||||
new_file_name = f"{base_name}{counter}{ext}"
|
||||
counter += 1
|
||||
|
||||
name_counters[file_name] = {
|
||||
"base_name": base_name,
|
||||
"ext": ext,
|
||||
"counter": counter,
|
||||
}
|
||||
existing_names.add(new_file_name)
|
||||
db_existing_names.add(new_file_name)
|
||||
else:
|
||||
counter = name_counters[file_name]["counter"]
|
||||
base_name = name_counters[file_name]["base_name"]
|
||||
ext = name_counters[file_name]["ext"]
|
||||
new_file_name = f"{base_name}{counter}{ext}"
|
||||
|
||||
while new_file_name in existing_names:
|
||||
counter += 1
|
||||
new_file_name = f"{base_name}{counter}{ext}"
|
||||
|
||||
name_counters[file_name]["counter"] = counter + 1
|
||||
existing_names.add(new_file_name)
|
||||
db_existing_names.add(new_file_name)
|
||||
counter = 1
|
||||
new_file_name = file_name
|
||||
|
||||
while new_file_name in existing_names:
|
||||
new_file_name = f"{base_name}_{counter}{ext}"
|
||||
counter += 1
|
||||
|
||||
item["resource"].file_name = new_file_name
|
||||
db.add(item["resource"])
|
||||
existing_names.add(new_file_name)
|
||||
|
||||
results.append({
|
||||
"source_id": item["source_id"],
|
||||
@@ -423,7 +397,7 @@ async def batch_update_filename(
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"code": 0,
|
||||
"code": 1,
|
||||
"message": f"批量修改文件名失败:{str(e)}",
|
||||
"success_count": 0,
|
||||
"fail_count": 0,
|
||||
@@ -463,69 +437,3 @@ async def get_upload_history(
|
||||
"code": 0,
|
||||
"message": f"查询上传任务历史失败:{str(e)}",
|
||||
}
|
||||
|
||||
#读取指定资源id的素材信息,包括size大小,尺寸,帧率,编码格式,码率,高宽比例
|
||||
@router.get(
|
||||
"/upload-material/{resource_id}",
|
||||
summary="查询上传素材信息",
|
||||
description="查询指定上传素材的详细信息",
|
||||
)
|
||||
async def get_upload_material_info(
|
||||
resource_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any | dict:
|
||||
try:
|
||||
upload_material = await db.execute(
|
||||
select(GeneratedResource)
|
||||
.where(
|
||||
GeneratedResource.id == resource_id,
|
||||
)
|
||||
)
|
||||
upload_material = upload_material.scalar_one_or_none()
|
||||
if not upload_material:
|
||||
return {"code": 1, "message": f"素材{resource_id}不存在"}
|
||||
storage_path = upload_material.storage_path
|
||||
|
||||
import ffmpeg
|
||||
# 使用 ffmpeg.probe 获取视频的元数据[reference:20]
|
||||
probe = ffmpeg.probe(storage_path)
|
||||
|
||||
# 从 'format' 中获取文件信息和码率[reference:21]
|
||||
format_info = probe['format']
|
||||
bit_rate = int(format_info.get('bit_rate', 0)) # 码率,单位 bps[reference:22]
|
||||
file_size = int(format_info.get('size', 0)) # 文件大小,单位 bytes[reference:23]
|
||||
duration = float(format_info.get('duration', 0)) # 时长,单位秒[reference:24]
|
||||
|
||||
# 从 'streams' 中查找视频流(通常是第一个视频流)
|
||||
video_stream = next((stream for stream in probe['streams'] if stream['codec_type'] == 'video'), None)
|
||||
if video_stream is None:
|
||||
return None
|
||||
|
||||
width = int(video_stream['width'])
|
||||
height = int(video_stream['height'])
|
||||
# 帧率可能以分数形式表示,如 "30000/1001"[reference:25]
|
||||
r_frame_rate = video_stream.get('r_frame_rate', '0/0')
|
||||
if '/' in r_frame_rate:
|
||||
num, den = map(int, r_frame_rate.split('/'))
|
||||
fps = num / den if den != 0 else 0
|
||||
else:
|
||||
fps = float(r_frame_rate)
|
||||
|
||||
codec_name = video_stream.get('codec_name', 'unknown') # 编码格式名称,如 h264[reference:26]
|
||||
|
||||
return {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fps": fps,
|
||||
"codec": codec_name,
|
||||
"bit_rate": bit_rate, # 单位 bps
|
||||
"file_size": file_size, # 单位 bytes
|
||||
"duration": duration,
|
||||
"aspect_ratio": width / height
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"code": 0,
|
||||
"message": f"查询上传素材信息失败:{str(e)}",
|
||||
}
|
||||
@@ -56,4 +56,9 @@ class ResourcesMaterialListResponse(BaseModel):
|
||||
code: int = Field(0, description="返回码,0表示成功")
|
||||
message: str = Field("查询成功", description="返回消息")
|
||||
data: list[ResourcesMaterialOut] = Field(..., description="素材列表数据")
|
||||
total: int = Field(..., description="总记录数")
|
||||
total: int = Field(..., description="总记录数")
|
||||
|
||||
|
||||
class PreTestMaterialRequest(BaseModel):
|
||||
resources_material_ids: list[str] = Field(..., description="资源素材表id列表")
|
||||
pre_test_template_id: str = Field(..., description="前测模板id")
|
||||
@@ -342,6 +342,51 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
|
||||
return task
|
||||
|
||||
|
||||
def _resolve_error_message(error_message: str | None) -> str | None:
|
||||
"""匹配 ARK_ERRORS 字典,将原始错误码转换为友好提示。
|
||||
与 app/api/v1/generation.py 的 _record_to_out 保持一致。
|
||||
|
||||
注意:celery 任务中已调用 extract_error_message 将错误码转为中文提示后存入数据库,
|
||||
所以到达此函数的 message 可能是:
|
||||
1. 已翻译的中文提示(ARK_ERRORS 的 value)→ 直接返回
|
||||
2. 原始错误字符串(含 code='...' 或 JSON 格式)→ 匹配 ARK_ERRORS
|
||||
3. 未知内容 → 返回 "生成失败"
|
||||
"""
|
||||
if not error_message:
|
||||
return error_message
|
||||
|
||||
from app.services.error_codes import ARK_ERRORS
|
||||
|
||||
# 如果已经是 ARK_ERRORS 中已翻译的中文值,直接返回
|
||||
if error_message in ARK_ERRORS.values():
|
||||
return error_message
|
||||
|
||||
import re
|
||||
|
||||
# 匹配以下格式中的错误码:
|
||||
# 1. {'error': {'code': 'XXX', ...}} — str(error_obj) 的 Python dict 形式
|
||||
# 2. {"error": {"code": "XXX", ...}} — JSON 形式
|
||||
# 3. code='XXX' — 旧格式
|
||||
for pattern in [
|
||||
r"'code'\s*:\s*'([^']+)'", # 'code': 'XXX'
|
||||
r'"code"\s*:\s*"([^"]+)"', # "code": "XXX"
|
||||
r"code='([^']+)'", # code='XXX'
|
||||
]:
|
||||
match = re.search(pattern, error_message)
|
||||
if match:
|
||||
code = match.group(1)
|
||||
if code in ARK_ERRORS:
|
||||
return ARK_ERRORS[code]
|
||||
|
||||
# 兜底:按冒号分割,检查第二部分是否是已知错误码
|
||||
parts = error_message.split(":")
|
||||
if len(parts) >= 2 and parts[1].strip() in ARK_ERRORS:
|
||||
return ARK_ERRORS[parts[1].strip()]
|
||||
|
||||
# 没有匹配到已知错误码时,直接返回"生成失败"
|
||||
return "生成失败"
|
||||
|
||||
|
||||
def record_to_out(
|
||||
task: ChatGenerationTask,
|
||||
is_admin: bool = False,
|
||||
@@ -410,7 +455,7 @@ def record_to_out(
|
||||
video_tokens_used=task.video_tokens_used or 0,
|
||||
retry_count=task.retry_count or 0,
|
||||
poll_count=task.poll_count or 0,
|
||||
error_message=task.error_message,
|
||||
error_message=_resolve_error_message(task.error_message),
|
||||
created_at=task.created_at,
|
||||
generated_at=task.generated_at,
|
||||
)
|
||||
@@ -611,7 +656,7 @@ def generation_record_to_history_out(
|
||||
video_tokens_used=record.video_tokens_used or 0,
|
||||
retry_count=0,
|
||||
poll_count=0,
|
||||
error_message=record.error_message,
|
||||
error_message=_resolve_error_message(record.error_message),
|
||||
created_at=record.created_at,
|
||||
generated_at=record.generated_at,
|
||||
)
|
||||
|
||||
@@ -18,11 +18,14 @@ async def get_resources_material_list(
|
||||
resource_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Tuple[list[ResourcesMaterialOut], int]:
|
||||
resource_alias = aliased(GeneratedResource)
|
||||
|
||||
query = select(ResourcesMaterial).where(ResourcesMaterial.deleted_at.is_(None))
|
||||
|
||||
|
||||
if user_id:
|
||||
query = query.where(ResourcesMaterial.user_id == user_id)
|
||||
if advertiser_id:
|
||||
query = query.where(ResourcesMaterial.advertiser_id == advertiser_id)
|
||||
if material_id:
|
||||
|
||||
+57
-57
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BsNc32TJ.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DeB32LOF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BhPFzWLH.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1180,15 +1180,106 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
<Modal title={<Space><GiftOutlined />积分充值</Space>} open={rechargeModalOpen}
|
||||
onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
|
||||
footer={null} width={680}
|
||||
width={680}
|
||||
className="recharge-modal"
|
||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}>
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '12px 0 0', borderTop: '1px solid #f0f0f0' }}>
|
||||
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} style={{ borderRadius: 10, marginRight: 12 }}>取消</Button>
|
||||
<Button type="primary" size="large" disabled={!selectedPlan || (!enabledMethods.alipay && !enabledMethods.wechat)} loading={paying}
|
||||
onClick={async () => {
|
||||
const plan = rechargeOptions.find((opt: any) => opt.id === selectedPlan);
|
||||
if (!plan) return;
|
||||
const totalCredits = (plan.credits || 0) + (plan.bonus_credits || plan.bonusCredits || 0);
|
||||
try {
|
||||
setPaying(true);
|
||||
const order = await createRechargeOrder(plan.id, paymentMethod);
|
||||
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
|
||||
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
|
||||
const paymentInfo = {
|
||||
price: plan.price,
|
||||
credits: totalCredits,
|
||||
qrCode: qrCode,
|
||||
method: order.paymentMethod,
|
||||
};
|
||||
setCurrentPaymentInfo(paymentInfo);
|
||||
setRechargeModalOpen(false);
|
||||
setQrCodeModalOpen(true);
|
||||
currentOrderNoRef.current = order.orderNo;
|
||||
|
||||
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
|
||||
orderNo: order.orderNo,
|
||||
price: plan.price,
|
||||
credits: totalCredits,
|
||||
qrCode: qrCode,
|
||||
method: order.paymentMethod,
|
||||
createdAt: order.createdAt || new Date().toISOString(),
|
||||
timeoutSeconds: 180,
|
||||
}));
|
||||
|
||||
startPolling(order.orderNo);
|
||||
} else {
|
||||
message.success('充值成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
setRechargeModalOpen(false);
|
||||
setSelectedPlan(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '创建订单失败,请重试');
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 10, fontWeight: 600,
|
||||
background: selectedPlan ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : '#d1d5db',
|
||||
border: 'none', boxShadow: selectedPlan ? '0 8px 24px rgba(99,102,241,0.3)' : 'none',
|
||||
}}>
|
||||
确认充值
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<WalletOutlined style={{ color: '#6366f1' }} />
|
||||
<Typography.Text style={{ color: '#64748b', letterSpacing: 0 }}>当前积分余额</Typography.Text>
|
||||
<Typography.Text strong style={{ color: '#6366f1', fontSize: 20, fontWeight: 600 }}>{user?.credits ?? 0}</Typography.Text>
|
||||
</Space>
|
||||
|
||||
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
|
||||
<div style={{ marginBottom: 16, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
|
||||
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
|
||||
⚠️ 暂无可用的支付方式,请联系管理员开启支付功能
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13, marginBottom: 8, display: 'block' }}>选择支付方式</Typography.Text>
|
||||
<Radio.Group value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
style={{ display: 'flex', gap: 12 }}>
|
||||
{enabledMethods.alipay && (
|
||||
<Radio.Button value="alipay" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
color: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
}}>
|
||||
<AlipayCircleOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
支付宝
|
||||
</Radio.Button>
|
||||
)}
|
||||
{enabledMethods.wechat && (
|
||||
<Radio.Button value="wechat" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
color: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
}}>
|
||||
<WechatOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
微信支付
|
||||
</Radio.Button>
|
||||
)}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(99, 102, 241, 0.06)',
|
||||
@@ -1246,96 +1337,6 @@ const AppLayout: React.FC = () => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
|
||||
<div style={{ marginTop: 20, marginBottom: 8, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
|
||||
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
|
||||
⚠️ 暂无可用的支付方式,请联系管理员开启支付功能
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 20, marginBottom: 8 }}>
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13, marginBottom: 8, display: 'block' }}>选择支付方式</Typography.Text>
|
||||
<Radio.Group value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
style={{ display: 'flex', gap: 12 }}>
|
||||
{enabledMethods.alipay && (
|
||||
<Radio.Button value="alipay" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
color: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
}}>
|
||||
<AlipayCircleOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
支付宝
|
||||
</Radio.Button>
|
||||
)}
|
||||
{enabledMethods.wechat && (
|
||||
<Radio.Button value="wechat" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
color: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
}}>
|
||||
<WechatOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
微信支付
|
||||
</Radio.Button>
|
||||
)}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} style={{ borderRadius: 10, marginRight: 12 }}>取消</Button>
|
||||
<Button type="primary" size="large" disabled={!selectedPlan || (!enabledMethods.alipay && !enabledMethods.wechat)} loading={paying}
|
||||
onClick={async () => {
|
||||
const plan = rechargeOptions.find((opt: any) => opt.id === selectedPlan);
|
||||
if (!plan) return;
|
||||
const totalCredits = (plan.credits || 0) + (plan.bonus_credits || plan.bonusCredits || 0);
|
||||
try {
|
||||
setPaying(true);
|
||||
const order = await createRechargeOrder(plan.id, paymentMethod);
|
||||
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
|
||||
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
|
||||
const paymentInfo = {
|
||||
price: plan.price,
|
||||
credits: totalCredits,
|
||||
qrCode: qrCode,
|
||||
method: order.paymentMethod,
|
||||
};
|
||||
setCurrentPaymentInfo(paymentInfo);
|
||||
setRechargeModalOpen(false);
|
||||
setQrCodeModalOpen(true);
|
||||
currentOrderNoRef.current = order.orderNo;
|
||||
|
||||
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
|
||||
orderNo: order.orderNo,
|
||||
price: plan.price,
|
||||
credits: totalCredits,
|
||||
qrCode: qrCode,
|
||||
method: order.paymentMethod,
|
||||
createdAt: order.createdAt || new Date().toISOString(),
|
||||
timeoutSeconds: 180,
|
||||
}));
|
||||
|
||||
startPolling(order.orderNo);
|
||||
} else {
|
||||
message.success('充值成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
setRechargeModalOpen(false);
|
||||
setSelectedPlan(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '创建订单失败,请重试');
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 10, fontWeight: 600,
|
||||
background: selectedPlan ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : '#d1d5db',
|
||||
border: 'none', boxShadow: selectedPlan ? '0 8px 24px rgba(99,102,241,0.3)' : 'none',
|
||||
}}>
|
||||
确认充值
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
LayoutOutlined,
|
||||
ArrowUpOutlined,
|
||||
DownloadOutlined,
|
||||
ReloadOutlined,
|
||||
|
||||
} from '@ant-design/icons';
|
||||
|
||||
@@ -178,10 +179,10 @@ const AIChatPage: React.FC = () => {
|
||||
return media.map((m) => {
|
||||
if (m.type === 'image') {
|
||||
imgCount++;
|
||||
return `图片${numberToChinese(imgCount)}`;
|
||||
return `图片${imgCount}`;
|
||||
} else {
|
||||
vidCount++;
|
||||
return `视频${numberToChinese(vidCount)}`;
|
||||
return `视频${vidCount}`;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1274,8 +1275,55 @@ const AIChatPage: React.FC = () => {
|
||||
{msg.genType === 'image' ? '图片生成' : '视频生成'}
|
||||
</span>
|
||||
</div>
|
||||
{/* 删除按钮 - 右上角 */}
|
||||
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 100 }}>
|
||||
{/* 附件详情 - 右上角 */}
|
||||
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
|
||||
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 100 }}>
|
||||
<span style={{ padding: '4px 12px', borderRadius: 16, color: '#6366f1', cursor: 'pointer', fontWeight: 500, border: '1px solid rgba(99, 102, 241, 0.2)', background: 'rgba(99, 102, 241, 0.04)' }} onClick={(e) => { e.stopPropagation(); const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); setAttachmentPopupPosition({ x: rect.left, y: rect.top - 10 }); setAttachmentPopupMessageId(msg.id); setAttachmentPopupVisible(true); }}>附件详情</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 操作按钮 - 右下角 */}
|
||||
<div style={{ position: 'absolute', bottom: 8, right: 8, zIndex: 100, display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setInputValue(msg.originalPrompt || '');
|
||||
if (msg.mediaReferences && msg.mediaReferences.length > 0) {
|
||||
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
|
||||
name: ref.name,
|
||||
type: ref.type,
|
||||
url: ref.url,
|
||||
label: ref.label || '',
|
||||
})));
|
||||
} else {
|
||||
setCurrentMedia([]);
|
||||
}
|
||||
msgApi.success('已加载到编辑区');
|
||||
}}
|
||||
style={{
|
||||
height: 28,
|
||||
padding: '0 10px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: 'rgba(99, 102, 241, 0.08)',
|
||||
color: '#6366f1',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
gap: 4,
|
||||
fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.08)';
|
||||
}}
|
||||
>
|
||||
<ReloadOutlined style={{ fontSize: 12 }} />
|
||||
重新编辑
|
||||
</button>
|
||||
<Popconfirm
|
||||
title="确定要删除吗?"
|
||||
onConfirm={async () => {
|
||||
@@ -1297,8 +1345,8 @@ const AIChatPage: React.FC = () => {
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
padding: '0 10px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: 'rgba(99, 102, 241, 0.08)',
|
||||
@@ -1308,7 +1356,8 @@ const AIChatPage: React.FC = () => {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
gap: 4,
|
||||
fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
@@ -1320,9 +1369,9 @@ const AIChatPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: 12 }} />
|
||||
删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 文本内容 */}
|
||||
@@ -1410,7 +1459,10 @@ const AIChatPage: React.FC = () => {
|
||||
<div style={{ width: 48, height: 48, borderRadius: '50%', background: 'rgba(254, 226, 226, 0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<WarningOutlined style={{ color: '#ef4444', fontSize: 22 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 14, color: '#94a3b8' }}>生成失败</span>
|
||||
<span style={{ fontSize: 14, color: '#94a3b8' }}>生成失败(积分已退)</span>
|
||||
{msg.errorMessage && (
|
||||
<span style={{ fontSize: 13, color: '#ef4444', textAlign: 'center', padding: '0 8px', lineHeight: 1.5 }}>{msg.errorMessage}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -1449,11 +1501,6 @@ const AIChatPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
)}
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
|
||||
<span style={{ padding: '4px 12px', borderRadius: 16, color: '#6366f1', cursor: 'pointer', fontWeight: 500, border: '1px solid rgba(99, 102, 241, 0.2)', background: 'rgba(99, 102, 241, 0.04)' }} onClick={(e) => { e.stopPropagation(); const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); setAttachmentPopupPosition({ x: rect.left, y: rect.top - 10 }); setAttachmentPopupMessageId(msg.id); setAttachmentPopupVisible(true); }}>附件详情</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user