diff --git a/src/components/DeviceRegisterDialog.vue b/src/components/DeviceRegisterDialog.vue index 0305fa5..9df446a 100644 --- a/src/components/DeviceRegisterDialog.vue +++ b/src/components/DeviceRegisterDialog.vue @@ -56,6 +56,10 @@ watch(isOpen, (newVal) => { if (newVal && activeTab.value === 'register' && !newUuid.value) { generateRandomUuid() } + // 对话框打开且当前为注册页时,根据登录状态默认勾选绑定 + if (newVal && activeTab.value === 'register' && accountStore.isAuthenticated) { + bindToAccount.value = true + } }) // 监听选项卡切换 @@ -69,6 +73,10 @@ watch(activeTab, (newVal) => { if (newVal === 'register' && !newUuid.value) { generateRandomUuid() } + // 进入注册页时,根据当前登录状态设置“绑定到账户”的默认勾选 + if (newVal === 'register') { + bindToAccount.value = accountStore.isAuthenticated + } }) // 监听是否登录,自动设置绑定选项 @@ -113,7 +121,7 @@ const loadAccountDevices = async () => { loadingDevices.value = true try { - const response = await apiClient.getAccountDevices(accountStore.token) + const response = await apiClient.getAccountDevices() accountDevices.value = response.data || [] if (accountDevices.value.length === 0) { @@ -179,14 +187,13 @@ const registerDevice = async () => { // 2. 调用设备注册接口(会自动在云端创建设备) await apiClient.registerDevice( newUuid.value.trim(), - deviceName.value.trim(), - accountStore.isAuthenticated ? accountStore.token : null + deviceName.value.trim() ) // 3. 如果选择绑定到账户,现在可以安全地绑定 if (bindToAccount.value && accountStore.isAuthenticated) { try { - await apiClient.bindDeviceToAccount(accountStore.token, newUuid.value.trim()) + await apiClient.bindDeviceToAccount(newUuid.value.trim()) } catch (error) { console.warn('设备绑定失败:', error.message) toast.warning('设备注册成功,但绑定到账户失败') diff --git a/src/components/FeatureNavigation.vue b/src/components/FeatureNavigation.vue index 770c301..0850ca5 100644 --- a/src/components/FeatureNavigation.vue +++ b/src/components/FeatureNavigation.vue @@ -33,15 +33,6 @@ const features = [ iconBg: 'bg-purple-500/10', iconColor: 'text-purple-600 dark:text-purple-400', }, - { - title: 'KV 管理器', - description: '浏览和管理键值存储数据,支持批量操作', - icon: Database, - path: '/kv-manager', - color: 'from-green-500 to-emerald-500', - iconBg: 'bg-green-500/10', - iconColor: 'text-green-600 dark:text-green-400', - }, { title: '设备管理', description: '管理您账户下的所有设备,修改名称和密码', diff --git a/src/composables/useOAuthCallback.js b/src/composables/useOAuthCallback.js index d6e5996..51f9f52 100644 --- a/src/composables/useOAuthCallback.js +++ b/src/composables/useOAuthCallback.js @@ -14,6 +14,11 @@ export function useOAuthCallback() { const handleOAuthCallback = async () => { const { token, provider, color, success, error } = route.query + // 新版参数:access_token / refresh_token / expires_in / providerName / providerColor + const access_token = route.query.access_token || token + const refresh_token = route.query.refresh_token || null + const providerName = route.query.providerName || provider + const providerColor = route.query.providerColor || color // 检查是否是OAuth回调 if (!success && !error) { @@ -21,15 +26,15 @@ export function useOAuthCallback() { } // 处理成功回调 - if (success === 'true' && token) { + if (success === 'true' && access_token) { try { - // 保存token到localStorage - localStorage.setItem('auth_token', token) - localStorage.setItem('auth_provider', provider) - if (color) localStorage.setItem('auth_provider_color', color) + // 保存到 Store(同时写入 localStorage) + accountStore.setTokens(access_token, refresh_token) + if (providerName) localStorage.setItem('auth_provider', providerName) + if (providerColor) localStorage.setItem('auth_provider_color', providerColor) - // 登录到store - await accountStore.login(token) + // 登录到store(加载资料、设备) + await accountStore.login(access_token) // 显示成功提示 toast.success('登录成功', { @@ -37,22 +42,21 @@ export function useOAuthCallback() { }) // 清除URL参数 - router.replace({ query: {} }) + router.replace({ query: {} }) // 触发storage事件,通知其他窗口 - window.dispatchEvent(new StorageEvent('storage', { - key: 'auth_token', - newValue: token, - url: window.location.href - })) + window.dispatchEvent(new StorageEvent('storage', { key: 'auth_token', newValue: access_token, url: window.location.href })) + if (refresh_token) { + window.dispatchEvent(new StorageEvent('storage', { key: 'auth_refresh_token', newValue: refresh_token, url: window.location.href })) + } // 如果是在新窗口中打开的OAuth回调,自动关闭窗口 if (window.opener) { // 通知父窗口登录成功 window.opener.postMessage({ type: 'oauth_success', - token, - provider, + token: access_token, + provider: providerName, }, window.location.origin) // 延迟关闭窗口,确保消息已发送 @@ -111,6 +115,9 @@ export function useOAuthCallback() { // 其他标签页已登录,刷新当前页面的状态 accountStore.login(e.newValue) } + if (e.key === 'auth_refresh_token' && e.newValue) { + accountStore.setTokens(accountStore.token, e.newValue) + } } onMounted(() => { diff --git a/src/lib/api.js b/src/lib/api.js index 3c17851..6ecbcd5 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -310,35 +310,29 @@ class ApiClient { } } - // 账户相关 API + // 账户相关 API(Authorization 由 axios 拦截器统一注入) async getOAuthProviders() { return this.fetch('/accounts/oauth/providers') } - async getAccountProfile(token) { - return this.fetch('/accounts/profile', { - headers: { 'Authorization': `Bearer ${token}` } - }) + async getAccountProfile() { + return this.fetch('/accounts/profile') } - async getAccountDevices(token) { - return this.fetch('/accounts/devices', { - headers: { 'Authorization': `Bearer ${token}` } - }) + async getAccountDevices() { + return this.fetch('/accounts/devices') } - async bindDevice(token, deviceUuid) { + async bindDevice(deviceUuid) { return this.fetch('/accounts/devices/bind', { method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ uuid: deviceUuid }), }) } - async unbindDevice(token, deviceUuid) { + async unbindDevice(deviceUuid) { return this.fetch('/accounts/devices/unbind', { method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ uuid: deviceUuid }), }) } @@ -347,28 +341,44 @@ class ApiClient { return this.fetch(`/accounts/device/${deviceUuid}/account`) } + // ===== 账户 Token 刷新与信息 ===== + async refreshAccessToken(refreshToken) { + return this.fetch('/accounts/refresh', { + method: 'POST', + // 刷新接口不应由请求拦截器附加旧的 Authorization + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }) + } + + async getTokenInfo(accessToken) { + return this.fetch('/accounts/token-info', { + headers: { 'Authorization': `Bearer ${accessToken}` } + }) + } + // 绑定设备到当前账户 - async bindDeviceToAccount(token, deviceUuid) { - return this.authenticatedFetch('/accounts/devices/bind', { + async bindDeviceToAccount(deviceUuid) { + return this.fetch('/accounts/devices/bind', { method: 'POST', body: JSON.stringify({ uuid: deviceUuid }), - }, token) + }) } // 解绑设备 - async unbindDeviceFromAccount(token, deviceUuid) { - return this.authenticatedFetch('/accounts/devices/unbind', { + async unbindDeviceFromAccount(deviceUuid) { + return this.fetch('/accounts/devices/unbind', { method: 'POST', body: JSON.stringify({ uuid: deviceUuid }), - }, token) + }) } // 批量解绑设备 - async batchUnbindDevices(token, deviceUuids) { - return this.authenticatedFetch('/accounts/devices/unbind', { + async batchUnbindDevices(deviceUuids) { + return this.fetch('/accounts/devices/unbind', { method: 'POST', body: JSON.stringify({ uuids: deviceUuids }), - }, token) + }) } // 设备名称管理 API diff --git a/src/lib/axios.js b/src/lib/axios.js index 893761a..86d2620 100644 --- a/src/lib/axios.js +++ b/src/lib/axios.js @@ -14,9 +14,42 @@ const axiosInstance = axios.create({ }, }) +// ===== 认证相关 Hooks(由账户 Store 注入)===== +let authHandlers = { + // 可选:返回访问令牌 + getAccessToken: () => null, + // 可选:返回刷新令牌 + getRefreshToken: () => null, + // 可选:仅更新访问令牌 + setAccessToken: (_t) => {}, + // 可选:执行刷新动作,返回新的访问令牌 + refreshAccessToken: null, + // 可选:当刷新失败时回调(例如触发登出) + onAuthFailure: () => {}, +} + +// 对外方法:由外部(如 Pinia store)注入实际的处理函数 +export function setAuthHandlers(handlers) { + authHandlers = { ...authHandlers, ...(handlers || {}) } +} + // 请求拦截器 axiosInstance.interceptors.request.use( (config) => { + try { + // 为非刷新接口自动附加 Authorization 头 + const isRefreshRequest = typeof config.url === 'string' && /\/accounts\/refresh(\b|\/|\?)/.test(config.url) + const skipAuth = config.__skipAuth === true || isRefreshRequest + if (!skipAuth && authHandlers?.getAccessToken) { + const token = authHandlers.getAccessToken() + if (token) { + config.headers = config.headers || {} + config.headers['Authorization'] = `Bearer ${token}` + } + } + } catch { + // ignore + } return config }, (error) => { @@ -96,17 +129,62 @@ async function ensureDeviceRegistered(uuid, authHeader) { } } -// 响应拦截器(含自动注册并重试) +// 刷新中的 Promise(用于合并并发 401 刷新) +let refreshingPromise = null + +// 响应拦截器(含自动换发、自动注册并重试、401 刷新) axiosInstance.interceptors.response.use( (response) => { + // 如果服务端主动通过响应头下发新访问令牌,更新本地 + try { + const headers = response?.headers || {} + const newToken = getHeaderIgnoreCase(headers, 'X-New-Access-Token') + if (newToken && authHandlers?.setAccessToken) { + authHandlers.setAccessToken(newToken) + } + } catch {} return response.data }, async (error) => { const config = error.config || {} + const resp = error.response + const status = resp?.status const skip = config.skipDeviceRegistrationRetry || config.__isRegistrationRequest - const backendMessage = error.response?.data?.message + const backendMessage = resp?.data?.message const message = backendMessage || error.message || 'Unknown error' + // 优先处理 401:尝试使用刷新令牌换发 + if (status === 401 && !config.__retriedAfterRefresh) { + try { + // 若没有刷新能力或没有刷新令牌,则直接走失败逻辑 + if (!authHandlers?.refreshAccessToken || !authHandlers?.getRefreshToken || !authHandlers.getRefreshToken()) { + throw new Error('NO_REFRESH_TOKEN') + } + + // 合并并发刷新 + if (!refreshingPromise) { + refreshingPromise = authHandlers.refreshAccessToken() + .catch((e) => { + // 刷新失败,触发失败处理 + try { authHandlers?.onAuthFailure && authHandlers.onAuthFailure(e) } catch {} + throw e + }) + .finally(() => { + refreshingPromise = null + }) + } + + await refreshingPromise + // 刷新成功后重试一次原请求 + config.__retriedAfterRefresh = true + // 由请求拦截器负责附加新 Authorization,无需手动改 headers + return await axiosInstance.request(config) + } catch (refreshErr) { + // 刷新失败,返回原始错误信息 + return Promise.reject(new Error(message)) + } + } + // 仅在后端提示设备不存在时尝试注册并重试,且保证只重试一次 if (!skip && !config.__retriedAfterRegistration && typeof backendMessage === 'string' && backendMessage.startsWith('设备不存在')) { // 从 headers / url / body 提取 uuid @@ -139,6 +217,7 @@ axiosInstance.interceptors.response.use( } } + // 其他错误:附带信息抛出 return Promise.reject(new Error(message)) } ) diff --git a/src/pages/auto-auth-management.vue b/src/pages/auto-auth-management.vue index b0aff19..bfc22fc 100644 --- a/src/pages/auto-auth-management.vue +++ b/src/pages/auto-auth-management.vue @@ -108,9 +108,9 @@ const checkDeviceAndLoad = async () => { try { deviceInfo.value = await apiClient.getDeviceInfo(uuid) console.log(deviceInfo.value) - console.log(accountStore) + console.log(accountStore.profile) // 检查设备是否绑定到当前账户 - if (!deviceInfo.value.account.id || deviceInfo.value.account.id !== accountStore.profile.id) { + if (!deviceInfo.value.account || !deviceInfo.value.account.id || deviceInfo.value.account.id !== accountStore.profile.id) { toast.error('该设备未绑定到您的账户', { description: '请先在主页绑定设备到您的账户' }) diff --git a/src/pages/device-management.vue b/src/pages/device-management.vue index ca35341..baf184a 100644 --- a/src/pages/device-management.vue +++ b/src/pages/device-management.vue @@ -48,7 +48,7 @@ const loadDevices = async () => { isLoading.value = true try { - const response = await apiClient.getAccountDevices(accountStore.token) + const response = await apiClient.getAccountDevices() devices.value = response.data || [] } catch (error) { toast.error('加载设备列表失败:' + error.message) @@ -68,7 +68,7 @@ const unbindDevice = async () => { if (!currentDevice.value) return try { - await apiClient.unbindDeviceFromAccount(accountStore.token, currentDevice.value.uuid) + await apiClient.unbindDeviceFromAccount(currentDevice.value.uuid) toast.success('设备已解绑') showDeleteDialog.value = false currentDevice.value = null diff --git a/src/pages/index.vue b/src/pages/index.vue index a20bfe7..57d4e40 100644 --- a/src/pages/index.vue +++ b/src/pages/index.vue @@ -161,9 +161,7 @@ const authorizeApp = async () => { options.password = authPassword.value } - if (accountStore.isAuthenticated) { - options.token = accountStore.token - } + // 账户已登录时无需显式传 token,axios 会自动注入 Authorization // 调用授权接口 await apiClient.authorizeApp( @@ -327,7 +325,7 @@ const bindCurrentDevice = async () => { } try { - await apiClient.bindDeviceToAccount(accountStore.token, deviceUuid.value) + await apiClient.bindDeviceToAccount(deviceUuid.value) await loadDeviceInfo() toast.success('设备已绑定到您的账户') } catch (error) { @@ -336,10 +334,9 @@ const bindCurrentDevice = async () => { try { await apiClient.registerDevice( deviceUuid.value, - deviceInfo.value?.deviceName || null, - accountStore.token + deviceInfo.value?.deviceName || null ) - await apiClient.bindDeviceToAccount(accountStore.token, deviceUuid.value) + await apiClient.bindDeviceToAccount(deviceUuid.value) await loadDeviceInfo() toast.success('设备已注册并绑定到您的账户') } catch (retryError) { @@ -359,7 +356,7 @@ const unbindCurrentDevice = async () => { } try { - await apiClient.unbindDeviceFromAccount(accountStore.token, deviceUuid.value) + await apiClient.unbindDeviceFromAccount(deviceUuid.value) await loadDeviceInfo() toast.success('设备已解绑') } catch (error) { @@ -421,7 +418,7 @@ onMounted(async () => {
云原生键值数据库
+文档形键值数据库