This commit is contained in:
2026-08-26 15:48:34 +08:00
parent d03f7b3f5d
commit fac3153472
20 changed files with 917 additions and 23 deletions
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { FileUp, MessageSquareText, Pencil, Trash2 } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { formatOverseasDateTime } from '../lastMilePresentation'
import {
getCustomerVisit,
listCustomerVisits,
saveCustomerVisit,
uploadCustomerVisitAttachment,
type CustomerVisitAttachment,
type CustomerVisitDetail,
type CustomerVisitRow,
} from '../api/customerVisits'
import { overseasWarehousePermissions as permission } from '../permissions'
const access = useAccessPreviewStore()
const rows = ref<CustomerVisitRow[]>([])
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const summary = reactive({ visitable: 0, pending: 0, completed: 0 })
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ keyword: '', status: '', completedAt: [] })
const fields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '派送单号 / 客户 / 联系人' },
{ key: 'status', type: 'select', label: '回访状态', placeholder: '全部状态', options: [{ label: '待回访', value: 'PENDING' }, { label: '已回访', value: 'COMPLETED' }] },
{ key: 'completedAt', type: 'daterange', label: '派送完成时间' },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 130, fixed: 'left', slotName: 'actions' },
{ key: 'deliveryNo', prop: 'deliveryNo', label: '派送单号', minWidth: 180, fixed: 'left' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 180 },
{ key: 'contact', label: '联系人', minWidth: 170, formatter: (row: CustomerVisitRow) => [row.contactName, row.contactPhone].filter(Boolean).join(' ') || '—' },
{ key: 'deliveryCompletedAt', prop: 'deliveryCompletedAt', label: '完成时间', minWidth: 170, formatter: (row: CustomerVisitRow) => formatOverseasDateTime(row.deliveryCompletedAt) },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 140 },
{ key: 'status', prop: 'status', label: '回访状态', width: 110, slotName: 'status' },
{ key: 'visitedAt', prop: 'visitedAt', label: '回访时间', minWidth: 170, formatter: (row: CustomerVisitRow) => formatOverseasDateTime(row.visitedAt) },
{ key: 'visitResult', prop: 'visitResult', label: '回访结果', minWidth: 130, formatter: (row: CustomerVisitRow) => resultLabels[row.visitResult || ''] || '—' },
{ key: 'attachments', label: '资料', minWidth: 110, formatter: (row: CustomerVisitRow) => row.attachments.length ? `${row.attachments.length} 份` : '—' },
]
const resultLabels: Record<string, string> = { SATISFIED: '满意', NORMAL: '正常', FOLLOW_UP: '需跟进', NO_CONTACT: '未联系上' }
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
loading.value = true
error.value = null
try {
const range = Array.isArray(filters.value.completedAt) ? filters.value.completedAt.map(String) : []
const result = await listCustomerVisits({
page: page.value,
size: pageSize.value,
keyword: text('keyword'),
status: text('status'),
completedFrom: range[0] ? `${range[0]}T00:00:00` : undefined,
completedTo: range[1] ? `${range[1]}T23:59:59` : undefined,
})
rows.value = result.records
total.value = result.total
Object.assign(summary, { visitable: result.visitable, pending: result.pending, completed: result.completed })
} catch (cause) {
rows.value = []
total.value = 0
error.value = getApiErrorDetails(cause, '客户回访列表加载失败')
} finally { loading.value = false }
}
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
const dialogOpen = ref(false)
const detailLoading = ref(false)
const saving = ref(false)
const uploading = ref(false)
const detail = ref<CustomerVisitDetail | null>(null)
const form = reactive({ selectedInboundOrderId: '', visitResult: '', customerFeedback: '', remark: '', attachments: [] as CustomerVisitAttachment[] })
async function openVisit(row: CustomerVisitRow) {
dialogOpen.value = true
detailLoading.value = true
detail.value = null
Object.assign(form, { selectedInboundOrderId: '', visitResult: '', customerFeedback: '', remark: '', attachments: [] })
try {
const value = await getCustomerVisit(row.id, row.customerId)
detail.value = value
Object.assign(form, {
selectedInboundOrderId: value.inboundOrders.find(option => option.inboundCode === value.visit.selectedInboundCode)?.inboundOrderId || (value.inboundOrders.length === 1 ? value.inboundOrders[0]?.inboundOrderId : '') || '',
visitResult: value.visit.visitResult || '',
customerFeedback: value.customerFeedback || '',
remark: value.remark || '',
attachments: [...value.visit.attachments],
})
} catch (cause) {
ElMessage.error(getApiErrorDetails(cause, '回访信息加载失败').message)
dialogOpen.value = false
} finally { detailLoading.value = false }
}
async function selectFiles(event: Event) {
const input = event.target as HTMLInputElement
const selected = Array.from(input.files || [])
input.value = ''
if (!detail.value || !selected.length) return
if (form.attachments.length + selected.length > 20) { ElMessage.warning('最多保留20份回访资料'); return }
uploading.value = true
try {
for (const file of selected) {
const uploaded = await uploadCustomerVisitAttachment(detail.value.visit.id, detail.value.visit.customerId, file)
if (!form.attachments.some(item => item.fileId === uploaded.fileId)) form.attachments.push(uploaded)
}
ElMessage.success('回访资料上传成功')
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '回访资料上传失败').message) }
finally { uploading.value = false }
}
function removeAttachment(fileId: string) {
form.attachments = form.attachments.filter(item => item.fileId !== fileId)
}
async function submit() {
if (!detail.value || !form.selectedInboundOrderId || !form.visitResult) {
ElMessage.warning('请选择本次回访的入仓单和回访结果')
return
}
saving.value = true
try {
await saveCustomerVisit(detail.value.visit.id, detail.value.visit.customerId, {
selectedInboundOrderId: form.selectedInboundOrderId,
visitResult: form.visitResult,
customerFeedback: form.customerFeedback.trim() || undefined,
remark: form.remark.trim() || undefined,
attachments: form.attachments,
})
dialogOpen.value = false
ElMessage.success(detail.value.visit.status === 'COMPLETED' ? '再次回访已保存' : '回访已完成')
await load()
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '回访保存失败').message) }
finally { saving.value = false }
}
function fileSize(value: number) { return value >= 1024 * 1024 ? `${(value / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB` }
onMounted(load)
</script>
<template>
<AdminPage>
<ListPageLayout aria-label="客户回访列表">
<template #toolbar>
<section class="visit-summary surface-panel" aria-label="回访统计">
<div><strong>{{ summary.visitable }}</strong><span>可回访订单</span></div>
<div><strong>{{ summary.pending }}</strong><span>待回访</span></div>
<div><strong>{{ summary.completed }}</strong><span>已完成回访</span></div>
</section>
<QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="客户回访筛选" preference-key="overseas-customer-visits-query" @search="search" @reset="reset" />
</template>
<template v-if="error" #state><LoadErrorState title="客户回访列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-customer-visits" row-key="id" empty-text="暂无可回访派送单" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }"><el-button v-if="access.can(permission.customerVisitEdit)" size="small" type="primary" plain @click="openVisit(row as CustomerVisitRow)"><component :is="row.status === 'COMPLETED' ? Pencil : MessageSquareText" :size="15" />{{ row.status === 'COMPLETED' ? '再次回访' : '回访' }}</el-button></template>
<template #status="{ row }"><StatusBadge :status="row.status === 'COMPLETED' ? '已回访' : '待回访'" /></template>
<template #pagination-left>共 {{ total }} 条回访记录</template>
</DataTable>
</section>
</ListPageLayout>
</AdminPage>
<AppDialog v-model="dialogOpen" title="客户回访" width="min(920px, calc(100vw - 28px))" confirm-text="保存回访" :loading="detailLoading" :confirm-loading="saving" :confirm-disabled="uploading" @confirm="submit">
<template v-if="detail">
<section class="visit-order-summary">
<div><span>派送单号</span><strong>{{ detail.visit.deliveryNo }}</strong></div>
<div><span>客户</span><strong>{{ detail.visit.customerName || '—' }}</strong></div>
<div><span>完成时间</span><strong>{{ formatOverseasDateTime(detail.visit.deliveryCompletedAt) }}</strong></div>
<div><span>目的地</span><strong>{{ detail.visit.destination || '—' }}</strong></div>
</section>
<el-form label-position="top" class="visit-form">
<el-form-item label="选择本次回访的入仓单" required>
<div class="inbound-options">
<label v-for="option in detail.inboundOrders" :key="option.inboundOrderId" class="inbound-option" :class="{ selected: form.selectedInboundOrderId === option.inboundOrderId }">
<el-radio v-model="form.selectedInboundOrderId" :value="option.inboundOrderId"><strong>{{ option.inboundCode }}</strong></el-radio>
<span>尾程单:{{ option.lastMileOrderNo }}</span><span>目的仓:{{ option.destinationWarehouseCode || option.destinationWarehouseName || '—' }}</span><span>箱数:{{ option.packageCount }}</span>
</label>
</div>
</el-form-item>
<el-form-item label="回访结果" required><el-select v-model="form.visitResult" placeholder="请选择回访结果"><el-option v-for="(label, value) in resultLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="客户反馈"><el-input v-model="form.customerFeedback" type="textarea" :rows="4" maxlength="2000" show-word-limit placeholder="记录客户对派送服务的反馈" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="补充后续跟进事项" /></el-form-item>
<el-form-item label="回访资料">
<label class="file-picker" :class="{ disabled: uploading }"><FileUp :size="16" />{{ uploading ? '上传中...' : '选择文件' }}<input type="file" multiple :disabled="uploading" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.webp" @change="selectFiles" /></label>
<span class="file-tip">支持 PDF、Office、JPG、PNG、WEBP,单个不超过20MB</span>
<div v-if="form.attachments.length" class="attachment-list">
<div v-for="file in form.attachments" :key="file.fileId"><span>{{ file.fileName }}</span><small>{{ fileSize(file.sizeBytes) }}</small><el-button text type="danger" aria-label="移除资料" @click="removeAttachment(file.fileId)"><Trash2 :size="15" /></el-button></div>
</div>
<div v-else class="attachment-empty">暂未上传回访资料</div>
</el-form-item>
</el-form>
</template>
</AppDialog>
</template>
<style scoped>
.visit-summary { display: flex; gap: 12px; padding: 12px; }
.visit-summary > div { display: flex; align-items: baseline; gap: 8px; min-width: 140px; padding: 8px 14px; border: 1px solid var(--cc-border-color); border-radius: 8px; }
.visit-summary strong { font-size: 22px; color: var(--cc-text-primary); } .visit-summary span { color: var(--cc-text-secondary); }
.visit-order-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 28px; padding: 18px; margin-bottom: 18px; border: 1px solid var(--cc-border-color); border-radius: 8px; background: var(--cc-bg-muted); }
.visit-order-summary div { display: grid; gap: 5px; } .visit-order-summary span { color: var(--cc-text-secondary); } .visit-order-summary strong { color: var(--cc-text-primary); }
.inbound-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; width: 100%; }
.inbound-option { display: grid; gap: 6px; padding: 12px; border: 1px solid var(--cc-border-color); border-radius: 8px; cursor: pointer; color: var(--cc-text-secondary); }
.inbound-option.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.file-picker { display: inline-flex; align-items: center; gap: 6px; padding: 8px 12px; color: var(--el-color-primary); border: 1px solid var(--el-color-primary); border-radius: 6px; cursor: pointer; }
.file-picker input { display: none; } .file-picker.disabled { opacity: .6; cursor: not-allowed; } .file-tip { margin-left: 12px; color: var(--cc-text-secondary); }
.attachment-list, .attachment-empty { width: 100%; margin-top: 12px; }
.attachment-list > div { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 10px; padding: 8px 10px; border-bottom: 1px solid var(--cc-border-color); }
.attachment-list small, .attachment-empty { color: var(--cc-text-secondary); }
@media (max-width: 720px) { .visit-summary { flex-wrap: wrap; } .visit-order-summary, .inbound-options { grid-template-columns: 1fr; } .file-tip { display: block; margin: 8px 0 0; } }
</style>