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:
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user