slidesConfig 结构错误 — migrate_017 输出改为数组格式,migrate_019 修复历史数据

迁移系统加固 — 加 advisory lock 防并发,失败回滚并中止启动(原来是 catch-and-continue)
安全漏洞 — Settings/History API 权限收紧,API key 返回掩码值,海报 case 所有权校验
依赖缺失 — requirements.txt 补齐 python-pptx/openai/Pillow,Dockerfile 改为统一安装
海报鉴权下载 — 前端全部改用 authenticated blob,不再 window.open 无 token URL
LLM 配置分离 — 海报文案读取 poster_llm_*(不再复用 ppt_llm_*),支持 config namespace
图片生成器 — 兼容 b64_json 和 URL 两种响应格式,追踪 generation_mode/provider/model
种子数据 — 新环境自动获得 2 个海报模板 + 2 个文案模板
This commit is contained in:
wsb1224 2026-07-27 13:21:34 +08:00
parent 48f6388554
commit 67b2dac23f
15 changed files with 314 additions and 120 deletions

View File

@ -187,10 +187,10 @@ def delete_copy_template(template_id):
return jsonify(ppt_admin_service.delete_copy_template(template_id))
# ---- 历史记录管理 ----
# ---- 历史记录管理(需要审计权限) ----
@ppt_admin_bp.route("/history", methods=["GET"])
@jwt_required
@permission_required("audit_view")
def list_history():
params = {
"page": request.args.get("page", 1, type=int),
@ -209,7 +209,7 @@ def delete_history(history_id):
@ppt_admin_bp.route("/history/export", methods=["GET"])
@jwt_required
@permission_required("audit_view")
def export_history():
from flask import Response
params = {
@ -228,13 +228,13 @@ def export_history():
# ---- 系统配置 ----
@ppt_admin_bp.route("/available-models", methods=["GET"])
@jwt_required
@permission_required("config_manage")
def get_available_models():
return jsonify(ppt_admin_service.get_available_models())
@ppt_admin_bp.route("/settings", methods=["GET"])
@jwt_required
@permission_required("config_manage")
def get_settings():
return jsonify(ppt_admin_service.get_settings())

View File

@ -549,21 +549,52 @@ class PptAdminService:
logger.warning(f"从 Dify 数据库获取模型列表失败: {e}")
return None
# 需要掩码处理的敏感 key包含 api_key 或 secret 等)
_SENSITIVE_KEYS = {"api_key", "secret", "password", "token"}
@staticmethod
def _mask_value(key: str, value: str) -> str:
"""对敏感配置项进行掩码,不返回完整密钥。"""
if not value or len(value) < 8:
return value
key_lower = key.lower()
if any(s in key_lower for s in PptAdminService._SENSITIVE_KEYS):
return f"{value[:4]}****{value[-4:]}"
return value
def get_settings(self) -> dict:
settings = SystemSetting.query.all()
data = {}
for s in settings:
if any(kw in s.key.lower() for kw in self._SENSITIVE_KEYS):
data[s.key] = self._mask_value(s.key, s.value)
else:
data[s.key] = s.value
return {
"code": 0,
"data": {s.key: s.value for s in settings},
"data": data,
}
@staticmethod
def _is_masked_value(value: str) -> bool:
"""判断值是否为掩码格式(如 sk-****abcd"""
return isinstance(value, str) and "****" in value
def update_settings(self, data: dict, updated_by: str = "") -> dict:
for key, value in data.items():
value_str = str(value) if value is not None else ""
setting = SystemSetting.query.filter_by(key=key).first()
if setting:
setting.value = str(value)
# 如果是掩码值,跳过更新(保留真实密钥)
if self._is_masked_value(value_str):
continue
setting.value = value_str
setting.updated_by = updated_by
else:
db.session.add(SystemSetting(key=key, value=str(value), updated_by=updated_by))
# 新建设置项,不允许掩码值
if self._is_masked_value(value_str):
continue
db.session.add(SystemSetting(key=key, value=value_str, updated_by=updated_by))
db.session.commit()
return {"code": 0, "data": None}

View File

@ -22,18 +22,49 @@ def _ensure_migration_table():
from insurance.db.compat import db
from sqlalchemy import text
db.session.execute(text("""
CREATE TABLE IF NOT EXISTS db_migration_history (
id SERIAL PRIMARY KEY,
migration_name VARCHAR(255) UNIQUE NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
db.session.commit()
def _acquire_advisory_lock():
"""PostgreSQL advisory lock防止多 Worker 并发执行迁移。
返回 True 表示获得锁False 表示另一个进程正在迁移
"""
from insurance.db.compat import db
from sqlalchemy import text
try:
# 创建迁移历史表(如果不存在)
db.session.execute(text("""
CREATE TABLE IF NOT EXISTS db_migration_history (
id SERIAL PRIMARY KEY,
migration_name VARCHAR(255) UNIQUE NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
db.session.commit()
except Exception as e:
logger.warning(f"创建迁移历史表失败: {e}")
result = db.session.execute(text("SELECT pg_try_advisory_lock(20260727)"))
locked = result.scalar()
return bool(locked)
except Exception:
# SQLite 或其他数据库不支持 advisory lock用文件锁替代
import fcntl
lock_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".migration.lock")
try:
lock_fd = open(lock_path, "w")
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except (IOError, OSError):
return False
def _release_advisory_lock():
"""释放 advisory lock。"""
from insurance.db.compat import db
from sqlalchemy import text
try:
db.session.execute(text("SELECT pg_advisory_unlock(20260727)"))
except Exception:
pass
def _get_executed_migrations():
@ -55,22 +86,16 @@ def _record_migration(migration_name: str):
from insurance.db.compat import db
from sqlalchemy import text
try:
db.session.execute(text(
"INSERT INTO db_migration_history (migration_name, executed_at) VALUES (:name, :now)"
), {"name": migration_name, "now": datetime.now()})
db.session.commit()
except Exception as e:
logger.warning(f"记录迁移 {migration_name} 失败: {e}")
try:
db.session.rollback()
except Exception:
pass
db.session.execute(text(
"INSERT INTO db_migration_history (migration_name, executed_at) VALUES (:name, :now)"
), {"name": migration_name, "now": datetime.now()})
db.session.commit()
def run_migrations():
"""自动执行所有迁移脚本(带幂等保护)。"""
"""自动执行所有迁移脚本(带幂等保护、事务回滚和并发锁)。"""
from flask import current_app
from insurance.db.compat import db
# 检查是否启用自动迁移
if not current_app.config.get("MIGRATION_ENABLED", False):
@ -78,38 +103,52 @@ def run_migrations():
logger.info("开始执行数据库迁移...")
# 确保迁移历史表存在
_ensure_migration_table()
# 获取 advisory lock防止多 Worker 并发迁移
if not _acquire_advisory_lock():
logger.info("另一个进程正在执行迁移,跳过")
return
# 获取已执行的迁移
executed = _get_executed_migrations()
try:
# 确保迁移历史表存在
_ensure_migration_table()
# 获取当前目录
current_dir = os.path.dirname(os.path.abspath(__file__))
# 获取已执行的迁移
executed = _get_executed_migrations()
# 扫描所有迁移脚本migrate_*.py
migrations = []
for filename in sorted(os.listdir(current_dir)):
if filename.startswith("migrate_") and filename.endswith(".py"):
migrations.append(filename[:-3]) # 去掉 .py 后缀
# 获取当前目录
current_dir = os.path.dirname(os.path.abspath(__file__))
# 按顺序执行未执行的迁移
for migration_name in migrations:
if migration_name in executed:
logger.debug(f"跳过已执行的迁移: {migration_name}")
continue
try:
module = importlib.import_module(f"insurance.db.{migration_name}")
if hasattr(module, "migrate"):
logger.info(f"执行迁移: {migration_name}")
module.migrate()
# 记录迁移已执行
_record_migration(migration_name)
except Exception as e:
logger.warning(f"迁移 {migration_name} 失败: {e}")
# 继续执行下一个迁移,不中断
# 扫描所有迁移脚本migrate_*.py
migrations = []
for filename in sorted(os.listdir(current_dir)):
if filename.startswith("migrate_") and filename.endswith(".py"):
migrations.append(filename[:-3])
logger.info("迁移执行完成")
# 按顺序执行未执行的迁移
for migration_name in migrations:
if migration_name in executed:
logger.debug(f"跳过已执行的迁移: {migration_name}")
continue
try:
module = importlib.import_module(f"insurance.db.{migration_name}")
if hasattr(module, "migrate"):
logger.info(f"执行迁移: {migration_name}")
module.migrate()
_record_migration(migration_name)
logger.info(f"迁移 {migration_name} 完成")
except Exception as e:
# 回滚残留事务
try:
db.session.rollback()
except Exception:
pass
logger.error(f"迁移 {migration_name} 失败: {e}")
# 失败后停止,不带病继续
raise RuntimeError(f"迁移 {migration_name} 失败,中止启动: {e}") from e
logger.info("迁移执行完成")
finally:
_release_advisory_lock()
def get_migration_status():

View File

@ -132,7 +132,7 @@ def _build_default_slides_config(page_types, plan_type):
meta = dict(defaults.get(pt, {"pageType": pt}))
slides.append(meta)
return {"slides": slides}
return slides
def downgrade(engine):

View File

@ -20,6 +20,9 @@ class PosterRecord(db.Model):
export_size = Column(String(20), nullable=True, comment="导出尺寸")
reference_image_used = Column(String(500), nullable=True, comment="使用的参考图(合规留痕)")
prompt_used = Column(Text, nullable=True, comment="完整 prompt合规留痕")
generation_mode = Column(String(20), nullable=True, comment="生成方式: ai/fallback")
image_provider = Column(String(50), nullable=True, comment="图片供应商")
image_model = Column(String(100), nullable=True, comment="图片模型名称")
created_at = Column(TIMESTAMP, server_default=func.now())
def to_dict(self):
@ -38,5 +41,8 @@ class PosterRecord(db.Model):
"exportSize": self.export_size,
"referenceImageUsed": self.reference_image_used,
"promptUsed": self.prompt_used,
"generationMode": self.generation_mode,
"imageProvider": self.image_provider,
"imageModel": self.image_model,
"createdAt": self.created_at.isoformat() if self.created_at else None,
}

View File

@ -58,7 +58,7 @@ class CopyGenerator:
返回:
{"headline": "...", "body": "...", "call_to_action": "..."}
"""
from insurance.ppt.llm_client import llm_client
from insurance.ppt.llm_client import poster_llm_client
system_prompt = f"""你是一位专业的保险营销文案撰写人。
根据以下产品信息和客户数据生成一张保险营销海报的文案
@ -76,7 +76,7 @@ class CopyGenerator:
产品信息{json.dumps(product_rules, ensure_ascii=False)}
客户数据{json.dumps(customer_data, ensure_ascii=False)}"""
result, _response = await llm_client.structured_output(
result, _response = await poster_llm_client.structured_output(
user_prompt, system_prompt,
schema={
"type": "object",

View File

@ -2,6 +2,9 @@
import os
import base64
import logging
import tempfile
import httpx
logger = logging.getLogger(__name__)
@ -106,15 +109,35 @@ class PosterImageGenerator:
return "\n".join(parts)
def generate(self, prompt: str, size: str = "1024x1792", reference_image: str = None) -> bytes:
def _extract_image_bytes(self, response) -> bytes:
"""从图片 API 响应中提取图片 bytes支持 b64_json 和 URL 两种格式。"""
data_item = response.data[0] if response.data else None
if not data_item:
raise ValueError("图片 API 返回空数据")
# 优先使用 b64_json
if hasattr(data_item, 'b64_json') and data_item.b64_json:
return base64.b64decode(data_item.b64_json)
# 回退到 URL 下载
if hasattr(data_item, 'url') and data_item.url:
logger.info(f"图片 API 返回 URL正在下载: {data_item.url[:80]}...")
resp = httpx.get(data_item.url, timeout=60, follow_redirects=True)
resp.raise_for_status()
return resp.content
raise ValueError("图片 API 响应既无 b64_json 也无 url")
def generate(self, prompt: str, size: str = "1024x1792", reference_image: str = None) -> tuple:
"""调用配置的生图模型生成海报图片。
返回:
PNG 图片的 bytes
(image_bytes, provider_info) PNG 图片的 bytes 和生成信息
"""
api_size = SIZE_MAP.get(size, "1024x1792")
client = self._get_client()
model = self._get_model()
provider_info = {"provider": self._get_config().get("poster_image_provider", "openai"), "model": model}
kwargs = {
"model": model,
@ -130,23 +153,22 @@ class PosterImageGenerator:
if not ref_abs.startswith(uploads_root):
logger.warning(f"参考图路径不在允许目录: {reference_image}")
reference_image = None
try:
with open(reference_image, "rb") as img_file:
response = client.images.edit(
model=model,
image=img_file,
prompt=prompt,
n=1,
size=api_size,
)
image_base64 = response.data[0].b64_json
return base64.b64decode(image_base64)
except Exception as e:
logger.warning(f"参考图 edit 失败,降级为纯 prompt 生成: {e}")
else:
try:
with open(reference_image, "rb") as img_file:
response = client.images.edit(
model=model,
image=img_file,
prompt=prompt,
n=1,
size=api_size,
)
return self._extract_image_bytes(response), provider_info
except Exception as e:
logger.warning(f"参考图 edit 失败,降级为纯 prompt 生成: {e}")
response = client.images.generate(**kwargs)
image_base64 = response.data[0].b64_json
return base64.b64decode(image_base64)
return self._extract_image_bytes(response), provider_info
def generate_fallback(copy_content: dict, size: str = "1024x1792") -> bytes:

View File

@ -176,6 +176,14 @@ class PosterService:
product_id = data.get("productId")
reference_image = data.get("referenceImage")
# 校验 case 所有权SEC-P0-03
if case_upload_id:
case = PosterCaseUpload.query.get(case_upload_id)
if not case or case.user_id != user_id:
return {"code": 404, "message": "记录不存在", "data": None}
if case.confirmed_data is None:
return {"code": 1002, "message": "请先确认解析数据", "data": None}
# 获取模板
poster_template = PosterTemplate.query.get(template_id) if template_id else None
product = PptProduct.query.get(product_id) if product_id else None
@ -193,12 +201,15 @@ class PosterService:
)
# 生成图片
generation_mode = "ai"
provider_info = {}
try:
image_bytes = generator.generate(prompt, size=size, reference_image=reference_image)
image_bytes, provider_info = generator.generate(prompt, size=size, reference_image=reference_image)
except Exception as e:
logger.warning(f"GPT image API 失败,使用降级方案: {e}")
from insurance.poster.image_generator import generate_fallback
image_bytes = generate_fallback(copy_content, size=size)
generation_mode = "fallback"
# 保存文件
output_dir = os.path.join(current_app.config.get("UPLOAD_FOLDER", "uploads"), "posters")
@ -224,6 +235,9 @@ class PosterService:
export_size=size,
reference_image_used=reference_image,
prompt_used=prompt[:2000] if prompt else None,
generation_mode=generation_mode,
image_provider=provider_info.get("provider", ""),
image_model=provider_info.get("model", ""),
)
db.session.add(record)
db.session.commit()

View File

@ -289,13 +289,17 @@ async def _call_dify(
# ─── 统一客户端 ───────────────────────────────────────────
class LLMClient:
"""多供应商 LLM 客户端,支持自动切换和速率限制。"""
"""多供应商 LLM 客户端,支持自动切换和速率限制。
def __init__(self):
config_prefix: 数据库配置键前缀 "ppt" 读取 ppt_llm_*"poster" 读取 poster_llm_*
"""
def __init__(self, config_prefix: str = "ppt"):
self._configs: list[tuple[LLMProviderConfig, str]] = []
self._limiters: dict[str, RateLimiter] = {}
self._active_idx = 0
self._timeout_ms = _parse_timeout_ms(os.getenv("PPT_LLM_TIMEOUT_MS"))
self._config_prefix = config_prefix
self._timeout_ms = _parse_timeout_ms(os.getenv(f"{config_prefix.upper()}_LLM_TIMEOUT_MS"))
self._db_config_time: float = 0 # 上次从数据库加载配置的时间戳
self._db_config_ttl: float = 60 # 配置缓存有效期(秒)
@ -303,7 +307,8 @@ class LLMClient:
def _load_env_config(self):
"""从环境变量加载默认配置。"""
deepseek_key = os.environ.get("DEEPSEEK_API_KEY") or os.environ.get("OPENAI_API_KEY", "")
prefix = self._config_prefix.upper()
deepseek_key = os.environ.get(f"{prefix}_LLM_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") or os.environ.get("OPENAI_API_KEY", "")
minimax_key = os.environ.get("MINIMAX_API_KEY", "")
gemini_key = os.environ.get("GEMINI_API_KEY", "")
@ -326,23 +331,25 @@ class LLMClient:
if now - self._db_config_time < self._db_config_ttl:
return
self._db_config_time = now
prefix = self._config_prefix
try:
from insurance.models.system_setting import SystemSetting
settings = {s.key: s.value for s in SystemSetting.query.filter(
SystemSetting.key.in_([
"ppt_llm_provider", "ppt_llm_model", "ppt_llm_api_key", "ppt_llm_base_url",
"ppt_llm_timeout_ms",
f"{prefix}_llm_provider", f"{prefix}_llm_model",
f"{prefix}_llm_api_key", f"{prefix}_llm_base_url",
f"{prefix}_llm_timeout_ms",
])
).all()}
self._timeout_ms = _parse_timeout_ms(
settings.get("ppt_llm_timeout_ms"),
_parse_timeout_ms(os.getenv("PPT_LLM_TIMEOUT_MS")),
settings.get(f"{prefix}_llm_timeout_ms"),
_parse_timeout_ms(os.getenv(f"{prefix.upper()}_LLM_TIMEOUT_MS")),
)
provider = settings.get("ppt_llm_provider", "").strip()
provider = settings.get(f"{prefix}_llm_provider", "").strip()
if not provider:
return
model = settings.get("ppt_llm_model", "").strip()
model = settings.get(f"{prefix}_llm_model", "").strip()
# Dify 模式:通过 Dify Chat API 调用,无需独立 API Key
if provider == "dify":
@ -352,14 +359,14 @@ class LLMClient:
self._configs = [(cfg, "")]
self._limiters = {"dify": RateLimiter(0)}
self._active_idx = 0
logger.info(f"[LLMClient] 使用 Dify 模式: {model}")
logger.info(f"[LLMClient:{prefix}] 使用 Dify 模式: {model}")
return
api_key = settings.get("ppt_llm_api_key", "").strip()
api_key = settings.get(f"{prefix}_llm_api_key", "").strip()
if not api_key:
return
base_url = settings.get("ppt_llm_base_url", "").strip()
base_url = settings.get(f"{prefix}_llm_base_url", "").strip()
# 内置供应商:替换对应配置
if provider in PROVIDERS and not base_url:
@ -372,7 +379,7 @@ class LLMClient:
self._configs = [(cfg, api_key)]
self._limiters = {provider: RateLimiter(cfg.rate_limit)}
self._active_idx = 0
logger.info(f"[LLMClient] 使用数据库配置: {provider}/{cfg.model}")
logger.info(f"[LLMClient:{prefix}] 使用数据库配置: {provider}/{cfg.model}")
return
# 自定义供应商
@ -384,7 +391,7 @@ class LLMClient:
self._configs = [(cfg, api_key)]
self._limiters = {provider: RateLimiter(0)}
self._active_idx = 0
logger.info(f"[LLMClient] 使用自定义模型: {provider}/{cfg.model}")
logger.info(f"[LLMClient:{prefix}] 使用自定义模型: {provider}/{cfg.model}")
except Exception:
pass # 无 Flask 上下文或数据库不可用,使用环境变量配置
@ -438,7 +445,10 @@ class LLMClient:
"""多供应商自动切换调用。"""
self._try_load_db_config()
if not self._configs:
return LLMResponse(content="{}", provider="mock", latency_ms=0)
raise RuntimeError(
"未配置任何 LLM API Key请在管理后台 > 系统配置中设置 "
"ppt_llm_provider / ppt_llm_api_key或设置环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY"
)
start_idx = self._active_idx
tried = set()
@ -487,4 +497,5 @@ class LLMClient:
# ─── 单例 ────────────────────────────────────────────────
llm_client = LLMClient()
llm_client = LLMClient(config_prefix="ppt")
poster_llm_client = LLMClient(config_prefix="poster")

View File

@ -35,3 +35,10 @@ PyMuPDF>=1.23.0
PyPDF2>=3.0.0
pypdf>=3.0.0
pdfplumber>=0.9.0
# PPT generation (required for fast_pptx_renderer)
python-pptx>=0.6.23
# Image generation (required for poster AI image and fallback)
openai>=1.6.0
Pillow>=10.0.0

View File

@ -1,6 +1,6 @@
# 保险智能客服系统 — 文档索引
> **最后更新**2026-07-23
> **最后更新**2026-07-27
> **项目状态**:后端框架完成 + 核心功能完成 64%,产品推荐功能待实现
---
@ -47,6 +47,7 @@
| 文档 | 内容摘要 |
|------|---------|
| [PPT与海报功能问题整改计划.md](PPT与海报功能问题整改计划.md) | 当前问题审计、根因、文件级修复方案、测试、发布与回滚计划 |
| [PPT与海报功能完整解决方案.md](PPT与海报功能完整解决方案.md) | PPT 增强 + 海报新模块数据库设计、API、前端、AI 生图方案(已确认) |
| [PPT与海报功能开发任务清单.md](PPT与海报功能开发任务清单.md) | 120 项可勾选任务,分 7 个 Phase预估 11.5 天 |
| [baodanppt集成计划.md](baodanppt集成计划.md) | baodanppt 原始集成计划 |

View File

@ -2,7 +2,7 @@
<div class="step-preview">
<h3>预览导出</h3>
<div v-if="!posterUrl" class="generate-section">
<div v-if="!posterObjectUrl" class="generate-section">
<el-select v-model="selectedSize" style="width: 200px; margin-bottom: 16px">
<el-option label="竖版海报 1080x1920" value="1024x1792" />
<el-option label="横版海报 900x500" value="1792x1024" />
@ -17,12 +17,12 @@
</div>
<div v-else class="result-section">
<el-image :src="posterUrl" fit="contain" style="max-width: 100%; max-height: 500px; border-radius: 8px" />
<el-image :src="posterObjectUrl" fit="contain" style="max-width: 100%; max-height: 500px; border-radius: 8px" />
<div class="result-actions">
<el-button type="primary" @click="onDownload">
<el-icon><Download /></el-icon>
</el-button>
<el-button @click="posterUrl = null; recordId = null">重新生成</el-button>
<el-button @click="clearPoster">重新生成</el-button>
</div>
</div>
@ -33,7 +33,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { Picture, Download } from '@element-plus/icons-vue'
import { posterApi } from '@/utils/poster-api'
@ -49,9 +49,17 @@ defineEmits<{ back: [] }>()
const selectedSize = ref('1024x1792')
const generating = ref(false)
const posterUrl = ref<string | null>(null)
const posterObjectUrl = ref<string | null>(null)
const recordId = ref<number | null>(null)
function clearPoster() {
if (posterObjectUrl.value) {
URL.revokeObjectURL(posterObjectUrl.value)
}
posterObjectUrl.value = null
recordId.value = null
}
async function onGenerate() {
generating.value = true
try {
@ -66,8 +74,10 @@ async function onGenerate() {
})
const record = res?.data?.data ?? res?.data
recordId.value = record?.id
if (record?.exportUrl) {
posterUrl.value = `/insurance/poster/download/${record.id}`
if (record?.id) {
// Blob
const blob = await posterApi.downloadPoster(record.id)
posterObjectUrl.value = URL.createObjectURL(blob)
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '生成失败')
@ -76,11 +86,26 @@ async function onGenerate() {
}
}
function onDownload() {
async function onDownload() {
if (!recordId.value) return
const url = posterApi.getDownloadUrl(recordId.value)
window.open(url, '_blank')
try {
const blob = await posterApi.downloadPoster(recordId.value)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `poster_${recordId.value}.png`
a.click()
URL.revokeObjectURL(url)
} catch (e: any) {
ElMessage.error('下载失败')
}
}
onBeforeUnmount(() => {
if (posterObjectUrl.value) {
URL.revokeObjectURL(posterObjectUrl.value)
}
})
</script>
<style scoped>

View File

@ -15,6 +15,13 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="generationMode" label="生成方式" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.generationMode === 'ai' ? 'primary' : 'warning'">
{{ row.generationMode === 'ai' ? 'AI 生图' : '降级' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="exportSize" label="尺寸" width="100" />
<el-table-column label="操作" width="180">
<template #default="{ row }">
@ -30,10 +37,11 @@
<el-dialog v-model="detailVisible" title="海报详情" width="600px">
<div v-if="detailRecord">
<el-image v-if="detailRecord.exportUrl" :src="`/insurance/poster/download/${detailRecord.id}`"
fit="contain" style="width: 100%; max-height: 300px; margin-bottom: 16px" />
<img v-if="detailPreviewUrl" :src="detailPreviewUrl"
style="width: 100%; max-height: 300px; object-fit: contain; margin-bottom: 16px; border-radius: 8px" />
<el-descriptions :column="1" border>
<el-descriptions-item label="文案模式">{{ detailRecord.copyMode }}</el-descriptions-item>
<el-descriptions-item label="生成方式">{{ detailRecord.generationMode === 'ai' ? 'AI 生图' : '降级方案' }}</el-descriptions-item>
<el-descriptions-item label="文案内容">
<pre v-if="detailRecord.copyContent" style="font-size: 12px">{{ JSON.stringify(detailRecord.copyContent, null, 2) }}</pre>
<span v-else>-</span>
@ -47,7 +55,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { posterApi } from '@/utils/poster-api'
const list = ref<any[]>([])
@ -58,6 +67,7 @@ const loading = ref(false)
const detailVisible = ref(false)
const detailRecord = ref<any>(null)
const detailPreviewUrl = ref<string | null>(null)
onMounted(() => loadData())
@ -65,22 +75,50 @@ async function loadData() {
loading.value = true
try {
const res: any = await posterApi.getRecords({ page: page.value, page_size: pageSize })
// Axios response.datares { code, data: { total, items } }
const data = res?.data ?? res
list.value = data?.data?.items ?? []
total.value = data?.data?.total ?? 0
list.value = data?.items ?? []
total.value = data?.total ?? 0
} finally {
loading.value = false
}
}
function showDetail(row: any) {
async function showDetail(row: any) {
detailRecord.value = row
detailVisible.value = true
//
if (detailPreviewUrl.value) {
URL.revokeObjectURL(detailPreviewUrl.value)
detailPreviewUrl.value = null
}
try {
const blob = await posterApi.downloadPoster(row.id)
detailPreviewUrl.value = URL.createObjectURL(blob)
} catch {
detailPreviewUrl.value = null
}
}
function onDownload(row: any) {
window.open(posterApi.getDownloadUrl(row.id), '_blank')
async function onDownload(row: any) {
try {
const blob = await posterApi.downloadPoster(row.id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `poster_${row.id}.png`
a.click()
URL.revokeObjectURL(url)
} catch (e: any) {
ElMessage.error('下载失败')
}
}
onBeforeUnmount(() => {
if (detailPreviewUrl.value) {
URL.revokeObjectURL(detailPreviewUrl.value)
}
})
</script>
<style scoped>

View File

@ -37,6 +37,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { SuccessFilled, Download } from '@element-plus/icons-vue'
import { pptApi } from '@/utils/ppt-api'
@ -81,8 +82,7 @@ async function downloadPpt() {
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('下载失败:', error)
const url = pptApi.getDownloadUrl(props.sessionId)
window.open(url, '_blank')
ElMessage.error('PPT 下载失败,请重试')
} finally {
setTimeout(() => { downloading.value = false }, 500)
}

View File

@ -49,9 +49,9 @@ export const posterApi = {
return api.post('/poster/generate', params)
},
/** 下载海报 */
getDownloadUrl(recordId: number) {
return `/insurance/poster/download/${recordId}`
/** 下载海报(鉴权 Blob用于预览和下载 */
downloadPoster(recordId: number): Promise<Blob> {
return api.get(`/poster/download/${recordId}`, { responseType: 'blob' })
},
/** 海报记录列表 */