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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ initials(row) }}
+
+
+ {{ row.friend_nickname || '--' }}
+ {{ row.friend_id }}
+
+
+
+
+
+ {{ fromTypeLabel(readField(row, 'fromType')) }}
+
+
+ {{ row.friend_remark || '-' }}
+
+
+ {{ row.last_chat_time ? formatTime(row.last_chat_time) : '-' }}
+
+
+ {{ row.send_num ?? '-' }}
+
+
+ {{ row.receive_num ?? '-' }}
+
+
+
+
+ 查看聊天记录
+ 导出HTML聊天记录
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ groupInitials(row) }}
+
+
+ {{ row.room_name || '--' }}
+ {{ row.room_id }}
+
+
+
+
+
+
+
+ {{ row.owner_name || '-' }}
+ {{ row.owner_wxid }}
+
+
+
+
+ {{ row.member_count ?? '-' }}
+
+
+ {{ row.message_count ?? '-' }}
+
+
+ {{ row.last_chat_time ? formatTime(row.last_chat_time) : '-' }}
+
+
+
+ 查看群聊记录
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 导出
+
+
+
+
+
+
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)