refactor(survey): 重构工单详情页与侧边栏样式,统一详情查看流程

1.  重构勘察工单详情逻辑,将弹窗详情改为直接跳转编辑页,支持只读查看
2.  更新API数据结构,新增回执URL与照片URL字段支持
3.  优化全局滚动条样式为iOS风格,适配深色侧边栏
4.  修复侧边栏布局问题,添加flex布局与滚动容器适配
5.  新增只读详情页面,展示测绘成果与复核记录
This commit is contained in:
andyesfly committed 2026-09-19 15:12:11 +08:00
1 parent 6b00f61550
commit 430e5c24f8
5 files changed
+520 -228

No files matched your search

+450 -20
View File
@@ -15,10 +15,27 @@
<div class="header-card">
<div class="header-top">
<div class="header-left">
<h1 class="main-title">现场勘察派单向导</h1>
<p class="desc">标准化派发配电房现场测绘任务,向共享电工推单并回传拓扑参数</p>
<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 class="step-badge">STEP 0{{ currentStep }} / 03</span>
<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">
@@ -67,6 +84,7 @@
size="large"
style="width: 100%"
option-filter-prop="label"
:disabled="isReadonly"
:options="customerSelectOptions"
@change="handleCustomerChange"
/>
@@ -90,6 +108,7 @@
:class="{ err: errors.taskTitle }"
type="text"
maxlength="128"
:disabled="isReadonly"
placeholder="如:实训楼智能配电改造一期工程现场测绘"
@input="clearError('taskTitle')"
/>
@@ -104,6 +123,7 @@
:class="{ err: errors.siteLocation }"
type="text"
maxlength="255"
:disabled="isReadonly"
placeholder="如:实训主楼 1F低压室 / 2~3F强电井"
@input="clearError('siteLocation')"
/>
@@ -117,6 +137,7 @@
class="ipt mono-ipt"
type="text"
maxlength="128"
:disabled="isReadonly"
placeholder="如:380V / 400A MCCB (SCB10-630kVA)"
/>
</div>
@@ -130,6 +151,7 @@
:class="{ err: errors.loopCount }"
type="number"
min="0"
:disabled="isReadonly"
@input="clearError('loopCount')"
/>
<span class="unit">路</span>
@@ -141,7 +163,7 @@
<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" />
<input v-model.number="formState.expectMinutes" class="ipt mono-ipt" type="number" min="0" :disabled="isReadonly" />
<span class="unit">分钟内</span>
</div>
</div>
@@ -153,6 +175,7 @@
class="ipt"
type="text"
maxlength="128"
:disabled="isReadonly"
placeholder="如:程建业 (项目主管) - 138****9201"
/>
</div>
@@ -164,6 +187,7 @@
format="YYYY-MM-DD"
size="large"
style="width: 100%"
:disabled="isReadonly"
:placeholder="['开始日期', '结束日期']"
/>
</div>
@@ -177,6 +201,7 @@
type="button"
class="mode-pill"
:class="{ active: formState.priority === p.value }"
:disabled="isReadonly"
@click="formState.priority = p.value"
>
{{ p.label }}
@@ -194,7 +219,7 @@
<FileDoneOutlined class="sec-icon clr-secondary" />
<h2>第 2 步:勘察技术要点与回执标准</h2>
</div>
<div class="tmpl-box">
<div v-if="!isReadonly" class="tmpl-box">
<span class="head-hint">SOP规范模板:</span>
<button type="button" class="tmpl-btn" @click="applySopTemplate">
<PlusOutlined />
@@ -216,7 +241,7 @@
</div>
</div>
<div class="sop-add" @click="handleAddCustomItem">
<div v-if="!isReadonly" class="sop-add" @click="handleAddCustomItem">
<PlusOutlined class="sop-add-icon" />
<span>+ 自定义勘察检查项</span>
</div>
@@ -242,7 +267,7 @@
v-for="w in workers"
:key="w.id"
class="cand-card"
:class="{ selected: formState.workerId === w.id }"
:class="{ selected: formState.workerId === w.id, readonly: isReadonly }"
@click="selectWorker(w)"
>
<div class="cand-avatar" :class="{ muted: formState.workerId !== w.id }">
@@ -256,7 +281,7 @@
<span v-else class="cand-badge-plain">{{ w.workerTitle || '共享电工' }}</span>
</div>
<CheckCircleFilled v-if="formState.workerId === w.id" class="cand-check" />
<button v-else type="button" class="cand-switch" @click.stop="selectWorker(w)">改选此人</button>
<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>
@@ -290,6 +315,7 @@
v-model:value="formState.slaLevel"
size="small"
style="width: 210px"
:disabled="isReadonly"
:options="SLA_OPTIONS"
/>
</div>
@@ -302,25 +328,147 @@
</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>向导已填报 {{ filledCount }}/3 核心项 · 数据直连共享电工协同网</span>
<span v-if="isReadonly">工单已流转至「{{ statusMeta?.label || '只读' }}」 · 当前为只读查看</span>
<span v-else>向导已填报 {{ filledCount }}/3 核心项 · 数据直连共享电工协同网</span>
</div>
<div class="action-btns">
<a-button @click="goLedger">
<a-button v-if="isReadonly" @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 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>
@@ -348,6 +496,7 @@ import {
surveyOrderApi,
SOP_TEMPLATE_CODE,
SOP_TEMPLATE_LABEL,
SURVEY_STATUS,
SURVEY_PRIORITY,
SURVEY_PRIORITY_OPTIONS,
SURVEY_BASE_FEE,
@@ -355,6 +504,7 @@ import {
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';
@@ -387,9 +537,54 @@ const DEFAULT_EXPECT_MINUTES = 45;
// ==================== 模式 ====================
const editId = computed(() => Number(route.params.id || 0));
const isEdit = computed(() => editId.value > 0);
const pageTitle = computed(() => (isEdit.value ? '编辑勘察派单' : '新建勘察派单'));
// 详情与编辑共用本页: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);
@@ -487,6 +682,7 @@ function handleCustomerChange() {
}
function selectWorker(w: WorkerRecommend) {
if (isReadonly.value) return;
formState.workerId = w.id;
}
@@ -575,6 +771,7 @@ async function loadDetail() {
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 || '';
@@ -791,6 +988,28 @@ onMounted(async () => {
.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;
@@ -813,6 +1032,10 @@ onMounted(async () => {
font-size: 12px;
font-weight: 700;
}
.step-badge.ro {
background: rgba(148, 163, 184, 0.16);
color: #5b6b7e;
}
/* 步骤条 */
.step-bar {
@@ -1026,6 +1249,12 @@ onMounted(async () => {
.unit-pre {
font-weight: 400;
}
/* 只读态:保留原有布局,仅弱化控件视觉 */
.ipt:disabled {
background: #f8fafc;
color: #5b6b7e;
cursor: not-allowed;
}
/* 客户 chips */
.customer-chips {
@@ -1086,6 +1315,16 @@ onMounted(async () => {
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 {
@@ -1230,6 +1469,9 @@ onMounted(async () => {
background: rgba(29, 111, 255, 0.08);
opacity: 1;
}
.cand-card.readonly {
cursor: default;
}
.cand-avatar {
width: 48px;
height: 48px;
@@ -1388,6 +1630,194 @@ onMounted(async () => {
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;