feat(staff): 员工详情独立页(StaffDetail.vue,替代 drawer 降级,对齐云客 userDetailNew)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> 返回
|
||||
</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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user