Jelajahi Sumber

更新扫描记录功能,新增维护代码参数以支持通过维护代码获取最近扫描记录。同时,优化了备注字段的处理逻辑,允许用户添加多条备注,并在界面中相应展示。添加了维护代码输入对话框以便用户验证。

wuyi 1 bulan lalu
induk
melakukan
bd46adc1b9
2 mengubah file dengan 273 tambahan dan 24 penghapusan
  1. 2 2
      src/services/api.js
  2. 271 22
      src/views/ScanView.vue

+ 2 - 2
src/services/api.js

@@ -78,9 +78,9 @@ export const createScanRecordApi = async (recordPayload) => {
   return response.data
 }
 
-export const fetchRecentScanRecordsApi = async (qrCode, limit = 10) => {
+export const fetchRecentScanRecordsApi = async (qrCode, maintenanceCode) => {
   const response = await api.get('/scan/recent', {
-    params: { qrCode, limit }
+    params: { qrCode, maintenanceCode }
   })
   return response.data
 }

+ 271 - 22
src/views/ScanView.vue

@@ -47,6 +47,9 @@ const isEditing = ref(false)
 const showLocationDialog = ref(false)
 const showLocationViewDialog = ref(false)
 const showScanRecordsDialog = ref(false)
+const showScanRecordsMaintenanceDialog = ref(false)
+const scanRecordsMaintenanceCode = ref('')
+const verifiedMaintenanceCode = ref('')
 const scanRecords = ref(null)
 const loadingScanRecords = ref(false)
 
@@ -76,7 +79,7 @@ const DEFAULT_FORM_STATE = Object.freeze({
   emergencyContactName: '',
   emergencyContactPhone: '',
   emergencyContactEmail: '',
-  remark: '',
+  remark: {},
   contactName: '',
   contactPhone: '',
   contactEmail: '',
@@ -85,7 +88,8 @@ const DEFAULT_FORM_STATE = Object.freeze({
 })
 
 const createDefaultFormState = () => ({
-  ...DEFAULT_FORM_STATE
+  ...DEFAULT_FORM_STATE,
+  remark: { ...DEFAULT_FORM_STATE.remark }
 })
 
 const formData = ref(createDefaultFormState())
@@ -145,6 +149,33 @@ const resetForm = (source = null) => {
         // 对于布尔值,直接使用值(包括 false)
         if (key === 'isVisible') {
           nextState[key] = source[key] !== undefined ? source[key] : true
+        } else if (key === 'remark') {
+          // 处理 remark 字段:如果是字符串,尝试解析为 JSON 对象
+          const value = source[key]
+          if (typeof value === 'string' && value.trim()) {
+            try {
+              const parsed = JSON.parse(value)
+              if (typeof parsed === 'object' && parsed !== null) {
+                nextState[key] = parsed
+              } else {
+                // 如果解析结果不是对象,将其作为第一个备注
+                nextState[key] = { '1': value }
+              }
+            } catch (e) {
+              // 如果解析失败,将字符串作为第一个备注
+              nextState[key] = value ? { '1': value } : { '1': '' }
+            }
+          } else if (typeof value === 'object' && value !== null) {
+            // 确保对象至少有一个字段
+            const keys = Object.keys(value)
+            if (keys.length === 0) {
+              nextState[key] = { '1': '' }
+            } else {
+              nextState[key] = value
+            }
+          } else {
+            nextState[key] = { '1': '' }
+          }
         } else {
           nextState[key] = source[key] ?? DEFAULT_FORM_STATE[key] ?? ''
         }
@@ -278,6 +309,14 @@ const buildProfilePayload = () => {
   getActiveFormKeys().forEach((key) => {
     if (key === 'isVisible') {
       payload[key] = formData.value[key] !== undefined ? formData.value[key] : true
+    } else if (key === 'remark') {
+      // 将 remark 对象转换为 JSON 字符串
+      const remarkObj = formData.value[key] || {}
+      // 过滤掉空值
+      const filteredRemark = Object.fromEntries(
+        Object.entries(remarkObj).filter(([_, value]) => value && value.trim())
+      )
+      payload[key] = Object.keys(filteredRemark).length > 0 ? JSON.stringify(filteredRemark) : ''
     } else {
       payload[key] = formData.value[key] ?? ''
     }
@@ -470,21 +509,24 @@ const openGoogleMaps = () => {
   }
 }
 
-const handleOpenScanRecords = async () => {
+const handleOpenScanRecords = () => {
   if (!qrCode.value) {
     toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter a QR code first.', life: 2400 })
     return
   }
-  showScanRecordsDialog.value = true
-  await fetchScanRecords()
+  showScanRecordsMaintenanceDialog.value = true
 }
 
-const fetchScanRecords = async () => {
-  if (!qrCode.value) return
+const fetchScanRecords = async (maintenanceCode) => {
+  if (!qrCode.value || !maintenanceCode) return
   loadingScanRecords.value = true
   try {
-    const data = await fetchRecentScanRecordsApi(qrCode.value, 10)
+    const data = await fetchRecentScanRecordsApi(qrCode.value, maintenanceCode)
     scanRecords.value = data
+    verifiedMaintenanceCode.value = maintenanceCode
+    showScanRecordsMaintenanceDialog.value = false
+    showScanRecordsDialog.value = true
+    scanRecordsMaintenanceCode.value = ''
   } catch (error) {
     toast.add({ severity: 'error', summary: 'Failed', detail: parseError(error), life: 3200 })
     scanRecords.value = null
@@ -493,6 +535,14 @@ const fetchScanRecords = async () => {
   }
 }
 
+const handleVerifyScanRecordsMaintenance = async () => {
+  if (!qrCode.value || !scanRecordsMaintenanceCode.value) {
+    toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter the maintenance code.', life: 2400 })
+    return
+  }
+  await fetchScanRecords(scanRecordsMaintenanceCode.value)
+}
+
 const formatDateTime = (dateString) => {
   if (!dateString) return '-'
   const date = new Date(dateString)
@@ -506,6 +556,117 @@ const formatDateTime = (dateString) => {
   })
 }
 
+const formatRemark = (remark) => {
+  if (!remark) return 'No extra details'
+  // 如果是字符串,尝试解析
+  if (typeof remark === 'string') {
+    if (!remark.trim()) return 'No extra details'
+    try {
+      const parsed = JSON.parse(remark)
+      if (typeof parsed === 'object' && parsed !== null) {
+        return Object.values(parsed).filter(v => v && v.trim()).join('\n')
+      }
+      return remark
+    } catch (e) {
+      return remark
+    }
+  }
+  // 如果是对象,提取所有值
+  if (typeof remark === 'object' && remark !== null) {
+    const values = Object.values(remark).filter(v => v && v.trim())
+    return values.length > 0 ? values.join('\n') : 'No extra details'
+  }
+  return 'No extra details'
+}
+
+const parseRemarkToArray = (remark) => {
+  if (!remark) return []
+  // 如果是字符串,尝试解析
+  if (typeof remark === 'string') {
+    if (!remark.trim()) return []
+    try {
+      const parsed = JSON.parse(remark)
+      if (typeof parsed === 'object' && parsed !== null) {
+        // 按索引排序,提取所有非空值
+        return Object.entries(parsed)
+          .sort(([a], [b]) => parseInt(a) - parseInt(b))
+          .map(([_, value]) => value)
+          .filter(v => v && v.trim())
+      }
+      return [remark]
+    } catch (e) {
+      return [remark]
+    }
+  }
+  // 如果是对象,提取所有值并按索引排序
+  if (typeof remark === 'object' && remark !== null) {
+    return Object.entries(remark)
+      .sort(([a], [b]) => parseInt(a) - parseInt(b))
+      .map(([_, value]) => value)
+      .filter(v => v && v.trim())
+  }
+  return []
+}
+
+const getRemarkIndexes = () => {
+  if (!formData.value.remark || typeof formData.value.remark !== 'object') {
+    // 如果 remark 不存在或不是对象,初始化它
+    formData.value.remark = { '1': '' }
+    return [1]
+  }
+  const keys = Object.keys(formData.value.remark)
+  if (keys.length === 0) {
+    // 如果对象为空,初始化一个字段
+    formData.value.remark = { '1': '' }
+    return [1]
+  }
+  // 获取所有已存在的索引,并确保至少有一个
+  const indexes = keys.map(k => parseInt(k) || 0).filter(k => k > 0).sort((a, b) => a - b)
+  if (indexes.length === 0) {
+    formData.value.remark = { '1': '' }
+    return [1]
+  }
+  return indexes
+}
+
+const getRemarkFieldCount = () => {
+  return getRemarkIndexes().length
+}
+
+const addRemarkField = () => {
+  if (!formData.value.remark || typeof formData.value.remark !== 'object') {
+    formData.value.remark = { '1': '' }
+  }
+  const currentIndexes = getRemarkIndexes()
+  if (currentIndexes.length < 4) {
+    const nextIndex = currentIndexes.length + 1
+    // 确保使用 Vue 的响应式更新
+    formData.value.remark = {
+      ...formData.value.remark,
+      [nextIndex.toString()]: ''
+    }
+  }
+}
+
+const removeRemarkField = (index) => {
+  if (!formData.value.remark || typeof formData.value.remark !== 'object') {
+    return
+  }
+  const indexes = getRemarkIndexes()
+  if (indexes.length <= 1) {
+    // 至少保留一个空字段
+    formData.value.remark = { '1': '' }
+    return
+  }
+  // 删除指定索引
+  const newRemark = { ...formData.value.remark }
+  delete newRemark[index.toString()]
+  // 重新整理索引,确保从1开始连续
+  const entries = Object.entries(newRemark)
+    .map(([_, value], idx) => [(idx + 1).toString(), value || ''])
+  formData.value.remark = Object.fromEntries(entries)
+}
+
 const openRecordLocation = (record) => {
   if (record.latitude && record.longitude) {
     window.open(`https://www.google.com/maps?q=${record.latitude},${record.longitude}`, '_blank')
@@ -726,21 +887,38 @@ onMounted(() => {
                         </button>
                       </div>
                     </div>
-                    <div class="rounded-2xl border border-amber-200 bg-gradient-to-br from-amber-50 to-white p-5">
+                    <div class="rounded-2xl border border-cyan-200/50 bg-gradient-to-br from-cyan-50/80 to-white p-5">
                       <div class="flex items-center gap-2">
-                        <div class="flex h-8 w-8 items-center justify-center rounded-full bg-amber-100">
-                          <i class="pi pi-info-circle text-sm text-amber-600" />
+                        <div class="flex h-8 w-8 items-center justify-center rounded-full bg-cyan-100">
+                          <i class="pi pi-info-circle text-sm text-cyan-600" />
                         </div>
                         <p class="text-xs font-medium uppercase tracking-wider text-slate-500">{{ isPerson ? 'Additional notes' : isGoods ? 'Item notes' : 'Extra description' }}</p>
                       </div>
-                      <p class="mt-3 whitespace-pre-wrap text-sm leading-relaxed text-slate-700">
-                        {{ isPerson ? (profile?.specialNote || 'No extra details') : (profile?.remark || 'No extra details') }}
-                      </p>
+                      <div v-if="isPerson" class="mt-3">
+                        <p class="whitespace-pre-wrap text-sm leading-relaxed text-slate-700">
+                          {{ profile?.specialNote || 'No extra details' }}
+                        </p>
+                      </div>
+                      <div v-else class="mt-3">
+                        <div v-if="parseRemarkToArray(profile?.remark).length === 0" class="text-sm text-slate-500">
+                          No extra details
+                        </div>
+                        <template v-else>
+                          <div
+                            v-for="(remarkItem, index) in parseRemarkToArray(profile?.remark)"
+                            :key="index">
+                            <p class="text-sm leading-relaxed text-slate-700 whitespace-pre-wrap">
+                              {{ remarkItem }}
+                            </p>
+                            <div v-if="index < parseRemarkToArray(profile?.remark).length - 1" class="my-3 border-t border-cyan-200/50"></div>
+                          </div>
+                        </template>
+                      </div>
                     </div>
                     <!-- Action buttons -->
                     <div v-if="!isEditing" class="pt-4 space-y-3">
                       <button type="button"
-                        class="w-full flex items-center justify-center gap-2 rounded-xl border border-cyan-300 bg-cyan-50 px-5 py-2.5 text-sm font-medium text-cyan-700 transition-all duration-200 hover:border-cyan-400 hover:bg-cyan-100 hover:shadow-md"
+                        class="w-full flex items-center justify-center gap-2 rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-medium text-yellow-700 transition-all duration-200 hover:border-slate-400 hover:shadow-md"
                         @click="handleOpenScanRecords">
                         <i class="pi pi-history text-xs" />
                         SCAN RECORDS
@@ -935,15 +1113,44 @@ onMounted(() => {
               </label>
             </div>
 
-            <label class="block space-y-2 text-sm">
-              <span class="text-slate-500">{{ isPerson ? 'Additional notes / health tips' : isGoods ? 'Item notes' : 'Extra remarks' }}</span>
-              <textarea v-if="isPerson" v-model="formData.specialNote" rows="4"
+            <label v-if="isPerson" class="block space-y-2 text-sm">
+              <span class="text-slate-500">Additional notes / health tips</span>
+              <textarea v-model="formData.specialNote" rows="4"
                 class="w-full rounded-2xl border border-slate-200 px-4 py-3"
                 placeholder="e.g. allergies, medical needs, carried items" />
-              <textarea v-else v-model="formData.remark" rows="4"
-                class="w-full rounded-2xl border border-slate-200 px-4 py-3"
-                :placeholder="isGoods ? 'e.g. item features, usage notes, handling tips' : 'e.g. pet habits, health info, behavior notes'" />
             </label>
+            <div v-else class="space-y-3">
+              <label class="block text-sm">
+                <span class="text-slate-500">{{ isGoods ? 'Item notes' : 'Extra remarks' }}</span>
+                <p class="mt-1 text-xs text-slate-400">You can add up to 4 remarks</p>
+              </label>
+              <div class="space-y-3">
+                <div v-for="index in getRemarkIndexes()" :key="index" class="flex gap-2 items-start">
+                  <div class="flex-1">
+                    <textarea
+                      v-model="formData.remark[index.toString()]"
+                      rows="3"
+                      class="w-full rounded-2xl border border-slate-200 px-4 py-3 resize-y min-h-[3rem]"
+                      :placeholder="isGoods ? `Item note ${index} (e.g. item features, usage notes)` : `Remark ${index} (e.g. pet habits, health info)`" />
+                  </div>
+                  <button
+                    v-if="index > 1 || (index === 1 && getRemarkIndexes().length > 1)"
+                    type="button"
+                    class="mt-1 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm font-medium text-red-600 transition hover:bg-red-100 flex-shrink-0"
+                    @click="removeRemarkField(index)">
+                    <i class="pi pi-times" />
+                  </button>
+                </div>
+              </div>
+              <button
+                v-if="getRemarkIndexes().length < 4"
+                type="button"
+                class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
+                @click="addRemarkField">
+                <i class="pi pi-plus mr-2" />
+                Add remark
+              </button>
+            </div>
 
             <div class="rounded-2xl border border-slate-200 bg-slate-50 p-5">
               <div class="flex items-center justify-between">
@@ -1086,7 +1293,7 @@ onMounted(() => {
           </button>
           <button type="button"
             class="rounded-xl bg-cyan-600 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-cyan-700 disabled:opacity-50"
-            :disabled="loadingScanRecords" @click="fetchScanRecords">
+            :disabled="loadingScanRecords || !verifiedMaintenanceCode" @click="fetchScanRecords(verifiedMaintenanceCode)">
             <i class="pi pi-refresh mr-2" :class="{ 'pi-spin': loadingScanRecords }" />
             Refresh
           </button>
@@ -1094,6 +1301,48 @@ onMounted(() => {
       </template>
     </Dialog>
 
+    <!-- Scan records maintenance code dialog -->
+    <Dialog v-model:visible="showScanRecordsMaintenanceDialog" modal :closable="true" :closeOnEscape="true"
+      :dismissableMask="true" :style="{ width: '90vw', maxWidth: '450px' }" :draggable="false">
+      <template #header>
+        <div class="flex items-center gap-3">
+          <div class="flex h-10 w-10 items-center justify-center rounded-full bg-cyan-100">
+            <i class="pi pi-lock text-lg text-cyan-600" />
+          </div>
+          <div>
+            <h3 class="text-lg font-semibold text-slate-900">Verification required</h3>
+            <p class="text-sm text-slate-500">
+              Enter the maintenance code to view scan records.
+            </p>
+          </div>
+        </div>
+      </template>
+
+      <div class="space-y-4 py-4">
+        <div>
+          <label class="mb-2 block text-sm font-medium text-slate-700">Maintenance code</label>
+          <input v-model="scanRecordsMaintenanceCode" type="text" maxlength="8"
+            class="w-full rounded-xl border border-slate-300 px-4 py-3 text-slate-900 placeholder:text-slate-400 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
+            placeholder="Enter the maintenance code" @keyup.enter="handleVerifyScanRecordsMaintenance" autofocus />
+        </div>
+      </div>
+
+      <template #footer>
+        <div class="flex justify-end gap-3">
+          <button type="button"
+            class="rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
+            @click="() => { showScanRecordsMaintenanceDialog = false; scanRecordsMaintenanceCode = '' }">
+            Cancel
+          </button>
+          <button type="button"
+            class="rounded-xl bg-cyan-600 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-cyan-700 disabled:opacity-50"
+            :disabled="loadingScanRecords || !scanRecordsMaintenanceCode" @click="handleVerifyScanRecordsMaintenance">
+            {{ loadingScanRecords ? 'Verifying...' : 'Verify' }}
+          </button>
+        </div>
+      </template>
+    </Dialog>
+
     <!-- Maintenance code dialog -->
     <Dialog v-model:visible="showMaintenanceDialog" modal :closable="!isFirstFill" :closeOnEscape="!isFirstFill"
       :dismissableMask="!isFirstFill" :style="{ width: '90vw', maxWidth: '450px' }" :draggable="false">