Ver Fonte

用户二维码管理

wuyi há 2 semanas atrás
pai
commit
2a6dbeb4e3

+ 8 - 48
src/services/api.js

@@ -34,7 +34,6 @@ api.interceptors.response.use(
   }
 )
 
-// 兼容旧接口(使用name)
 export const login = async (name, password) => {
   const response = await api.post('/users/login', {
     name,
@@ -43,15 +42,6 @@ export const login = async (name, password) => {
   return response.data
 }
 
-// 使用EMAIL登录
-export const loginWithEmail = async (email, password) => {
-  const response = await api.post('/users/login', {
-    email,
-    password
-  })
-  return response.data
-}
-
 export const profile = async () => {
   const response = await api.get('/users/profile')
   return response.data
@@ -62,9 +52,8 @@ export const resetPasswordApi = async (password) => {
     password
   })
   return response.data
-} 
+}
 
-// 兼容旧接口(使用name)
 export const register = async (name, password) => {
   const response = await api.post('/users/register', {
     password,
@@ -73,15 +62,6 @@ export const register = async (name, password) => {
   return response.data
 }
 
-// 使用EMAIL注册
-export const registerWithEmail = async (email, password) => {
-  const response = await api.post('/users/register', {
-    email,
-    password
-  })
-  return response.data
-}
-
 export const uploadFile = async (file) => {
   const formData = new FormData()
   formData.append('file', file)
@@ -149,36 +129,16 @@ export const fetchRecentScanRecordsApi = async (qrCode, maintenanceCode) => {
   return response.data
 }
 
-// 忘记密码相关接口
-// 发送验证码到邮箱
-export const sendVerificationCodeApi = async (email) => {
-  const response = await api.post('/users/forgot-password/send-code', {
-    email
-  })
-  return response.data
-}
-
-// 验证验证码
-export const verifyCodeApi = async (email, code) => {
-  const response = await api.post('/users/forgot-password/verify-code', {
-    email,
-    code
-  })
-  return response.data
-}
-
-// 通过验证码重置密码
-export const resetPasswordByCodeApi = async (email, code, newPassword) => {
-  const response = await api.post('/users/forgot-password/reset', {
-    email,
-    code,
-    password: newPassword
+// 获取用户自己的二维码列表
+export const fetchMyQrCodesApi = async (page = 0, pageSize = 20) => {
+  const response = await api.get('/qr/my', {
+    params: { page, pageSize }
   })
   return response.data
 }
 
-// 获取用户自己的二维码列表
-export const fetchMyQrCodesApi = async () => {
-  const response = await api.get('/qr/my')
+// 绑定二维码
+export const bindQrCodeApi = async (payload) => {
+  const response = await api.post('/qr/bind', payload)
   return response.data
 }

+ 1 - 23
src/stores/user.js

@@ -3,10 +3,8 @@ import { ref } from 'vue'
 import { useStorage } from '@vueuse/core'
 import {
   login as apiLogin,
-  loginWithEmail,
   profile,
-  register as apiRegister,
-  registerWithEmail
+  register as apiRegister
 } from '@/services/api'
 
 export const useUserStore = defineStore('user', () => {
@@ -32,15 +30,6 @@ export const useUserStore = defineStore('user', () => {
     return response
   }
 
-  // 使用EMAIL登录
-  const loginWithEmailStore = async (email, password) => {
-    const response = await loginWithEmail(email, password)
-    setToken(response?.token)
-    const userProfile = await profile()
-    setUserInfo(userProfile)
-    return response
-  }
-
   // 兼容旧接口(使用用户名)
   const register = async (username, password) => {
     const response = await apiRegister(username, password)
@@ -50,15 +39,6 @@ export const useUserStore = defineStore('user', () => {
     return response
   }
 
-  // 使用EMAIL注册
-  const registerWithEmailStore = async (email, password) => {
-    const response = await registerWithEmail(email, password)
-    setToken(response?.token)
-    const userProfile = await profile()
-    setUserInfo(userProfile)
-    return response
-  }
-
   const sync = async () => {
     const response = await profile()
     setUserInfo(response)
@@ -75,9 +55,7 @@ export const useUserStore = defineStore('user', () => {
     setToken,
     setUserInfo,
     login,
-    loginWithEmail: loginWithEmailStore,
     register,
-    registerWithEmail: registerWithEmailStore,
     logout,
     sync
   }

+ 35 - 35
src/views/qrmanager/AuthView.vue

@@ -31,37 +31,37 @@ const qrCode = computed(() => route.query.qrCode?.toString() || '')
 
 // 密码验证
 const validatePassword = (password) => {
-  if (password.length < 8) return '密码长度必须至少8位'
-  if (!/[a-z]/.test(password)) return '密码必须包含小写字母'
-  if (!/[A-Z]/.test(password)) return '密码必须包含大写字母'
-  if (!/[0-9]/.test(password)) return '密码必须包含数字'
+  if (password.length < 8) return 'Password must be at least 8 characters'
+  if (!/[a-z]/.test(password)) return 'Password must contain lowercase letters'
+  if (!/[A-Z]/.test(password)) return 'Password must contain uppercase letters'
+  if (!/[0-9]/.test(password)) return 'Password must contain numbers'
   return ''
 }
 
 // 注册
 const handleRegister = async () => {
   if (!registerName.value.trim()) {
-    toast.add({ severity: 'warn', summary: '提示', detail: '请输入用户名', life: 2500 })
+    toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter username', life: 2500 })
     return
   }
   if (!registerPassword.value) {
-    toast.add({ severity: 'warn', summary: '提示', detail: '请输入密码', life: 2500 })
+    toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter password', life: 2500 })
     return
   }
   const pwdErr = validatePassword(registerPassword.value)
   if (pwdErr) {
-    toast.add({ severity: 'warn', summary: '校验失败', detail: pwdErr, life: 3000 })
+    toast.add({ severity: 'warn', summary: 'Validation Failed', detail: pwdErr, life: 3000 })
     return
   }
   if (registerPassword.value !== registerConfirmPassword.value) {
-    toast.add({ severity: 'warn', summary: '校验失败', detail: '两次输入的密码不一致', life: 3000 })
+    toast.add({ severity: 'warn', summary: 'Validation Failed', detail: 'Passwords do not match', life: 3000 })
     return
   }
 
   registerLoading.value = true
   try {
     await userStore.register(registerName.value.trim(), registerPassword.value)
-    toast.add({ severity: 'success', summary: '注册成功', detail: '已自动登录', life: 2000 })
+    toast.add({ severity: 'success', summary: 'Registration Successful', detail: 'Auto-logged in', life: 2000 })
     
     // 根据是否有二维码参数决定跳转
     if (qrCode.value) {
@@ -74,8 +74,8 @@ const handleRegister = async () => {
   } catch (e) {
     toast.add({
       severity: 'error',
-      summary: '注册失败',
-      detail: e?.message || e?.error || '用户名可能已被注册',
+      summary: 'Registration Failed',
+      detail: e?.message || e?.error || 'Username may already be registered',
       life: 3000
     })
   } finally {
@@ -86,18 +86,18 @@ const handleRegister = async () => {
 // 登录
 const handleLogin = async () => {
   if (!loginName.value.trim()) {
-    toast.add({ severity: 'warn', summary: '提示', detail: '请输入用户名', life: 2500 })
+    toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter username', life: 2500 })
     return
   }
   if (!loginPassword.value) {
-    toast.add({ severity: 'warn', summary: '提示', detail: '请输入密码', life: 2500 })
+    toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter password', life: 2500 })
     return
   }
 
   loginLoading.value = true
   try {
     await userStore.login(loginName.value.trim(), loginPassword.value)
-    toast.add({ severity: 'success', summary: '登录成功', detail: '欢迎回来', life: 2000 })
+    toast.add({ severity: 'success', summary: 'Login Successful', detail: 'Welcome back', life: 2000 })
     
     // 根据是否有二维码参数决定跳转
     if (qrCode.value) {
@@ -110,8 +110,8 @@ const handleLogin = async () => {
   } catch (e) {
     toast.add({
       severity: 'error',
-      summary: '登录失败',
-      detail: e?.message || e?.error || '用户名或密码错误',
+      summary: 'Login Failed',
+      detail: e?.message || e?.error || 'Incorrect username or password',
       life: 3000
     })
   } finally {
@@ -126,10 +126,10 @@ const handleLogin = async () => {
       <div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-lg">
         <!-- 标题 -->
         <div class="mb-6 text-center">
-          <div class="text-2xl font-bold text-slate-900">二维码管理</div>
-          <div class="mt-1 text-sm text-slate-500">注册或登录以管理您的二维码</div>
+          <div class="text-2xl font-bold text-slate-900">QR Code Management</div>
+          <div class="mt-1 text-sm text-slate-500">Register or login to manage your QR codes</div>
           <div v-if="qrCode" class="mt-2 rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-700">
-            检测到未激活的二维码:<span class="font-mono font-semibold">{{ qrCode }}</span>
+            Detected inactive QR code: <span class="font-mono font-semibold">{{ qrCode }}</span>
           </div>
         </div>
 
@@ -141,7 +141,7 @@ const handleLogin = async () => {
             :class="mode === 'login' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-600 hover:text-slate-900'"
             @click="mode = 'login'"
           >
-            登录
+            Login
           </button>
           <button
             type="button"
@@ -149,30 +149,30 @@ const handleLogin = async () => {
             :class="mode === 'register' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-600 hover:text-slate-900'"
             @click="mode = 'register'"
           >
-            注册
+            Register
           </button>
         </div>
 
         <!-- 登录表单 -->
         <form v-if="mode === 'login'" class="space-y-4" @submit.prevent="handleLogin">
           <div>
-            <label class="mb-1 block text-sm font-medium text-slate-700">用户名</label>
+            <label class="mb-1 block text-sm font-medium text-slate-700">Username</label>
             <input
               v-model="loginName"
               type="text"
               class="w-full rounded-xl border border-slate-200 px-3 py-2 outline-none focus:border-slate-900"
-              placeholder="请输入用户名"
+              placeholder="Enter username"
               autocomplete="username"
             />
           </div>
           <div>
-            <label class="mb-1 block text-sm font-medium text-slate-700">密码</label>
+            <label class="mb-1 block text-sm font-medium text-slate-700">Password</label>
             <div class="relative">
               <input
                 v-model="loginPassword"
                 :type="showLoginPassword ? 'text' : 'password'"
                 class="w-full rounded-xl border border-slate-200 px-3 py-2 pr-10 outline-none focus:border-slate-900"
-                placeholder="请输入密码"
+                placeholder="Enter password"
                 autocomplete="current-password"
               />
               <button
@@ -190,12 +190,12 @@ const handleLogin = async () => {
             class="w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 disabled:opacity-60"
             :disabled="loginLoading"
           >
-            {{ loginLoading ? '登录中...' : '登录' }}
+            {{ loginLoading ? 'Logging in...' : 'Login' }}
           </button>
 
           <div class="text-center text-sm">
             <RouterLink class="text-slate-600 hover:text-slate-900" :to="{ name: 'home' }">
-              返回扫码页
+              Return to Scan Page
             </RouterLink>
           </div>
         </form>
@@ -203,23 +203,23 @@ const handleLogin = async () => {
         <!-- 注册表单 -->
         <form v-else class="space-y-4" @submit.prevent="handleRegister">
           <div>
-            <label class="mb-1 block text-sm font-medium text-slate-700">用户名</label>
+            <label class="mb-1 block text-sm font-medium text-slate-700">Username</label>
             <input
               v-model="registerName"
               type="text"
               class="w-full rounded-xl border border-slate-200 px-3 py-2 outline-none focus:border-slate-900"
-              placeholder="请输入用户名"
+              placeholder="Enter username"
               autocomplete="username"
             />
           </div>
           <div>
-            <label class="mb-1 block text-sm font-medium text-slate-700">密码</label>
+            <label class="mb-1 block text-sm font-medium text-slate-700">Password</label>
             <div class="relative">
               <input
                 v-model="registerPassword"
                 :type="showRegisterPassword ? 'text' : 'password'"
                 class="w-full rounded-xl border border-slate-200 px-3 py-2 pr-10 outline-none focus:border-slate-900"
-                placeholder="至少8位,包含大小写字母和数字"
+                placeholder="At least 8 characters, including uppercase, lowercase letters and numbers"
                 autocomplete="new-password"
               />
               <button
@@ -232,13 +232,13 @@ const handleLogin = async () => {
             </div>
           </div>
           <div>
-            <label class="mb-1 block text-sm font-medium text-slate-700">确认密码</label>
+            <label class="mb-1 block text-sm font-medium text-slate-700">Confirm Password</label>
             <div class="relative">
               <input
                 v-model="registerConfirmPassword"
                 :type="showRegisterConfirmPassword ? 'text' : 'password'"
                 class="w-full rounded-xl border border-slate-200 px-3 py-2 pr-10 outline-none focus:border-slate-900"
-                placeholder="请再次输入密码"
+                placeholder="Re-enter password"
                 autocomplete="new-password"
               />
               <button
@@ -256,12 +256,12 @@ const handleLogin = async () => {
             class="w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 disabled:opacity-60"
             :disabled="registerLoading"
           >
-            {{ registerLoading ? '注册中...' : '注册' }}
+            {{ registerLoading ? 'Registering...' : 'Register' }}
           </button>
 
           <div class="text-center text-sm">
             <RouterLink class="text-slate-600 hover:text-slate-900" :to="{ name: 'home' }">
-              返回扫码页
+              Return to Scan Page
             </RouterLink>
           </div>
         </form>

+ 4 - 4
src/views/qrmanager/ForbiddenView.vue

@@ -8,20 +8,20 @@ const router = useRouter()
   <div class="min-h-screen bg-[var(--p-surface-50)]">
     <div class="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4 py-10">
       <div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
-        <div class="text-lg font-semibold text-slate-900">无权限访问</div>
-        <div class="mt-2 text-sm text-slate-600">你的账号角色不满足该页面访问条件。</div>
+        <div class="text-lg font-semibold text-slate-900">Access Denied</div>
+        <div class="mt-2 text-sm text-slate-600">Your account role does not meet the access requirements for this page.</div>
         <div class="mt-6 flex gap-2">
           <button
             class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800"
             @click="router.push({ name: 'qrmanagerHome' })"
           >
-            返回概览
+            Return to Overview
           </button>
           <button
             class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50"
             @click="router.push({ name: 'home' })"
           >
-            返回扫码页
+            Return to Scan Page
           </button>
         </div>
       </div>

+ 321 - 92
src/views/qrmanager/HomeView.vue

@@ -1,7 +1,7 @@
 <script setup>
 import { ref, onMounted, computed, inject } from 'vue'
 import { useRouter } from 'vue-router'
-import { fetchMyQrCodesApi, fetchQrInfoApi, resetPasswordApi } from '@/services/api'
+import { fetchMyQrCodesApi, fetchQrInfoApi, resetPasswordApi, bindQrCodeApi } from '@/services/api'
 import { useToast } from 'primevue/usetoast'
 
 const router = useRouter()
@@ -10,9 +10,18 @@ const toast = useToast()
 // 注入父组件提供的注册方法
 const registerOpenPasswordDialog = inject('registerOpenPasswordDialog', null)
 
+// 注入时区相关的功能
+const selectedTimeZone = inject('selectedTimeZone', ref('America/New_York'))
+const formatDateTime = inject('formatDateTime', (dateString) => dateString || '-')
+
 // 二维码列表
 const qrCodes = ref([])
 const loading = ref(false)
+const pagination = ref({
+  page: 0,
+  pageSize: 20,
+  total: 0
+})
 
 // 修改密码弹窗
 const showPasswordDialog = ref(false)
@@ -26,33 +35,91 @@ const qrCode = ref('')
 const qrLookupLoading = ref(false)
 const qrLookupData = ref(null)
 
+// 绑定二维码弹窗
+const showBindDialog = ref(false)
+const bindQrCode = ref('')
+const bindMaintenanceCode = ref('')
+const bindLoading = ref(false)
+
 const qrTypeLabel = computed(() => {
   if (!qrLookupData.value?.qrType) return '-'
-  return qrLookupData.value.qrType === 'person' ? '人员' : qrLookupData.value.qrType === 'pet' ? '宠物/物品' : qrLookupData.value.qrType
+  return qrLookupData.value.qrType === 'person' ? 'Person' : qrLookupData.value.qrType === 'pet' ? 'Pet/Item' : qrLookupData.value.qrType
 })
 
+// 根据 qrType 获取类型名称
+const getQrTypeName = (qrType) => {
+  const typeMap = {
+    person: 'Person',
+    pet: 'Pet',
+    goods: 'Item',
+    link: 'Link'
+  }
+  return typeMap[qrType] || qrType || 'Unknown'
+}
+
+// 根据 qrType 获取类型颜色
+const getQrTypeColor = (qrType) => {
+  const colorMap = {
+    person: 'bg-blue-100 text-blue-700',
+    pet: 'bg-green-100 text-green-700',
+    goods: 'bg-purple-100 text-purple-700',
+    link: 'bg-cyan-100 text-cyan-700'
+  }
+  return colorMap[qrType] || 'bg-slate-100 text-slate-700'
+}
+
+// 根据类型获取显示名称或链接
+const getDisplayName = (qr) => {
+  if (qr.qrType === 'link') {
+    return qr.jumpUrl || '-'
+  }
+  return qr.name || '-'
+}
+
+
 // 获取我的二维码列表
-const fetchMyQrCodes = async () => {
+const fetchMyQrCodes = async (page = 0) => {
   loading.value = true
   try {
-    const data = await fetchMyQrCodesApi()
-    qrCodes.value = Array.isArray(data) ? data : (data?.list || data?.qrCodes || [])
+    const data = await fetchMyQrCodesApi(page, pagination.value.pageSize)
+    // 根据文档,响应格式为 { content: [], metadata: { total, page, size } }
+    if (data?.content) {
+      qrCodes.value = data.content
+      pagination.value = {
+        page: data.metadata?.page ?? page,
+        pageSize: data.metadata?.size ?? pagination.value.pageSize,
+        total: data.metadata?.total ?? 0
+      }
+    } else {
+      // 兼容旧格式
+      qrCodes.value = Array.isArray(data) ? data : (data?.list || data?.qrCodes || [])
+      pagination.value.total = qrCodes.value.length
+    }
   } catch (e) {
     toast.add({
       severity: 'error',
-      summary: '加载失败',
-      detail: e?.message || e?.error || '获取二维码列表失败',
+      summary: 'Load Failed',
+      detail: e?.message || e?.error || 'Failed to fetch QR code list',
       life: 3000
     })
     qrCodes.value = []
+    pagination.value.total = 0
   } finally {
     loading.value = false
   }
 }
 
 // 跳转到二维码详情
-const goToQrDetail = (qrCode) => {
-  router.push({ name: 'scan', params: { qrCode } })
+const goToQrDetail = (qr) => {
+  const qrCode = typeof qr === 'string' ? qr : (qr?.qrCode || qr?.code || qr?.id)
+  const qrType = typeof qr === 'object' ? qr?.qrType : null
+  
+  // 如果是 link 类型,跳转到 jump 路由,否则跳转到 scan 路由
+  if (qrType === 'link') {
+    router.push({ name: 'jumpWithCode', params: { qrCode } })
+  } else {
+    router.push({ name: 'scan', params: { qrCode } })
+  }
 }
 
 // 修改密码相关
@@ -70,18 +137,18 @@ const closePasswordDialog = () => {
 
 const validatePassword = () => {
   const p = password.value
-  if (p.length < 8) return '密码长度必须至少8位'
-  if (!/[a-z]/.test(p)) return '密码必须包含小写字母'
-  if (!/[A-Z]/.test(p)) return '密码必须包含大写字母'
-  if (!/[0-9]/.test(p)) return '密码必须包含数字'
-  if (p !== confirmPassword.value) return '两次输入的密码不一致'
+  if (p.length < 8) return 'Password must be at least 8 characters'
+  if (!/[a-z]/.test(p)) return 'Password must contain lowercase letters'
+  if (!/[A-Z]/.test(p)) return 'Password must contain uppercase letters'
+  if (!/[0-9]/.test(p)) return 'Password must contain numbers'
+  if (p !== confirmPassword.value) return 'Passwords do not match'
   return ''
 }
 
 const submitPassword = async () => {
   const err = validatePassword()
   if (err) {
-    toast.add({ severity: 'warn', summary: '校验失败', detail: err, life: 3000 })
+    toast.add({ severity: 'warn', summary: 'Validation Failed', detail: err, life: 3000 })
     return
   }
 
@@ -90,16 +157,16 @@ const submitPassword = async () => {
     const data = await resetPasswordApi(password.value)
     toast.add({
       severity: 'success',
-      summary: '成功',
-      detail: data?.message || '密码重置成功',
+      summary: 'Success',
+      detail: data?.message || 'Password reset successfully',
       life: 2500
     })
     closePasswordDialog()
   } catch (e) {
     toast.add({
       severity: 'error',
-      summary: '失败',
-      detail: e?.message || e?.error || '重置失败,请确认已登录且密码符合规则',
+      summary: 'Failed',
+      detail: e?.message || e?.error || 'Reset failed, please confirm you are logged in and the password meets the requirements',
       life: 3500
     })
   } finally {
@@ -123,7 +190,7 @@ const closeQrLookupDialog = () => {
 const queryQrCode = async () => {
   const code = qrCode.value.trim()
   if (!code) {
-    toast.add({ severity: 'warn', summary: '提示', detail: '请输入 qrCode', life: 2000 })
+    toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter qrCode', life: 2000 })
     return
   }
   qrLookupLoading.value = true
@@ -133,8 +200,8 @@ const queryQrCode = async () => {
   } catch (e) {
     toast.add({
       severity: 'error',
-      summary: '查询失败',
-      detail: e?.message || e?.error || '请确认 qrCode 是否正确',
+      summary: 'Query Failed',
+      detail: e?.message || e?.error || 'Please confirm the qrCode is correct',
       life: 3000
     })
   } finally {
@@ -148,6 +215,53 @@ const openScanPage = async () => {
   await router.push({ name: 'scan', params: { qrCode: code } })
 }
 
+// 绑定二维码相关
+const openBindDialog = () => {
+  showBindDialog.value = true
+  bindQrCode.value = ''
+  bindMaintenanceCode.value = ''
+}
+
+const closeBindDialog = () => {
+  showBindDialog.value = false
+  bindQrCode.value = ''
+  bindMaintenanceCode.value = ''
+}
+
+const submitBind = async () => {
+  const code = bindQrCode.value.trim()
+  if (!code) {
+    toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter QR code number', life: 2000 })
+    return
+  }
+  const maintenanceCode = bindMaintenanceCode.value.trim()
+  if (!maintenanceCode) {
+    toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter maintenance code', life: 2000 })
+    return
+  }
+  bindLoading.value = true
+  try {
+    await bindQrCodeApi({ qrCode: code, maintenanceCode })
+    toast.add({
+      severity: 'success',
+      summary: 'Success',
+      detail: 'Binding successful',
+      life: 2500
+    })
+    closeBindDialog()
+    await fetchMyQrCodes(pagination.value.page)
+  } catch (e) {
+    toast.add({
+      severity: 'error',
+      summary: 'Binding Failed',
+      detail: e?.message || e?.error || 'Binding failed, please check if the QR code number is correct',
+      life: 3000
+    })
+  } finally {
+    bindLoading.value = false
+  }
+}
+
 onMounted(() => {
   fetchMyQrCodes()
   
@@ -161,78 +275,138 @@ onMounted(() => {
 <template>
   <div>
     <!-- 顶部操作栏 -->
-    <div class="mb-6 flex items-center justify-between">
+    <div class="mb-6 flex items-center justify-between flex-wrap gap-4">
       <div>
-        <div class="text-xl font-semibold text-slate-900">我的二维码</div>
-        <div class="mt-1 text-sm text-slate-500">管理您创建的二维码</div>
+        <div class="text-xl font-semibold text-slate-900">My QR Codes</div>
+        <div class="mt-1 text-sm text-slate-500">Manage your created QR codes</div>
+      </div>
+      <div>
+        <button
+          class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 transition-colors"
+          @click="openBindDialog"
+        >
+          Bind QR Code
+        </button>
       </div>
-      <button
-        class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition-colors"
-        @click="openQrLookupDialog"
-      >
-        查询二维码
-      </button>
     </div>
 
     <!-- 二维码列表 -->
     <div v-if="loading" class="flex items-center justify-center py-12">
-      <div class="text-sm text-slate-500">加载中...</div>
+      <div class="text-sm text-slate-500">Loading...</div>
     </div>
 
     <div v-else-if="qrCodes.length === 0" class="rounded-2xl border border-slate-200 bg-slate-50 p-12 text-center">
-      <div class="text-sm text-slate-500">暂无二维码</div>
+      <div class="text-sm text-slate-500">No QR codes</div>
     </div>
 
-    <div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2">
+    <div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
       <div
         v-for="qr in qrCodes"
         :key="qr.qrCode || qr.id"
-        class="group cursor-pointer rounded-2xl border border-slate-200 bg-white p-4 transition-all hover:border-slate-300 hover:shadow-md"
-        @click="goToQrDetail(qr.qrCode || qr.code || qr.id)"
+        class="group cursor-pointer rounded-2xl border border-slate-200 bg-white p-5 transition-all hover:border-slate-300 hover:shadow-lg"
+        @click="goToQrDetail(qr)"
       >
-        <div class="flex items-start justify-between">
-          <div class="flex-1">
-            <div class="text-sm font-semibold text-slate-900">
-              {{ qr.name || qr.title || `二维码 ${qr.qrCode || qr.code || qr.id}` }}
-            </div>
-            <div class="mt-1 text-xs text-slate-500">
+        <!-- 头部:二维码编号和操作按钮 -->
+        <div class="flex items-start justify-between mb-3">
+          <div class="flex-1 min-w-0">
+            <div class="text-sm font-mono text-slate-500 mb-1">QR Code</div>
+            <div class="text-base font-semibold text-slate-900 truncate">
               {{ qr.qrCode || qr.code || qr.id }}
             </div>
-            <div v-if="qr.qrType" class="mt-2">
-              <span
-                class="inline-block rounded-lg px-2 py-0.5 text-xs font-medium"
-                :class="
-                  qr.qrType === 'person'
-                    ? 'bg-blue-100 text-blue-700'
-                    : qr.qrType === 'pet'
-                      ? 'bg-green-100 text-green-700'
-                      : 'bg-slate-100 text-slate-700'
-                "
-              >
-                {{ qr.qrType === 'person' ? '人员' : qr.qrType === 'pet' ? '宠物' : qr.qrType }}
-              </span>
-            </div>
-            <div v-if="qr.isActivated !== undefined" class="mt-2">
-              <span
-                class="inline-block rounded-lg px-2 py-0.5 text-xs font-medium"
-                :class="qr.isActivated ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'"
-              >
-                {{ qr.isActivated ? '已激活' : '未激活' }}
-              </span>
-            </div>
           </div>
-          <svg
-            xmlns="http://www.w3.org/2000/svg"
-            fill="none"
-            viewBox="0 0 24 24"
-            stroke-width="1.5"
-            stroke="currentColor"
-            class="h-5 w-5 text-slate-400 group-hover:text-slate-600 transition-colors"
+          <button
+            class="ml-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50 hover:border-slate-300 transition-colors flex items-center gap-1 flex-shrink-0"
+            @click.stop="goToQrDetail(qr)"
           >
-            <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
-          </svg>
+            <span>View</span>
+            <svg
+              xmlns="http://www.w3.org/2000/svg"
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke-width="1.5"
+              stroke="currentColor"
+              class="h-3.5 w-3.5"
+            >
+              <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
+            </svg>
+          </button>
         </div>
+
+        <!-- 名称或链接 -->
+        <div class="mb-3">
+          <div class="text-xs text-slate-500 mb-1">
+            {{ qr.qrType === 'link' ? 'Jump URL' : 'Name' }}
+          </div>
+          <div 
+            v-if="qr.qrType === 'link'"
+            class="text-sm font-medium text-cyan-600 truncate"
+            :title="getDisplayName(qr)"
+          >
+            {{ getDisplayName(qr) }}
+          </div>
+          <div 
+            v-else
+            class="text-sm font-medium text-slate-900 truncate"
+            :title="getDisplayName(qr)"
+          >
+            {{ getDisplayName(qr) }}
+          </div>
+        </div>
+
+        <!-- 类型和状态标签 -->
+        <div class="flex items-center gap-2 flex-wrap mb-3">
+          <span
+            v-if="qr.qrType"
+            class="inline-block rounded-lg px-2.5 py-1 text-xs font-semibold"
+            :class="getQrTypeColor(qr.qrType)"
+          >
+            {{ getQrTypeName(qr.qrType) }}
+          </span>
+          <span
+            v-if="qr.isActivated !== undefined"
+            class="inline-block rounded-lg px-2.5 py-1 text-xs font-medium"
+            :class="qr.isActivated ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'"
+          >
+            {{ qr.isActivated ? 'Activated' : 'Not Activated' }}
+          </span>
+          <span
+            v-if="qr.isVisible !== undefined"
+            class="inline-block rounded-lg px-2.5 py-1 text-xs font-medium"
+            :class="qr.isVisible ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'"
+          >
+            {{ qr.isVisible ? 'Visible' : 'Hidden' }}
+          </span>
+        </div>
+
+        <!-- 最后扫描时间 -->
+        <div class="pt-3 border-t border-slate-100">
+          <div class="text-xs text-slate-500">
+            <span class="font-medium">Last Scan:</span>
+            <span class="ml-1">{{ formatDateTime(qr.lastScanAt) }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 分页 -->
+    <div v-if="pagination.total > pagination.pageSize" class="mt-6 flex items-center justify-center gap-2">
+      <button
+        class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+        :disabled="pagination.page === 0"
+        @click="fetchMyQrCodes(pagination.page - 1)"
+      >
+        Previous
+      </button>
+      <div class="text-sm text-slate-600">
+        Page {{ pagination.page + 1 }} of {{ Math.ceil(pagination.total / pagination.pageSize) }}
       </div>
+      <button
+        class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+        :disabled="(pagination.page + 1) * pagination.pageSize >= pagination.total"
+        @click="fetchMyQrCodes(pagination.page + 1)"
+      >
+        Next
+      </button>
     </div>
 
     <!-- 修改密码对话框 -->
@@ -243,28 +417,28 @@ onMounted(() => {
     >
       <div class="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-xl">
         <div class="mb-4">
-          <div class="text-xl font-semibold text-slate-900">修改密码</div>
-          <div class="mt-1 text-sm text-slate-500">密码规则:至少8位,包含大小写字母和数字</div>
+          <div class="text-xl font-semibold text-slate-900">Change Password</div>
+          <div class="mt-1 text-sm text-slate-500">Password rules: at least 8 characters, including uppercase, lowercase letters and numbers</div>
         </div>
 
         <form class="space-y-4" @submit.prevent="submitPassword">
           <div>
-            <label class="mb-1 block text-sm font-medium text-slate-700">新密码</label>
+            <label class="mb-1 block text-sm font-medium text-slate-700">New Password</label>
             <input
               v-model="password"
               type="password"
               class="w-full rounded-xl border border-slate-200 px-3 py-2 outline-none focus:border-slate-900"
-              placeholder="例如:Abcdefg1"
+              placeholder="e.g., Abcdefg1"
               autocomplete="new-password"
             />
           </div>
           <div>
-            <label class="mb-1 block text-sm font-medium text-slate-700">确认新密码</label>
+            <label class="mb-1 block text-sm font-medium text-slate-700">Confirm New Password</label>
             <input
               v-model="confirmPassword"
               type="password"
               class="w-full rounded-xl border border-slate-200 px-3 py-2 outline-none focus:border-slate-900"
-              placeholder="再次输入"
+              placeholder="Re-enter password"
               autocomplete="new-password"
             />
           </div>
@@ -276,14 +450,14 @@ onMounted(() => {
               @click="closePasswordDialog"
               :disabled="passwordLoading"
             >
-              取消
+              Cancel
             </button>
             <button
               type="submit"
               class="flex-1 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 disabled:opacity-60"
               :disabled="passwordLoading"
             >
-              {{ passwordLoading ? '提交中...' : '确认修改' }}
+              {{ passwordLoading ? 'Submitting...' : 'Confirm Change' }}
             </button>
           </div>
         </form>
@@ -298,8 +472,8 @@ onMounted(() => {
     >
       <div class="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl border border-slate-200 bg-white p-6 shadow-xl">
         <div class="mb-4">
-          <div class="text-xl font-semibold text-slate-900">二维码查询</div>
-          <div class="mt-1 text-sm text-slate-500">调用扫码页接口 /qr/info(不需要 token)</div>
+          <div class="text-xl font-semibold text-slate-900">QR Code Lookup</div>
+          <div class="mt-1 text-sm text-slate-500">Call scan page API /qr/info (no token required)</div>
         </div>
 
         <div class="flex flex-col gap-3 sm:flex-row sm:items-end">
@@ -308,7 +482,7 @@ onMounted(() => {
             <input
               v-model="qrCode"
               class="w-full rounded-xl border border-slate-200 px-3 py-2 outline-none focus:border-slate-900"
-              placeholder="例如:QR2025ABC..."
+              placeholder="e.g., QR2025ABC..."
             />
           </div>
           <div class="flex gap-2">
@@ -317,20 +491,20 @@ onMounted(() => {
               :disabled="qrLookupLoading"
               @click="queryQrCode"
             >
-              {{ qrLookupLoading ? '查询中...' : '查询' }}
+              {{ qrLookupLoading ? 'Querying...' : 'Query' }}
             </button>
             <button
               class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 disabled:opacity-60"
               :disabled="!qrLookupData"
               @click="openScanPage"
             >
-              打开扫码页
+              Open Scan Page
             </button>
             <button
               class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50"
               @click="closeQrLookupDialog"
             >
-              关闭
+              Close
             </button>
           </div>
         </div>
@@ -338,26 +512,81 @@ onMounted(() => {
         <div v-if="qrLookupData" class="mt-6 space-y-3">
           <div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
             <div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
-              <div class="text-xs font-medium text-slate-500">二维码类型</div>
+              <div class="text-xs font-medium text-slate-500">QR Code Type</div>
               <div class="mt-1 text-base font-semibold text-slate-900">{{ qrTypeLabel }}</div>
             </div>
             <div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
-              <div class="text-xs font-medium text-slate-500">是否激活</div>
-              <div class="mt-1 text-base font-semibold text-slate-900">{{ qrLookupData?.isActivated ? '是' : '否' }}</div>
+              <div class="text-xs font-medium text-slate-500">Activated</div>
+              <div class="mt-1 text-base font-semibold text-slate-900">{{ qrLookupData?.isActivated ? 'Yes' : 'No' }}</div>
             </div>
             <div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
-              <div class="text-xs font-medium text-slate-500">扫描次数</div>
+              <div class="text-xs font-medium text-slate-500">Scan Count</div>
               <div class="mt-1 text-base font-semibold text-slate-900">{{ qrLookupData?.scanCount ?? '-' }}</div>
             </div>
           </div>
 
           <div class="rounded-2xl border border-slate-200 p-4">
-            <div class="mb-2 text-sm font-semibold text-slate-900">绑定信息(info)</div>
+            <div class="mb-2 text-sm font-semibold text-slate-900">Binding Info (info)</div>
             <pre class="overflow-auto rounded-xl bg-slate-950 p-3 text-xs text-slate-100">{{ JSON.stringify(qrLookupData?.info, null, 2) }}</pre>
           </div>
         </div>
       </div>
     </div>
+
+    <!-- 绑定二维码对话框 -->
+    <div
+      v-if="showBindDialog"
+      class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
+      @click.self="closeBindDialog"
+    >
+      <div class="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-xl">
+        <div class="mb-4">
+          <div class="text-xl font-semibold text-slate-900">Bind QR Code</div>
+          <div class="mt-1 text-sm text-slate-500">Bind by QR code number</div>
+        </div>
+
+        <form class="space-y-4" @submit.prevent="submitBind">
+          <div>
+            <label class="mb-1 block text-sm font-medium text-slate-700">QR Code Number <span class="text-red-500">*</span></label>
+            <input
+              v-model="bindQrCode"
+              class="w-full rounded-xl border border-slate-200 px-3 py-2 outline-none focus:border-slate-900"
+              placeholder="e.g., ABC123XYZ456"
+              required
+            />
+          </div>
+          <div>
+            <label class="mb-1 block text-sm font-medium text-slate-700">Maintenance Code <span class="text-red-500">*</span></label>
+            <input
+              v-model="bindMaintenanceCode"
+              type="text"
+              maxlength="8"
+              class="w-full rounded-xl border border-slate-200 px-3 py-2 outline-none focus:border-slate-900"
+              placeholder="e.g., AB12CD34"
+              required
+            />
+          </div>
+
+          <div class="flex gap-3 pt-2">
+            <button
+              type="button"
+              class="flex-1 rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50"
+              @click="closeBindDialog"
+              :disabled="bindLoading"
+            >
+              Cancel
+            </button>
+            <button
+              type="submit"
+              class="flex-1 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 disabled:opacity-60"
+              :disabled="bindLoading"
+            >
+              {{ bindLoading ? 'Binding...' : 'Confirm Bind' }}
+            </button>
+          </div>
+        </form>
+      </div>
+    </div>
   </div>
 </template>
 

+ 144 - 10
src/views/qrmanager/NavigationHeader.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { computed, ref, provide } from 'vue'
+import { computed, ref, provide, onMounted, onUnmounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { useUserStore } from '@/stores/user'
 
@@ -33,7 +33,82 @@ const handleClickOutside = (event) => {
   if (!event.target.closest('.user-menu-container')) {
     showUserMenu.value = false
   }
+  if (!event.target.closest('.timezone-selector-container')) {
+    showTimeZoneSelector.value = false
+  }
+}
+
+// 时区选择
+const timeZones = [
+  { value: 'America/New_York', label: 'Eastern Time (ET)', abbr: 'EST/EDT', short: 'ET' },
+  { value: 'America/Chicago', label: 'Central Time (CT)', abbr: 'CST/CDT', short: 'CT' },
+  { value: 'America/Denver', label: 'Mountain Time (MT)', abbr: 'MST/MDT', short: 'MT' },
+  { value: 'America/Los_Angeles', label: 'Pacific Time (PT)', abbr: 'PST/PDT', short: 'PT' }
+]
+
+// 从localStorage读取保存的时区,默认为东部时间
+const selectedTimeZone = ref(localStorage.getItem('selectedTimeZone') || 'America/New_York')
+
+// 时区选择器显示状态
+const showTimeZoneSelector = ref(false)
+
+// 切换时区
+const changeTimeZone = (timeZone) => {
+  selectedTimeZone.value = timeZone
+  localStorage.setItem('selectedTimeZone', timeZone)
+  showTimeZoneSelector.value = false
+}
+
+// 获取当前选中的时区标签
+const currentTimeZoneLabel = computed(() => {
+  const tz = timeZones.find(tz => tz.value === selectedTimeZone.value)
+  return tz ? `${tz.label} (${tz.abbr})` : 'Eastern Time (ET)'
+})
+
+// 格式化日期时间函数 - 提供给子组件使用
+const formatDateTime = (dateString) => {
+  if (!dateString) return '-'
+  try {
+    let date
+    
+    // API返回的格式是 2025-12-24T09:23:25.063Z,标记为UTC但实际是北京时间
+    // 将Z替换为+08:00表示这是北京时间
+    if (dateString.endsWith('Z')) {
+      date = new Date(dateString.replace('Z', '+08:00'))
+    } else if (/[+-]\d{2}:?\d{2}$/.test(dateString)) {
+      // 已经有明确的时区信息,直接解析
+      date = new Date(dateString)
+    } else {
+      // 没有时区信息,假设是北京时间(UTC+8),添加 +08:00
+      date = new Date(dateString + '+08:00')
+    }
+    
+    // 转换为选中的美国时区
+    return date.toLocaleString('en-US', {
+      timeZone: selectedTimeZone.value,
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+      hour: '2-digit',
+      minute: '2-digit',
+      hour12: true
+    })
+  } catch {
+    return dateString
+  }
 }
+
+// 提供时区相关的功能给子组件
+provide('selectedTimeZone', selectedTimeZone)
+provide('formatDateTime', formatDateTime)
+
+onMounted(() => {
+  document.addEventListener('click', handleClickOutside)
+})
+
+onUnmounted(() => {
+  document.removeEventListener('click', handleClickOutside)
+})
 </script>
 
 <template>
@@ -46,16 +121,74 @@ const handleClickOutside = (event) => {
           </div>
           <div>
             <div class="text-sm font-semibold text-slate-900">QR Manager</div>
-            <div class="text-xs text-slate-500">账户与二维码管理</div>
+            <div class="text-xs text-slate-500">Account & QR Code Management</div>
           </div>
         </div>
 
-        <div class="user-menu-container relative">
-          <button
-            class="flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 hover:bg-slate-50 hover:border-slate-300 transition-colors"
-            @click.stop="showUserMenu = !showUserMenu"
-            title="用户菜单"
-          >
+        <div class="flex items-center gap-3">
+          <!-- 时区选择器 -->
+          <div class="timezone-selector-container relative">
+            <button
+              class="flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50 hover:border-slate-300 transition-colors"
+              @click.stop="showTimeZoneSelector = !showTimeZoneSelector"
+              :title="currentTimeZoneLabel"
+            >
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke-width="1.5"
+                stroke="currentColor"
+                class="h-3.5 w-3.5"
+              >
+                <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
+              </svg>
+              <span>{{ timeZones.find(tz => tz.value === selectedTimeZone)?.short || 'ET' }}</span>
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke-width="1.5"
+                stroke="currentColor"
+                class="h-3 w-3"
+              >
+                <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
+              </svg>
+            </button>
+            
+            <!-- 时区下拉菜单 -->
+            <div
+              v-if="showTimeZoneSelector"
+              class="absolute right-0 top-full mt-2 w-64 rounded-xl border border-slate-200 bg-white shadow-lg z-50"
+              @click.stop
+            >
+              <div class="p-2">
+                <div class="mb-2 px-3 py-2 text-xs font-semibold text-slate-500 uppercase">Select Time Zone</div>
+                <div class="space-y-1">
+                  <button
+                    v-for="tz in timeZones"
+                    :key="tz.value"
+                    class="w-full rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors"
+                    :class="selectedTimeZone === tz.value 
+                      ? 'bg-slate-900 text-white' 
+                      : 'text-slate-700 hover:bg-slate-50'"
+                    @click="changeTimeZone(tz.value)"
+                  >
+                    <div class="font-semibold">{{ tz.label }}</div>
+                    <div class="text-xs opacity-80">{{ tz.abbr }}</div>
+                  </button>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <!-- 个人信息按钮 -->
+          <div class="user-menu-container relative">
+            <button
+              class="flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 hover:bg-slate-50 hover:border-slate-300 transition-colors"
+              @click.stop="showUserMenu = !showUserMenu"
+              title="User Menu"
+            >
             <svg
               xmlns="http://www.w3.org/2000/svg"
               fill="none"
@@ -85,16 +218,17 @@ const handleClickOutside = (event) => {
                 class="w-full rounded-lg px-3 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 transition-colors"
                 @click="openPasswordDialog"
               >
-                修改密码
+                Change Password
               </button>
               <button
                 class="w-full rounded-lg px-3 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 transition-colors"
                 @click="logout"
               >
-                退出登录
+                Logout
               </button>
             </div>
           </div>
+          </div>
         </div>
       </div>
     </header>