Files
yunke/yunkes/src/views/StaffList.vue
T

981 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getAccounts, getCustomers, getMessages } from '@/api'
import type { Account, Channel, Customer } from '@/api/types'
import YkStaffDetailModal from '@/components/YkStaffDetailModal.vue'
import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format'
import { buildAccountSections, formatBytes, readField } from '@/utils/accountFields'
/**
* 员工 list — channel-tabbed account view mirroring the 云客 backend's
* 员工微信列表 (WeChat staff accounts) and WhatsApp 员工列表.
*
* Column layout matches 云客 verbatim (captured via Playwright against
* crm2.yunkecn.com): the WeChat tab uses 云客's 6 compound columns
* (员工信息 / 数据更新时间·最新消息时间 / 好友数 / 消息数 / 沟通数 / 及时回复),
* the WhatsApp tab uses 云客's 10 columns ending in an 操作 column with
* 好友列表 / 群聊 actions. Each compound cell stacks label+value pairs.
*
* Fields are read structured-first, raw_json fallback via accountFields.
* readField. Time fields upstream are strings ("YYYY-MM-DD HH:mm:ss"), so
* cellTimeText renders strings directly and only falls back to formatTime
* for numeric epochs.
*
* Row click opens the all-fields drawer reused from AccountList.vue.
*/
type TabKey = Channel
interface TabState {
items: Account[]
total: number
page: number
pageSize: number
loading: boolean
errorMsg: string | null
/** Has this tab been loaded at least once? Tabs lazy-load on first activation. */
loaded: boolean
}
function freshTabState(): TabState {
return {
items: [],
total: 0,
page: 1,
pageSize: 20,
loading: false,
errorMsg: null,
loaded: false,
}
}
const activeTab = ref<TabKey>('wechat')
const tabState = reactive<Record<TabKey, TabState>>({
wechat: freshTabState(),
whatsapp: freshTabState(),
})
const router = useRouter()
const channelStore = useChannelStore()
// All-fields drawer state (shared across tabs).
const drawerOpen = ref(false)
const drawerAccount = ref<Account | null>(null)
// ---------------------------------------------------------------------------
// Cell helpers (structured-first, raw_json fallback via accountFields.readField)
// ---------------------------------------------------------------------------
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 cellNumber(row: Account, camelKey: string): number | undefined {
const v = cell(row, camelKey)
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
}
/** Numeric value with a fallback (defaults to 0) — for stat cells. */
function cellNum(row: Account, camelKey: string, fallback = 0): number {
return cellNumber(row, camelKey) ?? fallback
}
/**
* Time cell: upstream sends strings ("YYYY-MM-DD HH:mm[:ss]"); legacy/numeric
* epochs are formatted via formatTime. Returns '--' only when truly empty.
*/
function cellTimeText(row: Account, camelKey: string): string {
const v = cell(row, camelKey)
if (typeof v === 'number' && Number.isFinite(v)) return formatTime(v)
if (typeof v === 'string' && v.trim()) return v.trim()
return '--'
}
function imageCell(row: Account, camelKey: string): string | undefined {
const v = cell(row, camelKey)
return typeof v === 'string' && v !== '' ? v : undefined
}
/** First character of userName/nickName/account_id for avatar fallbacks. */
function initials(row: Account): string {
const name =
cell(row, 'userName') ?? cell(row, 'nickName') ?? row.account_id ?? '?'
const s = String(name).trim()
return s ? s.slice(0, 1).toUpperCase() : '?'
}
function avatarUrl(row: Account): string | undefined {
// WeChat records key the avatar as `headImageUrl`; tolerate the legacy
// `headImg` / `headImgUrl` shapes too so the column degrades gracefully.
return (
imageCell(row, 'headImageUrl') ??
imageCell(row, 'headImgUrl') ??
imageCell(row, 'headImg')
)
}
function dbSizeText(row: Account): string {
const v = cellNumber(row, 'dbSize')
return v === undefined ? '--' : formatBytes(v)
}
// 在职: structured column is boolean; raw_json may carry 1/0. Handle both so
// the tag is correct regardless of which source populated the row.
function onJobTagType(v: unknown): 'success' | 'info' | '' {
if (v === true || v === 1 || v === '1') return 'success'
if (v === false || v === 0 || v === '0') return 'info'
return ''
}
function onJobLabel(v: unknown): string {
if (v === true || v === 1 || v === '1') return '在职'
if (v === false || v === 0 || v === '0') return '离职'
return '--'
}
/**
* 主/副号 tag for the WeChat 员工信息 cell. 云客 marks the primary
* dual-open account with 主 and the secondary with 副. Mapped from the
* upstream flags: isOpenDuo===1 → 主 (this account hosts a dual-open),
* isDuo===1 → 副 (this account is the secondary instance).
*
* Split into text/type helpers because Vue's template expression parser
* rejects TS non-null assertions (duoTag()!.x); two plain functions
* sidestep that cleanly.
*/
function duoTagText(row: Account): string {
if (cellNumber(row, 'isDuo') === 1) return '副'
if (cellNumber(row, 'isOpenDuo') === 1) return '主'
return ''
}
function duoTagType(row: Account): 'success' | 'warning' {
return cellNumber(row, 'isDuo') === 1 ? 'warning' : 'success'
}
function channelTagType(c: Channel): 'success' | 'warning' {
return c === 'wechat' ? 'success' : 'warning'
}
/** WhatsApp row序号: accounts for current page/page-size. */
function waSerial(index: number): number {
const st = tabState.whatsapp
return (st.page - 1) * st.pageSize + index + 1
}
// ---------------------------------------------------------------------------
// Action buttons (操作 column) — jump to the corresponding list scoped to the
// row's account, presetting the global channel filter to the row's channel.
// owner_account_id is the staff's account_id (wxId for WeChat, uid for WA),
// which both CustomerList and GroupList already filter on server-side.
// ---------------------------------------------------------------------------
function viewCustomers(row: Account): void {
channelStore.setChannel(row.channel)
router.push({
path: '/customers',
query: { owner_account_id: row.account_id },
})
}
function viewGroups(row: Account): void {
channelStore.setChannel(row.channel)
router.push({
path: '/groups',
query: { owner_account_id: row.account_id },
})
}
async function viewChat(row: Account) {
// 取该账号最近一条会话,跳 ChatTimeline(降级:云客是会话列表,我们跳最近一条)
const res = await getMessages({
channel: row.channel,
owner_account_id: row.account_id,
page: 1,
page_size: 1,
})
const m = res.items[0]
if (!m) {
ElMessage.info('该账号暂无聊天记录')
return
}
const target = m.room_id ?? m.talker_id
if (!target) {
ElMessage.info('最近一条消息无会话标识')
return
}
await router.push(
m.room_id
? `/group/${row.channel}/${row.account_id}/${m.room_id}`
: `/chat/${row.channel}/${row.account_id}/${m.talker_id}`,
)
}
// ---------------------------------------------------------------------------
// Detail modal (stat-number click → 明细) — reused for 添加人数 / 沟通人数.
// fetcher is set by open*Modal; the modal triggers it on open.
// ---------------------------------------------------------------------------
const detailOpen = ref(false)
const detailTitle = ref('')
// fetcher is set by open*Modal; the modal invokes it on open.
const detailFetcher = ref<(p: number, s: number) => Promise<any>>(
async () => ({ items: [], total: 0 }),
)
const detailCols = ref<any[]>([])
const fmtTime = (_r: any, _c: any, v: any): string =>
typeof v === 'number' && v > 0 ? formatTime(v) : v ? String(v) : '--'
const baseCustomerCols = [
{ prop: 'friend_nickname', label: '客户', minWidth: 140 },
{ prop: 'phone', label: '客户号码', width: 130 },
{ prop: 'last_chat_time', label: '最后沟通', width: 150, formatter: fmtTime },
{ prop: 'add_time', label: '添加时间', width: 150, formatter: fmtTime },
]
function openAddedModal(row: Account) {
// ⚠️ WA customer has no add_time (Task 2 confirmed: WA raw_json lacks
// addTime; createTime is owner-staff). Degrade: do NOT set add_time_since
// (would be empty); show all customers of this account, sorted by last_chat_time.
// 用 owner_account_id(=account_id,始终存在)而非 user_id(可能 null 导致全表泄漏)。
// 标题如实写"客户列表"——非"添加人数",因 WA 无法按 add_time 精确筛新增。
detailTitle.value = `客户列表 · ${row.user_name ?? row.account_id}`
detailCols.value = baseCustomerCols
detailFetcher.value = (p, s) =>
getCustomers({ owner_account_id: row.account_id, channel: row.channel, page: p, page_size: s })
detailOpen.value = true
}
function openTalkedModal(row: Account) {
// 沟通人数: chatted within the last 7 days (last_chat_time exists on both
// channels). 用 owner_account_id(始终存在)而非 user_id(可能 null)。
const since = Date.now() - 7 * 86400000
detailTitle.value = `沟通人数 · ${row.user_name ?? row.account_id}`
detailCols.value = baseCustomerCols
detailFetcher.value = (p, s) =>
getCustomers({
owner_account_id: row.account_id,
channel: row.channel,
last_chat_since: since,
page: p,
page_size: s,
})
detailOpen.value = true
}
/** 微信员工「沟通数」3 数字 → 明细 modal(数据源:该员工客户列表,度量精确区分见 plan 数据源说明)。 */
function openWechatCommModal(row: Account, title: string, withMsgCols: boolean): void {
detailTitle.value = `${title} · ${row.user_name ?? row.account_id}`
// 客户微信列可点 → 跳该客户聊天记录(ChatTimeline,name:'chat' 路由同 CustomerList.viewChat)。
// modal 列数据是 Customer,goChat 守卫 owner/friend 存在;缺失则静默 no-op(与 onClick?: 一致)。
const goChat = (r: Customer) => {
if (!r.owner_account_id || !r.friend_id) return
router.push({
name: 'chat',
params: {
channel: r.channel,
ownerId: r.owner_account_id,
talkerId: r.friend_id,
},
})
}
detailCols.value = withMsgCols
? [
{ prop: 'friend_nickname', label: '客户微信', minWidth: 160, onClick: goChat },
{ prop: 'send_num', label: '发送消息数', width: 110, align: 'center' },
{ prop: 'receive_num', label: '接收消息数', width: 110, align: 'center' },
]
: [{ prop: 'friend_nickname', label: '客户微信', minWidth: 160, onClick: goChat }]
detailFetcher.value = async (page: number, page_size: number) =>
getCustomers({ channel: 'wechat', owner_account_id: row.account_id, page, page_size })
detailOpen.value = true
}
// ---------------------------------------------------------------------------
// Per-tab loading
// ---------------------------------------------------------------------------
async function loadTab(ch: TabKey): Promise<void> {
const st = tabState[ch]
st.loading = true
st.errorMsg = null
try {
const res = await getAccounts({
channel: ch,
page: st.page,
page_size: st.pageSize,
})
st.items = res.items
st.total = res.total
st.loaded = true
} catch (e) {
st.errorMsg = (e as Error).message
ElMessage.error(`员工列表加载失败:${(e as Error).message}`)
} finally {
st.loading = false
}
}
function handlePageChange(ch: TabKey, p: number): void {
tabState[ch].page = p
loadTab(ch)
}
function handlePageSizeChange(ch: TabKey, s: number): void {
tabState[ch].pageSize = s
tabState[ch].page = 1
loadTab(ch)
}
// Per-tab wrappers so template bindings carry proper types (vue-tsc strict
// mode does not infer inline-arrow params in @event handlers).
function onWechatPageChange(p: number): void {
handlePageChange('wechat', p)
}
function onWechatPageSizeChange(s: number): void {
handlePageSizeChange('wechat', s)
}
function onWhatsappPageChange(p: number): void {
handlePageChange('whatsapp', p)
}
function onWhatsappPageSizeChange(s: number): void {
handlePageSizeChange('whatsapp', s)
}
function onTabChange(name: string | number): void {
const ch = String(name) as TabKey
if (!tabState[ch].loaded) loadTab(ch)
}
// ---------------------------------------------------------------------------
// Drawer
// ---------------------------------------------------------------------------
function openDrawer(row: Account): void {
drawerAccount.value = row
drawerOpen.value = true
}
const drawerSections = computed(() =>
drawerAccount.value ? buildAccountSections(drawerAccount.value) : [],
)
const drawerAvatarSrc = computed<string | undefined>(() => {
const a = drawerAccount.value
return a ? avatarUrl(a) : undefined
})
onMounted(() => {
// The visible tab on first paint is 微信员工 — load it. The other tab
// lazy-loads on activation.
loadTab('wechat')
})
</script>
<template>
<div class="yk-staff">
<div class="yk-staff__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">员工列表</h2>
<span class="yk-staff__sub">
按渠道展示员工账号(微信号 / WhatsApp 号),列布局对齐云客后台,点击行查看全部字段。
</span>
</div>
</div>
<el-tabs v-model="activeTab" class="yk-staff__tabs" @tab-change="onTabChange">
<!-- ============================================================
微信员工 (WeChat staff accounts) — 6 compound columns mirroring
云客 /cms/wechat/wechatlist (员工微信列表).
============================================================ -->
<el-tab-pane label="微信员工" name="wechat">
<el-alert
v-if="tabState.wechat.errorMsg"
:title="tabState.wechat.errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="tabState.wechat.loading"
:data="tabState.wechat.items"
border
row-key="account_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无微信员工账号"
@row-click="openDrawer"
>
<!-- 员工信息 -->
<el-table-column label="员工信息" min-width="300">
<template #default="{ row }">
<div class="yk-staffinfo">
<template v-if="avatarUrl(row)">
<el-image
:src="avatarUrl(row)"
fit="cover"
class="yk-avatar-round"
>
<template #error>
<div class="yk-avatar-fallback">{{ initials(row) }}</div>
</template>
</el-image>
</template>
<el-avatar v-else :size="40" shape="circle">
{{ initials(row) }}
</el-avatar>
<div class="yk-staffinfo__meta">
<div class="yk-staffinfo__line">
<el-tag
v-if="duoTagText(row)"
:type="duoTagType(row)"
size="small"
effect="dark"
class="yk-staffinfo__duo"
style="cursor:pointer"
@click.stop="viewChat(row)"
>{{ duoTagText(row) }}</el-tag>
<span class="yk-staffinfo__nick yk-link" :title="cellText(row, 'nickName')" @click.stop="viewChat(row)">
{{ cellText(row, 'nickName') }}
</span>
</div>
<div class="yk-staffinfo__pair">
<span class="yk-staffinfo__k">员工姓名</span>
<el-button link type="primary" size="small" class="yk-staffinfo__v" @click.stop="openDrawer(row)">{{ cellText(row, 'userName') }}</el-button>
</div>
<div class="yk-staffinfo__pair">
<span class="yk-staffinfo__k">微信手机号</span>
<span class="yk-staffinfo__v">{{ cellText(row, 'phone') }}</span>
</div>
<div class="yk-staffinfo__pair">
<span class="yk-staffinfo__k">微信号</span>
<span class="yk-staffinfo__v">{{ cellText(row, 'alias') }}</span>
</div>
</div>
</div>
</template>
</el-table-column>
<!-- 数据更新时间 / 最新消息时间 -->
<el-table-column label="数据更新时间/最新消息时间" width="220">
<template #default="{ row }">
<div class="yk-pair">
<span class="yk-pair__k">数据更新</span>
<span class="yk-pair__v">{{ cellTimeText(row, 'uploadTime') }}</span>
</div>
<div class="yk-pair">
<span class="yk-pair__k">最新消息</span>
<span class="yk-pair__v">{{ cellTimeText(row, 'lastUpdate') }}</span>
</div>
<div class="yk-pair">
<span class="yk-pair__k">数据库大小</span>
<span class="yk-pair__v">{{ dbSizeText(row) }}</span>
</div>
</template>
</el-table-column>
<!-- 好友数 -->
<el-table-column label="好友数" width="130">
<template #default="{ row }">
<div class="yk-pair">
<span class="yk-pair__k">好友数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="viewCustomers(row)">{{ cellNum(row, 'contactsNum') }}</el-button>
</div>
<div class="yk-pair">
<span class="yk-pair__k">微信群数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="viewGroups(row)">{{ cellNum(row, 'groupNum') }}</el-button>
</div>
</template>
</el-table-column>
<!-- 消息数 -->
<el-table-column label="消息数" width="150">
<template #default="{ row }">
<div class="yk-pair">
<span class="yk-pair__k">发送消息数</span>
<span class="yk-pair__v">{{ cellNum(row, 'sendNum') }}</span>
</div>
<div class="yk-pair">
<span class="yk-pair__k">接收消息数</span>
<span class="yk-pair__v">{{ cellNum(row, 'receiveNum') }}</span>
</div>
<div class="yk-pair">
<span class="yk-pair__k">撤回消息数</span>
<span class="yk-pair__v">{{ cellNum(row, 'withdrawMsgCount') }}</span>
</div>
</template>
</el-table-column>
<!-- 沟通数 -->
<el-table-column label="沟通数" width="170">
<template #default="{ row }">
<div class="yk-pair">
<span class="yk-pair__k">沟通人数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '沟通人数(已去重)', false)">{{ cellNum(row, 'chatCount') }}</el-button>
</div>
<div class="yk-pair">
<span class="yk-pair__k">有效沟通数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '有效沟通数(已去重)', true)">{{ cellNum(row, 'contactCount') }}</el-button>
</div>
<div class="yk-pair">
<span class="yk-pair__k">跟进客户数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '跟进客户数', false)">{{ cellNum(row, 'followCount') }}</el-button>
</div>
</template>
</el-table-column>
<!-- 及时回复 -->
<el-table-column label="及时回复" width="200">
<template #default="{ row }">
<div class="yk-pair">
<span class="yk-pair__k">员工最后回复数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '员工最后回复数', false)">{{ cellNum(row, 'saleSendLastNum') }}</el-button>
</div>
<div class="yk-pair">
<span class="yk-pair__k">员工最后回复率</span>
<span class="yk-pair__v">{{ cellText(row, 'saleSendLastNumRate') }}</span>
</div>
<div class="yk-pair">
<span class="yk-pair__k">未回复客户数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '未回复客户数', false)">{{ cellNum(row, 'customerSendLastNum') }}</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="yk-staff__pager">
<el-pagination
:current-page="tabState.wechat.page"
:page-size="tabState.wechat.pageSize"
:page-sizes="[20, 50, 100]"
:total="tabState.wechat.total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="onWechatPageChange"
@size-change="onWechatPageSizeChange"
/>
</div>
</el-tab-pane>
<!-- ============================================================
WhatsApp员工 (WhatsApp staff accounts) — 10 columns mirroring
云客 /cms/wechat/waBusiness/userlist (员工列表), ending in an
操作 column with 好友列表 / 群聊.
============================================================ -->
<el-tab-pane label="WhatsApp员工" name="whatsapp">
<el-alert
v-if="tabState.whatsapp.errorMsg"
:title="tabState.whatsapp.errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="tabState.whatsapp.loading"
:data="tabState.whatsapp.items"
border
row-key="account_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无 WhatsApp 员工账号"
@row-click="openDrawer"
>
<!-- 序号 -->
<el-table-column label="序号" width="70" align="center">
<template #default="{ $index }">{{ waSerial($index) }}</template>
</el-table-column>
<!-- 员工信息 -->
<el-table-column label="员工信息" min-width="150">
<template #default="{ row }">
<div class="yk-staffinfo__nick" :title="cellText(row, 'userName')">
{{ cellText(row, 'userName') }}
</div>
<div class="yk-staffinfo__muted">{{ cellText(row, 'salePhone') }}</div>
</template>
</el-table-column>
<!-- 员工WhatsApp -->
<el-table-column label="员工WhatsApp" min-width="220">
<template #default="{ row }">
<div class="yk-staffinfo">
<el-avatar :size="36" shape="circle">{{ initials(row) }}</el-avatar>
<div class="yk-staffinfo__meta">
<span
class="yk-staffinfo__nick yk-link"
:title="cellText(row, 'nickName')"
@click.stop="viewChat(row)"
>
{{ cellText(row, 'nickName') }}
</span>
<div class="yk-staffinfo__muted">{{ cellText(row, 'phone') }}</div>
</div>
</div>
</template>
</el-table-column>
<!-- 是否在职 -->
<el-table-column label="是否在职" width="90" align="center">
<template #default="{ row }">
<el-tag
:type="onJobTagType(cell(row, 'isOnJob'))"
size="small"
effect="plain"
>
{{ onJobLabel(cell(row, 'isOnJob')) }}
</el-tag>
</template>
</el-table-column>
<!-- 添加人数 -->
<el-table-column label="添加人数" width="100" align="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="openAddedModal(row)">
{{ cellNum(row, 'friendAddNum') }}
</el-button>
</template>
</el-table-column>
<!-- 沟通人数 -->
<el-table-column label="沟通人数" width="100" align="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="openTalkedModal(row)">
{{ cellNum(row, 'talkerCount') }}
</el-button>
</template>
</el-table-column>
<!-- 好友数 -->
<el-table-column label="好友数" width="90" align="right">
<template #default="{ row }">{{ cellNum(row, 'contactsNum') }}</template>
</el-table-column>
<!-- 群数 -->
<el-table-column label="群数" width="80" align="right">
<template #default="{ row }">{{ cellNum(row, 'groupNum') }}</template>
</el-table-column>
<!-- 最新消息时间 -->
<el-table-column label="最新消息时间" width="170">
<template #default="{ row }">
{{ cellTimeText(row, 'lastUpdate') }}
</template>
</el-table-column>
<!-- 操作 -->
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="viewCustomers(row)">
好友列表
</el-button>
<el-button link type="primary" size="small" @click.stop="viewGroups(row)">
群聊
</el-button>
</template>
</el-table-column>
</el-table>
<div class="yk-staff__pager">
<el-pagination
:current-page="tabState.whatsapp.page"
:page-size="tabState.whatsapp.pageSize"
:page-sizes="[20, 50, 100]"
:total="tabState.whatsapp.total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="onWhatsappPageChange"
@size-change="onWhatsappPageSizeChange"
/>
</div>
</el-tab-pane>
</el-tabs>
<YkStaffDetailModal
v-model="detailOpen"
:title="detailTitle"
:cols="detailCols"
:fetcher="detailFetcher"
/>
<p class="yk-staff__hint">点击任意一行查看员工账号全部字段。</p>
<!-- All-fields drawer (reused from AccountList.vue / accountFields.ts) -->
<el-drawer
v-model="drawerOpen"
:title="drawerAccount ? `员工详情 · ${drawerAccount.user_name ?? drawerAccount.nickname ?? drawerAccount.account_id}` : '员工详情'"
direction="rtl"
size="560px"
>
<template v-if="drawerAccount">
<div class="yk-drawer__avatar">
<el-avatar :size="56" :src="drawerAvatarSrc" shape="square">
{{ initials(drawerAccount) }}
</el-avatar>
<div class="yk-drawer__avatar-meta">
<div class="yk-drawer__avatar-name">
{{ drawerAccount.user_name ?? drawerAccount.nickname ?? drawerAccount.account_id }}
</div>
<div class="yk-drawer__avatar-sub">
<el-tag
:type="channelTagType(drawerAccount.channel)"
size="small"
effect="plain"
>
{{ channelLabel(drawerAccount.channel) }}
</el-tag>
<el-tag
:type="onJobTagType(cell(drawerAccount, 'isOnJob'))"
size="small"
effect="plain"
style="margin-left: 6px"
>
{{ onJobLabel(cell(drawerAccount, 'isOnJob')) }}
</el-tag>
</div>
</div>
</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-staff__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-staff__sub {
color: #909399;
font-size: 13px;
}
.yk-staff__tabs {
margin-top: 4px;
}
.yk-staff__pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.yk-staff__hint {
margin: 8px 2px 0;
font-size: 12px;
color: #c0c4cc;
}
/* ---- 员工信息 compound cell (avatar + labelled lines) ---- */
.yk-staffinfo {
display: flex;
align-items: center;
gap: 10px;
}
.yk-staffinfo__meta {
min-width: 0;
flex: 1;
}
.yk-staffinfo__line {
display: flex;
align-items: center;
gap: 6px;
}
.yk-staffinfo__duo {
flex-shrink: 0;
}
.yk-staffinfo__nick {
display: block;
font-weight: 600;
font-size: 13px;
color: #303133;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.yk-staffinfo__muted {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.yk-link {
color: var(--el-color-primary);
cursor: pointer;
}
.yk-link:hover {
opacity: 0.8;
}
.yk-staffinfo__pair {
font-size: 12px;
line-height: 1.7;
display: flex;
gap: 4px;
}
.yk-staffinfo__k {
color: #909399;
flex-shrink: 0;
}
.yk-staffinfo__v {
color: #606266;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- label/value pair stack (KPI compound cells) ---- */
.yk-pair {
display: flex;
gap: 6px;
line-height: 1.8;
font-size: 13px;
white-space: nowrap;
}
.yk-pair__k {
color: #909399;
flex-shrink: 0;
}
.yk-pair__v {
color: #303133;
}
/* Round 40px avatar — el-image renders an <img> inside; size + round it. */
.yk-avatar-round {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
background: #f0f2f5;
flex-shrink: 0;
}
.yk-avatar-round :deep(img) {
width: 40px;
height: 40px;
object-fit: cover;
}
.yk-avatar-fallback {
width: 40px;
height: 40px;
line-height: 40px;
text-align: center;
color: #909399;
font-size: 14px;
background: #f0f2f5;
}
.yk-drawer__avatar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.yk-drawer__avatar-meta {
min-width: 0;
}
.yk-drawer__avatar-name {
font-size: 16px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.yk-drawer__avatar-sub {
font-size: 12px;
color: #909399;
margin-top: 4px;
display: flex;
align-items: center;
}
.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>