Files
smart_grid_iot/client/src/views/dispatch/survey/form.vue
T
wk 41765cdf44 feat: 新增智电改造线路台账功能(ICC拉取同步)
- 数据库:新增迁移脚本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
2026-10-12 15:57:02 +08:00

1886 lines
47 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="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>