feat: show customer service timeline
This commit is contained in:
parent
c0475362cb
commit
bf4d874770
@ -37,6 +37,63 @@ export const getLeads = (status = 'pending') =>
|
||||
export const getServiceCustomers = (params: Record<string, unknown> = {}) =>
|
||||
apiGet('/admin/service-customers', params)
|
||||
|
||||
export type CustomerTimelineItem = {
|
||||
eventId: string
|
||||
eventType: string
|
||||
occurredAt?: string | null
|
||||
title: string
|
||||
description: string
|
||||
source?: string
|
||||
actorRole?: string | null
|
||||
actorName?: string | null
|
||||
appointment?: {
|
||||
id: number
|
||||
appointmentTime?: string | null
|
||||
endTime?: string | null
|
||||
petName?: string | null
|
||||
petType?: string | null
|
||||
serviceType?: string | null
|
||||
durationMinutes?: number
|
||||
currentStatus?: string | null
|
||||
}
|
||||
report?: {
|
||||
id: number
|
||||
appointmentId?: number | null
|
||||
petName?: string | null
|
||||
serviceType?: string | null
|
||||
sendStatus?: 'unknown' | 'unsent' | 'sent' | null
|
||||
sentAt?: string | null
|
||||
sendChannel?: 'wechat' | 'qr' | 'other' | null
|
||||
}
|
||||
lead?: {
|
||||
id: number
|
||||
reportId?: number | null
|
||||
petName?: string | null
|
||||
serviceType?: string | null
|
||||
remindDate?: string | null
|
||||
remindStatus?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type CustomerTimelineResponse = {
|
||||
customer: {
|
||||
storeCustomerId: number
|
||||
displayName?: string | null
|
||||
phoneMasked?: string | null
|
||||
originSource?: string | null
|
||||
firstContactAt?: string | null
|
||||
lastContactAt?: string | null
|
||||
}
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
hasMore: boolean
|
||||
list: CustomerTimelineItem[]
|
||||
}
|
||||
|
||||
export const getServiceCustomerTimeline = (storeCustomerId: number, page = 1, pageSize = 20) =>
|
||||
apiGet<CustomerTimelineResponse>('/admin/service-customers/timeline', { storeCustomerId, page, pageSize })
|
||||
|
||||
export const getWorkbenchReportFunnel = (date?: string) =>
|
||||
apiGet('/admin/workbench/report-funnel', date ? { date } : {})
|
||||
|
||||
|
||||
@ -35,17 +35,23 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer v-model="drawer" title="客户详情" size="420px">
|
||||
<el-drawer v-model="drawer" title="客户详情" size="580px">
|
||||
<template v-if="current">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="姓名">{{ current.displayName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机">{{ current.phoneMasked }}</el-descriptions-item>
|
||||
<el-descriptions-item label="首次来源">{{ formatOriginSource(current.originSource) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="首次接触">{{ current.firstContactAt || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近接触">{{ current.lastContactAt || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近到店">{{ current.lastVisitAt || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="留资">{{ current.leadStatus || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宠物">
|
||||
<section class="customer-profile">
|
||||
<div>
|
||||
<div class="profile-kicker">STORE CUSTOMER</div>
|
||||
<h3>{{ timelineCustomer?.displayName || current.displayName || '客户' }}</h3>
|
||||
<p>{{ timelineCustomer?.phoneMasked || current.phoneMasked || '暂无手机号' }}</p>
|
||||
</div>
|
||||
<el-tag effect="light" round>{{ formatOriginSource(timelineCustomer?.originSource || current.originSource) }}</el-tag>
|
||||
</section>
|
||||
|
||||
<el-descriptions :column="2" border size="small" class="customer-facts">
|
||||
<el-descriptions-item label="首次接触">{{ formatDateTime(timelineCustomer?.firstContactAt || current.firstContactAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近接触">{{ formatDateTime(timelineCustomer?.lastContactAt || current.lastContactAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近到店">{{ formatDateTime(current.lastVisitAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="回访状态">{{ formatLeadStatus(current.leadStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宠物" :span="2">
|
||||
<el-tag v-for="p in current.pets || []" :key="p.name" size="small" class="pet-tag">{{ p.name }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
@ -53,6 +59,58 @@
|
||||
<el-button v-if="current.lastReportId" type="primary" @click="goReport(current)">最近报告</el-button>
|
||||
<el-button v-if="current.hasPendingLead" @click="goLead">去回访</el-button>
|
||||
</div>
|
||||
|
||||
<div class="timeline-head">
|
||||
<div>
|
||||
<h3>服务时间线</h3>
|
||||
<p>按业务事实发生时间倒序,共 {{ timelineTotal }} 条</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-loading="timelineLoading" class="timeline-shell">
|
||||
<el-empty v-if="!timelineLoading && timelineItems.length === 0" description="暂无可用业务事实" :image-size="72" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item
|
||||
v-for="item in timelineItems"
|
||||
:key="item.eventId"
|
||||
:timestamp="formatDateTime(item.occurredAt)"
|
||||
placement="top"
|
||||
:type="timelineTone(item.eventType)"
|
||||
:hollow="item.eventType === 'report_opened' || item.eventType === 'report_reopened'"
|
||||
>
|
||||
<article class="timeline-event">
|
||||
<div class="timeline-title-row">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span v-if="item.actorName || item.actorRole" class="event-actor">
|
||||
{{ item.actorName || roleLabel(item.actorRole) }}
|
||||
</span>
|
||||
</div>
|
||||
<p>{{ item.description }}</p>
|
||||
<div class="event-tags">
|
||||
<el-tag v-if="item.appointment?.currentStatus" size="small" effect="plain">
|
||||
预约{{ appointmentStatusLabel(item.appointment.currentStatus) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="item.report?.sendStatus" size="small" effect="plain">
|
||||
{{ reportSendLabel(item.report.sendStatus) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="item.lead?.remindStatus" size="small" effect="plain">
|
||||
{{ formatLeadStatus(item.lead.remindStatus) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-if="item.appointment || item.report || item.lead" class="event-actions">
|
||||
<el-button v-if="item.appointment" link type="primary" @click="goAppointment(item.appointment.id)">查看预约</el-button>
|
||||
<el-button v-if="item.report" link type="primary" @click="goReportId(item.report.id)">查看报告</el-button>
|
||||
<el-button v-if="item.lead" link type="primary" @click="goLead">进入回访池</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-button
|
||||
v-if="timelineHasMore"
|
||||
class="load-more"
|
||||
:loading="timelineLoadingMore"
|
||||
@click="loadMoreTimeline"
|
||||
>加载更早记录</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
@ -61,7 +119,13 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getServiceCustomers } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getServiceCustomers,
|
||||
getServiceCustomerTimeline,
|
||||
type CustomerTimelineItem,
|
||||
type CustomerTimelineResponse,
|
||||
} from '@/api'
|
||||
|
||||
type CustomerRow = {
|
||||
storeCustomerId: number
|
||||
@ -88,6 +152,13 @@ const hasPendingLead = ref(false)
|
||||
const q = ref('')
|
||||
const drawer = ref(false)
|
||||
const current = ref<CustomerRow | null>(null)
|
||||
const timelineCustomer = ref<CustomerTimelineResponse['customer'] | null>(null)
|
||||
const timelineItems = ref<CustomerTimelineItem[]>([])
|
||||
const timelineTotal = ref(0)
|
||||
const timelinePage = ref(1)
|
||||
const timelineHasMore = ref(false)
|
||||
const timelineLoading = ref(false)
|
||||
const timelineLoadingMore = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@ -110,9 +181,50 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(row: CustomerRow) {
|
||||
async function openDetail(row: CustomerRow) {
|
||||
current.value = row
|
||||
drawer.value = true
|
||||
timelineCustomer.value = null
|
||||
timelineItems.value = []
|
||||
timelineTotal.value = 0
|
||||
timelinePage.value = 1
|
||||
timelineHasMore.value = false
|
||||
timelineLoading.value = true
|
||||
try {
|
||||
await fetchTimeline(row.storeCustomerId, 1, false)
|
||||
} catch {
|
||||
ElMessage.error('客户时间线加载失败,请稍后重试')
|
||||
} finally {
|
||||
timelineLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTimeline(storeCustomerId: number, page: number, append: boolean) {
|
||||
const response = await getServiceCustomerTimeline(storeCustomerId, page, 20)
|
||||
if (current.value?.storeCustomerId !== storeCustomerId) return
|
||||
if (response.code !== 200 || !response.data) {
|
||||
ElMessage.error(response.message || '客户时间线加载失败')
|
||||
return
|
||||
}
|
||||
timelineCustomer.value = response.data.customer
|
||||
timelineItems.value = append
|
||||
? [...timelineItems.value, ...response.data.list]
|
||||
: response.data.list
|
||||
timelineTotal.value = response.data.total
|
||||
timelinePage.value = response.data.page
|
||||
timelineHasMore.value = response.data.hasMore
|
||||
}
|
||||
|
||||
async function loadMoreTimeline() {
|
||||
if (!current.value || !timelineHasMore.value || timelineLoadingMore.value) return
|
||||
timelineLoadingMore.value = true
|
||||
try {
|
||||
await fetchTimeline(current.value.storeCustomerId, timelinePage.value + 1, true)
|
||||
} catch {
|
||||
ElMessage.error('更早的客户记录加载失败,请稍后重试')
|
||||
} finally {
|
||||
timelineLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goReport(row: CustomerRow) {
|
||||
@ -120,10 +232,65 @@ function goReport(row: CustomerRow) {
|
||||
router.push({ path: '/reports', query: { q: String(row.lastReportId) } })
|
||||
}
|
||||
|
||||
function goReportId(reportId: number) {
|
||||
drawer.value = false
|
||||
router.push({ path: '/reports', query: { q: String(reportId) } })
|
||||
}
|
||||
|
||||
function goAppointment(appointmentId: number) {
|
||||
drawer.value = false
|
||||
router.push({ path: '/appointments', query: { id: String(appointmentId) } })
|
||||
}
|
||||
|
||||
function goLead() {
|
||||
drawer.value = false
|
||||
router.push({ path: '/leads', query: { due: 'today' } })
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return '—'
|
||||
const normalized = String(value).replace('T', ' ')
|
||||
return normalized.length >= 16 ? normalized.slice(0, 16) : normalized
|
||||
}
|
||||
|
||||
function formatLeadStatus(value?: string | null) {
|
||||
if (value === 'pending') return '待回访'
|
||||
if (value === 'sent') return '已发送提醒'
|
||||
if (value === 'canceled') return '已取消'
|
||||
if (value === 'unsubscribed') return '已退订'
|
||||
return value || '—'
|
||||
}
|
||||
|
||||
function appointmentStatusLabel(value?: string | null) {
|
||||
if (value === 'new') return '待开始'
|
||||
if (value === 'doing') return '服务中'
|
||||
if (value === 'done') return '已完成'
|
||||
if (value === 'cancel') return '已取消'
|
||||
return value || '未知'
|
||||
}
|
||||
|
||||
function reportSendLabel(value?: string | null) {
|
||||
if (value === 'sent') return '报告已发送'
|
||||
if (value === 'unsent') return '报告待发送'
|
||||
if (value === 'unknown') return '发送状态未知'
|
||||
return '报告'
|
||||
}
|
||||
|
||||
function roleLabel(value?: string | null) {
|
||||
if (value === 'boss') return '老板'
|
||||
if (value === 'staff') return '员工'
|
||||
if (value === 'customer') return '宠主'
|
||||
return '系统'
|
||||
}
|
||||
|
||||
function timelineTone(eventType: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
if (eventType === 'service_completed' || eventType === 'report_sent') return 'success'
|
||||
if (eventType === 'lead_submitted') return 'warning'
|
||||
if (eventType === 'appointment_status_changed') return 'danger'
|
||||
if (eventType === 'report_opened' || eventType === 'report_reopened') return 'info'
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
function formatSource(value?: string) {
|
||||
if (value === 'appointment+lead') return '预约 + 报告留资'
|
||||
if (value === 'appointment') return '预约'
|
||||
@ -151,4 +318,94 @@ onMounted(load)
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.customer-profile {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
background: radial-gradient(circle at 88% 0, rgb(47 111 237 / 12%), transparent 38%), #f7f9fd;
|
||||
border: 1px solid #e0e8f5;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.profile-kicker {
|
||||
color: #2f6fed;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.customer-profile h3 {
|
||||
margin: 6px 0 3px;
|
||||
color: #24324a;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.customer-profile p,
|
||||
.timeline-head p,
|
||||
.timeline-event p {
|
||||
margin: 0;
|
||||
color: #778398;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.customer-facts {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.timeline-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin: 28px 0 18px;
|
||||
}
|
||||
|
||||
.timeline-head h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.timeline-shell {
|
||||
min-height: 160px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.timeline-event {
|
||||
padding: 12px 14px;
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #e8ebf0;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.timeline-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.event-actor {
|
||||
color: #8d97a8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.event-tags,
|
||||
.event-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
margin-bottom: -5px;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user