This commit is contained in:
2026-09-15 09:58:15 +08:00
parent 3f998bdb88
commit 4bf66d9571
90 changed files with 1599 additions and 1388 deletions
@@ -65,6 +65,10 @@ describe('CustomerVisitsView permissions and visit material', () => {
await wrapper.findAll('button').find(button => button.text() === '回访')!.trigger('click')
await flushPromises()
const input = wrapper.find('[data-dialog="客户回访"] input[type="file"]')
const inputClick = vi.spyOn(input.element as HTMLInputElement, 'click').mockImplementation(() => {})
await wrapper.findAll('button').find(button => button.text() === '选择文件')!.trigger('click')
expect(inputClick).toHaveBeenCalledOnce()
inputClick.mockRestore()
const file = new File(['data'], 'visit.pdf', { type: 'application/pdf' })
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
await input.trigger('change')
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onMounted, reactive, ref } from 'vue'
import { FileUp, MessageSquareText, Pencil, Trash2 } from '@lucide/vue'
import { FileUp } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -84,6 +85,7 @@ 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[] })
@@ -155,20 +157,16 @@ onMounted(load)
</script>
<template>
<AdminPage>
<AdminPage class="admin-density">
<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>
<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" 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 #actions="{ row }"><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></template>
<template #status="{ row }"><StatusBadge :status="row.status === 'COMPLETED' ? '已回访' : '待回访'" /></template>
<template #pagination-left>共 {{ total }} 条回访记录</template>
</DataTable>
@@ -176,15 +174,15 @@ onMounted(load)
</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">
<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">
<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">
<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 }">
@@ -197,10 +195,10 @@ onMounted(load)
<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>
<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 text type="danger" aria-label="移除资料" @click="removeAttachment(file.fileId)"><Trash2 :size="15" /></el-button></div>
<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>
@@ -210,19 +208,15 @@ onMounted(load)
</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; }
.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-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; } }
.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>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onMounted, reactive, ref } from 'vue'
import { Eye, Plus } from '@lucide/vue'
import { Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -76,20 +77,20 @@ onMounted(load)
</script>
<template>
<AdminPage><ListPageLayout aria-label="异常订单列表">
<AdminPage class="admin-density"><ListPageLayout aria-label="异常订单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="异常订单筛选" preference-key="overseas-exception-orders-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-exception-orders" row-key="id" empty-text="暂无异常订单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.exceptionOrderCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增异常订单</el-button></template>
<template #actions="{ row }"><el-button size="small" type="primary" plain @click="openDetail(row as ExceptionOrderRow)"><Eye :size="15" />查看</el-button></template>
<template #actions="{ row }"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as ExceptionOrderRow)">查看</el-button></template>
<template #related="{ row }"><div class="related-orders"><span v-if="row.lastMileOrderNo" class="related-order-reference">尾程 <OrderReferenceText :value="String(row.lastMileOrderNo)" /></span><span v-if="row.deliveryNo">派送 {{ row.deliveryNo }}</span><span v-if="row.inboundCode">入仓 {{ row.inboundCode }}</span><span v-if="row.outboundNo">出库 {{ row.outboundNo }}</span></div></template>
<template #handlerName="{ row }">{{ displayHandler(row.handlerName) }}</template>
<template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status)" /></template><template #pagination-left>共 {{ total }} 条异常订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog v-model="createOpen" title="新增异常订单" width="min(760px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="saving" :loading="resolving" @confirm="submit">
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新增异常订单" width="min(760px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="saving" :loading="resolving" @confirm="submit">
<el-alert title="入仓码和尾程订单号至少填写一个;两项都填写时必须属于同一订单。" type="warning" :closable="false" />
<el-form label-position="top" class="create-form"><div class="form-grid">
<el-form label-position="top" class="create-form density-form"><div class="form-grid">
<el-form-item label="入仓码"><el-input v-model="form.inboundCode" placeholder="请输入入仓码" @blur="resolveRelation" /></el-form-item>
<el-form-item label="尾程订单号"><el-input v-model="form.lastMileOrderNo" placeholder="请输入尾程订单号" @blur="resolveRelation" /></el-form-item>
<el-form-item label="异常类型" required><el-select v-model="form.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, value) in typeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
@@ -101,8 +102,8 @@ onMounted(load)
<el-alert v-else-if="relationError" :title="relationError" type="error" :closable="false" />
</el-form>
</AppDialog>
<AppDialog v-model="detailOpen" title="异常订单详情" width="min(760px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭">
<el-descriptions v-if="detailRow" :column="2" border>
<AppDialog class="admin-density-dialog" v-model="detailOpen" title="异常订单详情" width="min(760px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭">
<el-descriptions class="density-descriptions" v-if="detailRow" :column="2" border>
<el-descriptions-item label="异常订单号">{{ detailRow.exceptionNo }}</el-descriptions-item>
<el-descriptions-item label="处理状态">{{ statusLabels[String(detailRow.status)] || detailRow.status }}</el-descriptions-item>
<el-descriptions-item label="异常类型">{{ typeLabels[detailRow.exceptionType] || detailRow.exceptionType }}</el-descriptions-item>
@@ -118,6 +119,7 @@ onMounted(load)
</template>
<style scoped>
.related-orders { display: grid; gap: 2px; font-size: 12px; }.related-order-reference,.relation-alert-title { display: flex; min-width: 0; align-items: baseline; gap: 3px; }.create-form { margin-top: 18px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
.related-orders { display: grid; gap: var(--admin-space-1); font-size: var(--admin-font-caption); }.related-order-reference,.relation-alert-title { display: flex; min-width: 0; align-items: baseline; gap: 3px; }.create-form { margin-top: var(--admin-space-5); }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
@media (max-width: 720px) { .form-grid { grid-template-columns: 1fr; } }
.form-grid :deep(.el-input-number), .form-grid :deep(.el-select) { width: 100%; }
</style>