Files
admin-web/src/modules/overseas-warehouse/pages/CustomerVisitsView.vue
T

226 lines
14 KiB
Vue
Raw Normal View History

2026-08-26 15:48:34 +08:00
<script setup lang="ts">
2026-09-15 09:58:15 +08:00
import '@/styles/adminDensity.css'
2026-08-26 15:48:34 +08:00
import { onMounted, reactive, ref } from 'vue'
2026-09-15 09:58:15 +08:00
import { FileUp } from '@lucide/vue'
2026-08-26 15:48:34 +08:00
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'
2026-08-26 15:48:34 +08:00
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)
2026-09-15 09:58:15 +08:00
const fileInput = ref<HTMLInputElement | null>(null)
2026-08-26 15:48:34 +08:00
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>
2026-09-15 09:58:15 +08:00
<AdminPage class="admin-density">
2026-08-26 15:48:34 +08:00
<ListPageLayout aria-label="客户回访列表">
2026-09-30 14:01:58 +08:00
<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>
2026-09-15 09:58:15 +08:00
<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>
2026-09-30 14:01:58 +08:00
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="客户回访筛选" preference-key="overseas-customer-visits-query" :search-on-reset="false" @search="search" @reset="reset" />
2026-08-26 15:48:34 +08:00
</template>
2026-09-30 14:01:58 +08:00
<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>
2026-08-26 15:48:34 +08:00
<template #status="{ row }"><StatusBadge :status="row.status === 'COMPLETED' ? '已回访' : '待回访'" /></template>
<template #pagination-left>共 {{ total }} 条回访记录</template>
2026-09-30 14:01:58 +08:00
</DataTable></section>
2026-08-26 15:48:34 +08:00
</ListPageLayout>
</AdminPage>
2026-09-15 09:58:15 +08:00
<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">
2026-08-26 15:48:34 +08:00
<template v-if="detail">
2026-09-15 09:58:15 +08:00
<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">
2026-08-26 15:48:34 +08:00
<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>
2026-08-26 15:48:34 +08:00
</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>
2026-10-20 14:52:06 +08:00
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
2026-08-26 15:48:34 +08:00
<el-form-item label="回访资料">
2026-09-15 09:58:15 +08:00
<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" />
2026-08-26 15:48:34 +08:00
<span class="file-tip">支持 PDF、Office、JPG、PNG、WEBP,单个不超过20MB</span>
<div v-if="form.attachments.length" class="attachment-list">
2026-09-15 09:58:15 +08:00
<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>
2026-08-26 15:48:34 +08:00
</div>
<div v-else class="attachment-empty">暂未上传回访资料</div>
</el-form-item>
</el-form>
</template>
</AppDialog>
</template>
<style scoped>
2026-09-15 09:58:15 +08:00
.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); }
2026-08-26 15:48:34 +08:00
.inbound-option.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
2026-09-15 09:58:15 +08:00
.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; } }
2026-08-26 15:48:34 +08:00
</style>