229 lines
15 KiB
Vue
229 lines
15 KiB
Vue
<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'
|
||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||
|
||
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-on-reset="false" @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 class="visit-order-reference">尾程单:<OrderReferenceText :value="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); }
|
||
.visit-order-reference { display: flex; min-width: 0; align-items: baseline; gap: 3px; }
|
||
.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>
|