feat(export): 导出 HTML 媒体内嵌(图片/语音/视频/附件, OSS URL 直接嵌入)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -31,15 +31,60 @@ def _ts_text(ts: int | None) -> str:
|
||||
return str(ts)
|
||||
|
||||
|
||||
def _render_media(m) -> str:
|
||||
"""Render media (image/voice/video/file) HTML for a message, or empty string.
|
||||
|
||||
OSS URLs (http/https) embed directly. Local /api/ paths can't resolve in a
|
||||
standalone HTML file (opened from disk, not served by backend) → show a note.
|
||||
"""
|
||||
msg_type = getattr(m, "type", None) or ""
|
||||
file_url = getattr(m, "file_url", None) or ""
|
||||
if not file_url:
|
||||
return ""
|
||||
|
||||
# Local backend path → not accessible from a downloaded HTML file
|
||||
if file_url.startswith("/api/"):
|
||||
return '<div class="media">📎 [本地媒体]</div>'
|
||||
|
||||
# Only embed full URLs; anything else (bare path, data URI, etc.) → note
|
||||
if not (file_url.startswith("http://") or file_url.startswith("https://")):
|
||||
return '<div class="media">📎 [本地媒体]</div>'
|
||||
|
||||
url = escape(file_url, quote=True)
|
||||
if msg_type == "image":
|
||||
return (
|
||||
f'<div class="media"><img src="{url}" alt="图片" loading="lazy"'
|
||||
f' style="max-width:100%;border-radius:4px;margin:4px 0"/></div>'
|
||||
)
|
||||
if msg_type in ("voice", "audio"):
|
||||
duration = getattr(m, "duration", None)
|
||||
dur_label = f" ({duration}秒)" if duration else ""
|
||||
return (
|
||||
f'<div class="media"><audio controls preload="none" src="{url}"'
|
||||
f' style="max-width:100%"></audio><span class="dur">{dur_label}</span></div>'
|
||||
)
|
||||
if msg_type == "video":
|
||||
return (
|
||||
f'<div class="media"><video controls preload="none" src="{url}"'
|
||||
f' style="max-width:100%;border-radius:4px"></video></div>'
|
||||
)
|
||||
if msg_type == "file":
|
||||
return f'<div class="media"><a href="{url}" target="_blank">📎 附件</a></div>'
|
||||
return ""
|
||||
|
||||
|
||||
def _render_chat_html(rows: list[Message], channel: str, owner: str, peer: str) -> str:
|
||||
bubbles = []
|
||||
for m in rows:
|
||||
side = "right" if m.is_send else "left"
|
||||
ts = escape(_ts_text(getattr(m, "timestamp", None)))
|
||||
text = escape(getattr(m, "text", "") or "")
|
||||
bubbles.append(
|
||||
f'<div class="bubble {side}"><div class="t">{ts}</div><div class="b">{text}</div></div>'
|
||||
)
|
||||
text_html = escape(getattr(m, "text", "") or "")
|
||||
media_html = _render_media(m)
|
||||
if text_html or media_html:
|
||||
body = f'<div class="b">{text_html}</div>{media_html}'
|
||||
else:
|
||||
body = '<div class="b">(空消息)</div>'
|
||||
bubbles.append(f'<div class="bubble {side}"><div class="t">{ts}</div>{body}</div>')
|
||||
title = f"聊天记录 · {escape(channel)} · {escape(owner)} / {escape(peer)}"
|
||||
return f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
||||
<title>{title}</title><style>
|
||||
@@ -50,6 +95,9 @@ h1{{font-size:16px;margin:0 0 12px}}
|
||||
.bubble.left{{float:left;background:#fff}}
|
||||
.bubble .t{{font-size:11px;color:#999;margin-bottom:2px}}
|
||||
.bubble .b{{font-size:14px;white-space:pre-wrap;word-break:break-word}}
|
||||
.bubble .media{{margin-top:4px;font-size:13px}}
|
||||
.bubble .media audio,.bubble .media video,.bubble .media img{{display:block;max-width:100%}}
|
||||
.bubble .media .dur{{font-size:12px;color:#666;margin-left:4px}}
|
||||
</style></head><body><h1>{title}</h1>
|
||||
{''.join(bubbles) or '<p>暂无消息</p>'}
|
||||
</body></html>"""
|
||||
|
||||
@@ -25,6 +25,16 @@ async def _seed(session: AsyncSession) -> None:
|
||||
Message(msg_id="g2", channel="wechat", owner_account_id="wx_owner",
|
||||
talker_id="room_topic", room_id="g1@chatroom", text="范围外",
|
||||
timestamp=9999999, is_send=True, type="text", type_raw=1),
|
||||
# 图文消息 — OSS URL 应直接嵌入 <img>
|
||||
Message(msg_id="dm3", channel="wechat", owner_account_id="wx_owner",
|
||||
talker_id="wx_friend", room_id=None, text=None,
|
||||
timestamp=2500, is_send=False, type="image", type_raw=3,
|
||||
file_url="https://oss.example.com/x/p1.jpg"),
|
||||
# 本地 /api/media 路径 — 应显示 "[本地媒体]" 占位
|
||||
Message(msg_id="dm4", channel="wechat", owner_account_id="wx_owner",
|
||||
talker_id="wx_friend", room_id=None, text=None,
|
||||
timestamp=2600, is_send=True, type="voice", type_raw=34,
|
||||
file_url="/api/media/abc123", duration=8),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
@@ -58,3 +68,18 @@ async def test_export_empty(make_client):
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("text/html")
|
||||
assert "暂无消息" in r.text
|
||||
|
||||
|
||||
async def test_export_embeds_media(make_client):
|
||||
"""图片(OSS URL)直接 <img> 嵌入;本地 /api/ 路径显示 [本地媒体] 占位。"""
|
||||
client = await make_client(_seed)
|
||||
r = await client.get("/api/export/chat-html", params={
|
||||
"channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "wx_friend"})
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
# OSS URL → <img> with the URL escaped into src
|
||||
assert "<img" in body
|
||||
assert "https://oss.example.com/x/p1.jpg" in body
|
||||
# 本地路径 → 占位提示,不嵌 <audio>
|
||||
assert "[本地媒体]" in body
|
||||
assert "/api/media/abc123" not in body
|
||||
|
||||
Reference in New Issue
Block a user