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> = {}) =>
|
export const getServiceCustomers = (params: Record<string, unknown> = {}) =>
|
||||||
apiGet('/admin/service-customers', params)
|
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) =>
|
export const getWorkbenchReportFunnel = (date?: string) =>
|
||||||
apiGet('/admin/workbench/report-funnel', date ? { date } : {})
|
apiGet('/admin/workbench/report-funnel', date ? { date } : {})
|
||||||
|
|
||||||
|
|||||||
@ -35,17 +35,23 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<el-drawer v-model="drawer" title="客户详情" size="420px">
|
<el-drawer v-model="drawer" title="客户详情" size="580px">
|
||||||
<template v-if="current">
|
<template v-if="current">
|
||||||
<el-descriptions :column="1" border>
|
<section class="customer-profile">
|
||||||
<el-descriptions-item label="姓名">{{ current.displayName }}</el-descriptions-item>
|
<div>
|
||||||
<el-descriptions-item label="手机">{{ current.phoneMasked }}</el-descriptions-item>
|
<div class="profile-kicker">STORE CUSTOMER</div>
|
||||||
<el-descriptions-item label="首次来源">{{ formatOriginSource(current.originSource) }}</el-descriptions-item>
|
<h3>{{ timelineCustomer?.displayName || current.displayName || '客户' }}</h3>
|
||||||
<el-descriptions-item label="首次接触">{{ current.firstContactAt || '—' }}</el-descriptions-item>
|
<p>{{ timelineCustomer?.phoneMasked || current.phoneMasked || '暂无手机号' }}</p>
|
||||||
<el-descriptions-item label="最近接触">{{ current.lastContactAt || '—' }}</el-descriptions-item>
|
</div>
|
||||||
<el-descriptions-item label="最近到店">{{ current.lastVisitAt || '—' }}</el-descriptions-item>
|
<el-tag effect="light" round>{{ formatOriginSource(timelineCustomer?.originSource || current.originSource) }}</el-tag>
|
||||||
<el-descriptions-item label="留资">{{ current.leadStatus || '—' }}</el-descriptions-item>
|
</section>
|
||||||
<el-descriptions-item label="宠物">
|
|
||||||
|
<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-tag v-for="p in current.pets || []" :key="p.name" size="small" class="pet-tag">{{ p.name }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
@ -53,6 +59,58 @@
|
|||||||
<el-button v-if="current.lastReportId" type="primary" @click="goReport(current)">最近报告</el-button>
|
<el-button v-if="current.lastReportId" type="primary" @click="goReport(current)">最近报告</el-button>
|
||||||
<el-button v-if="current.hasPendingLead" @click="goLead">去回访</el-button>
|
<el-button v-if="current.hasPendingLead" @click="goLead">去回访</el-button>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</div>
|
</div>
|
||||||
@ -61,7 +119,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 = {
|
type CustomerRow = {
|
||||||
storeCustomerId: number
|
storeCustomerId: number
|
||||||
@ -88,6 +152,13 @@ const hasPendingLead = ref(false)
|
|||||||
const q = ref('')
|
const q = ref('')
|
||||||
const drawer = ref(false)
|
const drawer = ref(false)
|
||||||
const current = ref<CustomerRow | null>(null)
|
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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@ -110,9 +181,50 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDetail(row: CustomerRow) {
|
async function openDetail(row: CustomerRow) {
|
||||||
current.value = row
|
current.value = row
|
||||||
drawer.value = true
|
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) {
|
function goReport(row: CustomerRow) {
|
||||||
@ -120,10 +232,65 @@ function goReport(row: CustomerRow) {
|
|||||||
router.push({ path: '/reports', query: { q: String(row.lastReportId) } })
|
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() {
|
function goLead() {
|
||||||
|
drawer.value = false
|
||||||
router.push({ path: '/leads', query: { due: 'today' } })
|
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) {
|
function formatSource(value?: string) {
|
||||||
if (value === 'appointment+lead') return '预约 + 报告留资'
|
if (value === 'appointment+lead') return '预约 + 报告留资'
|
||||||
if (value === 'appointment') return '预约'
|
if (value === 'appointment') return '预约'
|
||||||
@ -151,4 +318,94 @@ onMounted(load)
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
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>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user