Files
admin-web/src/modules/overseas-warehouse/pages/CustomerVisitsView.vue
T
2026-10-20 14:52:06 +08:00

226 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onMounted, reactive, ref } from 'vue'
import { FileUp } 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 fileInput = ref<HTMLInputElement | null>(null)
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 class="admin-density">
<ListPageLayout aria-label="客户回访列表">
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" 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 #toolbar-start><h1>客户回访</h1></template>
<template #filters>
<dl class="visit-summary density-detail-overview" aria-label="回访统计"><div><dt>可回访订单</dt><dd>{{ summary.visitable }}</dd></div><div><dt>待回访</dt><dd>{{ summary.pending }}</dd></div><div><dt>已完成回访</dt><dd>{{ summary.completed }}</dd></div></dl>
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="客户回访筛选" preference-key="overseas-customer-visits-query" :search-on-reset="false" @search="search" @reset="reset" />
</template>
<template #notices><template v-if="error"><LoadErrorState title="客户回访列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="access.can(permission.customerVisitEdit)" class="density-action" data-tone="neutral" size="small" @click="openVisit(row as CustomerVisitRow)">{{ row.status === 'COMPLETED' ? '再次回访' : '回访' }}</el-button></div></template>
<template #status="{ row }"><StatusBadge :status="row.status === 'COMPLETED' ? '已回访' : '待回访'" /></template>
<template #pagination-left>共 {{ total }} 条回访记录</template>
</DataTable></section>
</ListPageLayout>
</AdminPage>
<AppDialog class="admin-density-dialog" 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">
<dl class="visit-order-summary density-fields">
<div><dt>派送单号</dt><dd>{{ detail.visit.deliveryNo }}</dd></div>
<div><dt>客户</dt><dd>{{ detail.visit.customerName || '—' }}</dd></div>
<div><dt>完成时间</dt><dd>{{ formatOverseasDateTime(detail.visit.deliveryCompletedAt) }}</dd></div>
<div><dt>目的地</dt><dd>{{ detail.visit.destination || '—' }}</dd></div>
</dl>
<el-form label-position="top" class="visit-form density-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 /></el-form-item>
<el-form-item label="回访资料">
<el-button :disabled="uploading" :loading="uploading" @click="fileInput?.click()"><FileUp :size="16" />{{ uploading ? '上传中...' : '选择文件' }}</el-button><input ref="fileInput" hidden type="file" multiple :disabled="uploading" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.webp" @change="selectFiles" />
<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 class="density-action" data-tone="danger" size="small" aria-label="移除资料" @click="removeAttachment(file.fileId)">移除</el-button></div>
</div>
<div v-else class="attachment-empty">暂未上传回访资料</div>
</el-form-item>
</el-form>
</template>
</AppDialog>
</template>
<style scoped>
.visit-summary { flex-wrap: wrap; }
.visit-order-summary.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: var(--admin-space-5); }
.inbound-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); width: 100%; }
.inbound-option { display: grid; min-width: 0; gap: var(--admin-space-2); padding: var(--admin-space-3); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); cursor: pointer; color: var(--cc-muted); overflow-wrap: anywhere; }
.visit-order-reference { display: flex; min-width: 0; align-items: baseline; gap: var(--admin-space-1); }
.inbound-option.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.file-tip { margin-left: var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.attachment-list, .attachment-empty { width: 100%; margin-top: var(--admin-space-3); }
.attachment-list > div { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-2) 0; border-bottom: 1px solid var(--cc-border); overflow-wrap: anywhere; }
.attachment-list small, .attachment-empty { color: var(--cc-muted); font-size: var(--admin-font-caption); }
@media (max-width: 720px) { .visit-summary { flex-wrap: wrap; } .visit-order-summary.density-fields, .inbound-options { grid-template-columns: 1fr; } .file-tip { display: block; margin: 8px 0 0; } }
</style>