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>
|
||||||
<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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+57
-57
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user