|
|
@@ -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">
|