Files
smart_grid_iot/client/src/views/project/survey/index.vue
T

1998 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="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>