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

+6 -1
View File
@@ -78,7 +78,10 @@ export interface SurveyOrderSopResult {
surveyOrderId: number; surveyOrderId: number;
sopItemId: number; sopItemId: number;
checked: boolean; checked: boolean;
receiptValue?: string; // 回执内容:photo/meter 类为照片对象 key 数组,form 类为单元素文本值数组
receiptValue?: string[];
// 详情/回执接口附带:照片类回执的预签名访问 URL(与 receiptValue 顺序一致)
receiptUrls?: string[];
remark?: string; remark?: string;
checkedAt?: string; checkedAt?: string;
// 由后端 join 的检查项信息 // 由后端 join 的检查项信息
@@ -127,6 +130,8 @@ export interface SurveyOrderInfo {
estimatedFee?: number | string; estimatedFee?: number | string;
actualFee?: number | string; actualFee?: number | string;
photos?: string[]; photos?: string[];
// 详情附带:现场照片预签名访问 URL(与 photos 顺序一致)
photoUrls?: string[];
reviewRemark?: string; reviewRemark?: string;
reviewedAt?: string; reviewedAt?: string;
cancelReason?: string; cancelReason?: string;
+26 -9
View File
@@ -265,28 +265,45 @@ ol {
animation: slideInUp 0.3s ease-out; animation: slideInUp 0.3s ease-out;
} }
// ==================== 滚动条美化 ==================== // ==================== 滚动条美化(iOS 风格) ====================
// iOS 风格特征:极细(4px)、全圆角胶囊形、无轨道、半透明 thumb 悬浮在内容上
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 4px;
height: 8px; height: 4px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #f1f1f1; background: transparent;
border-radius: 4px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #c1c1c1; background: rgba(0, 0, 0, 0.2);
border-radius: 4px; border-radius: 999px;
&:hover { &:hover {
background: #a8a8a8; background: rgba(0, 0, 0, 0.35);
} }
} }
// 深色背景下的滚动条变体(侧边栏等)
.sider-inner ::-webkit-scrollbar-thumb,
.ant-layout-sider ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.25);
}
.sider-inner ::-webkit-scrollbar-thumb:hover,
.ant-layout-sider ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.4);
}
// Firefox // Firefox
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #c1c1c1 #f1f1f1; scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
// Firefox 深色变体
.sider-inner,
.ant-layout-sider {
scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
} }
+30 -15
View File
@@ -3,19 +3,21 @@
<!-- 侧边栏 --> <!-- 侧边栏 -->
<a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible :width="240" :collapsed-width="80" <a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible :width="240" :collapsed-width="80"
theme="dark" class="sider"> theme="dark" class="sider">
<!-- Logo区域 --> <div class="sider-inner">
<div class="logo" @click="$router.push('/dashboard')"> <!-- Logo区域 -->
<img src="@/assets/vue.svg" alt="logo" class="logo-img" /> <div class="logo" @click="$router.push('/dashboard')">
<h1 v-show="!collapsed" class="logo-title">智能电网IoT</h1> <img src="@/assets/vue.svg" alt="logo" class="logo-img" />
</div> <h1 v-show="!collapsed" class="logo-title">智能电网IoT</h1>
</div>
<!-- 菜单 --> <!-- 菜单 -->
<a-menu v-model:selectedKeys="selectedKeys" v-model:openKeys="openKeys" mode="inline" theme="dark" <a-menu v-model:selectedKeys="selectedKeys" v-model:openKeys="openKeys" mode="inline" theme="dark"
:items="menuItems" @click="handleMenuClick" /> :items="menuItems" @click="handleMenuClick" />
<!-- 折叠按钮 --> <!-- 折叠按钮 -->
<div class="collapse-btn" @click="toggleCollapse"> <div class="collapse-btn" @click="toggleCollapse">
<component :is="collapsed ? MenuUnfoldOutlined : MenuFoldOutlined" /> <component :is="collapsed ? MenuUnfoldOutlined : MenuFoldOutlined" />
</div>
</div> </div>
</a-layout-sider> </a-layout-sider>
@@ -301,7 +303,19 @@ function handleUserMenuClick({ key }: { key: string }) {
z-index: 100; z-index: 100;
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35); box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
:deep(.ant-layout-sider-children) {
height: 100%;
}
.sider-inner {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.logo { .logo {
flex-shrink: 0;
height: 64px; height: 64px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -336,6 +350,10 @@ function handleUserMenuClick({ key }: { key: string }) {
:deep(.ant-menu) { :deep(.ant-menu) {
border-right: none; border-right: none;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
} }
// iconfont 图标统一:该图标集为彩色字体,颜色写死在字形里,无法用 color 覆盖, // iconfont 图标统一:该图标集为彩色字体,颜色写死在字形里,无法用 color 覆盖,
@@ -363,10 +381,7 @@ function handleUserMenuClick({ key }: { key: string }) {
} }
.collapse-btn { .collapse-btn {
position: absolute; flex-shrink: 0;
bottom: 0;
left: 0;
right: 0;
height: 48px; height: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
+450 -20
View File
@@ -15,10 +15,27 @@
<div class="header-card"> <div class="header-card">
<div class="header-top"> <div class="header-top">
<div class="header-left"> <div class="header-left">
<h1 class="main-title">现场勘察派单向导</h1> <div class="title-row">
<p class="desc">标准化派发配电房现场测绘任务,向共享电工推单并回传拓扑参数</p> <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> </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>
<div class="step-bar"> <div class="step-bar">
@@ -67,6 +84,7 @@
size="large" size="large"
style="width: 100%" style="width: 100%"
option-filter-prop="label" option-filter-prop="label"
:disabled="isReadonly"
:options="customerSelectOptions" :options="customerSelectOptions"
@change="handleCustomerChange" @change="handleCustomerChange"
/> />
@@ -90,6 +108,7 @@
:class="{ err: errors.taskTitle }" :class="{ err: errors.taskTitle }"
type="text" type="text"
maxlength="128" maxlength="128"
:disabled="isReadonly"
placeholder="如:实训楼智能配电改造一期工程现场测绘" placeholder="如:实训楼智能配电改造一期工程现场测绘"
@input="clearError('taskTitle')" @input="clearError('taskTitle')"
/> />
@@ -104,6 +123,7 @@
:class="{ err: errors.siteLocation }" :class="{ err: errors.siteLocation }"
type="text" type="text"
maxlength="255" maxlength="255"
:disabled="isReadonly"
placeholder="如:实训主楼 1F低压室 / 2~3F强电井" placeholder="如:实训主楼 1F低压室 / 2~3F强电井"
@input="clearError('siteLocation')" @input="clearError('siteLocation')"
/> />
@@ -117,6 +137,7 @@
class="ipt mono-ipt" class="ipt mono-ipt"
type="text" type="text"
maxlength="128" maxlength="128"
:disabled="isReadonly"
placeholder="如:380V / 400A MCCB (SCB10-630kVA)" placeholder="如:380V / 400A MCCB (SCB10-630kVA)"
/> />
</div> </div>
@@ -130,6 +151,7 @@
:class="{ err: errors.loopCount }" :class="{ err: errors.loopCount }"
type="number" type="number"
min="0" min="0"
:disabled="isReadonly"
@input="clearError('loopCount')" @input="clearError('loopCount')"
/> />
<span class="unit">路</span> <span class="unit">路</span>
@@ -141,7 +163,7 @@
<label class="field-label"><span>要求期望到场时限</span></label> <label class="field-label"><span>要求期望到场时限</span></label>
<div class="inline-2"> <div class="inline-2">
<span class="unit unit-pre">接单后</span> <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> <span class="unit">分钟内</span>
</div> </div>
</div> </div>
@@ -153,6 +175,7 @@
class="ipt" class="ipt"
type="text" type="text"
maxlength="128" maxlength="128"
:disabled="isReadonly"
placeholder="如:程建业 (项目主管) - 138****9201" placeholder="如:程建业 (项目主管) - 138****9201"
/> />
</div> </div>
@@ -164,6 +187,7 @@
format="YYYY-MM-DD" format="YYYY-MM-DD"
size="large" size="large"
style="width: 100%" style="width: 100%"
:disabled="isReadonly"
:placeholder="['开始日期', '结束日期']" :placeholder="['开始日期', '结束日期']"
/> />
</div> </div>
@@ -177,6 +201,7 @@
type="button" type="button"
class="mode-pill" class="mode-pill"
:class="{ active: formState.priority === p.value }" :class="{ active: formState.priority === p.value }"
:disabled="isReadonly"
@click="formState.priority = p.value" @click="formState.priority = p.value"
> >
{{ p.label }} {{ p.label }}
@@ -194,7 +219,7 @@
<FileDoneOutlined class="sec-icon clr-secondary" /> <FileDoneOutlined class="sec-icon clr-secondary" />
<h2>第 2 步:勘察技术要点与回执标准</h2> <h2>第 2 步:勘察技术要点与回执标准</h2>
</div> </div>
<div class="tmpl-box"> <div v-if="!isReadonly" class="tmpl-box">
<span class="head-hint">SOP规范模板:</span> <span class="head-hint">SOP规范模板:</span>
<button type="button" class="tmpl-btn" @click="applySopTemplate"> <button type="button" class="tmpl-btn" @click="applySopTemplate">
<PlusOutlined /> <PlusOutlined />
@@ -216,7 +241,7 @@
</div> </div>
</div> </div>
<div class="sop-add" @click="handleAddCustomItem"> <div v-if="!isReadonly" class="sop-add" @click="handleAddCustomItem">
<PlusOutlined class="sop-add-icon" /> <PlusOutlined class="sop-add-icon" />
<span>+ 自定义勘察检查项</span> <span>+ 自定义勘察检查项</span>
</div> </div>
@@ -242,7 +267,7 @@
v-for="w in workers" v-for="w in workers"
:key="w.id" :key="w.id"
class="cand-card" class="cand-card"
:class="{ selected: formState.workerId === w.id }" :class="{ selected: formState.workerId === w.id, readonly: isReadonly }"
@click="selectWorker(w)" @click="selectWorker(w)"
> >
<div class="cand-avatar" :class="{ muted: formState.workerId !== w.id }"> <div class="cand-avatar" :class="{ muted: formState.workerId !== w.id }">
@@ -256,7 +281,7 @@
<span v-else class="cand-badge-plain">{{ w.workerTitle || '共享电工' }}</span> <span v-else class="cand-badge-plain">{{ w.workerTitle || '共享电工' }}</span>
</div> </div>
<CheckCircleFilled v-if="formState.workerId === w.id" class="cand-check" /> <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>
<div class="cand-meta"> <div class="cand-meta">
<span class="cand-rating"><StarFilled /> {{ Number(w.rating || 0).toFixed(2) }} 分</span> <span class="cand-rating"><StarFilled /> {{ Number(w.rating || 0).toFixed(2) }} 分</span>
@@ -290,6 +315,7 @@
v-model:value="formState.slaLevel" v-model:value="formState.slaLevel"
size="small" size="small"
style="width: 210px" style="width: 210px"
:disabled="isReadonly"
:options="SLA_OPTIONS" :options="SLA_OPTIONS"
/> />
</div> </div>
@@ -302,25 +328,147 @@
</div> </div>
</section> </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-bar">
<div class="action-hint"> <div class="action-hint">
<CheckCircleFilled class="hint-icon" /> <CheckCircleFilled class="hint-icon" />
<span>向导已填报 {{ filledCount }}/3 核心项 · 数据直连共享电工协同网</span> <span v-if="isReadonly">工单已流转至「{{ statusMeta?.label || '只读' }}」 · 当前为只读查看</span>
<span v-else>向导已填报 {{ filledCount }}/3 核心项 · 数据直连共享电工协同网</span>
</div> </div>
<div class="action-btns"> <div class="action-btns">
<a-button @click="goLedger"> <a-button v-if="isReadonly" @click="goLedger">
<template #icon><ArrowLeftOutlined /></template> <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> </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>
</div> </div>
@@ -348,6 +496,7 @@ import {
surveyOrderApi, surveyOrderApi,
SOP_TEMPLATE_CODE, SOP_TEMPLATE_CODE,
SOP_TEMPLATE_LABEL, SOP_TEMPLATE_LABEL,
SURVEY_STATUS,
SURVEY_PRIORITY, SURVEY_PRIORITY,
SURVEY_PRIORITY_OPTIONS, SURVEY_PRIORITY_OPTIONS,
SURVEY_BASE_FEE, SURVEY_BASE_FEE,
@@ -355,6 +504,7 @@ import {
type SurveySopItem, type SurveySopItem,
type SurveyQuote, type SurveyQuote,
type SurveyOrderCreateParams, type SurveyOrderCreateParams,
type SurveyOrderInfo,
} from '@/api/surveyOrder'; } from '@/api/surveyOrder';
import { workerApi, type WorkerRecommend } from '@/api/worker'; import { workerApi, type WorkerRecommend } from '@/api/worker';
import { customerApi, type CustomerInfo } from '@/api/customer'; 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 editId = computed(() => Number(route.params.id || 0));
const isEdit = computed(() => editId.value > 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 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 loading = ref(false);
const submitting = ref(false); const submitting = ref(false);
@@ -487,6 +682,7 @@ function handleCustomerChange() {
} }
function selectWorker(w: WorkerRecommend) { function selectWorker(w: WorkerRecommend) {
if (isReadonly.value) return;
formState.workerId = w.id; formState.workerId = w.id;
} }
@@ -575,6 +771,7 @@ async function loadDetail() {
const res = await surveyOrderApi.getDetail(editId.value); const res = await surveyOrderApi.getDetail(editId.value);
if (res.code === 200 && res.data) { if (res.code === 200 && res.data) {
const d = res.data; const d = res.data;
detailRecord.value = d;
surveyNo.value = d.surveyNo || ''; surveyNo.value = d.surveyNo || '';
formState.customerId = d.customerId; formState.customerId = d.customerId;
formState.taskTitle = d.taskTitle || ''; formState.taskTitle = d.taskTitle || '';
@@ -791,6 +988,28 @@ onMounted(async () => {
.header-left { .header-left {
min-width: 0; 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 { .main-title {
margin: 0; margin: 0;
font-size: 23px; font-size: 23px;
@@ -813,6 +1032,10 @@ onMounted(async () => {
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
} }
.step-badge.ro {
background: rgba(148, 163, 184, 0.16);
color: #5b6b7e;
}
/* 步骤条 */ /* 步骤条 */
.step-bar { .step-bar {
@@ -1026,6 +1249,12 @@ onMounted(async () => {
.unit-pre { .unit-pre {
font-weight: 400; font-weight: 400;
} }
/* 只读态:保留原有布局,仅弱化控件视觉 */
.ipt:disabled {
background: #f8fafc;
color: #5b6b7e;
cursor: not-allowed;
}
/* 客户 chips */ /* 客户 chips */
.customer-chips { .customer-chips {
@@ -1086,6 +1315,16 @@ onMounted(async () => {
color: #fff; color: #fff;
box-shadow: 0 2px 8px rgba(0, 84, 205, 0.28); 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 ---------- */ /* ---------- 第 2 步:SOP ---------- */
.tmpl-box { .tmpl-box {
@@ -1230,6 +1469,9 @@ onMounted(async () => {
background: rgba(29, 111, 255, 0.08); background: rgba(29, 111, 255, 0.08);
opacity: 1; opacity: 1;
} }
.cand-card.readonly {
cursor: default;
}
.cand-avatar { .cand-avatar {
width: 48px; width: 48px;
height: 48px; height: 48px;
@@ -1388,6 +1630,194 @@ onMounted(async () => {
color: #0054cd; 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 { .action-bar {
position: sticky; position: sticky;
+8 -183
View File
@@ -166,7 +166,7 @@
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'surveyNo'"> <template v-if="column.key === 'surveyNo'">
<div class="no-cell"> <div class="no-cell">
<span class="survey-no" @click="openDetail(record)">{{ record.surveyNo }}</span> <span class="survey-no" @click="goDetail(record)">{{ record.surveyNo }}</span>
<span <span
v-if="priorityMeta(record.priority)" v-if="priorityMeta(record.priority)"
class="chip" class="chip"
@@ -253,12 +253,10 @@
<a-button type="link" size="small">更多 <DownOutlined /></a-button> <a-button type="link" size="small">更多 <DownOutlined /></a-button>
<template #overlay> <template #overlay>
<a-menu @click="({ key }) => handleMore(key as string, record)"> <a-menu @click="({ key }) => handleMore(key as string, record)">
<a-menu-item v-if="canEdit(record)" key="edit">编辑</a-menu-item>
<a-menu-item v-if="record.status === SURVEY_STATUS.SURVEYING" key="reassign">改派</a-menu-item> <a-menu-item v-if="record.status === SURVEY_STATUS.SURVEYING" key="reassign">改派</a-menu-item>
<a-menu-item v-if="canUrge(record)" key="urge">催办</a-menu-item> <a-menu-item v-if="canUrge(record)" key="urge">催办</a-menu-item>
<a-menu-item v-if="record.status === SURVEY_STATUS.PENDING_REVIEW" key="photos">取证照片</a-menu-item>
<a-menu-item v-if="record.status === SURVEY_STATUS.ARCHIVED" key="report">查看报告</a-menu-item> <a-menu-item v-if="record.status === SURVEY_STATUS.ARCHIVED" key="report">查看报告</a-menu-item>
<a-menu-item key="detail">详情</a-menu-item> <a-menu-item key="open">{{ canEdit(record) ? '编辑' : '详情' }}</a-menu-item>
<a-menu-item v-if="canCancel(record)" key="cancel" danger>取消</a-menu-item> <a-menu-item v-if="canCancel(record)" key="cancel" danger>取消</a-menu-item>
</a-menu> </a-menu>
</template> </template>
@@ -407,67 +405,6 @@
</a-form> </a-form>
</a-modal> </a-modal>
<!-- 工单详情 -->
<a-modal v-model:open="detailVisible" title="勘察工单详情" :footer="null" width="820px">
<div v-if="currentRecord" class="detail">
<div class="detail-grid">
<div class="detail-item"><span class="k">工单编号</span><span class="v mono">{{ currentRecord.surveyNo }}</span></div>
<div class="detail-item"><span class="k">当前状态</span><span class="v">{{ statusMeta(currentRecord.status).label }}</span></div>
<div class="detail-item"><span class="k">关联客户</span><span class="v">{{ currentRecord.customerName || currentRecord.customer?.customerName || '-' }}</span></div>
<div class="detail-item"><span class="k">勘察标题</span><span class="v">{{ currentRecord.taskTitle || '-' }}</span></div>
<div class="detail-item"><span class="k">物理区域</span><span class="v">{{ currentRecord.siteLocation || '-' }}</span></div>
<div class="detail-item"><span class="k">回路 / 规格</span><span class="v">{{ currentRecord.loopCount ?? 0 }} 路 · {{ currentRecord.loopSpec || '—' }}</span></div>
<div class="detail-item"><span class="k">责任业务员</span><span class="v">{{ currentRecord.salesmanName || '-' }}</span></div>
<div class="detail-item"><span class="k">指派电工</span><span class="v">{{ currentRecord.workerName || '待指派' }}</span></div>
<div class="detail-item"><span class="k">技术联系人</span><span class="v">{{ currentRecord.techContact || '-' }}</span></div>
<div class="detail-item"><span class="k">期望到场</span><span class="v">{{ formatDateTime(currentRecord.expectArrivalAt) }}</span></div>
<div class="detail-item"><span class="k">预估劳务费</span><span class="v">{{ money(currentRecord.estimatedFee) }}</span></div>
<div class="detail-item"><span class="k">SLA 等级</span><span class="v">{{ currentRecord.slaLevel || '-' }}</span></div>
<div class="detail-item"><span class="k">测绘进度</span><span class="v">{{ currentRecord.progressDone ?? 0 }} / {{ currentRecord.progressTotal ?? 0 }}</span></div>
<div class="detail-item"><span class="k">催办次数</span><span class="v">{{ currentRecord.urgeCount ?? 0 }}</span></div>
</div>
<div v-if="currentRecord.status === SURVEY_STATUS.PENDING_REVIEW || currentRecord.status === SURVEY_STATUS.ARCHIVED" class="detail-block">
<div class="detail-label">SOP 测绘成果回执</div>
<div v-if="currentRecord.sopResults?.length" class="sop-list">
<div v-for="item in currentRecord.sopResults" :key="item.id" class="sop-row">
<span class="sop-check" :class="{ ok: item.checked }">
<CheckOutlined v-if="item.checked" />
<CloseOutlined v-else />
</span>
<span class="sop-name">{{ item.itemName || item.sopItem?.itemName || '—' }}</span>
<span class="sop-receipt">{{ receiptLabel(item.receiptType || item.sopItem?.receiptType) }}</span>
<span class="sop-value">{{ item.receiptValue || item.remark || '—' }}</span>
</div>
</div>
<div v-else class="detail-text">—</div>
</div>
<div class="detail-block">
<div class="detail-label">现场取证照片</div>
<div v-if="currentRecord.photos?.length" class="photo-list">
<a-image-preview-group>
<a-image
v-for="(url, idx) in currentRecord.photos"
:key="idx"
:src="url"
:width="110"
:height="80"
style="object-fit: cover; border-radius: 8px"
/>
</a-image-preview-group>
</div>
<div v-else class="detail-text">—</div>
</div>
<div v-if="currentRecord.reviewRemark || currentRecord.cancelReason || currentRecord.remark" class="detail-block">
<div class="detail-label">复核 / 取消 / 备注</div>
<div v-if="currentRecord.reviewRemark" class="detail-text">复核意见:{{ currentRecord.reviewRemark }}</div>
<div v-if="currentRecord.cancelReason" class="detail-text">取消原因:{{ currentRecord.cancelReason }}</div>
<div v-if="currentRecord.remark" class="detail-text">备注:{{ currentRecord.remark }}</div>
</div>
</div>
</a-modal>
</div> </div>
</template> </template>
@@ -488,8 +425,6 @@ import {
ThunderboltOutlined, ThunderboltOutlined,
FileSearchOutlined, FileSearchOutlined,
CheckCircleOutlined, CheckCircleOutlined,
CheckOutlined,
CloseOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
@@ -773,18 +708,16 @@ function handlePrimary(record: SurveyOrderInfo) {
const action = primaryAction(record); const action = primaryAction(record);
if (!action) return; if (!action) return;
if (action.label === '指派电工') return openAssign(record, false); if (action.label === '指派电工') return openAssign(record, false);
if (action.label === '进度跟踪') return openDetail(record); if (action.label === '进度跟踪') return goDetail(record);
if (action.label === '审核测绘成果') return openReview(record); if (action.label === '审核测绘成果') return openReview(record);
if (action.label === '生成方案') return message.info('生成项目方案功能暂未开放'); if (action.label === '生成方案') return message.info('生成项目方案功能暂未开放');
} }
function handleMore(key: string, record: SurveyOrderInfo) { function handleMore(key: string, record: SurveyOrderInfo) {
if (key === 'edit') return router.push({ name: 'ProjectSurveyEdit', params: { id: record.id } }); if (key === 'open') return goDetail(record);
if (key === 'reassign') return openAssign(record, true); if (key === 'reassign') return openAssign(record, true);
if (key === 'urge') return handleUrge(record); if (key === 'urge') return handleUrge(record);
if (key === 'photos') return openDetail(record);
if (key === 'report') return message.info('查看勘察报告功能暂未开放'); if (key === 'report') return message.info('查看勘察报告功能暂未开放');
if (key === 'detail') return openDetail(record);
if (key === 'cancel') return openCancel(record); if (key === 'cancel') return openCancel(record);
} }
@@ -946,18 +879,10 @@ async function handleCancelSubmit() {
} }
} }
// ==================== 详情 ==================== // ==================== 详情 / 编辑 ====================
const detailVisible = ref(false); // 详情与编辑统一进编辑页;不可编辑状态由 form.vue 按 status 自动降级为只读
function goDetail(record: SurveyOrderInfo) {
async function openDetail(record: SurveyOrderInfo) { router.push({ name: 'ProjectSurveyEdit', params: { id: record.id } });
currentRecord.value = record;
detailVisible.value = true;
try {
const res = await surveyOrderApi.getDetail(record.id);
currentRecord.value = res.data;
} catch {
// 详情获取失败时保留列表行数据
}
} }
// ==================== 展示辅助 ==================== // ==================== 展示辅助 ====================
@@ -984,11 +909,6 @@ function maskPhone(phone?: string) {
if (phone.length < 7) return phone; if (phone.length < 7) return phone;
return `${phone.slice(0, 3)}****${phone.slice(-4)}`; return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
} }
function receiptLabel(type?: string) {
const map: Record<string, string> = { photo: '现场照片', form: '清单表单', meter: '测仪截图' };
return type ? map[type] || type : '—';
}
</script> </script>
<style scoped> <style scoped>
@@ -1529,101 +1449,6 @@ function receiptLabel(type?: string) {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
/* 详情弹窗 */
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px 20px;
padding: 4px 0 12px;
}
.detail-item {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.detail-item .k {
flex-shrink: 0;
width: 84px;
font-size: 12px;
color: #5b6b7e;
}
.detail-item .v {
font-size: 13px;
color: #0d1c2c;
word-break: break-all;
}
.detail-block {
border-top: 1px solid #eef1f6;
padding-top: 12px;
margin-top: 4px;
}
.detail-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
margin-bottom: 8px;
}
.detail-text {
font-size: 13px;
color: #0d1c2c;
line-height: 1.7;
white-space: pre-wrap;
}
.sop-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.sop-row {
display: grid;
grid-template-columns: 20px 1fr 84px 1.2fr;
align-items: center;
gap: 8px;
font-size: 12.5px;
color: #0d1c2c;
}
.sop-check {
display: flex;
align-items: center;
justify-content: center;
color: #dc2626;
}
.sop-check.ok {
color: #059669;
}
.sop-name {
font-weight: 600;
}
.sop-receipt {
font-size: 11.5px;
color: #8a97a8;
}
.sop-value {
font-size: 11.5px;
color: #5b6b7e;
word-break: break-all;
}
.photo-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.mono { .mono {
font-family: Consolas, Menlo, monospace; font-family: Consolas, Menlo, monospace;
color: #0054cd; color: #0054cd;