b63b71bf68
Co-Authored-By: Claude <noreply@anthropic.com>
981 lines
32 KiB
Vue
981 lines
32 KiB
Vue
<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>
|