This commit is contained in:
sjy
2026-07-01 17:54:48 +08:00
14 changed files with 847 additions and 280 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </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"> <link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head> </head>
<body> <body>
@@ -217,6 +217,7 @@ const AdminGenerationRecords: React.FC = () => {
username: item.username, username: item.username,
projectId: item.projectId, projectId: item.projectId,
projectName: item.projectName, projectName: item.projectName,
industry: item.industry,
originalPrompt: item.originalPrompt, originalPrompt: item.originalPrompt,
optimizedPrompt: item.optimizedPrompt, optimizedPrompt: item.optimizedPrompt,
duration: item.duration, duration: item.duration,
@@ -390,6 +391,10 @@ const AdminGenerationRecords: React.FC = () => {
title: '项目', dataIndex: 'projectName', width: 120, ellipsis: true, title: '项目', dataIndex: 'projectName', width: 120, ellipsis: true,
render: (v: string) => <Typography.Text style={{ fontSize: 13 }}>{v || '-'}</Typography.Text>, 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, title: '类型', dataIndex: 'genType', width: 90, ellipsis: true,
render: (v: string) => { render: (v: string) => {
+1
View File
@@ -319,6 +319,7 @@ export interface AdminGenerationRecord {
username: string; username: string;
projectId: string; projectId: string;
projectName: string; projectName: string;
industry?: string;
originalPrompt: string; originalPrompt: string;
optimizedPrompt: string; optimizedPrompt: string;
duration?: number; duration?: number;
+4 -2
View File
@@ -1400,9 +1400,10 @@ async def admin_list_generation_records(
): ):
"""List all generation records across all users, with optional filters.""" """List all generation records across all users, with optional filters."""
query = ( 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(User, GenerationRecord.user_id == User.id)
.join(Project, GenerationRecord.project_id == Project.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)) .where(GenerationRecord.deleted_at.is_(None), Project.deleted_at.is_(None))
.order_by(GenerationRecord.created_at.desc()) .order_by(GenerationRecord.created_at.desc())
) )
@@ -1427,7 +1428,7 @@ async def admin_list_generation_records(
rows = result.all() rows = result.all()
items = [] items = []
for record, username, project_name in rows: for record, username, project_name, industry, industry_label in rows:
refs = None refs = None
if record.media_references: if record.media_references:
try: try:
@@ -1440,6 +1441,7 @@ async def admin_list_generation_records(
"username": username, "username": username,
"project_id": record.project_id, "project_id": record.project_id,
"project_name": project_name, "project_name": project_name,
"industry": industry_label or industry,
"original_prompt": record.original_prompt, "original_prompt": record.original_prompt,
"optimized_prompt": record.optimized_prompt, "optimized_prompt": record.optimized_prompt,
"duration": record.duration, "duration": record.duration,
+11 -9
View File
@@ -1,13 +1,15 @@
import logging
from typing import Any, Optional 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.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.models.resources_material import ResourcesMaterial from app.models.resources_material import ResourcesMaterial
from app.models.pre_test_template import PreTestTemplate from app.models.pre_test_template import PreTestTemplate
from app.dependencies import get_db, get_current_user 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.resources_material_service import get_resources_material_list
from app.services.pre_test_queue import pre_test_queue from app.services.pre_test_queue import pre_test_queue
from app.utils.id_gen import generate_id 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: int = Query(1, description="页码"),
page_size: int = Query(20, description="每页数量"), page_size: int = Query(20, description="每页数量"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> Any | dict: ) -> Any | dict:
items, total = await get_resources_material_list( items, total = await get_resources_material_list(
db=db, db=db,
@@ -40,6 +43,7 @@ async def get_resources_material_list_api(
resource_type=resource_type, resource_type=resource_type,
page=page, page=page,
page_size=page_size, page_size=page_size,
user_id=current_user.id,
) )
return { return {
@@ -56,8 +60,7 @@ async def get_resources_material_list_api(
description="通过素材列表提交未前测的素材,异步处理,立即返回任务ID,结果稍后通过列表查询", description="通过素材列表提交未前测的素材,异步处理,立即返回任务ID,结果稍后通过列表查询",
) )
async def pre_test_material( async def pre_test_material(
resources_material_ids: list[str] = Query(..., description="资源素材表id"), req: PreTestMaterialRequest = Body(..., description="前测请求体"),
pre_test_template_id: str = Query(..., description="前测模板id"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
) -> Any | dict: ) -> Any | dict:
@@ -70,16 +73,16 @@ async def pre_test_material(
pre_test_template = await db.execute( pre_test_template = await db.execute(
select(PreTestTemplate) select(PreTestTemplate)
.where( .where(
PreTestTemplate.id == pre_test_template_id, PreTestTemplate.id == req.pre_test_template_id,
PreTestTemplate.user_id == current_user.id, PreTestTemplate.user_id == current_user.id,
PreTestTemplate.deleted_at.is_(None), PreTestTemplate.deleted_at.is_(None),
) )
) )
pre_test_template = pre_test_template.scalar_one_or_none() pre_test_template = pre_test_template.scalar_one_or_none()
if not pre_test_template: 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( resource_material = await db.execute(
select(ResourcesMaterial) select(ResourcesMaterial)
.where( .where(
@@ -115,7 +118,6 @@ async def pre_test_material(
}) })
if invalid_ids: if invalid_ids:
logger.error(f"前测素材提交失败,无效素材ID: {'; '.join(invalid_ids)}")
return {"code": 1, "message": "; ".join(invalid_ids)} return {"code": 1, "message": "; ".join(invalid_ids)}
if not grouped_videos: if not grouped_videos:
@@ -126,7 +128,7 @@ async def pre_test_material(
await pre_test_queue.enqueue({ await pre_test_queue.enqueue({
"task_id": task_id, "task_id": task_id,
"grouped_videos": grouped_videos, "grouped_videos": grouped_videos,
"pre_test_template_id": pre_test_template_id, "pre_test_template_id": req.pre_test_template_id,
}) })
return { return {
+9 -101
View File
@@ -357,51 +357,25 @@ async def batch_update_filename(
.where(GeneratedResource.file_name.is_not(None)) .where(GeneratedResource.file_name.is_not(None))
) )
result = await db.execute(query) result = await db.execute(query)
db_existing_names = set(row[0] for row in result.all()) existing_names = set(row[0] for row in result.all())
name_counters = {}
for item in valid_items: for item in valid_items:
file_name = item["file_name"] file_name = item["file_name"]
resource = item["resource"] resource = item["resource"]
base_name, ext = os.path.splitext(file_name) 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: if resource.file_name and resource.file_name in existing_names:
existing_names.remove(resource.file_name) existing_names.remove(resource.file_name)
if file_name not in name_counters: counter = 1
counter = 1 new_file_name = file_name
new_file_name = file_name
while new_file_name in existing_names:
while new_file_name in existing_names: new_file_name = f"{base_name}_{counter}{ext}"
new_file_name = f"{base_name}{counter}{ext}" counter += 1
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)
item["resource"].file_name = new_file_name item["resource"].file_name = new_file_name
db.add(item["resource"]) existing_names.add(new_file_name)
results.append({ results.append({
"source_id": item["source_id"], "source_id": item["source_id"],
@@ -423,7 +397,7 @@ async def batch_update_filename(
} }
except Exception as e: except Exception as e:
return { return {
"code": 0, "code": 1,
"message": f"批量修改文件名失败:{str(e)}", "message": f"批量修改文件名失败:{str(e)}",
"success_count": 0, "success_count": 0,
"fail_count": 0, "fail_count": 0,
@@ -463,69 +437,3 @@ async def get_upload_history(
"code": 0, "code": 0,
"message": f"查询上传任务历史失败:{str(e)}", "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表示成功") code: int = Field(0, description="返回码,0表示成功")
message: str = Field("查询成功", description="返回消息") message: str = Field("查询成功", description="返回消息")
data: list[ResourcesMaterialOut] = 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 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( def record_to_out(
task: ChatGenerationTask, task: ChatGenerationTask,
is_admin: bool = False, is_admin: bool = False,
@@ -410,7 +455,7 @@ def record_to_out(
video_tokens_used=task.video_tokens_used or 0, video_tokens_used=task.video_tokens_used or 0,
retry_count=task.retry_count or 0, retry_count=task.retry_count or 0,
poll_count=task.poll_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, created_at=task.created_at,
generated_at=task.generated_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, video_tokens_used=record.video_tokens_used or 0,
retry_count=0, retry_count=0,
poll_count=0, poll_count=0,
error_message=record.error_message, error_message=_resolve_error_message(record.error_message),
created_at=record.created_at, created_at=record.created_at,
generated_at=record.generated_at, generated_at=record.generated_at,
) )
@@ -18,11 +18,14 @@ async def get_resources_material_list(
resource_type: Optional[str] = None, resource_type: Optional[str] = None,
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
user_id: Optional[str] = None,
) -> Tuple[list[ResourcesMaterialOut], int]: ) -> Tuple[list[ResourcesMaterialOut], int]:
resource_alias = aliased(GeneratedResource) resource_alias = aliased(GeneratedResource)
query = select(ResourcesMaterial).where(ResourcesMaterial.deleted_at.is_(None)) query = select(ResourcesMaterial).where(ResourcesMaterial.deleted_at.is_(None))
if user_id:
query = query.where(ResourcesMaterial.user_id == user_id)
if advertiser_id: if advertiser_id:
query = query.where(ResourcesMaterial.advertiser_id == advertiser_id) query = query.where(ResourcesMaterial.advertiser_id == advertiser_id)
if material_id: if material_id:
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </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"> <link rel="stylesheet" crossorigin href="/assets/index-BhPFzWLH.css">
</head> </head>
<body> <body>
@@ -1180,15 +1180,106 @@ const AppLayout: React.FC = () => {
<Modal title={<Space><GiftOutlined /></Space>} open={rechargeModalOpen} <Modal title={<Space><GiftOutlined /></Space>} open={rechargeModalOpen}
onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
footer={null} width={680} width={680}
className="recharge-modal" 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 }}> <div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
<WalletOutlined style={{ color: '#6366f1' }} /> <WalletOutlined style={{ color: '#6366f1' }} />
<Typography.Text style={{ color: '#64748b', letterSpacing: 0 }}></Typography.Text> <Typography.Text style={{ color: '#64748b', letterSpacing: 0 }}></Typography.Text>
<Typography.Text strong style={{ color: '#6366f1', fontSize: 20, fontWeight: 600 }}>{user?.credits ?? 0}</Typography.Text> <Typography.Text strong style={{ color: '#6366f1', fontSize: 20, fontWeight: 600 }}>{user?.credits ?? 0}</Typography.Text>
</Space> </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={{ <div style={{
padding: '12px 16px', padding: '12px 16px',
background: 'rgba(99, 102, 241, 0.06)', background: 'rgba(99, 102, 241, 0.06)',
@@ -1246,96 +1337,6 @@ const AppLayout: React.FC = () => {
); );
})} })}
</div> </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> </div>
</Modal> </Modal>
+60 -13
View File
@@ -46,6 +46,7 @@ import {
LayoutOutlined, LayoutOutlined,
ArrowUpOutlined, ArrowUpOutlined,
DownloadOutlined, DownloadOutlined,
ReloadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -178,10 +179,10 @@ const AIChatPage: React.FC = () => {
return media.map((m) => { return media.map((m) => {
if (m.type === 'image') { if (m.type === 'image') {
imgCount++; imgCount++;
return `图片${numberToChinese(imgCount)}`; return `图片${imgCount}`;
} else { } else {
vidCount++; vidCount++;
return `视频${numberToChinese(vidCount)}`; return `视频${vidCount}`;
} }
}); });
}; };
@@ -1274,8 +1275,55 @@ const AIChatPage: React.FC = () => {
{msg.genType === 'image' ? '图片生成' : '视频生成'} {msg.genType === 'image' ? '图片生成' : '视频生成'}
</span> </span>
</div> </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 <Popconfirm
title="确定要删除吗?" title="确定要删除吗?"
onConfirm={async () => { onConfirm={async () => {
@@ -1297,8 +1345,8 @@ const AIChatPage: React.FC = () => {
<button <button
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
width: 28,
height: 28, height: 28,
padding: '0 10px',
borderRadius: 8, borderRadius: 8,
border: 'none', border: 'none',
background: 'rgba(99, 102, 241, 0.08)', background: 'rgba(99, 102, 241, 0.08)',
@@ -1308,7 +1356,8 @@ const AIChatPage: React.FC = () => {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
padding: 0, gap: 4,
fontSize: 12,
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)'; e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
@@ -1320,9 +1369,9 @@ const AIChatPage: React.FC = () => {
}} }}
> >
<DeleteOutlined style={{ fontSize: 12 }} /> <DeleteOutlined style={{ fontSize: 12 }} />
</button> </button>
</Popconfirm> </Popconfirm>
</div> </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' }}> <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 }} /> <WarningOutlined style={{ color: '#ef4444', fontSize: 22 }} />
</div> </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> </div>
) : ( ) : (
<> <>
@@ -1449,11 +1501,6 @@ const AIChatPage: React.FC = () => {
</div> </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> </div>