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'),
|
component: () => import('@/views/StaffList.vue'),
|
||||||
meta: { title: '员工列表' },
|
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',
|
path: '/accounts',
|
||||||
redirect: { name: 'staff' },
|
redirect: { name: 'staff' },
|
||||||
|
|||||||
@@ -156,6 +156,21 @@ function openDrawer(row: PhoneCall): void {
|
|||||||
drawerOpen.value = true
|
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。 */
|
/** 把通话类型(voice_call/wa_voice_call)映射到聊天渠道(wechat/whatsapp)。电话返回 null。 */
|
||||||
function chatChannel(ch: CallChannel): 'wechat' | 'whatsapp' | null {
|
function chatChannel(ch: CallChannel): 'wechat' | 'whatsapp' | null {
|
||||||
return ch === 'voice_call' ? 'wechat' : ch === 'wa_voice_call' ? '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>
|
||||||
<el-table-column label="员工信息" min-width="130">
|
<el-table-column label="员工信息" min-width="130">
|
||||||
<template #default="{ row }">
|
<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>
|
<span v-else>--</span>
|
||||||
<div class="yk-cell__sub">{{ cell(row, 'userPhone') || '--' }}</div>
|
<div class="yk-cell__sub">{{ cell(row, 'userPhone') || '--' }}</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -303,7 +318,7 @@ onMounted(load)
|
|||||||
<el-table-column label="员工信息" min-width="160">
|
<el-table-column label="员工信息" min-width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div>
|
<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 v-else>--</span>
|
||||||
<span> · {{ cell(row, 'userPhone') || '--' }}</span>
|
<span> · {{ cell(row, 'userPhone') || '--' }}</span>
|
||||||
</div>
|
</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
|
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(() =>
|
const drawerSections = computed(() =>
|
||||||
drawerAccount.value ? buildAccountSections(drawerAccount.value) : [],
|
drawerAccount.value ? buildAccountSections(drawerAccount.value) : [],
|
||||||
)
|
)
|
||||||
@@ -608,7 +622,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="yk-staffinfo__pair">
|
<div class="yk-staffinfo__pair">
|
||||||
<span class="yk-staffinfo__k">员工姓名</span>
|
<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>
|
||||||
<div class="yk-staffinfo__pair">
|
<div class="yk-staffinfo__pair">
|
||||||
<span class="yk-staffinfo__k">微信手机号</span>
|
<span class="yk-staffinfo__k">微信手机号</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user