feat: turn leads into actionable follow-up tasks
This commit is contained in:
parent
bf4d874770
commit
078ae0f9d1
@ -34,6 +34,69 @@ export const startHighlight = (reportId: number) =>
|
||||
export const getLeads = (status = 'pending') =>
|
||||
apiGet('/report/leads', { status })
|
||||
|
||||
export type FollowUpTaskView = {
|
||||
taskId: string
|
||||
storeCustomerId: number
|
||||
sourceLeadId: number
|
||||
reportId?: number | null
|
||||
displayName?: string | null
|
||||
phone?: string | null
|
||||
phoneMasked?: string | null
|
||||
petName?: string | null
|
||||
serviceType?: string | null
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'canceled'
|
||||
outcome?: 'rebooked' | 'not_interested' | 'invalid_contact' | 'unsubscribed' | null
|
||||
dueDate: string
|
||||
attemptCount: number
|
||||
lastAttemptOutcome?: 'no_answer' | 'follow_later' | null
|
||||
assignedUserId?: number | null
|
||||
assignedUserName?: string | null
|
||||
assignedToCurrentUser: boolean
|
||||
lastContactedAt?: string | null
|
||||
closedAt?: string | null
|
||||
rebookedAppointmentId?: number | null
|
||||
overdue: boolean
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export type FollowUpTaskPage = {
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
hasMore: boolean
|
||||
list: FollowUpTaskView[]
|
||||
}
|
||||
|
||||
export const getFollowUpTasks = (params: Record<string, unknown> = {}) =>
|
||||
apiGet<FollowUpTaskPage>('/admin/follow-up-tasks', params)
|
||||
|
||||
export const startFollowUpTask = (taskId: string) =>
|
||||
apiPost<FollowUpTaskView>(`/admin/follow-up-tasks/${encodeURIComponent(taskId)}/start`)
|
||||
|
||||
export const rescheduleFollowUpTask = (
|
||||
taskId: string,
|
||||
dueDate: string,
|
||||
reason: 'no_answer' | 'follow_later',
|
||||
) => apiPost<FollowUpTaskView>(
|
||||
`/admin/follow-up-tasks/${encodeURIComponent(taskId)}/reschedule`,
|
||||
{ dueDate, reason },
|
||||
)
|
||||
|
||||
export const closeFollowUpTask = (
|
||||
taskId: string,
|
||||
outcome: 'not_interested' | 'invalid_contact',
|
||||
) => apiPost<FollowUpTaskView>(
|
||||
`/admin/follow-up-tasks/${encodeURIComponent(taskId)}/close`,
|
||||
{ outcome },
|
||||
)
|
||||
|
||||
export const createAppointment = (data: Record<string, unknown>) =>
|
||||
apiPost('/appointment/create', data)
|
||||
|
||||
export const getAppointmentAvailableSlots = (storeId: number, date: string, serviceType: string) =>
|
||||
apiGet('/appointment/available-slots', { storeId, date, serviceType })
|
||||
|
||||
export const getServiceCustomers = (params: Record<string, unknown> = {}) =>
|
||||
apiGet('/admin/service-customers', params)
|
||||
|
||||
@ -73,6 +136,14 @@ export type CustomerTimelineItem = {
|
||||
remindDate?: string | null
|
||||
remindStatus?: string | null
|
||||
}
|
||||
followUpTask?: {
|
||||
taskId: string
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'canceled'
|
||||
outcome?: 'rebooked' | 'not_interested' | 'invalid_contact' | 'unsubscribed' | null
|
||||
dueDate?: string | null
|
||||
attemptCount?: number
|
||||
rebookedAppointmentId?: number | null
|
||||
}
|
||||
}
|
||||
|
||||
export type CustomerTimelineResponse = {
|
||||
|
||||
@ -95,11 +95,15 @@
|
||||
<el-tag v-if="item.lead?.remindStatus" size="small" effect="plain">
|
||||
{{ formatLeadStatus(item.lead.remindStatus) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="item.followUpTask?.status" size="small" effect="plain">
|
||||
{{ followUpStatusLabel(item.followUpTask.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-if="item.appointment || item.report || item.lead" class="event-actions">
|
||||
<div v-if="item.appointment || item.report || item.lead || item.followUpTask" 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>
|
||||
<el-button v-if="item.followUpTask" link type="primary" @click="goLead">查看回访任务</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</el-timeline-item>
|
||||
@ -276,6 +280,14 @@ function reportSendLabel(value?: string | null) {
|
||||
return '报告'
|
||||
}
|
||||
|
||||
function followUpStatusLabel(value?: string | null) {
|
||||
if (value === 'pending') return '回访待领取'
|
||||
if (value === 'in_progress') return '回访处理中'
|
||||
if (value === 'completed') return '回访已完成'
|
||||
if (value === 'canceled') return '回访已取消'
|
||||
return '回访任务'
|
||||
}
|
||||
|
||||
function roleLabel(value?: string | null) {
|
||||
if (value === 'boss') return '老板'
|
||||
if (value === 'staff') return '员工'
|
||||
|
||||
@ -1,88 +1,497 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<h2 class="page-title">回访线索</h2>
|
||||
<div class="page-card follow-up-page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<div class="eyebrow">FOLLOW-UP TASKS</div>
|
||||
<h2 class="page-title">回访任务</h2>
|
||||
<p>领取后记录结果;“再次预约”只在真实预约创建成功后自动闭环。</p>
|
||||
</div>
|
||||
<el-button :loading="loading" @click="load">刷新</el-button>
|
||||
</header>
|
||||
|
||||
<div class="filter-row">
|
||||
<el-select v-model="status" style="width: 140px" @change="load">
|
||||
<el-option label="待回访" value="pending" />
|
||||
<el-select v-model="status" style="width: 150px" @change="resetAndLoad">
|
||||
<el-option label="待处理" value="open" />
|
||||
<el-option label="待领取" value="pending" />
|
||||
<el-option label="处理中" value="in_progress" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="已取消" value="canceled" />
|
||||
<el-option label="全部" value="all" />
|
||||
</el-select>
|
||||
<el-checkbox v-model="dueToday" @change="load">到期≤今天</el-checkbox>
|
||||
<el-checkbox v-model="dueToday" @change="resetAndLoad">到期≤今天</el-checkbox>
|
||||
<span class="summary">共 {{ total }} 项</span>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe>
|
||||
<el-table-column prop="petName" label="宠物" width="120" />
|
||||
<el-table-column prop="serviceType" label="服务" min-width="120" />
|
||||
<el-table-column prop="phone" label="手机" width="140" />
|
||||
<el-table-column prop="remindDate" label="建议日期" width="120" />
|
||||
<el-table-column prop="remindStatus" label="状态" width="100" />
|
||||
<el-table-column label="微信" width="80">
|
||||
<template #default="{ row }">{{ row.wechatBound ? '是' : '否' }}</template>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" row-key="taskId" stripe>
|
||||
<el-table-column label="宠主 / 宠物" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.displayName || '客户' }}</strong>
|
||||
<div class="muted">{{ row.petName || '宠物待确认' }} · {{ row.serviceType || '服务待确认' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<el-table-column label="联系" width="150">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.phone || row.phoneMasked || '—' }}</span>
|
||||
<el-button v-if="row.phone" link type="primary" @click="copyPhone(row.phone)">复制</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计划回访" width="130">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ overdue: row.overdue }">{{ row.dueDate || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag>
|
||||
<div v-if="row.outcome" class="muted">{{ outcomeLabel(row.outcome) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处理" width="150">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.assignedUserName || (row.status === 'pending' ? '待领取' : '—') }}</span>
|
||||
<div class="muted">已联系 {{ row.attemptCount || 0 }} 次</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="390" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.reportId"
|
||||
v-if="row.status === 'pending'"
|
||||
link
|
||||
type="primary"
|
||||
@click="openReport(row)"
|
||||
>打开报告</el-button>
|
||||
@click="startTask(row)"
|
||||
>领取</el-button>
|
||||
<template v-if="row.status === 'in_progress' && row.assignedToCurrentUser">
|
||||
<el-button link @click="openReschedule(row, 'no_answer')">未接通</el-button>
|
||||
<el-button link @click="openReschedule(row, 'follow_later')">稍后联系</el-button>
|
||||
<el-button link type="warning" @click="closeTask(row, 'not_interested')">暂无意向</el-button>
|
||||
<el-button link type="danger" @click="closeTask(row, 'invalid_contact')">号码无效</el-button>
|
||||
<el-button link type="success" @click="openRebook(row)">创建预约</el-button>
|
||||
</template>
|
||||
<el-button
|
||||
v-if="row.rebookedAppointmentId"
|
||||
link
|
||||
type="success"
|
||||
@click="goAppointment(row.rebookedAppointmentId)"
|
||||
>查看预约</el-button>
|
||||
<span
|
||||
v-if="row.status === 'in_progress' && !row.assignedToCurrentUser"
|
||||
class="muted"
|
||||
>由其他员工处理</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="total > pageSize"
|
||||
class="pagination"
|
||||
layout="prev, pager, next"
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="changePage"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="rescheduleVisible" title="记录联系结果并改期" width="430px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="本次结果">
|
||||
<el-radio-group v-model="rescheduleReason">
|
||||
<el-radio value="no_answer">未接通</el-radio>
|
||||
<el-radio value="follow_later">宠主希望稍后联系</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="下次回访日期">
|
||||
<el-date-picker
|
||||
v-model="rescheduleDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled-date="disablePastDate"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rescheduleVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveReschedule">确认改期</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="rebookVisible" title="为回访客户创建真实预约" width="520px">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="预约成功后任务才会标记为“已再次预约”;容量不足或创建失败不会改变任务状态。"
|
||||
/>
|
||||
<el-form label-position="top" class="rebook-form">
|
||||
<el-form-item label="宠主">
|
||||
<el-input :model-value="`${rebookTask?.displayName || '客户'} · ${rebookTask?.phone || ''}`" disabled />
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="宠物名">
|
||||
<el-input v-model="rebookForm.petName" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="宠物类型">
|
||||
<el-select v-model="rebookForm.petType" style="width: 100%">
|
||||
<el-option label="猫" value="猫" />
|
||||
<el-option label="狗" value="狗" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="服务项目">
|
||||
<el-select v-model="rebookForm.serviceType" style="width: 100%" @change="loadSlots">
|
||||
<el-option
|
||||
v-for="service in serviceTypes"
|
||||
:key="String(service.id)"
|
||||
:label="`${service.name} · ${service.durationMinutes || 60} 分钟`"
|
||||
:value="String(service.name)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="预约日期">
|
||||
<el-date-picker
|
||||
v-model="rebookForm.date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled-date="disablePastDate"
|
||||
style="width: 100%"
|
||||
@change="loadSlots"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="可约时段">
|
||||
<el-select v-model="rebookForm.time" :loading="slotsLoading" style="width: 100%">
|
||||
<el-option
|
||||
v-for="slot in availableSlots"
|
||||
:key="slot.time"
|
||||
:label="`${slot.time}–${slot.endTime}`"
|
||||
:value="slot.time"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rebookVisible = false">取消</el-button>
|
||||
<el-button type="success" :loading="saving" @click="submitRebook">创建并归因</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getLeads, getReportList } from '@/api'
|
||||
import { reportPublicUrl } from '@/utils/publicLinks'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
closeFollowUpTask,
|
||||
createAppointment,
|
||||
getAdminStore,
|
||||
getAppointmentAvailableSlots,
|
||||
getFollowUpTasks,
|
||||
getServiceTypeList,
|
||||
rescheduleFollowUpTask,
|
||||
startFollowUpTask,
|
||||
type FollowUpTaskView,
|
||||
} from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const status = ref('pending')
|
||||
const saving = ref(false)
|
||||
const rows = ref<FollowUpTaskView[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const status = ref((route.query.status as string) || 'open')
|
||||
const dueToday = ref(route.query.due === 'today')
|
||||
const rows = ref<Array<Record<string, unknown>>>([])
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
const rescheduleVisible = ref(false)
|
||||
const rescheduleTask = ref<FollowUpTaskView | null>(null)
|
||||
const rescheduleReason = ref<'no_answer' | 'follow_later'>('no_answer')
|
||||
const rescheduleDate = ref('')
|
||||
|
||||
const rebookVisible = ref(false)
|
||||
const rebookTask = ref<FollowUpTaskView | null>(null)
|
||||
const storeId = ref<number | null>(null)
|
||||
const serviceTypes = ref<Array<Record<string, unknown>>>([])
|
||||
const slotsLoading = ref(false)
|
||||
const availableSlots = ref<Array<{ time: string; endTime: string }>>([])
|
||||
const rebookForm = reactive({ petName: '', petType: '', serviceType: '', date: '', time: '' })
|
||||
|
||||
function todayString() {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getLeads(status.value)
|
||||
let list = Array.isArray(res.data) ? (res.data as Array<Record<string, unknown>>) : []
|
||||
if (dueToday.value) {
|
||||
const t = todayStr()
|
||||
list = list.filter((l) => {
|
||||
const d = l.remindDate as string | undefined
|
||||
return !d || d <= t
|
||||
})
|
||||
const response = await getFollowUpTasks({
|
||||
status: status.value,
|
||||
dueDateTo: dueToday.value ? todayString() : undefined,
|
||||
page: page.value,
|
||||
pageSize,
|
||||
})
|
||||
if (response.code !== 200 || !response.data) {
|
||||
ElMessage.error(response.message || '回访任务加载失败')
|
||||
return
|
||||
}
|
||||
rows.value = list
|
||||
rows.value = response.data.list
|
||||
total.value = response.data.total
|
||||
} catch {
|
||||
ElMessage.error('回访任务加载失败,请检查网络')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openReport(row: Record<string, unknown>) {
|
||||
const reportId = Number(row.reportId)
|
||||
if (!reportId) return
|
||||
// 优先用列表里的 token;没有则从报告列表反查
|
||||
const listRes = await getReportList({ page: 1, pageSize: 200 })
|
||||
const reports = Array.isArray(listRes.data) ? (listRes.data as Array<Record<string, unknown>>) : []
|
||||
const hit = reports.find((r) => Number(r.id) === reportId)
|
||||
const token = hit?.reportToken as string | undefined
|
||||
if (token) {
|
||||
window.open(reportPublicUrl(token), '_blank')
|
||||
function resetAndLoad() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function changePage(value: number) {
|
||||
page.value = value
|
||||
load()
|
||||
}
|
||||
|
||||
async function startTask(task: FollowUpTaskView) {
|
||||
try {
|
||||
const response = await startFollowUpTask(task.taskId)
|
||||
if (response.code !== 200) {
|
||||
ElMessage.error(response.message || '领取失败')
|
||||
await load()
|
||||
return
|
||||
}
|
||||
ElMessage.success('任务已领取')
|
||||
await load()
|
||||
} catch {
|
||||
ElMessage.error('领取失败,请检查网络')
|
||||
}
|
||||
}
|
||||
|
||||
function openReschedule(task: FollowUpTaskView, reason: 'no_answer' | 'follow_later') {
|
||||
rescheduleTask.value = task
|
||||
rescheduleReason.value = reason
|
||||
rescheduleDate.value = task.dueDate >= todayString() ? task.dueDate : todayString()
|
||||
rescheduleVisible.value = true
|
||||
}
|
||||
|
||||
async function saveReschedule() {
|
||||
if (!rescheduleTask.value || !rescheduleDate.value) {
|
||||
ElMessage.warning('请选择下次回访日期')
|
||||
return
|
||||
}
|
||||
ElMessage.warning('未找到公开链接,已跳转报告中心')
|
||||
await router.push({ path: '/reports', query: { q: String(reportId) } })
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await rescheduleFollowUpTask(
|
||||
rescheduleTask.value.taskId,
|
||||
rescheduleDate.value,
|
||||
rescheduleReason.value,
|
||||
)
|
||||
if (response.code !== 200) {
|
||||
ElMessage.error(response.message || '改期失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已记录本次联系并改期')
|
||||
rescheduleVisible.value = false
|
||||
await load()
|
||||
} catch {
|
||||
ElMessage.error('改期失败,请检查网络')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function closeTask(task: FollowUpTaskView, outcome: 'not_interested' | 'invalid_contact') {
|
||||
const label = outcome === 'not_interested' ? '本轮暂无意向' : '联系方式无效'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认以“${label}”关闭本轮回访?`, '关闭回访任务', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await closeFollowUpTask(task.taskId, outcome)
|
||||
if (response.code !== 200) {
|
||||
ElMessage.error(response.message || '关闭失败')
|
||||
await load()
|
||||
return
|
||||
}
|
||||
ElMessage.success('回访任务已关闭')
|
||||
await load()
|
||||
} catch {
|
||||
ElMessage.error('关闭失败,请检查网络')
|
||||
}
|
||||
}
|
||||
|
||||
async function openRebook(task: FollowUpTaskView) {
|
||||
rebookTask.value = task
|
||||
rebookForm.petName = task.petName || ''
|
||||
rebookForm.petType = ''
|
||||
rebookForm.serviceType = task.serviceType || ''
|
||||
rebookForm.date = ''
|
||||
rebookForm.time = ''
|
||||
availableSlots.value = []
|
||||
rebookVisible.value = true
|
||||
try {
|
||||
const storeResponse = await getAdminStore()
|
||||
if (storeResponse.code !== 200 || !storeResponse.data?.id) {
|
||||
rebookVisible.value = false
|
||||
ElMessage.error(storeResponse.message || '门店信息加载失败')
|
||||
return
|
||||
}
|
||||
storeId.value = Number(storeResponse.data.id)
|
||||
const serviceResponse = await getServiceTypeList(storeId.value)
|
||||
if (serviceResponse.code !== 200) {
|
||||
rebookVisible.value = false
|
||||
ElMessage.error(serviceResponse.message || '服务项目加载失败')
|
||||
return
|
||||
}
|
||||
serviceTypes.value = Array.isArray(serviceResponse.data)
|
||||
? (serviceResponse.data as Array<Record<string, unknown>>)
|
||||
: []
|
||||
} catch {
|
||||
rebookVisible.value = false
|
||||
ElMessage.error('预约基础数据加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSlots() {
|
||||
rebookForm.time = ''
|
||||
availableSlots.value = []
|
||||
if (!storeId.value || !rebookForm.date || !rebookForm.serviceType) return
|
||||
slotsLoading.value = true
|
||||
try {
|
||||
const response = await getAppointmentAvailableSlots(
|
||||
storeId.value,
|
||||
rebookForm.date,
|
||||
rebookForm.serviceType,
|
||||
)
|
||||
if (response.code !== 200) {
|
||||
ElMessage.error(response.message || '可约时段加载失败')
|
||||
return
|
||||
}
|
||||
const data = response.data as { slots?: Array<{ time: string; endTime: string; available: boolean }> } | undefined
|
||||
availableSlots.value = (data?.slots || []).filter((slot) => slot.available)
|
||||
if (availableSlots.value.length === 0) ElMessage.warning('当天暂无可约时段')
|
||||
} catch {
|
||||
ElMessage.error('可约时段加载失败,请检查网络')
|
||||
} finally {
|
||||
slotsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRebook() {
|
||||
const task = rebookTask.value
|
||||
if (!task?.phone || !rebookForm.petName || !rebookForm.petType
|
||||
|| !rebookForm.serviceType || !rebookForm.date || !rebookForm.time) {
|
||||
ElMessage.warning('请补齐宠物、服务和可约时段')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await createAppointment({
|
||||
followUpTaskId: task.taskId,
|
||||
customerPhone: task.phone,
|
||||
customerName: task.displayName || '',
|
||||
petName: rebookForm.petName,
|
||||
petType: rebookForm.petType,
|
||||
serviceType: rebookForm.serviceType,
|
||||
appointmentTime: `${rebookForm.date}T${rebookForm.time}:00`,
|
||||
})
|
||||
if (response.code !== 200) {
|
||||
ElMessage.error(response.message || '预约创建失败,任务状态未改变')
|
||||
if (response.bizCode === 'CAPACITY_FULL') await loadSlots()
|
||||
return
|
||||
}
|
||||
ElMessage.success('预约已创建并完成回访归因')
|
||||
rebookVisible.value = false
|
||||
await load()
|
||||
} catch {
|
||||
ElMessage.error('预约创建失败,请检查网络;任务状态未改变')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPhone(phone: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(phone)
|
||||
ElMessage.success('手机号已复制')
|
||||
} catch {
|
||||
ElMessage.warning(`请手动复制:${phone}`)
|
||||
}
|
||||
}
|
||||
|
||||
function goAppointment(id: number) {
|
||||
router.push({ path: '/appointments', query: { id: String(id) } })
|
||||
}
|
||||
|
||||
function statusLabel(value: FollowUpTaskView['status']) {
|
||||
return { pending: '待领取', in_progress: '处理中', completed: '已完成', canceled: '已取消' }[value]
|
||||
}
|
||||
|
||||
function statusTag(value: FollowUpTaskView['status']): 'primary' | 'success' | 'warning' | 'info' {
|
||||
if (value === 'in_progress') return 'primary'
|
||||
if (value === 'completed') return 'success'
|
||||
if (value === 'canceled') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function outcomeLabel(value: NonNullable<FollowUpTaskView['outcome']>) {
|
||||
return {
|
||||
rebooked: '已再次预约',
|
||||
not_interested: '本轮暂无意向',
|
||||
invalid_contact: '联系方式无效',
|
||||
unsubscribed: '宠主已退订',
|
||||
}[value]
|
||||
}
|
||||
|
||||
function disablePastDate(date: Date) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return date.getTime() < today.getTime()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.follow-up-page {
|
||||
min-width: 980px;
|
||||
}
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-head p,
|
||||
.muted,
|
||||
.summary {
|
||||
color: #7a8699;
|
||||
font-size: 12px;
|
||||
}
|
||||
.eyebrow {
|
||||
color: #2f6fed;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.overdue {
|
||||
color: #d64545;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pagination {
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.rebook-form {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -102,7 +102,7 @@
|
||||
<span class="section-kicker">服务结果</span>
|
||||
<h3>今天的报告走到哪一步</h3>
|
||||
</div>
|
||||
<span class="funnel-note">发送已接入真实确认;再次预约仍待归因</span>
|
||||
<span class="funnel-note">发送与再次预约均来自真实业务回执</span>
|
||||
</div>
|
||||
<div class="funnel-grid">
|
||||
<div v-for="step in funnelSteps" :key="step.label" class="funnel-step">
|
||||
@ -134,7 +134,7 @@ type WorkbenchMetrics = {
|
||||
}
|
||||
|
||||
type ActionItem = {
|
||||
resourceType: 'appointment' | 'report' | 'lead'
|
||||
resourceType: 'appointment' | 'report' | 'follow_up_task'
|
||||
resourceId: number
|
||||
petName?: string | null
|
||||
serviceType?: string | null
|
||||
@ -244,7 +244,7 @@ const groupDefinitions: Record<string, { title: string; description: string; to:
|
||||
},
|
||||
follow_up_due: {
|
||||
title: '回访日期已到',
|
||||
description: '只显示低敏摘要;进入回访池后再按权限查看联系信息。',
|
||||
description: '领取任务、记录联系结果;只有真实创建预约才会计入转化。',
|
||||
to: { path: '/leads', query: { due: 'today' } },
|
||||
},
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user