From b583fd9ce7ef8ff08cd6c874b945b0ddd31818f0 Mon Sep 17 00:00:00 2001 From: Benins Date: Sat, 7 Mar 2026 23:26:52 +0800 Subject: [PATCH 01/18] =?UTF-8?q?refactor(auth):=20=E5=8F=8CToken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/auth.js | 42 +++++++- src/stores/auth.js | 230 +++++++++++++++++++++++++++++++++++++------ src/utils/request.js | 196 ++++++++++++++++++++++++++++-------- 3 files changed, 391 insertions(+), 77 deletions(-) diff --git a/src/api/auth.js b/src/api/auth.js index 164d899..f57db41 100644 --- a/src/api/auth.js +++ b/src/api/auth.js @@ -1,8 +1,44 @@ -import { get, post, put } from '../utils/request' +import { get, post, put, request } from '../utils/request' export const authAPI = { - wechatLogin(code) { - return post('/api/v0/auth/wechat-login', { code }) + wechatLogin(code, options = {}) { + return post('/api/v0/auth/wechat-login', { code }, { + skipAuthRefresh: true, + retryOnAuthFailure: false, + ...options + }) + }, + + refresh(refreshToken, options = {}) { + return post('/api/v0/auth/refresh', { + refresh_token: refreshToken + }, { + skipAuthRefresh: true, + retryOnAuthFailure: false, + ...options + }) + }, + + logout(options = {}) { + return request({ + url: '/api/v0/auth/logout', + method: 'POST', + skipAuthRefresh: true, + retryOnAuthFailure: false, + handleAuthFailure: false, + ...options + }) + }, + + logoutAll(options = {}) { + return request({ + url: '/api/v0/auth/logout-all', + method: 'POST', + skipAuthRefresh: true, + retryOnAuthFailure: false, + handleAuthFailure: false, + ...options + }) } } diff --git a/src/stores/auth.js b/src/stores/auth.js index 4850ce1..c511c21 100644 --- a/src/stores/auth.js +++ b/src/stores/auth.js @@ -2,9 +2,33 @@ import { defineStore } from 'pinia' import Taro from '@tarojs/taro' import { authAPI, userAPI } from '../api' +const STORAGE_KEYS = { + token: 'token', + refreshToken: 'refreshToken', + accessTokenExpiresAt: 'accessTokenExpiresAt', + userInfo: 'userInfo' +} + +const normalizeTimestampToMs = (timestamp) => { + if (timestamp === null || timestamp === undefined || timestamp === '') { + return null + } + + const numericTimestamp = Number(timestamp) + if (Number.isNaN(numericTimestamp) || numericTimestamp <= 0) { + return null + } + + return numericTimestamp > 1e12 ? numericTimestamp : numericTimestamp * 1000 +} + +let ongoingRefreshPromise = null + export const useAuthStore = defineStore('auth', { state: () => ({ token: null, + refreshToken: null, + accessTokenExpiresAt: null, userInfo: null, isLoggedIn: false }), @@ -31,11 +55,15 @@ export const useAuthStore = defineStore('auth', { // 初始化认证状态 initAuth() { try { - const token = Taro.getStorageSync('token') - const userInfo = Taro.getStorageSync('userInfo') + const token = Taro.getStorageSync(STORAGE_KEYS.token) + const refreshToken = Taro.getStorageSync(STORAGE_KEYS.refreshToken) + const accessTokenExpiresAt = Taro.getStorageSync(STORAGE_KEYS.accessTokenExpiresAt) + const userInfo = Taro.getStorageSync(STORAGE_KEYS.userInfo) if (token && userInfo) { this.token = token + this.refreshToken = refreshToken || null + this.accessTokenExpiresAt = accessTokenExpiresAt || null this.userInfo = userInfo this.isLoggedIn = true } @@ -56,9 +84,14 @@ export const useAuthStore = defineStore('auth', { // 调用后端登录接口 const result = await authAPI.wechatLogin(code) - if (result.token && result.user_info) { + if (result.token && result.refresh_token && result.user_info) { // 保存认证信息 - this.setAuth(result.token, result.user_info) + this.setAuth({ + token: result.token, + refreshToken: result.refresh_token, + accessTokenExpiresAt: result.access_token_expires_at, + userInfo: result.user_info + }) Taro.showToast({ title: '登录成功', icon: 'success' @@ -80,57 +113,192 @@ export const useAuthStore = defineStore('auth', { }, // 设置认证信息 - setAuth(token, userInfo) { + setAuth(authData, userInfo) { try { + const normalizedAuthData = typeof authData === 'object' && authData !== null + ? authData + : { + token: authData, + userInfo + } + + const { + token, + refreshToken = null, + accessTokenExpiresAt = null, + userInfo: nextUserInfo = null + } = normalizedAuthData + this.token = token - this.userInfo = userInfo + this.refreshToken = refreshToken + this.accessTokenExpiresAt = accessTokenExpiresAt || null + this.userInfo = nextUserInfo this.isLoggedIn = true // 持久化存储 - Taro.setStorageSync('token', token) - Taro.setStorageSync('userInfo', userInfo) + Taro.setStorageSync(STORAGE_KEYS.token, token) + Taro.setStorageSync(STORAGE_KEYS.userInfo, nextUserInfo) + + if (refreshToken) { + Taro.setStorageSync(STORAGE_KEYS.refreshToken, refreshToken) + } else { + Taro.removeStorageSync(STORAGE_KEYS.refreshToken) + } + + if (accessTokenExpiresAt) { + Taro.setStorageSync(STORAGE_KEYS.accessTokenExpiresAt, accessTokenExpiresAt) + } else { + Taro.removeStorageSync(STORAGE_KEYS.accessTokenExpiresAt) + } } catch (error) { console.error('保存认证信息失败:', error) } }, - // 退出登录 - logout() { + clearAuthState() { + this.token = null + this.refreshToken = null + this.accessTokenExpiresAt = null + this.userInfo = null + this.isLoggedIn = false + }, + + clearAuthStorage() { + Taro.removeStorageSync(STORAGE_KEYS.token) + Taro.removeStorageSync(STORAGE_KEYS.refreshToken) + Taro.removeStorageSync(STORAGE_KEYS.accessTokenExpiresAt) + Taro.removeStorageSync(STORAGE_KEYS.userInfo) + }, + + isAccessTokenExpiringSoon(bufferMs = 60 * 1000) { + const expiresAtMs = normalizeTimestampToMs(this.accessTokenExpiresAt) + if (!this.token || !this.refreshToken || !expiresAtMs) { + return false + } + + return expiresAtMs - Date.now() <= bufferMs + }, + + async refreshAccessToken(options = {}) { + const { + silent = true, + force = false + } = options + + if (!this.refreshToken) { + throw new Error('缺少刷新令牌,请重新登录') + } + + if (!force && this.token && !this.isAccessTokenExpiringSoon()) { + return this.token + } + + if (ongoingRefreshPromise) { + return ongoingRefreshPromise + } + + const currentRefreshToken = this.refreshToken + ongoingRefreshPromise = (async () => { + const result = await authAPI.refresh(currentRefreshToken, { silent }) + if (!result?.token || !result?.refresh_token || !result?.user_info) { + throw new Error('刷新登录态失败') + } + + this.setAuth({ + token: result.token, + refreshToken: result.refresh_token, + accessTokenExpiresAt: result.access_token_expires_at, + userInfo: result.user_info + }) + + return result.token + })() + try { - this.token = null - this.userInfo = null - this.isLoggedIn = false + return await ongoingRefreshPromise + } catch (error) { + console.error('刷新访问令牌失败:', error) + throw error + } finally { + ongoingRefreshPromise = null + } + }, - // 清除本地存储 - Taro.removeStorageSync('token') - Taro.removeStorageSync('userInfo') + async ensureValidAccessToken(bufferMs = 60 * 1000) { + if (!this.token || !this.refreshToken) { + return this.token + } + if (!this.isAccessTokenExpiringSoon(bufferMs)) { + return this.token + } + + return this.refreshAccessToken({ silent: true }) + }, + + // 退出登录 + async logout(options = {}) { + const { + remote = true, + showToast = true + } = options + + const accessToken = this.token + + this.clearAuthState() + this.clearAuthStorage() + + if (showToast) { Taro.showToast({ title: '已退出登录', icon: 'success' }) + } + + try { + if (remote && accessToken) { + authAPI.logout({ + authToken: accessToken, + silent: true + }).catch(error => { + console.error('服务端退出登录失败:', error) + }) + } } catch (error) { - console.error('退出登录失败:', error) + console.error('服务端退出登录失败:', error) } }, - expireToken() { - if (!this.isLoggedIn && !this.token) { + expireToken(options = {}) { + const normalizedOptions = typeof options === 'string' + ? { title: options } + : options + + const { + title = '请重新登录', + showToast = true, + navigate = true + } = normalizedOptions + + if (!this.isLoggedIn && !this.token && !this.refreshToken) { return } - this.token = null - this.userInfo = null - this.isLoggedIn = false + this.clearAuthState() try { - Taro.removeStorageSync('token') - Taro.removeStorageSync('userInfo') - Taro.showToast({ - title: '请重新登录', - icon: 'error' - }) - Taro.navigateTo({ url: '/pages/login/index' }) + this.clearAuthStorage() + + if (showToast) { + Taro.showToast({ + title, + icon: 'error' + }) + } + + if (navigate) { + Taro.navigateTo({ url: '/pages/login/index' }) + } } catch (error) { console.error('expireToken处理失败:', error) } @@ -144,7 +312,7 @@ export const useAuthStore = defineStore('auth', { // 更新本地用户信息 const updatedUserInfo = { ...this.userInfo, ...data } this.userInfo = updatedUserInfo - Taro.setStorageSync('userInfo', updatedUserInfo) + Taro.setStorageSync(STORAGE_KEYS.userInfo, updatedUserInfo) Taro.showToast({ title: '更新成功', @@ -163,7 +331,7 @@ export const useAuthStore = defineStore('auth', { try { const userInfo = await userAPI.getProfile() this.userInfo = userInfo - Taro.setStorageSync('userInfo', userInfo) + Taro.setStorageSync(STORAGE_KEYS.userInfo, userInfo) return userInfo } catch (error) { console.error('获取用户信息失败:', error) diff --git a/src/utils/request.js b/src/utils/request.js index e9bf046..be454d9 100644 --- a/src/utils/request.js +++ b/src/utils/request.js @@ -3,6 +3,12 @@ import { useAuthStore } from '../stores/auth' // API基础配置 const BASE_URL = 'https://example.com' // 请根据实际情况修改 +const AUTH_REFRESH_BUFFER_MS = 60 * 1000 +const AUTH_BYPASS_REFRESH_ENDPOINTS = [ + '/api/v0/auth/refresh', + '/api/v0/auth/wechat-login', + '/api/v0/auth/mock-wechat-login' +] // 生成UUID v4(使用微信小程序加密随机数生成器) const generateUUID = () => { @@ -134,10 +140,47 @@ const startCacheCleanup = () => { } startCacheCleanup() +const createRequestError = (message, extra = {}) => { + return Object.assign(new Error(message), extra) +} + +const getRequestPath = (url = '') => { + if (!url) { + return '' + } + + return url.replace(/^https?:\/\/[^/]+/i, '') +} + +const shouldBypassAuthRefresh = (url = '') => { + const requestPath = getRequestPath(url) + return AUTH_BYPASS_REFRESH_ENDPOINTS.some(endpoint => requestPath.endsWith(endpoint)) +} + +const shouldRetryAfterAuthFailure = (error, options = {}) => { + if (!error?.isAuthError || options.retryOnAuthFailure === false) { + return false + } + + if (options.skipAuthRefresh || shouldBypassAuthRefresh(options.url)) { + return false + } + + return Boolean(useAuthStore().refreshToken) +} + // 请求拦截器 const interceptors = { async request(config) { - const token = useAuthStore().token + const authStore = useAuthStore() + const authToken = config.authToken + const skipAuthRefresh = config.skipAuthRefresh || shouldBypassAuthRefresh(config.url) + + if (!skipAuthRefresh) { + await authStore.ensureValidAccessToken(AUTH_REFRESH_BUFFER_MS) + } + + const token = authToken || authStore.token if (token) { config.header = { ...config.header, @@ -161,9 +204,14 @@ const interceptors = { method, config.url, config.data - ) + ) } + delete config.authToken + delete config.skipAuthRefresh + delete config.retryOnAuthFailure + delete config.handleAuthFailure + return config }, @@ -180,67 +228,125 @@ const interceptors = { if (data.StatusCode === 0) { return data.Result } else if (data.StatusCode === 401) { - // token过期或无效,跳转到登录页 - useAuthStore().expireToken() - return Promise.reject(new Error(data.StatusMessage || '登录已过期')) + return Promise.reject(createRequestError(data.StatusMessage || '登录已过期', { + isAuthError: true, + statusCode: 401, + response + })) } else { - return Promise.reject(new Error(data.StatusMessage || '请求失败')) + return Promise.reject(createRequestError(data.StatusMessage || '请求失败', { + statusCode: data.StatusCode, + response + })) } } else { - return Promise.reject(new Error(`HTTP ${statusCode}`)) + if (statusCode === 401) { + return Promise.reject(createRequestError(data?.StatusMessage || '登录已过期', { + isAuthError: true, + statusCode, + response + })) + } + + return Promise.reject(createRequestError(data?.StatusMessage || `HTTP ${statusCode}`, { + statusCode, + response + })) } } } +const executeRequest = async (options) => { + let requestOptions = { ...options } + delete requestOptions.silent + + // 添加基础URL + if (!requestOptions.url.startsWith('http')) { + requestOptions.url = BASE_URL + requestOptions.url + } + + // 应用请求拦截器 + requestOptions = await interceptors.request(requestOptions) + + return Taro.request(requestOptions).then(interceptors.response) +} + // 通用请求方法 export const request = async (options) => { if (!options?.url) { return Promise.reject(new Error('Request URL is required')) } - // 添加基础URL - if (!options.url.startsWith('http')) { - options.url = BASE_URL + options.url - } - // 是否静默(不显示全局错误 Toast),由调用方自行处理错误提示 - const silent = options.silent - delete options.silent + const { + silent = false, + retryOnAuthFailure = true, + handleAuthFailure = true + } = options // 保存原始请求信息(用于清除幂等性 Key) const originalMethod = options.method const originalUrl = options.url const originalData = options.data - // 应用请求拦截器 - options = await interceptors.request(options) - - return Taro.request(options) - .then(interceptors.response) - .catch(error => { - console.error('Request error:', error) - - if (!silent) { - Taro.showToast({ - title: error.message || error.errMsg || '网络错误', - icon: 'error', - duration: 2000 + const requestOptions = { + ...options, + silent, + retryOnAuthFailure, + handleAuthFailure + } + + try { + return await executeRequest(requestOptions) + } catch (error) { + console.error('Request error:', error) + + let finalError = error + if (shouldRetryAfterAuthFailure(error, requestOptions)) { + try { + await useAuthStore().refreshAccessToken({ + silent: true, + force: true }) - } - return Promise.reject(error) - }).finally(() => { - const method = originalMethod?.toUpperCase() - if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) { - clearIdempotencyKey(method, originalUrl, originalData) + return await request({ + ...options, + retryOnAuthFailure: false + }) + } catch (refreshError) { + console.error('Retry after token refresh failed:', refreshError) + finalError = refreshError } - }) + } + + if (finalError?.isAuthError && handleAuthFailure) { + useAuthStore().expireToken(finalError.message || '请重新登录') + } else if (!silent) { + Taro.showToast({ + title: finalError.message || finalError.errMsg || '网络错误', + icon: 'error', + duration: 2000 + }) + } + + return Promise.reject(finalError) + } finally { + const method = originalMethod?.toUpperCase() + if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) { + clearIdempotencyKey(method, originalUrl, originalData) + } + } } // GET请求 -export const get = (url, data) => { +export const get = (url, data, options = {}) => { if (!data) { - return request({ url, method: 'GET', data: {} }) + return request({ + url, + method: 'GET', + data: {}, + ...options + }) } // 处理数组参数(如categories),转换为多个同名参数 @@ -269,34 +375,38 @@ export const get = (url, data) => { return request({ url, method: 'GET', - data: {} // 传空对象,参数已经在URL中 + data: {}, // 传空对象,参数已经在URL中 + ...options }) } // POST请求 -export const post = (url, data) => { +export const post = (url, data, options = {}) => { return request({ url, method: 'POST', - data + data, + ...options }) } // PUT请求 -export const put = (url, data) => { +export const put = (url, data, options = {}) => { return request({ url, method: 'PUT', - data + data, + ...options }) } // DELETE请求 -export const del = (url, data) => { +export const del = (url, data, options = {}) => { return request({ url, method: 'DELETE', - data + data, + ...options }) } From a1fc17d9d87bcb29acccecee090ac2f4e21f04c8 Mon Sep 17 00:00:00 2001 From: Benins Date: Sun, 8 Mar 2026 01:01:59 +0800 Subject: [PATCH 02/18] =?UTF-8?q?refactor(error):=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=A0=81=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/system.js | 24 ++--- src/pages/admin/rbac/index.vue | 3 +- src/pages/final-review/detail/index.vue | 1 - src/pages/final-review/index.vue | 2 +- src/pages/materials/detail/index.vue | 16 ++-- src/utils/request.js | 111 +++++++++++++++++------- 6 files changed, 99 insertions(+), 58 deletions(-) diff --git a/src/api/system.js b/src/api/system.js index 77f1cf1..8e8febe 100644 --- a/src/api/system.js +++ b/src/api/system.js @@ -1,21 +1,15 @@ -import Taro from '@tarojs/taro' -import { get } from '../utils/request' +import { get, request } from '../utils/request' export const systemAPI = { healthCheck() { - return new Promise((resolve, reject) => { - Taro.request({ - url: 'https://example.com/health', - method: 'GET', - success: (res) => { - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve(res.data) - } else { - reject(new Error(`Health check failed: HTTP ${res.statusCode}`)) - } - }, - fail: (err) => reject(new Error(err.errMsg || 'Health check failed')) - }) + return request({ + url: 'https://example.com/health', + method: 'GET', + data: {}, + silent: true, + skipAuthRefresh: true, + retryOnAuthFailure: false, + handleAuthFailure: false }) } } diff --git a/src/pages/admin/rbac/index.vue b/src/pages/admin/rbac/index.vue index d063ae8..ab64b1e 100644 --- a/src/pages/admin/rbac/index.vue +++ b/src/pages/admin/rbac/index.vue @@ -428,8 +428,7 @@ const fetchPermissions = async () => { const fetchRolePermissions = async () => { try { const response = await rbacAPI.getRolesPermissions(); - // 处理不同的响应格式:可能是 response.roles 或 response.data?.roles - rolePermissions.value = response?.roles || response?.data?.roles || []; + rolePermissions.value = response?.roles || []; } catch (error) { console.error("获取角色权限关联失败:", error); Taro.showToast({ diff --git a/src/pages/final-review/detail/index.vue b/src/pages/final-review/detail/index.vue index 86d60fe..1f1364d 100644 --- a/src/pages/final-review/detail/index.vue +++ b/src/pages/final-review/detail/index.vue @@ -817,7 +817,6 @@ const extractQuestionIds = (res) => { if (Array.isArray(res.question_ids)) return res.question_ids; if (Array.isArray(res.data)) return res.data; if (Array.isArray(res.list)) return res.list; - if (res.Result && Array.isArray(res.Result.question_ids)) return res.Result.question_ids; return []; }; diff --git a/src/pages/final-review/index.vue b/src/pages/final-review/index.vue index 66ca645..a574a85 100644 --- a/src/pages/final-review/index.vue +++ b/src/pages/final-review/index.vue @@ -138,7 +138,7 @@ const fetchCategories = async () => { categoryData = JSON.parse(res); } else if (res && typeof res === 'object') { // 如果返回的是对象,尝试从value字段获取 - categoryData = res.value || res.data || res; + categoryData = Object.prototype.hasOwnProperty.call(res, 'value') ? res.value : res; if (typeof categoryData === 'string') { categoryData = JSON.parse(categoryData); } diff --git a/src/pages/materials/detail/index.vue b/src/pages/materials/detail/index.vue index c96e799..ab0020f 100644 --- a/src/pages/materials/detail/index.vue +++ b/src/pages/materials/detail/index.vue @@ -416,14 +416,14 @@ const deleteMaterial = () => { content: '确定要删除这份资料吗?此操作不可撤销。', success: async (res) => { if (res.confirm) { - const result = await materialAPI.deleteMaterial(md5.value) - Taro.showToast({ - title: result.message, - icon: 'success' - }) - setTimeout(() => { - Taro.navigateBack() - }, 1500) + const response = await materialAPI.deleteMaterial(md5.value) + Taro.showToast({ + title: '删除成功', + icon: 'success' + }) + setTimeout(() => { + Taro.navigateBack() + }, 1500) } } }) diff --git a/src/utils/request.js b/src/utils/request.js index be454d9..0f00ca1 100644 --- a/src/utils/request.js +++ b/src/utils/request.js @@ -2,12 +2,11 @@ import Taro from '@tarojs/taro' import { useAuthStore } from '../stores/auth' // API基础配置 -const BASE_URL = 'https://example.com' // 请根据实际情况修改 +const BASE_URL = '' // 请根据实际情况修改 const AUTH_REFRESH_BUFFER_MS = 60 * 1000 const AUTH_BYPASS_REFRESH_ENDPOINTS = [ '/api/v0/auth/refresh', - '/api/v0/auth/wechat-login', - '/api/v0/auth/mock-wechat-login' + '/api/v0/auth/wechat-login' ] // 生成UUID v4(使用微信小程序加密随机数生成器) @@ -144,6 +143,55 @@ const createRequestError = (message, extra = {}) => { return Object.assign(new Error(message), extra) } +const isStandardResponseEnvelope = (data) => { + return Boolean(data) && + typeof data === 'object' && + Object.prototype.hasOwnProperty.call(data, 'StatusCode') +} + +const getHeaderValue = (headers = {}, targetName = '') => { + if (!headers || !targetName) { + return null + } + + const matchedKey = Object.keys(headers).find(key => key.toLowerCase() === targetName.toLowerCase()) + return matchedKey ? headers[matchedKey] : null +} + +const getResponseRequestId = (response = {}) => { + if (response?.data?.RequestId) { + return response.data.RequestId + } + + return getHeaderValue(response?.header || response?.headers, 'x-request-id') +} + +const getBusinessStatusCode = (data) => { + if (!isStandardResponseEnvelope(data)) { + return null + } + + return data.StatusCode +} + +const createResponseError = (response, fallbackMessage, extra = {}) => { + const data = response?.data + const httpStatusCode = response?.statusCode ?? null + const businessStatusCode = getBusinessStatusCode(data) + const requestId = getResponseRequestId(response) + const message = (data && typeof data === 'object' && data.StatusMessage) || fallbackMessage || '请求失败' + + return createRequestError(message, { + httpStatusCode, + businessStatusCode, + requestId, + statusMessage: message, + statusCode: businessStatusCode ?? httpStatusCode, + response, + ...extra + }) +} + const getRequestPath = (url = '') => { if (!url) { return '' @@ -211,53 +259,48 @@ const interceptors = { delete config.skipAuthRefresh delete config.retryOnAuthFailure delete config.handleAuthFailure + delete config.returnEnvelope return config }, - response(response) { + response(response, options = {}) { const { statusCode, data } = response + const businessStatusCode = getBusinessStatusCode(data) + const isAuthError = statusCode === 401 || businessStatusCode === 401 + const hasStandardEnvelope = isStandardResponseEnvelope(data) // 处理HTTP状态码 if (statusCode >= 200 && statusCode < 300) { - if (!data || typeof data !== 'object') { + if (!hasStandardEnvelope) { return data } // 检查业务状态码 if (data.StatusCode === 0) { - return data.Result - } else if (data.StatusCode === 401) { - return Promise.reject(createRequestError(data.StatusMessage || '登录已过期', { - isAuthError: true, - statusCode: 401, - response - })) - } else { - return Promise.reject(createRequestError(data.StatusMessage || '请求失败', { - statusCode: data.StatusCode, - response - })) - } - } else { - if (statusCode === 401) { - return Promise.reject(createRequestError(data?.StatusMessage || '登录已过期', { - isAuthError: true, - statusCode, - response - })) + return options.returnEnvelope ? data : data.Result } - return Promise.reject(createRequestError(data?.StatusMessage || `HTTP ${statusCode}`, { - statusCode, - response - })) + return Promise.reject(createResponseError( + response, + data.StatusMessage || '请求失败', + { isAuthError } + )) } + + return Promise.reject(createResponseError( + response, + data?.StatusMessage || `HTTP ${statusCode}`, + { isAuthError } + )) } } const executeRequest = async (options) => { let requestOptions = { ...options } + const responseOptions = { + returnEnvelope: requestOptions.returnEnvelope + } delete requestOptions.silent // 添加基础URL @@ -268,7 +311,7 @@ const executeRequest = async (options) => { // 应用请求拦截器 requestOptions = await interceptors.request(requestOptions) - return Taro.request(requestOptions).then(interceptors.response) + return Taro.request(requestOptions).then(response => interceptors.response(response, responseOptions)) } // 通用请求方法 @@ -299,7 +342,13 @@ export const request = async (options) => { try { return await executeRequest(requestOptions) } catch (error) { - console.error('Request error:', error) + console.error('Request error:', { + message: error?.message, + httpStatusCode: error?.httpStatusCode, + businessStatusCode: error?.businessStatusCode, + requestId: error?.requestId, + error + }) let finalError = error if (shouldRetryAfterAuthFailure(error, requestOptions)) { From 0617782289a1541f4193b457f745ccb535d58936 Mon Sep 17 00:00:00 2001 From: Benins Date: Sun, 8 Mar 2026 02:00:17 +0800 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20=E7=BB=91=E5=AE=9A=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E3=80=81=E9=87=8D=E7=BD=AE=E8=AF=BE=E8=A1=A8=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E3=80=81=E7=99=BB=E9=99=86=E8=BF=9B=E5=BA=A6api?= =?UTF-8?q?=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/auth.js | 4 + src/api/courseTable.js | 11 +- src/pages/profile/index.vue | 77 +++++++++++++- src/pages/schedule/schedule-bind/index.vue | 112 ++++++++++++++------- 4 files changed, 164 insertions(+), 40 deletions(-) diff --git a/src/api/auth.js b/src/api/auth.js index f57db41..ed425f5 100644 --- a/src/api/auth.js +++ b/src/api/auth.js @@ -49,5 +49,9 @@ export const userAPI = { updateProfile(data) { return put('/api/v0/user/profile', data) + }, + + getLoginDays() { + return get('/api/v0/user/login-days') } } diff --git a/src/api/courseTable.js b/src/api/courseTable.js index 7998d05..bb1b406 100644 --- a/src/api/courseTable.js +++ b/src/api/courseTable.js @@ -1,10 +1,14 @@ -import { get, put, post } from '../utils/request' +import { get, put, post, del } from '../utils/request' export const courseTableAPI = { getCourseTable(params) { return get('/api/v0/coursetable', params) }, + getBindCount() { + return get('/api/v0/coursetable/bind-count') + }, + searchClass(params) { return get('/api/v0/coursetable/search', params) }, @@ -20,5 +24,10 @@ export const courseTableAPI = { resetBindCount(userId) { if (!userId) return Promise.reject(new Error('userId is required')) return post(`/api/v0/coursetable/reset/${userId}`) + }, + + deleteSchedule(semester) { + if (!semester) return Promise.reject(new Error('semester is required')) + return del(`/api/v0/coursetable/schedule?semester=${encodeURIComponent(semester)}`) } } diff --git a/src/pages/profile/index.vue b/src/pages/profile/index.vue index 743b28e..0d18bfe 100644 --- a/src/pages/profile/index.vue +++ b/src/pages/profile/index.vue @@ -22,7 +22,7 @@ 账号 - {{ roleTagText }} + {{ roleTagText }} {{ userInfo?.id || "无" }} @@ -168,6 +168,54 @@ + + + + + + 用户等级 + + + + + + 登录天数 + {{ loginDaysData.loginDays }}/25 + + + + + + + + + + 当前等级 + {{ roleTagText }} + + + 下一等级 + {{ roleTagMap.user_active.text }} + + + 达成条件 + 过去 {{ loginDaysData.pastDays }} 天内登录 25 天 + + + + + + 知道了 + + + @@ -175,12 +223,14 @@ import { ref, computed } from "vue"; import { useAuthStore } from "../../stores/auth"; import Taro from "@tarojs/taro"; -import { courseTableAPI, pointsAPI } from "../../api/index"; +import { courseTableAPI, pointsAPI, userAPI } from "../../api/index"; const authStore = useAuthStore(); const userPoints = ref(0); const pointsLoaded = ref(false); const pointsLoadFailed = ref(false); +const loginDaysModal = ref(false); +const loginDaysData = ref({ loginDays: 0, pastDays: 100 }); // 计算属性 const userInfo = computed(() => authStore.userInfo); @@ -216,6 +266,11 @@ const roleTagClass = computed(() => { return roleTagMap.user_basic.class; }); +const isAtLeastActive = computed(() => { + const roleTags = userInfo.value?.role_tags || []; + return ['admin', 'operator', 'user_verified', 'user_active'].some(tag => roleTags.includes(tag)); +}); + // 方法 const goToLogin = () => { Taro.navigateTo({ url: "/pages/login/index" }); @@ -303,6 +358,24 @@ const goToTermsOfService = () => { Taro.navigateTo({ url: "/pages/terms-of-service/index" }); }; +// 显示登录活跃度进度 +const showLoginDaysProgress = async () => { + try { + Taro.showLoading({ title: '加载中...', mask: true }) + const res = await userAPI.getLoginDays() + Taro.hideLoading() + + loginDaysData.value = { + loginDays: res.login_days || 0, + pastDays: res.past_days || 100 + } + loginDaysModal.value = true + } catch (error) { + Taro.hideLoading() + Taro.showToast({ title: '获取失败', icon: 'error' }) + } +}; + // 获取用户积分 const fetchUserPoints = async () => { diff --git a/src/pages/schedule/schedule-bind/index.vue b/src/pages/schedule/schedule-bind/index.vue index 16cc4c8..1ceb9df 100644 --- a/src/pages/schedule/schedule-bind/index.vue +++ b/src/pages/schedule/schedule-bind/index.vue @@ -4,17 +4,11 @@ - + @input="handleSearch" /> - + 搜索 @@ -27,12 +21,8 @@ - + {{ classItem.class_id }} {{ classItem.semester }} @@ -44,23 +34,16 @@ - + 上一页 {{ currentPage }} / {{ totalPages }} - + :class="{ 'opacity-50': currentPage >= totalPages }"> 下一页 @@ -68,7 +51,8 @@ - + 没有找到相关班级 请检查班级名称是否正确 @@ -76,9 +60,15 @@ - 仅有 2 次绑定机会 + 已绑定 {{ bindCount }}/2 次 请输入班级名称进行搜索 如:25软件1班 + + + + 重置课表数据 + @@ -90,17 +80,11 @@ - + 取消 - + {{ isBinding ? '绑定中...' : '确认绑定' }} @@ -114,6 +98,7 @@ import { ref, computed, onBeforeUnmount } from 'vue' import Taro from '@tarojs/taro' import { useScheduleStore } from '../../../stores/schedule' import { useAuthStore } from '../../../stores/auth' +import { courseTableAPI } from '../../../api/index' defineOptions({ name: 'ScheduleBindPage' @@ -122,6 +107,22 @@ defineOptions({ const scheduleStore = useScheduleStore() const authStore = useAuthStore() +// 绑定次数 +const bindCount = ref(0) +const bindCountLoaded = ref(false) + +const fetchBindCount = async () => { + try { + const res = await courseTableAPI.getBindCount() + bindCount.value = res.bind_count || 0 + bindCountLoaded.value = true + } catch (error) { + console.error('获取绑定次数失败:', error) + } +} + +fetchBindCount() + // 计算属性:判断用户是否只有 user_basic 角色标签 const isOnlyUserBasic = computed(() => { const roleTags = authStore.userInfo?.role_tags || [] @@ -279,6 +280,43 @@ const confirmBind = async () => { selectedClass.value = null } } + +// 重置个人课表 +const handleResetSchedule = () => { + const currentSemester = scheduleStore.semester + + if (!currentSemester) { + Taro.showToast({ title: '无学期信息', icon: 'error' }) + return + } + + Taro.showModal({ + title: '重置个人课表', + content: `确定要重置「${currentSemester}」的课表数据吗?重置后课表将恢复为班级初始数据。`, + confirmColor: '#ef4444', + success: async (res) => { + if (res.confirm) { + try { + await courseTableAPI.deleteSchedule(currentSemester) + scheduleStore.fetchCourseTable(currentSemester, true) + Taro.showToast({ title: '重置成功', icon: 'success' }) + setTimeout(() => { + // 传递消息通知课表页刷新 + Taro.eventCenter.trigger('reloadSchedule') + Taro.navigateBack() + }, 1000) + + } catch (error) { + Taro.showModal({ + title: '重置失败', + content: error.message || '请稍后重试', + showCancel: false + }) + } + } + } + }) +} diff --git a/src/pages/notifications/index.vue b/src/pages/notifications/index.vue index 6f6ab18..7998259 100644 --- a/src/pages/notifications/index.vue +++ b/src/pages/notifications/index.vue @@ -45,13 +45,13 @@ - 创建 - + --> - 投稿 - + --> @@ -409,13 +409,13 @@ const goToNotificationDetail = (notification) => { }); }; -const goToCreateNotification = () => { - if (!authStore.requireAuth()) return; +// const goToCreateNotification = () => { +// if (!authStore.requireAuth()) return; - Taro.navigateTo({ - url: "/pages/notifications/create/index", - }); -}; +// Taro.navigateTo({ +// url: "/pages/notifications/create/index", +// }); +// }; const goToManageNotifications = () => { if (!authStore.requireAuth()) return; @@ -441,13 +441,13 @@ const goToManageCategories = () => { }); }; -const goToCreateContribution = () => { - if (!authStore.requireAuth()) return; +// const goToCreateContribution = () => { +// if (!authStore.requireAuth()) return; - Taro.navigateTo({ - url: "/pages/contributions/create/index", - }); -}; +// Taro.navigateTo({ +// url: "/pages/contributions/create/index", +// }); +// }; const goToMyContributions = () => { if (!authStore.requireAuth()) return; From a8401366d905a286874f0f2d55eee1eeb78a3b08 Mon Sep 17 00:00:00 2001 From: Benins Date: Sun, 8 Mar 2026 16:01:38 +0800 Subject: [PATCH 05/18] =?UTF-8?q?delete:=20=E5=88=A0=E9=99=A4=E6=8A=95?= =?UTF-8?q?=E7=A8=BF=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/contribution.js | 27 - src/api/index.js | 1 - src/app.config.js | 4 - .../contributions/create/index.config.js | 3 - src/pages/contributions/create/index.vue | 244 ------- .../contributions/detail/index.config.js | 3 - src/pages/contributions/detail/index.vue | 330 --------- src/pages/contributions/mine/index.config.js | 5 - src/pages/contributions/mine/index.vue | 401 ----------- .../contributions/review/index.config.js | 3 - src/pages/contributions/review/index.vue | 639 ------------------ src/pages/materials/index.vue | 71 +- src/pages/notifications/index.vue | 383 +++++------ src/stores/notifications.js | 230 +------ 14 files changed, 238 insertions(+), 2106 deletions(-) delete mode 100644 src/api/contribution.js delete mode 100644 src/pages/contributions/create/index.config.js delete mode 100644 src/pages/contributions/create/index.vue delete mode 100644 src/pages/contributions/detail/index.config.js delete mode 100644 src/pages/contributions/detail/index.vue delete mode 100644 src/pages/contributions/mine/index.config.js delete mode 100644 src/pages/contributions/mine/index.vue delete mode 100644 src/pages/contributions/review/index.config.js delete mode 100644 src/pages/contributions/review/index.vue diff --git a/src/api/contribution.js b/src/api/contribution.js deleted file mode 100644 index b0739df..0000000 --- a/src/api/contribution.js +++ /dev/null @@ -1,27 +0,0 @@ -import { get, post } from '../utils/request' - -export const contributionAPI = { - createContribution(data) { - return post('/api/v0/contributions', data) - }, - - getContributions(params) { - return get('/api/v0/contributions', params) - }, - - getContributionDetail(id) { - return get(`/api/v0/contributions/${id}`) - }, - - reviewContribution(id, data) { - return post(`/api/v0/contributions/${id}/review`, data) - }, - - getContributionStats() { - return get('/api/v0/contributions/stats') - }, - - getContributionStatsAdmin() { - return get('/api/v0/contributions/stats-admin') - } -} diff --git a/src/api/index.js b/src/api/index.js index 4572a2d..73d3786 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -7,7 +7,6 @@ export { configAPI } from './config' export { ossAPI } from './oss' export { questionsAPI } from './questions' export { notificationAPI } from './notification' -export { contributionAPI } from './contribution' export { materialAPI } from './material' export { systemAPI, statAPI } from './system' export { pointsAPI } from './points' diff --git a/src/app.config.js b/src/app.config.js index 7dbd527..42b2448 100644 --- a/src/app.config.js +++ b/src/app.config.js @@ -31,10 +31,6 @@ export default { 'pages/notifications/detail/index', 'pages/notifications/manage/index', 'pages/notifications/categories/index', - // 用户投稿相关页面 - 'pages/contributions/mine/index', - 'pages/contributions/detail/index', - 'pages/contributions/review/index', // 资料库相关页面 'pages/materials/index', 'pages/materials/detail/index', diff --git a/src/pages/contributions/create/index.config.js b/src/pages/contributions/create/index.config.js deleted file mode 100644 index 9797d3d..0000000 --- a/src/pages/contributions/create/index.config.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - navigationBarTitleText: '创建投稿' -} diff --git a/src/pages/contributions/create/index.vue b/src/pages/contributions/create/index.vue deleted file mode 100644 index 6146549..0000000 --- a/src/pages/contributions/create/index.vue +++ /dev/null @@ -1,244 +0,0 @@ -