1620 lines
42 KiB
Vue
1620 lines
42 KiB
Vue
<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>
|