1998 lines
50 KiB
Vue
1998 lines
50 KiB
Vue
<template>
|
||
<div class="survey-page">
|
||
<!-- 顶部标题 + 台账操作 -->
|
||
<div class="page-header">
|
||
<div class="breadcrumb">
|
||
<span>项目管理</span>
|
||
<span class="sep">/</span>
|
||
<span class="cur">现场勘察派单台账</span>
|
||
<span class="mono-badge">SURVEY DISPATCH LEDGER</span>
|
||
</div>
|
||
<div class="header-main">
|
||
<div class="header-left">
|
||
<div class="header-row">
|
||
<span class="title-bar"></span>
|
||
<span class="main-title">现场勘察派单台账</span>
|
||
</div>
|
||
<p class="desc">
|
||
统一调度与全周期管理电力工程勘察测绘工单,监控现场执行时效、电工指派状态及测绘成果归档。
|
||
</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<a-button @click="handleExport">
|
||
<template #icon><DownloadOutlined /></template>
|
||
导出台账数据
|
||
</a-button>
|
||
<a-button @click="handleOpenBatchAssign">
|
||
<template #icon><TeamOutlined /></template>
|
||
批量派工
|
||
</a-button>
|
||
<a-button @click="openSmartAssign">
|
||
<template #icon><ThunderboltOutlined /></template>
|
||
智能派单
|
||
</a-button>
|
||
<a-button type="primary" @click="handleAdd">
|
||
<template #icon><PlusOutlined /></template>
|
||
发起勘察派单
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- KPI 统计卡片(可点击作为状态 Tab) -->
|
||
<div class="kpi-section">
|
||
<div class="kpi-grid">
|
||
<div
|
||
v-for="k in kpiCards"
|
||
:key="k.key"
|
||
class="kpi-card"
|
||
:class="{ active: activeStatKey === k.key }"
|
||
:style="{ borderColor: activeStatKey === k.key ? k.color : k.tint }"
|
||
@click="handleStatClick(k)"
|
||
>
|
||
<div class="kpi-main">
|
||
<div class="kpi-label">
|
||
<span v-if="k.dot" class="kpi-dot" :style="{ background: k.color }"></span>
|
||
{{ 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-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.status"
|
||
placeholder="全部状态"
|
||
allow-clear
|
||
:options="statusOptions"
|
||
/>
|
||
</div>
|
||
<div class="filter-item">
|
||
<label class="filter-label">指派电工</label>
|
||
<a-select
|
||
v-model:value="searchForm.workerId"
|
||
placeholder="全部电工"
|
||
allow-clear
|
||
show-search
|
||
option-filter-prop="label"
|
||
:options="workerOptions"
|
||
/>
|
||
</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-input
|
||
v-model:value="searchForm.salesmanKeyword"
|
||
placeholder="业务员姓名"
|
||
allow-clear
|
||
/>
|
||
</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>特急 {{ stats.priorityCounts.urgent }}</span>
|
||
<span class="legend"><i class="dot-amber"></i>加急 {{ stats.priorityCounts.expedited }}</span>
|
||
<span class="legend"><i class="dot-slate"></i>常规 {{ stats.priorityCounts.regular }}</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 === 'surveyNo'">
|
||
<div class="no-cell">
|
||
<span class="survey-no" @click="openDetail(record)">{{ record.surveyNo }}</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.taskTitle || record.customerName || record.customer?.customerName || '-' }}</div>
|
||
<div class="sub-line">签约客户:{{ record.customer?.customerName || record.customerName || '—' }}</div>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'site'">
|
||
<div class="site-line">
|
||
<EnvironmentOutlined class="site-icon" />
|
||
<span>{{ record.siteLocation || '—' }}</span>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'loop'">
|
||
<div class="type-main mono-strong">{{ record.loopCount ?? 0 }} 回路</div>
|
||
<div class="sub-line">{{ record.loopSpec || '—' }}</div>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'salesman'">
|
||
<div class="person">
|
||
<div>
|
||
<div class="person-name">{{ record.salesmanName || '—' }}</div>
|
||
<div class="person-sub">{{ maskPhone(record.salesmanPhone) || '—' }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'worker'">
|
||
<div v-if="record.workerId || record.workerName" class="person">
|
||
<div class="avatar avatar-worker">{{ (record.workerName || '—').charAt(0) }}</div>
|
||
<div>
|
||
<div class="person-name">{{ record.workerName || '—' }}</div>
|
||
<div class="person-sub">★{{ record.workerRating ?? '—' }}</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="worker-empty">
|
||
<span class="worker-empty-tag">待指派电工</span>
|
||
<a-button type="link" size="small" @click="openAssign(record, false)">指派</a-button>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'sla'">
|
||
<div class="plan-time" :class="{ warn: isSlaWarn(record) }">
|
||
{{ record.slaLevel || `接单后 ${record.expectMinutes ?? 45} 分钟内` }}
|
||
</div>
|
||
<div class="sub-line">
|
||
{{ formatDateTime(record.expectArrivalAt) }}
|
||
<span v-if="record.urgeCount"> · 已催办 {{ record.urgeCount }} 次</span>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'fee'">
|
||
<div class="fee-main">{{ money(record.estimatedFee) }}</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 v-if="canEdit(record)" key="edit">编辑</a-menu-item>
|
||
<a-menu-item v-if="record.status === SURVEY_STATUS.SURVEYING" key="reassign">改派</a-menu-item>
|
||
<a-menu-item v-if="canUrge(record)" key="urge">催办</a-menu-item>
|
||
<a-menu-item v-if="record.status === SURVEY_STATUS.PENDING_REVIEW" key="archive">报告归档</a-menu-item>
|
||
<a-menu-item v-if="canCancel(record)" key="cancel" danger>取消</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="!selectedRowKeys.length" @click="handleOpenBatchAssign">
|
||
批量指定电工
|
||
</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="assignVisible"
|
||
:title="assignForm.reassign ? '改派共享电工' : '指派共享电工'"
|
||
:confirm-loading="assignLoading"
|
||
width="480px"
|
||
@ok="handleAssignSubmit"
|
||
>
|
||
<a-form :label-col="{ span: 7 }" :wrapper-col="{ span: 15 }">
|
||
<a-form-item label="工单编号">
|
||
<span class="mono">{{ currentRecord?.surveyNo }}</span>
|
||
</a-form-item>
|
||
<a-form-item label="指派电工" required>
|
||
<a-select
|
||
v-model:value="assignForm.workerId"
|
||
placeholder="请选择共享电工"
|
||
show-search
|
||
option-filter-prop="label"
|
||
:options="workerOptions"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="预估劳务费">
|
||
<a-input-number
|
||
v-model:value="assignForm.estimatedFee"
|
||
:min="0"
|
||
:precision="2"
|
||
style="width: 100%"
|
||
placeholder="默认按基础工时费 + 回路补贴测算"
|
||
/>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<!-- 批量指定电工 -->
|
||
<a-modal
|
||
v-model:open="batchAssignVisible"
|
||
title="批量指定电工"
|
||
:confirm-loading="batchAssignLoading"
|
||
width="480px"
|
||
@ok="handleBatchAssignSubmit"
|
||
>
|
||
<a-form :label-col="{ span: 7 }" :wrapper-col="{ span: 15 }">
|
||
<a-form-item label="已选工单">
|
||
<span class="mono">{{ selectedRowKeys.length }} 笔</span>
|
||
</a-form-item>
|
||
<a-form-item label="指派电工" required>
|
||
<a-select
|
||
v-model:value="batchWorkerId"
|
||
placeholder="请选择共享电工"
|
||
show-search
|
||
option-filter-prop="label"
|
||
:options="workerOptions"
|
||
/>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<!-- 智能派单:推荐电工并批量指派 -->
|
||
<a-modal
|
||
v-model:open="smartVisible"
|
||
title="智能派单 · 共享电工优选"
|
||
:confirm-loading="smartLoading"
|
||
width="560px"
|
||
ok-text="按推荐结果批量指派"
|
||
@ok="handleSmartAssignSubmit"
|
||
>
|
||
<div class="smart-box">
|
||
<p class="smart-tip">
|
||
已勾选 <b class="mono">{{ selectedRowKeys.length }}</b> 笔待指派工单,系统按「评分 50% + 资质 30% + 经验 20%」加权推荐共享电工。
|
||
</p>
|
||
<a-spin :spinning="smartLoading">
|
||
<div v-if="smartWorkers.length === 0" class="smart-empty">暂无可用共享电工</div>
|
||
<a-radio-group v-else v-model:value="smartWorkerId" class="smart-list">
|
||
<div
|
||
v-for="w in smartWorkers"
|
||
:key="w.id"
|
||
class="smart-item"
|
||
:class="{ selected: smartWorkerId === w.id }"
|
||
@click="smartWorkerId = w.id"
|
||
>
|
||
<div class="smart-avatar" :class="{ muted: smartWorkerId !== w.id }">{{ (w.workerName || '—').charAt(0) }}</div>
|
||
<div class="smart-main">
|
||
<div class="smart-name-row">
|
||
<span class="smart-name">{{ w.workerName }}</span>
|
||
<span v-if="w.isTop" class="smart-badge-top">首选推荐 {{ w.matchRate }}%</span>
|
||
<span v-else class="smart-badge">{{ w.workerTitle || '共享电工' }}</span>
|
||
</div>
|
||
<div class="smart-meta">
|
||
★{{ Number(w.rating || 0).toFixed(2) }} · {{ w.certInfo || '暂无资质' }} · 累计 {{ w.completedCount || 0 }} 场
|
||
</div>
|
||
</div>
|
||
<a-radio :value="w.id" class="smart-radio" />
|
||
</div>
|
||
</a-radio-group>
|
||
</a-spin>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 审核测绘成果 -->
|
||
<a-modal
|
||
v-model:open="reviewVisible"
|
||
title="审核测绘成果"
|
||
:confirm-loading="reviewLoading"
|
||
width="560px"
|
||
@ok="handleReviewSubmit"
|
||
>
|
||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||
<a-form-item label="工单编号">
|
||
<span class="mono">{{ currentRecord?.surveyNo }}</span>
|
||
</a-form-item>
|
||
<a-form-item label="复核结论">
|
||
<a-radio-group v-model:value="reviewForm.result">
|
||
<a-radio value="pass">通过并归档</a-radio>
|
||
<a-radio value="reject">驳回退回现场</a-radio>
|
||
</a-radio-group>
|
||
</a-form-item>
|
||
<a-form-item label="复核意见">
|
||
<a-textarea
|
||
v-model:value="reviewForm.remark"
|
||
:rows="3"
|
||
maxlength="500"
|
||
placeholder="请填写技术复核意见(驳回时必填)"
|
||
/>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<!-- 取消工单 -->
|
||
<a-modal
|
||
v-model:open="cancelVisible"
|
||
title="取消勘察工单"
|
||
:confirm-loading="cancelLoading"
|
||
width="480px"
|
||
@ok="handleCancelSubmit"
|
||
>
|
||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||
<a-form-item label="工单编号">
|
||
<span class="mono">{{ currentRecord?.surveyNo }}</span>
|
||
</a-form-item>
|
||
<a-form-item label="取消原因">
|
||
<a-textarea v-model:value="cancelReason" :rows="3" maxlength="500" placeholder="请填写取消原因" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<!-- 工单详情 -->
|
||
<a-modal v-model:open="detailVisible" title="勘察工单详情" :footer="null" width="860px">
|
||
<div v-if="detailRecord" class="detail">
|
||
<div class="detail-grid">
|
||
<div class="detail-item"><span class="k">工单编号</span><span class="v mono">{{ detailRecord.surveyNo }}</span></div>
|
||
<div class="detail-item"><span class="k">当前状态</span><span class="v">{{ statusMeta(detailRecord.status).label }}</span></div>
|
||
<div class="detail-item"><span class="k">关联客户</span><span class="v">{{ detailRecord.customer?.customerName || detailRecord.customerName || '-' }}</span></div>
|
||
<div class="detail-item"><span class="k">客户编号</span><span class="v mono">{{ detailRecord.customer?.customerNo || '-' }}</span></div>
|
||
<div class="detail-item"><span class="k">任务主题</span><span class="v">{{ detailRecord.taskTitle || '-' }}</span></div>
|
||
<div class="detail-item"><span class="k">现场位置</span><span class="v">{{ detailRecord.siteLocation || '-' }}</span></div>
|
||
<div class="detail-item"><span class="k">技术联系人</span><span class="v">{{ detailRecord.techContact || '-' }}</span></div>
|
||
<div class="detail-item"><span class="k">回路规格</span><span class="v">{{ detailRecord.loopSpec || '-' }}</span></div>
|
||
<div class="detail-item"><span class="k">回路数量</span><span class="v mono">{{ detailRecord.loopCount ?? 0 }} 路</span></div>
|
||
<div class="detail-item"><span class="k">优先级</span><span class="v">{{ priorityMeta(detailRecord.priority)?.label || '-' }}</span></div>
|
||
<div class="detail-item"><span class="k">责任业务员</span><span class="v">{{ detailRecord.salesmanName || '-' }}</span></div>
|
||
<div class="detail-item"><span class="k">业务员电话</span><span class="v mono">{{ detailRecord.salesmanPhone || '-' }}</span></div>
|
||
<div class="detail-item"><span class="k">指派电工</span><span class="v">{{ detailRecord.workerName || '待指派' }}</span></div>
|
||
<div class="detail-item"><span class="k">电工资质</span><span class="v">{{ detailRecord.workerCertInfo || '-' }}</span></div>
|
||
<div class="detail-item"><span class="k">计划时间</span><span class="v">{{ formatRange(detailRecord.planStart, detailRecord.planEnd) }}</span></div>
|
||
<div class="detail-item"><span class="k">期望到场</span><span class="v">{{ formatDateTime(detailRecord.expectArrivalAt) }}</span></div>
|
||
<div class="detail-item"><span class="k">预估劳务费</span><span class="v mono">{{ money(detailRecord.estimatedFee) }}</span></div>
|
||
<div class="detail-item"><span class="k">实发劳务费</span><span class="v mono">{{ money(detailRecord.actualFee) }}</span></div>
|
||
<div class="detail-item"><span class="k">测绘进度</span><span class="v mono">{{ detailRecord.progressDone ?? 0 }}/{{ detailRecord.progressTotal ?? 0 }}</span></div>
|
||
<div class="detail-item"><span class="k">催办次数</span><span class="v">{{ detailRecord.urgeCount ?? 0 }} 次</span></div>
|
||
</div>
|
||
|
||
<div class="detail-block">
|
||
<div class="detail-label">技术复核意见</div>
|
||
<div class="detail-text">{{ detailRecord.reviewRemark || '—' }}</div>
|
||
</div>
|
||
|
||
<div v-if="detailRecord.cancelReason" class="detail-block">
|
||
<div class="detail-label">取消原因</div>
|
||
<div class="detail-text">{{ detailRecord.cancelReason }}</div>
|
||
</div>
|
||
|
||
<div class="detail-block">
|
||
<div class="detail-label">工单备注</div>
|
||
<div class="detail-text">{{ detailRecord.remark || '—' }}</div>
|
||
</div>
|
||
|
||
<div class="detail-block">
|
||
<div class="detail-label">现场照片</div>
|
||
<div v-if="detailRecord.photoUrls?.length" class="photo-list">
|
||
<a-image-preview-group>
|
||
<a-image
|
||
v-for="(url, idx) in detailRecord.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 v-if="detailRecord.sopResults?.length" class="detail-block">
|
||
<div class="detail-label">
|
||
勘察回执({{ detailSopDone }} / {{ detailRecord.sopResults.length }} 项已完成)
|
||
</div>
|
||
<div v-for="row in detailRecord.sopResults" :key="row.id" class="sop-item">
|
||
<div class="sop-item-head">
|
||
<span class="sop-item-name">{{ row.itemName || row.sopItem?.itemName || '检查项' }}</span>
|
||
<span class="chip" :style="sopChipStyle(row.checked)">{{ row.checked ? '已回执' : '未回执' }}</span>
|
||
</div>
|
||
<div v-if="row.receiptUrls?.length" class="photo-list">
|
||
<a-image
|
||
v-for="(url, idx) in row.receiptUrls"
|
||
:key="idx"
|
||
:src="url"
|
||
:width="110"
|
||
:height="80"
|
||
style="object-fit: cover; border-radius: 8px"
|
||
/>
|
||
</div>
|
||
<div v-else-if="row.receiptValue?.length" class="detail-text">
|
||
{{ row.receiptValue.join('、') }}
|
||
</div>
|
||
<div v-if="row.remark" class="sop-item-remark">备注:{{ row.remark }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted } from 'vue';
|
||
import { useRouter, useRoute } from 'vue-router';
|
||
import { message, Modal } from 'ant-design-vue';
|
||
import {
|
||
SearchOutlined,
|
||
ReloadOutlined,
|
||
PlusOutlined,
|
||
DownloadOutlined,
|
||
FilterOutlined,
|
||
DownOutlined,
|
||
TeamOutlined,
|
||
ThunderboltOutlined,
|
||
EnvironmentOutlined,
|
||
ProfileOutlined,
|
||
ClockCircleOutlined,
|
||
FileSearchOutlined,
|
||
CheckCircleOutlined,
|
||
} from '@ant-design/icons-vue';
|
||
import dayjs from 'dayjs';
|
||
import {
|
||
surveyOrderApi,
|
||
SURVEY_STATUS,
|
||
SURVEY_STATUS_OPTIONS,
|
||
SURVEY_PRIORITY_OPTIONS,
|
||
SURVEY_BASE_FEE,
|
||
SURVEY_LOOP_FEE,
|
||
type SurveyOrderInfo,
|
||
type SurveyOrderQuery,
|
||
type SurveyOrderStats,
|
||
} from '@/api/surveyOrder';
|
||
import { workerApi, type WorkerRecommend } from '@/api/worker';
|
||
import { useTableScroll } from '@/composables/useTableScroll';
|
||
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
const { wrapperRef, tableHeight } = useTableScroll();
|
||
|
||
// ==================== 状态与元数据 ====================
|
||
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' },
|
||
5: { label: '已取消', color: '#94a3b8', bg: '#f8fafc', border: '#e2e8f0' },
|
||
};
|
||
|
||
const statusOptions = SURVEY_STATUS_OPTIONS;
|
||
const priorityOptions = SURVEY_PRIORITY_OPTIONS;
|
||
|
||
const priorityMeta = (v?: number) => (v && PRIORITY_META[v] ? PRIORITY_META[v] : null);
|
||
const statusMeta = (v: number) => STATUS_META[v] || STATUS_META[0];
|
||
|
||
const chipStyle = (m: { color: string; bg: string; border: string }) => ({
|
||
background: m.bg,
|
||
color: m.color,
|
||
borderColor: m.border,
|
||
});
|
||
const pillStyle = chipStyle;
|
||
|
||
// ==================== 列表数据 ====================
|
||
const loading = ref(false);
|
||
const dataSource = ref<SurveyOrderInfo[]>([]);
|
||
const total = ref(0);
|
||
const selectedRowKeys = ref<number[]>([]);
|
||
const advanced = ref(false);
|
||
const activeStatKey = ref<string>('total');
|
||
|
||
const searchForm = reactive({
|
||
page: 1,
|
||
pageSize: 10,
|
||
keyword: '',
|
||
status: undefined as number | undefined,
|
||
workerId: undefined as number | undefined,
|
||
priority: undefined as number | undefined,
|
||
salesmanKeyword: '',
|
||
planRange: undefined as any,
|
||
});
|
||
|
||
const columns = [
|
||
{ title: '工单编号 / 优先级', key: 'surveyNo', width: 170 },
|
||
{ title: '关联客户与项目', key: 'customer', width: 240 },
|
||
{ title: '现场空间位置', key: 'site', width: 200 },
|
||
{ title: '测绘回路 / 规格', key: 'loop', width: 150 },
|
||
{ title: '责任业务员', key: 'salesman', width: 130 },
|
||
{ title: '调度指派电工', key: 'worker', width: 170 },
|
||
{ title: '期望时效 / SLA', key: 'sla', width: 200 },
|
||
{ title: '预估劳务费', key: 'fee', width: 120 },
|
||
{ title: '工单状态', key: 'status', width: 130, align: 'center' as const },
|
||
{ title: '操作', key: 'action', width: 200, 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: SurveyOrderInfo) => (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));
|
||
|
||
// ==================== KPI ====================
|
||
const stats = reactive<SurveyOrderStats>({
|
||
total: 0,
|
||
monthGrowth: 0,
|
||
pendingAssign: 0,
|
||
pendingWarn: 0,
|
||
surveying: 0,
|
||
onTimeRate: 0,
|
||
pendingReview: 0,
|
||
avgReviewHours: 0,
|
||
archived: 0,
|
||
planRate: 0,
|
||
priorityCounts: { urgent: 0, expedited: 0, regular: 0 },
|
||
});
|
||
|
||
interface KpiCard {
|
||
key: string;
|
||
label: string;
|
||
value: number | string;
|
||
sub: string;
|
||
color: string;
|
||
tint: string;
|
||
tintBg: string;
|
||
icon: any;
|
||
status?: number;
|
||
dot?: boolean;
|
||
}
|
||
|
||
const kpiCards = computed<KpiCard[]>(() => [
|
||
{
|
||
key: 'total',
|
||
label: '全部工单',
|
||
value: stats.total,
|
||
sub: `当月环比 ${stats.monthGrowth >= 0 ? '+' : ''}${stats.monthGrowth}%`,
|
||
color: '#0054cd',
|
||
tint: '#cfe0ff',
|
||
tintBg: 'rgba(0,84,205,0.08)',
|
||
icon: ProfileOutlined,
|
||
},
|
||
{
|
||
key: 'pendingAssign',
|
||
label: '待指派 / 待派单',
|
||
value: stats.pendingAssign,
|
||
sub: `${stats.pendingWarn} 单即将超出到场时效`,
|
||
color: '#d97706',
|
||
tint: '#fde68a',
|
||
tintBg: 'rgba(217,119,6,0.10)',
|
||
icon: ClockCircleOutlined,
|
||
status: SURVEY_STATUS.PENDING_ASSIGN,
|
||
dot: true,
|
||
},
|
||
{
|
||
key: 'surveying',
|
||
label: '现场勘察中',
|
||
value: stats.surveying,
|
||
sub: `平均履约到场率 ${stats.onTimeRate}%`,
|
||
color: '#0054cd',
|
||
tint: '#cfe0ff',
|
||
tintBg: 'rgba(0,84,205,0.08)',
|
||
icon: ThunderboltOutlined,
|
||
status: SURVEY_STATUS.SURVEYING,
|
||
dot: true,
|
||
},
|
||
{
|
||
key: 'pendingReview',
|
||
label: '成果待技术复核',
|
||
value: stats.pendingReview,
|
||
sub: `总工复审平均 ${stats.avgReviewHours} 小时`,
|
||
color: '#7c3aed',
|
||
tint: '#ddd0fe',
|
||
tintBg: 'rgba(124,58,237,0.10)',
|
||
icon: FileSearchOutlined,
|
||
status: SURVEY_STATUS.PENDING_REVIEW,
|
||
dot: true,
|
||
},
|
||
{
|
||
key: 'archived',
|
||
label: '勘察完成 / 已归档',
|
||
value: stats.archived,
|
||
sub: `方案出具率 ${stats.planRate}%`,
|
||
color: '#059669',
|
||
tint: '#a7f3d0',
|
||
tintBg: 'rgba(5,150,105,0.10)',
|
||
icon: CheckCircleOutlined,
|
||
status: SURVEY_STATUS.ARCHIVED,
|
||
dot: true,
|
||
},
|
||
]);
|
||
|
||
function handleStatClick(k: KpiCard) {
|
||
activeStatKey.value = k.key;
|
||
searchForm.status = k.status;
|
||
searchForm.page = 1;
|
||
fetchList();
|
||
}
|
||
|
||
// ==================== 下拉选项 ====================
|
||
const workerOptions = ref<{ label: string; value: number }[]>([]);
|
||
|
||
async function fetchWorkerOptions() {
|
||
try {
|
||
const res = await workerApi.getOptions();
|
||
workerOptions.value = (res.data || []).map((item) => ({
|
||
label: item.workerName,
|
||
value: item.id,
|
||
}));
|
||
} catch {
|
||
workerOptions.value = [];
|
||
}
|
||
}
|
||
|
||
// ==================== 数据请求 ====================
|
||
function buildQuery(): SurveyOrderQuery {
|
||
const range = searchForm.planRange as any[] | undefined;
|
||
return {
|
||
page: searchForm.page,
|
||
pageSize: searchForm.pageSize,
|
||
keyword: searchForm.keyword || undefined,
|
||
status: searchForm.status,
|
||
workerId: searchForm.workerId,
|
||
priority: searchForm.priority,
|
||
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 surveyOrderApi.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 surveyOrderApi.getStats();
|
||
Object.assign(stats, res.data);
|
||
} catch {
|
||
// 统计失败时保持默认值
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchList();
|
||
fetchStats();
|
||
fetchWorkerOptions();
|
||
const preCustomerId = Number(route.query.customerId || 0);
|
||
if (preCustomerId) {
|
||
router.replace({ name: 'ProjectSurveyCreate', query: { customerId: String(preCustomerId) } });
|
||
}
|
||
});
|
||
|
||
// ==================== 筛选与分页 ====================
|
||
function handleSearch() {
|
||
searchForm.page = 1;
|
||
fetchList();
|
||
}
|
||
|
||
function handleReset() {
|
||
searchForm.keyword = '';
|
||
searchForm.status = undefined;
|
||
searchForm.workerId = undefined;
|
||
searchForm.priority = undefined;
|
||
searchForm.salesmanKeyword = '';
|
||
searchForm.planRange = undefined;
|
||
searchForm.page = 1;
|
||
activeStatKey.value = 'total';
|
||
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: 'ProjectSurveyCreate' });
|
||
}
|
||
|
||
// ==================== 行内操作 ====================
|
||
const canEdit = (record: SurveyOrderInfo) =>
|
||
record.status === SURVEY_STATUS.PENDING_ASSIGN || record.status === SURVEY_STATUS.SURVEYING;
|
||
const canUrge = (record: SurveyOrderInfo) =>
|
||
record.status === SURVEY_STATUS.PENDING_ASSIGN ||
|
||
record.status === SURVEY_STATUS.SURVEYING ||
|
||
record.status === SURVEY_STATUS.PENDING_REVIEW;
|
||
const canCancel = (record: SurveyOrderInfo) =>
|
||
record.status !== SURVEY_STATUS.ARCHIVED && record.status !== SURVEY_STATUS.CANCELED;
|
||
|
||
function primaryAction(record: SurveyOrderInfo): { label: string } | null {
|
||
if (record.status === SURVEY_STATUS.PENDING_ASSIGN) return { label: '指派电工' };
|
||
if (record.status === SURVEY_STATUS.SURVEYING) return { label: '进度跟踪' };
|
||
if (record.status === SURVEY_STATUS.PENDING_REVIEW) return { label: '审核测绘成果' };
|
||
if (record.status === SURVEY_STATUS.ARCHIVED) return { label: '生成方案' };
|
||
return null;
|
||
}
|
||
|
||
function handlePrimary(record: SurveyOrderInfo) {
|
||
const action = primaryAction(record);
|
||
if (!action) return;
|
||
if (action.label === '指派电工') return openAssign(record, false);
|
||
if (action.label === '进度跟踪') return goDetail(record);
|
||
if (action.label === '审核测绘成果') return openReview(record);
|
||
if (action.label === '生成方案') return message.info('生成项目方案功能暂未开放');
|
||
}
|
||
|
||
function handleMore(key: string, record: SurveyOrderInfo) {
|
||
if (key === 'detail') return openDetail(record);
|
||
if (key === 'edit') return goDetail(record);
|
||
if (key === 'reassign') return openAssign(record, true);
|
||
if (key === 'urge') return handleUrge(record);
|
||
if (key === 'cancel') return openCancel(record);
|
||
if (key === 'archive') return handleArchive(record);
|
||
}
|
||
|
||
function handleArchive(record: SurveyOrderInfo) {
|
||
Modal.confirm({
|
||
title: '确认报告归档?',
|
||
content: `工单 ${record.surveyNo} 将复核通过并变更为「已归档」。`,
|
||
okText: '确认归档',
|
||
cancelText: '取消',
|
||
async onOk() {
|
||
try {
|
||
const res = await surveyOrderApi.review(record.id, { result: 'pass' });
|
||
message.success(res.message || '归档成功');
|
||
handleRefresh();
|
||
} catch {
|
||
// 拦截器已提示
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
// ==================== 指派 / 改派 ====================
|
||
const assignVisible = ref(false);
|
||
const assignLoading = ref(false);
|
||
const assignForm = reactive({
|
||
workerId: undefined as number | undefined,
|
||
estimatedFee: undefined as number | undefined,
|
||
reassign: false,
|
||
});
|
||
const currentRecord = ref<SurveyOrderInfo | null>(null);
|
||
|
||
function defaultFee(record: SurveyOrderInfo) {
|
||
if (record.estimatedFee != null && record.estimatedFee !== '') return Number(record.estimatedFee);
|
||
return SURVEY_BASE_FEE + (record.loopCount || 0) * SURVEY_LOOP_FEE;
|
||
}
|
||
|
||
function openAssign(record: SurveyOrderInfo, reassign: boolean) {
|
||
currentRecord.value = record;
|
||
assignForm.reassign = reassign;
|
||
assignForm.workerId = record.workerId;
|
||
assignForm.estimatedFee = defaultFee(record);
|
||
assignVisible.value = true;
|
||
}
|
||
|
||
async function handleAssignSubmit() {
|
||
if (!currentRecord.value) return;
|
||
if (!assignForm.workerId) {
|
||
message.warning('请选择指派电工');
|
||
return;
|
||
}
|
||
assignLoading.value = true;
|
||
try {
|
||
const res = await surveyOrderApi.assign(currentRecord.value.id, {
|
||
workerId: assignForm.workerId,
|
||
estimatedFee: assignForm.estimatedFee,
|
||
});
|
||
message.success(res.message || '指派成功');
|
||
assignVisible.value = false;
|
||
handleRefresh();
|
||
} catch {
|
||
// 拦截器已提示
|
||
} finally {
|
||
assignLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// ==================== 批量指定电工 ====================
|
||
const batchAssignVisible = ref(false);
|
||
const batchAssignLoading = ref(false);
|
||
const batchWorkerId = ref<number | undefined>(undefined);
|
||
|
||
function handleOpenBatchAssign() {
|
||
if (!selectedRowKeys.value.length) {
|
||
message.warning('请先勾选需要派工的工单');
|
||
return;
|
||
}
|
||
batchWorkerId.value = undefined;
|
||
batchAssignVisible.value = true;
|
||
}
|
||
|
||
async function handleBatchAssignSubmit() {
|
||
if (!batchWorkerId.value) {
|
||
message.warning('请选择指派电工');
|
||
return;
|
||
}
|
||
batchAssignLoading.value = true;
|
||
try {
|
||
const res = await surveyOrderApi.batchAssign({
|
||
ids: selectedRowKeys.value,
|
||
workerId: batchWorkerId.value,
|
||
});
|
||
const { successCount, failCount } = res.data;
|
||
message.success(`成功派工 ${successCount} 笔${failCount ? `,失败 ${failCount} 笔` : ''}`);
|
||
batchAssignVisible.value = false;
|
||
selectedRowKeys.value = [];
|
||
handleRefresh();
|
||
} catch {
|
||
// 拦截器已提示
|
||
} finally {
|
||
batchAssignLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// ==================== 智能派单 ====================
|
||
const smartVisible = ref(false);
|
||
const smartLoading = ref(false);
|
||
const smartWorkers = ref<WorkerRecommend[]>([]);
|
||
const smartWorkerId = ref<number | undefined>(undefined);
|
||
|
||
async function openSmartAssign() {
|
||
if (!selectedRowKeys.value.length) {
|
||
message.warning('请先勾选需要智能派单的待指派工单');
|
||
return;
|
||
}
|
||
smartVisible.value = true;
|
||
smartLoading.value = true;
|
||
smartWorkers.value = [];
|
||
smartWorkerId.value = undefined;
|
||
try {
|
||
const res = await workerApi.getRecommend({});
|
||
smartWorkers.value = res.data || [];
|
||
const top = smartWorkers.value.find((w) => w.isTop) || smartWorkers.value[0];
|
||
if (top) smartWorkerId.value = top.id;
|
||
} catch {
|
||
smartWorkers.value = [];
|
||
} finally {
|
||
smartLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function handleSmartAssignSubmit() {
|
||
if (!smartWorkerId.value) {
|
||
message.warning('请选择推荐电工');
|
||
return;
|
||
}
|
||
smartLoading.value = true;
|
||
try {
|
||
const res = await surveyOrderApi.batchAssign({
|
||
ids: selectedRowKeys.value,
|
||
workerId: smartWorkerId.value,
|
||
});
|
||
const { successCount, failCount } = res.data;
|
||
message.success(`智能派单完成:成功 ${successCount} 笔${failCount ? `,失败 ${failCount} 笔` : ''}`);
|
||
smartVisible.value = false;
|
||
selectedRowKeys.value = [];
|
||
handleRefresh();
|
||
} catch {
|
||
// 拦截器已提示
|
||
} finally {
|
||
smartLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// ==================== 技术复核 ====================
|
||
const reviewVisible = ref(false);
|
||
const reviewLoading = ref(false);
|
||
const reviewForm = reactive({ result: 'pass' as 'pass' | 'reject', remark: '' });
|
||
|
||
function openReview(record: SurveyOrderInfo) {
|
||
currentRecord.value = record;
|
||
reviewForm.result = 'pass';
|
||
reviewForm.remark = '';
|
||
reviewVisible.value = true;
|
||
}
|
||
|
||
async function handleReviewSubmit() {
|
||
if (!currentRecord.value) return;
|
||
if (reviewForm.result === 'reject' && !reviewForm.remark.trim()) {
|
||
message.warning('驳回时请填写复核意见');
|
||
return;
|
||
}
|
||
reviewLoading.value = true;
|
||
try {
|
||
const res = await surveyOrderApi.review(currentRecord.value.id, {
|
||
result: reviewForm.result,
|
||
remark: reviewForm.remark || undefined,
|
||
});
|
||
message.success(res.message || '复核完成');
|
||
reviewVisible.value = false;
|
||
handleRefresh();
|
||
} catch {
|
||
// 拦截器已提示
|
||
} finally {
|
||
reviewLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// ==================== 催办 / 取消 ====================
|
||
function handleUrge(record: SurveyOrderInfo) {
|
||
Modal.confirm({
|
||
title: '确认催办该工单?',
|
||
content: `工单 ${record.surveyNo} 将向指派电工发送催办提醒。`,
|
||
okText: '确认催办',
|
||
cancelText: '返回',
|
||
async onOk() {
|
||
try {
|
||
const res = await surveyOrderApi.urge(record.id);
|
||
message.success(res.message || '已催办');
|
||
handleRefresh();
|
||
} catch {
|
||
// 拦截器已提示
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
const cancelVisible = ref(false);
|
||
const cancelLoading = ref(false);
|
||
const cancelReason = ref('');
|
||
|
||
function openCancel(record: SurveyOrderInfo) {
|
||
currentRecord.value = record;
|
||
cancelReason.value = '';
|
||
cancelVisible.value = true;
|
||
}
|
||
|
||
async function handleCancelSubmit() {
|
||
if (!currentRecord.value) return;
|
||
cancelLoading.value = true;
|
||
try {
|
||
const res = await surveyOrderApi.cancel(currentRecord.value.id, {
|
||
reason: cancelReason.value || undefined,
|
||
});
|
||
message.success(res.message || '已取消');
|
||
cancelVisible.value = false;
|
||
handleRefresh();
|
||
} catch {
|
||
// 拦截器已提示
|
||
} finally {
|
||
cancelLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// ==================== 详情 / 编辑 ====================
|
||
const detailVisible = ref(false);
|
||
const detailRecord = ref<SurveyOrderInfo | null>(null);
|
||
const detailSopDone = computed(
|
||
() => (detailRecord.value?.sopResults || []).filter((row) => row.checked).length
|
||
);
|
||
|
||
async function openDetail(record: SurveyOrderInfo) {
|
||
detailRecord.value = record;
|
||
detailVisible.value = true;
|
||
try {
|
||
const res = await surveyOrderApi.getDetail(record.id);
|
||
detailRecord.value = res.data;
|
||
} catch {
|
||
// 详情获取失败时保留列表行数据
|
||
}
|
||
}
|
||
|
||
function goDetail(record: SurveyOrderInfo) {
|
||
router.push({ name: 'ProjectSurveyEdit', params: { id: record.id } });
|
||
}
|
||
|
||
// ==================== 展示辅助 ====================
|
||
function formatDateTime(value?: string) {
|
||
if (!value) return '—';
|
||
return dayjs(value).format('YYYY-MM-DD HH:mm');
|
||
}
|
||
|
||
function formatRange(start?: string, end?: string) {
|
||
if (!start && !end) return '—';
|
||
const s = start ? dayjs(start).format('YYYY-MM-DD HH:mm') : '—';
|
||
const e = end ? dayjs(end).format('YYYY-MM-DD HH:mm') : '—';
|
||
return `${s} ~ ${e}`;
|
||
}
|
||
|
||
const sopChipStyle = (checked?: boolean) =>
|
||
chipStyle(
|
||
checked
|
||
? { color: '#059669', bg: '#d1fae5', border: '#a7f3d0' }
|
||
: { color: '#94a3b8', bg: '#f8fafc', border: '#e2e8f0' }
|
||
);
|
||
|
||
function money(value?: number | string) {
|
||
if (value == null || value === '') return '—';
|
||
const num = Number(value);
|
||
if (Number.isNaN(num)) return '—';
|
||
return `¥${num.toFixed(2)}`;
|
||
}
|
||
|
||
function isSlaWarn(record: SurveyOrderInfo) {
|
||
if (!record.expectArrivalAt) return false;
|
||
if (record.status !== SURVEY_STATUS.PENDING_ASSIGN && record.status !== SURVEY_STATUS.SURVEYING) return false;
|
||
return dayjs(record.expectArrivalAt).isBefore(dayjs());
|
||
}
|
||
|
||
function maskPhone(phone?: string) {
|
||
if (!phone) return '';
|
||
if (phone.length < 7) return phone;
|
||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.survey-page {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
padding: 20px 24px;
|
||
box-sizing: border-box;
|
||
background: #f4f7fb;
|
||
}
|
||
|
||
/* 顶部标题 + 操作 */
|
||
.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: #0d1c2c;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.mono-badge {
|
||
padding: 2px 10px;
|
||
border-radius: 6px;
|
||
background: #f1f5f9;
|
||
border: 1px solid #e2e8f0;
|
||
color: #5b6b7e;
|
||
font-family: Consolas, Menlo, monospace;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.desc {
|
||
margin: 6px 0 0;
|
||
font-size: 13px;
|
||
color: #5b6b7e;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.header-actions {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* 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 #e2e8f0;
|
||
border-radius: 12px;
|
||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
|
||
padding: 14px 16px;
|
||
box-sizing: border-box;
|
||
cursor: pointer;
|
||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||
}
|
||
|
||
.kpi-card:hover {
|
||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.kpi-card.active {
|
||
border-width: 2px;
|
||
padding: 13px 15px;
|
||
}
|
||
|
||
.kpi-icon {
|
||
width: 38px;
|
||
height: 38px;
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 18px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.kpi-icon :deep(svg) {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.kpi-main {
|
||
min-width: 0;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
|
||
.kpi-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.kpi-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
}
|
||
|
||
.kpi-value {
|
||
font-size: 24px;
|
||
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-text {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
/* 主体卡片 */
|
||
.content-card {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
background: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 12px;
|
||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
|
||
padding: 16px 18px;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 筛选区 */
|
||
.filter-card {
|
||
flex-shrink: 0;
|
||
background: #f8fafc;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 10px;
|
||
padding: 14px 16px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.filter-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(220px, 2fr) 1fr 1fr 1.6fr auto;
|
||
gap: 12px 14px;
|
||
align-items: end;
|
||
}
|
||
|
||
.filter-grid-adv {
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px dashed #e2e8f0;
|
||
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: 11.5px;
|
||
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: 14px;
|
||
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;
|
||
}
|
||
|
||
.survey-no {
|
||
font-family: Consolas, Menlo, monospace;
|
||
font-weight: 700;
|
||
color: #0054cd;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.survey-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;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 1;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.type-main {
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.mono-strong {
|
||
font-family: Consolas, Menlo, monospace;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.site-line {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 4px;
|
||
font-size: 12.5px;
|
||
color: #1e293b;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.site-icon {
|
||
color: #94a3b8;
|
||
font-size: 14px;
|
||
margin-top: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.person {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.avatar {
|
||
width: 24px;
|
||
height: 24px;
|
||
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;
|
||
}
|
||
|
||
.avatar-worker {
|
||
background: rgba(0, 84, 205, 0.1);
|
||
color: #0054cd;
|
||
}
|
||
|
||
.person-name {
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
line-height: 1.3;
|
||
font-size: 12.5px;
|
||
}
|
||
|
||
.person-sub {
|
||
font-family: Consolas, Menlo, monospace;
|
||
font-size: 11px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.worker-empty {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 3px 8px;
|
||
border-radius: 8px;
|
||
background: #fef3c7;
|
||
border: 1px solid #fde68a;
|
||
}
|
||
|
||
.worker-empty-tag {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #92400e;
|
||
}
|
||
|
||
.plan-time {
|
||
font-family: Consolas, Menlo, monospace;
|
||
font-size: 12.5px;
|
||
color: #0d1c2c;
|
||
}
|
||
|
||
.plan-time.warn {
|
||
color: #d97706;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.fee-main {
|
||
font-family: Consolas, Menlo, monospace;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.mono {
|
||
font-family: Consolas, Menlo, monospace;
|
||
color: #0054cd;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 智能派单 */
|
||
.smart-box {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.smart-tip {
|
||
margin: 0;
|
||
font-size: 12.5px;
|
||
color: #5b6b7e;
|
||
}
|
||
|
||
.smart-empty {
|
||
padding: 24px;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
color: #94a3b8;
|
||
background: #f8fafc;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.smart-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
width: 100%;
|
||
}
|
||
|
||
.smart-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 14px;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 10px;
|
||
background: #f8fafc;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.smart-item.selected {
|
||
border: 2px solid #0054cd;
|
||
background: rgba(0, 84, 205, 0.06);
|
||
padding: 11px 13px;
|
||
}
|
||
|
||
.smart-avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
background: rgba(0, 84, 205, 0.1);
|
||
color: #0054cd;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.smart-avatar.muted {
|
||
background: #e2e8f0;
|
||
color: #5b6b7e;
|
||
}
|
||
|
||
.smart-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.smart-name-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.smart-name {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
}
|
||
|
||
.smart-badge-top {
|
||
padding: 1px 8px;
|
||
border-radius: 9999px;
|
||
background: #0054cd;
|
||
color: #fff;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.smart-badge {
|
||
padding: 1px 8px;
|
||
border-radius: 9999px;
|
||
background: #e2e8f0;
|
||
color: #5b6b7e;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.smart-meta {
|
||
font-family: Consolas, Menlo, monospace;
|
||
font-size: 11.5px;
|
||
color: #5b6b7e;
|
||
}
|
||
|
||
.smart-radio {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 详情弹窗 */
|
||
.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;
|
||
}
|
||
|
||
.sop-item {
|
||
padding: 10px 0;
|
||
border-bottom: 1px dashed #eef1f6;
|
||
}
|
||
|
||
.sop-item:last-child {
|
||
border-bottom: none;
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
.sop-item-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.sop-item-name {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #0d1c2c;
|
||
}
|
||
|
||
.sop-item-remark {
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
color: #8a97a8;
|
||
}
|
||
|
||
/* AntD 覆盖 */
|
||
:deep(.ant-table) {
|
||
font-size: 13px;
|
||
height: 100%;
|
||
}
|
||
|
||
:deep(.ant-table-thead > tr > th) {
|
||
background: #f8fafc;
|
||
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>
|