ScanView.vue 70 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623
  1. <script setup>
  2. import { computed, onMounted, reactive, ref, watch } from 'vue'
  3. import { useRoute, useRouter } from 'vue-router'
  4. import { useToast } from 'primevue/usetoast'
  5. import Dialog from 'primevue/dialog'
  6. import LocationPicker from '@/components/LocationPicker.vue'
  7. import {
  8. fetchQrInfoApi,
  9. updatePersonProfileApi,
  10. updatePetProfileApi,
  11. updateGoodsInfoApi,
  12. updateLinkInfoApi,
  13. verifyMaintenanceCodeApi,
  14. verifyMaintenanceCodeInfoApi,
  15. uploadFile,
  16. fetchRecentScanRecordsApi
  17. } from '@/services/api'
  18. const route = useRoute()
  19. const router = useRouter()
  20. const toast = useToast()
  21. // Pull QR code from route params
  22. const qrCode = ref(route.params.qrCode?.toString().trim() || '')
  23. const queryInput = ref(qrCode.value)
  24. const loading = reactive({
  25. info: false,
  26. saving: false,
  27. verifying: false,
  28. photo: false
  29. })
  30. const qrDetail = ref(null)
  31. const profile = ref(null)
  32. const infoStatus = reactive({
  33. state: qrCode.value ? 'loading' : 'idle',
  34. message: ''
  35. })
  36. const maintenanceCode = ref('')
  37. const maintenancePassed = ref(false)
  38. const showMaintenancePanel = ref(false)
  39. const showMaintenanceDialog = ref(false)
  40. const viewMaintenanceCode = ref('')
  41. const loadingViewVerification = ref(false)
  42. const isEditing = ref(false)
  43. const showLocationDialog = ref(false)
  44. const showLocationViewDialog = ref(false)
  45. const showScanRecordsDialog = ref(false)
  46. const showScanRecordsMaintenanceDialog = ref(false)
  47. const scanRecordsMaintenanceCode = ref('')
  48. const verifiedMaintenanceCode = ref('')
  49. const scanRecords = ref(null)
  50. const loadingScanRecords = ref(false)
  51. const FORM_KEY_MAP = Object.freeze({
  52. person: [
  53. 'photoUrl',
  54. 'name',
  55. 'gender',
  56. 'phone',
  57. 'specialNote',
  58. 'emergencyContactName',
  59. 'emergencyContactPhone',
  60. 'emergencyContactEmail',
  61. 'location',
  62. 'isVisible'
  63. ],
  64. pet: ['photoUrl', 'name', 'contactName', 'contactPhone', 'contactEmail', 'location', 'remark', 'isVisible'],
  65. goods: ['photoUrl', 'name', 'contactName', 'contactPhone', 'contactEmail', 'location', 'remark', 'isVisible'],
  66. link: ['jumpUrl', 'linkRemark', 'isVisible']
  67. })
  68. const DEFAULT_FORM_STATE = Object.freeze({
  69. photoUrl: '',
  70. name: '',
  71. gender: 'unknown',
  72. phone: '',
  73. specialNote: '',
  74. emergencyContactName: '',
  75. emergencyContactPhone: '',
  76. emergencyContactEmail: '',
  77. remark: {},
  78. contactName: '',
  79. contactPhone: '',
  80. contactEmail: '',
  81. location: '',
  82. isVisible: true,
  83. jumpUrl: '',
  84. linkRemark: ''
  85. })
  86. const createDefaultFormState = () => ({
  87. ...DEFAULT_FORM_STATE,
  88. remark: { ...DEFAULT_FORM_STATE.remark }
  89. })
  90. const formData = ref(createDefaultFormState())
  91. const profileApiMap = Object.freeze({
  92. person: updatePersonProfileApi,
  93. pet: updatePetProfileApi,
  94. goods: updateGoodsInfoApi,
  95. link: updateLinkInfoApi
  96. })
  97. const MAX_PHOTO_SIZE = 15 * 1024 * 1024
  98. const DEFAULT_NAME_BY_TYPE = Object.freeze({
  99. person: 'Person',
  100. pet: 'Pet',
  101. goods: 'Item'
  102. })
  103. const qrType = computed(() => qrDetail.value?.qrType || 'person')
  104. const isPerson = computed(() => qrType.value === 'person')
  105. const isPet = computed(() => qrType.value === 'pet')
  106. const isGoods = computed(() => qrType.value === 'goods')
  107. const isLink = computed(() => qrType.value === 'link')
  108. const hasProfile = computed(() => Boolean(profile.value))
  109. const isFirstFill = computed(() => {
  110. // 优先判断 isVisible,如果不可见则不认为是第一次使用
  111. if (qrDetail.value?.isVisible === false) {
  112. return false
  113. }
  114. // 如果 isActivated 为 false 或 undefined,则认为是第一次填写
  115. return Boolean(qrDetail.value) && !qrDetail.value?.isActivated
  116. })
  117. const heroTitle = computed(() => {
  118. if (!qrDetail.value) return 'Contact Card'
  119. if (isPerson.value) return 'Person Card'
  120. if (isPet.value) return 'Pet Card'
  121. if (isGoods.value) return 'Item Card'
  122. if (isLink.value) return 'Link Card'
  123. return 'Contact Card'
  124. })
  125. const getActiveFormKeys = () => FORM_KEY_MAP[qrType.value] || FORM_KEY_MAP.person
  126. const parseError = (error) => {
  127. return (
  128. error?.response?.data?.message ||
  129. error?.data?.message ||
  130. error?.detail ||
  131. error?.message ||
  132. error?.error ||
  133. (typeof error === 'string' ? error : '') ||
  134. 'Request failed'
  135. )
  136. }
  137. const resetForm = (source = null) => {
  138. const nextState = createDefaultFormState()
  139. if (source) {
  140. // 对于 link 类型,特殊处理
  141. if (isLink.value) {
  142. nextState.jumpUrl = source.jumpUrl ?? ''
  143. nextState.linkRemark = source.remark ?? ''
  144. nextState.isVisible = source.isVisible !== undefined ? source.isVisible : true
  145. } else {
  146. getActiveFormKeys().forEach((key) => {
  147. if (Object.prototype.hasOwnProperty.call(source, key)) {
  148. // 对于布尔值,直接使用值(包括 false)
  149. if (key === 'isVisible') {
  150. nextState[key] = source[key] !== undefined ? source[key] : true
  151. } else if (key === 'remark') {
  152. // 处理 remark 字段:如果是字符串,尝试解析为 JSON 对象
  153. const value = source[key]
  154. if (typeof value === 'string' && value.trim()) {
  155. try {
  156. const parsed = JSON.parse(value)
  157. if (typeof parsed === 'object' && parsed !== null) {
  158. nextState[key] = parsed
  159. } else {
  160. // 如果解析结果不是对象,将其作为第一个备注
  161. nextState[key] = { '1': value }
  162. }
  163. } catch (e) {
  164. // 如果解析失败,将字符串作为第一个备注
  165. nextState[key] = value ? { '1': value } : { '1': '' }
  166. }
  167. } else if (typeof value === 'object' && value !== null) {
  168. // 确保对象至少有一个字段
  169. const keys = Object.keys(value)
  170. if (keys.length === 0) {
  171. nextState[key] = { '1': '' }
  172. } else {
  173. nextState[key] = value
  174. }
  175. } else {
  176. nextState[key] = { '1': '' }
  177. }
  178. } else {
  179. nextState[key] = source[key] ?? DEFAULT_FORM_STATE[key] ?? ''
  180. }
  181. }
  182. })
  183. }
  184. }
  185. // 如果 source 中没有 isVisible,则从 qrDetail 中获取
  186. if (!source || !Object.prototype.hasOwnProperty.call(source, 'isVisible')) {
  187. if (qrDetail.value && Object.prototype.hasOwnProperty.call(qrDetail.value, 'isVisible')) {
  188. nextState.isVisible = qrDetail.value.isVisible !== false
  189. }
  190. }
  191. formData.value = nextState
  192. }
  193. const setDocumentTitle = () => {
  194. if (typeof document === 'undefined') return
  195. // 如果没有二维码或没有详情,只显示 Emergency QR
  196. if (!qrCode.value || !qrDetail.value) {
  197. document.title = 'Emergency QR'
  198. return
  199. }
  200. if (isLink.value) {
  201. const linkUrl = profile.value?.jumpUrl || 'Link Information'
  202. document.title = `${linkUrl} | Link QR`
  203. return
  204. }
  205. const defaultName = DEFAULT_NAME_BY_TYPE[qrType.value] || DEFAULT_NAME_BY_TYPE.person
  206. const name = profile.value?.name || defaultName
  207. document.title = `${name} | Emergency QR`
  208. }
  209. const fetchQrDetails = async () => {
  210. if (!qrCode.value) return
  211. loading.info = true
  212. infoStatus.state = 'loading'
  213. infoStatus.message = ''
  214. try {
  215. const data = await fetchQrInfoApi(qrCode.value)
  216. qrDetail.value = data
  217. // 如果是 link 类型且已激活且有 jumpUrl,直接跳转
  218. if (data.qrType === 'link' && data.isActivated && data.info?.jumpUrl) {
  219. window.location.href = data.info.jumpUrl
  220. return
  221. }
  222. if (data.isVisible === false) {
  223. infoStatus.state = 'notVisible'
  224. infoStatus.message = 'QR code information is not visible'
  225. profile.value = null
  226. resetForm()
  227. setDocumentTitle()
  228. return
  229. }
  230. profile.value = data.info || null
  231. resetForm(profile.value)
  232. infoStatus.state = 'ready'
  233. setDocumentTitle()
  234. } catch (error) {
  235. infoStatus.state = 'error'
  236. infoStatus.message = parseError(error)
  237. } finally {
  238. loading.info = false
  239. }
  240. }
  241. const handleQrSubmit = () => {
  242. if (!queryInput.value.trim()) {
  243. toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter a QR code first.', life: 2600 })
  244. return
  245. }
  246. router.push({ name: 'scan', params: { qrCode: queryInput.value.trim() } })
  247. }
  248. const handleVerifyMaintenance = async () => {
  249. if (!qrCode.value || !maintenanceCode.value) {
  250. toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter the maintenance code.', life: 2400 })
  251. return
  252. }
  253. loading.verifying = true
  254. try {
  255. await verifyMaintenanceCodeApi({
  256. qrCode: qrCode.value,
  257. maintenanceCode: maintenanceCode.value
  258. })
  259. maintenancePassed.value = true
  260. showMaintenancePanel.value = false
  261. showMaintenanceDialog.value = false
  262. isEditing.value = true
  263. toast.add({ severity: 'success', summary: 'Verified', detail: 'Maintenance editing unlocked.', life: 2600 })
  264. // Scroll to the form the first time we collect data
  265. if (isFirstFill.value) {
  266. setTimeout(scrollToForm, 300)
  267. }
  268. } catch (error) {
  269. maintenancePassed.value = false
  270. toast.add({ severity: 'error', summary: 'Verification failed', detail: parseError(error), life: 3200 })
  271. } finally {
  272. loading.verifying = false
  273. }
  274. }
  275. const handleVerifyMaintenanceForView = async () => {
  276. if (!qrCode.value || !viewMaintenanceCode.value) {
  277. toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter the maintenance code.', life: 2400 })
  278. return
  279. }
  280. loadingViewVerification.value = true
  281. try {
  282. const data = await verifyMaintenanceCodeInfoApi({
  283. qrCode: qrCode.value,
  284. maintenanceCode: viewMaintenanceCode.value
  285. })
  286. if (data.valid) {
  287. // 更新二维码详情和用户信息
  288. qrDetail.value = {
  289. ...qrDetail.value,
  290. ...data,
  291. isVisible: true // 验证成功后视为可见
  292. }
  293. profile.value = data.info || null
  294. resetForm(profile.value)
  295. infoStatus.state = 'ready'
  296. infoStatus.message = ''
  297. viewMaintenanceCode.value = ''
  298. setDocumentTitle()
  299. toast.add({ severity: 'success', summary: 'Verified', detail: data.message || 'Verification successful.', life: 2600 })
  300. } else {
  301. throw new Error(data.message || 'Verification failed')
  302. }
  303. } catch (error) {
  304. toast.add({ severity: 'error', summary: 'Verification failed', detail: parseError(error), life: 3200 })
  305. } finally {
  306. loadingViewVerification.value = false
  307. }
  308. }
  309. const isValidUrl = (url) => {
  310. if (!url || !url.trim()) return true // Empty value is considered valid (optional field)
  311. try {
  312. const urlObj = new URL(url)
  313. return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
  314. } catch {
  315. return false
  316. }
  317. }
  318. const testJumpUrl = () => {
  319. const url = formData.value.jumpUrl?.trim()
  320. if (!url) {
  321. toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter a URL first.', life: 2400 })
  322. return
  323. }
  324. // 验证 URL 格式
  325. if (isValidUrl(url)) {
  326. toast.add({
  327. severity: 'success',
  328. summary: 'URL Format Valid',
  329. detail: 'The URL format is correct and ready to save.',
  330. life: 3000
  331. })
  332. } else {
  333. toast.add({
  334. severity: 'error',
  335. summary: 'Invalid URL Format',
  336. detail: 'Please check the URL format (must start with http:// or https://).',
  337. life: 3200
  338. })
  339. }
  340. }
  341. const buildProfilePayload = () => {
  342. const payload = {
  343. qrCode: qrCode.value
  344. }
  345. // 对于 link 类型,特殊处理
  346. if (isLink.value) {
  347. payload.isVisible = formData.value.isVisible !== undefined ? formData.value.isVisible : true
  348. if (formData.value.jumpUrl && formData.value.jumpUrl.trim()) {
  349. payload.jumpUrl = formData.value.jumpUrl.trim()
  350. }
  351. if (formData.value.linkRemark && formData.value.linkRemark.trim()) {
  352. payload.remark = formData.value.linkRemark.trim()
  353. }
  354. if (maintenanceCode.value) {
  355. payload.maintenanceCode = maintenanceCode.value
  356. }
  357. return payload
  358. }
  359. getActiveFormKeys().forEach((key) => {
  360. if (key === 'isVisible') {
  361. payload[key] = formData.value[key] !== undefined ? formData.value[key] : true
  362. } else if (key === 'remark') {
  363. // 将 remark 对象转换为 JSON 字符串
  364. const remarkObj = formData.value[key] || {}
  365. // 过滤掉空值
  366. const filteredRemark = Object.fromEntries(
  367. Object.entries(remarkObj).filter(([_, value]) => value && value.trim())
  368. )
  369. payload[key] = Object.keys(filteredRemark).length > 0 ? JSON.stringify(filteredRemark) : ''
  370. } else {
  371. payload[key] = formData.value[key] ?? ''
  372. }
  373. })
  374. if (maintenanceCode.value) {
  375. payload.maintenanceCode = maintenanceCode.value
  376. }
  377. return payload
  378. }
  379. const handleSaveProfile = async () => {
  380. if (!qrCode.value) return
  381. // 对于 link 类型,验证 URL
  382. if (isLink.value) {
  383. if (formData.value.jumpUrl && formData.value.jumpUrl.trim()) {
  384. if (formData.value.jumpUrl.length > 2000) {
  385. toast.add({ severity: 'warn', summary: 'Notice', detail: 'Jump URL cannot exceed 2000 characters.', life: 2400 })
  386. return
  387. }
  388. if (!isValidUrl(formData.value.jumpUrl)) {
  389. toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter a valid URL (must start with http:// or https://).', life: 2400 })
  390. return
  391. }
  392. }
  393. if (formData.value.linkRemark && formData.value.linkRemark.length > 500) {
  394. toast.add({ severity: 'warn', summary: 'Notice', detail: 'Remark cannot exceed 500 characters.', life: 2400 })
  395. return
  396. }
  397. }
  398. loading.saving = true
  399. try {
  400. const payload = buildProfilePayload()
  401. const updater = profileApiMap[qrType.value] || profileApiMap.person
  402. const response = await updater(payload)
  403. toast.add({
  404. severity: 'success',
  405. summary: 'Saved',
  406. detail: response?.message || (isLink.value ? 'Link information has been updated.' : 'Profile has been updated.'),
  407. life: 3000
  408. })
  409. await fetchQrDetails()
  410. isEditing.value = false
  411. maintenancePassed.value = false
  412. maintenanceCode.value = ''
  413. } catch (error) {
  414. toast.add({ severity: 'error', summary: 'Save failed', detail: parseError(error), life: 3200 })
  415. } finally {
  416. loading.saving = false
  417. }
  418. }
  419. const photoInputRef = ref(null)
  420. const triggerPhotoPicker = () => {
  421. photoInputRef.value?.click?.()
  422. }
  423. const handleImageError = (event) => {
  424. // Handle broken image preview
  425. console.warn('Image load failed:', formData.value.photoUrl)
  426. event.target.style.display = 'none'
  427. }
  428. const handlePhotoChange = async (event) => {
  429. const file = event.target.files?.[0]
  430. if (!file) return
  431. // Validate mime type
  432. if (!file.type.startsWith('image/')) {
  433. toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please choose an image file.', life: 2400 })
  434. return
  435. }
  436. // Validate file size (limit 15MB)
  437. if (file.size > MAX_PHOTO_SIZE) {
  438. toast.add({ severity: 'warn', summary: 'Notice', detail: 'Image size cannot exceed 15MB.', life: 2400 })
  439. return
  440. }
  441. loading.photo = true
  442. try {
  443. const response = await uploadFile(file)
  444. // Normalize upload response shape
  445. const url = response?.data?.url || response?.url || ''
  446. if (url) {
  447. formData.value.photoUrl = url
  448. toast.add({ severity: 'success', summary: 'Uploaded', detail: 'Photo has been updated.', life: 2400 })
  449. } else {
  450. throw new Error('Image URL not found')
  451. }
  452. } catch (error) {
  453. toast.add({ severity: 'error', summary: 'Upload failed', detail: parseError(error), life: 3200 })
  454. } finally {
  455. loading.photo = false
  456. // Reset input so the same file can trigger change
  457. if (event.target) {
  458. event.target.value = ''
  459. }
  460. }
  461. }
  462. const scrollToForm = () => {
  463. const el = document.getElementById('scan-form-section')
  464. if (el) {
  465. el.scrollIntoView({ behavior: 'smooth', block: 'start' })
  466. }
  467. }
  468. const callNumber = (phone) => {
  469. if (!phone) return
  470. window.location.href = `tel:${phone}`
  471. }
  472. const sendEmail = (email) => {
  473. if (!email) return
  474. window.location.href = `mailto:${email}`
  475. }
  476. const copyInfo = async (value, label = 'info') => {
  477. const content = value?.toString().trim()
  478. if (!content) {
  479. toast.add({ severity: 'info', summary: 'Notice', detail: `Nothing to copy for ${label}.`, life: 2200 })
  480. return
  481. }
  482. try {
  483. if (navigator?.clipboard?.writeText) {
  484. await navigator.clipboard.writeText(content)
  485. } else {
  486. const textarea = document.createElement('textarea')
  487. textarea.value = content
  488. textarea.style.position = 'fixed'
  489. textarea.style.opacity = '0'
  490. document.body.appendChild(textarea)
  491. textarea.focus()
  492. textarea.select()
  493. document.execCommand('copy')
  494. document.body.removeChild(textarea)
  495. }
  496. toast.add({ severity: 'success', summary: 'Copied', detail: `${label} copied to clipboard.`, life: 2000 })
  497. } catch (error) {
  498. console.error('Copy failed', error)
  499. toast.add({ severity: 'error', summary: 'Copy failed', detail: 'Please copy manually.', life: 2200 })
  500. }
  501. }
  502. const resetPageState = () => {
  503. qrDetail.value = null
  504. profile.value = null
  505. maintenanceCode.value = ''
  506. maintenancePassed.value = false
  507. showMaintenancePanel.value = false
  508. showMaintenanceDialog.value = false
  509. viewMaintenanceCode.value = ''
  510. isEditing.value = false
  511. infoStatus.state = qrCode.value ? 'loading' : 'idle'
  512. infoStatus.message = ''
  513. resetForm()
  514. }
  515. watch(
  516. () => route.params.qrCode,
  517. (value) => {
  518. const normalized = value?.toString().trim() || ''
  519. queryInput.value = normalized
  520. qrCode.value = normalized
  521. resetPageState()
  522. if (normalized) {
  523. fetchQrDetails()
  524. }
  525. },
  526. { immediate: true }
  527. )
  528. watch([qrType, profile], () => {
  529. resetForm(profile.value)
  530. setDocumentTitle()
  531. })
  532. // Auto open verification dialog on first fill
  533. watch(isFirstFill, (value) => {
  534. if (value && !maintenancePassed.value) {
  535. showMaintenanceDialog.value = true
  536. }
  537. })
  538. const handleOpenLocationDialog = () => {
  539. showLocationDialog.value = true
  540. }
  541. const handleSaveLocation = (location) => {
  542. formData.value.location = location
  543. toast.add({ severity: 'success', summary: 'Saved', detail: 'Address selected.', life: 2400 })
  544. }
  545. const handleOpenLocationView = () => {
  546. showLocationViewDialog.value = true
  547. }
  548. const openGoogleMaps = () => {
  549. const location = profile.value?.location
  550. if (!location) return
  551. // Try to parse coordinate format (lat, lng)
  552. const coordsMatch = location.match(/(-?\d+\.?\d*),\s*(-?\d+\.?\d*)/)
  553. if (coordsMatch) {
  554. const lat = coordsMatch[1]
  555. const lng = coordsMatch[2]
  556. // Open Google Maps with coordinates
  557. window.open(`https://www.google.com/maps?q=${lat},${lng}`, '_blank')
  558. } else {
  559. // Otherwise search by address string
  560. window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(location)}`, '_blank')
  561. }
  562. }
  563. const handleOpenScanRecords = () => {
  564. if (!qrCode.value) {
  565. toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter a QR code first.', life: 2400 })
  566. return
  567. }
  568. showScanRecordsMaintenanceDialog.value = true
  569. }
  570. const fetchScanRecords = async (maintenanceCode) => {
  571. if (!qrCode.value || !maintenanceCode) return
  572. loadingScanRecords.value = true
  573. try {
  574. const data = await fetchRecentScanRecordsApi(qrCode.value, maintenanceCode)
  575. scanRecords.value = data
  576. verifiedMaintenanceCode.value = maintenanceCode
  577. showScanRecordsMaintenanceDialog.value = false
  578. showScanRecordsDialog.value = true
  579. scanRecordsMaintenanceCode.value = ''
  580. } catch (error) {
  581. toast.add({ severity: 'error', summary: 'Failed', detail: parseError(error), life: 3200 })
  582. scanRecords.value = null
  583. } finally {
  584. loadingScanRecords.value = false
  585. }
  586. }
  587. const handleVerifyScanRecordsMaintenance = async () => {
  588. if (!qrCode.value || !scanRecordsMaintenanceCode.value) {
  589. toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter the maintenance code.', life: 2400 })
  590. return
  591. }
  592. await fetchScanRecords(scanRecordsMaintenanceCode.value)
  593. }
  594. const formatDateTime = (dateString) => {
  595. if (!dateString) return '-'
  596. const date = new Date(dateString)
  597. return date.toLocaleString('en-US', {
  598. year: 'numeric',
  599. month: '2-digit',
  600. day: '2-digit',
  601. hour: '2-digit',
  602. minute: '2-digit',
  603. second: '2-digit'
  604. })
  605. }
  606. const formatRemark = (remark) => {
  607. if (!remark) return 'No extra details'
  608. // 如果是字符串,尝试解析
  609. if (typeof remark === 'string') {
  610. if (!remark.trim()) return 'No extra details'
  611. try {
  612. const parsed = JSON.parse(remark)
  613. if (typeof parsed === 'object' && parsed !== null) {
  614. return Object.values(parsed).filter(v => v && v.trim()).join('\n')
  615. }
  616. return remark
  617. } catch (e) {
  618. return remark
  619. }
  620. }
  621. // 如果是对象,提取所有值
  622. if (typeof remark === 'object' && remark !== null) {
  623. const values = Object.values(remark).filter(v => v && v.trim())
  624. return values.length > 0 ? values.join('\n') : 'No extra details'
  625. }
  626. return 'No extra details'
  627. }
  628. const parseRemarkToArray = (remark) => {
  629. if (!remark) return []
  630. // 如果是字符串,尝试解析
  631. if (typeof remark === 'string') {
  632. if (!remark.trim()) return []
  633. try {
  634. const parsed = JSON.parse(remark)
  635. if (typeof parsed === 'object' && parsed !== null) {
  636. // 按索引排序,提取所有非空值
  637. return Object.entries(parsed)
  638. .sort(([a], [b]) => parseInt(a) - parseInt(b))
  639. .map(([_, value]) => value)
  640. .filter(v => v && v.trim())
  641. }
  642. return [remark]
  643. } catch (e) {
  644. return [remark]
  645. }
  646. }
  647. // 如果是对象,提取所有值并按索引排序
  648. if (typeof remark === 'object' && remark !== null) {
  649. return Object.entries(remark)
  650. .sort(([a], [b]) => parseInt(a) - parseInt(b))
  651. .map(([_, value]) => value)
  652. .filter(v => v && v.trim())
  653. }
  654. return []
  655. }
  656. const getRemarkIndexes = () => {
  657. if (!formData.value.remark || typeof formData.value.remark !== 'object') {
  658. // 如果 remark 不存在或不是对象,初始化它
  659. formData.value.remark = { '1': '' }
  660. return [1]
  661. }
  662. const keys = Object.keys(formData.value.remark)
  663. if (keys.length === 0) {
  664. // 如果对象为空,初始化一个字段
  665. formData.value.remark = { '1': '' }
  666. return [1]
  667. }
  668. // 获取所有已存在的索引,并确保至少有一个
  669. const indexes = keys.map(k => parseInt(k) || 0).filter(k => k > 0).sort((a, b) => a - b)
  670. if (indexes.length === 0) {
  671. formData.value.remark = { '1': '' }
  672. return [1]
  673. }
  674. return indexes
  675. }
  676. const getRemarkFieldCount = () => {
  677. return getRemarkIndexes().length
  678. }
  679. const addRemarkField = () => {
  680. if (!formData.value.remark || typeof formData.value.remark !== 'object') {
  681. formData.value.remark = { '1': '' }
  682. }
  683. const currentIndexes = getRemarkIndexes()
  684. if (currentIndexes.length < 4) {
  685. const nextIndex = currentIndexes.length + 1
  686. // 确保使用 Vue 的响应式更新
  687. formData.value.remark = {
  688. ...formData.value.remark,
  689. [nextIndex.toString()]: ''
  690. }
  691. }
  692. }
  693. const removeRemarkField = (index) => {
  694. if (!formData.value.remark || typeof formData.value.remark !== 'object') {
  695. return
  696. }
  697. const indexes = getRemarkIndexes()
  698. if (indexes.length <= 1) {
  699. // 至少保留一个空字段
  700. formData.value.remark = { '1': '' }
  701. return
  702. }
  703. // 删除指定索引
  704. const newRemark = { ...formData.value.remark }
  705. delete newRemark[index.toString()]
  706. // 重新整理索引,确保从1开始连续
  707. const entries = Object.entries(newRemark)
  708. .map(([_, value], idx) => [(idx + 1).toString(), value || ''])
  709. formData.value.remark = Object.fromEntries(entries)
  710. }
  711. const openRecordLocation = (record) => {
  712. if (record.latitude && record.longitude) {
  713. window.open(`https://www.google.com/maps?q=${record.latitude},${record.longitude}`, '_blank')
  714. } else if (record.address) {
  715. window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(record.address)}`, '_blank')
  716. }
  717. }
  718. onMounted(() => {
  719. if (!qrCode.value) {
  720. setDocumentTitle()
  721. }
  722. })
  723. </script>
  724. <template>
  725. <div class="scan-page min-h-screen bg-slate-950 text-slate-100">
  726. <div class="relative isolate px-4 py-10 sm:px-6 lg:px-8">
  727. <div
  728. class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-96 bg-gradient-to-br from-cyan-500/20 via-indigo-500/10 to-transparent blur-3xl" />
  729. <div class="mx-auto max-w-6xl space-y-8">
  730. <header class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
  731. <div>
  732. <p class="text-xs uppercase tracking-[0.4em] text-cyan-200">Qr emergency link</p>
  733. <h1 class="mt-2 text-3xl font-semibold text-white sm:text-4xl">
  734. {{ heroTitle }}
  735. </h1>
  736. </div>
  737. </header>
  738. <section v-if="!qrCode"
  739. class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-xl shadow-cyan-500/10 backdrop-blur">
  740. <div class="flex items-start gap-4">
  741. <div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-cyan-500/20">
  742. <i class="pi pi-qrcode text-2xl text-cyan-300" />
  743. </div>
  744. <div class="flex-1">
  745. <p class="text-lg font-semibold text-white">Welcome to the Emergency QR system</p>
  746. <p class="mt-2 text-sm text-slate-300">
  747. Scan the QR code or enter its value below to continue.
  748. </p>
  749. </div>
  750. </div>
  751. <div class="mt-6 flex flex-col gap-4 sm:flex-row sm:items-start">
  752. <div class="flex-1 space-y-2">
  753. <input v-model="queryInput" type="text" placeholder="Enter QR code"
  754. class="w-full rounded-2xl border border-white/20 bg-white/5 px-5 py-3.5 text-base text-white placeholder:text-slate-400 backdrop-blur-sm transition-all duration-200 focus:border-cyan-400 focus:bg-white/10 focus:outline-none focus:ring-2 focus:ring-cyan-400/30"
  755. @keyup.enter="handleQrSubmit" />
  756. <div class="flex items-center gap-2 px-1">
  757. <svg class="h-4 w-4 flex-shrink-0 text-cyan-400/70" fill="none" viewBox="0 0 24 24"
  758. stroke="currentColor">
  759. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
  760. d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  761. </svg>
  762. <span class="text-sm text-slate-400/90">Example: <span
  763. class="font-mono text-slate-300/80">QRMIH76J350CE2CF6150A51F4E</span></span>
  764. </div>
  765. </div>
  766. <button type="button"
  767. class="rounded-2xl bg-gradient-to-r from-cyan-400 to-blue-400 px-8 py-3.5 text-base font-semibold text-slate-900 shadow-lg shadow-cyan-500/30 transition-all duration-200 hover:scale-105 hover:shadow-xl hover:shadow-cyan-500/40 sm:whitespace-nowrap"
  768. @click="handleQrSubmit">
  769. View info
  770. </button>
  771. </div>
  772. </section>
  773. <section v-else class="space-y-6">
  774. <div v-if="infoStatus.state === 'error'" class="rounded-3xl border border-red-500/40 bg-red-500/10 p-5">
  775. <p class="text-sm text-red-100">{{ infoStatus.message }}</p>
  776. <p class="mt-2 text-xs text-red-200">Please double-check the QR code or reach out for assistance.</p>
  777. </div>
  778. <div v-if="infoStatus.state === 'notVisible'" class="rounded-3xl border border-amber-500/40 bg-amber-500/10 p-6">
  779. <div class="flex items-start gap-4">
  780. <div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500/20 flex-shrink-0">
  781. <i class="pi pi-eye-slash text-2xl text-amber-300" />
  782. </div>
  783. <div class="flex-1">
  784. <p class="text-lg font-semibold text-amber-100">{{ infoStatus.message }}</p>
  785. <p class="mt-2 text-sm text-amber-200">This QR code information has been set to invisible and cannot be viewed.</p>
  786. </div>
  787. </div>
  788. </div>
  789. <div v-if="infoStatus.state === 'notVisible'" class="rounded-3xl border border-cyan-500/40 bg-cyan-500/10 p-6">
  790. <div class="flex items-start gap-4">
  791. <div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-cyan-500/20 flex-shrink-0">
  792. <i class="pi pi-key text-2xl text-cyan-300" />
  793. </div>
  794. <div class="flex-1">
  795. <p class="text-lg font-semibold text-cyan-100">View with maintenance code</p>
  796. <p class="mt-2 text-sm text-cyan-200">Enter the maintenance code to view complete information.</p>
  797. <div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
  798. <div class="flex-1">
  799. <label class="mb-1.5 block text-xs font-medium text-cyan-100">Maintenance code</label>
  800. <input v-model="viewMaintenanceCode" type="text" maxlength="8"
  801. class="w-full rounded-xl border border-cyan-400/40 bg-white/10 px-4 py-2.5 text-sm text-white placeholder:text-cyan-300/60 focus:border-cyan-300 focus:bg-white/15 focus:outline-none focus:ring-2 focus:ring-cyan-300/30"
  802. placeholder="Enter maintenance code" @keyup.enter="handleVerifyMaintenanceForView" />
  803. </div>
  804. <button type="button"
  805. class="rounded-xl bg-cyan-500 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-cyan-600 disabled:opacity-50 sm:whitespace-nowrap"
  806. :disabled="loadingViewVerification || !viewMaintenanceCode" @click="handleVerifyMaintenanceForView">
  807. <i v-if="loadingViewVerification" class="pi pi-spin pi-spinner mr-2" />
  808. <i v-else class="pi pi-check mr-2" />
  809. {{ loadingViewVerification ? 'Verifying...' : 'Verify & View' }}
  810. </button>
  811. </div>
  812. </div>
  813. </div>
  814. </div>
  815. <div v-if="loading.info" class="rounded-3xl border border-white/10 bg-white/5 p-6 text-base text-white">
  816. Loading QR information...
  817. </div>
  818. <!-- Only render details after maintenance verification -->
  819. <div v-if="infoStatus.state === 'ready' && qrDetail && (maintenancePassed || hasProfile)" class="space-y-6">
  820. <!-- Link type display -->
  821. <div v-if="isLink && hasProfile && !isEditing"
  822. class="rounded-3xl border border-white/10 bg-white text-slate-900 shadow-2xl shadow-cyan-500/10 relative">
  823. <div class="space-y-6 p-6">
  824. <!-- Jump URL card -->
  825. <div
  826. class="rounded-2xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-5 transition hover:shadow-md">
  827. <div class="flex items-center gap-3">
  828. <div
  829. class="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 shadow-inner">
  830. <i class="pi pi-link text-lg text-emerald-600" />
  831. </div>
  832. <p class="text-[11px] font-semibold uppercase tracking-[0.35em] text-slate-500">
  833. JUMP URL
  834. </p>
  835. </div>
  836. <div class="mt-3">
  837. <p v-if="profile.jumpUrl"
  838. class="cursor-pointer text-lg font-semibold text-slate-800 break-all mb-4"
  839. title="Click to copy link" @click="copyInfo(profile.jumpUrl, 'Link')">
  840. {{ profile.jumpUrl }}
  841. </p>
  842. <p v-else class="text-sm text-slate-500 mb-4">Not set</p>
  843. <button v-if="profile.jumpUrl" type="button"
  844. class="inline-flex w-full items-center justify-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700 transition hover:bg-emerald-100"
  845. @click="() => window.open(profile.jumpUrl, '_blank')">
  846. <i class="pi pi-external-link" /> Open link
  847. </button>
  848. </div>
  849. </div>
  850. <!-- Remark card -->
  851. <div v-if="profile.remark"
  852. class="rounded-2xl border border-cyan-200/50 bg-gradient-to-br from-cyan-50/80 to-white p-5">
  853. <div class="flex items-center gap-2">
  854. <div class="flex h-8 w-8 items-center justify-center rounded-full bg-cyan-100">
  855. <i class="pi pi-info-circle text-sm text-cyan-600" />
  856. </div>
  857. <p class="text-xs font-medium uppercase tracking-wider text-slate-500">REMARK</p>
  858. </div>
  859. <div class="mt-3">
  860. <p class="whitespace-pre-wrap text-sm leading-relaxed text-slate-700">
  861. {{ profile.remark }}
  862. </p>
  863. </div>
  864. </div>
  865. <!-- Action buttons -->
  866. <div v-if="!isEditing" class="pt-4 space-y-3">
  867. <button type="button"
  868. 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"
  869. @click="handleOpenScanRecords">
  870. <i class="pi pi-history text-xs" />
  871. SCAN RECORDS
  872. </button>
  873. <button type="button"
  874. class="w-full flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 transition-all duration-200 hover:border-slate-400 hover:bg-slate-50 hover:shadow-md"
  875. @click="showMaintenanceDialog = true">
  876. <i class="pi pi-pencil text-xs" />
  877. EDIT INFORMATION
  878. </button>
  879. </div>
  880. </div>
  881. </div>
  882. <!-- Other types display -->
  883. <div v-else-if="hasProfile && !isEditing && !isLink"
  884. class="rounded-3xl border border-white/10 bg-white text-slate-900 shadow-2xl shadow-cyan-500/10 relative">
  885. <template v-if="hasProfile">
  886. <div class="space-y-6 p-6">
  887. <div class="flex flex-col items-center gap-4">
  888. <div class="relative w-full max-w-md overflow-hidden rounded-2xl bg-slate-100 ring-2 ring-slate-200"
  889. style="aspect-ratio: 1/1;">
  890. <img v-if="profile?.photoUrl" :src="profile.photoUrl" alt="profile"
  891. class="h-full w-full object-cover transition-transform duration-300 hover:scale-105"
  892. @error="(e) => e.target.style.display = 'none'" />
  893. <div v-else class="flex h-full w-full flex-col items-center justify-center gap-2 text-slate-400">
  894. <i class="pi pi-image text-4xl" />
  895. <span class="text-sm">No photo</span>
  896. </div>
  897. </div>
  898. <p class="text-xs uppercase tracking-[0.4em] text-slate-400">
  899. {{ qrCode }}
  900. </p>
  901. </div>
  902. <div class="space-y-4">
  903. <div>
  904. <p class="mt-1 text-sm text-slate-500">
  905. {{ isPerson ? 'Emergency contact card' : isGoods ? 'Item information' : 'Pet information' }}
  906. </p>
  907. <p class="text-2xl font-semibold text-slate-900">
  908. {{ profile?.name || 'Unnamed' }}
  909. </p>
  910. </div>
  911. <div
  912. class="rounded-2xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-5 transition hover:shadow-md">
  913. <div class="flex items-center gap-3">
  914. <div
  915. class="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 shadow-inner">
  916. <i class="pi pi-user text-lg text-emerald-600" />
  917. </div>
  918. <p class="text-[11px] font-semibold uppercase tracking-[0.35em] text-slate-500">{{ isPerson ?
  919. 'EMERGENCY CONTACT' : 'CONTACT' }}</p>
  920. </div>
  921. <p class="mt-3 cursor-pointer text-3xl font-semibold leading-tight text-slate-800"
  922. title="Click to copy name"
  923. @click="copyInfo(isPerson ? profile?.emergencyContactName : profile?.contactName, 'Name')">
  924. {{ isPerson ? profile?.emergencyContactName || '-' : profile?.contactName || '-' }}
  925. </p>
  926. <div class="mt-4 space-y-3 text-sm text-slate-600">
  927. <div class="flex items-center gap-3">
  928. <i class="pi pi-phone text-lg text-slate-500" />
  929. <div class="leading-tight cursor-pointer select-text text-slate-600"
  930. title="Click to copy phone"
  931. @click="copyInfo(isPerson ? profile?.emergencyContactPhone : profile?.contactPhone, 'Phone')">
  932. <p class="text-[10px] uppercase tracking-[0.4em] text-slate-400">PHONE</p>
  933. <p class="text-base font-semibold text-slate-700">
  934. {{ isPerson ? profile?.emergencyContactPhone || '-' : profile?.contactPhone || '-' }}
  935. </p>
  936. </div>
  937. </div>
  938. <div v-if="(isPerson ? profile?.emergencyContactEmail : profile?.contactEmail)"
  939. class="flex items-center gap-3">
  940. <i class="pi pi-envelope text-lg text-slate-500" />
  941. <div class="leading-tight min-w-0 cursor-pointer select-text text-slate-600"
  942. title="Click to copy email"
  943. @click="copyInfo(isPerson ? profile?.emergencyContactEmail : profile?.contactEmail, 'Email')">
  944. <p class="text-[10px] uppercase tracking-[0.4em] text-slate-400">EMAIL</p>
  945. <p class="truncate text-base font-semibold text-slate-700">
  946. {{ isPerson ? profile?.emergencyContactEmail : profile?.contactEmail }}
  947. </p>
  948. </div>
  949. </div>
  950. <div v-if="profile?.location" class="flex items-start gap-3">
  951. <i class="pi pi-map-marker text-lg text-slate-500 mt-0.5 flex-shrink-0" />
  952. <div class="leading-tight cursor-pointer select-text text-slate-600"
  953. title="Click to copy address" @click="copyInfo(profile?.location, 'Address')">
  954. <p class="text-[10px] uppercase tracking-[0.4em] text-slate-400">LOCATION</p>
  955. <p class="flex-1 break-words whitespace-normal text-base font-medium text-slate-700">
  956. {{ profile.location }}
  957. </p>
  958. </div>
  959. </div>
  960. </div>
  961. <div class="mt-4 grid grid-cols-2 gap-2">
  962. <button v-if="profile?.emergencyContactPhone || profile?.contactPhone" type="button"
  963. class="inline-flex w-full items-center justify-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700 transition hover:bg-emerald-100"
  964. @click="callNumber(isPerson ? profile?.emergencyContactPhone : profile?.contactPhone)">
  965. <i class="pi pi-phone" /> Call
  966. </button>
  967. <button
  968. v-if="(isPerson && profile?.emergencyContactEmail) || (!isPerson && profile?.contactEmail)"
  969. type="button"
  970. class="inline-flex w-full items-center justify-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 transition hover:bg-blue-100"
  971. @click="sendEmail(isPerson ? profile?.emergencyContactEmail : profile?.contactEmail)">
  972. <i class="pi pi-envelope" /> Email
  973. </button>
  974. <button v-if="profile?.location" type="button"
  975. class="inline-flex w-full items-center justify-center gap-1.5 rounded-full border border-purple-200 bg-purple-50 px-3 py-1.5 text-xs font-medium text-purple-700 transition hover:bg-purple-100"
  976. @click="handleOpenLocationView">
  977. <i class="pi pi-map" /> Map
  978. </button>
  979. <button v-if="profile?.location" type="button"
  980. class="inline-flex w-full items-center justify-center gap-1.5 rounded-full border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 transition hover:bg-red-100"
  981. @click="openGoogleMaps">
  982. <i class="pi pi-external-link" /> Google Maps
  983. </button>
  984. </div>
  985. </div>
  986. <div class="rounded-2xl border border-cyan-200/50 bg-gradient-to-br from-cyan-50/80 to-white p-5">
  987. <div class="flex items-center gap-2">
  988. <div class="flex h-8 w-8 items-center justify-center rounded-full bg-cyan-100">
  989. <i class="pi pi-info-circle text-sm text-cyan-600" />
  990. </div>
  991. <p class="text-xs font-medium uppercase tracking-wider text-slate-500">{{ isPerson ? 'Additional notes' : isGoods ? 'Item notes' : 'Extra description' }}</p>
  992. </div>
  993. <div v-if="isPerson" class="mt-3">
  994. <p class="whitespace-pre-wrap text-sm leading-relaxed text-slate-700">
  995. {{ profile?.specialNote || 'No extra details' }}
  996. </p>
  997. </div>
  998. <div v-else class="mt-3">
  999. <div v-if="parseRemarkToArray(profile?.remark).length === 0" class="text-sm text-slate-500">
  1000. No extra details
  1001. </div>
  1002. <template v-else>
  1003. <div
  1004. v-for="(remarkItem, index) in parseRemarkToArray(profile?.remark)"
  1005. :key="index">
  1006. <p class="text-sm leading-relaxed text-slate-700 whitespace-pre-wrap">
  1007. {{ remarkItem }}
  1008. </p>
  1009. <div v-if="index < parseRemarkToArray(profile?.remark).length - 1" class="my-3 border-t border-cyan-200/50"></div>
  1010. </div>
  1011. </template>
  1012. </div>
  1013. </div>
  1014. <!-- Action buttons -->
  1015. <div v-if="!isEditing" class="pt-4 space-y-3">
  1016. <button type="button"
  1017. 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"
  1018. @click="handleOpenScanRecords">
  1019. <i class="pi pi-history text-xs" />
  1020. SCAN RECORDS
  1021. </button>
  1022. <button type="button"
  1023. class="w-full flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 transition-all duration-200 hover:border-slate-400 hover:bg-slate-50 hover:shadow-md"
  1024. @click="showMaintenanceDialog = true">
  1025. <i class="pi pi-pencil text-xs" />
  1026. EDIT INFORMAION
  1027. </button>
  1028. </div>
  1029. </div>
  1030. </div>
  1031. </template>
  1032. </div>
  1033. </div>
  1034. </section>
  1035. <section v-if="qrCode && maintenancePassed" id="scan-form-section"
  1036. class="rounded-3xl border border-white/10 bg-white p-6 text-slate-900 shadow-2xl shadow-cyan-500/10">
  1037. <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
  1038. <div>
  1039. <p class="text-xs uppercase tracking-[0.3em] text-slate-400">{{ isLink ? 'Link setup' : 'Profile setup' }}</p>
  1040. <h2 class="mt-1 text-2xl font-semibold text-slate-900">
  1041. {{ isLink ? (hasProfile ? 'Update link' : 'First-time setup') : (hasProfile ? 'Update profile' : 'First-time setup') }}
  1042. </h2>
  1043. </div>
  1044. <p class="text-sm text-slate-500">
  1045. QR Code: <span class="font-mono text-slate-700">{{ qrCode }}</span>
  1046. </p>
  1047. </div>
  1048. <div v-if="isFirstFill" class="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
  1049. <div class="flex items-start gap-3">
  1050. <i class="pi pi-info-circle text-emerald-600" />
  1051. <div class="flex-1 text-sm text-emerald-700">
  1052. <p class="font-semibold">First-time tip</p>
  1053. <p class="mt-1">
  1054. This is the first record for this QR code. Once submitted, it becomes active. Keep the maintenance
  1055. code
  1056. safe for future edits.
  1057. </p>
  1058. </div>
  1059. </div>
  1060. </div>
  1061. <div v-else class="mt-4 rounded-2xl border border-cyan-200 bg-cyan-50 p-4">
  1062. <div class="flex items-start gap-3">
  1063. <i class="pi pi-check-circle text-cyan-600" />
  1064. <div class="flex-1 text-sm text-cyan-700">
  1065. <p class="font-semibold">Edit mode</p>
  1066. <p class="mt-1">
  1067. Maintenance code verified. You can now edit the profile—remember to save your changes.
  1068. </p>
  1069. </div>
  1070. </div>
  1071. </div>
  1072. <form class="mt-6 space-y-6" @submit.prevent="handleSaveProfile">
  1073. <!-- Link type form -->
  1074. <template v-if="isLink">
  1075. <div class="space-y-2">
  1076. <label class="block text-sm font-medium text-slate-700">
  1077. Jump URL
  1078. </label>
  1079. <div class="flex gap-2">
  1080. <input v-model="formData.jumpUrl" type="url" placeholder="https://example.com"
  1081. maxlength="2000"
  1082. class="flex-1 rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 placeholder:text-slate-400 transition-all duration-200 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20" />
  1083. <button v-if="isFirstFill" type="button"
  1084. class="flex-shrink-0 rounded-lg border border-cyan-500 bg-cyan-500 px-3 py-2 text-xs font-medium text-white shadow-sm transition-all duration-200 hover:bg-cyan-600 hover:border-cyan-600 hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-cyan-500 disabled:hover:shadow-sm whitespace-nowrap"
  1085. :disabled="!formData.jumpUrl?.trim()"
  1086. @click="testJumpUrl">
  1087. <i class="pi pi-check-circle mr-1 text-xs" />
  1088. Validate
  1089. </button>
  1090. </div>
  1091. <p class="text-xs text-slate-500">
  1092. Optional, max 2000 characters, must be a valid URL (must start with http:// or https://)
  1093. <span v-if="isFirstFill" class="text-cyan-600"> • Click "Validate" to verify the URL format</span>
  1094. </p>
  1095. </div>
  1096. <div class="space-y-2">
  1097. <label class="block text-sm font-medium text-slate-700">
  1098. Remark
  1099. </label>
  1100. <textarea v-model="formData.linkRemark" placeholder="Enter remark information" maxlength="500" rows="4"
  1101. class="w-full rounded-2xl border border-slate-300 bg-white px-5 py-3.5 text-base text-slate-900 placeholder:text-slate-400 transition-all duration-200 focus:border-cyan-400 focus:outline-none focus:ring-2 focus:ring-cyan-400/30 resize-none"></textarea>
  1102. <p class="text-xs text-slate-500">
  1103. Optional, max 500 characters, current: {{ formData.linkRemark.length }}/500
  1104. </p>
  1105. </div>
  1106. </template>
  1107. <!-- Other types form -->
  1108. <template v-else>
  1109. <div class="space-y-4">
  1110. <div>
  1111. <p class="text-sm font-medium text-slate-700">Display photo / item image</p>
  1112. <p class="mt-1 text-xs text-slate-500">Recommended 640×640+, supports JPG/PNG, up to 15MB.</p>
  1113. </div>
  1114. <div
  1115. class="relative w-full max-w-md mx-auto overflow-hidden rounded-2xl bg-slate-100 ring-2 ring-slate-200"
  1116. style="aspect-ratio: 1/1;">
  1117. <img v-if="formData.photoUrl && !loading.photo" :src="formData.photoUrl" alt="profile"
  1118. class="h-full w-full object-cover transition-opacity duration-200" @error="handleImageError" />
  1119. <div v-if="loading.photo" class="flex h-full w-full items-center justify-center">
  1120. <i class="pi pi-spin pi-spinner text-4xl text-slate-400" />
  1121. </div>
  1122. <div v-else-if="!formData.photoUrl"
  1123. class="flex h-full w-full flex-col items-center justify-center gap-2 text-slate-400">
  1124. <i class="pi pi-image text-5xl" />
  1125. <span class="text-sm">No image</span>
  1126. </div>
  1127. </div>
  1128. <input ref="photoInputRef" type="file" accept="image/jpeg,image/jpg,image/png,image/webp" class="hidden"
  1129. @change="handlePhotoChange" />
  1130. <div class="flex justify-center gap-3">
  1131. <button type="button"
  1132. class="rounded-full border border-slate-300 bg-white px-6 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:opacity-50"
  1133. :disabled="loading.photo" @click="triggerPhotoPicker">
  1134. <i class="pi mr-2" :class="loading.photo ? 'pi-spin pi-spinner' : 'pi-upload'" />
  1135. {{ loading.photo ? 'Uploading...' : 'Upload image' }}
  1136. </button>
  1137. <button v-if="formData.photoUrl" type="button"
  1138. class="rounded-full border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100"
  1139. @click="formData.photoUrl = ''">
  1140. <i class="pi pi-times mr-1" />
  1141. Clear image
  1142. </button>
  1143. </div>
  1144. </div>
  1145. <div class="grid gap-4 md:grid-cols-2">
  1146. <label class="space-y-2 text-sm">
  1147. <span class="text-slate-500">{{ isPerson ? 'Name' : isGoods ? 'Item name' : 'Pet name' }}</span>
  1148. <input v-model="formData.name" type="text" class="w-full rounded-2xl border border-slate-200 px-4 py-3"
  1149. :placeholder="isPerson ? 'e.g. John Doe' : isGoods ? 'e.g. MacBook Pro' : 'e.g. Snowy'" required />
  1150. </label>
  1151. <label v-if="isPerson" class="space-y-2 text-sm">
  1152. <span class="text-slate-500">Gender</span>
  1153. <div class="flex gap-2">
  1154. <button v-for="option in [
  1155. { label: 'Male', value: 'male' },
  1156. { label: 'Female', value: 'female' },
  1157. { label: 'Prefer not to say', value: 'unknown' }
  1158. ]" :key="option.value" type="button" class="flex-1 rounded-2xl border px-4 py-3 text-sm"
  1159. :class="formData.gender === option.value ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 text-slate-500'"
  1160. @click="formData.gender = option.value">
  1161. {{ option.label }}
  1162. </button>
  1163. </div>
  1164. </label>
  1165. </div>
  1166. <div v-if="isPerson" class="grid gap-4 md:grid-cols-2">
  1167. <label class="space-y-2 text-sm">
  1168. <span class="text-slate-500">Owner phone</span>
  1169. <input v-model="formData.phone" type="tel" class="w-full rounded-2xl border border-slate-200 px-4 py-3"
  1170. required />
  1171. </label>
  1172. <label class="space-y-2 text-sm">
  1173. <span class="text-slate-500">Emergency contact name</span>
  1174. <input v-model="formData.emergencyContactName" type="text"
  1175. class="w-full rounded-2xl border border-slate-200 px-4 py-3" required />
  1176. </label>
  1177. <label class="space-y-2 text-sm">
  1178. <span class="text-slate-500">Emergency contact phone</span>
  1179. <input v-model="formData.emergencyContactPhone" type="tel"
  1180. class="w-full rounded-2xl border border-slate-200 px-4 py-3" required />
  1181. </label>
  1182. <label class="space-y-2 text-sm md:col-span-2">
  1183. <span class="text-slate-500">Emergency contact email</span>
  1184. <input v-model="formData.emergencyContactEmail" type="email"
  1185. class="w-full rounded-2xl border border-slate-200 px-4 py-3" placeholder="Optional" />
  1186. </label>
  1187. <label class="space-y-2 text-sm md:col-span-2">
  1188. <span class="text-slate-500">Address</span>
  1189. <div class="flex gap-2">
  1190. <input v-model="formData.location" type="text" readonly
  1191. class="flex-1 rounded-2xl border border-slate-200 px-4 py-3 bg-slate-50 cursor-pointer"
  1192. placeholder="Click to select address" @click="handleOpenLocationDialog" />
  1193. <button type="button"
  1194. class="rounded-2xl border border-slate-300 bg-white px-6 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-50 whitespace-nowrap"
  1195. @click="handleOpenLocationDialog">
  1196. <i class="pi pi-map-marker mr-2" />
  1197. Select address
  1198. </button>
  1199. </div>
  1200. </label>
  1201. </div>
  1202. <div v-else class="grid gap-4 md:grid-cols-2">
  1203. <label class="space-y-2 text-sm">
  1204. <span class="text-slate-500">Contact name</span>
  1205. <input v-model="formData.contactName" type="text"
  1206. class="w-full rounded-2xl border border-slate-200 px-4 py-3" required />
  1207. </label>
  1208. <label class="space-y-2 text-sm">
  1209. <span class="text-slate-500">Contact phone</span>
  1210. <input v-model="formData.contactPhone" type="tel"
  1211. class="w-full rounded-2xl border border-slate-200 px-4 py-3" required />
  1212. </label>
  1213. <label class="space-y-2 text-sm md:col-span-2">
  1214. <span class="text-slate-500">Contact email</span>
  1215. <input v-model="formData.contactEmail" type="email"
  1216. class="w-full rounded-2xl border border-slate-200 px-4 py-3" placeholder="Optional" />
  1217. </label>
  1218. <label class="space-y-2 text-sm md:col-span-2">
  1219. <span class="text-slate-500">Contact address</span>
  1220. <div class="flex gap-2">
  1221. <input v-model="formData.location" type="text" readonly
  1222. class="flex-1 rounded-2xl border border-slate-200 px-4 py-3 bg-slate-50 cursor-pointer"
  1223. placeholder="Click to select address" @click="handleOpenLocationDialog" />
  1224. <button type="button"
  1225. class="rounded-2xl border border-slate-300 bg-white px-6 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-50 whitespace-nowrap"
  1226. @click="handleOpenLocationDialog">
  1227. <i class="pi pi-map-marker mr-2" />
  1228. Select address
  1229. </button>
  1230. </div>
  1231. </label>
  1232. </div>
  1233. <label v-if="isPerson" class="block space-y-2 text-sm">
  1234. <span class="text-slate-500">Additional notes / health tips</span>
  1235. <textarea v-model="formData.specialNote" rows="4"
  1236. class="w-full rounded-2xl border border-slate-200 px-4 py-3"
  1237. placeholder="e.g. allergies, medical needs, carried items" />
  1238. </label>
  1239. <div v-else class="space-y-3">
  1240. <label class="block text-sm">
  1241. <span class="text-slate-500">{{ isGoods ? 'Item notes' : 'Extra remarks' }}</span>
  1242. <p class="mt-1 text-xs text-slate-400">You can add up to 4 remarks</p>
  1243. </label>
  1244. <div class="space-y-3">
  1245. <div v-for="index in getRemarkIndexes()" :key="index" class="flex gap-2 items-start">
  1246. <div class="flex-1">
  1247. <textarea
  1248. v-model="formData.remark[index.toString()]"
  1249. rows="3"
  1250. class="w-full rounded-2xl border border-slate-200 px-4 py-3 resize-y min-h-[3rem]"
  1251. :placeholder="isGoods ? `Item note ${index} (e.g. item features, usage notes)` : `Remark ${index} (e.g. pet habits, health info)`" />
  1252. </div>
  1253. <button
  1254. v-if="index > 1 || (index === 1 && getRemarkIndexes().length > 1)"
  1255. type="button"
  1256. 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"
  1257. @click="removeRemarkField(index)">
  1258. <i class="pi pi-times" />
  1259. </button>
  1260. </div>
  1261. </div>
  1262. <button
  1263. v-if="getRemarkIndexes().length < 4"
  1264. type="button"
  1265. 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"
  1266. @click="addRemarkField">
  1267. <i class="pi pi-plus mr-2" />
  1268. Add remark
  1269. </button>
  1270. </div>
  1271. <div class="rounded-2xl border border-slate-200 bg-slate-50 p-5">
  1272. <div class="flex items-center justify-between">
  1273. <div class="flex-1">
  1274. <p class="text-sm font-medium text-slate-700">Visibility</p>
  1275. <p class="mt-1 text-xs text-slate-500">When disabled, others will not be able to view information when scanning this QR code</p>
  1276. </div>
  1277. <label class="relative inline-flex items-center cursor-pointer">
  1278. <input v-model="formData.isVisible" type="checkbox" class="sr-only peer" />
  1279. <div class="w-11 h-6 bg-slate-300 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-cyan-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-cyan-600"></div>
  1280. </label>
  1281. </div>
  1282. </div>
  1283. </template>
  1284. <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
  1285. <p class="text-sm text-slate-500">
  1286. Submitted data will sync to the public scan page.
  1287. </p>
  1288. <button type="submit"
  1289. class="rounded-full bg-slate-900 px-8 py-3 text-sm font-semibold text-white hover:bg-slate-800"
  1290. :disabled="loading.saving">
  1291. {{ loading.saving ? 'Saving...' : (isLink ? (hasProfile ? 'Save changes' : 'Submit link') : (hasProfile ? 'Save changes' : 'Submit profile')) }}
  1292. </button>
  1293. </div>
  1294. </form>
  1295. </section>
  1296. <footer class="mt-8 border-t border-white/10 pt-6 text-center">
  1297. <p class="text-xs text-slate-400">
  1298. Data is used solely for emergency contact scenarios. Uploading means you consent to display it when scanned.
  1299. </p>
  1300. <p class="mt-2 text-xs text-slate-500">
  1301. <i class="pi pi-shield text-cyan-400" /> Your privacy stays protected
  1302. </p>
  1303. </footer>
  1304. </div>
  1305. </div>
  1306. <!-- Location picker dialog -->
  1307. <LocationPicker v-model="showLocationDialog" :initial-location="formData.location" @save="handleSaveLocation" />
  1308. <!-- Location preview dialog (read-only) -->
  1309. <LocationPicker v-model="showLocationViewDialog" :initial-location="profile?.location" :closable="true"
  1310. :readonly="true" />
  1311. <!-- Scan records dialog -->
  1312. <Dialog v-model:visible="showScanRecordsDialog" modal :closable="true" :closeOnEscape="true"
  1313. :dismissableMask="true" :style="{ width: '90vw', maxWidth: '800px' }" :draggable="false">
  1314. <template #header>
  1315. <div class="flex items-center gap-3">
  1316. <div class="flex h-10 w-10 items-center justify-center rounded-full bg-cyan-100">
  1317. <i class="pi pi-history text-lg text-cyan-600" />
  1318. </div>
  1319. <div>
  1320. <h3 class="text-lg font-semibold text-slate-900">Recent Scan Records</h3>
  1321. <p class="text-sm text-slate-500">
  1322. QR Code: <span class="font-mono">{{ qrCode }}</span>
  1323. </p>
  1324. </div>
  1325. </div>
  1326. </template>
  1327. <div class="space-y-4 py-4">
  1328. <div v-if="loadingScanRecords" class="flex items-center justify-center py-8">
  1329. <i class="pi pi-spin pi-spinner text-3xl text-slate-400" />
  1330. <span class="ml-3 text-slate-600">Loading...</span>
  1331. </div>
  1332. <div v-else-if="scanRecords">
  1333. <div v-if="scanRecords.records && scanRecords.records.length > 0" class="space-y-3 max-h-[60vh] overflow-y-auto">
  1334. <div v-for="record in scanRecords.records" :key="record.id"
  1335. class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm hover:shadow-md transition-shadow">
  1336. <div class="flex items-start justify-between gap-4">
  1337. <div class="flex-1 space-y-2">
  1338. <div class="flex items-center gap-2">
  1339. <i class="pi pi-clock text-sm text-slate-500" />
  1340. <span class="text-sm font-medium text-slate-700">Scan Time</span>
  1341. <span class="text-sm text-slate-600">{{ formatDateTime(record.scanTime) }}</span>
  1342. </div>
  1343. <div v-if="record.address" class="flex items-start gap-2">
  1344. <i class="pi pi-map-marker text-sm text-slate-500 mt-0.5" />
  1345. <div class="flex-1">
  1346. <span class="text-sm font-medium text-slate-700">Address</span>
  1347. <p class="text-sm text-slate-600 break-words">{{ record.address }}</p>
  1348. </div>
  1349. <button v-if="record.latitude && record.longitude" type="button"
  1350. class="ml-2 flex-shrink-0 rounded-lg border border-cyan-200 bg-cyan-50 px-2 py-1 text-xs text-cyan-700 hover:bg-cyan-100 transition"
  1351. @click="openRecordLocation(record)">
  1352. <i class="pi pi-external-link mr-1" />
  1353. Map
  1354. </button>
  1355. </div>
  1356. <div v-if="record.latitude && record.longitude" class="flex items-center gap-2">
  1357. <i class="pi pi-globe text-sm text-slate-500" />
  1358. <span class="text-sm text-slate-600">
  1359. Coordinates: {{ record.latitude }}, {{ record.longitude }}
  1360. </span>
  1361. </div>
  1362. <div v-if="record.ipAddress" class="flex items-center gap-2">
  1363. <i class="pi pi-desktop text-sm text-slate-500" />
  1364. <span class="text-sm text-slate-600">IP: {{ record.ipAddress }}</span>
  1365. </div>
  1366. <div v-if="record.userAgent" class="flex items-start gap-2">
  1367. <i class="pi pi-browser text-sm text-slate-500 mt-0.5" />
  1368. <div class="flex-1">
  1369. <span class="text-sm font-medium text-slate-700">Device Info</span>
  1370. <p class="text-xs text-slate-500 break-words mt-0.5">{{ record.userAgent }}</p>
  1371. </div>
  1372. </div>
  1373. </div>
  1374. </div>
  1375. </div>
  1376. </div>
  1377. <div v-else class="rounded-xl border border-slate-200 bg-slate-50 p-8 text-center">
  1378. <i class="pi pi-inbox text-4xl text-slate-400 mb-3" />
  1379. <p class="text-sm text-slate-600">No scan records</p>
  1380. </div>
  1381. </div>
  1382. <div v-else class="rounded-xl border border-red-200 bg-red-50 p-4">
  1383. <div class="flex items-center gap-2">
  1384. <i class="pi pi-exclamation-triangle text-red-600" />
  1385. <span class="text-sm text-red-700">Failed to load, please try again later</span>
  1386. </div>
  1387. </div>
  1388. </div>
  1389. <template #footer>
  1390. <div class="flex justify-end gap-3">
  1391. <button type="button"
  1392. class="rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
  1393. @click="showScanRecordsDialog = false">
  1394. Close
  1395. </button>
  1396. <button type="button"
  1397. 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"
  1398. :disabled="loadingScanRecords || !verifiedMaintenanceCode" @click="fetchScanRecords(verifiedMaintenanceCode)">
  1399. <i class="pi pi-refresh mr-2" :class="{ 'pi-spin': loadingScanRecords }" />
  1400. Refresh
  1401. </button>
  1402. </div>
  1403. </template>
  1404. </Dialog>
  1405. <!-- Scan records maintenance code dialog -->
  1406. <Dialog v-model:visible="showScanRecordsMaintenanceDialog" modal :closable="true" :closeOnEscape="true"
  1407. :dismissableMask="true" :style="{ width: '90vw', maxWidth: '450px' }" :draggable="false">
  1408. <template #header>
  1409. <div class="flex items-center gap-3">
  1410. <div class="flex h-10 w-10 items-center justify-center rounded-full bg-cyan-100">
  1411. <i class="pi pi-lock text-lg text-cyan-600" />
  1412. </div>
  1413. <div>
  1414. <h3 class="text-lg font-semibold text-slate-900">Verification required</h3>
  1415. <p class="text-sm text-slate-500">
  1416. Enter the maintenance code to view scan records.
  1417. </p>
  1418. </div>
  1419. </div>
  1420. </template>
  1421. <div class="space-y-4 py-4">
  1422. <div>
  1423. <label class="mb-2 block text-sm font-medium text-slate-700">Maintenance code</label>
  1424. <input v-model="scanRecordsMaintenanceCode" type="text" maxlength="8"
  1425. 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"
  1426. placeholder="Enter the maintenance code" @keyup.enter="handleVerifyScanRecordsMaintenance" autofocus />
  1427. </div>
  1428. </div>
  1429. <template #footer>
  1430. <div class="flex justify-end gap-3">
  1431. <button type="button"
  1432. class="rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
  1433. @click="() => { showScanRecordsMaintenanceDialog = false; scanRecordsMaintenanceCode = '' }">
  1434. Cancel
  1435. </button>
  1436. <button type="button"
  1437. 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"
  1438. :disabled="loadingScanRecords || !scanRecordsMaintenanceCode" @click="handleVerifyScanRecordsMaintenance">
  1439. {{ loadingScanRecords ? 'Verifying...' : 'Verify' }}
  1440. </button>
  1441. </div>
  1442. </template>
  1443. </Dialog>
  1444. <!-- Maintenance code dialog -->
  1445. <Dialog v-model:visible="showMaintenanceDialog" modal :closable="!isFirstFill" :closeOnEscape="!isFirstFill"
  1446. :dismissableMask="!isFirstFill" :style="{ width: '90vw', maxWidth: '450px' }" :draggable="false">
  1447. <template #header>
  1448. <div class="flex items-center gap-3">
  1449. <div class="flex h-10 w-10 items-center justify-center rounded-full bg-cyan-100">
  1450. <i class="pi pi-lock text-lg text-cyan-600" />
  1451. </div>
  1452. <div>
  1453. <h3 class="text-lg font-semibold text-slate-900">
  1454. {{ isFirstFill ? 'Verification required for first use' : 'Verification required to edit' }}
  1455. </h3>
  1456. <p class="text-sm text-slate-500">
  1457. {{ isFirstFill ? 'Enter the maintenance code that came with the QR tag.' : 'Enter the maintenance code to unlock editing.' }}
  1458. </p>
  1459. </div>
  1460. </div>
  1461. </template>
  1462. <div class="space-y-4 py-4">
  1463. <div>
  1464. <label class="mb-2 block text-sm font-medium text-slate-700">Maintenance code</label>
  1465. <input v-model="maintenanceCode" type="text" maxlength="8"
  1466. 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"
  1467. placeholder="Enter the maintenance code" @keyup.enter="handleVerifyMaintenance" autofocus />
  1468. </div>
  1469. <div v-if="isFirstFill" class="rounded-xl border border-emerald-200 bg-emerald-50 p-3">
  1470. <div class="flex items-start gap-2">
  1471. <i class="pi pi-info-circle text-sm text-emerald-600" />
  1472. <p class="text-xs text-emerald-700">
  1473. This is the first record for this QR code. Verify to begin entering details.
  1474. </p>
  1475. </div>
  1476. </div>
  1477. </div>
  1478. <template #footer>
  1479. <div class="flex justify-end gap-3">
  1480. <button v-if="!isFirstFill" type="button"
  1481. class="rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
  1482. @click="showMaintenanceDialog = false">
  1483. Cancel
  1484. </button>
  1485. <button type="button"
  1486. 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"
  1487. :disabled="loading.verifying || !maintenanceCode" @click="handleVerifyMaintenance">
  1488. {{ loading.verifying ? 'Verifying...' : 'Verify' }}
  1489. </button>
  1490. </div>
  1491. </template>
  1492. </Dialog>
  1493. </div>
  1494. </template>
  1495. <style scoped>
  1496. .scan-page {
  1497. background-image: radial-gradient(circle at 20% 20%, rgba(14, 165, 233, 0.12), transparent 45%),
  1498. radial-gradient(circle at 80% 0%, rgba(129, 140, 248, 0.12), transparent 50%),
  1499. linear-gradient(135deg, #020617, #030712);
  1500. }
  1501. .fade-enter-active,
  1502. .fade-leave-active {
  1503. transition: opacity 0.3s ease;
  1504. }
  1505. .fade-enter-from,
  1506. .fade-leave-to {
  1507. opacity: 0;
  1508. }
  1509. </style>