- 数据库:新增迁移脚本20261011_icc_line_ledger.sql(electric_line/line_room/icc_sync_log三表、菜单61、role_menu(1,61)、定时任务icc_line_sync),同步db_seed.sql - 后端:新增ElectricLine/IccSyncLog实体、查询DTO、线路台账服务与ICC拉取同步服务、/api/electric-lines控制器,接入RBAC(device:list)与定时任务 - 前端:新增智电改造线路台账页面及路由,API封装electricLineApi - ICC同步采用本系统主动拉取模式,失败仅记日志不抛错,接口统一code/message/data格式兼容APP
1886 lines
47 KiB
Vue
1886 lines
47 KiB
Vue
<template>
|
||
<div class="dispatch-page">
|
||
<div class="wizard-wrap" :class="{ 'is-loading': loading }">
|
||
<!-- ==================== 页头 + 步骤条 ==================== -->
|
||
<div class="page-header">
|
||
<div class="breadcrumb">
|
||
<span class="link" @click="goLedger">项目管理</span>
|
||
<span class="sep">/</span>
|
||
<span class="link" @click="goLedger">现场勘察派单</span>
|
||
<span class="sep">/</span>
|
||
<span v-if="surveyNo" class="mono-badge">{{ surveyNo }}</span>
|
||
<span v-else class="cur">{{ pageTitle }}</span>
|
||
</div>
|
||
|
||
<div class="header-card">
|
||
<div class="header-top">
|
||
<div class="header-left">
|
||
<div class="title-row">
|
||
<h1 class="main-title">{{ isReadonly ? '勘察工单详情' : '现场勘察派单向导' }}</h1>
|
||
<span
|
||
v-if="statusMeta"
|
||
class="status-pill"
|
||
:style="{ color: statusMeta.color, background: statusMeta.bg }"
|
||
>
|
||
<i class="dot" :style="{ background: statusMeta.color }"></i>
|
||
{{ statusMeta.label }}
|
||
</span>
|
||
</div>
|
||
<p class="desc">
|
||
{{
|
||
isReadonly
|
||
? '该工单已流转至复核 / 归档环节,信息仅供查看;如需调整请联系技术负责人'
|
||
: '标准化派发配电房现场测绘任务,向共享电工推单并回传拓扑参数'
|
||
}}
|
||
</p>
|
||
</div>
|
||
<span v-if="isReadonly" class="step-badge ro">只读查看</span>
|
||
<span v-else class="step-badge">STEP 0{{ currentStep }} / 03</span>
|
||
</div>
|
||
|
||
<div class="step-bar">
|
||
<div
|
||
v-for="s in STEPS"
|
||
:key="s.value"
|
||
class="step-item"
|
||
:class="stepClass(s.value)"
|
||
@click="scrollToStep(s.value)"
|
||
>
|
||
<div class="step-no">
|
||
<CheckOutlined v-if="s.value < currentStep" />
|
||
<span v-else>{{ s.value }}</span>
|
||
</div>
|
||
<div class="step-text">
|
||
<div class="step-name">{{ s.name }}</div>
|
||
<div class="step-sub">{{ s.sub }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 第 1 步 ==================== -->
|
||
<section id="wizard-step-1" class="section">
|
||
<div class="section-head">
|
||
<div class="section-title">
|
||
<span class="bar bar-primary"></span>
|
||
<ApartmentOutlined class="sec-icon clr-primary" />
|
||
<h2>第 1 步:勘察基本信息与现场环境</h2>
|
||
</div>
|
||
<span class="head-hint">已自动关联 ICC 客资档案</span>
|
||
</div>
|
||
|
||
<div class="field-grid">
|
||
<div class="field span-2">
|
||
<label class="field-label">
|
||
<span>关联客户及工程单位 <b class="req">*</b></span>
|
||
<span class="field-hint">支持客户全称或编号检索</span>
|
||
</label>
|
||
<a-select
|
||
v-model:value="formState.customerId"
|
||
placeholder="搜索并选择关联客户"
|
||
show-search
|
||
allow-clear
|
||
size="large"
|
||
style="width: 100%"
|
||
option-filter-prop="label"
|
||
:disabled="isReadonly"
|
||
:options="customerSelectOptions"
|
||
@change="handleCustomerChange"
|
||
/>
|
||
<div v-if="selectedCustomer" class="customer-chips">
|
||
<span class="chip chip-primary">{{ selectedCustomer.customerNo }}</span>
|
||
<span class="chip chip-plain">{{ selectedCustomer.customerLevel || '未分级' }}</span>
|
||
<span class="chip-text">
|
||
{{ selectedCustomer.contactPerson || '暂无联系人' }}
|
||
{{ selectedCustomer.contactPhone ? `· ${selectedCustomer.contactPhone}` : '' }}
|
||
{{ selectedCustomer.address ? `· ${selectedCustomer.address}` : '' }}
|
||
</span>
|
||
</div>
|
||
<p v-if="errors.customerId" class="err-msg">{{ errors.customerId }}</p>
|
||
</div>
|
||
|
||
<div class="field span-2">
|
||
<label class="field-label"><span>勘察任务标题 <b class="req">*</b></span></label>
|
||
<input
|
||
v-model="formState.taskTitle"
|
||
class="ipt"
|
||
:class="{ err: errors.taskTitle }"
|
||
type="text"
|
||
maxlength="128"
|
||
:disabled="isReadonly"
|
||
placeholder="如:实训楼智能配电改造一期工程现场测绘"
|
||
@input="clearError('taskTitle')"
|
||
/>
|
||
<p v-if="errors.taskTitle" class="err-msg">{{ errors.taskTitle }}</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="field-label"><span>具体勘察物理区域 <b class="req">*</b></span></label>
|
||
<input
|
||
v-model="formState.siteLocation"
|
||
class="ipt"
|
||
:class="{ err: errors.siteLocation }"
|
||
type="text"
|
||
maxlength="255"
|
||
:disabled="isReadonly"
|
||
placeholder="如:实训主楼 1F低压室 / 2~3F强电井"
|
||
@input="clearError('siteLocation')"
|
||
/>
|
||
<p v-if="errors.siteLocation" class="err-msg">{{ errors.siteLocation }}</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="field-label"><span>主进线规约与断路器容量</span></label>
|
||
<input
|
||
v-model="formState.loopSpec"
|
||
class="ipt mono-ipt"
|
||
type="text"
|
||
maxlength="128"
|
||
:disabled="isReadonly"
|
||
placeholder="如:380V / 400A MCCB (SCB10-630kVA)"
|
||
/>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="field-label"><span>预估分支测绘回路数 <b class="req">*</b></span></label>
|
||
<div class="inline-2">
|
||
<input
|
||
v-model.number="formState.loopCount"
|
||
class="ipt mono-ipt strong"
|
||
:class="{ err: errors.loopCount }"
|
||
type="number"
|
||
min="0"
|
||
:disabled="isReadonly"
|
||
@input="clearError('loopCount')"
|
||
/>
|
||
<span class="unit">路</span>
|
||
</div>
|
||
<p v-if="errors.loopCount" class="err-msg">{{ errors.loopCount }}</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="field-label"><span>要求期望到场时限</span></label>
|
||
<div class="inline-2">
|
||
<span class="unit unit-pre">接单后</span>
|
||
<input v-model.number="formState.expectMinutes" class="ipt mono-ipt" type="number" min="0" :disabled="isReadonly" />
|
||
<span class="unit">分钟内</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="field-label"><span>现场技术联系人</span></label>
|
||
<input
|
||
v-model="formState.techContact"
|
||
class="ipt"
|
||
type="text"
|
||
maxlength="128"
|
||
:disabled="isReadonly"
|
||
placeholder="如:程建业 (项目主管) - 138****9201"
|
||
/>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="field-label"><span>计划勘察窗口</span></label>
|
||
<a-range-picker
|
||
v-model:value="formState.planRange"
|
||
format="YYYY-MM-DD"
|
||
size="large"
|
||
style="width: 100%"
|
||
:disabled="isReadonly"
|
||
:placeholder="['开始日期', '结束日期']"
|
||
/>
|
||
</div>
|
||
|
||
<div class="field span-2">
|
||
<label class="field-label"><span>工单优先级</span></label>
|
||
<div class="mode-grid">
|
||
<button
|
||
v-for="p in SURVEY_PRIORITY_OPTIONS"
|
||
:key="p.value"
|
||
type="button"
|
||
class="mode-pill"
|
||
:class="{ active: formState.priority === p.value }"
|
||
:disabled="isReadonly"
|
||
@click="formState.priority = p.value"
|
||
>
|
||
{{ p.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field span-2">
|
||
<label class="field-label"><span>备注</span></label>
|
||
<a-textarea
|
||
v-model:value="formState.remark"
|
||
:rows="3"
|
||
:maxlength="500"
|
||
show-count
|
||
placeholder="派单备注说明,如现场特殊要求、安全交底等"
|
||
:disabled="isReadonly"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ==================== 第 2 步 ==================== -->
|
||
<section id="wizard-step-2" class="section">
|
||
<div class="section-head">
|
||
<div class="section-title">
|
||
<span class="bar bar-secondary"></span>
|
||
<FileDoneOutlined class="sec-icon clr-secondary" />
|
||
<h2>第 2 步:勘察技术要点与回执标准</h2>
|
||
</div>
|
||
<div v-if="!isReadonly" class="tmpl-box">
|
||
<span class="head-hint">SOP规范模板:</span>
|
||
<button type="button" class="tmpl-btn" @click="applySopTemplate">
|
||
<PlusOutlined />
|
||
{{ SOP_TEMPLATE_LABEL }}(已应用 {{ sopItems.length }} 项)
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sop-grid" :class="{ 'is-empty': !sopLoading && sopItems.length === 0 }">
|
||
<div v-for="item in sopItems" :key="item.id" class="sop-card">
|
||
<div class="sop-top">
|
||
<span class="sop-name">{{ item.sortOrder }}. {{ item.itemName }}</span>
|
||
<span v-if="item.required" class="sop-required">必填</span>
|
||
</div>
|
||
<div class="sop-desc">{{ item.itemDesc || '—' }}</div>
|
||
<div class="sop-foot">
|
||
<span>{{ receiptTypeLabel(item.receiptType) }}</span>
|
||
<span>{{ item.receiptRequire || '—' }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!isReadonly" class="sop-add" @click="handleAddCustomItem">
|
||
<PlusOutlined class="sop-add-icon" />
|
||
<span>+ 自定义勘察检查项</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ==================== 第 3 步 ==================== -->
|
||
<section id="wizard-step-3" class="section">
|
||
<div class="section-head">
|
||
<div class="section-title">
|
||
<span class="bar bar-tertiary"></span>
|
||
<ToolOutlined class="sec-icon clr-tertiary" />
|
||
<h2>第 3 步:指派电工与服务报酬</h2>
|
||
</div>
|
||
<span class="head-hint clr-tertiary">根据持证资质与历史履约智能优选</span>
|
||
</div>
|
||
|
||
<div class="sec-body">
|
||
<div v-if="workerLoading" class="cand-empty">正在匹配共享电工…</div>
|
||
<div v-else-if="workers.length === 0" class="cand-empty">暂无可用共享电工,可先保存为草稿稍后指派</div>
|
||
<div v-else class="cand-grid">
|
||
<div
|
||
v-for="w in workers"
|
||
:key="w.id"
|
||
class="cand-card"
|
||
:class="{ selected: formState.workerId === w.id, readonly: isReadonly }"
|
||
@click="selectWorker(w)"
|
||
>
|
||
<div class="cand-avatar" :class="{ muted: formState.workerId !== w.id }">
|
||
{{ (w.workerName || '—').slice(0, 1) }}
|
||
</div>
|
||
<div class="cand-main">
|
||
<div class="cand-top">
|
||
<div class="cand-name-row">
|
||
<span class="cand-name">{{ w.workerName }}</span>
|
||
<span v-if="w.isTop" class="cand-badge-primary">首选推荐 {{ w.matchRate }}%</span>
|
||
<span v-else class="cand-badge-plain">{{ w.workerTitle || '共享电工' }}</span>
|
||
</div>
|
||
<CheckCircleFilled v-if="formState.workerId === w.id" class="cand-check" />
|
||
<button v-else-if="!isReadonly" type="button" class="cand-switch" @click.stop="selectWorker(w)">改选此人</button>
|
||
</div>
|
||
<div class="cand-meta">
|
||
<span class="cand-rating"><StarFilled /> {{ Number(w.rating || 0).toFixed(2) }} 分</span>
|
||
<span class="cand-cert">{{ w.certInfo || '暂无资质信息' }}</span>
|
||
<span v-if="w.certVerified" class="cand-verified">已核验</span>
|
||
<span v-else class="cand-unverified">待核验</span>
|
||
</div>
|
||
<div class="cand-foot">
|
||
历史累计勘察 {{ w.completedCount || 0 }} 场 · 智能匹配度 {{ w.matchRate }}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 报酬汇总 -->
|
||
<div class="quote-box">
|
||
<div class="quote-left">
|
||
<div class="quote-item">
|
||
<span class="q-label">基础出场工时费</span>
|
||
<span class="q-value mono">¥ {{ quote.baseFee.toFixed(2) }}</span>
|
||
</div>
|
||
<div class="q-div"></div>
|
||
<div class="quote-item">
|
||
<span class="q-label">{{ quote.loopCount }}路测点补贴 (¥{{ quote.loopFee }}/路)</span>
|
||
<span class="q-value mono">¥ {{ (quote.loopCount * quote.loopFee).toFixed(2) }}</span>
|
||
</div>
|
||
<div class="q-div"></div>
|
||
<div class="quote-item">
|
||
<span class="q-label">履约质保要求</span>
|
||
<a-select
|
||
v-model:value="formState.slaLevel"
|
||
size="small"
|
||
style="width: 210px"
|
||
:disabled="isReadonly"
|
||
:options="SLA_OPTIONS"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="quote-right">
|
||
<span class="q-total-label">应付勘察劳务总报酬:</span>
|
||
<span class="q-total mono">¥ {{ quote.total.toFixed(2) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ==================== 只读:测绘成果与复核记录 ==================== -->
|
||
<section v-if="isReadonly" id="wizard-step-4" class="section">
|
||
<div class="section-head">
|
||
<div class="section-title">
|
||
<span class="bar bar-tertiary"></span>
|
||
<FileDoneOutlined class="sec-icon clr-tertiary" />
|
||
<h2>测绘成果与复核记录</h2>
|
||
</div>
|
||
<span class="head-hint">现场回执数据 · 仅供查看</span>
|
||
</div>
|
||
|
||
<div class="ro-body">
|
||
<!-- 成果概览 -->
|
||
<div class="ro-facts">
|
||
<div class="ro-fact">
|
||
<span class="ro-k">实际劳务费</span>
|
||
<span class="ro-v mono">{{ roActualFee }}</span>
|
||
</div>
|
||
<div class="ro-fact">
|
||
<span class="ro-k">测绘进度</span>
|
||
<span class="ro-v mono">{{ roProgressDone }} / {{ roProgressTotal }} 路</span>
|
||
</div>
|
||
<div class="ro-fact">
|
||
<span class="ro-k">SOP 回执</span>
|
||
<span class="ro-v mono">{{ roSopChecked }} / {{ roSopResults.length }} 项</span>
|
||
</div>
|
||
<div class="ro-fact">
|
||
<span class="ro-k">现场照片</span>
|
||
<span class="ro-v mono">{{ roPhotos.length }} 张</span>
|
||
</div>
|
||
<div v-if="detailRecord?.workerName" class="ro-fact">
|
||
<span class="ro-k">指派电工</span>
|
||
<span class="ro-v">{{ detailRecord.workerName }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 现场测绘完成度 -->
|
||
<div class="ro-progress">
|
||
<div class="ro-progress-head">
|
||
<span>现场测绘完成度</span>
|
||
<span class="mono">{{ roProgressPercent }}%</span>
|
||
</div>
|
||
<div class="ro-track">
|
||
<div class="ro-fill" :style="{ width: `${roProgressPercent}%` }"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SOP 回执明细 -->
|
||
<div class="ro-block">
|
||
<div class="ro-block-title">SOP 回执明细</div>
|
||
<div v-if="roSopResults.length === 0" class="ro-empty">暂无 SOP 回执记录</div>
|
||
<div v-else class="ro-list">
|
||
<div v-for="(r, idx) in roSopResults" :key="r.id || idx" class="ro-sop-row">
|
||
<span class="ro-sop-check" :class="{ done: r.checked }">
|
||
<CheckOutlined v-if="r.checked" />
|
||
</span>
|
||
<span class="ro-sop-name">{{ r.itemName || r.sopItem?.itemName || `检查项 #${r.sopItemId}` }}</span>
|
||
<span class="ro-sop-tag">{{ receiptTypeLabel(r.receiptType || r.sopItem?.receiptType) }}</span>
|
||
<div v-if="r.receiptUrls && r.receiptUrls.length" class="ro-sop-thumbs">
|
||
<a-image v-for="(url, i) in r.receiptUrls" :key="i" :src="url" :width="56" :height="42" class="ro-sop-thumb" />
|
||
</div>
|
||
<span v-else class="ro-sop-value mono">{{ r.receiptValue?.length ? r.receiptValue.join('、') : r.remark || '—' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 现场取证照片 -->
|
||
<div class="ro-block">
|
||
<div class="ro-block-title">现场取证照片</div>
|
||
<div v-if="roPhotos.length === 0" class="ro-empty">暂无现场照片</div>
|
||
<div v-else class="ro-photo-list">
|
||
<a-image-preview-group>
|
||
<a-image
|
||
v-for="(url, idx) in roPhotos"
|
||
:key="idx"
|
||
:src="url"
|
||
:width="120"
|
||
:height="90"
|
||
class="ro-photo"
|
||
/>
|
||
</a-image-preview-group>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 复核 / 取消 / 备注 -->
|
||
<div class="ro-block">
|
||
<div class="ro-block-title">复核与处理记录</div>
|
||
<div v-if="!roHasNotes" class="ro-empty">暂无复核与备注记录</div>
|
||
<div v-else class="ro-notes">
|
||
<div v-if="detailRecord?.reviewRemark" class="ro-note review">
|
||
<div class="ro-note-head">
|
||
<span class="ro-note-tag">技术复核</span>
|
||
<span v-if="detailRecord?.reviewedAt" class="ro-note-time mono">
|
||
{{ formatDateTime(detailRecord.reviewedAt) }}
|
||
</span>
|
||
</div>
|
||
<div class="ro-note-text">{{ detailRecord.reviewRemark }}</div>
|
||
</div>
|
||
<div v-if="detailRecord?.cancelReason" class="ro-note cancel">
|
||
<div class="ro-note-head">
|
||
<span class="ro-note-tag">取消原因</span>
|
||
</div>
|
||
<div class="ro-note-text">{{ detailRecord.cancelReason }}</div>
|
||
</div>
|
||
<div v-if="detailRecord?.remark" class="ro-note plain">
|
||
<div class="ro-note-head">
|
||
<span class="ro-note-tag">派单备注</span>
|
||
</div>
|
||
<div class="ro-note-text">{{ detailRecord.remark }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ==================== 底部操作条 ==================== -->
|
||
<div class="action-bar">
|
||
<div class="action-hint">
|
||
<CheckCircleFilled class="hint-icon" />
|
||
<span v-if="isReadonly">工单已流转至「{{ statusMeta?.label || '只读' }}」 · 当前为只读查看</span>
|
||
<span v-else>向导已填报 {{ filledCount }}/3 核心项 · 数据直连共享电工协同网</span>
|
||
</div>
|
||
<div class="action-btns">
|
||
<a-button v-if="isReadonly" @click="goLedger">
|
||
<template #icon><ArrowLeftOutlined /></template>
|
||
返回勘察台账
|
||
</a-button>
|
||
<template v-else>
|
||
<a-button @click="goLedger">
|
||
<template #icon><ArrowLeftOutlined /></template>
|
||
上一步
|
||
</a-button>
|
||
<a-button :loading="savingDraft" @click="handleSubmit(true)">
|
||
<template #icon><SaveOutlined /></template>
|
||
保存为草稿
|
||
</a-button>
|
||
<a-button type="primary" :loading="submitting" @click="handleSubmit(false)">
|
||
<template #icon><SendOutlined /></template>
|
||
确认并推送共享电工派单
|
||
</a-button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted, watch } from 'vue';
|
||
import { useRouter, useRoute } from 'vue-router';
|
||
import { message } from 'ant-design-vue';
|
||
import dayjs from 'dayjs';
|
||
import {
|
||
ArrowLeftOutlined,
|
||
SaveOutlined,
|
||
SendOutlined,
|
||
CheckOutlined,
|
||
CheckCircleFilled,
|
||
StarFilled,
|
||
PlusOutlined,
|
||
ApartmentOutlined,
|
||
FileDoneOutlined,
|
||
ToolOutlined,
|
||
} from '@ant-design/icons-vue';
|
||
import {
|
||
surveyOrderApi,
|
||
SOP_TEMPLATE_CODE,
|
||
SOP_TEMPLATE_LABEL,
|
||
SURVEY_STATUS,
|
||
SURVEY_PRIORITY,
|
||
SURVEY_PRIORITY_OPTIONS,
|
||
SURVEY_BASE_FEE,
|
||
SURVEY_LOOP_FEE,
|
||
type SurveySopItem,
|
||
type SurveyQuote,
|
||
type SurveyOrderCreateParams,
|
||
type SurveyOrderInfo,
|
||
} from '@/api/surveyOrder';
|
||
import { workerApi, type WorkerRecommend } from '@/api/worker';
|
||
import { customerApi, type CustomerInfo } from '@/api/customer';
|
||
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
|
||
// ==================== 步骤元数据 ====================
|
||
const STEPS = [
|
||
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
|
||
{ value: 2, name: '技术要点与回执标准', sub: '标准SOP模版、拍照指标' },
|
||
{ value: 3, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
|
||
];
|
||
|
||
const RECEIPT_TYPE_LABEL: Record<string, string> = {
|
||
photo: '现场照片',
|
||
form: '清单格式',
|
||
meter: '手持测仪',
|
||
};
|
||
|
||
const SLA_OPTIONS = [
|
||
{ value: 'SLA-GRADE A (24h回传)', label: 'SLA-GRADE A (24h回传)' },
|
||
{ value: 'SLA-GRADE B (48h回传)', label: 'SLA-GRADE B (48h回传)' },
|
||
{ value: 'SLA-GRADE C (72h回传)', label: 'SLA-GRADE C (72h回传)' },
|
||
];
|
||
|
||
const DEFAULT_LOOP_COUNT = 40;
|
||
const DEFAULT_EXPECT_MINUTES = 45;
|
||
|
||
// ==================== 模式 ====================
|
||
const editId = computed(() => Number(route.params.id || 0));
|
||
const isEdit = computed(() => editId.value > 0);
|
||
|
||
// 详情与编辑共用本页:status 0 待指派 / 1 现场勘察中 可编辑,其余状态进入只读查看
|
||
const detailRecord = ref<SurveyOrderInfo | null>(null);
|
||
const isReadonly = computed(() => {
|
||
const status = detailRecord.value?.status;
|
||
return status !== undefined && status !== SURVEY_STATUS.PENDING_ASSIGN && status !== SURVEY_STATUS.SURVEYING;
|
||
});
|
||
|
||
const pageTitle = computed(() => (isEdit.value ? (isReadonly.value ? '勘察工单详情' : '编辑勘察派单') : '新建勘察派单'));
|
||
const surveyNo = ref('');
|
||
|
||
// ==================== 只读视图数据 ====================
|
||
const STATUS_META: Record<number, { label: string; color: string; bg: string }> = {
|
||
[SURVEY_STATUS.PENDING_ASSIGN]: { label: '待指派', color: '#d97706', bg: 'rgba(217, 119, 6, 0.1)' },
|
||
[SURVEY_STATUS.SURVEYING]: { label: '现场勘察中', color: '#0054cd', bg: 'rgba(0, 84, 205, 0.1)' },
|
||
[SURVEY_STATUS.PENDING_REVIEW]: { label: '待技术复核', color: '#712ae2', bg: 'rgba(113, 42, 226, 0.1)' },
|
||
[SURVEY_STATUS.ARCHIVED]: { label: '已归档', color: '#00685f', bg: 'rgba(0, 104, 95, 0.1)' },
|
||
[SURVEY_STATUS.CANCELED]: { label: '已取消', color: '#94a3b8', bg: 'rgba(148, 163, 184, 0.15)' },
|
||
};
|
||
|
||
const statusMeta = computed(() => (detailRecord.value ? STATUS_META[detailRecord.value.status] || null : null));
|
||
const roPhotos = computed(() => (detailRecord.value?.photoUrls || []).filter((u) => !!u));
|
||
const roSopResults = computed(() => detailRecord.value?.sopResults || []);
|
||
const roSopChecked = computed(() => roSopResults.value.filter((r) => r.checked).length);
|
||
const roProgressDone = computed(() => Number(detailRecord.value?.progressDone) || 0);
|
||
const roProgressTotal = computed(() => Number(detailRecord.value?.progressTotal) || 0);
|
||
const roProgressPercent = computed(() => {
|
||
const total = roProgressTotal.value;
|
||
if (total <= 0) return 0;
|
||
return Math.min(100, Math.round((roProgressDone.value / total) * 100));
|
||
});
|
||
const roActualFee = computed(() => money(detailRecord.value?.actualFee ?? detailRecord.value?.estimatedFee));
|
||
const roHasNotes = computed(() => {
|
||
const d = detailRecord.value;
|
||
return !!(d?.reviewRemark || d?.cancelReason || d?.remark);
|
||
});
|
||
|
||
function money(value?: number | string) {
|
||
if (value === undefined || value === null || value === '') return '—';
|
||
const num = Number(value);
|
||
if (Number.isNaN(num)) return '—';
|
||
return `¥ ${num.toFixed(2)}`;
|
||
}
|
||
|
||
function formatDateTime(value?: string) {
|
||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—';
|
||
}
|
||
|
||
// ==================== 表单状态 ====================
|
||
const loading = ref(false);
|
||
const submitting = ref(false);
|
||
const savingDraft = ref(false);
|
||
const errors = reactive<Record<string, string>>({});
|
||
|
||
const formState = reactive({
|
||
customerId: undefined as number | undefined,
|
||
taskTitle: '',
|
||
siteLocation: '',
|
||
loopSpec: '',
|
||
loopCount: DEFAULT_LOOP_COUNT as number | undefined,
|
||
techContact: '',
|
||
expectMinutes: DEFAULT_EXPECT_MINUTES as number | undefined,
|
||
planRange: undefined as any,
|
||
priority: SURVEY_PRIORITY.REGULAR as number,
|
||
slaLevel: SLA_OPTIONS[0].value,
|
||
workerId: undefined as number | undefined,
|
||
remark: '',
|
||
});
|
||
|
||
// ==================== 选项数据 ====================
|
||
const customerOptions = ref<CustomerInfo[]>([]);
|
||
const customerSelectOptions = computed(() =>
|
||
customerOptions.value.map((c) => ({ value: c.id, label: `${c.customerName} · ${c.customerNo}` }))
|
||
);
|
||
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
|
||
|
||
// ==================== SOP 检查项 ====================
|
||
const sopItems = ref<SurveySopItem[]>([]);
|
||
const sopLoading = ref(false);
|
||
|
||
function receiptTypeLabel(type?: string) {
|
||
if (!type) return '—';
|
||
return RECEIPT_TYPE_LABEL[type] || type;
|
||
}
|
||
|
||
// ==================== 报酬与电工 ====================
|
||
const quote = ref<SurveyQuote>({
|
||
baseFee: SURVEY_BASE_FEE,
|
||
loopFee: SURVEY_LOOP_FEE,
|
||
loopCount: DEFAULT_LOOP_COUNT,
|
||
total: SURVEY_BASE_FEE + SURVEY_LOOP_FEE * DEFAULT_LOOP_COUNT,
|
||
});
|
||
|
||
const workers = ref<WorkerRecommend[]>([]);
|
||
const workerLoading = ref(false);
|
||
|
||
// ==================== 步骤推进 ====================
|
||
const step1Done = computed(
|
||
() =>
|
||
!!formState.customerId &&
|
||
!!formState.taskTitle.trim() &&
|
||
!!formState.siteLocation.trim() &&
|
||
Number(formState.loopCount) > 0
|
||
);
|
||
const step3Done = computed(() => !!formState.workerId);
|
||
const currentStep = computed(() => (step1Done.value ? (step3Done.value ? 3 : 2) : 1));
|
||
const filledCount = computed(
|
||
() => [!!formState.customerId, Number(formState.loopCount) > 0, !!formState.workerId].filter(Boolean).length
|
||
);
|
||
|
||
function stepClass(value: number) {
|
||
if (value < currentStep.value) return 'done';
|
||
if (value === currentStep.value) return 'active';
|
||
return 'pending';
|
||
}
|
||
|
||
function scrollToStep(value: number) {
|
||
const el = document.getElementById(`wizard-step-${value}`);
|
||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
|
||
// ==================== 交互 ====================
|
||
function clearError(key: string) {
|
||
if (errors[key]) delete errors[key];
|
||
}
|
||
|
||
function goLedger() {
|
||
router.push({ name: 'DispatchSurvey' });
|
||
}
|
||
|
||
function handleCustomerChange() {
|
||
clearError('customerId');
|
||
const customer = selectedCustomer.value;
|
||
if (customer) {
|
||
if (!formState.siteLocation.trim() && customer.address) {
|
||
formState.siteLocation = customer.address;
|
||
}
|
||
if (!formState.techContact.trim() && customer.contactPerson) {
|
||
formState.techContact = `${customer.contactPerson}${customer.contactPhone ? ` - ${customer.contactPhone}` : ''}`;
|
||
}
|
||
}
|
||
loadWorkers();
|
||
}
|
||
|
||
function selectWorker(w: WorkerRecommend) {
|
||
if (isReadonly.value) return;
|
||
formState.workerId = w.id;
|
||
}
|
||
|
||
function applySopTemplate() {
|
||
if (sopItems.value.length === 0) {
|
||
loadSopItems();
|
||
return;
|
||
}
|
||
message.success(`已应用${SOP_TEMPLATE_LABEL} ${sopItems.value.length} 项检查标准`);
|
||
}
|
||
|
||
function handleAddCustomItem() {
|
||
message.info('自定义勘察检查项功能暂未开放');
|
||
}
|
||
|
||
// ==================== 数据加载 ====================
|
||
async function loadCustomers() {
|
||
try {
|
||
// 使用 list 接口获取完整字段(options 接口仅返回 id/customerNo/customerName/status)
|
||
const res = await customerApi.getCustomerList({ page: 1, pageSize: 200 });
|
||
if (res.code === 200 && res.data) {
|
||
customerOptions.value = res.data.list || [];
|
||
}
|
||
} catch {
|
||
message.error('客户列表加载失败');
|
||
}
|
||
}
|
||
|
||
async function loadSopItems() {
|
||
sopLoading.value = true;
|
||
try {
|
||
const res = await surveyOrderApi.getSopItems(SOP_TEMPLATE_CODE);
|
||
if (res.code === 200 && res.data) {
|
||
sopItems.value = res.data;
|
||
}
|
||
} catch {
|
||
message.error('SOP 检查项加载失败');
|
||
} finally {
|
||
sopLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadQuote() {
|
||
const count = Number(formState.loopCount) || 0;
|
||
try {
|
||
const res = await surveyOrderApi.getQuote(count);
|
||
if (res.code === 200 && res.data) {
|
||
quote.value = res.data;
|
||
}
|
||
} catch {
|
||
// 报价接口异常时按前端兜底规则展示
|
||
quote.value = {
|
||
baseFee: SURVEY_BASE_FEE,
|
||
loopFee: SURVEY_LOOP_FEE,
|
||
loopCount: count,
|
||
total: SURVEY_BASE_FEE + SURVEY_LOOP_FEE * count,
|
||
};
|
||
}
|
||
}
|
||
|
||
async function loadWorkers() {
|
||
workerLoading.value = true;
|
||
try {
|
||
const res = await workerApi.getRecommend({
|
||
loopCount: Number(formState.loopCount) || 0,
|
||
priority: formState.priority,
|
||
});
|
||
if (res.code === 200 && res.data) {
|
||
workers.value = res.data;
|
||
// 新建时默认选中首选推荐,减少一次点击
|
||
if (!isEdit.value && !formState.workerId && workers.value.length > 0) {
|
||
formState.workerId = workers.value[0].id;
|
||
}
|
||
}
|
||
} catch {
|
||
// 推荐失败不阻断表单,第 3 步降级为「稍后指派」
|
||
} finally {
|
||
workerLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadDetail() {
|
||
if (!isEdit.value) return;
|
||
loading.value = true;
|
||
try {
|
||
const res = await surveyOrderApi.getDetail(editId.value);
|
||
if (res.code === 200 && res.data) {
|
||
const d = res.data;
|
||
detailRecord.value = d;
|
||
surveyNo.value = d.surveyNo || '';
|
||
formState.customerId = d.customerId;
|
||
formState.taskTitle = d.taskTitle || '';
|
||
formState.siteLocation = d.siteLocation || '';
|
||
formState.loopSpec = d.loopSpec || '';
|
||
formState.loopCount = d.loopCount ?? DEFAULT_LOOP_COUNT;
|
||
formState.techContact = d.techContact || '';
|
||
formState.expectMinutes = d.expectMinutes ?? DEFAULT_EXPECT_MINUTES;
|
||
formState.priority = d.priority ?? SURVEY_PRIORITY.REGULAR;
|
||
formState.slaLevel = d.slaLevel || SLA_OPTIONS[0].value;
|
||
formState.workerId = d.workerId;
|
||
formState.remark = d.remark || '';
|
||
if (d.planStart || d.planEnd) {
|
||
formState.planRange = [
|
||
d.planStart ? dayjs(d.planStart) : null,
|
||
d.planEnd ? dayjs(d.planEnd) : null,
|
||
];
|
||
}
|
||
} else {
|
||
message.error(res.message || '获取勘察派单详情失败');
|
||
}
|
||
} catch {
|
||
message.error('获取勘察派单详情失败');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
// 回路数变化时同步重算报价(电工推荐与回路数无关,仅在优先级变化时刷新)
|
||
watch(
|
||
() => formState.loopCount,
|
||
() => {
|
||
clearError('loopCount');
|
||
loadQuote();
|
||
}
|
||
);
|
||
|
||
// ==================== 校验与提交 ====================
|
||
function validate(): boolean {
|
||
Object.keys(errors).forEach((k) => delete errors[k]);
|
||
if (!formState.customerId) errors.customerId = '请选择关联客户及工程单位';
|
||
if (!formState.taskTitle.trim()) errors.taskTitle = '请填写勘察任务标题';
|
||
if (!formState.siteLocation.trim()) errors.siteLocation = '请填写具体勘察物理区域';
|
||
if (!(Number(formState.loopCount) > 0)) errors.loopCount = '预估分支测绘回路数需大于 0';
|
||
|
||
if (Object.keys(errors).length > 0) {
|
||
message.error('请完善必填项后再提交');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function buildPayload(isDraft: number): SurveyOrderCreateParams {
|
||
const range = formState.planRange;
|
||
const start = range?.[0];
|
||
const end = range?.[1];
|
||
return {
|
||
customerId: Number(formState.customerId),
|
||
taskTitle: formState.taskTitle.trim() || undefined,
|
||
siteLocation: formState.siteLocation.trim() || undefined,
|
||
loopSpec: formState.loopSpec.trim() || undefined,
|
||
loopCount: Number(formState.loopCount) || 0,
|
||
techContact: formState.techContact.trim() || undefined,
|
||
planStart: start ? `${start.format('YYYY-MM-DD')}T00:00:00+08:00` : undefined,
|
||
planEnd: end ? `${end.format('YYYY-MM-DD')}T23:59:59+08:00` : undefined,
|
||
expectMinutes: Number(formState.expectMinutes) || DEFAULT_EXPECT_MINUTES,
|
||
slaLevel: formState.slaLevel,
|
||
priority: formState.priority,
|
||
workerId: formState.workerId,
|
||
estimatedFee: quote.value.total,
|
||
remark: formState.remark.trim() || undefined,
|
||
source: 'ledger',
|
||
isDraft,
|
||
templateCode: SOP_TEMPLATE_CODE,
|
||
};
|
||
}
|
||
|
||
async function handleSubmit(draft: boolean) {
|
||
if (submitting.value || savingDraft.value) return;
|
||
|
||
if (draft) {
|
||
if (!formState.customerId) {
|
||
errors.customerId = '暂存草稿至少需要选择关联客户';
|
||
message.error('暂存草稿至少需要选择关联客户');
|
||
return;
|
||
}
|
||
} else if (!validate()) {
|
||
return;
|
||
}
|
||
|
||
const flag = draft ? savingDraft : submitting;
|
||
flag.value = true;
|
||
try {
|
||
const payload = buildPayload(draft ? 1 : 0);
|
||
const res: any = isEdit.value
|
||
? await surveyOrderApi.update(editId.value, payload)
|
||
: await surveyOrderApi.create(payload);
|
||
|
||
if (res.code !== 200 || !res.data) {
|
||
message.error(res.message || '保存失败');
|
||
return;
|
||
}
|
||
|
||
// 正式推送且已选电工:走指派接口落状态(0待指派 → 1现场勘察中)
|
||
if (!draft && formState.workerId) {
|
||
const orderId = Number(res.data.id || editId.value);
|
||
const assignRes: any = await surveyOrderApi.assign(orderId, {
|
||
workerId: formState.workerId,
|
||
estimatedFee: quote.value.total,
|
||
});
|
||
if (assignRes.code !== 200) {
|
||
message.warning(assignRes.message || '工单已保存,但指派共享电工失败,请在台账中重试');
|
||
goLedger();
|
||
return;
|
||
}
|
||
}
|
||
|
||
message.success(draft ? '已保存为草稿' : '勘察派单已推送共享电工');
|
||
goLedger();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '保存失败');
|
||
} finally {
|
||
flag.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
// 客户拜访台账「一键发起勘察派单」跳转预填:/dispatch/survey/create?customerId=xx
|
||
const preCustomerId = Number(route.query.customerId || 0);
|
||
if (preCustomerId && !isEdit.value) {
|
||
formState.customerId = preCustomerId;
|
||
}
|
||
|
||
await loadCustomers();
|
||
await Promise.all([loadSopItems(), loadQuote(), loadWorkers()]);
|
||
await loadDetail();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.dispatch-page {
|
||
height: 100%;
|
||
overflow-y: auto;
|
||
padding: 20px 24px 32px;
|
||
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
|
||
box-sizing: border-box;
|
||
}
|
||
.wizard-wrap {
|
||
max-width: 1024px;
|
||
margin: 0 auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
.wizard-wrap.is-loading {
|
||
opacity: 0.6;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ---------- 页头 ---------- */
|
||
.page-header {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.breadcrumb {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: #5b6b7e;
|
||
}
|
||
.breadcrumb .link {
|
||
cursor: pointer;
|
||
transition: color 0.2s;
|
||
}
|
||
.breadcrumb .link:hover {
|
||
color: #0054cd;
|
||
}
|
||
.breadcrumb .sep {
|
||
color: #a7b3c2;
|
||
}
|
||
.breadcrumb .cur {
|
||
color: #0d1c2c;
|
||
}
|
||
.mono-badge {
|
||
padding: 2px 10px;
|
||
border-radius: 9999px;
|
||
background: rgba(29, 111, 255, 0.08);
|
||
color: #0054cd;
|
||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
.header-card {
|
||
background: #fff;
|
||
border: 1px solid #dbe4ee;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 14px rgba(16, 24, 40, 0.06);
|
||
padding: 16px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
.header-top {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid #eef2f8;
|
||
}
|
||
.header-left {
|
||
min-width: 0;
|
||
}
|
||
.title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.status-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 2px 10px;
|
||
border-radius: 9999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
}
|
||
.status-pill .dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.main-title {
|
||
margin: 0;
|
||
font-size: 23px;
|
||
line-height: 28px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
}
|
||
.desc {
|
||
margin: 4px 0 0;
|
||
font-size: 12px;
|
||
color: #5b6b7e;
|
||
}
|
||
.step-badge {
|
||
flex-shrink: 0;
|
||
padding: 4px 12px;
|
||
border-radius: 9999px;
|
||
background: #eef4ff;
|
||
color: #0054cd;
|
||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
.step-badge.ro {
|
||
background: rgba(148, 163, 184, 0.16);
|
||
color: #5b6b7e;
|
||
}
|
||
|
||
/* 步骤条 */
|
||
.step-bar {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 8px;
|
||
}
|
||
.step-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 12px;
|
||
border: 1px solid #e6edf5;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.step-item.active {
|
||
background: rgba(0, 84, 205, 0.05);
|
||
border-color: rgba(0, 84, 205, 0.2);
|
||
}
|
||
.step-item.pending {
|
||
opacity: 0.75;
|
||
}
|
||
.step-no {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
background: #e5efff;
|
||
color: #424655;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.step-item.active .step-no {
|
||
background: #0054cd;
|
||
color: #fff;
|
||
}
|
||
.step-item.done .step-no {
|
||
background: #00685f;
|
||
color: #fff;
|
||
}
|
||
.step-text {
|
||
min-width: 0;
|
||
}
|
||
.step-name {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
}
|
||
.step-item.active .step-name {
|
||
color: #0054cd;
|
||
}
|
||
.step-sub {
|
||
font-size: 11px;
|
||
color: #5b6b7e;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
/* ---------- 卡片 ---------- */
|
||
.section {
|
||
background: #fff;
|
||
border: 1px solid #e6edf5;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 14px rgba(16, 24, 40, 0.06);
|
||
overflow: hidden;
|
||
scroll-margin-top: 12px;
|
||
}
|
||
.section-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 14px 20px;
|
||
background: rgba(238, 244, 255, 0.55);
|
||
border-bottom: 1px solid #eef2f8;
|
||
}
|
||
.section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
.section-title h2 {
|
||
margin: 0;
|
||
font-size: 17px;
|
||
line-height: 24px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
}
|
||
.sec-icon {
|
||
font-size: 20px;
|
||
}
|
||
.clr-primary {
|
||
color: #0054cd;
|
||
}
|
||
.clr-secondary {
|
||
color: #712ae2;
|
||
}
|
||
.clr-tertiary {
|
||
color: #00685f;
|
||
}
|
||
.bar {
|
||
width: 8px;
|
||
height: 16px;
|
||
border-radius: 9999px;
|
||
display: inline-block;
|
||
flex-shrink: 0;
|
||
}
|
||
.bar-primary {
|
||
background: #0054cd;
|
||
}
|
||
.bar-secondary {
|
||
background: #712ae2;
|
||
}
|
||
.bar-tertiary {
|
||
background: #00685f;
|
||
}
|
||
.head-hint {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: #5b6b7e;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ---------- 字段 ---------- */
|
||
.field-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 16px;
|
||
padding: 18px 20px 20px;
|
||
}
|
||
.field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
}
|
||
.span-2 {
|
||
grid-column: 1 / -1;
|
||
}
|
||
.field-label {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
}
|
||
.field-hint {
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: #5b6b7e;
|
||
}
|
||
.req {
|
||
color: #dc2626;
|
||
font-weight: 700;
|
||
}
|
||
.err-msg {
|
||
margin: 2px 0 0;
|
||
font-size: 12px;
|
||
color: #dc2626;
|
||
}
|
||
.ipt {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
padding: 9px 14px;
|
||
border: 1px solid #dbe4ee;
|
||
border-radius: 8px;
|
||
background: #f4f7fb;
|
||
font-size: 15px;
|
||
line-height: 22px;
|
||
color: #0d1c2c;
|
||
outline: none;
|
||
transition: all 0.2s;
|
||
}
|
||
.ipt:focus {
|
||
background: #fff;
|
||
border-color: #0054cd;
|
||
box-shadow: 0 0 0 2px rgba(0, 84, 205, 0.1);
|
||
}
|
||
.ipt.err {
|
||
border-color: #dc2626;
|
||
}
|
||
.mono-ipt {
|
||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||
font-size: 13px;
|
||
}
|
||
.mono-ipt.strong {
|
||
font-weight: 700;
|
||
color: #0054cd;
|
||
}
|
||
.inline-2 {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.unit {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: #5b6b7e;
|
||
flex-shrink: 0;
|
||
}
|
||
.unit-pre {
|
||
font-weight: 400;
|
||
}
|
||
/* 只读态:保留原有布局,仅弱化控件视觉 */
|
||
.ipt:disabled {
|
||
background: #f8fafc;
|
||
color: #5b6b7e;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* 客户 chips */
|
||
.customer-chips {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 2px 8px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
.chip-primary {
|
||
background: rgba(0, 84, 205, 0.1);
|
||
color: #0054cd;
|
||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||
}
|
||
.chip-plain {
|
||
background: #eef4ff;
|
||
color: #424655;
|
||
}
|
||
.chip-text {
|
||
font-size: 12px;
|
||
color: #5b6b7e;
|
||
}
|
||
|
||
/* 优先级 */
|
||
.mode-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 8px;
|
||
}
|
||
.mode-pill {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
padding: 9px 8px;
|
||
border: 1px solid #dbe4ee;
|
||
border-radius: 8px;
|
||
background: #eef4ff;
|
||
color: #0d1c2c;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.mode-pill:hover {
|
||
background: #dbe9ff;
|
||
}
|
||
.mode-pill.active {
|
||
background: #0054cd;
|
||
border-color: #0054cd;
|
||
color: #fff;
|
||
box-shadow: 0 2px 8px rgba(0, 84, 205, 0.28);
|
||
}
|
||
.mode-pill:disabled {
|
||
cursor: not-allowed;
|
||
}
|
||
.mode-pill:disabled:hover {
|
||
background: #eef4ff;
|
||
}
|
||
.mode-pill:disabled.active,
|
||
.mode-pill:disabled.active:hover {
|
||
background: #0054cd;
|
||
}
|
||
|
||
/* ---------- 第 2 步:SOP ---------- */
|
||
.tmpl-box {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
.tmpl-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 12px;
|
||
border: none;
|
||
border-radius: 9999px;
|
||
background: rgba(113, 42, 226, 0.1);
|
||
color: #712ae2;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
.tmpl-btn:hover {
|
||
background: rgba(113, 42, 226, 0.2);
|
||
}
|
||
.sop-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 12px;
|
||
padding: 18px 20px 20px;
|
||
}
|
||
.sop-grid.is-empty {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.sop-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 12px;
|
||
border: 1px solid rgba(219, 228, 238, 0.5);
|
||
border-radius: 8px;
|
||
background: #eef4ff;
|
||
}
|
||
.sop-top {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
.sop-name {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
line-height: 1.5;
|
||
}
|
||
.sop-required {
|
||
flex-shrink: 0;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
background: rgba(220, 38, 38, 0.1);
|
||
color: #dc2626;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
}
|
||
.sop-desc {
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
color: #5b6b7e;
|
||
}
|
||
.sop-foot {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding-top: 8px;
|
||
border-top: 1px solid rgba(219, 228, 238, 0.5);
|
||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: #0054cd;
|
||
}
|
||
.sop-add {
|
||
min-height: 96px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
border: 1px dashed #c2c6d8;
|
||
border-radius: 8px;
|
||
color: #5b6b7e;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.sop-add:hover {
|
||
border-color: rgba(0, 84, 205, 0.5);
|
||
color: #0054cd;
|
||
}
|
||
.sop-add-icon {
|
||
font-size: 22px;
|
||
}
|
||
|
||
/* ---------- 第 3 步:电工与报酬 ---------- */
|
||
.sec-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
padding: 18px 20px 20px;
|
||
}
|
||
.cand-empty {
|
||
padding: 24px;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
color: #5b6b7e;
|
||
background: #eef4ff;
|
||
border-radius: 10px;
|
||
}
|
||
.cand-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 16px;
|
||
}
|
||
.cand-card {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 16px;
|
||
padding: 16px;
|
||
border: 1px solid #dbe4ee;
|
||
border-radius: 12px;
|
||
background: #eef4ff;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
opacity: 0.85;
|
||
}
|
||
.cand-card:hover {
|
||
border-color: #c2c6d8;
|
||
}
|
||
.cand-card.selected {
|
||
border: 2px solid #0054cd;
|
||
background: rgba(29, 111, 255, 0.08);
|
||
opacity: 1;
|
||
}
|
||
.cand-card.readonly {
|
||
cursor: default;
|
||
}
|
||
.cand-avatar {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 50%;
|
||
background: rgba(0, 84, 205, 0.1);
|
||
color: #0054cd;
|
||
font-size: 17px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.cand-avatar.muted {
|
||
background: #dbe9ff;
|
||
color: #424655;
|
||
}
|
||
.cand-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.cand-top {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
.cand-name-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
.cand-name {
|
||
font-size: 17px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
}
|
||
.cand-badge-primary {
|
||
padding: 2px 8px;
|
||
border-radius: 9999px;
|
||
background: #0054cd;
|
||
color: #fff;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
}
|
||
.cand-badge-plain {
|
||
padding: 2px 8px;
|
||
border-radius: 9999px;
|
||
background: #e5efff;
|
||
color: #5b6b7e;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
}
|
||
.cand-check {
|
||
color: #0054cd;
|
||
font-size: 20px;
|
||
}
|
||
.cand-switch {
|
||
border: none;
|
||
background: transparent;
|
||
padding: 0;
|
||
color: #0054cd;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
.cand-switch:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.cand-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
font-size: 12px;
|
||
color: #5b6b7e;
|
||
}
|
||
.cand-rating {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||
font-weight: 700;
|
||
color: #e0852b;
|
||
}
|
||
.cand-cert {
|
||
color: #0d1c2c;
|
||
}
|
||
.cand-verified {
|
||
color: #00685f;
|
||
font-weight: 700;
|
||
}
|
||
.cand-unverified {
|
||
color: #e0852b;
|
||
font-weight: 700;
|
||
}
|
||
.cand-foot {
|
||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||
font-size: 11px;
|
||
color: #5b6b7e;
|
||
}
|
||
|
||
/* 报酬汇总 */
|
||
.quote-box {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
padding: 16px;
|
||
border: 1px solid rgba(219, 228, 238, 0.5);
|
||
border-radius: 12px;
|
||
background: #eef4ff;
|
||
}
|
||
.quote-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.quote-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
.q-label {
|
||
font-size: 12px;
|
||
color: #5b6b7e;
|
||
}
|
||
.q-value {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
}
|
||
.q-div {
|
||
width: 1px;
|
||
height: 24px;
|
||
background: #dbe4ee;
|
||
}
|
||
.quote-right {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
}
|
||
.q-total-label {
|
||
font-size: 12px;
|
||
color: #5b6b7e;
|
||
}
|
||
.q-total {
|
||
font-size: 23px;
|
||
font-weight: 700;
|
||
color: #0054cd;
|
||
}
|
||
|
||
/* ---------- 只读:测绘成果与复核记录 ---------- */
|
||
.ro-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
padding: 18px 20px 20px;
|
||
}
|
||
.ro-facts {
|
||
display: flex;
|
||
align-items: stretch;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.ro-fact {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
min-width: 120px;
|
||
padding: 10px 16px;
|
||
border: 1px solid rgba(219, 228, 238, 0.5);
|
||
border-radius: 10px;
|
||
background: #eef4ff;
|
||
}
|
||
.ro-k {
|
||
font-size: 12px;
|
||
color: #5b6b7e;
|
||
}
|
||
.ro-v {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
}
|
||
.ro-progress {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.ro-progress-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: #5b6b7e;
|
||
}
|
||
.ro-track {
|
||
height: 8px;
|
||
border-radius: 9999px;
|
||
background: #e5efff;
|
||
overflow: hidden;
|
||
}
|
||
.ro-fill {
|
||
height: 100%;
|
||
border-radius: 9999px;
|
||
background: linear-gradient(90deg, #0054cd, #4a8cf7);
|
||
transition: width 0.3s;
|
||
}
|
||
.ro-block {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
padding-top: 14px;
|
||
border-top: 1px solid #eef2f8;
|
||
}
|
||
.ro-block-title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
}
|
||
.ro-empty {
|
||
padding: 16px;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
background: #f8fafc;
|
||
border-radius: 10px;
|
||
}
|
||
.ro-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.ro-sop-row {
|
||
display: grid;
|
||
grid-template-columns: 20px 1fr 88px 1.2fr;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
background: #f8fafc;
|
||
font-size: 12.5px;
|
||
color: #0d1c2c;
|
||
}
|
||
.ro-sop-check {
|
||
width: 16px;
|
||
height: 16px;
|
||
border: 1px solid #dbe4ee;
|
||
border-radius: 4px;
|
||
background: #fff;
|
||
color: #fff;
|
||
font-size: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.ro-sop-check.done {
|
||
background: #00685f;
|
||
border-color: #00685f;
|
||
}
|
||
.ro-sop-name {
|
||
font-weight: 700;
|
||
}
|
||
.ro-sop-tag {
|
||
font-size: 11.5px;
|
||
font-weight: 700;
|
||
color: #712ae2;
|
||
}
|
||
.ro-sop-value {
|
||
font-size: 11.5px;
|
||
color: #5b6b7e;
|
||
word-break: break-all;
|
||
}
|
||
.ro-sop-thumbs {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
.ro-sop-thumb :deep(img) {
|
||
object-fit: cover;
|
||
border-radius: 6px;
|
||
border: 1px solid #dbe4ee;
|
||
}
|
||
.ro-photo-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
.ro-photo :deep(img) {
|
||
object-fit: cover;
|
||
border-radius: 8px;
|
||
}
|
||
.ro-notes {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.ro-note {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding: 10px 14px;
|
||
border-left: 3px solid #dbe4ee;
|
||
border-radius: 10px;
|
||
background: #f8fafc;
|
||
}
|
||
.ro-note.review {
|
||
border-left-color: #0054cd;
|
||
background: rgba(29, 111, 255, 0.05);
|
||
}
|
||
.ro-note.cancel {
|
||
border-left-color: #dc2626;
|
||
background: rgba(220, 38, 38, 0.05);
|
||
}
|
||
.ro-note.plain {
|
||
border-left-color: #94a3b8;
|
||
}
|
||
.ro-note-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
}
|
||
.ro-note-tag {
|
||
font-size: 11.5px;
|
||
font-weight: 700;
|
||
color: #5b6b7e;
|
||
}
|
||
.ro-note-time {
|
||
font-size: 11.5px;
|
||
color: #94a3b8;
|
||
}
|
||
.ro-note-text {
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
color: #0d1c2c;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
/* ---------- 底部操作条 ---------- */
|
||
.action-bar {
|
||
position: sticky;
|
||
bottom: 12px;
|
||
z-index: 10;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 14px 20px;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
backdrop-filter: blur(6px);
|
||
border: 1px solid #dbe4ee;
|
||
border-radius: 16px;
|
||
box-shadow: 0 8px 20px rgba(16, 24, 40, 0.12);
|
||
}
|
||
.action-hint {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: #5b6b7e;
|
||
}
|
||
.hint-icon {
|
||
color: #00685f;
|
||
font-size: 16px;
|
||
}
|
||
.action-btns {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
@media (max-width: 960px) {
|
||
.field-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.sop-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.cand-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.step-bar {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.action-bar {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
}
|
||
</style>
|