mirror of
https://github.com/ZeroCatDev/ClassworksKVAdmin.git
synced 2025-12-07 18:13:09 +00:00
feat: 优化账户相关 API,移除显式传递 token,支持自动注入;添加访问令牌刷新机制
This commit is contained in:
parent
971f8c121e
commit
36d0da03fb
@ -56,6 +56,10 @@ watch(isOpen, (newVal) => {
|
|||||||
if (newVal && activeTab.value === 'register' && !newUuid.value) {
|
if (newVal && activeTab.value === 'register' && !newUuid.value) {
|
||||||
generateRandomUuid()
|
generateRandomUuid()
|
||||||
}
|
}
|
||||||
|
// 对话框打开且当前为注册页时,根据登录状态默认勾选绑定
|
||||||
|
if (newVal && activeTab.value === 'register' && accountStore.isAuthenticated) {
|
||||||
|
bindToAccount.value = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听选项卡切换
|
// 监听选项卡切换
|
||||||
@ -69,6 +73,10 @@ watch(activeTab, (newVal) => {
|
|||||||
if (newVal === 'register' && !newUuid.value) {
|
if (newVal === 'register' && !newUuid.value) {
|
||||||
generateRandomUuid()
|
generateRandomUuid()
|
||||||
}
|
}
|
||||||
|
// 进入注册页时,根据当前登录状态设置“绑定到账户”的默认勾选
|
||||||
|
if (newVal === 'register') {
|
||||||
|
bindToAccount.value = accountStore.isAuthenticated
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听是否登录,自动设置绑定选项
|
// 监听是否登录,自动设置绑定选项
|
||||||
@ -113,7 +121,7 @@ const loadAccountDevices = async () => {
|
|||||||
|
|
||||||
loadingDevices.value = true
|
loadingDevices.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.getAccountDevices(accountStore.token)
|
const response = await apiClient.getAccountDevices()
|
||||||
accountDevices.value = response.data || []
|
accountDevices.value = response.data || []
|
||||||
|
|
||||||
if (accountDevices.value.length === 0) {
|
if (accountDevices.value.length === 0) {
|
||||||
@ -179,14 +187,13 @@ const registerDevice = async () => {
|
|||||||
// 2. 调用设备注册接口(会自动在云端创建设备)
|
// 2. 调用设备注册接口(会自动在云端创建设备)
|
||||||
await apiClient.registerDevice(
|
await apiClient.registerDevice(
|
||||||
newUuid.value.trim(),
|
newUuid.value.trim(),
|
||||||
deviceName.value.trim(),
|
deviceName.value.trim()
|
||||||
accountStore.isAuthenticated ? accountStore.token : null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 3. 如果选择绑定到账户,现在可以安全地绑定
|
// 3. 如果选择绑定到账户,现在可以安全地绑定
|
||||||
if (bindToAccount.value && accountStore.isAuthenticated) {
|
if (bindToAccount.value && accountStore.isAuthenticated) {
|
||||||
try {
|
try {
|
||||||
await apiClient.bindDeviceToAccount(accountStore.token, newUuid.value.trim())
|
await apiClient.bindDeviceToAccount(newUuid.value.trim())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('设备绑定失败:', error.message)
|
console.warn('设备绑定失败:', error.message)
|
||||||
toast.warning('设备注册成功,但绑定到账户失败')
|
toast.warning('设备注册成功,但绑定到账户失败')
|
||||||
|
|||||||
@ -33,15 +33,6 @@ const features = [
|
|||||||
iconBg: 'bg-purple-500/10',
|
iconBg: 'bg-purple-500/10',
|
||||||
iconColor: 'text-purple-600 dark:text-purple-400',
|
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: '设备管理',
|
title: '设备管理',
|
||||||
description: '管理您账户下的所有设备,修改名称和密码',
|
description: '管理您账户下的所有设备,修改名称和密码',
|
||||||
|
|||||||
@ -14,6 +14,11 @@ export function useOAuthCallback() {
|
|||||||
|
|
||||||
const handleOAuthCallback = async () => {
|
const handleOAuthCallback = async () => {
|
||||||
const { token, provider, color, success, error } = route.query
|
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回调
|
// 检查是否是OAuth回调
|
||||||
if (!success && !error) {
|
if (!success && !error) {
|
||||||
@ -21,15 +26,15 @@ export function useOAuthCallback() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理成功回调
|
// 处理成功回调
|
||||||
if (success === 'true' && token) {
|
if (success === 'true' && access_token) {
|
||||||
try {
|
try {
|
||||||
// 保存token到localStorage
|
// 保存到 Store(同时写入 localStorage)
|
||||||
localStorage.setItem('auth_token', token)
|
accountStore.setTokens(access_token, refresh_token)
|
||||||
localStorage.setItem('auth_provider', provider)
|
if (providerName) localStorage.setItem('auth_provider', providerName)
|
||||||
if (color) localStorage.setItem('auth_provider_color', color)
|
if (providerColor) localStorage.setItem('auth_provider_color', providerColor)
|
||||||
|
|
||||||
// 登录到store
|
// 登录到store(加载资料、设备)
|
||||||
await accountStore.login(token)
|
await accountStore.login(access_token)
|
||||||
|
|
||||||
// 显示成功提示
|
// 显示成功提示
|
||||||
toast.success('登录成功', {
|
toast.success('登录成功', {
|
||||||
@ -37,22 +42,21 @@ export function useOAuthCallback() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 清除URL参数
|
// 清除URL参数
|
||||||
router.replace({ query: {} })
|
router.replace({ query: {} })
|
||||||
|
|
||||||
// 触发storage事件,通知其他窗口
|
// 触发storage事件,通知其他窗口
|
||||||
window.dispatchEvent(new StorageEvent('storage', {
|
window.dispatchEvent(new StorageEvent('storage', { key: 'auth_token', newValue: access_token, url: window.location.href }))
|
||||||
key: 'auth_token',
|
if (refresh_token) {
|
||||||
newValue: token,
|
window.dispatchEvent(new StorageEvent('storage', { key: 'auth_refresh_token', newValue: refresh_token, url: window.location.href }))
|
||||||
url: window.location.href
|
}
|
||||||
}))
|
|
||||||
|
|
||||||
// 如果是在新窗口中打开的OAuth回调,自动关闭窗口
|
// 如果是在新窗口中打开的OAuth回调,自动关闭窗口
|
||||||
if (window.opener) {
|
if (window.opener) {
|
||||||
// 通知父窗口登录成功
|
// 通知父窗口登录成功
|
||||||
window.opener.postMessage({
|
window.opener.postMessage({
|
||||||
type: 'oauth_success',
|
type: 'oauth_success',
|
||||||
token,
|
token: access_token,
|
||||||
provider,
|
provider: providerName,
|
||||||
}, window.location.origin)
|
}, window.location.origin)
|
||||||
|
|
||||||
// 延迟关闭窗口,确保消息已发送
|
// 延迟关闭窗口,确保消息已发送
|
||||||
@ -111,6 +115,9 @@ export function useOAuthCallback() {
|
|||||||
// 其他标签页已登录,刷新当前页面的状态
|
// 其他标签页已登录,刷新当前页面的状态
|
||||||
accountStore.login(e.newValue)
|
accountStore.login(e.newValue)
|
||||||
}
|
}
|
||||||
|
if (e.key === 'auth_refresh_token' && e.newValue) {
|
||||||
|
accountStore.setTokens(accountStore.token, e.newValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@ -310,35 +310,29 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 账户相关 API
|
// 账户相关 API(Authorization 由 axios 拦截器统一注入)
|
||||||
async getOAuthProviders() {
|
async getOAuthProviders() {
|
||||||
return this.fetch('/accounts/oauth/providers')
|
return this.fetch('/accounts/oauth/providers')
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccountProfile(token) {
|
async getAccountProfile() {
|
||||||
return this.fetch('/accounts/profile', {
|
return this.fetch('/accounts/profile')
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccountDevices(token) {
|
async getAccountDevices() {
|
||||||
return this.fetch('/accounts/devices', {
|
return this.fetch('/accounts/devices')
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async bindDevice(token, deviceUuid) {
|
async bindDevice(deviceUuid) {
|
||||||
return this.fetch('/accounts/devices/bind', {
|
return this.fetch('/accounts/devices/bind', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
|
||||||
body: JSON.stringify({ uuid: deviceUuid }),
|
body: JSON.stringify({ uuid: deviceUuid }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async unbindDevice(token, deviceUuid) {
|
async unbindDevice(deviceUuid) {
|
||||||
return this.fetch('/accounts/devices/unbind', {
|
return this.fetch('/accounts/devices/unbind', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
|
||||||
body: JSON.stringify({ uuid: deviceUuid }),
|
body: JSON.stringify({ uuid: deviceUuid }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -347,28 +341,44 @@ class ApiClient {
|
|||||||
return this.fetch(`/accounts/device/${deviceUuid}/account`)
|
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) {
|
async bindDeviceToAccount(deviceUuid) {
|
||||||
return this.authenticatedFetch('/accounts/devices/bind', {
|
return this.fetch('/accounts/devices/bind', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ uuid: deviceUuid }),
|
body: JSON.stringify({ uuid: deviceUuid }),
|
||||||
}, token)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解绑设备
|
// 解绑设备
|
||||||
async unbindDeviceFromAccount(token, deviceUuid) {
|
async unbindDeviceFromAccount(deviceUuid) {
|
||||||
return this.authenticatedFetch('/accounts/devices/unbind', {
|
return this.fetch('/accounts/devices/unbind', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ uuid: deviceUuid }),
|
body: JSON.stringify({ uuid: deviceUuid }),
|
||||||
}, token)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量解绑设备
|
// 批量解绑设备
|
||||||
async batchUnbindDevices(token, deviceUuids) {
|
async batchUnbindDevices(deviceUuids) {
|
||||||
return this.authenticatedFetch('/accounts/devices/unbind', {
|
return this.fetch('/accounts/devices/unbind', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ uuids: deviceUuids }),
|
body: JSON.stringify({ uuids: deviceUuids }),
|
||||||
}, token)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设备名称管理 API
|
// 设备名称管理 API
|
||||||
|
|||||||
@ -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(
|
axiosInstance.interceptors.request.use(
|
||||||
(config) => {
|
(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
|
return config
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@ -96,17 +129,62 @@ async function ensureDeviceRegistered(uuid, authHeader) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应拦截器(含自动注册并重试)
|
// 刷新中的 Promise(用于合并并发 401 刷新)
|
||||||
|
let refreshingPromise = null
|
||||||
|
|
||||||
|
// 响应拦截器(含自动换发、自动注册并重试、401 刷新)
|
||||||
axiosInstance.interceptors.response.use(
|
axiosInstance.interceptors.response.use(
|
||||||
(response) => {
|
(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
|
return response.data
|
||||||
},
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const config = error.config || {}
|
const config = error.config || {}
|
||||||
|
const resp = error.response
|
||||||
|
const status = resp?.status
|
||||||
const skip = config.skipDeviceRegistrationRetry || config.__isRegistrationRequest
|
const skip = config.skipDeviceRegistrationRetry || config.__isRegistrationRequest
|
||||||
const backendMessage = error.response?.data?.message
|
const backendMessage = resp?.data?.message
|
||||||
const message = backendMessage || error.message || 'Unknown error'
|
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('设备不存在')) {
|
if (!skip && !config.__retriedAfterRegistration && typeof backendMessage === 'string' && backendMessage.startsWith('设备不存在')) {
|
||||||
// 从 headers / url / body 提取 uuid
|
// 从 headers / url / body 提取 uuid
|
||||||
@ -139,6 +217,7 @@ axiosInstance.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 其他错误:附带信息抛出
|
||||||
return Promise.reject(new Error(message))
|
return Promise.reject(new Error(message))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -108,9 +108,9 @@ const checkDeviceAndLoad = async () => {
|
|||||||
try {
|
try {
|
||||||
deviceInfo.value = await apiClient.getDeviceInfo(uuid)
|
deviceInfo.value = await apiClient.getDeviceInfo(uuid)
|
||||||
console.log(deviceInfo.value)
|
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('该设备未绑定到您的账户', {
|
toast.error('该设备未绑定到您的账户', {
|
||||||
description: '请先在主页绑定设备到您的账户'
|
description: '请先在主页绑定设备到您的账户'
|
||||||
})
|
})
|
||||||
|
|||||||
@ -48,7 +48,7 @@ const loadDevices = async () => {
|
|||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.getAccountDevices(accountStore.token)
|
const response = await apiClient.getAccountDevices()
|
||||||
devices.value = response.data || []
|
devices.value = response.data || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('加载设备列表失败:' + error.message)
|
toast.error('加载设备列表失败:' + error.message)
|
||||||
@ -68,7 +68,7 @@ const unbindDevice = async () => {
|
|||||||
if (!currentDevice.value) return
|
if (!currentDevice.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.unbindDeviceFromAccount(accountStore.token, currentDevice.value.uuid)
|
await apiClient.unbindDeviceFromAccount(currentDevice.value.uuid)
|
||||||
toast.success('设备已解绑')
|
toast.success('设备已解绑')
|
||||||
showDeleteDialog.value = false
|
showDeleteDialog.value = false
|
||||||
currentDevice.value = null
|
currentDevice.value = null
|
||||||
|
|||||||
@ -161,9 +161,7 @@ const authorizeApp = async () => {
|
|||||||
options.password = authPassword.value
|
options.password = authPassword.value
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountStore.isAuthenticated) {
|
// 账户已登录时无需显式传 token,axios 会自动注入 Authorization
|
||||||
options.token = accountStore.token
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用授权接口
|
// 调用授权接口
|
||||||
await apiClient.authorizeApp(
|
await apiClient.authorizeApp(
|
||||||
@ -327,7 +325,7 @@ const bindCurrentDevice = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.bindDeviceToAccount(accountStore.token, deviceUuid.value)
|
await apiClient.bindDeviceToAccount(deviceUuid.value)
|
||||||
await loadDeviceInfo()
|
await loadDeviceInfo()
|
||||||
toast.success('设备已绑定到您的账户')
|
toast.success('设备已绑定到您的账户')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -336,10 +334,9 @@ const bindCurrentDevice = async () => {
|
|||||||
try {
|
try {
|
||||||
await apiClient.registerDevice(
|
await apiClient.registerDevice(
|
||||||
deviceUuid.value,
|
deviceUuid.value,
|
||||||
deviceInfo.value?.deviceName || null,
|
deviceInfo.value?.deviceName || null
|
||||||
accountStore.token
|
|
||||||
)
|
)
|
||||||
await apiClient.bindDeviceToAccount(accountStore.token, deviceUuid.value)
|
await apiClient.bindDeviceToAccount(deviceUuid.value)
|
||||||
await loadDeviceInfo()
|
await loadDeviceInfo()
|
||||||
toast.success('设备已注册并绑定到您的账户')
|
toast.success('设备已注册并绑定到您的账户')
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
@ -359,7 +356,7 @@ const unbindCurrentDevice = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.unbindDeviceFromAccount(accountStore.token, deviceUuid.value)
|
await apiClient.unbindDeviceFromAccount(deviceUuid.value)
|
||||||
await loadDeviceInfo()
|
await loadDeviceInfo()
|
||||||
toast.success('设备已解绑')
|
toast.success('设备已解绑')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -421,7 +418,7 @@ onMounted(async () => {
|
|||||||
<h1 class="text-2xl font-bold bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text">
|
<h1 class="text-2xl font-bold bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text">
|
||||||
Classworks KV
|
Classworks KV
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-muted-foreground">云原生键值数据库</p>
|
<p class="text-sm text-muted-foreground">文档形键值数据库</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@ -1,15 +1,20 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { apiClient } from '@/lib/api'
|
import { apiClient } from '@/lib/api'
|
||||||
|
import { setAuthHandlers } from '@/lib/axios'
|
||||||
|
|
||||||
export const useAccountStore = defineStore('account', () => {
|
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 profile = ref(null)
|
||||||
const devices = ref([])
|
const devices = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const providerName = ref(localStorage.getItem('auth_provider') || '')
|
const providerName = ref(localStorage.getItem('auth_provider') || '')
|
||||||
const providerColor = ref(localStorage.getItem('auth_provider_color') || '')
|
const providerColor = ref(localStorage.getItem('auth_provider_color') || '')
|
||||||
|
let proactiveTimer = null
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const isAuthenticated = computed(() => !!token.value)
|
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 userProviderDisplay = computed(() => profile.value?.providerInfo?.displayName || profile.value?.providerInfo?.name || providerName.value || profile.value?.provider || '')
|
||||||
const userProviderColor = computed(() => profile.value?.providerInfo?.color || providerColor.value || '')
|
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) => {
|
const setToken = (newToken) => {
|
||||||
token.value = newToken
|
token.value = newToken
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
localStorage.setItem('auth_token', 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 {
|
} else {
|
||||||
localStorage.removeItem('auth_token')
|
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')
|
||||||
localStorage.removeItem('auth_provider_color')
|
localStorage.removeItem('auth_provider_color')
|
||||||
providerName.value = ''
|
providerName.value = ''
|
||||||
providerColor.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
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.getAccountProfile(token.value)
|
const response = await apiClient.getAccountProfile()
|
||||||
profile.value = response.data
|
profile.value = response.data
|
||||||
// 若后端返回 providerInfo,则回填前端展示字段
|
// 若后端返回 providerInfo,则回填前端展示字段
|
||||||
const p = profile.value?.providerInfo
|
const p = profile.value?.providerInfo
|
||||||
@ -50,10 +108,6 @@ export const useAccountStore = defineStore('account', () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load profile:', error)
|
console.error('Failed to load profile:', error)
|
||||||
// Token可能无效,清除
|
|
||||||
if (error.message.includes('401')) {
|
|
||||||
logout()
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -63,7 +117,7 @@ export const useAccountStore = defineStore('account', () => {
|
|||||||
if (!token.value) return
|
if (!token.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.getAccountDevices(token.value)
|
const response = await apiClient.getAccountDevices()
|
||||||
devices.value = response.data || []
|
devices.value = response.data || []
|
||||||
return devices.value
|
return devices.value
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -75,7 +129,7 @@ export const useAccountStore = defineStore('account', () => {
|
|||||||
const bindDevice = async (deviceUuid) => {
|
const bindDevice = async (deviceUuid) => {
|
||||||
if (!token.value) throw new Error('未登录')
|
if (!token.value) throw new Error('未登录')
|
||||||
|
|
||||||
const response = await apiClient.bindDevice(token.value, deviceUuid)
|
const response = await apiClient.bindDevice(deviceUuid)
|
||||||
// 重新加载设备列表
|
// 重新加载设备列表
|
||||||
await loadDevices()
|
await loadDevices()
|
||||||
return response
|
return response
|
||||||
@ -84,7 +138,7 @@ export const useAccountStore = defineStore('account', () => {
|
|||||||
const unbindDevice = async (deviceUuid) => {
|
const unbindDevice = async (deviceUuid) => {
|
||||||
if (!token.value) throw new Error('未登录')
|
if (!token.value) throw new Error('未登录')
|
||||||
|
|
||||||
const response = await apiClient.unbindDevice(token.value, deviceUuid)
|
const response = await apiClient.unbindDevice(deviceUuid)
|
||||||
// 重新加载设备列表
|
// 重新加载设备列表
|
||||||
await loadDevices()
|
await loadDevices()
|
||||||
return response
|
return response
|
||||||
@ -100,15 +154,37 @@ export const useAccountStore = defineStore('account', () => {
|
|||||||
token.value = null
|
token.value = null
|
||||||
profile.value = null
|
profile.value = null
|
||||||
devices.value = []
|
devices.value = []
|
||||||
localStorage.removeItem('auth_token')
|
setToken(null)
|
||||||
localStorage.removeItem('auth_provider')
|
|
||||||
localStorage.removeItem('auth_provider_color')
|
|
||||||
providerName.value = ''
|
|
||||||
providerColor.value = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化时加载用户信息
|
// 刷新访问令牌(按需刷新)
|
||||||
|
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) {
|
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()
|
loadProfile()
|
||||||
loadDevices()
|
loadDevices()
|
||||||
}
|
}
|
||||||
@ -116,6 +192,7 @@ export const useAccountStore = defineStore('account', () => {
|
|||||||
return {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
token,
|
token,
|
||||||
|
refreshToken,
|
||||||
profile,
|
profile,
|
||||||
devices,
|
devices,
|
||||||
loading,
|
loading,
|
||||||
@ -130,11 +207,13 @@ export const useAccountStore = defineStore('account', () => {
|
|||||||
userProviderColor,
|
userProviderColor,
|
||||||
// 方法
|
// 方法
|
||||||
setToken,
|
setToken,
|
||||||
|
setTokens,
|
||||||
loadProfile,
|
loadProfile,
|
||||||
loadDevices,
|
loadDevices,
|
||||||
bindDevice,
|
bindDevice,
|
||||||
unbindDevice,
|
unbindDevice,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
refreshNow,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user