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 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) => {
+1
View File
@@ -319,6 +319,7 @@ export interface AdminGenerationRecord {
username: string;
projectId: string;
projectName: string;
industry?: string;
originalPrompt: string;
optimizedPrompt: string;
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."""
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,
+11 -9
View File
@@ -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 {
+9 -101
View File
@@ -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:
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>
+60 -13
View File
@@ -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>