Files
smart_grid_iot/client/src/views/customer/visit/index.vue
T

1620 lines
42 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.
<template>
<div class="visit-page">
<!-- 顶部标题 + 台账操作 -->
<div class="page-header">
<div class="breadcrumb">
<span>运营管控体系</span>
<span class="sep">/</span>
<span>客户管理</span>
<span class="sep">/</span>
<span class="cur">客户拜访管理台账</span>
</div>
<div class="header-main">
<div class="header-left">
<div class="header-row">
<span class="title-bar"></span>
<span class="main-title">客户拜访管理台账</span>
<span class="sub-title">CUSTOMER VISIT LEDGER</span>
</div>
<p class="desc">统筹全周期客户现场拜访任务、意向转化节点跟进、现场需求取证及拜访转立项派单全流程。</p>
</div>
<div class="header-actions">
<a-button @click="handleExport">
<template #icon><DownloadOutlined /></template>
导出台账数据
</a-button>
<a-button @click="handleOpenBatch">
<template #icon><CalendarOutlined /></template>
批量安排拜访
</a-button>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增拜访计划
</a-button>
</div>
</div>
</div>
<!-- KPI 统计卡片 -->
<div class="kpi-section">
<div class="kpi-grid">
<div v-for="k in kpiCards" :key="k.key" class="kpi-card" :style="{ borderColor: k.tint }">
<div class="kpi-main">
<div class="kpi-label">{{ k.label }}</div>
<div class="kpi-value" :style="{ color: k.color }">{{ k.value }}</div>
<div v-if="k.sub" class="kpi-sub">
<span class="sub-dot" :style="{ background: k.color }"></span>
<span class="sub-text">{{ k.sub }}</span>
</div>
</div>
<div class="kpi-icon" :style="{ background: k.tintBg, color: k.color }">
<component :is="k.icon" />
</div>
</div>
</div>
</div>
<!-- 主体卡片 -->
<div class="content-card">
<!-- 筛选区 -->
<div class="filter-card">
<div class="filter-grid">
<div class="filter-item filter-item-search">
<label class="filter-label">模糊搜索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="输入客户名称、拜访流水号、跟进业务员..."
allow-clear
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
<div class="filter-item">
<label class="filter-label">拜访类型</label>
<a-select
v-model:value="searchForm.visitType"
placeholder="全部类型"
allow-clear
:options="typeOptions"
/>
</div>
<div v-if="isAdmin" class="filter-item">
<label class="filter-label">跟进业务员</label>
<a-select
v-model:value="searchForm.salesmanId"
placeholder="全部业务员"
allow-clear
show-search
option-filter-prop="label"
:options="salesmanOptions"
/>
</div>
<div class="filter-item filter-item-date">
<label class="filter-label">计划拜访日期窗口</label>
<a-range-picker
v-model:value="searchForm.planRange"
style="width: 100%"
:placeholder="['开始日期', '结束日期']"
/>
</div>
<div class="filter-item filter-item-actions">
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
<a-button type="link" @click="advanced = !advanced">
高级
<template #icon><FilterOutlined /></template>
</a-button>
</div>
</div>
<!-- 高级筛选 -->
<div v-if="advanced" class="filter-grid filter-grid-adv">
<div class="filter-item">
<label class="filter-label">优先级</label>
<a-select
v-model:value="searchForm.priority"
placeholder="全部优先级"
allow-clear
:options="priorityOptions"
/>
</div>
<div class="filter-item">
<label class="filter-label">当前状态</label>
<a-select
v-model:value="searchForm.status"
placeholder="全部状态"
allow-clear
:options="statusOptions"
/>
</div>
</div>
</div>
<!-- 列表区 -->
<div class="list-section">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
<span class="t">拜访数据台账</span>
<span class="divider">|</span>
<span class="range-text">
显示第 <b>{{ rangeStart }}</b> 到 <b>{{ rangeEnd }}</b> 笔,共
<b class="strong">{{ total }}</b> 笔拜访记录
</span>
</div>
<div class="list-extra">
<span class="legend"><i class="dot-red"></i>特急 {{ priorityCounts[1] }}</span>
<span class="legend"><i class="dot-amber"></i>加急 {{ priorityCounts[2] }}</span>
<span class="legend"><i class="dot-slate"></i>常规 {{ priorityCounts[3] }}</span>
<a-button type="text" size="small" title="刷新列表" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
</a-button>
</div>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="false"
:row-selection="rowSelection"
:row-class-name="rowClassName"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'visitNo'">
<div class="no-cell">
<span class="visit-no" @click="openDetail(record)">{{ record.visitNo }}</span>
<span
v-if="priorityMeta(record.priority)"
class="chip"
:style="chipStyle(priorityMeta(record.priority))"
>{{ priorityMeta(record.priority)!.label }}</span>
</div>
</template>
<template v-else-if="column.key === 'customer'">
<div class="cust-name">{{ record.customer?.customerName || '-' }}</div>
<div class="sub-line">{{ record.spaceDesc || record.customer?.address || '—' }}</div>
</template>
<template v-else-if="column.key === 'subject'">
<div class="type-main">{{ record.subject || '—' }}</div>
<div class="sub-line">{{ typeLabel(record.visitType) }} · {{ modeLabel(record.visitMode) }}</div>
</template>
<template v-else-if="column.key === 'salesman'">
<div class="salesman">
<div class="avatar">{{ (record.salesmanName || '—').charAt(0) }}</div>
<div>
<div class="salesman-name">{{ record.salesmanName || '—' }}</div>
<div class="salesman-phone">{{ maskPhone(record.salesmanPhone) || '—' }}</div>
</div>
</div>
</template>
<template v-else-if="column.key === 'plan'">
<div class="plan-time" :class="{ warn: isToday(record.planTime) && record.status === 0 }">
{{ formatDateTime(record.planTime) }}
</div>
<div class="sub-line">{{ checkinText(record) }}</div>
</template>
<template v-else-if="column.key === 'intent'">
<span
v-if="intentMeta(record.intentLevel)"
class="chip"
:style="chipStyle(intentMeta(record.intentLevel))"
>{{ intentMeta(record.intentLevel)!.label }}</span>
<div class="sub-line">{{ record.intentDesc || record.demands || '—' }}</div>
</template>
<template v-else-if="column.key === 'status'">
<span class="status-pill" :style="pillStyle(statusMeta(record.status))">
<i class="dot" :style="{ background: statusMeta(record.status).color }"></i>
{{ statusMeta(record.status).label }}
</span>
</template>
<template v-else-if="column.key === 'action'">
<div class="row-actions">
<a-button
v-if="primaryAction(record)"
type="primary"
size="small"
@click="handlePrimary(record)"
>{{ primaryAction(record)!.label }}</a-button>
<a-dropdown trigger="click">
<a-button type="link" size="small">更多 <DownOutlined /></a-button>
<template #overlay>
<a-menu @click="({ key }) => handleMore(key as string, record)">
<a-menu-item key="detail">查看详情</a-menu-item>
<a-menu-item key="edit">编辑</a-menu-item>
<a-menu-item v-if="canSurvey(record)" key="survey">一键发起勘察派单</a-menu-item>
<a-menu-item v-if="record.status === 0" key="reassign">改派</a-menu-item>
<a-menu-item v-if="record.status === 0" key="cancel" danger>取消</a-menu-item>
<a-menu-item v-if="record.status === 3" key="archive">报告归档</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
</template>
<template #emptyText>
<a-empty description="暂无拜访数据" />
</template>
</a-table>
</div>
<!-- 表尾:批量操作 + 分页 -->
<div class="list-footer">
<div class="batch-actions">
<span class="selected-text">
已选中 <b>{{ selectedRowKeys.length }}</b> 项
</span>
<a-button size="small" disabled>批量转勘察派单</a-button>
<a-button size="small" disabled>批量导出拜访记录</a-button>
</div>
<a-pagination
v-model:current="searchForm.page"
v-model:page-size="searchForm.pageSize"
:total="total"
:show-size-changer="true"
:show-quick-jumper="true"
:page-size-options="['10', '20', '50']"
:show-total="(t: number) => `共 ${t} 条`"
@change="handlePageChange"
/>
</div>
</div>
</div>
<!-- 批量安排拜访 -->
<a-modal
v-model:open="batchVisible"
title="批量安排拜访"
:confirm-loading="batchLoading"
width="640px"
@ok="handleBatchSubmit"
>
<a-form ref="batchFormRef" :model="batchForm" :rules="batchRules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-form-item label="选择客户" name="customerIds">
<a-select
v-model:value="batchForm.customerIds"
mode="multiple"
placeholder="请选择需要安排拜访的客户(可多选)"
show-search
option-filter-prop="label"
:options="customerOptions"
:max-tag-count="3"
/>
</a-form-item>
<a-form-item label="计划时间" name="planTime">
<a-date-picker
v-model:value="batchForm.planTime"
show-time
style="width: 100%"
placeholder="请选择计划拜访时间"
/>
</a-form-item>
<a-form-item label="结束时间" name="planEndTime">
<a-date-picker
v-model:value="batchForm.planEndTime"
show-time
style="width: 100%"
placeholder="选填"
/>
</a-form-item>
<a-form-item v-if="isAdmin" label="跟进业务员" name="salesmanId">
<a-select
v-model:value="batchForm.salesmanId"
placeholder="默认为当前登录人"
allow-clear
show-search
option-filter-prop="label"
:options="salesmanOptions"
/>
</a-form-item>
<a-form-item label="拜访类型" name="visitType">
<a-select v-model:value="batchForm.visitType" :options="typeOptions" />
</a-form-item>
<a-form-item label="拜访方式" name="visitMode">
<a-select v-model:value="batchForm.visitMode" :options="modeOptions" />
</a-form-item>
<a-form-item label="优先级" name="priority">
<a-select v-model:value="batchForm.priority" :options="priorityOptions" />
</a-form-item>
<a-form-item label="拜访目的" name="subject">
<a-input v-model:value="batchForm.subject" placeholder="如:配电物联网与微断改造" maxlength="128" />
</a-form-item>
</a-form>
</a-modal>
<!-- 改派 -->
<a-modal
v-model:open="reassignVisible"
title="改派跟进业务员"
:confirm-loading="reassignLoading"
width="420px"
@ok="handleReassignSubmit"
>
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="拜访流水">
<span class="mono">{{ currentRecord?.visitNo }}</span>
</a-form-item>
<a-form-item label="改派至" required>
<a-select
v-model:value="reassignSalesmanId"
placeholder="请选择业务员"
show-search
option-filter-prop="label"
:options="salesmanOptions"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 审核现场取证 -->
<a-modal
v-model:open="reviewVisible"
title="审核现场取证"
:confirm-loading="reviewLoading"
width="620px"
@ok="handleReviewSubmit"
>
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="拜访流水">
<span class="mono">{{ currentRecord?.visitNo }}</span>
</a-form-item>
<a-form-item label="成果评级">
<a-input v-model:value="reviewForm.resultRating" placeholder="如:A级 · 方案通过 / B级 · 待补充" maxlength="64" />
</a-form-item>
<a-form-item label="审核结论">
<a-radio-group v-model:value="reviewForm.target">
<a-radio :value="3">转立项勘察</a-radio>
<a-radio :value="4">通过并归档</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item v-if="reviewForm.target === 3" label="关联工程编号">
<a-input v-model:value="reviewForm.projectNo" placeholder="选填,如 PRJ-2026-041" maxlength="64" />
</a-form-item>
</a-form>
</a-modal>
<!-- 拜访详情 -->
<a-modal v-model:open="detailVisible" title="拜访详情" :footer="null" width="760px">
<div v-if="currentRecord" class="detail">
<div class="detail-grid">
<div class="detail-item"><span class="k">拜访流水</span><span class="v mono">{{ currentRecord.visitNo }}</span></div>
<div class="detail-item"><span class="k">当前状态</span><span class="v">{{ statusMeta(currentRecord.status).label }}</span></div>
<div class="detail-item"><span class="k">关联客户</span><span class="v">{{ currentRecord.customer?.customerName || '-' }}</span></div>
<div class="detail-item"><span class="k">客户编号</span><span class="v mono">{{ currentRecord.customer?.customerNo || '-' }}</span></div>
<div class="detail-item"><span class="k">拜访类型</span><span class="v">{{ typeLabel(currentRecord.visitType) }}</span></div>
<div class="detail-item"><span class="k">拜访方式</span><span class="v">{{ modeLabel(currentRecord.visitMode) }}</span></div>
<div class="detail-item"><span class="k">计划时间</span><span class="v">{{ formatDateTime(currentRecord.planTime) }}</span></div>
<div class="detail-item"><span class="k">到场时间</span><span class="v">{{ formatDateTime(currentRecord.checkinTime) }}</span></div>
<div class="detail-item"><span class="k">跟进业务员</span><span class="v">{{ currentRecord.salesmanName || '-' }}</span></div>
<div class="detail-item"><span class="k">联系电话</span><span class="v mono">{{ currentRecord.salesmanPhone || '-' }}</span></div>
<div class="detail-item"><span class="k">空间性质</span><span class="v">{{ currentRecord.spaceDesc || '-' }}</span></div>
<div class="detail-item"><span class="k">意向评级</span><span class="v">{{ intentMeta(currentRecord.intentLevel)?.label || '-' }}</span></div>
<div class="detail-item"><span class="k">接待人</span><span class="v">{{ currentRecord.receptionPerson || '-' }}</span></div>
<div class="detail-item"><span class="k">接待人电话</span><span class="v mono">{{ currentRecord.receptionPhone || '-' }}</span></div>
<div class="detail-item"><span class="k">下次回访</span><span class="v">{{ formatDateTime(currentRecord.nextVisitTime) }}</span></div>
<div class="detail-item"><span class="k">下一步行动</span><span class="v">{{ currentRecord.nextAction || '-' }}</span></div>
</div>
<div class="detail-block">
<div class="detail-label">沟通纪要与关键反馈</div>
<div class="detail-text">{{ currentRecord.visitContent || '—' }}</div>
</div>
<div class="detail-block">
<div class="detail-label">现场照片 / 拜访资料</div>
<div v-if="currentRecord.photoUrls?.length" class="photo-list">
<a-image-preview-group>
<a-image
v-for="(url, idx) in currentRecord.photoUrls"
:key="idx"
:src="url"
:width="110"
:height="80"
style="object-fit: cover; border-radius: 8px"
/>
</a-image-preview-group>
</div>
<div v-else class="detail-text">—</div>
</div>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
DownloadOutlined,
CalendarOutlined,
FilterOutlined,
DownOutlined,
ProfileOutlined,
ClockCircleOutlined,
RiseOutlined,
FileSearchOutlined,
CheckCircleOutlined,
} from '@ant-design/icons-vue';
import dayjs from 'dayjs';
import {
customerVisitApi,
VISIT_TYPE_OPTIONS,
VISIT_MODE_OPTIONS,
VISIT_PRIORITY_OPTIONS,
VISIT_STATUS_OPTIONS,
VISIT_STATUS,
type CustomerVisitInfo,
type CustomerVisitQuery,
type CustomerVisitStats,
} from '@/api/customerVisit';
import { customerApi } from '@/api/customer';
import { userApi } from '@/api/user';
import { useTableScroll } from '@/composables/useTableScroll';
import { usePermissionStore } from '@/stores/permission';
const router = useRouter();
const { wrapperRef, tableHeight } = useTableScroll();
const permissionStore = usePermissionStore();
const isAdmin = computed(() => permissionStore.hasRole('ADMIN'));
// ==================== 状态与元数据 ====================
// 优先级配色:1特急(红) 2加急(琥珀) 3常规(灰)
const PRIORITY_META: Record<number, { label: string; color: string; bg: string; border: string }> = {
1: { label: '特急 · P1高优', color: '#dc2626', bg: '#fee2e2', border: '#fecaca' },
2: { label: '加急 · P2高优', color: '#d97706', bg: '#fef3c7', border: '#fde68a' },
3: { label: '常规 · P3回访', color: '#475569', bg: '#f1f5f9', border: '#e2e8f0' },
};
// 状态配色
const STATUS_META: Record<number, { label: string; color: string; bg: string; border: string }> = {
0: { label: '待执行', color: '#d97706', bg: '#fef3c7', border: '#fde68a' },
1: { label: '已完成待复核', color: '#0054cd', bg: '#eef4ff', border: '#cfe0ff' },
2: { label: '成果待技术复核', color: '#7c3aed', bg: '#f3e8ff', border: '#ddd0fe' },
3: { label: '已立项转勘察', color: '#059669', bg: '#d1fae5', border: '#a7f3d0' },
4: { label: '已归档', color: '#475569', bg: '#f1f5f9', border: '#e2e8f0' },
5: { label: '已取消', color: '#94a3b8', bg: '#f8fafc', border: '#e2e8f0' },
};
// 意向评级配色:1=A 2=B 3=C
const INTENT_META: Record<number, { label: string; color: string; bg: string; border: string }> = {
1: { label: 'A级 · 高意向', color: '#059669', bg: '#d1fae5', border: '#a7f3d0' },
2: { label: 'B级 · 中意向', color: '#d97706', bg: '#fef3c7', border: '#fde68a' },
3: { label: 'C级 · 低意向', color: '#475569', bg: '#f1f5f9', border: '#e2e8f0' },
};
const typeOptions = VISIT_TYPE_OPTIONS;
const modeOptions = VISIT_MODE_OPTIONS;
const priorityOptions = VISIT_PRIORITY_OPTIONS;
const statusOptions = VISIT_STATUS_OPTIONS;
const typeLabel = (v?: number) => VISIT_TYPE_OPTIONS.find((o) => o.value === v)?.label || '—';
const modeLabel = (v?: number) => VISIT_MODE_OPTIONS.find((o) => o.value === v)?.label || '—';
const priorityMeta = (v?: number) => (v && PRIORITY_META[v] ? PRIORITY_META[v] : null);
const intentMeta = (v?: number) => (v && INTENT_META[v] ? INTENT_META[v] : null);
const statusMeta = (v: number) => STATUS_META[v] || STATUS_META[4];
const chipStyle = (m: { color: string; bg: string; border: string }) => ({
background: m.bg,
color: m.color,
borderColor: m.border,
});
const pillStyle = (m: { color: string; bg: string; border: string }) => ({
background: m.bg,
color: m.color,
borderColor: m.border,
});
// ==================== 列表数据 ====================
const loading = ref(false);
const dataSource = ref<CustomerVisitInfo[]>([]);
const total = ref(0);
const selectedRowKeys = ref<number[]>([]);
const advanced = ref(false);
const searchForm = reactive({
page: 1,
pageSize: 10,
keyword: '',
visitType: undefined as number | undefined,
salesmanId: undefined as number | undefined,
priority: undefined as number | undefined,
status: undefined as number | undefined,
planRange: undefined as any,
});
const columns = [
{ title: '拜访流水 / 优先级', key: 'visitNo', width: 170 },
{ title: '关联客户与空间性质', key: 'customer', width: 220 },
{ title: '拜访类型与目的', key: 'subject', width: 180 },
{ title: '跟进业务员', key: 'salesman', width: 160 },
{ title: '计划与到场时间 / GPS签到', key: 'plan', width: 190 },
{ title: '现场意向与成果评级', key: 'intent', width: 190 },
{ title: '当前状态', key: 'status', width: 140, align: 'center' as const },
{ title: '操作', key: 'action', width: 170, align: 'center' as const, fixed: 'right' as const },
];
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: (string | number)[]) => {
selectedRowKeys.value = keys as number[];
},
}));
const rowClassName = (record: CustomerVisitInfo) => (record.priority === 1 ? 'row-urgent' : '');
const rangeStart = computed(() => (total.value === 0 ? 0 : (searchForm.page - 1) * searchForm.pageSize + 1));
const rangeEnd = computed(() => Math.min(searchForm.page * searchForm.pageSize, total.value));
const priorityCounts = computed(() => {
const counts: Record<number, number> = { 1: 0, 2: 0, 3: 0 };
for (const row of dataSource.value) {
if (row.priority && counts[row.priority] !== undefined) counts[row.priority] += 1;
}
return counts;
});
// ==================== KPI ====================
const stats = reactive<CustomerVisitStats>({
total: 0,
pending: 0,
todayPlan: 0,
urgentPending: 0,
conversionRate: 0,
pendingReview: 0,
completed: 0,
converted: 0,
intentTotal: 0,
});
const kpiCards = computed(() => [
{
key: 'total',
label: '全部拜访',
value: stats.total,
sub: '总台账量',
color: '#0054cd',
tint: '#cfe0ff',
tintBg: 'rgba(0,84,205,0.08)',
icon: ProfileOutlined,
},
{
key: 'pending',
label: '待执行 / 今日待访',
value: stats.pending,
sub: `今日待拜访 ${stats.todayPlan} 场 · 特急响应 ${stats.urgentPending} 场`,
color: '#d97706',
tint: '#fde68a',
tintBg: 'rgba(217,119,6,0.10)',
icon: ClockCircleOutlined,
},
{
key: 'conversion',
label: '意向勘察转化率',
value: `${stats.conversionRate}%`,
sub: `已转立项 ${stats.converted} 家`,
color: '#059669',
tint: '#a7f3d0',
tintBg: 'rgba(5,150,105,0.10)',
icon: RiseOutlined,
},
{
key: 'review',
label: '取证待技术复核',
value: stats.pendingReview,
sub: '场待核验',
color: '#7c3aed',
tint: '#ddd0fe',
tintBg: 'rgba(124,58,237,0.10)',
icon: FileSearchOutlined,
},
{
key: 'completed',
label: '拜访完成 / 已归档',
value: stats.completed,
sub: '场已归档',
color: '#0054cd',
tint: '#cfe0ff',
tintBg: 'rgba(0,84,205,0.08)',
icon: CheckCircleOutlined,
},
]);
// ==================== 下拉选项 ====================
const customerOptions = ref<{ label: string; value: number }[]>([]);
const salesmanOptions = ref<{ label: string; value: number }[]>([]);
async function fetchCustomerOptions() {
try {
const res = await customerApi.getCustomerOptions();
customerOptions.value = (res.data || []).map((item) => ({
label: item.customerName,
value: item.id,
}));
} catch {
customerOptions.value = [];
}
}
async function fetchSalesmanOptions() {
if (!isAdmin.value) return;
try {
const res = await userApi.getUserList({ page: 1, pageSize: 999 });
salesmanOptions.value = (res.data?.list || []).map((item) => ({
label: item.realName,
value: item.id,
}));
} catch {
salesmanOptions.value = [];
}
}
// ==================== 数据请求 ====================
function buildQuery(): CustomerVisitQuery {
const range = searchForm.planRange as any[] | undefined;
return {
page: searchForm.page,
pageSize: searchForm.pageSize,
keyword: searchForm.keyword || undefined,
visitType: searchForm.visitType,
salesmanId: searchForm.salesmanId,
priority: searchForm.priority,
status: searchForm.status,
planStartDate: range?.[0] ? range[0].format('YYYY-MM-DD') : undefined,
planEndDate: range?.[1] ? range[1].format('YYYY-MM-DD') : undefined,
};
}
async function fetchList() {
loading.value = true;
try {
const res = await customerVisitApi.getList(buildQuery());
dataSource.value = res.data.list || [];
total.value = res.data.total || 0;
} catch {
dataSource.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
async function fetchStats() {
try {
const res = await customerVisitApi.getStats();
Object.assign(stats, res.data);
} catch {
// 统计失败时保持默认值
}
}
onMounted(() => {
fetchList();
fetchStats();
fetchCustomerOptions();
fetchSalesmanOptions();
});
// ==================== 筛选与分页 ====================
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.visitType = undefined;
searchForm.salesmanId = undefined;
searchForm.priority = undefined;
searchForm.status = undefined;
searchForm.planRange = undefined;
searchForm.page = 1;
fetchList();
}
function handlePageChange(page: number, pageSize: number) {
searchForm.page = page;
searchForm.pageSize = pageSize;
fetchList();
}
function handleRefresh() {
fetchList();
fetchStats();
}
// ==================== 台账操作 ====================
function handleExport() {
message.info('导出台账数据功能暂未开放');
}
function handleAdd() {
router.push({ name: 'CustomerVisitCreate' });
}
// ==================== 行内操作 ====================
// 意向达标且尚未转立项的记录,可发起勘察派单
function canSurvey(record: CustomerVisitInfo) {
return (
(record.intentLevel === 1 || record.intentLevel === 2) &&
record.status !== VISIT_STATUS.SURVEY &&
record.status !== VISIT_STATUS.ARCHIVED &&
record.status !== VISIT_STATUS.CANCELED
);
}
type PrimaryActionKey = 'follow' | 'review' | 'project' | 'survey';
function primaryAction(record: CustomerVisitInfo): { key: PrimaryActionKey; label: string } | null {
if (record.status === VISIT_STATUS.PENDING) return { key: 'follow', label: '执行跟进' };
if (record.status === VISIT_STATUS.EVIDENCE) return { key: 'review', label: '审核取证' };
if (record.status === VISIT_STATUS.SURVEY) return { key: 'project', label: '查看工程' };
if (canSurvey(record)) return { key: 'survey', label: '发起派单' };
return null;
}
function handlePrimary(record: CustomerVisitInfo) {
const action = primaryAction(record);
if (!action) return;
if (action.key === 'follow') return handleFollow(record);
if (action.key === 'review') return openReview(record);
if (action.key === 'project') return handleViewProject(record);
if (action.key === 'survey') return handleSurvey(record);
}
function handleMore(key: string, record: CustomerVisitInfo) {
if (key === 'detail') return openDetail(record);
if (key === 'edit') return router.push({ name: 'CustomerVisitEdit', params: { id: record.id } });
if (key === 'survey') return handleSurvey(record);
if (key === 'reassign') return openReassign(record);
if (key === 'cancel') return handleCancelVisit(record);
if (key === 'archive') return handleArchive(record);
}
// 执行跟进:进入整页表单补充拜访结果
function handleFollow(record: CustomerVisitInfo) {
router.push({ name: 'CustomerVisitEdit', params: { id: record.id }, query: { action: 'follow' } });
}
// 一键发起勘察派单:跳转勘察派单页并预填客户(不创建、不回写状态)
function handleSurvey(record: CustomerVisitInfo) {
if (!record.customerId) return;
router.push({ path: '/dispatch/survey', query: { customerId: String(record.customerId) } });
}
// 查看关联工程
function handleViewProject(record: CustomerVisitInfo) {
if (record.projectNo) message.info(`关联工程编号:${record.projectNo}`);
else message.info('该项目暂未关联工程编号');
}
// 报告归档
function handleArchive(record: CustomerVisitInfo) {
Modal.confirm({
title: '确认报告归档?',
content: `拜访流水 ${record.visitNo} 将变更为「已归档」。`,
okText: '确认归档',
cancelText: '取消',
async onOk() {
try {
const res = await customerVisitApi.updateStatus(record.id, { status: VISIT_STATUS.ARCHIVED });
message.success(res.message || '归档成功');
handleRefresh();
} catch {
// 拦截器已提示
}
},
});
}
// 取消拜访
function handleCancelVisit(record: CustomerVisitInfo) {
Modal.confirm({
title: '确认取消该拜访?',
content: `拜访流水 ${record.visitNo} 将变更为「已取消」。`,
okText: '确认取消',
okType: 'danger',
cancelText: '返回',
async onOk() {
try {
const res = await customerVisitApi.updateStatus(record.id, { status: VISIT_STATUS.CANCELED });
message.success(res.message || '已取消');
handleRefresh();
} catch {
// 拦截器已提示
}
},
});
}
// ==================== 改派 ====================
const reassignVisible = ref(false);
const reassignLoading = ref(false);
const reassignSalesmanId = ref<number | undefined>(undefined);
const currentRecord = ref<CustomerVisitInfo | null>(null);
function openReassign(record: CustomerVisitInfo) {
currentRecord.value = record;
reassignSalesmanId.value = record.salesmanId;
reassignVisible.value = true;
}
async function handleReassignSubmit() {
if (!currentRecord.value) return;
if (!reassignSalesmanId.value) {
message.warning('请选择改派业务员');
return;
}
reassignLoading.value = true;
try {
const res = await customerVisitApi.reassign(currentRecord.value.id, {
salesmanId: reassignSalesmanId.value,
});
message.success(res.message || '改派成功');
reassignVisible.value = false;
handleRefresh();
} catch {
// 拦截器已提示
} finally {
reassignLoading.value = false;
}
}
// ==================== 审核现场取证 ====================
const reviewVisible = ref(false);
const reviewLoading = ref(false);
const reviewForm = reactive({ resultRating: '', target: 3 as number, projectNo: '' });
function openReview(record: CustomerVisitInfo) {
currentRecord.value = record;
reviewForm.resultRating = record.resultRating || '';
reviewForm.target = VISIT_STATUS.SURVEY;
reviewForm.projectNo = record.projectNo || '';
reviewVisible.value = true;
}
async function handleReviewSubmit() {
if (!currentRecord.value) return;
reviewLoading.value = true;
try {
const res = await customerVisitApi.updateStatus(currentRecord.value.id, {
status: reviewForm.target,
resultRating: reviewForm.resultRating || undefined,
projectNo: reviewForm.target === VISIT_STATUS.SURVEY ? reviewForm.projectNo || undefined : undefined,
});
message.success(res.message || '审核完成');
reviewVisible.value = false;
handleRefresh();
} catch {
// 拦截器已提示
} finally {
reviewLoading.value = false;
}
}
// ==================== 详情 ====================
const detailVisible = ref(false);
async function openDetail(record: CustomerVisitInfo) {
currentRecord.value = record;
detailVisible.value = true;
try {
const res = await customerVisitApi.getDetail(record.id);
currentRecord.value = res.data;
} catch {
// 详情获取失败时保留列表行数据
}
}
// ==================== 批量安排拜访 ====================
const batchVisible = ref(false);
const batchLoading = ref(false);
const batchFormRef = ref();
const batchForm = reactive({
customerIds: [] as number[],
planTime: undefined as any,
planEndTime: undefined as any,
salesmanId: undefined as number | undefined,
visitType: 1,
visitMode: 1,
priority: 3,
subject: '',
});
const batchRules = {
customerIds: [{ required: true, message: '请至少选择一个客户', trigger: 'change', type: 'array' as const }],
planTime: [{ required: true, message: '请选择计划拜访时间', trigger: 'change' }],
};
function handleOpenBatch() {
batchForm.customerIds = [];
batchForm.planTime = undefined;
batchForm.planEndTime = undefined;
batchForm.salesmanId = undefined;
batchForm.visitType = 1;
batchForm.visitMode = 1;
batchForm.priority = 3;
batchForm.subject = '';
batchVisible.value = true;
}
async function handleBatchSubmit() {
try {
await batchFormRef.value.validate();
} catch {
return;
}
batchLoading.value = true;
try {
const res = await customerVisitApi.batchCreate({
customerIds: batchForm.customerIds,
planTime: dayjs(batchForm.planTime).format('YYYY-MM-DD HH:mm:ss'),
planEndTime: batchForm.planEndTime ? dayjs(batchForm.planEndTime).format('YYYY-MM-DD HH:mm:ss') : undefined,
salesmanId: isAdmin.value ? batchForm.salesmanId : undefined,
visitType: batchForm.visitType,
visitMode: batchForm.visitMode,
priority: batchForm.priority,
subject: batchForm.subject || undefined,
});
message.success(res.message || '创建成功');
batchVisible.value = false;
handleRefresh();
} catch {
// 拦截器已提示
} finally {
batchLoading.value = false;
}
}
// ==================== 展示辅助 ====================
function formatDateTime(value?: string) {
if (!value) return '—';
return dayjs(value).format('YYYY-MM-DD HH:mm');
}
function isToday(value?: string) {
return !!value && dayjs(value).isSame(dayjs(), 'day');
}
function maskPhone(phone?: string) {
if (!phone) return '';
if (phone.length < 7) return phone;
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
function checkinText(record: CustomerVisitInfo) {
if (record.checkinStatus === 1) {
return record.checkinLat && record.checkinLng ? '已签到(GPS 定位)' : '已签到';
}
if (record.status === VISIT_STATUS.PENDING) return isToday(record.planTime) ? '今日待访' : '待访';
if (record.status === VISIT_STATUS.CANCELED) return '已取消';
return '—';
}
</script>
<style scoped>
.visit-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px 24px;
box-sizing: border-box;
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
}
/* 顶部标题 + 操作 */
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5b6b7e;
margin-bottom: 10px;
}
.breadcrumb .sep {
color: #c2c6d8;
}
.breadcrumb .cur {
color: #0054cd;
font-weight: 700;
}
.header-main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-left {
display: flex;
flex-direction: column;
}
.header-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-bar {
width: 5px;
height: 22px;
background: #0054cd;
border-radius: 3px;
align-self: center;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.4;
letter-spacing: -0.02em;
}
.sub-title {
font-size: 12px;
font-weight: 700;
color: #0054cd;
letter-spacing: 2px;
text-transform: uppercase;
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: #5b6b7e;
line-height: 1.6;
}
.header-actions {
flex-shrink: 0;
display: flex;
gap: 8px;
}
/* KPI 卡片 */
.kpi-section {
flex-shrink: 0;
margin-bottom: 16px;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
@media (max-width: 1200px) {
.kpi-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.kpi-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: #fff;
border: 1px solid #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 16px 18px;
box-sizing: border-box;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.kpi-card:hover {
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.kpi-icon {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.kpi-icon :deep(svg) {
font-size: 20px;
}
.kpi-main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
}
.kpi-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.kpi-value {
font-size: 28px;
font-weight: 700;
line-height: 1.1;
font-family: Consolas, Menlo, monospace;
letter-spacing: -0.01em;
}
.kpi-sub {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #8a97a8;
white-space: nowrap;
overflow: hidden;
}
.sub-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.sub-text {
overflow: hidden;
text-overflow: ellipsis;
}
/* 主体卡片 */
.content-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: #fff;
border: 1px solid #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
/* 筛选区 */
.filter-card {
flex-shrink: 0;
background: #f8faff;
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: minmax(220px, 2fr) 1fr 1fr 1.6fr auto;
gap: 12px 16px;
align-items: end;
}
.filter-grid-adv {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #dbe4ee;
grid-template-columns: repeat(4, minmax(160px, 1fr));
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.filter-item-actions {
flex-direction: row;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
/* 列表区 */
.list-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 4px 12px;
flex-wrap: wrap;
}
.list-title {
display: flex;
align-items: center;
gap: 10px;
}
.list-title .dot {
width: 4px;
height: 16px;
background: #0054cd;
border-radius: 2px;
}
.list-title .t {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.list-title .divider {
color: #cbd5e1;
}
.range-text {
font-size: 12px;
color: #5b6b7e;
}
.range-text b {
font-family: Consolas, Menlo, monospace;
color: #0d1c2c;
}
.range-text .strong {
color: #0054cd;
font-weight: 700;
}
.list-extra {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #5b6b7e;
}
.legend {
display: flex;
align-items: center;
gap: 5px;
font-family: Consolas, Menlo, monospace;
}
.legend i {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.dot-red {
background: #dc2626;
}
.dot-amber {
background: #d97706;
}
.dot-slate {
background: #94a3b8;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.list-footer {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding-top: 12px;
margin-top: 8px;
border-top: 1px solid #eef1f6;
}
.batch-actions {
display: flex;
align-items: center;
gap: 10px;
}
.selected-text {
font-size: 12.5px;
color: #5b6b7e;
}
.selected-text b {
color: #0054cd;
font-family: Consolas, Menlo, monospace;
}
/* 单元格内容 */
.no-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
.visit-no {
font-family: Consolas, Menlo, monospace;
font-weight: 700;
color: #0054cd;
cursor: pointer;
}
.visit-no:hover {
text-decoration: underline;
}
.chip {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 5px;
border: 1px solid transparent;
font-size: 10.5px;
font-weight: 600;
line-height: 18px;
white-space: nowrap;
}
.cust-name {
font-weight: 600;
color: #0d1c2c;
line-height: 1.4;
}
.type-main {
font-weight: 600;
color: #1e293b;
line-height: 1.4;
}
.sub-line {
margin-top: 3px;
font-size: 11.5px;
color: #8a97a8;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.salesman {
display: flex;
align-items: center;
gap: 8px;
}
.avatar {
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(0, 84, 205, 0.1);
color: #0054cd;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.salesman-name {
font-weight: 600;
color: #1e293b;
line-height: 1.3;
}
.salesman-phone {
font-family: Consolas, Menlo, monospace;
font-size: 11.5px;
color: #94a3b8;
}
.plan-time {
font-family: Consolas, Menlo, monospace;
font-size: 12.5px;
color: #0d1c2c;
}
.plan-time.warn {
color: #d97706;
font-weight: 700;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 10px;
border-radius: 9999px;
border: 1px solid transparent;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.status-pill .dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.row-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
flex-wrap: nowrap;
}
/* 详情弹窗 */
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px 20px;
padding: 4px 0 12px;
}
.detail-item {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.detail-item .k {
flex-shrink: 0;
width: 84px;
font-size: 12px;
color: #5b6b7e;
}
.detail-item .v {
font-size: 13px;
color: #0d1c2c;
word-break: break-all;
}
.detail-block {
border-top: 1px solid #eef1f6;
padding-top: 12px;
margin-top: 4px;
}
.detail-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
margin-bottom: 8px;
}
.detail-text {
font-size: 13px;
color: #0d1c2c;
line-height: 1.7;
white-space: pre-wrap;
}
.photo-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.mono {
font-family: Consolas, Menlo, monospace;
color: #0054cd;
font-weight: 600;
}
/* AntD 覆盖 */
:deep(.ant-table) {
font-size: 13px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
color: #5b6b7e;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.02em;
}
:deep(.ant-table-tbody > tr.row-urgent > td) {
background: rgba(254, 243, 199, 0.25);
}
:deep(.ant-table-tbody > tr.row-urgent:hover > td) {
background: rgba(254, 243, 199, 0.45);
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
:deep(.ant-btn-primary) {
background: #0054cd;
border-color: #0054cd;
}
:deep(.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):hover) {
background: #0947ab;
border-color: #0947ab;
}
:deep(.ant-table-selection-column) {
padding-left: 12px !important;
}
</style>