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

229 lines
15 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 { 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>