finish-plan-detail.vue 20.9 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626
<template>
  <view class="page-container" style="padding-bottom: 20px;">
    <!-- 页面级加载组件 -->
    <up-loading-page
        v-if="loading"
        :loading="true"
        title="加载中..."
        color="#3c9cff"
    ></up-loading-page>

    <!-- 内容容器 -->
    <template v-else>
      <view class="content-wrap" v-for="(i, index) in orderDetail" :key="index">
        <!-- 工单详情内容 -->
        <up-cell-group :border="false" inset>
          <!-- 1. 工单计划名称 -->
          <up-cell align="middle">
            <template #title>
              <view class="up-line-1">{{i.planName || '--'}}</view>
            </template>
          </up-cell>

          <!-- 2. 工单位置 -->
          <up-cell
              title="计划编码"
              :value="i.planNo || '--'"
              align="middle"
          ></up-cell>


          <up-cell
              title="养护周期"
              :value="`${i.rate}${uni.$dict.getDictLabel('cycle_id_type', i.cycleId)}`"
              align="middle"
          ></up-cell>

          <!-- 4. 情况描述 -->
          <up-cell
              title="计划有效期"
              :value="`${timeFormat(i.beginTime,'yyyy-mm-dd')} 至 ${timeFormat(i.endTime,'yyyy-mm-dd')}`"
              align="middle"
          ></up-cell>

          <!-- 5. 问题照片(核心修复:兼容WebP + 强制尺寸) -->
          <!-- <up-cell title="照片">
            <template #value>
              <view class="cell-content-wrap">

                <up-album
                    :urls="i.beginImgList "
                    singleSize="70"
                    multipleSize="70"
                    :preview-full-image="true"

                ></up-album>

<!--                &lt;!&ndash; 修复1:手动渲染图片,兼容WebP + 强制尺寸 &ndash;&gt;-->
<!--                <view v-if="!!i.beginImgList?.length" class="album-container">-->
<!--                  <view-->
<!--                      class="album-item"-->
<!--                      v-for="(imgUrl, imgIndex) in formatImageUrls(i.beginImgList)"-->
<!--                      :key="imgIndex"-->
<!--                      @click="previewImage(imgUrl, formatImageUrls(i.beginImgList))"-->
<!--                  >-->
<!--                    <image-->
<!--                        :src="imgUrl"-->
<!--                        mode="widthFix"-->
<!--                        class="album-image"-->
<!--                        :style="{width: '70px', height: '70px', objectFit: 'cover'}"-->
<!--                    ></image>-->
<!--                  </view>-->
<!--                </view>-->
<!--                <text v-else class="empty-text">暂无问题照片</text>-->
              <!-- </view>
            </template>
          </up-cell> --> 

          <!-- 7. 处理结果 -->
          <!-- <up-cell align="middle">
            <template #title>
              <view style="min-width: 200rpx">养护描述</view>
            </template>
            <template #value>
              <view class="up-line-1 common-text-color">{{i.remark || '--'}}</view>
            </template>
          </up-cell> -->

          <up-cell
              title="提交时间"
              :value="i.finishTime==null?'--':timeFormat(i.finishTime,'yyyy-mm-dd hh:MM:ss')"
              align="middle"
          ></up-cell>

          <up-cell
              title="提交人"
              :value="i.userName || '--'"
              align="middle"
             
          ></up-cell>
        </up-cell-group>

        <!-- 动态配置的养护字段填报值(garden_work_record/garden_work_field_value),未填报过则整段不显示 -->
        <up-cell-group
            v-if="i.fieldRows && i.fieldRows.length"
            :border="false"
            inset
            title=""
            
        >
          <up-cell v-for="(f, fi) in i.fieldRows" :key="fi"  align="middle">
            <template #title>
    <view class="field-cell-title-multi">{{f.name}}</view>
  </template>
            <template #value>
              <!-- 7 图片:复用本页照片的相册组件(预览/尺寸一致) -->
              <up-album
                  v-if="f.kind === 'image'"
                  :urls="f.images"
                  singleSize="60"
                  multipleSize="60"
                  :preview-full-image="true"
              ></up-album>

              <!-- 6 多选 / 3 历史多选 / 9 关联记录:标签平铺(自绘,避免引入未使用的组件) -->
              <view v-else-if="f.kind === 'multi'" class="field-tags">
                <view v-for="(t, ti) in f.tags" :key="ti" class="field-tag">{{ t.text }}</view>
              </view>

              <!-- 8 附件:点击下载后用系统应用打开 -->
              <view v-else-if="f.kind === 'file'" class="field-tags">
                <text
                    v-for="(file, ui) in f.files"
                    :key="ui"
                    class="field-file"
                    @click="openFile(file)"
                >{{ file.name }}</text>
              </view>

              <!-- 10 链接:小程序不能直接打开外部 URL,点击复制 -->
              <text v-else-if="f.kind === 'link'" class="field-link" @click="copyLink(f.text)">{{ f.text }}</text>

              <!-- 11 地理位置:点击调微信原生地图(无需地图 AK) -->
              <text v-else-if="f.kind === 'geo' && f.geo" class="field-link" @click="openGeo(f.geo)">{{ f.text }}</text>

              <!-- 其余(1数值/2文本/3单选枚举/4日期/5布尔等)文本直出 -->
              <text v-else class="common-text-color field-text">{{ f.text }}</text>
            </template>
          </up-cell>
        </up-cell-group>
      </view>
    </template>
  </view>
</template>

<script setup lang="ts">
import {ref} from 'vue';
import {detailList,getDetailList} from "@/api/maintain-manage/maintain-manage";
import {onLoad} from '@dcloudio/uni-app';
import {timeFormat} from '@/uni_modules/uview-plus';

// 状态管理
const loading = ref(true);
const orderDetail = ref([]);

/**
 * 格式化图片URL(兼容WebP + 清理非法字符)
 * @param urls 原始图片列表
 */
const formatImageUrls = (urls) => {
  if (!Array.isArray(urls) || urls.length === 0) return [];

  return urls.map(url => {
    if (typeof url !== 'string') return '';
    // 1. 清理URL开头的非法字符(如数字、空格)
    const validStart = url.indexOf('http');
    const cleanUrl = validStart > 0 ? url.substring(validStart) : url;
    // 2. 小程序兼容:WebP转JPEG(可选,后端无转换则注释)
    // return cleanUrl.replace('.webp', '.jpeg');
    return cleanUrl;
  }).filter(url => !!url);
};

/**
 * 预览图片
 * @param currentUrl 当前点击的图片
 * @param allUrls 所有图片列表
 */
const previewImage = (currentUrl, allUrls) => {
  uni.previewImage({
    current: currentUrl,
    urls: allUrls,
    fail: (err) => {
      console.error('预览图片失败:', err);
      uni.showToast({title: '预览图片失败', icon: 'none'});
    }
  });
};

/**
 * 获取工单详情
 */
const getOrderDetail = async (planNo: string) => {
  try {
    loading.value = true;
    let queryData = {
      planNo: planNo,
      pageSize: 100,
      pageNo: 1,
    };
    const res = await detailList(queryData);
    console.log('接口返回:', res);

    // 修复2:正确解析接口数据(res.data.list 而非 res.list)
    const list = res.data?.list || res.list || [];
    // 预解析动态字段,避免模板里重复 JSON.parse
    orderDetail.value = list.map(item => ({
      ...item,
      fieldRows: (item.fields || []).map(buildFieldRow)
    }));

    // 调试:打印格式化后的图片URL
    console.log('格式化后图片URL:', formatImageUrls(orderDetail.value[0]?.beginImgList || []));
  } catch (error) {
    console.error('获取工单详情失败:', error);
    uni.showToast({title: '加载失败,请重试', icon: 'none'});
  } finally {
    loading.value = false;
  }
};

// ========== 动态配置字段(fieldValue)展示 ==========
// 解析口径与 PC 端 FieldValueCell.vue + WorkRecordTab.vue 保持一致:
// 后端 valueText 是「类型无关直拷、提交无校验」,历史格式与脏数据都可能出现,故所有解析一律降级、不抛异常。
/** 空值 token:与 PC 的 EMPTY_TOKENS 一致 */
const EMPTY_TOKENS = ['', '[]', '{}', 'null', 'undefined'];
/** 走「新类型」渲染的字段类型(5布尔/6多选/7图片/8附件/9关联记录/10链接/11地理位置) */
const NEW_RENDER_TYPES = [5, 6, 7, 8, 9, 10, 11];
const TRUTHY = ['是', 'true', '1', 'y', 'yes'];
const FALSY = ['否', 'false', '0', 'n', 'no'];

/** 取原始文本,空/占位值归一成空串 */
const rawTextOf = (item) => {
  const text = String(item?.valueText ?? '').trim();
  return EMPTY_TOKENS.includes(text) ? '' : text;
};

/** JSON 数组解析;失败退化为逗号分隔(兼容历史枚举多选的存储格式) */
const parseArray = (raw) => {
  const text = String(raw ?? '').trim();
  if (!text) return [];
  try {
    const parsed = JSON.parse(text);
    if (Array.isArray(parsed)) return parsed;
    return parsed !== null && typeof parsed === 'object' ? [parsed] : [];
  } catch (e) {
    return text.split(',').map(s => s.trim()).filter(Boolean);
  }
};

/** JSON 对象解析,非法一律退化成空对象 */
const parseObject = (raw) => {
  if (!raw) return {};
  try {
    const parsed = JSON.parse(raw);
    return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
  } catch (e) {
    return {};
  }
};

/** 静态选项 value -> label(后端仅 option_source=1 时回传 options,控体积) */
const optionsMapOf = (item) => {
  const map = {};
  parseArray(item?.options).forEach(o => {
    if (o && o.value != null) map[String(o.value)] = String(o.label ?? o.value);
  });
  return map;
};

/**
 * 枚举值 -> 展示文本:优先字典(option_source=2),其次静态选项表(option_source=1),都取不到返回空串。
 * 后端「仅静态选项回传 options」就是给客户端渲染标签用的;取不到时调用方退回裸值(与 PC 的 formatValue 一致)。
 */
const enumLabelOf = (item, value) => {
  if (!value) return '';
  if (item.optionSource === 2 && item.dictType) {
    const label = uni.$dict.getDictLabel(item.dictType, value);
    if (label) return label;
  }
  return optionsMapOf(item)[value] ?? '';
};

/** 旧类型(1数值/2文本/3单选枚举/4日期)与物料的原文直出,口径同 PC 的 formatValue */
const legacyText = (item) => {
  // 物料以值行的 materialId 判定(物料字段实际是 fieldType=3 + option_source=3,并非独立类型)
  if (item.materialId != null) {
    return `${item.materialName || ''}${item.quantity != null ? ' × ' + item.quantity : ''}${item.unit ? ' ' + item.unit : ''}`;
  }
  const raw = rawTextOf(item);
  if (raw) return item.unit ? `${raw} ${item.unit}` : raw;
  if (item.valueNumber != null) return item.unit ? `${item.valueNumber} ${item.unit}` : String(item.valueNumber);
  return '-';
};

const isUrl = (v) => /^(https?:)?\/\//i.test(String(v)) || String(v).startsWith('/');

/** 文件名取 URL 末段(存储不保留原始文件名) */
const fileNameOf = (url) => {
  const path = String(url).split('?')[0].split('#')[0];
  const name = path.substring(path.lastIndexOf('/') + 1);
  try {
    return decodeURIComponent(name) || url;
  } catch (e) {
    return name || url;
  }
};

/** 多值元素归一化:兼容裸值("1")与带数量对象({value,label,quantity}) */
const toMultiItem = (v) => {
  if (v === null || typeof v !== 'object') {
    return { value: String(v), label: '', quantity: null };
  }
  const raw = v.value ?? v.id ?? v.label;
  const qty = Number(v.quantity);
  return {
    value: raw == null ? '' : String(raw),
    label: v.label != null ? String(v.label) : '',
    quantity: v.quantity != null && Number.isFinite(qty) ? qty : null
  };
};

/**
 * 多值(6多选 / 3历史多选 / 9关联记录)转标签文本。
 * 标签优先级:值行快照 label(9关联记录、6+needQuantity 会带)→ 字典 → 静态选项表 → 裸值。
 * 与 PC 的 DictTag/multiLabel 等价,且对带数量的项同样能显示标签。
 */
const buildTags = (item) => {
  return parseArray(item.valueText)
    .map(toMultiItem)
    .filter(i => i.value)
    .map(i => {
      const label = i.label || enumLabelOf(item, i.value) || i.value;
      return { text: i.quantity == null ? label : `${label} ×${i.quantity}` };
    });
};

/** 11 地理位置:JSON 对象 {lng,lat,address},兼容 "lng,lat" 裸文本 */
const parseGeo = (source, ext) => {
  let lng = null;
  let lat = null;
  let address = '';
  try {
    const parsed = JSON.parse(source);
    if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
      lng = Number(parsed.lng ?? parsed.longitude);
      lat = Number(parsed.lat ?? parsed.latitude);
      address = parsed.address || '';
    }
  } catch (e) {
    // 非法 JSON:继续尝试「经度,纬度」文本形态
  }
  if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
    const parts = String(source).split(',').map(s => Number(s.trim()));
    if (parts.length >= 2 && parts.every(n => Number.isFinite(n))) {
      lng = parts[0];
      lat = parts[1];
      address = '';
    } else {
      return null;
    }
  }
  const precision = Math.min(Math.max(Number(ext.precision) || 6, 0), 8);
  const coord = `${lng.toFixed(precision)}, ${lat.toFixed(precision)}`;
  return { lng, lat, address, text: address ? `${coord}(${address})` : coord };
};

/**
 * 把一个字段值行归一化成展示描述 { name, kind, text, tags, images, files, geo }。
 * 分发顺序与 PC 的 isNewTypeValue 完全一致:物料 → 枚举(3) → 新类型(5..11) → 旧类型原文直出。
 */
const buildFieldRow = (item) => {
  const row = { name: item.fieldName || item.fieldCode || '-', kind: 'text', text: '-', tags: [], images: [], files: [], geo: null };

  // ==========【新增修复代码】优先使用后端返回的text快照,解决ce_shi_4 / wu_liao不展示问题==========
  if (item.text != null && String(item.text).trim() !== '') {
    row.text = String(item.text).trim();
    // 注意:图片/多选/布尔等类型,不能直接用item.text,要保留原有解析逻辑,所以只对普通文本类型生效
    // 判断:不是新渲染类型、不是物料,才直接用快照文本
    const isNewType = NEW_RENDER_TYPES.includes(item.fieldType);
    const isMaterial = item.materialId != null;
    if (!isNewType && !isMaterial) {
      return row;
    }
  }
  // =========================================================================================

  const raw = rawTextOf(item);
  // ① 物料:优先级最高,即便凑巧带 multiple=1 也不当多值渲染
  if (item.materialId != null || item.fieldType == null) {
    row.text = legacyText(item);
    return row;
  }
  // ② 枚举(3):multiple=1 是历史多选(逗号分隔)走多值;multiple=0 单选按值查标签(字典/静态选项)
  if (item.fieldType === 3) {
    if (item.multiple !== 1) {
      // 单选存的是 option.value,直接直出会显示成 "2 把"(工具=锄头)这种无意义文本,
      // 故先查标签;option_source 为 3物料/4数据表/5SQL/6接口 时后端不回传选项表,查不到则退回 legacyText(与 PC 一致)
      const label = enumLabelOf(item, raw);
      row.text = label ? (item.unit ? `${label} ${item.unit}` : label) : legacyText(item);
      return row;
    }
    row.kind = 'multi';
    row.tags = buildTags(item);
    if (!row.tags.length) {
      row.kind = 'text';
      row.text = raw || '-';
    }
    return row;
  }
  // ③ 旧类型 1数值/2文本/4日期:原文直出
  if (!NEW_RENDER_TYPES.includes(item.fieldType)) {
    row.text = legacyText(item);
    return row;
  }
  const ext = parseObject(item.extConfig);
  switch (item.fieldType) {
    case 5: { // 布尔:识别不出真/假时退回原文,不臆测
      const lower = raw.toLowerCase();
      row.text = TRUTHY.includes(lower) ? '是' : (FALSY.includes(lower) ? '否' : (raw || '-'));
      break;
    }
    case 6: // 多选
      row.kind = 'multi';
      row.tags = buildTags(item);
      if (!row.tags.length) {
        row.kind = 'text';
        row.text = raw || '-';
      }
      break;
    case 7: // 图片:只接受 URL,空则退回原文
      row.images = parseArray(item.valueText).map(v => String(v)).filter(isUrl);
      row.kind = row.images.length ? 'image' : 'text';
      if (!row.images.length) row.text = raw || '-';
      break;
    case 8: // 附件
      row.files = parseArray(item.valueText).map(v => String(v)).filter(isUrl)
        .map(u => ({ url: u, name: fileNameOf(u) }));
      row.kind = row.files.length ? 'file' : 'text';
      if (!row.files.length) row.text = raw || '-';
      break;
    case 9: // 关联记录:name 随值落库当快照,缺 name 时显示裸 id(不回查选项)
      row.tags = parseArray(item.valueText).map(v => {
        if (v == null) return null;
        if (typeof v === 'object') {
          const id = v.id ?? v.value;
          if (id == null) return null;
          return { text: String(v.name ?? v.label ?? id) };
        }
        return { text: String(v) };
      }).filter(Boolean);
      row.kind = row.tags.length ? 'multi' : 'text';
      if (!row.tags.length) row.text = raw || '-';
      break;
    case 10: { // 链接:仅 http/https,避免 javascript: 之类伪协议
      const pattern = typeof ext.urlPattern === 'string' && ext.urlPattern.includes('{value}')
        ? ext.urlPattern.replace(/\{value\}/g, encodeURIComponent(raw))
        : raw;
      row.kind = 'link';
      row.text = /^https?:\/\//i.test(pattern) ? pattern : (raw || '-');
      break;
    }
    case 11: { // 地理位置
      row.geo = raw ? parseGeo(raw, ext) : null;
      row.kind = row.geo ? 'geo' : 'text';
      row.text = row.geo ? row.geo.text : (raw || '-');
      break;
    }
    default:
      row.text = legacyText(item);
  }
  return row;
};


/** 8 附件:下载后由系统应用打开(微信小程序无通用文件预览,失败给出提示) */
const openFile = (file) => {
  uni.showLoading({title: '正在打开...', mask: true});
  uni.downloadFile({
    url: file.url,
    success: (res) => {
      if (res.statusCode !== 200) {
        uni.hideLoading();
        uni.showToast({title: '文件打开失败', icon: 'none'});
        return;
      }
      uni.openDocument({
        filePath: res.tempFilePath,
        showMenu: true,
        complete: () => uni.hideLoading()
      });
    },
    fail: () => {
      uni.hideLoading();
      uni.showToast({title: '文件打开失败', icon: 'none'});
    }
  });
};

/** 10 链接:小程序不能直接打开外部 URL,改为复制(最接近 PC 可点链接的能力) */
const copyLink = (url) => {
  uni.setClipboardData({
    data: url,
    success: () => uni.showToast({title: '链接已复制', icon: 'none'})
  });
};

/** 11 地理位置:调微信原生地图,无需地图 AK */
const openGeo = (geo) => {
  if (!geo) return;
  uni.openLocation({
    latitude: Number(geo.lat),
    longitude: Number(geo.lng),
    name: geo.address || '作业位置',
    fail: () => uni.showToast({title: '地图打开失败', icon: 'none'})
  });
};

// 页面加载
onLoad((options) => {
  const {planNo} = options;
  if (planNo) {
    getOrderDetail(planNo);
  } else {
    loading.value = false;
    uni.showToast({title: '缺少工单ID', icon: 'none'});
  }
});
</script>

<style scoped lang="scss">
// 内容容器
.content-wrap {
  background: #fff;
  width: 100%;
  box-sizing: border-box;
  margin-bottom: 30rpx;
}

// 图片容器样式
.cell-content-wrap {
  //padding: 10rpx 0;
}

//// 自定义相册容器
//.album-container {
//  display: flex;
//  flex-wrap: wrap;
//  gap: 10rpx; // 图片间距
//}
//
//// 单张图片样式
//.album-item {
//  width: 70px;
//  height: 70px;
//  border-radius: 4rpx;
//  overflow: hidden;
//}
//
//// 图片样式(强制尺寸 + 覆盖)
//.album-image {
//  width: 70px;
//  height: 70px;
//  object-fit: cover; // 保持比例裁剪,避免拉伸
//}

// 空文本样式
.empty-text {
  color: #999;
  font-size: 14px;
  line-height: 70px; // 和图片高度对齐
}

// ===== 动态配置字段(填报明细) =====
// 多值(多选/关联记录)标签平铺
.field-tags {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 8rpx;
}

// 自绘标签:仓库未使用过 up-tag,避免引入未验证组件
.field-tag {
  padding: 2rpx 12rpx;
  background: #f2f3f5;
  border-radius: 6rpx;
  color: #606266;
  font-size: 24rpx;
  line-height: 36rpx;
}

// 附件(可点击打开)
.field-file {
  color: #3c9cff;
  font-size: 26rpx;
  margin-left: 16rpx;
  text-decoration: underline;
}

// 链接 / 地理位置(可点击)
.field-link {
  color: #3c9cff;
  font-size: 26rpx;
  word-break: break-all;
}

// 文本直出(数值/文本/枚举/日期/布尔)
.field-text {
  font-size: 26rpx;
  word-break: break-all;
}
:deep(.field-cell-title-multi) {
  min-width: 160rpx; // 固定左侧标题最大宽度,文字会在这个宽度内自动换行
}

</style>