fix: wechat account scrape (form-data+dates) + frontend staff page
Backend (scrape_wechat_accounts): - Send wxUser/list as form-urlencoded (not JSON) with the REQUIRED filters isOnJob / departmentId / startdate / enddate + empty userId/salesname/ weChatNickName, matching the live request (empty fails with code -90000). - scrape_wechat_accounts now takes department_id (the company-root dept id) plus optional start/end defaulting to a recent 7-day UTC window. - paginate_by_page gains a form=True mode (data= + form content-type). - Orchestrator sync_wechat_accounts resolves the root dept from the Department table (or accepts one) and skips with a warning when unknown. - seed_accounts.py resolves the root via scrape_departments. Frontend (yunkes): - Add StaffList.vue (/staff) with is_on_job filter + pagination. - Add /staff route and a 员工 nav item between 客户 and 群. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,13 @@ over the response shape via three callables:
|
|||||||
* ``build_body(page_num, page_size, **extra) -> dict`` — build the request
|
* ``build_body(page_num, page_size, **extra) -> dict`` — build the request
|
||||||
body for a given page.
|
body for a given page.
|
||||||
|
|
||||||
|
By default the body is sent as JSON. A few yunke endpoints
|
||||||
|
(e.g. ``msg/chats``, ``wxUser/list``) bind ``@RequestParam`` from
|
||||||
|
**form-urlencoded** bodies instead — pass ``form=True`` to switch the paginator
|
||||||
|
to ``data=<body>`` with content-type
|
||||||
|
``application/x-www-form-urlencoded`` (mirroring the form-encoded
|
||||||
|
``msg/chats`` request the message scraper already makes directly).
|
||||||
|
|
||||||
``paginate_by_cursor`` is decoupled from the client entirely: it takes a
|
``paginate_by_cursor`` is decoupled from the client entirely: it takes a
|
||||||
single ``fetch(cursor)`` coroutine that performs whatever request the caller
|
single ``fetch(cursor)`` coroutine that performs whatever request the caller
|
||||||
needs and returns ``(rows, next_cursor, has_more)``. The cursor-paginator's
|
needs and returns ``(rows, next_cursor, has_more)``. The cursor-paginator's
|
||||||
@@ -45,7 +52,17 @@ if TYPE_CHECKING:
|
|||||||
# :class:`RateLimiter` at runtime without forming an import cycle.
|
# :class:`RateLimiter` at runtime without forming an import cycle.
|
||||||
from app.session.yunke_client import YunkeClient
|
from app.session.yunke_client import YunkeClient
|
||||||
|
|
||||||
__all__ = ["RateLimiter", "paginate_by_cursor", "paginate_by_page"]
|
__all__ = [
|
||||||
|
"FORM_CONTENT_TYPE",
|
||||||
|
"RateLimiter",
|
||||||
|
"paginate_by_cursor",
|
||||||
|
"paginate_by_page",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Content type for form-encoded bodies. Several yunke endpoints (msg/chats,
|
||||||
|
# wxUser/list) bind @RequestParam from form data rather than JSON, so the
|
||||||
|
# paginator + the manual message fetcher both send this content-type.
|
||||||
|
FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
# Informal type aliases for the response-handling callables. The yunke
|
# Informal type aliases for the response-handling callables. The yunke
|
||||||
# response shapes are dict-shaped JSON and do not benefit from finer typing.
|
# response shapes are dict-shaped JSON and do not benefit from finer typing.
|
||||||
@@ -65,6 +82,7 @@ async def paginate_by_page(
|
|||||||
parse_rows: ParseRows,
|
parse_rows: ParseRows,
|
||||||
build_body: BuildBody,
|
build_body: BuildBody,
|
||||||
rate_limiter: RateLimiter | None = None,
|
rate_limiter: RateLimiter | None = None,
|
||||||
|
form: bool = False,
|
||||||
**extra: Any,
|
**extra: Any,
|
||||||
) -> AsyncIterator[dict[str, Any]]:
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
"""Walk a page-numbered list endpoint, yielding one dict per record.
|
"""Walk a page-numbered list endpoint, yielding one dict per record.
|
||||||
@@ -85,6 +103,15 @@ async def paginate_by_page(
|
|||||||
|
|
||||||
``rate_limiter`` is an optional :class:`RateLimiter`; when supplied, it is
|
``rate_limiter`` is an optional :class:`RateLimiter`; when supplied, it is
|
||||||
acquired before every upstream call.
|
acquired before every upstream call.
|
||||||
|
|
||||||
|
``form`` selects the wire encoding of ``build_body``'s output. The default
|
||||||
|
(``False``) sends the body as JSON (``client.post(path, json=body)``) —
|
||||||
|
correct for most yunke list endpoints. Pass ``True`` for endpoints that
|
||||||
|
bind ``@RequestParam`` from a form-urlencoded body (``msg/chats``,
|
||||||
|
``wxUser/list``): the paginator then sends
|
||||||
|
``client.post(path, data=body, headers={"content-type": ...})`` and lets
|
||||||
|
httpx URL-encode the dict. Either way ``build_body`` returns the same
|
||||||
|
plain dict of field names → values.
|
||||||
"""
|
"""
|
||||||
page_num = 1
|
page_num = 1
|
||||||
total: int | None = None
|
total: int | None = None
|
||||||
@@ -95,7 +122,14 @@ async def paginate_by_page(
|
|||||||
await rate_limiter.acquire()
|
await rate_limiter.acquire()
|
||||||
|
|
||||||
body = build_body(page_num, page_size, **extra)
|
body = build_body(page_num, page_size, **extra)
|
||||||
resp = await client.post(path, json=body)
|
if form:
|
||||||
|
resp = await client.post(
|
||||||
|
path,
|
||||||
|
data=body,
|
||||||
|
headers={"content-type": FORM_CONTENT_TYPE},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
resp = await client.post(path, json=body)
|
||||||
|
|
||||||
# Read the declared total once from the first response. The yunke
|
# Read the declared total once from the first response. The yunke
|
||||||
# envelopes keep it on every page, but reading it once is enough for
|
# envelopes keep it on every page, but reading it once is enough for
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ Endpoints covered today:
|
|||||||
``path`` is reconstructed from parent links (Task 2.0).
|
``path`` is reconstructed from parent links (Task 2.0).
|
||||||
* :func:`scrape_wechat_accounts` — wraps ``POST /wechatweb/wxUser/list`` and
|
* :func:`scrape_wechat_accounts` — wraps ``POST /wechatweb/wxUser/list`` and
|
||||||
yields normalized :class:`app.db.models.WechatAccount` dicts (plus the
|
yields normalized :class:`app.db.models.WechatAccount` dicts (plus the
|
||||||
embedded ``userId``/``userName`` staff pair). Page-number pagination
|
embedded ``userId``/``userName`` staff pair). The endpoint binds
|
||||||
(Task 2.1).
|
``@RequestParam`` from a **form-urlencoded** body (NOT JSON) and REQUIRES
|
||||||
|
``isOnJob`` / ``departmentId`` (the company-root dept id) / ``startdate`` /
|
||||||
|
``enddate`` or it answers ``code -90000`` "缺少..."; a recent ~7-day
|
||||||
|
window works (the date affects per-account stats, not which accounts are
|
||||||
|
returned). Page-number pagination (Task 2.1).
|
||||||
* :func:`scrape_wechat_groups` — wraps ``POST /wechatweb/chatroom/getChatroomList``
|
* :func:`scrape_wechat_groups` — wraps ``POST /wechatweb/chatroom/getChatroomList``
|
||||||
and yields normalized :class:`app.db.models.ChatGroup` dicts, parsing the
|
and yields normalized :class:`app.db.models.ChatGroup` dicts, parsing the
|
||||||
semicolon-joined ``memberIds`` into a ``member_ids_list`` so the
|
semicolon-joined ``memberIds`` into a ``member_ids_list`` so the
|
||||||
@@ -58,6 +62,7 @@ disambiguations where the source has two fields for one column:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -660,13 +665,57 @@ def _parse_wxuser_rows(resp: Any) -> list[dict[str, Any]]:
|
|||||||
return rows if isinstance(rows, list) else []
|
return rows if isinstance(rows, list) else []
|
||||||
|
|
||||||
|
|
||||||
def _build_wxuser_body(page_num: int, page_size: int) -> dict[str, Any]:
|
def _build_wxuser_body(
|
||||||
"""Build the ``wxUser/list`` request body for a given page.
|
page_num: int,
|
||||||
|
page_size: int,
|
||||||
|
*,
|
||||||
|
department_id: str,
|
||||||
|
is_on_job: str = "1",
|
||||||
|
start: str,
|
||||||
|
end: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the form-urlencoded ``wxUser/list`` body for a given page.
|
||||||
|
|
||||||
Unlike ``friend_list/es``, the ``wxUser/list`` body carries no
|
Phase-0 live probing established the real contract: the endpoint binds
|
||||||
staff/dept filter parameters in the live capture — just the page slice.
|
Spring ``@RequestParam`` from a form body (NOT JSON) and REQUIRES the
|
||||||
|
filters below or it answers ``code -90000`` ("缺少..."). Empty
|
||||||
|
``userId`` / ``salesname`` / ``weChatNickName`` are sent explicitly because
|
||||||
|
the real client does, and the upstream treats missing keys differently
|
||||||
|
from empty strings for some filters.
|
||||||
"""
|
"""
|
||||||
return {"pageNum": page_num, "pageSize": page_size}
|
return {
|
||||||
|
"pageNum": page_num,
|
||||||
|
"pageSize": page_size,
|
||||||
|
# REQUIRED — empty fails with code -90000. "1" => on-job accounts only
|
||||||
|
# (the 55 live rows). "0" would pull departed staff instead.
|
||||||
|
"isOnJob": is_on_job,
|
||||||
|
# REQUIRED — the company ROOT dept id (caller obtains it from
|
||||||
|
# scrape_departments). NOT hardcoded here.
|
||||||
|
"departmentId": department_id,
|
||||||
|
# REQUIRED — "缺少时间参数" if absent. YYYY-MM-DD strings. The window
|
||||||
|
# affects per-account stats, NOT which accounts are returned.
|
||||||
|
"startdate": start,
|
||||||
|
"enddate": end,
|
||||||
|
# Always-empty free-text filters (sent to mirror the real client).
|
||||||
|
"userId": "",
|
||||||
|
"salesname": "",
|
||||||
|
"weChatNickName": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _default_date_window(now: dt.datetime | None = None) -> tuple[str, str]:
|
||||||
|
"""A recent 7-day window as ``(start, end)`` ``YYYY-MM-DD`` strings.
|
||||||
|
|
||||||
|
The ``wxUser/list`` stats window is cosmetic (it affects per-account
|
||||||
|
counters, not which accounts are returned), so any recent window works.
|
||||||
|
We default to the last 7 days measured from ``now`` (UTC by default to
|
||||||
|
stay deterministic and match the rest of the scraper layer's naive-UTC
|
||||||
|
convention). Both bounds are inclusive calendar days.
|
||||||
|
"""
|
||||||
|
now = now or dt.datetime.now(dt.UTC)
|
||||||
|
end_date = now.date()
|
||||||
|
start_date = end_date - dt.timedelta(days=7)
|
||||||
|
return start_date.isoformat(), end_date.isoformat()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_wxuser_account(record: dict[str, Any]) -> dict[str, Any]:
|
def _normalize_wxuser_account(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
@@ -702,7 +751,11 @@ def _normalize_wxuser_account(record: dict[str, Any]) -> dict[str, Any]:
|
|||||||
async def scrape_wechat_accounts(
|
async def scrape_wechat_accounts(
|
||||||
client: YunkeClient,
|
client: YunkeClient,
|
||||||
*,
|
*,
|
||||||
|
department_id: str,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
|
is_on_job: str = "1",
|
||||||
|
start: str | None = None,
|
||||||
|
end: str | None = None,
|
||||||
) -> AsyncIterator[dict[str, Any]]:
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
"""Yield normalized WeChat staff-account records from ``wxUser/list``.
|
"""Yield normalized WeChat staff-account records from ``wxUser/list``.
|
||||||
|
|
||||||
@@ -711,6 +764,13 @@ async def scrape_wechat_accounts(
|
|||||||
mapping each record to the ``WechatAccount`` column shape plus a verbatim
|
mapping each record to the ``WechatAccount`` column shape plus a verbatim
|
||||||
``raw_json`` copy.
|
``raw_json`` copy.
|
||||||
|
|
||||||
|
The request is **form-urlencoded** (NOT JSON): the endpoint binds Spring
|
||||||
|
``@RequestParam`` from the form body and REQUIRES ``isOnJob`` /
|
||||||
|
``departmentId`` / ``startdate`` / ``enddate``. ``department_id`` is the
|
||||||
|
company-root dept id (the caller resolves it from
|
||||||
|
:func:`scrape_departments`); it is NOT hardcoded here so this scraper
|
||||||
|
stays account-agnostic.
|
||||||
|
|
||||||
The envelope differs from ``friend_list/es``: there is **no**
|
The envelope differs from ``friend_list/es``: there is **no**
|
||||||
``basicResult`` wrapper. The pagination fields sit directly under ``data``
|
``basicResult`` wrapper. The pagination fields sit directly under ``data``
|
||||||
(``data.data`` / ``data.total`` / ``data.pageNum`` / ``data.pageSize``).
|
(``data.data`` / ``data.total`` / ``data.pageNum`` / ``data.pageSize``).
|
||||||
@@ -720,10 +780,26 @@ async def scrape_wechat_accounts(
|
|||||||
client:
|
client:
|
||||||
Authenticated :class:`YunkeClient`. Auth headers / 401 re-login are
|
Authenticated :class:`YunkeClient`. Auth headers / 401 re-login are
|
||||||
handled by the client.
|
handled by the client.
|
||||||
|
department_id:
|
||||||
|
Company-root department id (REQUIRED upstream filter). The caller
|
||||||
|
obtains it from :func:`scrape_departments` (the root node whose
|
||||||
|
``parent_id`` is falsy).
|
||||||
page_size:
|
page_size:
|
||||||
Page size to request. Defaults to 50 (matches the live capture's
|
Page size to request. Defaults to 50 (matches the live capture's
|
||||||
observed page size for this endpoint).
|
observed page size for this endpoint).
|
||||||
|
is_on_job:
|
||||||
|
``"1"`` (default) for on-job staff, ``"0"`` for departed. Required
|
||||||
|
upstream filter — empty fails with ``code -90000``.
|
||||||
|
start, end:
|
||||||
|
Optional ``YYYY-MM-DD`` bounds for the per-account stats window. The
|
||||||
|
date affects per-account counters, NOT which accounts are returned
|
||||||
|
(live total is 55 regardless), so any recent window works. Defaults
|
||||||
|
to a recent 7-day window computed from ``datetime.now(UTC)``.
|
||||||
"""
|
"""
|
||||||
|
default_start, default_end = _default_date_window()
|
||||||
|
window_start = start or default_start
|
||||||
|
window_end = end or default_end
|
||||||
|
|
||||||
async for record in paginate_by_page(
|
async for record in paginate_by_page(
|
||||||
client,
|
client,
|
||||||
WXUSER_LIST_PATH,
|
WXUSER_LIST_PATH,
|
||||||
@@ -731,6 +807,11 @@ async def scrape_wechat_accounts(
|
|||||||
parse_total=_parse_wxuser_total,
|
parse_total=_parse_wxuser_total,
|
||||||
parse_rows=_parse_wxuser_rows,
|
parse_rows=_parse_wxuser_rows,
|
||||||
build_body=_build_wxuser_body,
|
build_body=_build_wxuser_body,
|
||||||
|
form=True,
|
||||||
|
department_id=department_id,
|
||||||
|
is_on_job=is_on_job,
|
||||||
|
start=window_start,
|
||||||
|
end=window_end,
|
||||||
):
|
):
|
||||||
yield _normalize_wxuser_account(record)
|
yield _normalize_wxuser_account(record)
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,10 @@ import logging
|
|||||||
from collections.abc import AsyncIterator, Callable
|
from collections.abc import AsyncIterator, Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import SyncRun
|
from app.db.models import Department, SyncRun
|
||||||
from app.scraper.call import scrape_phone_calls, scrape_sms
|
from app.scraper.call import scrape_phone_calls, scrape_sms
|
||||||
from app.scraper.media import download_message_media
|
from app.scraper.media import download_message_media
|
||||||
from app.scraper.wechat import (
|
from app.scraper.wechat import (
|
||||||
@@ -385,16 +386,72 @@ async def sync_departments(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_root_department_id(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve the company-root department id from the mirrored ``Department`` table.
|
||||||
|
|
||||||
|
The ``wxUser/list`` form body REQUIRES ``departmentId`` (the company-root
|
||||||
|
dept id) — empty fails with ``code -90000``. The root is a node whose
|
||||||
|
``parent_id`` is falsy (``None`` / ``""`` / ``"0"`` — the yunke tree
|
||||||
|
convention). If several roots exist, the first by ``dept_id`` is returned
|
||||||
|
(with a warning) so a multi-root org still scrapes. Returns ``None`` when
|
||||||
|
no root is present so the caller can skip gracefully rather than send a
|
||||||
|
known-bad empty filter.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
sa.select(Department)
|
||||||
|
.where(
|
||||||
|
sa.or_(
|
||||||
|
Department.parent_id.is_(None),
|
||||||
|
Department.parent_id == "",
|
||||||
|
Department.parent_id == "0",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Department.dept_id.asc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
row = (await session.execute(stmt)).scalars().first()
|
||||||
|
return row.dept_id if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
async def sync_wechat_accounts(
|
async def sync_wechat_accounts(
|
||||||
session: AsyncSession, client: YunkeClient
|
session: AsyncSession,
|
||||||
|
client: YunkeClient,
|
||||||
|
*,
|
||||||
|
department_id: str | None = None,
|
||||||
) -> SyncResult:
|
) -> SyncResult:
|
||||||
"""Full re-scrape of all WeChat staff accounts (+ embedded staff rows)."""
|
"""Full re-scrape of all WeChat staff accounts (+ embedded staff rows).
|
||||||
|
|
||||||
|
``department_id`` is the company-root dept id required by the
|
||||||
|
``wxUser/list`` form body. When omitted, the orchestrator resolves it from
|
||||||
|
the mirrored ``Department`` table (a root node whose ``parent_id`` is
|
||||||
|
falsy) — which is why :func:`sync_departments` must run first. If no root
|
||||||
|
can be resolved, the scrape is SKIPPED with a logged warning (returns a
|
||||||
|
``status='skipped'`` :class:`SyncResult` and records no ``SyncRun``) so a
|
||||||
|
missing org tree does not abort a multi-entity batch.
|
||||||
|
"""
|
||||||
|
resolved = department_id
|
||||||
|
if resolved is None:
|
||||||
|
resolved = await _find_root_department_id(session)
|
||||||
|
if not resolved:
|
||||||
|
logger.warning(
|
||||||
|
"sync_wechat_accounts: no root department_id resolved"
|
||||||
|
" (sync departments first?); skipping wxUser/list scrape"
|
||||||
|
)
|
||||||
|
return SyncResult(
|
||||||
|
entity=ENTITY_WECHAT_ACCOUNT,
|
||||||
|
status="skipped",
|
||||||
|
fetched=0,
|
||||||
|
upserted=0,
|
||||||
|
error="no root department_id available",
|
||||||
|
)
|
||||||
return await _sync_full_rescrape(
|
return await _sync_full_rescrape(
|
||||||
session,
|
session,
|
||||||
entity_type=ENTITY_WECHAT_ACCOUNT,
|
entity_type=ENTITY_WECHAT_ACCOUNT,
|
||||||
channel=None,
|
channel=None,
|
||||||
scope_id=None,
|
scope_id=None,
|
||||||
scrape=lambda: scrape_wechat_accounts(client),
|
scrape=lambda: scrape_wechat_accounts(client, department_id=resolved),
|
||||||
upsert=upsert_wechat_account,
|
upsert=upsert_wechat_account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Seed WeChat + WhatsApp accounts (and their embedded staff) into smoke.db."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.db import models # noqa: F401
|
||||||
|
from app.scraper.wechat import scrape_departments, scrape_wechat_accounts
|
||||||
|
from app.scraper.whatsapp import scrape_whatsapp_accounts
|
||||||
|
from app.storage.repos import upsert_wechat_account, upsert_whatsapp_account
|
||||||
|
from scripts.smoke_wechat import _push_settings_into_environ, build_client_from_env
|
||||||
|
|
||||||
|
|
||||||
|
async def _root_department_id(client) -> str | None:
|
||||||
|
"""First root department (parent_id falsy) from the live dept tree.
|
||||||
|
|
||||||
|
``wxUser/list`` REQUIRES the company-root ``departmentId`` filter; we pull
|
||||||
|
it from ``scrape_departments`` rather than hardcoding the live id.
|
||||||
|
"""
|
||||||
|
async for d in scrape_departments(client):
|
||||||
|
if not d.get("parent_id"):
|
||||||
|
return d.get("dept_id")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
_push_settings_into_environ()
|
||||||
|
client = build_client_from_env()
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///./smoke.db")
|
||||||
|
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
root_dept = await _root_department_id(client)
|
||||||
|
if not root_dept:
|
||||||
|
print("WARNING: no root department found; skipping wxUser/list scrape")
|
||||||
|
|
||||||
|
wx_n = wa_n = 0
|
||||||
|
async with sm() as session:
|
||||||
|
if root_dept:
|
||||||
|
async for rec in scrape_wechat_accounts(
|
||||||
|
client, department_id=root_dept, page_size=50
|
||||||
|
):
|
||||||
|
await upsert_wechat_account(session, rec)
|
||||||
|
wx_n += 1
|
||||||
|
async for rec in scrape_whatsapp_accounts(client, page_size=50):
|
||||||
|
await upsert_whatsapp_account(session, rec)
|
||||||
|
wa_n += 1
|
||||||
|
await session.commit()
|
||||||
|
print(f"upserted {wx_n} wechat accounts, {wa_n} whatsapp accounts (+ their staff)")
|
||||||
|
await client.aclose()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -5,8 +5,15 @@ data dictionary §2: ``wechatId, nickName, alias, phone, userId,
|
|||||||
userName(员工真名), contactsNum, groupNum, sendNum/receiveNum, isOnJob,
|
userName(员工真名), contactsNum, groupNum, sendNum/receiveNum, isOnJob,
|
||||||
dbSize, 在线状态, lastUpdate``; total=55).
|
dbSize, 在线状态, lastUpdate``; total=55).
|
||||||
|
|
||||||
The envelope differs from ``wxFriends/friend_list/es``: there is **no**
|
The REQUEST is **form-urlencoded** (NOT JSON): the endpoint binds Spring
|
||||||
``basicResult`` wrapper. The pagination fields sit directly under ``data``::
|
``@RequestParam`` from the form body and REQUIRES ``isOnJob`` /
|
||||||
|
``departmentId`` (the company-root dept id) / ``startdate`` / ``enddate`` or
|
||||||
|
it answers ``code -90000`` ("缺少..."). ``userId`` / ``salesname`` /
|
||||||
|
``weChatNickName`` are sent empty to mirror the real client.
|
||||||
|
|
||||||
|
The RESPONSE envelope differs from ``wxFriends/friend_list/es``: there is
|
||||||
|
**no** ``basicResult`` wrapper. The pagination fields sit directly under
|
||||||
|
``data``::
|
||||||
|
|
||||||
{"code":"10000","data":{"data":[...], "total":.., "pageNum":.., "pageSize":..}}
|
{"code":"10000","data":{"data":[...], "total":.., "pageNum":.., "pageSize":..}}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import sqlalchemy as sa
|
|||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.db.models import SyncRun, SyncState
|
from app.db.models import Department, SyncRun, SyncState
|
||||||
from app.sync import orchestrator
|
from app.sync import orchestrator
|
||||||
from app.sync.watermarks import get_last_timestamp
|
from app.sync.watermarks import get_last_timestamp
|
||||||
|
|
||||||
@@ -477,7 +477,7 @@ async def test_sync_all_wechat_runs_each_entity_then_messages(monkeypatch):
|
|||||||
try:
|
try:
|
||||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
async def empty_async_gen(_client=None):
|
async def empty_async_gen(_client=None, **_kwargs):
|
||||||
if False: # pragma: no cover
|
if False: # pragma: no cover
|
||||||
yield {}
|
yield {}
|
||||||
|
|
||||||
@@ -496,6 +496,11 @@ async def test_sync_all_wechat_runs_each_entity_then_messages(monkeypatch):
|
|||||||
# orchestrator namespace) are never called. No need to patch them.
|
# orchestrator namespace) are never called. No need to patch them.
|
||||||
|
|
||||||
async with SessionLocal() as session:
|
async with SessionLocal() as session:
|
||||||
|
# sync_wechat_accounts resolves the company-root dept id required
|
||||||
|
# by the wxUser/list form body from the Department table, so seed
|
||||||
|
# a root (parent_id=None) before running the coordinator.
|
||||||
|
session.add(Department(dept_id="ROOT_DEPT", parent_id=None, name="Root"))
|
||||||
|
await session.flush()
|
||||||
results = await orchestrator.sync_all_wechat(
|
results = await orchestrator.sync_all_wechat(
|
||||||
session,
|
session,
|
||||||
client=object(),
|
client=object(),
|
||||||
@@ -520,6 +525,92 @@ async def test_sync_all_wechat_runs_each_entity_then_messages(monkeypatch):
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_wechat_accounts_skips_when_no_root_department(monkeypatch):
|
||||||
|
"""``wxUser/list`` REQUIRES a ``departmentId`` filter. When the org tree is
|
||||||
|
empty (no root resolvable from the Department table) the orchestrator must
|
||||||
|
NOT send a known-bad empty filter; it skips with a logged warning and
|
||||||
|
returns a ``status='skipped'`` result without ever calling the scraper."""
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
called = False
|
||||||
|
|
||||||
|
async def fake_scrape_accounts(client, *, department_id, **_kwargs):
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
if False: # pragma: no cover
|
||||||
|
yield {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
orchestrator, "scrape_wechat_accounts", fake_scrape_accounts
|
||||||
|
)
|
||||||
|
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
result = await orchestrator.sync_wechat_accounts(
|
||||||
|
session, client=object()
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
assert result.status == "skipped"
|
||||||
|
assert result.fetched == 0
|
||||||
|
assert result.upserted == 0
|
||||||
|
assert not called
|
||||||
|
|
||||||
|
# No SyncRun recorded for a skip (it is not a success or a failure).
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(SyncRun).where(
|
||||||
|
SyncRun.entity_type == "wechat_account"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
assert rows == []
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_wechat_accounts_passes_resolved_root_to_scraper(monkeypatch):
|
||||||
|
"""When a root department exists in the DB, the orchestrator resolves its
|
||||||
|
id and passes it as ``department_id`` to the scraper."""
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
async def fake_scrape_accounts(client, *, department_id, **_kwargs):
|
||||||
|
captured["department_id"] = department_id
|
||||||
|
if False: # pragma: no cover
|
||||||
|
yield {}
|
||||||
|
|
||||||
|
async def fake_upsert(session, rec):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
orchestrator, "scrape_wechat_accounts", fake_scrape_accounts
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(orchestrator, "upsert_wechat_account", fake_upsert)
|
||||||
|
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
session.add(
|
||||||
|
Department(
|
||||||
|
dept_id="ROOT_DEPT", parent_id=None, name="Root"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
result = await orchestrator.sync_wechat_accounts(
|
||||||
|
session, client=object()
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
assert result.status == "success"
|
||||||
|
assert captured["department_id"] == "ROOT_DEPT"
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Phone calls + SMS — full re-scrape entities (M4)
|
# Phone calls + SMS — full re-scrape entities (M4)
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|||||||
@@ -16,15 +16,20 @@ snake_case columns + ``raw_json``), make exactly 3 calls, and stop without a
|
|||||||
4th. The empty-page stop is the primary signal; ``total=4`` is only a
|
4th. The empty-page stop is the primary signal; ``total=4`` is only a
|
||||||
defensive over-fetch ceiling that must NOT trigger early (we yield exactly
|
defensive over-fetch ceiling that must NOT trigger early (we yield exactly
|
||||||
4 == total, so the strict-``>`` ceiling never fires here).
|
4 == total, so the strict-``>`` ceiling never fires here).
|
||||||
|
|
||||||
|
The REQUEST body is form-urlencoded (NOT JSON) and must carry the required
|
||||||
|
``isOnJob`` / ``departmentId`` / ``startdate`` / ``enddate`` filters; the
|
||||||
|
"send pagination body" test asserts those keys land on the wire.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json as _json
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import respx
|
import respx
|
||||||
|
|
||||||
|
from app.scraper.base import FORM_CONTENT_TYPE
|
||||||
from app.scraper.wechat import scrape_wechat_accounts
|
from app.scraper.wechat import scrape_wechat_accounts
|
||||||
from app.session.yunke_client import YunkeClient
|
from app.session.yunke_client import YunkeClient
|
||||||
from tests.cassettes.wxUser_fixture import (
|
from tests.cassettes.wxUser_fixture import (
|
||||||
@@ -40,12 +45,30 @@ WXUSER_LIST_URL = f"{BASE}/wechatweb/wxUser/list"
|
|||||||
USERID = "a" * 32
|
USERID = "a" * 32
|
||||||
COMPANY = "8m0atd"
|
COMPANY = "8m0atd"
|
||||||
|
|
||||||
|
# The company-root department id used as the required ``departmentId`` filter.
|
||||||
|
# Mirrors the shape of the real root dept id (32 hex chars) without hardcoding
|
||||||
|
# the live value in the scraper itself.
|
||||||
|
ROOT_DEPARTMENT_ID = "0E9C4B803E134C7687CBA824886319DA"
|
||||||
|
|
||||||
|
|
||||||
def _primed_client() -> YunkeClient:
|
def _primed_client() -> YunkeClient:
|
||||||
"""A YunkeClient already in a logged-in state (login flow skipped)."""
|
"""A YunkeClient already in a logged-in state (login flow skipped)."""
|
||||||
return YunkeClient(base_url=BASE, userid=USERID, company=COMPANY)
|
return YunkeClient(base_url=BASE, userid=USERID, company=COMPANY)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_form(request: httpx.Request) -> dict[str, str]:
|
||||||
|
"""Decode a form-urlencoded request body into a flat ``{key: value}`` dict.
|
||||||
|
|
||||||
|
``parse_qs`` returns lists per key; the wxUser body has no repeated keys,
|
||||||
|
so we collapse each to its single value for ergonomic assertions.
|
||||||
|
``keep_blank_values=True`` so the always-empty ``userId`` / ``salesname``
|
||||||
|
filters (sent as ``userId=``) survive decoding and can be asserted on.
|
||||||
|
"""
|
||||||
|
body = request.content.decode()
|
||||||
|
parsed = parse_qs(body, keep_blank_values=True)
|
||||||
|
return {k: v[0] for k, v in parsed.items()}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# End-to-end: pagination + field mapping + raw_json
|
# End-to-end: pagination + field mapping + raw_json
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -77,7 +100,9 @@ async def test_scrape_wechat_accounts_yields_records_and_paginates():
|
|||||||
results = [
|
results = [
|
||||||
r
|
r
|
||||||
async for r in scrape_wechat_accounts(
|
async for r in scrape_wechat_accounts(
|
||||||
client, page_size=DEFAULT_PAGE_SIZE
|
client,
|
||||||
|
department_id=ROOT_DEPARTMENT_ID,
|
||||||
|
page_size=DEFAULT_PAGE_SIZE,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
finally:
|
finally:
|
||||||
@@ -86,6 +111,16 @@ async def test_scrape_wechat_accounts_yields_records_and_paginates():
|
|||||||
# Pagination: exactly 3 calls (page1, page2, empty-page stop).
|
# Pagination: exactly 3 calls (page1, page2, empty-page stop).
|
||||||
assert route.call_count == 3
|
assert route.call_count == 3
|
||||||
|
|
||||||
|
# The required form filters ride on every request (the live endpoint
|
||||||
|
# answers code -90000 without them).
|
||||||
|
for call in route.calls:
|
||||||
|
body = _parse_form(call.request)
|
||||||
|
assert body["departmentId"] == ROOT_DEPARTMENT_ID
|
||||||
|
assert body["isOnJob"] == "1"
|
||||||
|
assert body["startdate"]
|
||||||
|
assert body["enddate"]
|
||||||
|
assert call.request.headers["content-type"] == FORM_CONTENT_TYPE
|
||||||
|
|
||||||
# Exactly the 4 fixture records (not duplicated, not dropped).
|
# Exactly the 4 fixture records (not duplicated, not dropped).
|
||||||
assert len(results) == 4
|
assert len(results) == 4
|
||||||
assert {r["wx_id"] for r in results} == {
|
assert {r["wx_id"] for r in results} == {
|
||||||
@@ -132,7 +167,8 @@ async def test_scrape_wechat_accounts_yields_records_and_paginates():
|
|||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_scrape_wechat_accounts_sends_pagination_body():
|
async def test_scrape_wechat_accounts_sends_pagination_body():
|
||||||
"""Outgoing request body carries pageNum/pageSize; advances per page."""
|
"""Outgoing form body carries pageNum/pageSize + the required filters; the
|
||||||
|
page number advances per request."""
|
||||||
page1 = make_wxuser_list_response(
|
page1 = make_wxuser_list_response(
|
||||||
WXUSER_RECORDS[:1], total=TOTAL, page_num=1, page_size=1
|
WXUSER_RECORDS[:1], total=TOTAL, page_num=1, page_size=1
|
||||||
)
|
)
|
||||||
@@ -149,7 +185,14 @@ async def test_scrape_wechat_accounts_sends_pagination_body():
|
|||||||
client = _primed_client()
|
client = _primed_client()
|
||||||
try:
|
try:
|
||||||
results = [
|
results = [
|
||||||
r async for r in scrape_wechat_accounts(client, page_size=1)
|
r
|
||||||
|
async for r in scrape_wechat_accounts(
|
||||||
|
client,
|
||||||
|
department_id=ROOT_DEPARTMENT_ID,
|
||||||
|
page_size=1,
|
||||||
|
start="2026-07-01",
|
||||||
|
end="2026-07-08",
|
||||||
|
)
|
||||||
]
|
]
|
||||||
finally:
|
finally:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
@@ -158,15 +201,28 @@ async def test_scrape_wechat_accounts_sends_pagination_body():
|
|||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert route.call_count == 2
|
assert route.call_count == 2
|
||||||
|
|
||||||
# First request: pageNum=1.
|
# Every request is form-urlencoded (NOT JSON) and carries the required
|
||||||
body1 = _json.loads(route.calls[0].request.content.decode())
|
# filters the live endpoint demands (isOnJob / departmentId / startdate /
|
||||||
assert body1["pageNum"] == 1
|
# enddate), plus the always-empty free-text filters from the real client.
|
||||||
assert body1["pageSize"] == 1
|
for call in route.calls:
|
||||||
|
assert call.request.headers["content-type"] == FORM_CONTENT_TYPE
|
||||||
|
body = _parse_form(call.request)
|
||||||
|
assert body["isOnJob"] == "1"
|
||||||
|
assert body["departmentId"] == ROOT_DEPARTMENT_ID
|
||||||
|
assert body["startdate"] == "2026-07-01"
|
||||||
|
assert body["enddate"] == "2026-07-08"
|
||||||
|
assert body["userId"] == ""
|
||||||
|
assert body["salesname"] == ""
|
||||||
|
assert body["weChatNickName"] == ""
|
||||||
|
|
||||||
# Second request: pageNum advanced to 2.
|
# pageNum/pageSize advance correctly across the form body.
|
||||||
body2 = _json.loads(route.calls[1].request.content.decode())
|
body1 = _parse_form(route.calls[0].request)
|
||||||
assert body2["pageNum"] == 2
|
assert body1["pageNum"] == "1"
|
||||||
assert body2["pageSize"] == 1
|
assert body1["pageSize"] == "1"
|
||||||
|
|
||||||
|
body2 = _parse_form(route.calls[1].request)
|
||||||
|
assert body2["pageNum"] == "2"
|
||||||
|
assert body2["pageSize"] == "1"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -184,7 +240,12 @@ async def test_scrape_wechat_accounts_stops_on_empty_first_page():
|
|||||||
|
|
||||||
client = _primed_client()
|
client = _primed_client()
|
||||||
try:
|
try:
|
||||||
results = [r async for r in scrape_wechat_accounts(client)]
|
results = [
|
||||||
|
r
|
||||||
|
async for r in scrape_wechat_accounts(
|
||||||
|
client, department_id=ROOT_DEPARTMENT_ID
|
||||||
|
)
|
||||||
|
]
|
||||||
finally:
|
finally:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ onMounted(async () => {
|
|||||||
<el-icon><ChatLineSquare /></el-icon>
|
<el-icon><ChatLineSquare /></el-icon>
|
||||||
<span>客户</span>
|
<span>客户</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/staff">
|
||||||
|
<el-icon><Avatar /></el-icon>
|
||||||
|
<span>员工</span>
|
||||||
|
</el-menu-item>
|
||||||
<el-menu-item index="/groups">
|
<el-menu-item index="/groups">
|
||||||
<el-icon><User /></el-icon>
|
<el-icon><User /></el-icon>
|
||||||
<span>群</span>
|
<span>群</span>
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/CustomerList.vue'),
|
component: () => import('@/views/CustomerList.vue'),
|
||||||
meta: { title: '客户列表' },
|
meta: { title: '客户列表' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/staff',
|
||||||
|
name: 'staff',
|
||||||
|
component: () => import('@/views/StaffList.vue'),
|
||||||
|
meta: { title: '员工列表' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/groups',
|
path: '/groups',
|
||||||
name: 'groups',
|
name: 'groups',
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getStaff } from '@/api'
|
||||||
|
import type { Staff } from '@/api/types'
|
||||||
|
import { useOrgStore } from '@/stores/org'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const org = useOrgStore()
|
||||||
|
|
||||||
|
const staff = ref<Staff[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref<string | null>(null)
|
||||||
|
|
||||||
|
// On-job filter: 'all' (no filter) / 'on' (is_on_job=true) / 'off' (false).
|
||||||
|
const onJobFilter = ref<'all' | 'on' | 'off'>('all')
|
||||||
|
|
||||||
|
const departmentId = computed<string | undefined>(() =>
|
||||||
|
route.query.department_id ? String(route.query.department_id) : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
const onJobOptions: { value: 'all' | 'on' | 'off'; label: string }[] = [
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'on', label: '在职' },
|
||||||
|
{ value: 'off', label: '离职' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function onJobTagType(onJob?: boolean | null): 'success' | 'info' | '' {
|
||||||
|
if (onJob === true) return 'success'
|
||||||
|
if (onJob === false) return 'info'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onJobLabel(onJob?: boolean | null): string {
|
||||||
|
if (onJob === true) return '在职'
|
||||||
|
if (onJob === false) return '离职'
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
function deptName(deptId?: string | null): string | null {
|
||||||
|
if (!deptId) return null
|
||||||
|
return org.findNode(deptId)?.label ?? deptId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = null
|
||||||
|
try {
|
||||||
|
const res = await getStaff({
|
||||||
|
department_id: departmentId.value,
|
||||||
|
is_on_job:
|
||||||
|
onJobFilter.value === 'all' ? undefined : onJobFilter.value === 'on',
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
})
|
||||||
|
staff.value = res.items
|
||||||
|
total.value = res.total
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = (e as Error).message
|
||||||
|
ElMessage.error(`员工列表加载失败:${(e as Error).message}`)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(p: number): void {
|
||||||
|
page.value = p
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageSizeChange(s: number): void {
|
||||||
|
pageSize.value = s
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// React to the on-job filter + any dept query param.
|
||||||
|
watch(
|
||||||
|
() => [onJobFilter.value, departmentId.value] as const,
|
||||||
|
() => {
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Dept names come from the org tree; best-effort load so the 部门 column
|
||||||
|
// can render names instead of bare ids. A load failure is non-fatal — the
|
||||||
|
// column falls back to the raw dept_id.
|
||||||
|
try {
|
||||||
|
await org.ensureLoaded()
|
||||||
|
} catch {
|
||||||
|
/* surfaced in the aside placeholder */
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="yk-staff">
|
||||||
|
<div class="yk-staff__header">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin: 0 0 4px; font-size: 16px">员工列表</h2>
|
||||||
|
<span class="yk-staff__sub">共 {{ total }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="yk-staff__filters">
|
||||||
|
<el-radio-group v-model="onJobFilter" size="small">
|
||||||
|
<el-radio-button
|
||||||
|
v-for="opt in onJobOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="errorMsg"
|
||||||
|
:title="errorMsg"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
style="margin-bottom: 12px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="staff"
|
||||||
|
stripe
|
||||||
|
border
|
||||||
|
row-key="user_id"
|
||||||
|
style="width: 100%"
|
||||||
|
empty-text="暂无员工数据"
|
||||||
|
>
|
||||||
|
<el-table-column label="姓名" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.name">{{ row.name }}</span>
|
||||||
|
<span v-else style="color: #c0c4cc">--</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="手机" min-width="130">
|
||||||
|
<template #default="{ row }">{{ row.phone || '--' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="sale_phone" min-width="130">
|
||||||
|
<template #default="{ row }">{{ row.sale_phone || '--' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="部门" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="deptName(row.dept_id)">{{ deptName(row.dept_id) }}</span>
|
||||||
|
<span v-else style="color: #c0c4cc">--</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="在职" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="onJobTagType(row.is_on_job)"
|
||||||
|
size="small"
|
||||||
|
effect="plain"
|
||||||
|
>
|
||||||
|
{{ onJobLabel(row.is_on_job) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="yk-staff__pager">
|
||||||
|
<el-pagination
|
||||||
|
:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handlePageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.yk-staff__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yk-staff__sub {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yk-staff__filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yk-staff__pager {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user