feat: full-field display for message/staff/accounts (raw_json + drawers)

Replicates the Customer all-fields pattern across the remaining three
entities. Backend now exposes `raw_json` on MessageOut / StaffOut /
AccountOut so the frontend can render every 云客 source field without a
second endpoint. Each entity gains a Chinese-label field utility + an
el-drawer surfacing all fields (structured-first, raw_json fallback,
unknown keys under 其他).

Backend:
- schemas.py: add `raw_json: dict | None` to MessageOut / StaffOut /
  AccountOut. MessageOut/StaffOut pick the column up via from_attributes;
  AccountOut is built explicitly in the router, which now threads
  `raw_json` through from both WechatAccount and WhatsappAccount rows.
- tests: new raw_json serialization tests for messages, staff, accounts
  (assert populated dict round-trips verbatim + NULL serializes as null).

Frontend (yunkes/):
- New utils: messageFields.ts, staffFields.ts, accountFields.ts — each
  with a 中文 label map, ordered groups, structured-column preference,
  and an 其他 fallback for unknown raw_json keys.
- Message: clicking a chat bubble opens an all-fields drawer (referMsg
  renders as pretty-printed JSON). Image preview + file download links
  stopPropagation so their own interactions still work.
- Staff: row click → drawer; columns relabeled (员工ID, 销售手机).
- Accounts: new AccountList.vue at /accounts with a 账号 nav item,
  unified WeChat/WhatsApp table (渠道/账号ID/昵称/微信号/归属员工/好友数/
  群数/收发/在职/最后更新), channel filter, pagination, row-click drawer.
- api: add Account type + getAccounts(); add raw_json to Message/Staff.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-01 17:47:50 +08:00
parent e5af1a2bc2
commit 6ac47a1203
16 changed files with 1562 additions and 13 deletions
@@ -111,6 +111,7 @@ async def list_accounts(
contacts_num=r.contacts_num,
group_num=r.group_num,
is_on_job=r.is_on_job,
raw_json=r.raw_json,
)
)
if "whatsapp" in selected_channels:
@@ -127,6 +128,7 @@ async def list_accounts(
contacts_num=r.contacts_num,
group_num=r.group_num,
is_on_job=r.is_on_job,
raw_json=r.raw_json,
)
)
+20 -3
View File
@@ -68,7 +68,12 @@ class DepartmentOut(BaseModel):
class StaffOut(BaseModel):
"""Staff member (employee) row."""
"""Staff member (employee) row.
The structured columns power the list table; the verbatim upstream record
is exposed as ``raw_json`` so the frontend can render the all-fields drawer
without a second endpoint. Picked up automatically via ``from_attributes``.
"""
model_config = ConfigDict(from_attributes=True)
@@ -78,6 +83,7 @@ class StaffOut(BaseModel):
sale_phone: str | None = None
dept_id: str | None = None
is_on_job: bool | None = None
raw_json: dict[str, Any] | None = None
class AccountOut(BaseModel):
@@ -87,7 +93,9 @@ class AccountOut(BaseModel):
have different PK / column names; this view projects both onto a single
shape keyed by ``channel`` + ``account_id`` so the frontend can render one
list. Built explicitly in the route handler (not via ``from_attributes``)
because no single ORM row carries this schema.
because no single ORM row carries this schema. ``raw_json`` carries the
verbatim upstream record (wechatId/alias/sendNum/receiveNum/dbSize/...) so
the all-fields drawer can render every column the 云客 微信号管理 page shows.
"""
channel: str
@@ -99,6 +107,7 @@ class AccountOut(BaseModel):
contacts_num: int | None = None
group_num: int | None = None
is_on_job: bool | None = None
raw_json: dict[str, Any] | None = None
# ---------------------------------------------------------------------------
@@ -107,7 +116,14 @@ class AccountOut(BaseModel):
class MessageOut(BaseModel):
"""Chat timeline row — a single message (text/media/voice/...)."""
"""Chat timeline row — a single message (text/media/voice/...).
The structured columns power the bubble rendering; the verbatim upstream
record is exposed as ``raw_json`` so the frontend can render the all-fields
drawer (msgSvrId/keyId, file/fileTh/fileHd, md5/md5Th/md5Hd, paymsgid,
referMsg, imgPath, ...) without a second endpoint. Picked up automatically
via ``from_attributes``.
"""
model_config = ConfigDict(from_attributes=True)
@@ -132,6 +148,7 @@ class MessageOut(BaseModel):
sender_name: str | None = None
sender_head_url: str | None = None
timestamp: int | None = None
raw_json: dict[str, Any] | None = None
class GroupOut(BaseModel):
+76
View File
@@ -79,10 +79,86 @@ async def test_default_page_returns_all_ordered_asc(make_client):
"sender_name",
"sender_head_url",
"timestamp",
"raw_json",
):
assert key in body["items"][0]
async def test_raw_json_serializes_verbatim(make_client):
"""``raw_json`` round-trips the verbatim upstream message record.
The seeded messages omit ``raw_json`` (NULL); this test seeds one with a
populated blob and asserts every camelCase key is preserved verbatim while
the existing rows still serialize as ``null``.
"""
async def seed(session: AsyncSession) -> None:
await _seed(session)
session.add(
Message(
msg_id="m_raw",
channel="wechat",
owner_account_id="wx_owner_1",
talker_id="t_a",
room_id=None,
type="image",
type_raw=3,
timestamp=5000,
raw_json={
"msgSvrId": "m_raw",
"type": 3,
"file": "https://oss/full.jpg",
"fileTh": "https://oss/th.jpg",
"fileHd": "https://oss/hd.jpg",
"md5": "abc123",
"md5Th": "th123",
"md5Hd": "hd123",
"duration": 0,
"talker": "t_a",
"oriTalker": "t_a",
"name": "Alice",
"headUrl": "https://oss/alice.jpg",
"wechatId": "wx_owner_1",
"roomid": "",
"isSend": 1,
"paymsgid": "",
"referMsg": {"text": "原文"},
},
)
)
await session.commit()
client = await make_client(seed)
r = await client.get("/api/messages", params={"talker_id": "t_a"})
assert r.status_code == 200
by_id = {it["msg_id"]: it for it in r.json()["items"]}
raw = by_id["m_raw"]["raw_json"]
assert raw == {
"msgSvrId": "m_raw",
"type": 3,
"file": "https://oss/full.jpg",
"fileTh": "https://oss/th.jpg",
"fileHd": "https://oss/hd.jpg",
"md5": "abc123",
"md5Th": "th123",
"md5Hd": "hd123",
"duration": 0,
"talker": "t_a",
"oriTalker": "t_a",
"name": "Alice",
"headUrl": "https://oss/alice.jpg",
"wechatId": "wx_owner_1",
"roomid": "",
"isSend": 1,
"paymsgid": "",
"referMsg": {"text": "原文"},
}
# Seeded messages without raw_json serialize as JSON null.
assert by_id["m1"]["raw_json"] is None
async def test_filter_by_talker_id(make_client):
"""``?talker_id=t_a`` -> 2 messages for that 1:1 conversation."""
client = await make_client(_seed)
+90 -1
View File
@@ -27,12 +27,21 @@ async def _seed(session: AsyncSession) -> None:
dept_id="d1",
is_on_job=True,
phone="111",
# Verbatim upstream staff record — the all-fields drawer reads
# these camelCase keys off raw_json.
raw_json={
"userId": "u1",
"userName": "Alice",
"sale_phone": "111",
"deptId": "d1",
},
),
Staff(
user_id="u2",
name="Bob",
dept_id="d2",
is_on_job=False,
raw_json=None,
),
Staff(user_id="u3", name="Carol", dept_id="d1", is_on_job=True),
WechatAccount(
@@ -44,6 +53,15 @@ async def _seed(session: AsyncSession) -> None:
contacts_num=10,
group_num=2,
is_on_job=True,
raw_json={
"wechatId": "wx_a",
"nickName": "WxOne",
"alias": "alice_wx",
"sendNum": 120,
"receiveNum": 80,
"dbSize": 2048,
"onlineStatus": "online",
},
),
WechatAccount(
wx_id="wx_b",
@@ -51,6 +69,7 @@ async def _seed(session: AsyncSession) -> None:
user_id="u2",
contacts_num=5,
is_on_job=False,
raw_json=None,
),
WhatsappAccount(
uid="wa_c@s.whatsapp.net",
@@ -61,6 +80,12 @@ async def _seed(session: AsyncSession) -> None:
contacts_num=7,
group_num=1,
is_on_job=True,
raw_json={
"uid": "wa_c@s.whatsapp.net",
"nickName": "WaOne",
"talkerCount": 7,
"friendAddNum": 3,
},
),
]
)
@@ -92,10 +117,39 @@ async def test_staff_default_page_returns_all(make_client):
assert body["page_size"] == 20
assert body["total"] == 3
assert len(body["items"]) == 3
for key in ("user_id", "name", "phone", "sale_phone", "dept_id", "is_on_job"):
for key in (
"user_id",
"name",
"phone",
"sale_phone",
"dept_id",
"is_on_job",
"raw_json",
):
assert key in body["items"][0]
async def test_staff_raw_json_serializes_verbatim(make_client):
"""``raw_json`` round-trips the verbatim staff record unmolested.
Seeded ``u1`` carries a populated blob; ``u2`` has NULL raw_json; ``u3``
omits it entirely. All three must serialize (populated dict vs null).
"""
client = await make_client(_seed)
r = await client.get("/api/staff")
assert r.status_code == 200
by_id = {it["user_id"]: it for it in r.json()["items"]}
assert by_id["u1"]["raw_json"] == {
"userId": "u1",
"userName": "Alice",
"sale_phone": "111",
"deptId": "d1",
}
assert by_id["u2"]["raw_json"] is None
assert by_id["u3"]["raw_json"] is None
async def test_staff_filter_by_department_id(make_client):
"""``?department_id=d1`` -> 2 staff (Alice + Carol)."""
client = await make_client(_seed)
@@ -162,6 +216,7 @@ async def test_accounts_default_returns_both_channels(make_client):
"contacts_num",
"group_num",
"is_on_job",
"raw_json",
):
assert key in item
assert item["channel"] == "wechat"
@@ -169,6 +224,40 @@ async def test_accounts_default_returns_both_channels(make_client):
assert item["user_name"] == "Alice"
async def test_accounts_raw_json_serializes_verbatim(make_client):
"""``raw_json`` round-trips the verbatim account record for both channels.
Seeded ``wx_a`` and the whatsapp account carry populated blobs; ``wx_b``
has NULL raw_json. All three must serialize (populated dict vs null).
"""
client = await make_client(_seed)
r = await client.get("/api/accounts")
assert r.status_code == 200
by_id = {(it["channel"], it["account_id"]): it for it in r.json()["items"]}
wx_a = by_id[("wechat", "wx_a")]
assert wx_a["raw_json"] == {
"wechatId": "wx_a",
"nickName": "WxOne",
"alias": "alice_wx",
"sendNum": 120,
"receiveNum": 80,
"dbSize": 2048,
"onlineStatus": "online",
}
wa = by_id[("whatsapp", "wa_c@s.whatsapp.net")]
assert wa["raw_json"] == {
"uid": "wa_c@s.whatsapp.net",
"nickName": "WaOne",
"talkerCount": 7,
"friendAddNum": 3,
}
# NULL raw_json serializes as JSON null.
assert by_id[("wechat", "wx_b")]["raw_json"] is None
async def test_accounts_filter_channel_wechat(make_client):
"""``?channel=wechat`` -> only the 2 wechat accounts."""
client = await make_client(_seed)
+20
View File
@@ -5,6 +5,7 @@
// (the backend treats missing as "no filter").
import { getJson } from './client'
import type {
Account,
CallChannel,
Channel,
Customer,
@@ -102,6 +103,24 @@ export function getStaff(query: StaffQuery = {}): Promise<Page<Staff>> {
)
}
export interface AccountQuery {
/** channel filter; `'all'` (default) drops the param so both channels union. */
channel?: Channel | 'all'
page?: number
page_size?: number
}
export function getAccounts(query: AccountQuery = {}): Promise<Page<Account>> {
return getJson<Page<Account>>(
'/accounts',
clean({
channel: query.channel === 'all' ? undefined : query.channel,
page: query.page ?? 1,
page_size: query.page_size ?? 20,
}),
)
}
// ---------------------------------------------------------------------------
// Chat groups
// ---------------------------------------------------------------------------
@@ -197,6 +216,7 @@ export function getSyncStatus(): Promise<SyncStatus> {
}
export type {
Account,
CallChannel,
Channel,
Customer,
+31
View File
@@ -55,6 +55,30 @@ export interface Staff {
sale_phone?: string | null
dept_id?: string | null
is_on_job?: boolean | null
/**
* Verbatim upstream staff record (camelCase keys). Surfaced so the
* all-fields drawer can render every column without enumerating each.
*/
raw_json?: Record<string, unknown> | null
}
/**
* Unified staff messaging account (mirrors backend AccountOut). The two source
* tables (wechat_account / whatsapp_account) are projected onto this single
* shape keyed by `channel` + `account_id`. `raw_json` carries the verbatim
* upstream record (wechatId/alias/sendNum/receiveNum/dbSize/...).
*/
export interface Account {
channel: Channel
account_id: string
nickname?: string | null
user_id?: string | null
user_name?: string | null
phone?: string | null
contacts_num?: number | null
group_num?: number | null
is_on_job?: boolean | null
raw_json?: Record<string, unknown> | null
}
/** Chat group summary row (mirrors backend GroupOut). */
@@ -187,4 +211,11 @@ export interface Message {
/** ms-epoch */
timestamp?: number | null
is_del?: boolean | null
/**
* Verbatim upstream message record (camelCase keys: msgSvrId/keyId, type,
* file/fileTh/fileHd, md5/md5Th/md5Hd, paymsgid, referMsg, talker/oriTalker,
* name/headUrl, wechatId, roomid, ...). Surfaced so the all-fields drawer
* can render every column the 云客 chat detail page shows.
*/
raw_json?: Record<string, unknown> | null
}
+14 -5
View File
@@ -12,7 +12,14 @@ const props = defineProps<{ message: Message }>()
* `/api/media/{hash}` streaming endpoint exists for cases where the OSS URL is
* missing, but the current MessageOut schema exposes `file_url` only, so we
* degrade gracefully via `file_th_url || file_url` (forward-compatible).
*
* Clicking anywhere on the bubble (other than interactive children such as the
* image preview or the file download link) emits `open-detail` so the parent
* timeline can open the all-fields drawer for this message. The image preview
* and download link stop propagation so their own interactions still work.
*/
const emit = defineEmits<{ (e: 'open-detail', message: Message): void }>()
const isSend = computed(() => !!props.message.is_send)
// Centered (system/divider) rows ignore direction.
@@ -41,13 +48,15 @@ const imageThumbUrl = computed(
'yk-msg--center': isCentered,
}"
>
<!-- Centered: divider / system -->
<!-- Centered: divider / system — not clickable. -->
<div v-if="isCentered" class="yk-msg__divider">
{{ message.text || (message.type === 'divider' ? '—— 分割线 ——' : '系统消息') }}
</div>
<!-- Normal bubble row -->
<div v-else class="yk-msg__row">
<!-- Normal bubble row — click opens the all-fields drawer. The image
preview + file download link stop propagation so their own clicks
(zoom / navigate) don't also open the drawer. -->
<div v-else class="yk-msg__row yk-msg__row--clickable" @click="emit('open-detail', message)">
<div class="yk-msg__meta">
<span>{{ senderLabel }}</span>
<span v-if="timeLabel">{{ timeLabel }}</span>
@@ -59,7 +68,7 @@ const imageThumbUrl = computed(
</div>
<!-- image -->
<div v-else-if="message.type === 'image'">
<div v-else-if="message.type === 'image'" @click.stop>
<el-image
v-if="imageThumbUrl"
class="yk-msg__image"
@@ -98,7 +107,7 @@ const imageThumbUrl = computed(
</div>
<!-- file -->
<div v-else-if="message.type === 'file'">
<div v-else-if="message.type === 'file'" @click.stop>
<a
v-if="message.file_url"
class="yk-msg__chip yk-msg__chip--download"
+4
View File
@@ -123,6 +123,10 @@ onMounted(async () => {
<el-icon><Avatar /></el-icon>
<span>员工</span>
</el-menu-item>
<el-menu-item index="/accounts">
<el-icon><Iphone /></el-icon>
<span>账号</span>
</el-menu-item>
<el-menu-item index="/groups">
<el-icon><User /></el-icon>
<span>群</span>
+6
View File
@@ -21,6 +21,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/StaffList.vue'),
meta: { title: '员工列表' },
},
{
path: '/accounts',
name: 'accounts',
component: () => import('@/views/AccountList.vue'),
meta: { title: '账号列表' },
},
{
path: '/groups',
name: 'groups',
+12
View File
@@ -159,6 +159,18 @@ a {
align-items: flex-end;
}
/* Clickable bubble row — opens the all-fields drawer. Subtle hover so users
* discover the interaction without changing the bubble's own look. */
.yk-msg__row--clickable {
cursor: pointer;
border-radius: 6px;
transition: background-color 0.15s ease;
}
.yk-msg__row--clickable:hover {
background-color: var(--el-fill-color-light, #f5f7fa);
}
.yk-msg__meta {
display: flex;
gap: 8px;
+248
View File
@@ -0,0 +1,248 @@
/**
* Chinese-label map + grouping for the Account all-fields drawer.
*
* Mirrors the Customer approach (see customerFields.ts). The 云客 upstream
* account records (`WechatAccount.raw_json` / `WhatsappAccount.raw_json`)
* carry the verbatim wechatweb/qqweb account-list payload (wechatId / uid /
* nickName / alias / userName / sendNum / receiveNum / dbSize /
* onlineStatus / lastUpdate / talkerCount / friendAddNum / ...). This module
* turns that blob into a labeled, grouped, render-ready structure for the
* drawer opened by clicking an account row.
*
* Structured columns are preferred over the verbatim raw_json value when both
* exist. Unknown keys fall back to the raw key under 其他, so a new field
* added upstream still shows up.
*/
import type { Account } from '@/api/types'
import { formatTime } from './format'
/** camelCase raw_json key → 中文 label. Missing keys fall back to the key. */
export const ACCOUNT_FIELD_LABELS: Record<string, string> = {
// 标识
wechatId: '账号ID',
uid: '账号ID',
channel: '渠道',
nickName: '昵称',
alias: '微信号',
headImgUrl: '头像',
// 归属员工
userName: '员工姓名',
userId: '员工ID',
user_id: '员工ID',
user_name: '员工姓名',
phone: '手机',
salePhone: '销售手机',
sale_phone: '销售手机',
salesBindPhone: '绑定手机',
deptId: '部门ID',
deptName: '部门',
// 业务统计
contactsNum: '好友数',
groupNum: '群数',
sendNum: '发送消息数',
receiveNum: '接收消息数',
talkerCount: '会话数',
friendAddNum: '新增好友数',
dbSize: '本地库大小',
// 状态与时间
isOnJob: '在职',
is_on_job: '在职',
onlineStatus: '在线状态',
lastUpdate: '最后更新',
// 其他 / 内部
version: '版本号',
}
/**
* raw_json camelCase key → the structured snake_case column that mirrors it.
* When a structured column is populated we prefer it; we fall back to the
* verbatim raw_json value otherwise.
*/
const STRUCTURED_ALIAS: Record<string, keyof Account> = {
wechatId: 'account_id',
uid: 'account_id',
channel: 'channel',
nickName: 'nickname',
userName: 'user_name',
user_name: 'user_name',
userId: 'user_id',
user_id: 'user_id',
phone: 'phone',
contactsNum: 'contacts_num',
groupNum: 'group_num',
isOnJob: 'is_on_job',
is_on_job: 'is_on_job',
}
/** Fields whose value is an image URL → render as a thumbnail. */
const IMAGE_KEYS = new Set(['headImgUrl'])
/** Fields whose value is a millisecond epoch → format to local datetime. */
const TIMESTAMP_KEYS = new Set(['lastUpdate'])
/** Fields whose value is a boolean / 0|1 flag → render 是/否. */
const BOOLEAN_KEYS = new Set(['isOnJob', 'is_on_job'])
/** Drawer sections, rendered in this order. */
export interface AccountFieldGroup {
title: string
/** raw_json / snake_case keys to surface in this section. */
keys: string[]
}
export const ACCOUNT_FIELD_GROUPS: AccountFieldGroup[] = [
{
title: '基本信息',
keys: ['wechatId', 'uid', 'channel', 'nickName', 'alias', 'headImgUrl'],
},
{
title: '归属员工',
keys: [
'userName',
'user_name',
'userId',
'user_id',
'phone',
'salePhone',
'sale_phone',
'salesBindPhone',
'deptId',
'deptName',
],
},
{
title: '业务统计',
keys: [
'contactsNum',
'groupNum',
'sendNum',
'receiveNum',
'talkerCount',
'friendAddNum',
'dbSize',
],
},
{
title: '状态与时间',
keys: ['isOnJob', 'is_on_job', 'onlineStatus', 'lastUpdate', 'version'],
},
]
/** Every key already surfaced via a group (so 其他 can skip duplicates). */
const KNOWN_KEYS = new Set(ACCOUNT_FIELD_GROUPS.flatMap((g) => g.keys))
/** Resolve the 中文 label for a key (falls back to the key). */
export function fieldLabel(key: string): string {
return ACCOUNT_FIELD_LABELS[key] ?? key
}
/**
* Read a field value with structured-column preference.
* Returns the verbatim raw_json value when no structured column maps.
*/
export function readField(a: Account, camelKey: string): unknown {
const alias = STRUCTURED_ALIAS[camelKey]
if (alias) {
const v = a[alias]
if (v !== null && v !== undefined) return v
}
return a.raw_json?.[camelKey]
}
/** A single field prepared for drawer rendering. */
export interface DisplayField {
key: string
label: string
/** Raw value (for text fallback). */
value: unknown
/** How the template should render it. */
render: 'image' | 'time' | 'bool' | 'text' | 'empty'
/** Pre-formatted text. Empty for image/empty. */
text: string
}
/** 0|1|true|false → 是/否; other ints pass through so info isn't hidden. */
export function yesNoLabel(v: unknown): string {
if (v === true || v === 1 || v === '1') return '是'
if (v === false || v === 0 || v === '0') return '否'
return String(v)
}
/** Render a byte count as a human-readable string (KB/MB/GB). */
export function formatBytes(v: unknown): string {
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) return String(v)
if (v < 1024) return `${v} B`
const units = ['KB', 'MB', 'GB', 'TB']
let size = v / 1024
let i = 0
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[i]}`
}
/** Classify + format a single field for the drawer. */
export function toDisplayField(a: Account, camelKey: string): DisplayField {
const value = readField(a, camelKey)
const label = fieldLabel(camelKey)
if (value === null || value === undefined || value === '') {
return { key: camelKey, label, value, render: 'empty', text: '' }
}
if (IMAGE_KEYS.has(camelKey) && typeof value === 'string') {
return { key: camelKey, label, value, render: 'image', text: '' }
}
if (TIMESTAMP_KEYS.has(camelKey) && typeof value === 'number') {
return { key: camelKey, label, value, render: 'time', text: formatTime(value) }
}
if (BOOLEAN_KEYS.has(camelKey)) {
return { key: camelKey, label, value, render: 'bool', text: yesNoLabel(value) }
}
if (camelKey === 'dbSize' && typeof value === 'number') {
return {
key: camelKey,
label,
value,
render: 'text',
text: formatBytes(value),
}
}
return { key: camelKey, label, value, render: 'text', text: String(value) }
}
/**
* Build the grouped display model for an account.
*
* Each known group only includes fields that have a value (empty ones are
* dropped to keep the drawer compact). A trailing 其他 section collects every
* raw_json key not already surfaced, so new upstream fields show up
* automatically instead of being silently hidden.
*/
export interface AccountFieldSection {
title: string
fields: DisplayField[]
}
export function buildAccountSections(a: Account): AccountFieldSection[] {
const sections: AccountFieldSection[] = ACCOUNT_FIELD_GROUPS.map((g) => ({
title: g.title,
fields: g.keys
.map((k) => toDisplayField(a, k))
.filter((f) => f.render !== 'empty'),
}))
// 其他: every raw_json key not already shown, in original insertion order.
const raw = a.raw_json ?? {}
const others: DisplayField[] = []
for (const key of Object.keys(raw)) {
if (KNOWN_KEYS.has(key)) continue
const f = toDisplayField(a, key)
if (f.render === 'empty') continue
others.push(f)
}
if (others.length > 0) {
sections.push({ title: '其他字段', fields: others })
}
return sections.filter((sec) => sec.fields.length > 0)
}
+262
View File
@@ -0,0 +1,262 @@
/**
* Chinese-label map + grouping for the Message all-fields drawer.
*
* Mirrors the Customer approach (see customerFields.ts): the 云客 upstream
* message record (`Message.raw_json`) carries ~30 camelCase fields (msgSvrId/
* keyId, type, file/fileTh/fileHd, md5/md5Th/md5Hd, paymsgid, referMsg,
* talker/oriTalker, name/headUrl, wechatId, roomid, isSend, ...). This module
* turns that blob into a labeled, grouped, render-ready structure for the
* drawer opened by clicking a chat bubble.
*
* Structured columns are preferred over the verbatim raw_json value when both
* exist (the structured column is type-normalized on ingest). Unknown keys
* (anything not in the label map) fall back to the raw key under 其他, so a
* new field added upstream still shows up instead of disappearing.
*/
import type { Message } from '@/api/types'
import { formatTime } from './format'
/** camelCase raw_json key → 中文 label. Missing keys fall back to the key. */
export const MESSAGE_FIELD_LABELS: Record<string, string> = {
// 标识
msgSvrId: '消息ID',
keyId: '消息ID',
type: '类型',
isSend: '是否发出',
mine: '是否发出',
isDel: '已删除',
// 内容
text: '文字内容',
content: '文字内容',
transcriptionText: '语音转文字',
paymsgid: '转账ID',
referMsg: '引用消息',
// 媒体
file: '媒体地址',
fileTh: '缩略图',
fileHd: '高清图',
imgPath: '图片路径',
originalImage: '原图路径',
picType: '图片类型',
md5: 'MD5',
md5Th: 'MD5(缩略图)',
md5Hd: 'MD5(高清)',
duration: '时长(秒)',
// 发送者与归属
talker: '对方',
oriTalker: '对方(原始)',
name: '发送者',
headUrl: '发送者头像',
wechatId: '归属微信',
roomid: '群ID',
// 时间与元数据
timestamp: '时间',
createTime: '时间',
version: '版本',
}
/**
* raw_json camelCase key → the structured snake_case column that mirrors it.
* When a structured column is populated we prefer it (type-normalized); we
* fall back to the verbatim raw_json value otherwise.
*/
const STRUCTURED_ALIAS: Record<string, keyof Message> = {
msgSvrId: 'msg_id',
keyId: 'msg_id',
type: 'type',
text: 'text',
content: 'text',
transcriptionText: 'transcription_text',
file: 'file_url',
fileTh: 'file_th_url',
fileHd: 'file_hd_url',
md5: 'md5',
duration: 'duration',
talker: 'talker_id',
oriTalker: 'talker_id',
name: 'sender_name',
headUrl: 'sender_head_url',
roomid: 'room_id',
timestamp: 'timestamp',
createTime: 'timestamp',
isDel: 'is_del',
}
/** Fields whose value is an image URL → render as a thumbnail. */
const IMAGE_KEYS = new Set([
'file',
'fileTh',
'fileHd',
'imgPath',
'originalImage',
'headUrl',
])
/** Fields whose value is a millisecond epoch → format to local datetime. */
const TIMESTAMP_KEYS = new Set(['timestamp', 'createTime'])
/** Fields whose value is a boolean / 0|1 flag → render 是/否. */
const BOOLEAN_KEYS = new Set(['isSend', 'mine', 'isDel'])
/** Drawer sections, rendered in this order. */
export interface MessageFieldGroup {
title: string
/** camelCase raw_json keys to surface in this section. */
keys: string[]
}
export const MESSAGE_FIELD_GROUPS: MessageFieldGroup[] = [
{
title: '标识',
keys: ['msgSvrId', 'keyId', 'type', 'isSend', 'mine', 'isDel'],
},
{
title: '内容',
keys: ['text', 'content', 'transcriptionText', 'paymsgid', 'referMsg'],
},
{
title: '媒体',
keys: [
'file',
'fileTh',
'fileHd',
'imgPath',
'originalImage',
'picType',
'md5',
'md5Th',
'md5Hd',
'duration',
],
},
{
title: '发送者与归属',
keys: [
'talker',
'oriTalker',
'name',
'headUrl',
'wechatId',
'roomid',
],
},
{
title: '时间与元数据',
keys: ['timestamp', 'createTime', 'version'],
},
]
/** Every key already surfaced via a group (so 其他 can skip duplicates). */
const KNOWN_KEYS = new Set(MESSAGE_FIELD_GROUPS.flatMap((g) => g.keys))
/** Resolve the 中文 label for a raw_json key (falls back to the key). */
export function fieldLabel(key: string): string {
return MESSAGE_FIELD_LABELS[key] ?? key
}
/**
* Read a field value with structured-column preference.
* Returns the verbatim raw_json value when no structured column maps.
*/
export function readField(m: Message, camelKey: string): unknown {
const alias = STRUCTURED_ALIAS[camelKey]
if (alias) {
const v = m[alias]
if (v !== null && v !== undefined) return v
}
return m.raw_json?.[camelKey]
}
/** A single field prepared for drawer rendering. */
export interface DisplayField {
key: string
label: string
/** Raw value (for text fallback / object rendering). */
value: unknown
/** How the template should render it. */
render: 'image' | 'time' | 'bool' | 'object' | 'text' | 'empty'
/** Pre-formatted text (for time/bool/text kinds). Empty for image/object/empty. */
text: string
}
/** 0|1|true|false → 是/否; other ints pass through so info isn't hidden. */
export function yesNoLabel(v: unknown): string {
if (v === true || v === 1 || v === '1') return '是'
if (v === false || v === 0 || v === '0') return '否'
return String(v)
}
/** Render an object/array value as indented JSON for the drawer. */
export function formatJson(value: unknown): string {
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
/** Classify + format a single field for the drawer. */
export function toDisplayField(m: Message, camelKey: string): DisplayField {
const value = readField(m, camelKey)
const label = fieldLabel(camelKey)
if (value === null || value === undefined || value === '') {
return { key: camelKey, label, value, render: 'empty', text: '' }
}
if (IMAGE_KEYS.has(camelKey) && typeof value === 'string') {
return { key: camelKey, label, value, render: 'image', text: '' }
}
if (TIMESTAMP_KEYS.has(camelKey) && typeof value === 'number') {
return { key: camelKey, label, value, render: 'time', text: formatTime(value) }
}
if (BOOLEAN_KEYS.has(camelKey)) {
return { key: camelKey, label, value, render: 'bool', text: yesNoLabel(value) }
}
// referMsg + any object/array → pretty-printed JSON block.
if (typeof value === 'object') {
return {
key: camelKey,
label,
value,
render: 'object',
text: formatJson(value),
}
}
return { key: camelKey, label, value, render: 'text', text: String(value) }
}
/**
* Build the grouped display model for a message.
*
* Each known group only includes fields that have a value (empty ones are
* dropped to keep the drawer compact). A trailing 其他 section collects every
* raw_json key not already surfaced, so new upstream fields show up
* automatically instead of being silently hidden.
*/
export interface MessageFieldSection {
title: string
fields: DisplayField[]
}
export function buildMessageSections(m: Message): MessageFieldSection[] {
const sections: MessageFieldSection[] = MESSAGE_FIELD_GROUPS.map((g) => ({
title: g.title,
fields: g.keys
.map((k) => toDisplayField(m, k))
.filter((f) => f.render !== 'empty'),
}))
// 其他: every raw_json key not already shown, in original insertion order.
const raw = m.raw_json ?? {}
const others: DisplayField[] = []
for (const key of Object.keys(raw)) {
if (KNOWN_KEYS.has(key)) continue
const f = toDisplayField(m, key)
if (f.render === 'empty') continue
others.push(f)
}
if (others.length > 0) {
sections.push({ title: '其他字段', fields: others })
}
return sections.filter((s) => s.fields.length > 0)
}
+171
View File
@@ -0,0 +1,171 @@
/**
* Chinese-label map + grouping for the Staff all-fields drawer.
*
* Mirrors the Customer approach (see customerFields.ts). Staff rows are
* relatively sparse — the structured columns (user_id / name / phone /
* sale_phone / dept_id / is_on_job) are the main payload; raw_json carries
* whatever extra keys the upstream account-list payload embedded the staff
* record in (userId / userName / salePhone / deptId / ...). All such keys are
* surfaced under 其他 so a new upstream field still shows up.
*/
import type { Staff } from '@/api/types'
/** camelCase / raw_json key → 中文 label. Missing keys fall back to the key. */
export const STAFF_FIELD_LABELS: Record<string, string> = {
// 基本信息
user_id: '员工ID',
userId: '员工ID',
name: '姓名',
userName: '姓名',
// 联系方式
phone: '手机',
sale_phone: '销售手机',
salePhone: '销售手机',
mobile: '手机',
// 部门与状态
dept_id: '部门ID',
deptId: '部门ID',
deptName: '部门',
department: '部门',
is_on_job: '在职',
isOnJob: '在职',
onJob: '在职',
// 其他 / 内部
version: '版本号',
}
/**
* raw_json / snake_case key → the structured snake_case column that mirrors
* it. When a structured column is populated we prefer it; we fall back to the
* verbatim raw_json value otherwise.
*/
const STRUCTURED_ALIAS: Record<string, keyof Staff> = {
user_id: 'user_id',
userId: 'user_id',
name: 'name',
userName: 'name',
phone: 'phone',
mobile: 'phone',
sale_phone: 'sale_phone',
salePhone: 'sale_phone',
dept_id: 'dept_id',
deptId: 'dept_id',
is_on_job: 'is_on_job',
isOnJob: 'is_on_job',
onJob: 'is_on_job',
}
/** Fields whose value is a boolean / 0|1 flag → render 是/否. */
const BOOLEAN_KEYS = new Set(['is_on_job', 'isOnJob', 'onJob'])
/** Drawer sections, rendered in this order. */
export interface StaffFieldGroup {
title: string
/** raw_json / snake_case keys to surface in this section. */
keys: string[]
}
export const STAFF_FIELD_GROUPS: StaffFieldGroup[] = [
{
title: '基本信息',
keys: ['user_id', 'userId', 'name', 'userName'],
},
{
title: '联系方式',
keys: ['phone', 'mobile', 'sale_phone', 'salePhone'],
},
{
title: '部门与状态',
keys: ['dept_id', 'deptId', 'deptName', 'department', 'is_on_job', 'isOnJob', 'onJob'],
},
]
/** Every key already surfaced via a group (so 其他 can skip duplicates). */
const KNOWN_KEYS = new Set(STAFF_FIELD_GROUPS.flatMap((g) => g.keys))
/** Resolve the 中文 label for a key (falls back to the key). */
export function fieldLabel(key: string): string {
return STAFF_FIELD_LABELS[key] ?? key
}
/**
* Read a field value with structured-column preference.
* Returns the verbatim raw_json value when no structured column maps.
*/
export function readField(s: Staff, camelKey: string): unknown {
const alias = STRUCTURED_ALIAS[camelKey]
if (alias) {
const v = s[alias]
if (v !== null && v !== undefined) return v
}
return s.raw_json?.[camelKey]
}
/** A single field prepared for drawer rendering. */
export interface DisplayField {
key: string
label: string
/** Raw value (for text fallback). */
value: unknown
/** How the template should render it. */
render: 'bool' | 'text' | 'empty'
/** Pre-formatted text. Empty for empty. */
text: string
}
/** 0|1|true|false → 是/否; other ints pass through so info isn't hidden. */
export function yesNoLabel(v: unknown): string {
if (v === true || v === 1 || v === '1') return '是'
if (v === false || v === 0 || v === '0') return '否'
return String(v)
}
/** Classify + format a single field for the drawer. */
export function toDisplayField(s: Staff, camelKey: string): DisplayField {
const value = readField(s, camelKey)
const label = fieldLabel(camelKey)
if (value === null || value === undefined || value === '') {
return { key: camelKey, label, value, render: 'empty', text: '' }
}
if (BOOLEAN_KEYS.has(camelKey)) {
return { key: camelKey, label, value, render: 'bool', text: yesNoLabel(value) }
}
return { key: camelKey, label, value, render: 'text', text: String(value) }
}
/**
* Build the grouped display model for a staff member.
*
* Each known group only includes fields that have a value (empty ones are
* dropped to keep the drawer compact). A trailing 其他 section collects every
* raw_json key not already surfaced, so new upstream fields show up
* automatically instead of being silently hidden.
*/
export interface StaffFieldSection {
title: string
fields: DisplayField[]
}
export function buildStaffSections(s: Staff): StaffFieldSection[] {
const sections: StaffFieldSection[] = STAFF_FIELD_GROUPS.map((g) => ({
title: g.title,
fields: g.keys
.map((k) => toDisplayField(s, k))
.filter((f) => f.render !== 'empty'),
}))
// 其他: every raw_json key not already shown, in original insertion order.
const raw = s.raw_json ?? {}
const others: DisplayField[] = []
for (const key of Object.keys(raw)) {
if (KNOWN_KEYS.has(key)) continue
const f = toDisplayField(s, key)
if (f.render === 'empty') continue
others.push(f)
}
if (others.length > 0) {
sections.push({ title: '其他字段', fields: others })
}
return sections.filter((sec) => sec.fields.length > 0)
}
+367
View File
@@ -0,0 +1,367 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getAccounts } from '@/api'
import type { Account, Channel } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format'
import { buildAccountSections, readField } from '@/utils/accountFields'
/**
* 账号 list — unified WeChat + WhatsApp staff messaging accounts. Mirrors the
* 云客 微信号管理 / WhatsApp views: 渠道 / 账号ID / 昵称 / 微信号 / 归属员工 /
* 好友数 / 群数 / 收发 / 在职 / 最后更新. Clicking a row opens the all-fields
* drawer (every raw_json key, grouped + 中文-labeled).
*/
const channelStore = useChannelStore()
const accounts = ref<Account[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
// All-fields drawer state.
const drawerOpen = ref(false)
const drawerAccount = ref<Account | null>(null)
const channelOptions = [
{ value: 'all' as const, label: '全部渠道' },
{ value: 'wechat' as const, label: '微信' },
{ value: 'whatsapp' as const, label: 'WhatsApp' },
]
const channelLabelCurrent = computed(() =>
channelLabel(channelStore.channel === 'all' ? null : channelStore.channel),
)
function channelTagType(c: string): 'success' | 'warning' {
return c === 'wechat' ? 'success' : 'warning'
}
function onChannelChange(value: string | number | boolean | undefined): void {
channelStore.setChannel(value as Channel | 'all')
}
/** Read a raw_json camelCase key off an account, preferring the structured
* snake_case column when it is populated. */
function cell(row: Account, camelKey: string): unknown {
return readField(row, camelKey)
}
function cellText(row: Account, camelKey: string): string {
const v = cell(row, camelKey)
return v === null || v === undefined || v === '' ? '--' : String(v)
}
function timeCell(row: Account, camelKey: string): number | undefined {
const v = cell(row, camelKey)
return typeof v === 'number' ? v : undefined
}
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 '--'
}
async function load(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const res = await getAccounts({
channel: channelStore.channel,
page: page.value,
page_size: pageSize.value,
})
accounts.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 top-bar channel selector.
watch(
() => channelStore.channel,
() => {
page.value = 1
load()
},
)
function openDrawer(row: Account): void {
drawerAccount.value = row
drawerOpen.value = true
}
const drawerSections = computed(() =>
drawerAccount.value ? buildAccountSections(drawerAccount.value) : [],
)
onMounted(load)
</script>
<template>
<div class="yk-accounts">
<div class="yk-accounts__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">账号列表</h2>
<span class="yk-accounts__sub">
共 {{ total }} 条 · 渠道:{{ channelLabelCurrent }}
</span>
</div>
<div class="yk-accounts__filters">
<el-radio-group
:model-value="channelStore.channel"
size="small"
@change="onChannelChange"
>
<el-radio-button
v-for="opt in channelOptions"
: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="accounts"
stripe
border
row-key="account_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无账号数据"
@row-click="openDrawer"
>
<el-table-column label="渠道" width="100">
<template #default="{ row }">
<el-tag :type="channelTagType(row.channel)" size="small" effect="plain">
{{ channelLabel(row.channel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="账号ID" min-width="160">
<template #default="{ row }">{{ row.account_id }}</template>
</el-table-column>
<el-table-column label="昵称" min-width="120">
<template #default="{ row }">
<span v-if="row.nickname">{{ row.nickname }}</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="微信号" min-width="120">
<template #default="{ row }">{{ cellText(row, 'alias') }}</template>
</el-table-column>
<el-table-column label="归属员工" min-width="120">
<template #default="{ row }">
<span v-if="cell(row, 'userName') || row.user_name">
{{ cellText(row, 'userName') }}
</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="好友数" prop="contacts_num" width="90" align="right">
<template #default="{ row }">
{{ row.contacts_num ?? 0 }}
</template>
</el-table-column>
<el-table-column label="群数" prop="group_num" width="80" align="right">
<template #default="{ row }">
{{ row.group_num ?? 0 }}
</template>
</el-table-column>
<el-table-column label="发消息数" width="100" align="right">
<template #default="{ row }">{{ cellText(row, 'sendNum') }}</template>
</el-table-column>
<el-table-column label="收消息数" width="100" align="right">
<template #default="{ row }">{{ cellText(row, 'receiveNum') }}</template>
</el-table-column>
<el-table-column label="在职" width="90" 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-column label="最后更新" width="160">
<template #default="{ row }">
{{ formatTime(timeCell(row, 'lastUpdate')) }}
</template>
</el-table-column>
</el-table>
<div class="yk-accounts__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>
<p class="yk-accounts__hint">点击任意一行查看账号全部字段。</p>
<!-- All-fields drawer -->
<el-drawer
v-model="drawerOpen"
:title="drawerAccount ? `账号详情 · ${drawerAccount.nickname ?? drawerAccount.account_id}` : '账号详情'"
direction="rtl"
size="560px"
>
<template v-if="drawerAccount">
<div class="yk-drawer__topmeta">
<el-tag :type="channelTagType(drawerAccount.channel)" size="small" effect="plain">
{{ channelLabel(drawerAccount.channel) }}
</el-tag>
<el-tag
:type="onJobTagType(drawerAccount.is_on_job)"
size="small"
effect="plain"
>
{{ onJobLabel(drawerAccount.is_on_job) }}
</el-tag>
</div>
<div v-for="section in drawerSections" :key="section.title" class="yk-drawer__section">
<el-divider content-position="left">{{ section.title }}</el-divider>
<dl class="yk-drawer__kv">
<div v-for="f in section.fields" :key="f.key" class="yk-drawer__kv-item">
<dt>{{ f.label }}</dt>
<dd v-if="f.render === 'image'" class="yk-drawer__img">
<el-image
:src="String(f.value)"
:preview-src-list="[String(f.value)]"
fit="cover"
hide-on-click-modal
/>
</dd>
<dd v-else-if="f.render === 'empty'" class="yk-drawer__empty">—</dd>
<dd v-else>{{ f.text }}</dd>
</div>
</dl>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.yk-accounts__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-accounts__sub {
color: #909399;
font-size: 13px;
}
.yk-accounts__filters {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.yk-accounts__pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.yk-accounts__hint {
margin: 8px 2px 0;
font-size: 12px;
color: #c0c4cc;
}
.yk-drawer__topmeta {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-drawer__kv {
margin: 0 0 8px;
}
.yk-drawer__kv-item {
display: grid;
grid-template-columns: 110px 1fr;
gap: 8px 12px;
padding: 3px 0;
}
.yk-drawer__kv dt {
color: #909399;
font-size: 13px;
word-break: break-all;
}
.yk-drawer__kv dd {
margin: 0;
font-size: 13px;
color: #303133;
word-break: break-all;
min-width: 0;
}
.yk-drawer__img :deep(.el-image) {
width: 64px;
height: 64px;
border-radius: 4px;
border: 1px solid var(--el-border-color-light, #e4e7ed);
}
.yk-drawer__empty {
color: #c0c4cc;
}
</style>
+149 -1
View File
@@ -6,7 +6,8 @@ import { ArrowLeft } from '@element-plus/icons-vue'
import { getMessages } from '@/api'
import type { Channel, Message } from '@/api/types'
import MessageBubble from '@/components/MessageBubble.vue'
import { channelLabel } from '@/utils/format'
import { channelLabel, formatTime } from '@/utils/format'
import { buildMessageSections } from '@/utils/messageFields'
/**
* Chat timeline. Reused for two conversation shapes:
@@ -141,6 +142,26 @@ function goBack(): void {
}
}
// All-fields drawer state for the message detail view.
const drawerOpen = ref(false)
const drawerMessage = ref<Message | null>(null)
function openDetail(m: Message): void {
drawerMessage.value = m
drawerOpen.value = true
}
const drawerSections = computed(() =>
drawerMessage.value ? buildMessageSections(drawerMessage.value) : [],
)
const drawerTitle = computed(() => {
const m = drawerMessage.value
if (!m) return '消息详情'
const when = m.timestamp ? formatTime(m.timestamp) : ''
return `消息详情${when ? ` · ${when}` : ''}`
})
// Reload when the route params change (navigating between conversations).
watch(
() => [props.channel, props.ownerId, props.talkerId, props.roomId] as const,
@@ -200,8 +221,74 @@ onMounted(loadInitial)
v-for="m in messages"
:key="m.msg_id"
:message="m"
@open-detail="openDetail"
/>
</div>
<!-- All-fields drawer for the clicked message -->
<el-drawer
v-model="drawerOpen"
:title="drawerTitle"
direction="rtl"
size="560px"
>
<template v-if="drawerMessage">
<div class="yk-drawer__topmeta">
<el-tag size="small" effect="plain">
{{ channelLabel(channelTyped) }}
</el-tag>
<el-tag
v-if="drawerMessage.type"
size="small"
type="info"
effect="plain"
>
{{ drawerMessage.type }}
<template v-if="drawerMessage.type_raw != null">
({{ drawerMessage.type_raw }})
</template>
</el-tag>
<el-tag
v-if="drawerMessage.is_send != null"
size="small"
:type="drawerMessage.is_send ? 'success' : 'warning'"
effect="plain"
>
{{ drawerMessage.is_send ? '发出' : '接收' }}
</el-tag>
</div>
<div
v-for="section in drawerSections"
:key="section.title"
class="yk-drawer__section"
>
<el-divider content-position="left">{{ section.title }}</el-divider>
<dl class="yk-drawer__kv">
<div
v-for="f in section.fields"
:key="f.key"
class="yk-drawer__kv-item"
>
<dt>{{ f.label }}</dt>
<dd v-if="f.render === 'image'" class="yk-drawer__img">
<el-image
:src="String(f.value)"
:preview-src-list="[String(f.value)]"
fit="cover"
hide-on-click-modal
/>
</dd>
<dd v-else-if="f.render === 'empty'" class="yk-drawer__empty">—</dd>
<dd v-else-if="f.render === 'object'" class="yk-drawer__pre">
<pre>{{ f.text }}</pre>
</dd>
<dd v-else>{{ f.text }}</dd>
</div>
</dl>
</div>
</template>
</el-drawer>
</div>
</template>
@@ -213,4 +300,65 @@ onMounted(loadInitial)
max-height: calc(100vh - var(--yk-header-height) - 140px);
overflow-y: auto;
}
.yk-drawer__topmeta {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-drawer__kv {
margin: 0 0 8px;
}
.yk-drawer__kv-item {
display: grid;
grid-template-columns: 110px 1fr;
gap: 8px 12px;
padding: 3px 0;
}
.yk-drawer__kv dt {
color: #909399;
font-size: 13px;
word-break: break-all;
}
.yk-drawer__kv dd {
margin: 0;
font-size: 13px;
color: #303133;
word-break: break-all;
min-width: 0;
}
.yk-drawer__img :deep(.el-image) {
width: 64px;
height: 64px;
border-radius: 4px;
border: 1px solid var(--el-border-color-light, #e4e7ed);
}
.yk-drawer__empty {
color: #c0c4cc;
}
.yk-drawer__pre {
margin: 0;
min-width: 0;
}
.yk-drawer__pre pre {
margin: 0;
padding: 8px;
background: var(--el-fill-color-light, #f5f7fa);
border-radius: 4px;
font-size: 12px;
color: #303133;
white-space: pre-wrap;
word-break: break-all;
max-height: 240px;
overflow: auto;
}
</style>
+90 -3
View File
@@ -5,10 +5,15 @@ import { ElMessage } from 'element-plus'
import { getStaff } from '@/api'
import type { Staff } from '@/api/types'
import { useOrgStore } from '@/stores/org'
import { buildStaffSections, readField } from '@/utils/staffFields'
const route = useRoute()
const org = useOrgStore()
// All-fields drawer state.
const drawerOpen = ref(false)
const drawerStaff = ref<Staff | null>(null)
const staff = ref<Staff[]>([])
const total = ref(0)
const page = ref(1)
@@ -98,6 +103,26 @@ onMounted(async () => {
}
await load()
})
/** Read a raw_json camelCase key off a staff row, preferring the structured
* snake_case column when it is populated. */
function cell(row: Staff, camelKey: string): unknown {
return readField(row, camelKey)
}
function cellText(row: Staff, camelKey: string): string {
const v = cell(row, camelKey)
return v === null || v === undefined || v === '' ? '--' : String(v)
}
function openDrawer(row: Staff): void {
drawerStaff.value = row
drawerOpen.value = true
}
const drawerSections = computed(() =>
drawerStaff.value ? buildStaffSections(drawerStaff.value) : [],
)
</script>
<template>
@@ -136,8 +161,13 @@ onMounted(async () => {
border
row-key="user_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无员工数据"
@row-click="openDrawer"
>
<el-table-column label="员工ID" min-width="120">
<template #default="{ row }">{{ row.user_id }}</template>
</el-table-column>
<el-table-column label="姓名" min-width="120">
<template #default="{ row }">
<span v-if="row.name">{{ row.name }}</span>
@@ -145,10 +175,10 @@ onMounted(async () => {
</template>
</el-table-column>
<el-table-column label="手机" min-width="130">
<template #default="{ row }">{{ row.phone || '--' }}</template>
<template #default="{ row }">{{ cellText(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 label="销售手机" min-width="130">
<template #default="{ row }">{{ cellText(row, 'sale_phone') }}</template>
</el-table-column>
<el-table-column label="部门" min-width="140">
<template #default="{ row }">
@@ -181,6 +211,28 @@ onMounted(async () => {
@size-change="handlePageSizeChange"
/>
</div>
<p class="yk-staff__hint">点击任意一行查看员工全部字段。</p>
<!-- All-fields drawer -->
<el-drawer
v-model="drawerOpen"
:title="drawerStaff ? `员工详情 · ${drawerStaff.name ?? drawerStaff.user_id}` : '员工详情'"
direction="rtl"
size="520px"
>
<template v-if="drawerStaff">
<div v-for="section in drawerSections" :key="section.title" class="yk-drawer__section">
<el-divider content-position="left">{{ section.title }}</el-divider>
<dl class="yk-drawer__kv">
<div v-for="f in section.fields" :key="f.key" class="yk-drawer__kv-item">
<dt>{{ f.label }}</dt>
<dd v-if="f.render === 'empty'" class="yk-drawer__empty">—</dd>
<dd v-else>{{ f.text }}</dd>
</div>
</dl>
</div>
</template>
</el-drawer>
</div>
</template>
@@ -211,4 +263,39 @@ onMounted(async () => {
display: flex;
justify-content: flex-end;
}
.yk-staff__hint {
margin: 8px 2px 0;
font-size: 12px;
color: #c0c4cc;
}
.yk-drawer__kv {
margin: 0 0 8px;
}
.yk-drawer__kv-item {
display: grid;
grid-template-columns: 110px 1fr;
gap: 8px 12px;
padding: 3px 0;
}
.yk-drawer__kv dt {
color: #909399;
font-size: 13px;
word-break: break-all;
}
.yk-drawer__kv dd {
margin: 0;
font-size: 13px;
color: #303133;
word-break: break-all;
min-width: 0;
}
.yk-drawer__empty {
color: #c0c4cc;
}
</style>