feat: show customer service timeline

This commit is contained in:
malei 2026-08-02 01:22:09 +08:00
parent c0475362cb
commit bf4d874770
2 changed files with 326 additions and 12 deletions

View File

@ -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 } : {})

View File

@ -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>