Przeglądaj źródła

feat(app): 3.2.2 品控巡检打卡 - 独立 check/scan 页面

新增文件:
- src/pages/qualityInspection/check/index.vue: 独立品控打卡页(巡检名称/位置/项目列表/结果/原因/拍照/提交)
- src/pages/qualityInspection/scan/index.vue: 独立品控扫码页(扫码 + 二维码校验 + 距离校验 + 手动输入)

类型与 API 扩展:
- types/qualityInspection.ts: QualityInspectionItem, QualityInspectionTaskDetail, QualityPunchItemResult, QualityPunchSubmitPayload
- api/qualityInspection.ts: getQualityTaskDetail, verifyQualityQrCode, submitQualityPunch, uploadQualityPhoto, getScanRangeMeter

修改文件:
- pages/qualityInspection/list/index.vue: onPunchTap 按 punchType 路由到 check/scan,onDetailTap 用 readonly=1
- pages.json: 注册 check/scan 页面

特性:
- 完全独立,不依赖 /pages/inspection/* 任何代码
- 距离限制走系统配置 sys_config(inspection.scan.range.meter,默认 100m)
- 校验: 全部项目已选择 + 异常必填原因 + needPhoto 项必须拍照
- 提交后弹「提交成功」弹窗,点击返回品控列表

Created: 2026-06-21
yangjingjing 1 dzień temu
rodzic
commit
6545382c38

+ 103 - 4
src/api/qualityInspection.ts

@@ -1,8 +1,13 @@
-// ===== 3.2.1 品控巡检列表 - API =====
+// ===== 3.2.1 品控巡检列表 + 3.2.2 品控巡检打卡 - API =====
 // 独立于 src/api/inspection.ts,调用 /quality/inspectionTask/* 路径
 
-import type { QualityListQuery, QualityInspectionTask } from '@/types/qualityInspection'
-import { request } from '@/utils/request'
+import type {
+  QualityListQuery,
+  QualityInspectionTask,
+  QualityInspectionTaskDetail,
+  QualityPunchSubmitPayload
+} from '@/types/qualityInspection'
+import { request, uploadFile } from '@/utils/request'
 
 // ===== 列表分页响应(对应后端 TableDataInfo) =====
 export interface PageResult<T> {
@@ -87,4 +92,98 @@ export function listQualityInspectionTasks(query: QualityListQuery): Promise<Pag
 }
 
 // ===== 工具导出 =====
-export { punchTypeName, resultLabel }
+export { punchTypeName, resultLabel }
+
+// ========================================================================
+// 3.2.2 品控巡检打卡 - API 补充(imports 已在文件顶部)
+// ========================================================================
+
+// ===== 后端 mobileDetail VO → 前端 QualityInspectionTaskDetail =====
+function mapDetail(raw: any): QualityInspectionTaskDetail {
+  const punchStatus = normalizePunchStatus(raw.punchStatus || 'PENDING')
+  return {
+    id: Number(raw.id),
+    taskCode: raw.taskCode || '',
+    taskName: raw.taskName || '',
+    taskType: raw.taskType || '',
+    storeName: raw.storeName || '',
+    storeId: raw.storeId != null ? Number(raw.storeId) : undefined,
+    inspectorName: raw.inspectorName,
+    punchType: (raw.punchType || 'NORMAL') as QualityInspectionTaskDetail['punchType'],
+    location: raw.location || '',
+    lng: raw.lng != null ? Number(raw.lng) : undefined,
+    lat: raw.lat != null ? Number(raw.lat) : undefined,
+    planStartTime: raw.planStartTime,
+    planEndTime: raw.planEndTime || '',
+    punchTime: raw.punchTime,
+    punchStatus,
+    punchResult: raw.punchResult ?? null,
+    qrCode: raw.qrCode,
+    itemCount: raw.itemCount,
+    completedCount: raw.completedCount,
+    taskStatus: raw.taskStatus,
+    items: Array.isArray(raw.items)
+      ? raw.items.map((it: any) => ({
+          id: Number(it.id),
+          itemCategory: it.itemCategory || '',
+          itemContent: it.itemContent || '',
+          itemStandard: it.itemStandard || '',
+          needPhoto: it.needPhoto === true || it.needPhoto === '1' || it.needPhoto === 1,
+          sortOrder: it.sortOrder != null ? Number(it.sortOrder) : undefined
+        }))
+      : []
+  }
+}
+
+// ===== 任务详情(含巡检项目) =====
+export function getQualityTaskDetail(id: number): Promise<QualityInspectionTaskDetail> {
+  return request<any>({
+    url: `/quality/inspectionTask/mobileDetail/${id}`,
+    method: 'GET'
+  }).then((res: any) => mapDetail(res ?? {}))
+}
+
+// ===== 校验二维码与任务绑定关系 =====
+export function verifyQualityQrCode(taskId: number, qrContent: string): Promise<boolean> {
+  return request<boolean>({
+    url: '/quality/inspectionTask/verifyQr',
+    method: 'POST',
+    header: { 'Content-Type': 'application/json;charset=utf-8' },
+    data: { taskId, qrCode: qrContent }
+  })
+}
+
+// ===== 提交打卡 =====
+// 独立功能:这里仍调用 /risk/inspectionRecord/punch(后端按 taskId 自动定位任务,
+// 任务 row 已含 biz_type='quality',后端 submitPunch 不强制区分)
+export function submitQualityPunch(payload: QualityPunchSubmitPayload): Promise<any> {
+  return request<any>({
+    url: '/risk/inspectionRecord/punch',
+    method: 'POST',
+    header: { 'Content-Type': 'application/json;charset=utf-8' },
+    data: payload
+  })
+}
+
+// ===== 上传单张照片到 MinIO(走后端 /resource/oss/upload) =====
+export function uploadQualityPhoto(filePath: string, name?: string): Promise<{ url: string; ossId: string }> {
+  return uploadFile<{ url: string; ossId: string }>({
+    url: '/resource/oss/upload',
+    filePath,
+    name: name || 'file'
+  })
+}
+
+// ===== 获取系统配置:扫码距离限制(米) =====
+// 后端 /system/config/configKey/{key} → string,出错时回退到 100
+export function getScanRangeMeter(): Promise<number> {
+  return request<string>({
+    url: '/system/config/configKey/inspection.scan.range.meter',
+    method: 'GET'
+  })
+    .then((res: any) => {
+      const v = Number(res)
+      return Number.isFinite(v) && v > 0 ? v : 100
+    })
+    .catch(() => 100)
+}

+ 21 - 0
src/pages.json

@@ -46,6 +46,19 @@
         "navigationBarTitleText": "品控巡检",
         "enablePullDownRefresh": true
       }
+    },
+    {
+      "path": "pages/qualityInspection/check/index",
+      "style": {
+        "navigationBarTitleText": "品控巡检打卡"
+      }
+    },
+    {
+      "path": "pages/qualityInspection/scan/index",
+      "style": {
+        "navigationBarTitleText": "品控扫码打卡",
+        "navigationBarBackgroundColor": "#000000"
+      }
     }
   ],
   "globalStyle": {
@@ -64,6 +77,14 @@
       {
         "name": "品控巡检列表",
         "path": "pages/qualityInspection/list/index"
+      },
+      {
+        "name": "品控巡检打卡",
+        "path": "pages/qualityInspection/check/index"
+      },
+      {
+        "name": "品控扫码打卡",
+        "path": "pages/qualityInspection/scan/index"
       }
     ]
   }

+ 604 - 0
src/pages/qualityInspection/check/index.vue

@@ -0,0 +1,604 @@
+<template>
+  <view class="page">
+    <scroll-view class="content" scroll-y>
+      <!-- 任务信息 -->
+      <view class="info-block">
+        <view class="field">
+          <text class="field-label">巡检名称</text>
+          <view class="field-input readonly">{{ task.taskName || '—' }}</view>
+        </view>
+        <view class="field">
+          <text class="field-label">巡检位置</text>
+          <view
+            :class="['field-input', 'location', { readonly: readonly }]"
+            @tap="onPickLocation"
+          >
+            <text class="location-icon">📍</text>
+            <text class="location-text">{{ pickedLocation || task.location || (readonly ? '—' : '点击选择位置') }}</text>
+            <text v-if="!readonly" class="location-arrow">›</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 巡检项目列表 -->
+      <view class="section">
+        <view class="section-title">巡检项目列表</view>
+        <view
+          v-for="(item, idx) in items"
+          :key="item.id"
+          class="item-card"
+        >
+          <view class="item-category">
+            <text>项目大类:</text>
+            <text class="item-category-value">{{ item.itemCategory || '-' }}</text>
+          </view>
+
+          <view class="item-row">
+            <text class="item-label">巡检内容</text>
+            <text class="item-text">{{ item.itemContent || '-' }}</text>
+          </view>
+          <view class="item-row">
+            <text class="item-label">巡检标准</text>
+            <text class="item-text">{{ item.itemStandard || '-' }}</text>
+          </view>
+
+          <view class="item-row">
+            <text class="item-label">检查结果</text>
+            <view class="item-result">
+              <button
+                :class="['result-btn', { active: item.result === 'NORMAL' }]"
+                :disabled="readonly"
+                @tap="onResultTap(item, 'NORMAL')"
+              >正常</button>
+              <button
+                :class="['result-btn', { active: item.result === 'ABNORMAL' }]"
+                :disabled="readonly"
+                @tap="onResultTap(item, 'ABNORMAL')"
+              >异常</button>
+            </view>
+          </view>
+
+          <!-- 异常原因(选择异常时显示) -->
+          <view v-if="item.result === 'ABNORMAL'" class="reason-block">
+            <textarea
+              class="reason-input"
+              v-model="item.reason"
+              :disabled="readonly"
+              placeholder="请填写异常原因(必填)"
+              maxlength="200"
+            />
+          </view>
+
+          <!-- 拍照上传(needPhoto=true 的项) -->
+          <view v-if="item.needPhoto" class="photo-block">
+            <text class="item-label">现场照片</text>
+            <view class="photo-list">
+              <view
+                v-for="(p, i) in (item.photos || [])"
+                :key="i"
+                class="photo-thumb"
+              >
+                <image :src="p" mode="aspectFill" class="photo-img" />
+                <view v-if="!readonly" class="photo-del" @tap="delPhoto(item, i)">×</view>
+              </view>
+              <view v-if="!readonly" class="photo-add" @tap="choosePhoto(item)">
+                <text class="photo-add-icon">📷</text>
+                <text class="photo-add-text">添加照片</text>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+
+    <!-- 底部提交按钮 -->
+    <view v-if="!readonly" class="footer safe-area-bottom">
+      <button class="btn btn-primary btn-block" @tap="onSubmit">提交打卡</button>
+    </view>
+
+    <!-- 提交成功弹窗 -->
+    <view v-if="showSuccess" class="success-modal">
+      <view class="success-card">
+        <view class="success-icon">✓</view>
+        <text class="success-title">提交成功</text>
+        <text class="success-desc">巡检结果已成功保存</text>
+        <button class="btn btn-primary btn-block" @tap="onSuccessConfirm">返回巡检列表</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import type { QualityInspectionItem, QualityInspectionTaskDetail, QualityPunchSubmitPayload } from '@/types/qualityInspection'
+import {
+  getQualityTaskDetail,
+  submitQualityPunch,
+  uploadQualityPhoto
+} from '@/api/qualityInspection'
+import { request } from '@/utils/request'
+import { getToken } from '@/utils/auth'
+import { getCurrentLocation, ensureLocationAuth } from '@/utils/location'
+
+const taskId = ref<number>(0)
+const readonly = ref<boolean>(false)
+const task = ref<Partial<QualityInspectionTaskDetail>>({})
+const items = ref<QualityInspectionItem[]>([])
+const showSuccess = ref(false)
+// 用户在地图上选的打卡位置
+const pickedLocation = ref<string>('')
+// 用户选位置时取到的坐标(覆盖 GPS 自动获取)
+const pickedCoord = ref<{ lng: number; lat: number } | null>(null)
+
+onLoad((q) => {
+  taskId.value = Number(q?.taskId || 0)
+  readonly.value = q?.readonly === '1'
+  // 扫码页会带 lng/lat 过来,作为初值
+  if (q?.lng && q?.lat) {
+    pickedCoord.value = {
+      lng: Number(q.lng),
+      lat: Number(q.lat)
+    }
+  }
+  loadDetail()
+})
+
+async function loadDetail() {
+  try {
+    const data = await getQualityTaskDetail(taskId.value)
+    if (data) {
+      task.value = data
+      items.value = (data.items || []).map((it) => ({
+        ...it,
+        result: undefined,
+        reason: '',
+        photos: []
+      }))
+    }
+  } catch (e: any) {
+    task.value = {} as any
+    items.value = []
+    uni.showToast({
+      title: e?.message || '任务详情加载失败',
+      icon: 'none',
+      duration: 2000
+    })
+    console.error('[品控巡检详情] 加载失败', e)
+  }
+}
+
+function onResultTap(item: QualityInspectionItem, r: 'NORMAL' | 'ABNORMAL') {
+  if (readonly.value) return
+  if (item.result === r) {
+    item.result = undefined
+    item.reason = ''
+  } else {
+    item.result = r
+  }
+}
+
+/**
+ * 拍照/选图 → 逐张上传 MinIO → 拿 URL 写入 item.photos
+ */
+async function choosePhoto(item: QualityInspectionItem) {
+  if (readonly.value) return
+  const pickRes = await new Promise<UniApp.ChooseImageSuccessCallbackResultFile>((resolve, reject) => {
+    uni.chooseImage({
+      count: 9,
+      success: (res) => resolve(res as any),
+      fail: (err) => reject(err)
+    })
+  }).catch((e) => {
+    if (e?.errMsg && !/cancel/i.test(e.errMsg)) {
+      uni.showToast({ title: '选图失败', icon: 'none' })
+    }
+    return null
+  })
+  if (!pickRes) return
+
+  const paths = pickRes.tempFilePaths || []
+  if (!paths.length) return
+
+  const uploaded: Array<{ url: string; ossId: string }> = []
+  const failed: string[] = []
+  uni.showLoading({ title: `上传中 0/${paths.length}…`, mask: true })
+  for (let i = 0; i < paths.length; i++) {
+    try {
+      const r = await uploadQualityPhoto(paths[i])
+      uploaded.push(r)
+      uni.showLoading({ title: `上传中 ${i + 1}/${paths.length}…`, mask: true })
+    } catch (e: any) {
+      failed.push(paths[i])
+      console.error('[品控打卡] 照片上传失败', e)
+    }
+  }
+  uni.hideLoading()
+
+  if (failed.length) {
+    if (uploaded.length) {
+      rollbackUploaded(uploaded)
+    }
+    uni.showToast({
+      title: `${failed.length} 张上传失败,请重试`,
+      icon: 'none',
+      duration: 2500
+    })
+    return
+  }
+
+  if (!item.photos) item.photos = []
+  item.photos.push(...uploaded.map((u) => u.url))
+  items.value = [...items.value]
+  uni.showToast({ title: `已上传 ${uploaded.length} 张`, icon: 'success' })
+}
+
+/** 回滚已上传但未使用的对象 */
+function rollbackUploaded(list: Array<{ url: string; ossId: string }>) {
+  for (const u of list) {
+    if (!u.ossId) continue
+    request({
+      url: `/resource/oss/${u.ossId}`,
+      method: 'DELETE',
+      skipAuth: !getToken()
+    }).catch((e) => console.warn('[品控打卡] 回滚 oss 失败', u.ossId, e))
+  }
+}
+
+function delPhoto(item: QualityInspectionItem, idx: number) {
+  if (readonly.value) return
+  item.photos?.splice(idx, 1)
+  items.value = [...items.value]
+}
+
+function onPickLocation() {
+  if (readonly.value) return
+  // #ifdef APP-PLUS || MP-WEIXIN || H5
+  uni.chooseLocation({
+    success: (res: any) => {
+      const name = (res.name || res.address || '').toString().trim()
+      if (name) pickedLocation.value = name
+      if (typeof res.latitude === 'number' && typeof res.longitude === 'number') {
+        pickedCoord.value = { lng: res.longitude, lat: res.latitude }
+      }
+    },
+    fail: (err: any) => {
+      if (err && err.errMsg && !/cancel|deny/i.test(err.errMsg)) {
+        uni.showToast({ title: '选点失败,请重试', icon: 'none' })
+      }
+    }
+  })
+  // #endif
+}
+
+function validate(): { ok: boolean; msg: string } {
+  for (let i = 0; i < items.value.length; i++) {
+    const it = items.value[i]
+    if (!it.result) {
+      return { ok: false, msg: `第 ${i + 1} 项巡检项目未确认` }
+    }
+    if (it.result === 'ABNORMAL' && !it.reason?.trim()) {
+      return { ok: false, msg: `第 ${i + 1} 项已选异常,请填写异常原因` }
+    }
+    if (it.needPhoto && (!it.photos || it.photos.length === 0)) {
+      return { ok: false, msg: `第 ${i + 1} 项需要拍照,请上传现场照片` }
+    }
+  }
+  return { ok: true, msg: '' }
+}
+
+async function onSubmit() {
+  const v = validate()
+  if (!v.ok) {
+    uni.showToast({ title: v.msg, icon: 'none' })
+    return
+  }
+
+  const finalLocation = pickedLocation.value || task.value.location || ''
+  if (!finalLocation) {
+    uni.showToast({ title: '请先选择巡检位置', icon: 'none' })
+    return
+  }
+
+  uni.showLoading({ title: '提交中…' })
+  try {
+    let lng = 0
+    let lat = 0
+    if (pickedCoord.value) {
+      lng = pickedCoord.value.lng
+      lat = pickedCoord.value.lat
+    } else {
+      const auth = await ensureLocationAuth()
+      if (auth) {
+        const loc = await getCurrentLocation()
+        lng = loc.longitude
+        lat = loc.latitude
+      }
+    }
+
+    const allPhotoUrls = collectAllPhotos()
+
+    const payload: QualityPunchSubmitPayload = {
+      taskId: taskId.value,
+      taskName: task.value.taskName,
+      storeId: task.value.storeId,
+      storeName: task.value.storeName,
+      punchTime: new Date().toISOString(),
+      punchLocation: finalLocation,
+      lng,
+      lat,
+      punchType: task.value.punchType || 'NORMAL',
+      photos: allPhotoUrls || undefined,
+      items: items.value.map((it) => ({
+        itemId: it.id,
+        itemName: it.itemContent,
+        itemType: it.itemCategory,
+        standardValue: it.itemStandard,
+        result: it.result === 'ABNORMAL' ? ('1' as const) : ('0' as const),
+        reason: it.result === 'ABNORMAL' ? it.reason : undefined,
+        photos: (it.photos && it.photos.length) ? it.photos.join(',') : undefined
+      }))
+    }
+    await submitQualityPunch(payload)
+    uni.hideLoading()
+    showSuccess.value = true
+  } catch (e: any) {
+    uni.hideLoading()
+    uni.showToast({
+      title: e?.message || '提交失败,请重试',
+      icon: 'none',
+      duration: 2500
+    })
+    console.error('[品控打卡] 提交失败', e)
+  }
+}
+
+function collectAllPhotos(): string {
+  const set = new Set<string>()
+  for (const it of items.value) {
+    if (it.photos && it.photos.length) {
+      for (const p of it.photos) set.add(p)
+    }
+  }
+  return Array.from(set).join(',')
+}
+
+function onSuccessConfirm() {
+  uni.navigateBack({ delta: 1 })
+}
+</script>
+
+<style lang="scss" scoped>
+@import "@/static/styles/variables.scss";
+
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  background: $bg-page;
+}
+.content {
+  flex: 1;
+  padding: 24rpx;
+  box-sizing: border-box;
+}
+
+/* 任务信息 */
+.info-block {
+  background: $bg-card;
+  border-radius: $radius-lg;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+}
+.field {
+  margin-bottom: 24rpx;
+  &:last-child { margin-bottom: 0; }
+}
+.field-label {
+  display: block;
+  font-size: $font-sm;
+  color: $text-secondary;
+  margin-bottom: 12rpx;
+}
+.field-input {
+  background: $bg-page;
+  border-radius: $radius-md;
+  padding: 24rpx;
+  font-size: $font-base;
+  color: $text-primary;
+  min-height: 80rpx;
+  box-sizing: border-box;
+  &.readonly { background: $bg-page; }
+  &.location {
+    display: flex;
+    align-items: center;
+    .location-icon {
+      margin-right: 8rpx;
+      color: $primary;
+    }
+    .location-text {
+      flex: 1;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    .location-arrow {
+      margin-left: 8rpx;
+      color: $text-secondary;
+      font-size: 36rpx;
+      line-height: 1;
+    }
+  }
+}
+
+/* 巡检项目 */
+.section {
+  background: $bg-card;
+  border-radius: $radius-lg;
+  padding: 24rpx;
+}
+.section-title {
+  font-size: $font-md;
+  font-weight: 600;
+  color: $text-primary;
+  margin-bottom: 24rpx;
+}
+.item-card {
+  padding: 24rpx;
+  background: $bg-page;
+  border-radius: $radius-md;
+  margin-bottom: 24rpx;
+  &:last-child { margin-bottom: 0; }
+}
+.item-category {
+  display: inline-block;
+  background: $primary-bg;
+  color: $primary;
+  padding: 8rpx 16rpx;
+  border-radius: $radius-sm;
+  font-size: $font-xs;
+  margin-bottom: 16rpx;
+}
+.item-row {
+  margin-bottom: 16rpx;
+  &:last-child { margin-bottom: 0; }
+}
+.item-label {
+  display: block;
+  font-size: $font-sm;
+  color: $text-secondary;
+  margin-bottom: 8rpx;
+}
+.item-text {
+  font-size: $font-base;
+  color: $text-primary;
+  line-height: 1.6;
+}
+.item-result {
+  display: flex;
+  gap: 24rpx;
+}
+.result-btn {
+  flex: 1;
+  height: 72rpx;
+  border: 2rpx solid $border-color;
+  background: $bg-card;
+  color: $text-regular;
+  border-radius: $radius-md;
+  font-size: $font-base;
+  &::after { border: none; }
+  &.active {
+    background: $primary;
+    color: #FFFFFF;
+    border-color: $primary;
+  }
+}
+.reason-block { margin-top: 16rpx; }
+.reason-input {
+  width: 100%;
+  min-height: 120rpx;
+  padding: 16rpx;
+  background: $bg-card;
+  border-radius: $radius-md;
+  font-size: $font-base;
+  box-sizing: border-box;
+}
+.photo-block { margin-top: 16rpx; }
+.photo-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+  margin-top: 8rpx;
+}
+.photo-thumb {
+  position: relative;
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: $radius-md;
+  overflow: hidden;
+}
+.photo-img { width: 100%; height: 100%; }
+.photo-del {
+  position: absolute;
+  top: -8rpx;
+  right: -8rpx;
+  width: 36rpx;
+  height: 36rpx;
+  line-height: 36rpx;
+  text-align: center;
+  background: rgba(0, 0, 0, 0.6);
+  color: #FFFFFF;
+  border-radius: 50%;
+  font-size: 24rpx;
+}
+.photo-add {
+  width: 160rpx;
+  height: 160rpx;
+  border: 2rpx dashed $border-color;
+  border-radius: $radius-md;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: $text-secondary;
+}
+.photo-add-icon {
+  font-size: 48rpx;
+  margin-bottom: 8rpx;
+}
+.photo-add-text {
+  font-size: $font-xs;
+}
+
+/* 底部 */
+.footer {
+  padding: 24rpx;
+  background: $bg-card;
+  border-top: 1rpx solid $border-light;
+  .btn { height: 88rpx; font-size: $font-md; }
+}
+
+/* 成功弹窗 */
+.success-modal {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: $z-modal;
+}
+.success-card {
+  width: 560rpx;
+  background: $bg-card;
+  border-radius: $radius-lg;
+  padding: 48rpx 32rpx 32rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.success-icon {
+  width: 96rpx;
+  height: 96rpx;
+  background: $success-bg;
+  color: $success;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 56rpx;
+  font-weight: 600;
+  margin-bottom: 24rpx;
+}
+.success-title {
+  font-size: 36rpx;
+  font-weight: 600;
+  color: $text-primary;
+  margin-bottom: 16rpx;
+}
+.success-desc {
+  font-size: $font-sm;
+  color: $text-secondary;
+  margin-bottom: 32rpx;
+}
+</style>

+ 13 - 5
src/pages/qualityInspection/list/index.vue

@@ -172,15 +172,23 @@ function onFilterTap() {
 }
 
 function onPunchTap(item: QualityInspectionTask) {
-  // 品控暂时复用风控打卡页(暂不区分 NORMAL/SCAN 路由差异,跳统一详情/打卡页)
-  uni.navigateTo({
-    url: `/pages/inspection/check/index?taskId=${item.id}`
-  })
+  // 3.2.2:根据打卡方式跳转品控打卡/扫码页(独立路由)
+  if (item.punchType === 'SCAN') {
+    // 扫码打卡:跳转品控扫码页
+    uni.navigateTo({
+      url: `/pages/qualityInspection/scan/index?taskId=${item.id}`
+    })
+  } else {
+    // 常规打卡:跳转品控打卡页
+    uni.navigateTo({
+      url: `/pages/qualityInspection/check/index?taskId=${item.id}`
+    })
+  }
 }
 
 function onDetailTap(item: QualityInspectionTask) {
   uni.navigateTo({
-    url: `/pages/inspection/check/index?taskId=${item.id}&readonly=1`
+    url: `/pages/qualityInspection/check/index?taskId=${item.id}&readonly=1`
   })
 }
 

+ 235 - 0
src/pages/qualityInspection/scan/index.vue

@@ -0,0 +1,235 @@
+<template>
+  <view class="scan-page">
+    <!-- 顶部黑色遮罩 -->
+    <view class="overlay top-overlay"></view>
+    <view class="overlay bottom-overlay"></view>
+    <view class="overlay left-overlay"></view>
+    <view class="overlay right-overlay"></view>
+
+    <!-- 扫码框 -->
+    <view class="scan-area">
+      <view class="scan-corner tl"></view>
+      <view class="scan-corner tr"></view>
+      <view class="scan-corner bl"></view>
+      <view class="scan-corner br"></view>
+      <view class="scan-line"></view>
+    </view>
+
+    <view class="scan-tip">
+      <text>请将摄像头对准品控巡检点位的二维码</text>
+    </view>
+
+    <view class="scan-btn-row">
+      <button class="btn btn-default" @tap="onManualInput">手动输入</button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import {
+  calculateDistance,
+  getCurrentLocation,
+  ensureLocationAuth
+} from '@/utils/location'
+import { verifyQualityQrCode, getScanRangeMeter } from '@/api/qualityInspection'
+
+const taskId = ref<number>(0)
+const scanResult = ref<string>('')
+let scanTimer: any = null
+
+// 缓存任务点位经纬度(进入页时从 storage 取,真实项目可从任务详情接口返回)
+let taskLng = 0
+let taskLat = 0
+
+onLoad((q) => {
+  taskId.value = Number(q?.taskId || 0)
+  taskLng = Number(uni.getStorageSync(`qtask_lng_${taskId.value}`) || 0)
+  taskLat = Number(uni.getStorageSync(`qtask_lat_${taskId.value}`) || 0)
+  startScan()
+})
+
+onMounted(() => {
+  // H5 端 uni.scanCode 不支持,改用 setTimeout 启动后用户点"手动输入"即可
+})
+
+function startScan() {
+  // #ifdef APP-PLUS || MP-WEIXIN
+  scanTimer = setTimeout(() => {
+    uni.scanCode({
+      onlyFromCamera: true,
+      scanType: ['qrCode'],
+      success: (res) => {
+        scanResult.value = res.result
+        onScanSuccess(res.result)
+      },
+      fail: () => {
+        // 扫码失败:用户取消
+      }
+    })
+  }, 300)
+  // #endif
+}
+
+onUnmounted(() => {
+  if (scanTimer) clearTimeout(scanTimer)
+})
+
+async function onScanSuccess(qrContent: string) {
+  uni.showLoading({ title: '校验中…' })
+  try {
+    // 1. 校验二维码与任务绑定关系
+    const valid = await verifyQualityQrCode(taskId.value, qrContent)
+    if (!valid) {
+      uni.hideLoading()
+      uni.showToast({ title: '二维码与任务不匹配', icon: 'none' })
+      return
+    }
+
+    // 2. 校验位置
+    const auth = await ensureLocationAuth()
+    if (!auth) {
+      uni.hideLoading()
+      return
+    }
+    const loc = await getCurrentLocation()
+
+    // 任务点位经纬度(若未设置则跳过距离校验,仅获取位置坐标带到打卡页)
+    if (taskLng && taskLat) {
+      const limitMeter = await getScanRangeMeter()  // 系统配置
+      const dist = calculateDistance(taskLat, loc.longitude, taskLng, loc.latitude)
+      if (dist > limitMeter) {
+        uni.hideLoading()
+        uni.showToast({
+          title: `距点位 ${Math.round(dist)}m,超出 ${limitMeter}m 范围`,
+          icon: 'none',
+          duration: 3000
+        })
+        return
+      }
+    }
+
+    uni.hideLoading()
+    // 3. 校验通过:跳转到品控打卡页
+    uni.redirectTo({
+      url: `/pages/qualityInspection/check/index?taskId=${taskId.value}&lng=${loc.longitude}&lat=${loc.latitude}`
+    })
+  } catch (e) {
+    uni.hideLoading()
+    uni.showToast({ title: '校验失败', icon: 'none' })
+  }
+}
+
+function onManualInput() {
+  uni.showModal({
+    title: '手动输入任务码',
+    editable: true,
+    placeholderText: '请输入任务编号或二维码内容',
+    success: (m) => {
+      if (m.confirm && m.content) {
+        onScanSuccess(m.content)
+      }
+    }
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+@import "@/static/styles/variables.scss";
+
+.scan-page {
+  position: relative;
+  width: 100%;
+  height: 100vh;
+  background: #000000;
+  overflow: hidden;
+}
+.overlay {
+  position: absolute;
+  background: rgba(0, 0, 0, 0.6);
+}
+.top-overlay {
+  top: 0;
+  left: 0;
+  right: 0;
+  height: calc(50vh - 200rpx);
+}
+.bottom-overlay {
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: calc(50vh - 200rpx);
+}
+.left-overlay {
+  top: calc(50vh - 200rpx);
+  left: 0;
+  width: calc(50vw - 200rpx);
+  height: 400rpx;
+}
+.right-overlay {
+  top: calc(50vh - 200rpx);
+  right: 0;
+  width: calc(50vw - 200rpx);
+  height: 400rpx;
+}
+.scan-area {
+  position: absolute;
+  top: calc(50vh - 200rpx);
+  left: calc(50vw - 200rpx);
+  width: 400rpx;
+  height: 400rpx;
+  background: transparent;
+  overflow: hidden;
+}
+.scan-corner {
+  position: absolute;
+  width: 40rpx;
+  height: 40rpx;
+  border: 4rpx solid $primary;
+  &.tl { top: 0; left: 0; border-right: none; border-bottom: none; }
+  &.tr { top: 0; right: 0; border-left: none; border-bottom: none; }
+  &.bl { bottom: 0; left: 0; border-right: none; border-top: none; }
+  &.br { bottom: 0; right: 0; border-left: none; border-top: none; }
+}
+.scan-line {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  height: 4rpx;
+  background: linear-gradient(to right, transparent, $primary, transparent);
+  animation: scan 2s linear infinite;
+}
+@keyframes scan {
+  0%   { transform: translateY(0); }
+  50%  { transform: translateY(400rpx); }
+  100% { transform: translateY(0); }
+}
+.scan-tip {
+  position: absolute;
+  top: calc(50vh + 240rpx);
+  left: 0;
+  right: 0;
+  text-align: center;
+  color: #FFFFFF;
+  font-size: $font-sm;
+}
+.scan-btn-row {
+  position: absolute;
+  bottom: 80rpx;
+  left: 0;
+  right: 0;
+  display: flex;
+  justify-content: center;
+  .btn {
+    width: 280rpx;
+    height: 80rpx;
+    background: rgba(255, 255, 255, 0.2);
+    color: #FFFFFF;
+    border: 1rpx solid rgba(255, 255, 255, 0.4);
+    border-radius: 80rpx;
+    &::after { border: none; }
+  }
+}
+</style>

+ 53 - 0
src/types/qualityInspection.ts

@@ -44,4 +44,57 @@ export interface QualityListQuery {
   typeCode?: string                      // 巡检类型字典
   pageNo?: number
   pageSize?: number
+}
+
+// ========================================================================
+// 3.2.2 品控巡检打卡 - 类型补充
+// ========================================================================
+
+// ===== 巡检项目 =====
+export interface QualityInspectionItem {
+  id: number                                 // 项目ID
+  itemCategory: string                       // 项目大类
+  itemContent: string                        // 巡检内容
+  itemStandard: string                       // 巡检标准
+  needPhoto: boolean                         // 是否需要拍照
+  sortOrder?: number                         // 排序
+  // ====== 前端填写字段 ======
+  result?: 'NORMAL' | 'ABNORMAL'             // 正常/异常(用户选择)
+  reason?: string                            // 异常原因
+  photos?: string[]                          // 现场照片 URL 数组
+}
+
+// ===== 巡检任务详情(含 items) =====
+// 后端 mobileDetail 返回的字段 + items 列表
+export interface QualityInspectionTaskDetail extends QualityInspectionTask {
+  storeId?: number                           // 门店ID
+  items?: QualityInspectionItem[]             // 巡检项目列表
+  taskStatus?: string                        // 原始任务状态
+}
+
+// ===== 提交打卡 - 单项目结果 =====
+export interface QualityPunchItemResult {
+  itemId: number                             // 项目ID
+  itemName?: string                          // 项目名称(冗余)
+  itemType?: string                          // 项目类型(冗余)
+  standardValue?: string                     // 项目标准(冗余)
+  result: '0' | '1'                          // 0 正常 / 1 异常
+  reason?: string                            // 异常原因(result=1 必填)
+  photos?: string                            // 项目照片 URL(逗号分隔)
+}
+
+// ===== 提交打卡 - 整体 payload =====
+export interface QualityPunchSubmitPayload {
+  taskId: number                             // 任务ID
+  taskName?: string                          // 任务名称(冗余)
+  storeId?: number                           // 门店ID
+  storeName?: string                         // 门店名称
+  punchTime: string                          // ISO 字符串
+  punchLocation: string                      // 打卡地点
+  lng: number                                // 经度
+  lat: number                                // 纬度
+  punchType: PunchType                       // 常规/扫码
+  qrCode?: string                            // 扫码得到的二维码内容
+  photos?: string                            // 整单照片(逗号分隔)
+  items: QualityPunchItemResult[]            // 项目结果明细
 }