Pārlūkot izejas kodu

整合用户管理功能,新增二维码管理界面,包括用户注册、登录、密码重置及二维码查询功能。更新路由配置以支持新视图,添加用户状态管理,优化用户体验。

wuyi 2 nedēļas atpakaļ
vecāks
revīzija
5647c8e6b9

+ 3 - 0
src/main.js

@@ -1,6 +1,7 @@
 import './assets/main.css'
 
 import { createApp } from 'vue'
+import { createPinia } from 'pinia'
 import PrimeVue from 'primevue/config'
 import ToastService from 'primevue/toastservice'
 import ConfirmService from 'primevue/confirmationservice'
@@ -11,8 +12,10 @@ import App from './App.vue'
 import router from './router'
 
 const app = createApp(App)
+const pinia = createPinia()
 
 app.use(router)
+app.use(pinia)
 app.use(PrimeVue, { ripple: true, theme: { preset: Aura } })
 app.use(ToastService)
 app.use(ConfirmService)

+ 75 - 4
src/router/index.js

@@ -1,6 +1,11 @@
 import { createRouter, createWebHistory } from 'vue-router'
 import ScanView from '@/views/ScanView.vue'
 import JumpView from '@/views/JumpView.vue'
+import QrManagerAuthView from '@/views/qrmanager/AuthView.vue'
+import NavigationHeader from '@/views/qrmanager/NavigationHeader.vue'
+import QrManagerHomeView from '@/views/qrmanager/HomeView.vue'
+import QrManagerForbiddenView from '@/views/qrmanager/ForbiddenView.vue'
+import { useUserStore } from '@/stores/user'
 
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
@@ -10,6 +15,12 @@ const router = createRouter({
       name: 'home',
       component: ScanView
     },
+    {
+      path: '/info/:qrCode',
+      name: 'scan',
+      component: ScanView,
+      props: true
+    },
     {
       path: '/jump',
       name: 'jump',
@@ -22,10 +33,23 @@ const router = createRouter({
       props: true
     },
     {
-      path: '/:qrCode',
-      name: 'scan',
-      component: ScanView,
-      props: true
+      path: '/qrmanager',
+      name: 'qrmanagerAuth',
+      component: QrManagerAuthView,
+      meta: { guestOnly: true }
+    },
+    {
+      path: '/qrmanager/forbidden',
+      name: 'qrmanagerForbidden',
+      component: QrManagerForbiddenView
+    },
+    {
+      path: '/qrmanager/home',
+      component: NavigationHeader,
+      meta: { requiresAuth: true },
+      children: [
+        { path: '', name: 'qrmanagerHome', component: QrManagerHomeView }
+      ]
     }
   ],
   scrollBehavior() {
@@ -33,4 +57,51 @@ const router = createRouter({
   }
 })
 
+router.beforeEach(async (to) => {
+  const userStore = useUserStore()
+
+  // 已登录用户访问登录/注册页:直接进管理端首页
+  if (to.meta?.guestOnly) {
+    if (userStore.token) {
+      // 如果带有二维码参数,跳转到二维码页面
+      if (to.query.qrCode) {
+        return { name: 'scan', params: { qrCode: to.query.qrCode } }
+      }
+      return { name: 'qrmanagerHome' }
+    }
+    return true
+  }
+
+  const requiresAuth = to.matched.some((r) => r.meta?.requiresAuth)
+  if (!requiresAuth) return true
+
+  if (!userStore.token) {
+    // 如果目标路由带有二维码参数,传递到登录页
+    const qrCode = to.params?.qrCode || to.query?.qrCode
+    if (qrCode) {
+      return { name: 'qrmanagerAuth', query: { qrCode, redirect: to.fullPath } }
+    }
+    return { name: 'qrmanagerAuth', query: { redirect: to.fullPath } }
+  }
+
+  // 尽量从本地恢复 userInfo;没有则调用 profile 校验 token 并取回 role
+  if (!userStore.userInfo?.role) {
+    try {
+      await userStore.sync()
+    } catch (e) {
+      userStore.logout()
+      const qrCode = to.params?.qrCode || to.query?.qrCode
+      if (qrCode) {
+        return { name: 'qrmanagerAuth', query: { qrCode, redirect: to.fullPath } }
+      }
+      return { name: 'qrmanagerAuth', query: { redirect: to.fullPath } }
+    }
+  }
+
+  const requiredRoles = to.matched.flatMap((r) => r.meta?.roles || [])
+  if (requiredRoles.length && !requiredRoles.includes(userStore.userInfo.role)) return { name: 'qrmanagerForbidden' }
+
+  return true
+})
+
 export default router

+ 100 - 2
src/services/api.js

@@ -9,15 +9,79 @@ const api = axios.create({
 })
 
 api.interceptors.request.use(
-  (config) => config,
+  async (config) => {
+    // 参考你 admin 系统:从 pinia userStore 里取 token
+    const { useUserStore } = await import('@/stores/user')
+    const userStore = useUserStore()
+    if (userStore.token) {
+      config.headers = config.headers || {}
+      config.headers.Authorization = `Bearer ${userStore.token}`
+    }
+    return config
+  },
   (error) => Promise.reject(error)
 )
 
 api.interceptors.response.use(
   (response) => response,
-  (error) => Promise.reject(error.response?.data || error)
+  async (error) => {
+    if (error?.response?.status === 401) {
+      const { useUserStore } = await import('@/stores/user')
+      const userStore = useUserStore()
+      userStore.logout()
+    }
+    return Promise.reject(error.response?.data || error)
+  }
 )
 
+// 兼容旧接口(使用name)
+export const login = async (name, password) => {
+  const response = await api.post('/users/login', {
+    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
+}
+
+export const resetPasswordApi = async (password) => {
+  const response = await api.post('/users/reset-password', {
+    password
+  })
+  return response.data
+} 
+
+// 兼容旧接口(使用name)
+export const register = async (name, password) => {
+  const response = await api.post('/users/register', {
+    password,
+    name
+  })
+  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)
@@ -84,3 +148,37 @@ 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
+  })
+  return response.data
+}
+
+// 获取用户自己的二维码列表
+export const fetchMyQrCodesApi = async () => {
+  const response = await api.get('/qr/my')
+  return response.data
+}

+ 86 - 0
src/stores/user.js

@@ -0,0 +1,86 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { useStorage } from '@vueuse/core'
+import {
+  login as apiLogin,
+  loginWithEmail,
+  profile,
+  register as apiRegister,
+  registerWithEmail
+} from '@/services/api'
+
+export const useUserStore = defineStore('user', () => {
+  // 与你 admin 系统一致:localStorage key 直接用 token
+  const token = useStorage('token', '')
+  const userInfo = ref({})
+
+  const setToken = (newToken) => {
+    token.value = newToken || ''
+  }
+
+  const setUserInfo = (info) => {
+    userInfo.value = info || {}
+  }
+
+  // 兼容旧接口(使用用户名)
+  const login = async (username, password) => {
+    const response = await apiLogin(username, password)
+    setToken(response?.token)
+    // 用 profile 补齐 role 等字段
+    const userProfile = await profile()
+    setUserInfo(userProfile)
+    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)
+    setToken(response?.token)
+    const userProfile = await profile()
+    setUserInfo(userProfile)
+    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)
+  }
+
+  const logout = () => {
+    token.value = ''
+    userInfo.value = {}
+  }
+
+  return {
+    token,
+    userInfo,
+    setToken,
+    setUserInfo,
+    login,
+    loginWithEmail: loginWithEmailStore,
+    register,
+    registerWithEmail: registerWithEmailStore,
+    logout,
+    sync
+  }
+})
+
+

+ 6 - 0
src/views/ScanView.vue

@@ -239,6 +239,12 @@ const fetchQrDetails = async () => {
       return
     }
     
+    // 如果二维码未激活,跳转到用户管理入口并带着二维码参数
+    if (!data.isActivated) {
+      await router.replace({ name: 'qrmanagerAuth', query: { qrCode: qrCode.value } })
+      return
+    }
+    
     if (data.isVisible === false) {
       infoStatus.state = 'notVisible'
       infoStatus.message = 'QR code information is not visible'

+ 271 - 0
src/views/qrmanager/AuthView.vue

@@ -0,0 +1,271 @@
+<script setup>
+import { ref, computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { useToast } from 'primevue/usetoast'
+import { useUserStore } from '@/stores/user'
+
+const route = useRoute()
+const router = useRouter()
+const toast = useToast()
+const userStore = useUserStore()
+
+// 模式切换:'login' 或 'register'
+const mode = ref('login')
+
+// 注册表单
+const registerName = ref('')
+const registerPassword = ref('')
+const registerConfirmPassword = ref('')
+const showRegisterPassword = ref(false)
+const showRegisterConfirmPassword = ref(false)
+const registerLoading = ref(false)
+
+// 登录表单
+const loginName = ref('')
+const loginPassword = ref('')
+const showLoginPassword = ref(false)
+const loginLoading = ref(false)
+
+// 获取二维码参数
+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 '密码必须包含数字'
+  return ''
+}
+
+// 注册
+const handleRegister = async () => {
+  if (!registerName.value.trim()) {
+    toast.add({ severity: 'warn', summary: '提示', detail: '请输入用户名', life: 2500 })
+    return
+  }
+  if (!registerPassword.value) {
+    toast.add({ severity: 'warn', summary: '提示', detail: '请输入密码', life: 2500 })
+    return
+  }
+  const pwdErr = validatePassword(registerPassword.value)
+  if (pwdErr) {
+    toast.add({ severity: 'warn', summary: '校验失败', detail: pwdErr, life: 3000 })
+    return
+  }
+  if (registerPassword.value !== registerConfirmPassword.value) {
+    toast.add({ severity: 'warn', summary: '校验失败', detail: '两次输入的密码不一致', life: 3000 })
+    return
+  }
+
+  registerLoading.value = true
+  try {
+    await userStore.register(registerName.value.trim(), registerPassword.value)
+    toast.add({ severity: 'success', summary: '注册成功', detail: '已自动登录', life: 2000 })
+    
+    // 根据是否有二维码参数决定跳转
+    if (qrCode.value) {
+      // 跳转到二维码首次输入信息界面
+      await router.replace({ name: 'scan', params: { qrCode: qrCode.value } })
+    } else {
+      // 跳转到二维码管理主界面
+      await router.replace({ name: 'qrmanagerHome' })
+    }
+  } catch (e) {
+    toast.add({
+      severity: 'error',
+      summary: '注册失败',
+      detail: e?.message || e?.error || '用户名可能已被注册',
+      life: 3000
+    })
+  } finally {
+    registerLoading.value = false
+  }
+}
+
+// 登录
+const handleLogin = async () => {
+  if (!loginName.value.trim()) {
+    toast.add({ severity: 'warn', summary: '提示', detail: '请输入用户名', life: 2500 })
+    return
+  }
+  if (!loginPassword.value) {
+    toast.add({ severity: 'warn', summary: '提示', detail: '请输入密码', life: 2500 })
+    return
+  }
+
+  loginLoading.value = true
+  try {
+    await userStore.login(loginName.value.trim(), loginPassword.value)
+    toast.add({ severity: 'success', summary: '登录成功', detail: '欢迎回来', life: 2000 })
+    
+    // 根据是否有二维码参数决定跳转
+    if (qrCode.value) {
+      // 跳转到二维码首次输入信息界面
+      await router.replace({ name: 'scan', params: { qrCode: qrCode.value } })
+    } else {
+      // 跳转到二维码管理主界面
+      await router.replace({ name: 'qrmanagerHome' })
+    }
+  } catch (e) {
+    toast.add({
+      severity: 'error',
+      summary: '登录失败',
+      detail: e?.message || e?.error || '用户名或密码错误',
+      life: 3000
+    })
+  } finally {
+    loginLoading.value = false
+  }
+}
+</script>
+
+<template>
+  <div class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
+    <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-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 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>
+          </div>
+        </div>
+
+        <!-- 模式切换 -->
+        <div class="mb-6 flex rounded-xl bg-slate-100 p-1">
+          <button
+            type="button"
+            class="flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all"
+            :class="mode === 'login' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-600 hover:text-slate-900'"
+            @click="mode = 'login'"
+          >
+            登录
+          </button>
+          <button
+            type="button"
+            class="flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all"
+            :class="mode === 'register' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-600 hover:text-slate-900'"
+            @click="mode = '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>
+            <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="请输入用户名"
+              autocomplete="username"
+            />
+          </div>
+          <div>
+            <label class="mb-1 block text-sm font-medium text-slate-700">密码</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="请输入密码"
+                autocomplete="current-password"
+              />
+              <button
+                type="button"
+                class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+                @click="showLoginPassword = !showLoginPassword"
+              >
+                <i :class="showLoginPassword ? 'pi pi-eye-slash' : 'pi pi-eye'" class="text-sm" />
+              </button>
+            </div>
+          </div>
+
+          <button
+            type="submit"
+            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 ? '登录中...' : '登录' }}
+          </button>
+
+          <div class="text-center text-sm">
+            <RouterLink class="text-slate-600 hover:text-slate-900" :to="{ name: 'home' }">
+              返回扫码页
+            </RouterLink>
+          </div>
+        </form>
+
+        <!-- 注册表单 -->
+        <form v-else class="space-y-4" @submit.prevent="handleRegister">
+          <div>
+            <label class="mb-1 block text-sm font-medium text-slate-700">用户名</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="请输入用户名"
+              autocomplete="username"
+            />
+          </div>
+          <div>
+            <label class="mb-1 block text-sm font-medium text-slate-700">密码</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位,包含大小写字母和数字"
+                autocomplete="new-password"
+              />
+              <button
+                type="button"
+                class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+                @click="showRegisterPassword = !showRegisterPassword"
+              >
+                <i :class="showRegisterPassword ? 'pi pi-eye-slash' : 'pi pi-eye'" class="text-sm" />
+              </button>
+            </div>
+          </div>
+          <div>
+            <label class="mb-1 block text-sm font-medium text-slate-700">确认密码</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="请再次输入密码"
+                autocomplete="new-password"
+              />
+              <button
+                type="button"
+                class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+                @click="showRegisterConfirmPassword = !showRegisterConfirmPassword"
+              >
+                <i :class="showRegisterConfirmPassword ? 'pi pi-eye-slash' : 'pi pi-eye'" class="text-sm" />
+              </button>
+            </div>
+          </div>
+
+          <button
+            type="submit"
+            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 ? '注册中...' : '注册' }}
+          </button>
+
+          <div class="text-center text-sm">
+            <RouterLink class="text-slate-600 hover:text-slate-900" :to="{ name: 'home' }">
+              返回扫码页
+            </RouterLink>
+          </div>
+        </form>
+      </div>
+    </div>
+  </div>
+</template>

+ 32 - 0
src/views/qrmanager/ForbiddenView.vue

@@ -0,0 +1,32 @@
+<script setup>
+import { useRouter } from 'vue-router'
+
+const router = useRouter()
+</script>
+
+<template>
+  <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="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' })"
+          >
+            返回概览
+          </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' })"
+          >
+            返回扫码页
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+

+ 363 - 0
src/views/qrmanager/HomeView.vue

@@ -0,0 +1,363 @@
+<script setup>
+import { ref, onMounted, computed, inject } from 'vue'
+import { useRouter } from 'vue-router'
+import { fetchMyQrCodesApi, fetchQrInfoApi, resetPasswordApi } from '@/services/api'
+import { useToast } from 'primevue/usetoast'
+
+const router = useRouter()
+const toast = useToast()
+
+// 注入父组件提供的注册方法
+const registerOpenPasswordDialog = inject('registerOpenPasswordDialog', null)
+
+// 二维码列表
+const qrCodes = ref([])
+const loading = ref(false)
+
+// 修改密码弹窗
+const showPasswordDialog = ref(false)
+const password = ref('')
+const confirmPassword = ref('')
+const passwordLoading = ref(false)
+
+// 二维码查询弹窗
+const showQrLookupDialog = ref(false)
+const qrCode = ref('')
+const qrLookupLoading = ref(false)
+const qrLookupData = ref(null)
+
+const qrTypeLabel = computed(() => {
+  if (!qrLookupData.value?.qrType) return '-'
+  return qrLookupData.value.qrType === 'person' ? '人员' : qrLookupData.value.qrType === 'pet' ? '宠物/物品' : qrLookupData.value.qrType
+})
+
+// 获取我的二维码列表
+const fetchMyQrCodes = async () => {
+  loading.value = true
+  try {
+    const data = await fetchMyQrCodesApi()
+    qrCodes.value = Array.isArray(data) ? data : (data?.list || data?.qrCodes || [])
+  } catch (e) {
+    toast.add({
+      severity: 'error',
+      summary: '加载失败',
+      detail: e?.message || e?.error || '获取二维码列表失败',
+      life: 3000
+    })
+    qrCodes.value = []
+  } finally {
+    loading.value = false
+  }
+}
+
+// 跳转到二维码详情
+const goToQrDetail = (qrCode) => {
+  router.push({ name: 'scan', params: { qrCode } })
+}
+
+// 修改密码相关
+const openPasswordDialog = () => {
+  showPasswordDialog.value = true
+  password.value = ''
+  confirmPassword.value = ''
+}
+
+const closePasswordDialog = () => {
+  showPasswordDialog.value = false
+  password.value = ''
+  confirmPassword.value = ''
+}
+
+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 '两次输入的密码不一致'
+  return ''
+}
+
+const submitPassword = async () => {
+  const err = validatePassword()
+  if (err) {
+    toast.add({ severity: 'warn', summary: '校验失败', detail: err, life: 3000 })
+    return
+  }
+
+  passwordLoading.value = true
+  try {
+    const data = await resetPasswordApi(password.value)
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: data?.message || '密码重置成功',
+      life: 2500
+    })
+    closePasswordDialog()
+  } catch (e) {
+    toast.add({
+      severity: 'error',
+      summary: '失败',
+      detail: e?.message || e?.error || '重置失败,请确认已登录且密码符合规则',
+      life: 3500
+    })
+  } finally {
+    passwordLoading.value = false
+  }
+}
+
+// 二维码查询相关
+const openQrLookupDialog = () => {
+  showQrLookupDialog.value = true
+  qrCode.value = ''
+  qrLookupData.value = null
+}
+
+const closeQrLookupDialog = () => {
+  showQrLookupDialog.value = false
+  qrCode.value = ''
+  qrLookupData.value = null
+}
+
+const queryQrCode = async () => {
+  const code = qrCode.value.trim()
+  if (!code) {
+    toast.add({ severity: 'warn', summary: '提示', detail: '请输入 qrCode', life: 2000 })
+    return
+  }
+  qrLookupLoading.value = true
+  qrLookupData.value = null
+  try {
+    qrLookupData.value = await fetchQrInfoApi(code)
+  } catch (e) {
+    toast.add({
+      severity: 'error',
+      summary: '查询失败',
+      detail: e?.message || e?.error || '请确认 qrCode 是否正确',
+      life: 3000
+    })
+  } finally {
+    qrLookupLoading.value = false
+  }
+}
+
+const openScanPage = async () => {
+  const code = (qrLookupData.value?.qrCode || qrCode.value).trim()
+  if (!code) return
+  await router.push({ name: 'scan', params: { qrCode: code } })
+}
+
+onMounted(() => {
+  fetchMyQrCodes()
+  
+  // 注册打开密码对话框的方法到父组件
+  if (registerOpenPasswordDialog) {
+    registerOpenPasswordDialog(openPasswordDialog)
+  }
+})
+</script>
+
+<template>
+  <div>
+    <!-- 顶部操作栏 -->
+    <div class="mb-6 flex items-center justify-between">
+      <div>
+        <div class="text-xl font-semibold text-slate-900">我的二维码</div>
+        <div class="mt-1 text-sm text-slate-500">管理您创建的二维码</div>
+      </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>
+
+    <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>
+
+    <div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2">
+      <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)"
+      >
+        <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">
+              {{ 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"
+          >
+            <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
+          </svg>
+        </div>
+      </div>
+    </div>
+
+    <!-- 修改密码对话框 -->
+    <div
+      v-if="showPasswordDialog"
+      class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
+      @click.self="closePasswordDialog"
+    >
+      <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>
+
+        <form class="space-y-4" @submit.prevent="submitPassword">
+          <div>
+            <label class="mb-1 block text-sm font-medium text-slate-700">新密码</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"
+              autocomplete="new-password"
+            />
+          </div>
+          <div>
+            <label class="mb-1 block text-sm font-medium text-slate-700">确认新密码</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="再次输入"
+              autocomplete="new-password"
+            />
+          </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="closePasswordDialog"
+              :disabled="passwordLoading"
+            >
+              取消
+            </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 ? '提交中...' : '确认修改' }}
+            </button>
+          </div>
+        </form>
+      </div>
+    </div>
+
+    <!-- 二维码查询对话框 -->
+    <div
+      v-if="showQrLookupDialog"
+      class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
+      @click.self="closeQrLookupDialog"
+    >
+      <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>
+
+        <div class="flex flex-col gap-3 sm:flex-row sm:items-end">
+          <div class="w-full sm:max-w-md">
+            <label class="mb-1 block text-sm font-medium text-slate-700">qrCode</label>
+            <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..."
+            />
+          </div>
+          <div class="flex gap-2">
+            <button
+              class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 disabled:opacity-60"
+              :disabled="qrLookupLoading"
+              @click="queryQrCode"
+            >
+              {{ qrLookupLoading ? '查询中...' : '查询' }}
+            </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"
+            >
+              打开扫码页
+            </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"
+            >
+              关闭
+            </button>
+          </div>
+        </div>
+
+        <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="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>
+            <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?.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>
+            <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>
+</template>
+

+ 107 - 0
src/views/qrmanager/NavigationHeader.vue

@@ -0,0 +1,107 @@
+<script setup>
+import { computed, ref, provide } from 'vue'
+import { useRouter } from 'vue-router'
+import { useUserStore } from '@/stores/user'
+
+const router = useRouter()
+const userStore = useUserStore()
+
+const user = computed(() => userStore.userInfo)
+const showUserMenu = ref(false)
+
+// 提供打开密码对话框的方法
+const openPasswordDialogHandler = ref(null)
+provide('registerOpenPasswordDialog', (handler) => {
+  openPasswordDialogHandler.value = handler
+})
+
+const logout = async () => {
+  userStore.logout()
+  await router.replace({ name: 'qrmanagerAuth' })
+  showUserMenu.value = false
+}
+
+const openPasswordDialog = () => {
+  if (openPasswordDialogHandler.value) {
+    openPasswordDialogHandler.value()
+  }
+  showUserMenu.value = false
+}
+
+// 点击外部关闭菜单
+const handleClickOutside = (event) => {
+  if (!event.target.closest('.user-menu-container')) {
+    showUserMenu.value = false
+  }
+}
+</script>
+
+<template>
+  <div class="min-h-screen bg-[var(--p-surface-50)]" @click="handleClickOutside">
+    <header class="sticky top-0 z-10 border-b border-slate-200 bg-white/80 backdrop-blur">
+      <div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
+        <div class="flex items-center gap-3">
+          <div class="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white">
+            QR
+          </div>
+          <div>
+            <div class="text-sm font-semibold text-slate-900">QR Manager</div>
+            <div class="text-xs text-slate-500">账户与二维码管理</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="用户菜单"
+          >
+            <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"
+            >
+              <path
+                stroke-linecap="round"
+                stroke-linejoin="round"
+                d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
+              />
+            </svg>
+          </button>
+
+          <!-- 下拉菜单 -->
+          <div
+            v-if="showUserMenu"
+            class="absolute right-0 mt-2 w-48 rounded-xl border border-slate-200 bg-white shadow-lg"
+          >
+            <div class="p-2">
+              <div class="px-3 py-2 text-xs text-slate-500 border-b border-slate-100">
+                {{ user?.name || '-' }}
+              </div>
+              <button
+                class="w-full rounded-lg px-3 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 transition-colors"
+                @click="openPasswordDialog"
+              >
+                修改密码
+              </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"
+              >
+                退出登录
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </header>
+
+    <main class="mx-auto max-w-6xl px-4 py-6">
+      <RouterView />
+    </main>
+  </div>
+</template>
+