diff --git a/yunke-server/app/api/routers/export.py b/yunke-server/app/api/routers/export.py index 7ff4488..b33534b 100644 --- a/yunke-server/app/api/routers/export.py +++ b/yunke-server/app/api/routers/export.py @@ -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'
⚠️ 结果超过 {EXPORT_MAX_ROWS} 条,' + f"仅导出前 {EXPORT_MAX_ROWS} 条。请缩小日期范围。
" + 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"' + ) + }, + ) diff --git a/yunke-server/app/api/schemas.py b/yunke-server/app/api/schemas.py index e4cc39e..a44c288 100644 --- a/yunke-server/app/api/schemas.py +++ b/yunke-server/app/api/schemas.py @@ -39,6 +39,10 @@ class CustomerOut(BaseModel): last_chat_time: int | None = None add_time: int | None = None message_count: int | None = None + # 累计发送/接收条数(同步自云客 friend 记录的 sendNum/receiveNum;员工 + # 客户页「发送条数/接收条数」列用,2026-09-08) + send_num: int | None = None + receive_num: int | None = None version: str | None = None # Verbatim upstream friend record (~60 camelCase fields). Surfaced so the # all-fields drawer can render every column the 云客 source page shows diff --git a/yunke-server/tests/test_api_export.py b/yunke-server/tests/test_api_export.py index fb005cf..b8770e7 100644 --- a/yunke-server/tests/test_api_export.py +++ b/yunke-server/tests/test_api_export.py @@ -1,16 +1,19 @@ -"""Tests for GET /api/export/chat-html — 导出 1:1/群聊天为自包含 HTML。 +"""Tests for GET /api/export/chat-html(+zip) — 导出 1:1/群聊天为自包含 HTML。 对齐云客微信客户/群列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id) 拉消息(可选 start/end 时间范围),渲染为左右气泡 HTML 作为 attachment 返回下载。 +zip 版:员工客户页勾选多个客户批量「导出聊天记录」(2026-09-08)。 """ from __future__ import annotations import functools +import io +import zipfile from sqlalchemy.ext.asyncio import AsyncSession -from app.api.routers.export import EXPORT_MAX_ROWS -from app.db.models import Message +from app.api.routers.export import EXPORT_MAX_ROWS, ZIP_MAX_CONVS +from app.db.models import Customer, Message async def _seed(session: AsyncSession) -> None: @@ -160,3 +163,91 @@ async def test_export_group_ignores_owner_mismatch(make_client): assert r.status_code == 200 assert "群消息1" in r.text assert "群消息2" in r.text + + +# --------------------------------------------------------------------------- +# GET /api/export/chat-html-zip — 批量导出(2026-09-08 员工客户页) +# --------------------------------------------------------------------------- + +async def _seed_zip(session: AsyncSession) -> None: + """两个客户各几条 DM + 一条同 talker 的群消息(不应混入 DM zip)。""" + session.add_all([ + Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f1", + friend_nickname="小蜜", friend_remark="蜜姐"), + Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f2", + friend_nickname="老王"), + Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f3", + friend_nickname='坏/名字<>:"|'), + Message(msg_id="z1", channel="wechat", owner_account_id="wx_owner", + talker_id="f1", room_id=None, text="f1你好", timestamp=1000, + is_send=False, type="text", type_raw=1), + Message(msg_id="z2", channel="wechat", owner_account_id="wx_owner", + talker_id="f2", room_id=None, text="f2在吗", timestamp=2000, + is_send=True, type="text", type_raw=1), + Message(msg_id="z3", channel="wechat", owner_account_id="wx_owner", + talker_id="f2", room_id=None, text="f2更早", timestamp=500, + is_send=True, type="text", type_raw=1), + # f1 名下的群消息:room_id 有值,zip 的 DM 查询必须排除 + Message(msg_id="z4", channel="wechat", owner_account_id="wx_owner", + talker_id="f1", room_id="g@chatroom", text="群内消息不进zip", + timestamp=3000, is_send=True, type="text", type_raw=1), + ]) + await session.commit() + + +def _zip_entries(r) -> dict[str, str]: + assert r.headers["content-type"].startswith("application/zip") + with zipfile.ZipFile(io.BytesIO(r.content)) as zf: + return {name: zf.read(name).decode("utf-8") for name in zf.namelist()} + + +async def test_zip_two_conversations_with_display_names(make_client): + """zip 含两个会话 HTML;文件名取备注>昵称;群消息不混入。""" + client = await make_client(_seed_zip) + r = await client.get("/api/export/chat-html-zip", params={ + "channel": "wechat", "owner_account_id": "wx_owner", + "talker_ids": "f1,f2"}) + assert r.status_code == 200 + assert "attachment" in r.headers["content-disposition"] + entries = _zip_entries(r) + assert sorted(entries) == ["01_蜜姐.html", "02_老王.html"] + assert "f1你好" in entries["01_蜜姐.html"] + assert "f2在吗" in entries["02_老王.html"] + # DM 查询排除 room 消息 + assert "群内消息不进zip" not in "".join(entries.values()) + + +async def test_zip_time_range_filter(make_client): + """start/end 同样作用于 zip 内每个会话。""" + client = await make_client(_seed_zip) + r = await client.get("/api/export/chat-html-zip", params={ + "channel": "wechat", "owner_account_id": "wx_owner", + "talker_ids": "f2", "start": 1000}) + assert r.status_code == 200 + entries = _zip_entries(r) + assert "f2在吗" in entries["01_老王.html"] + assert "f2更早" not in entries["01_老王.html"] + + +async def test_zip_filename_sanitized_and_fallback(make_client): + """非法字符客户名清洗;无 customer 行的 talker 回退 friend_id。""" + client = await make_client(_seed_zip) + r = await client.get("/api/export/chat-html-zip", params={ + "channel": "wechat", "owner_account_id": "wx_owner", + "talker_ids": "f3,f_unknown"}) + assert r.status_code == 200 + names = sorted(_zip_entries(r)) + assert names[0].startswith("01_") and "/" not in names[0] and "<" not in names[0] + assert names[1] == "02_f_unknown.html" + + +async def test_zip_rejects_empty_and_over_limit(make_client): + """空列表/超上限 422,防误传。""" + client = await make_client(_seed_zip) + r_empty = await client.get("/api/export/chat-html-zip", params={ + "channel": "wechat", "owner_account_id": "wx_owner", "talker_ids": " , "}) + assert r_empty.status_code == 422 + many = ",".join(f"t{i}" for i in range(ZIP_MAX_CONVS + 1)) + r_many = await client.get("/api/export/chat-html-zip", params={ + "channel": "wechat", "owner_account_id": "wx_owner", "talker_ids": many}) + assert r_many.status_code == 422 diff --git a/yunkes/src/api/types.ts b/yunkes/src/api/types.ts index 830e047..df2da78 100644 --- a/yunkes/src/api/types.ts +++ b/yunkes/src/api/types.ts @@ -33,6 +33,9 @@ export interface Customer { /** 今日发送/接收条数(本地消息聚合,对齐云客今日口径)。 */ send_today?: number | null receive_today?: number | null + /** 累计发送/接收条数(同步自云客 friend 记录 sendNum/receiveNum;员工客户页列)。 */ + send_num?: number | null + receive_num?: number | null /** ms epoch of friend add time (from raw_json.addTime). */ add_time?: number | null message_count?: number | null diff --git a/yunkes/src/router/index.ts b/yunkes/src/router/index.ts index 4c8d631..0d98335 100644 --- a/yunkes/src/router/index.ts +++ b/yunkes/src/router/index.ts @@ -117,6 +117,15 @@ const routes: RouteRecordRaw[] = [ props: true, meta: { title: '员工聊天窗口' }, }, + { + // 员工客户列表(2026-09-08 对齐云客员工详情):员工列表「好友数/微信群数」 + // 账号维度下钻,与全局 /customers 分离; ?name= 直带员工显示名(直链自动解析) + path: '/staff-friends/:ownerId/:tab(customers|groups)?', + name: 'staff-friends', + component: () => import('@/views/StaffFriends.vue'), + props: true, + meta: { title: '员工客户列表' }, + }, { // 登录页(public:未登录可进;守卫据此放行) path: '/login', diff --git a/yunkes/src/views/StaffFriends.vue b/yunkes/src/views/StaffFriends.vue new file mode 100644 index 0000000..7dcccf6 --- /dev/null +++ b/yunkes/src/views/StaffFriends.vue @@ -0,0 +1,587 @@ + + + + + diff --git a/yunkes/src/views/StaffList.vue b/yunkes/src/views/StaffList.vue index 3f6c302..9065e8f 100644 --- a/yunkes/src/views/StaffList.vue +++ b/yunkes/src/views/StaffList.vue @@ -239,6 +239,16 @@ function waSerial(index: number): number { // --------------------------------------------------------------------------- function viewCustomers(row: Account): void { + // 微信行 → 员工客户列表独立页(2026-09-08 对齐云客员工详情,账号维度 + // 下钻:两 tab/搜索/批量导出);WA 行保持全局客户列表(该页暂仅微信)。 + if (row.channel === 'wechat') { + router.push({ + name: 'staff-friends', + params: { ownerId: row.account_id, tab: 'customers' }, + query: { name: row.nickname || row.user_name || '' }, + }) + return + } channelStore.setChannel(row.channel) router.push({ path: '/customers', @@ -247,6 +257,15 @@ function viewCustomers(row: Account): void { } function viewGroups(row: Account): void { + // 同上:微信群数 → 员工客户列表的群 tab;WA 保持原路径。 + if (row.channel === 'wechat') { + router.push({ + name: 'staff-friends', + params: { ownerId: row.account_id, tab: 'groups' }, + query: { name: row.nickname || row.user_name || '' }, + }) + return + } channelStore.setChannel(row.channel) if (row.channel === 'whatsapp') { // WA:对齐云客,群列表在客户列表页的子 tab(2026-08-24)