feat(staff): 员工详情独立页(StaffDetail.vue,替代 drawer 降级,对齐云客 userDetailNew)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-11 17:05:01 +08:00
parent 0e1cb87156
commit 20cf2c51f0
4 changed files with 347 additions and 3 deletions
+11
View File
@@ -21,6 +21,17 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/StaffList.vue'),
meta: { title: '员工列表' },
},
{
// Standalone 员工详情 page mirroring 云客's
// /cms/customer/userDetailNew?userId=... — replaces the all-fields drawer
// degradation for the 员工姓名 click. Row-click in StaffList still opens
// the drawer for a quick preview.
path: '/staff-detail/:userId',
name: 'staff-detail',
component: () => import('@/views/StaffDetail.vue'),
props: true,
meta: { title: '员工详情' },
},
{
path: '/accounts',
redirect: { name: 'staff' },
+17 -2
View File
@@ -156,6 +156,21 @@ function openDrawer(row: PhoneCall): void {
drawerOpen.value = true
}
/**
* 员工姓名 click → standalone 员工详情 page (mirrors 云客 userDetailNew).
* Distinct from row-click which still opens the all-fields call drawer.
* Prefer the structured `user_id`; fall back to the raw_json `userId` cell so
* calls whose structured user_id is null still resolve.
*/
function goToStaffDetail(row: PhoneCall): void {
const id = row.user_id ?? (cell(row, 'userId') as string | undefined)
if (!id) {
ElMessage.info('该通话无员工 userId,无法查看员工详情')
return
}
router.push({ name: 'staff-detail', params: { userId: String(id) } })
}
/** 把通话类型(voice_call/wa_voice_call)映射到聊天渠道(wechat/whatsapp)。电话返回 null。 */
function chatChannel(ch: CallChannel): 'wechat' | 'whatsapp' | null {
return ch === 'voice_call' ? 'wechat' : ch === 'wa_voice_call' ? 'whatsapp' : null
@@ -268,7 +283,7 @@ onMounted(load)
</el-table-column>
<el-table-column label="员工信息" min-width="130">
<template #default="{ row }">
<el-button v-if="row.user_id_name" link type="primary" size="small" @click.stop="openDrawer(row)">{{ row.user_id_name }}</el-button>
<el-button v-if="row.user_id_name" link type="primary" size="small" @click.stop="goToStaffDetail(row)">{{ row.user_id_name }}</el-button>
<span v-else>--</span>
<div class="yk-cell__sub">{{ cell(row, 'userPhone') || '--' }}</div>
</template>
@@ -303,7 +318,7 @@ onMounted(load)
<el-table-column label="员工信息" min-width="160">
<template #default="{ row }">
<div>
<el-button v-if="row.user_id_name" link type="primary" size="small" @click.stop="openDrawer(row)">{{ row.user_id_name }}</el-button>
<el-button v-if="row.user_id_name" link type="primary" size="small" @click.stop="goToStaffDetail(row)">{{ row.user_id_name }}</el-button>
<span v-else>--</span>
<span> · {{ cell(row, 'userPhone') || '--' }}</span>
</div>
+304
View File
@@ -0,0 +1,304 @@
<script setup lang="ts">
/**
* Standalone 员工详情 page — mirrors 云客's
* `/cms/customer/userDetailNew?userId=...` route. The 员工姓名 click in
* StaffList / CallList navigates here (same tab). Row-click in StaffList still
* opens the all-fields drawer for a quick preview, so both entry points
* coexist.
*
* Content is identical to the StaffList drawer: avatar + name + channel/onJob
* tags + the sections produced by buildAccountSections(account). The UX
* difference is drawer-vs-page; reusing buildAccountSections keeps the DATA
* identical to the drawer (single source of truth for fields/groups/labels).
*
* Data is fetched client-side: getAccounts returns the union of both channels,
* and we match by `user_id` first (the structured column) falling back to
* `account_id`. The dataset is small (~50 rows), so a single page_size=999
* fetch is fine; a per-userId backend endpoint would be cleaner but is out of
* scope for this UX swap.
*/
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { getAccounts } from '@/api'
import type { Account, Channel } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { channelLabel } from '@/utils/format'
import { buildAccountSections, readField } from '@/utils/accountFields'
const props = defineProps<{ userId: string }>()
const router = useRouter()
const channelStore = useChannelStore()
const account = ref<Account | null>(null)
const loading = ref(false)
const notFound = ref(false)
// ---------------------------------------------------------------------------
// Field helpers — same pattern as StaffList (structured-first, raw_json fallback).
// ---------------------------------------------------------------------------
function cell(a: Account, camelKey: string): unknown {
return readField(a, camelKey)
}
function imageCell(a: Account, camelKey: string): string | undefined {
const v = cell(a, camelKey)
return typeof v === 'string' && v !== '' ? v : undefined
}
function avatarUrl(a: Account): string | undefined {
return (
imageCell(a, 'headImageUrl') ??
imageCell(a, 'headImgUrl') ??
imageCell(a, 'headImg')
)
}
function initials(a: Account): string {
const name = cell(a, 'userName') ?? cell(a, 'nickName') ?? a.account_id ?? '?'
const s = String(name).trim()
return s ? s.slice(0, 1).toUpperCase() : '?'
}
function displayName(a: Account): string {
return a.user_name ?? a.nickname ?? a.account_id
}
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 '--'
}
function channelTagType(c: Channel): 'success' | 'warning' {
return c === 'wechat' ? 'success' : 'warning'
}
const sections = computed(() =>
account.value ? buildAccountSections(account.value) : [],
)
const avatarSrc = computed<string | undefined>(() =>
account.value ? avatarUrl(account.value) : undefined,
)
// ---------------------------------------------------------------------------
// Load
// ---------------------------------------------------------------------------
async function load(): Promise<void> {
loading.value = true
notFound.value = false
try {
// getAccounts drops the channel param when undefined → union of both tabs.
// page_size=999 is plenty; the staff-account dataset is small (~50 rows).
const res = await getAccounts({ page: 1, page_size: 999 })
const id = props.userId
const match =
res.items.find((a) => a.user_id === id) ??
res.items.find((a) => a.account_id === id) ??
null
if (!match) {
notFound.value = true
} else {
account.value = match
// Keep the global channel store in sync with the account being viewed so
// any list-page nav afterwards stays scoped to the right channel.
channelStore.setChannel(match.channel)
}
} catch (e) {
ElMessage.error(`员工详情加载失败:${(e as Error).message}`)
notFound.value = true
} finally {
loading.value = false
}
}
function goBack(): void {
// router.back() lands on the originating list if it exists; fall back to
// the staff list for direct URL entry / refresh on the detail page.
if (window.history.length > 1) {
router.back()
} else {
router.push({ name: 'staff' })
}
}
onMounted(load)
</script>
<template>
<div class="yk-staff-detail">
<div class="yk-staff-detail__header">
<el-button text @click="goBack">
<el-icon><ArrowLeft /></el-icon>&nbsp;返回
</el-button>
<h2 class="yk-staff-detail__title">员工详情</h2>
</div>
<div v-if="loading" v-loading="true" class="yk-staff-detail__loading"></div>
<div v-else-if="notFound" class="yk-empty">未找到该员工</div>
<div v-else-if="account" class="yk-staff-detail__body">
<!-- avatar + name + channel/onJob tags (same as StaffList drawer) -->
<div class="yk-drawer__avatar">
<el-avatar :size="56" :src="avatarSrc" shape="square">
{{ initials(account) }}
</el-avatar>
<div class="yk-drawer__avatar-meta">
<div class="yk-drawer__avatar-name">{{ displayName(account) }}</div>
<div class="yk-drawer__avatar-sub">
<el-tag
:type="channelTagType(account.channel)"
size="small"
effect="plain"
>
{{ channelLabel(account.channel) }}
</el-tag>
<el-tag
:type="onJobTagType(cell(account, 'isOnJob'))"
size="small"
effect="plain"
style="margin-left: 6px"
>
{{ onJobLabel(cell(account, 'isOnJob')) }}
</el-tag>
</div>
</div>
</div>
<!-- sections from buildAccountSections — identical content to the drawer -->
<div
v-for="section in sections"
: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>
</div>
</div>
</template>
<style scoped>
.yk-staff-detail {
padding: 4px 0 24px;
}
.yk-staff-detail__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.yk-staff-detail__title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.yk-staff-detail__loading {
height: 240px;
}
.yk-staff-detail__body {
max-width: 720px;
}
.yk-empty {
padding: 48px 0;
text-align: center;
color: #909399;
font-size: 14px;
}
/* ---- avatar header (mirrors StaffList drawer) ---- */
.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;
}
/* ---- section + kv list (mirrors StaffList drawer) ---- */
.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>
+15 -1
View File
@@ -511,6 +511,20 @@ function openDrawer(row: Account): void {
drawerOpen.value = true
}
/**
* 员工姓名 click → standalone 员工详情 page (mirrors 云客 userDetailNew).
* The 员工姓名 cell is a small, deliberate click target, distinct from the
* row-click quick-preview drawer. Prefer the structured `user_id`; fall back
* to `account_id` for accounts that carry no staff binding (so the page can
* still resolve by the account identifier).
*/
function goToDetail(row: Account): void {
router.push({
name: 'staff-detail',
params: { userId: row.user_id ?? row.account_id },
})
}
const drawerSections = computed(() =>
drawerAccount.value ? buildAccountSections(drawerAccount.value) : [],
)
@@ -608,7 +622,7 @@ onMounted(() => {
</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>
<el-button link type="primary" size="small" class="yk-staffinfo__v" @click.stop="goToDetail(row)">{{ cellText(row, 'userName') }}</el-button>
</div>
<div class="yk-staffinfo__pair">
<span class="yk-staffinfo__k">微信手机号</span>