feat(staff-friends): 员工客户列表独立页——对齐云客员工详情,好友数/微信群数下钻
员工列表点「好友数」原先跳全局 /customers?owner_account_id=x,布局与全局
列表无异且计数/分页绑全局声明口径(同日 dfa4fdd 修的显示 bug),与云客
「员工名下客户列表」体验割裂。新页 /staff-friends/:ownerId/:tab:
- 微信客户/微信群两 tab(对齐云客截图);标题=员工显示名(入口直带,
直链经 /api/accounts 解析),返回箭头回员工列表
- 搜索(昵称/微信号/备注名/手机号,服务端 q)+ 查询/导出 CSV
- 发送/接收条数=Customer.send_num/receive_num 累计值(CustomerOut 补
两列,同步自云客 friend 记录,与截图同源)
- 勾选批量「导出聊天记录」:新端点 GET /api/export/chat-html-zip,复用
chat-html 渲染器每会话一个 HTML 打 zip(文件名=备注>昵称>friend_id,
非法字符清洗;单会话 5000 行截断告警;一次上限 50 会话防误选打爆)
- 行内:查看聊天记录→staff-chat;导出HTML聊天记录(时间范围弹窗)
- WA 行保持原入口(该页暂仅微信)
后端 605 测试绿(zip 新 4 例);本地浏览器端到端验证:过滤 9 条/勾选
激活/群 tab/返回箭头;zip 产物 application/zip 双会话双气泡。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,25 @@
|
||||
"""GET /api/export/chat-html — 导出一段 1:1 / 群聊天为独立 HTML 文件。
|
||||
GET /api/export/chat-html-zip — 勾选的多个 1:1 会话打成一个 zip(每会话一个 HTML)。
|
||||
|
||||
对齐云客微信客户列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
|
||||
拉消息(可选 start/end 时间范围),渲染自包含 HTML 聊天页(is_send 决定左右气泡),
|
||||
作为 attachment 返回下载。
|
||||
作为 attachment 返回下载。zip 版对齐员工客户页的批量「导出聊天记录」(2026-09-08)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from html import escape
|
||||
from typing import Annotated
|
||||
|
||||
import sqlalchemy as sa
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.db.models import Message
|
||||
from app.db.models import Customer, Message
|
||||
|
||||
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||
|
||||
@@ -170,3 +174,92 @@ async def export_chat_html(
|
||||
media_type="text/html; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# 员工客户页批量「导出聊天记录」:一次勾选的会话数上限(防误点全选把服务端打爆;
|
||||
# 50 个会话 × 每会话 EXPORT_MAX_ROWS 已是内存上界内的最坏情况)。
|
||||
ZIP_MAX_CONVS = 50
|
||||
|
||||
|
||||
def _safe_zip_name(name: str, fallback: str) -> str:
|
||||
"""会话显示名 → zip 内文件名:去路径/Windows 非法字符,限长,空回退。"""
|
||||
cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip("._ ")
|
||||
return cleaned[:40] or fallback
|
||||
|
||||
|
||||
@router.get("/chat-html-zip")
|
||||
async def export_chat_html_zip(
|
||||
session: SessionDep,
|
||||
channel: Annotated[str, Query()],
|
||||
owner_account_id: Annotated[str, Query()],
|
||||
talker_ids: Annotated[str, Query()] = "",
|
||||
start: Annotated[int | None, Query(ge=0)] = None,
|
||||
end: Annotated[int | None, Query(ge=0)] = None,
|
||||
) -> Response:
|
||||
"""勾选客户的 1:1 聊天批量导出为一个 zip(每会话一个 HTML,复用单导出渲染器)。
|
||||
|
||||
``talker_ids`` 逗号分隔的 friend_id 列表;文件名取客户备注/昵称(缺省回退
|
||||
friend_id),加序号前缀保唯一;单会话行数沿用 EXPORT_MAX_ROWS 截断告警。
|
||||
"""
|
||||
ids = [t.strip() for t in talker_ids.split(",") if t.strip()]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=422, detail="talker_ids 不能为空")
|
||||
if len(ids) > ZIP_MAX_CONVS:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"一次最多导出 {ZIP_MAX_CONVS} 个会话"
|
||||
)
|
||||
|
||||
# 文件名显示名:批量取一次 Customer(备注 > 昵称 > friend_id)
|
||||
cust_rows = (await session.execute(
|
||||
sa.select(
|
||||
Customer.friend_id,
|
||||
Customer.friend_nickname,
|
||||
Customer.friend_remark,
|
||||
).where(
|
||||
Customer.channel == channel,
|
||||
Customer.owner_account_id == owner_account_id,
|
||||
Customer.friend_id.in_(ids),
|
||||
)
|
||||
)).all()
|
||||
names = {
|
||||
r.friend_id: (r.friend_remark or r.friend_nickname or r.friend_id)
|
||||
for r in cust_rows
|
||||
}
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for i, tid in enumerate(ids, 1):
|
||||
q = sa.select(Message).where(
|
||||
Message.channel == channel,
|
||||
Message.owner_account_id == owner_account_id,
|
||||
Message.talker_id == tid,
|
||||
# DM:限定 room_id 为空,避免误捞群消息(同单导出)
|
||||
(Message.room_id == None) | (Message.room_id == ""), # noqa: E711
|
||||
)
|
||||
if start is not None:
|
||||
q = q.where(Message.timestamp >= start)
|
||||
if end is not None:
|
||||
q = q.where(Message.timestamp <= end)
|
||||
q = q.order_by(
|
||||
sa.nullslast(Message.timestamp.asc()), Message.msg_id.asc()
|
||||
).limit(EXPORT_MAX_ROWS)
|
||||
rows = list((await session.execute(q)).scalars().all())
|
||||
capped = len(rows) >= EXPORT_MAX_ROWS
|
||||
warning = (
|
||||
f'<div class="export-warning">⚠️ 结果超过 {EXPORT_MAX_ROWS} 条,'
|
||||
f"仅导出前 {EXPORT_MAX_ROWS} 条。请缩小日期范围。</div>"
|
||||
if capped
|
||||
else ""
|
||||
)
|
||||
html = _render_chat_html(rows, channel, owner_account_id, tid, warning=warning)
|
||||
safe = _safe_zip_name(names.get(tid) or tid, f"conv{i}")
|
||||
zf.writestr(f"{i:02d}_{safe}.html", html)
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": (
|
||||
f'attachment; filename="chat-records-{channel}-{owner_account_id}.zip"'
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user