diff --git a/yunke-server/app/api/routers/export.py b/yunke-server/app/api/routers/export.py
index d34a9f6..33c9a97 100644
--- a/yunke-server/app/api/routers/export.py
+++ b/yunke-server/app/api/routers/export.py
@@ -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 '
📎 [本地媒体]
'
+
+ # Only embed full URLs; anything else (bare path, data URI, etc.) → note
+ if not (file_url.startswith("http://") or file_url.startswith("https://")):
+ return '📎 [本地媒体]
'
+
+ url = escape(file_url, quote=True)
+ if msg_type == "image":
+ return (
+ f''
+ )
+ if msg_type in ("voice", "audio"):
+ duration = getattr(m, "duration", None)
+ dur_label = f" ({duration}秒)" if duration else ""
+ return (
+ f''
+ )
+ if msg_type == "video":
+ return (
+ f''
+ )
+ if msg_type == "file":
+ return f''
+ 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''
- )
+ text_html = escape(getattr(m, "text", "") or "")
+ media_html = _render_media(m)
+ if text_html or media_html:
+ body = f'{text_html}
{media_html}'
+ else:
+ body = '(空消息)
'
+ bubbles.append(f'')
title = f"聊天记录 · {escape(channel)} · {escape(owner)} / {escape(peer)}"
return f"""
{title}{title}
{''.join(bubbles) or '暂无消息
'}
"""
diff --git a/yunke-server/tests/test_api_export.py b/yunke-server/tests/test_api_export.py
index 12291d3..f280b12 100644
--- a/yunke-server/tests/test_api_export.py
+++ b/yunke-server/tests/test_api_export.py
@@ -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 应直接嵌入
+ 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)直接
嵌入;本地 /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 →
with the URL escaped into src
+ assert "
+ assert "[本地媒体]" in body
+ assert "/api/media/abc123" not in body