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 () => {

Classworks KV

-

云原生键值数据库

+

文档形键值数据库

diff --git a/src/stores/account.js b/src/stores/account.js index 2d5a11b..9ebda3b 100644 --- a/src/stores/account.js +++ b/src/stores/account.js @@ -1,15 +1,20 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { apiClient } from '@/lib/api' +import { setAuthHandlers } from '@/lib/axios' export const useAccountStore = defineStore('account', () => { // 状态 - const token = ref(localStorage.getItem('auth_token') || null) + // 访问令牌(兼容旧 key: auth_token) + const token = ref(localStorage.getItem('auth_token') || localStorage.getItem('auth_access_token') || null) + const refreshToken = ref(localStorage.getItem('auth_refresh_token') || null) + const accessExpiresAt = ref(Number(localStorage.getItem('auth_access_exp') || 0) || 0) // ms 时间戳 const profile = ref(null) const devices = ref([]) const loading = ref(false) const providerName = ref(localStorage.getItem('auth_provider') || '') const providerColor = ref(localStorage.getItem('auth_provider_color') || '') + let proactiveTimer = null // 计算属性 const isAuthenticated = computed(() => !!token.value) @@ -19,17 +24,70 @@ export const useAccountStore = defineStore('account', () => { const userProviderDisplay = computed(() => profile.value?.providerInfo?.displayName || profile.value?.providerInfo?.name || providerName.value || profile.value?.provider || '') const userProviderColor = computed(() => profile.value?.providerInfo?.color || providerColor.value || '') + // 工具:解析 JWT 过期时间(秒时间戳),返回 ms + function decodeJwtExpMs(jwt) { + try { + const [, payload] = jwt.split('.') + if (!payload) return 0 + const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))) + if (!json?.exp) return 0 + return Number(json.exp) * 1000 + } catch { + return 0 + } + } + + function clearProactiveTimer() { + if (proactiveTimer) { + clearTimeout(proactiveTimer) + proactiveTimer = null + } + } + + function scheduleProactiveRefresh() { + clearProactiveTimer() + if (!token.value) return + const expMs = accessExpiresAt.value || decodeJwtExpMs(token.value) + if (!expMs) return + const now = Date.now() + // 提前 2 分钟刷新,最小 5 秒 + const lead = 2 * 60 * 1000 + let delay = expMs - now - lead + if (delay < 5000) delay = 5000 + proactiveTimer = setTimeout(() => { + refreshNow().catch(() => {}) + }, delay) + } + // 方法 const setToken = (newToken) => { token.value = newToken if (newToken) { localStorage.setItem('auth_token', newToken) + localStorage.setItem('auth_access_token', newToken) + accessExpiresAt.value = decodeJwtExpMs(newToken) || 0 + if (accessExpiresAt.value) localStorage.setItem('auth_access_exp', String(accessExpiresAt.value)) + scheduleProactiveRefresh() } else { localStorage.removeItem('auth_token') + localStorage.removeItem('auth_access_token') + localStorage.removeItem('auth_access_exp') + localStorage.removeItem('auth_refresh_token') localStorage.removeItem('auth_provider') localStorage.removeItem('auth_provider_color') providerName.value = '' providerColor.value = '' + refreshToken.value = null + accessExpiresAt.value = 0 + clearProactiveTimer() + } + } + + const setTokens = (access, refresh) => { + if (access) setToken(access) + if (refresh) { + refreshToken.value = refresh + localStorage.setItem('auth_refresh_token', refresh) } } @@ -38,7 +96,7 @@ export const useAccountStore = defineStore('account', () => { loading.value = true try { - const response = await apiClient.getAccountProfile(token.value) + const response = await apiClient.getAccountProfile() profile.value = response.data // 若后端返回 providerInfo,则回填前端展示字段 const p = profile.value?.providerInfo @@ -50,10 +108,6 @@ export const useAccountStore = defineStore('account', () => { } } catch (error) { console.error('Failed to load profile:', error) - // Token可能无效,清除 - if (error.message.includes('401')) { - logout() - } } finally { loading.value = false } @@ -63,7 +117,7 @@ export const useAccountStore = defineStore('account', () => { if (!token.value) return try { - const response = await apiClient.getAccountDevices(token.value) + const response = await apiClient.getAccountDevices() devices.value = response.data || [] return devices.value } catch (error) { @@ -75,7 +129,7 @@ export const useAccountStore = defineStore('account', () => { const bindDevice = async (deviceUuid) => { if (!token.value) throw new Error('未登录') - const response = await apiClient.bindDevice(token.value, deviceUuid) + const response = await apiClient.bindDevice(deviceUuid) // 重新加载设备列表 await loadDevices() return response @@ -84,7 +138,7 @@ export const useAccountStore = defineStore('account', () => { const unbindDevice = async (deviceUuid) => { if (!token.value) throw new Error('未登录') - const response = await apiClient.unbindDevice(token.value, deviceUuid) + const response = await apiClient.unbindDevice(deviceUuid) // 重新加载设备列表 await loadDevices() return response @@ -100,15 +154,37 @@ export const useAccountStore = defineStore('account', () => { token.value = null profile.value = null devices.value = [] - localStorage.removeItem('auth_token') - localStorage.removeItem('auth_provider') - localStorage.removeItem('auth_provider_color') - providerName.value = '' - providerColor.value = '' + setToken(null) } - // 初始化时加载用户信息 + // 刷新访问令牌(按需刷新) + const refreshNow = async () => { + if (!refreshToken.value) throw new Error('No refresh token') + const res = await apiClient.refreshAccessToken(refreshToken.value) + // 期望结构:{ success, data: { access_token, expires_in, account? } } + const newAccess = res?.data?.access_token || res?.access_token || res?.token || null + if (!newAccess) throw new Error(res?.message || '刷新失败') + setToken(newAccess) + return newAccess + } + + // 初始化注入 axios 认证处理 + setAuthHandlers({ + getAccessToken: () => token.value, + getRefreshToken: () => refreshToken.value, + setAccessToken: (t) => setToken(t), + refreshAccessToken: () => refreshNow(), + onAuthFailure: () => logout(), + }) + + // 初始化:迁移旧存储并设置定时刷新 if (token.value) { + // 若未存储过期时间,尝试从JWT解析 + if (!accessExpiresAt.value) { + accessExpiresAt.value = decodeJwtExpMs(token.value) || 0 + if (accessExpiresAt.value) localStorage.setItem('auth_access_exp', String(accessExpiresAt.value)) + } + scheduleProactiveRefresh() loadProfile() loadDevices() } @@ -116,6 +192,7 @@ export const useAccountStore = defineStore('account', () => { return { // 状态 token, + refreshToken, profile, devices, loading, @@ -130,11 +207,13 @@ export const useAccountStore = defineStore('account', () => { userProviderColor, // 方法 setToken, + setTokens, loadProfile, loadDevices, bindDevice, unbindDevice, login, logout, + refreshNow, } }) \ No newline at end of file