更新 RefundManagement.vue 组件,优化链接编号输入提示,新增输入格式说明,支持从URL提取链接编号,提升用户体验。
This commit is contained in:
@@ -27,9 +27,11 @@ export function refundLink(codeNo) {
|
||||
return http.post(`/api/link/${codeNo}/refund`)
|
||||
}
|
||||
|
||||
// 查询链接状态
|
||||
// 查询链接状态 - 使用GET请求,codeNo作为查询参数
|
||||
export function getLinkStatus(codeNo) {
|
||||
return http.get(`/api/link/${codeNo}/status`)
|
||||
return http.get('/api/link/status', {
|
||||
params: { codeNo }
|
||||
})
|
||||
}
|
||||
|
||||
// 按状态批量删除链接
|
||||
|
||||
@@ -3,52 +3,73 @@ import { getAccessToken, getRefreshToken, getTokenType, setTokens, clearTokens }
|
||||
import { showErrorMessage } from '@/utils/error'
|
||||
import router from '@/router'
|
||||
|
||||
const baseURL = import.meta.env?.VITE_API_BASE || '/'
|
||||
// —— 关键改动 1:默认用 /api,确保走 Vite 代理 ——
|
||||
// 配合 .env.development 里:VITE_API_BASE=/api
|
||||
const baseURL = import.meta.env?.VITE_API_BASE || '/api'
|
||||
|
||||
// 业务实例
|
||||
const http = axios.create({
|
||||
baseURL,
|
||||
timeout: 60000,
|
||||
})
|
||||
|
||||
// 专用刷新客户端,避免拦截器递归
|
||||
const refreshClient = axios.create({ baseURL, timeout: 15000 })
|
||||
// 刷新专用实例(不挂业务拦截器,避免递归)
|
||||
const refreshClient = axios.create({
|
||||
baseURL,
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
let isRefreshing = false
|
||||
let pendingQueue = []
|
||||
|
||||
function subscribeTokenRefresh(cb) {
|
||||
pendingQueue.push(cb)
|
||||
}
|
||||
|
||||
function subscribeTokenRefresh(cb) { pendingQueue.push(cb) }
|
||||
function onRefreshed(newToken, tokenType) {
|
||||
pendingQueue.forEach((cb) => cb(newToken, tokenType))
|
||||
pendingQueue.forEach(cb => cb(newToken, tokenType))
|
||||
pendingQueue = []
|
||||
}
|
||||
|
||||
function onRefreshFailed() {
|
||||
pendingQueue.forEach((cb) => cb(null, null))
|
||||
pendingQueue.forEach(cb => cb(null, null))
|
||||
pendingQueue = []
|
||||
}
|
||||
|
||||
// 请求拦截:附加 Authorization
|
||||
/** 统一获取“最终请求路径”(含 baseURL 的路径部分),用于可靠匹配公开接口 */
|
||||
function getPathname(config) {
|
||||
const url = config?.url || ''
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
try { return new URL(url).pathname } catch { return url }
|
||||
}
|
||||
const base = config?.baseURL ?? baseURL
|
||||
let basePath = ''
|
||||
try { basePath = new URL(base, 'http://local').pathname } catch { basePath = base || '' }
|
||||
|
||||
const joined =
|
||||
(basePath.endsWith('/') ? basePath.slice(0, -1) : basePath) +
|
||||
(url.startsWith('/') ? '' : '/') + url
|
||||
return joined.replace(/\/{2,}/g, '/')
|
||||
}
|
||||
|
||||
// —— 关键改动 2:以“尾部路径”匹配公开接口,不受是否带 /api 影响 ——
|
||||
// 注意最后一个是动态路径:/link/{code}/game-interface
|
||||
const PUBLIC_PATTERNS = [
|
||||
/^\/(?:api\/)?link\/status$/,
|
||||
/^\/(?:api\/)?link\/select-region$/,
|
||||
/^\/(?:api\/)?link\/poll-login$/,
|
||||
/^\/(?:api\/)?link\/progress$/,
|
||||
/^\/(?:api\/)?link\/refresh$/,
|
||||
/^\/(?:api\/)?link\/qr\.png$/,
|
||||
/^\/(?:api\/)?link\/[^/]+\/game-interface$/,
|
||||
]
|
||||
function isPublicPathname(pathname) {
|
||||
return PUBLIC_PATTERNS.some(re => re.test(pathname))
|
||||
}
|
||||
|
||||
// 请求拦截:附加 Authorization(公开接口不带)
|
||||
http.interceptors.request.use(
|
||||
(config) => {
|
||||
// 跳过公开API的身份验证(只跳过用户端游戏相关的公开接口)
|
||||
const publicAPIs = [
|
||||
'/api/link/status',
|
||||
'/api/link/select-region',
|
||||
'/api/link/poll-login',
|
||||
'/api/link/progress',
|
||||
'/api/link/refresh',
|
||||
'/api/link/qr.png'
|
||||
]
|
||||
// 游戏界面接口使用动态路径,需要特殊处理
|
||||
const isGameInterfaceAPI = /\/api\/link\/[^\/]+\/game-interface/.test(config.url || '')
|
||||
const isPublicAPI = publicAPIs.some(api => config.url?.includes(api)) || isGameInterfaceAPI
|
||||
|
||||
if (!isPublicAPI) {
|
||||
const pathname = getPathname(config)
|
||||
if (!isPublicPathname(pathname)) {
|
||||
const token = getAccessToken()
|
||||
const type = getTokenType()
|
||||
const type = getTokenType() || 'Bearer'
|
||||
if (token) {
|
||||
config.headers = config.headers || {}
|
||||
config.headers.Authorization = `${type} ${token}`
|
||||
@@ -59,46 +80,55 @@ http.interceptors.request.use(
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 刷新 token 请求
|
||||
// 刷新 token 请求(确保不携带 Authorization)
|
||||
async function refreshTokenRequest() {
|
||||
const refreshToken = getRefreshToken()
|
||||
if (!refreshToken) throw new Error('NO_REFRESH_TOKEN')
|
||||
const { data } = await refreshClient.post('/auth/refresh', { refreshToken })
|
||||
// 兼容 { code, data }
|
||||
const { data } = await refreshClient.post(
|
||||
'/auth/refresh',
|
||||
{ refreshToken },
|
||||
{
|
||||
transformRequest: [(payload, headers) => {
|
||||
if (headers && 'Authorization' in headers) delete headers.Authorization
|
||||
return JSON.stringify(payload)
|
||||
}],
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
const payload = data?.code === 0 && data?.data ? data.data : data
|
||||
if (!payload?.accessToken) throw new Error('INVALID_REFRESH_RESPONSE')
|
||||
setTokens(payload)
|
||||
setTokens(payload) // 内部应同时保存 tokenType/refreshToken 等
|
||||
return payload
|
||||
}
|
||||
|
||||
// 响应拦截:处理 401 自动刷新
|
||||
function logStatus(status, config, tag = '') {
|
||||
const method = (config?.method || '').toUpperCase()
|
||||
const url = getPathname?.(config) || config?.url || ''
|
||||
console.log(`[HTTP${tag ? ' ' + tag : ''}] status=${status} ${method} ${url}`)
|
||||
}
|
||||
|
||||
// 响应拦截:401 自动刷新 + 重放 + 打印状态码
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
(response) => {
|
||||
// 成功响应也打印
|
||||
logStatus(response?.status, response?.config, 'OK')
|
||||
return response
|
||||
},
|
||||
async (error) => {
|
||||
console.error("HTTP 响应拦截器捕获到错误:", error)
|
||||
const { config, response } = error || {}
|
||||
const status = response?.status
|
||||
const url = config?.url || ''
|
||||
const pathname = getPathname(config)
|
||||
const isAuthPath = /\/auth\/(login|refresh)$/.test(pathname)
|
||||
const publicAPI = isPublicPathname(pathname)
|
||||
|
||||
// 避免对登录/刷新自身和公开API进行重复刷新
|
||||
const isAuthPath = /\/auth\/(login|refresh)/.test(url || '')
|
||||
const publicAPIs = [
|
||||
'/api/link/status',
|
||||
'/api/link/select-region',
|
||||
'/api/link/poll-login',
|
||||
'/api/link/progress',
|
||||
'/api/link/refresh',
|
||||
'/api/link/qr.png'
|
||||
]
|
||||
// 游戏界面接口使用动态路径,需要特殊处理
|
||||
const isGameInterfaceAPI = /\/api\/link\/[^\/]+\/game-interface/.test(url || '')
|
||||
const isPublicAPI = publicAPIs.some(api => url?.includes(api)) || isGameInterfaceAPI
|
||||
|
||||
if (status === 401 && !isAuthPath && !isPublicAPI) {
|
||||
// 阻止浏览器显示基本认证弹窗
|
||||
if (error.response && error.response.headers) {
|
||||
if (status === 401 && !isAuthPath && !publicAPI) {
|
||||
console.log("检测到401错误,尝试刷新token")
|
||||
// 有些后端会带 Basic 的挑战头,删除以避免奇怪行为(XHR一般不会弹窗,但删除更干净)
|
||||
if (error.response?.headers?.['www-authenticate']) {
|
||||
delete error.response.headers['www-authenticate']
|
||||
}
|
||||
|
||||
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true
|
||||
try {
|
||||
@@ -109,35 +139,26 @@ http.interceptors.response.use(
|
||||
isRefreshing = false
|
||||
clearTokens()
|
||||
onRefreshFailed()
|
||||
// 跳转到登录页面,保存当前路径用于登录后重定向
|
||||
const currentPath = router.currentRoute.value.fullPath
|
||||
if (currentPath !== '/login') {
|
||||
router.replace({
|
||||
name: 'Login',
|
||||
query: { redirect: currentPath }
|
||||
})
|
||||
router.replace({ name: 'Login', query: { redirect: currentPath } })
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回一个新的 Promise,等待刷新完成后重试原请求
|
||||
// 等待刷新结果后重放原请求
|
||||
return new Promise((resolve, reject) => {
|
||||
subscribeTokenRefresh((newToken, tokenType) => {
|
||||
if (newToken) {
|
||||
const retryConfig = { ...config }
|
||||
retryConfig.headers = retryConfig.headers || {}
|
||||
retryConfig.headers.Authorization = `${tokenType || 'Bearer'} ${newToken}`
|
||||
resolve(http.request(retryConfig))
|
||||
} else {
|
||||
// token刷新失败,拒绝请求
|
||||
reject(error)
|
||||
}
|
||||
if (!newToken) return reject(error)
|
||||
const retry = { ...config }
|
||||
retry.headers = { ...(config.headers || {}), Authorization: `${tokenType || 'Bearer'} ${newToken}` }
|
||||
resolve(http.request(retry))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 对于非401错误,显示错误消息
|
||||
// 其它错误直接提示
|
||||
if (status !== 401) {
|
||||
showErrorMessage(error)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
v-model="searchForm.codeNo"
|
||||
size="large"
|
||||
clearable
|
||||
placeholder="请输入链接编号(支持粘贴后回车)"
|
||||
placeholder="请输入链接编号或完整链接URL(支持粘贴后回车)"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -185,6 +185,15 @@
|
||||
</ol>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item title="输入格式说明" name="5">
|
||||
<ul class="help-list">
|
||||
<li>支持直接输入链接编号,如:<code class="mono">RUC74NCV</code></li>
|
||||
<li>支持输入完整链接URL,如:<code class="mono">http://localhost:5173/play?code=RUC74NCV</code></li>
|
||||
<li>系统会自动从URL中提取链接编号</li>
|
||||
<li>支持使用粘贴按钮或Ctrl+V快速粘贴</li>
|
||||
</ul>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item title="注意事项" name="4">
|
||||
<ul class="help-list">
|
||||
<li>退单操作不可逆,请谨慎操作</li>
|
||||
@@ -293,7 +302,23 @@ export default {
|
||||
ElMessage.info('剪贴板为空')
|
||||
return
|
||||
}
|
||||
searchForm.codeNo = text.trim()
|
||||
|
||||
// 提取实际的代码编号
|
||||
const actualCode = extractCodeFromInput(text)
|
||||
if (!actualCode) {
|
||||
ElMessage.warning('剪贴板内容中未找到有效的链接编号')
|
||||
return
|
||||
}
|
||||
|
||||
searchForm.codeNo = actualCode
|
||||
|
||||
// 如果提取出的代码与原文本不同,提示用户
|
||||
if (actualCode !== text.trim()) {
|
||||
ElMessage.success(`已自动提取链接编号: ${actualCode}`)
|
||||
} else {
|
||||
ElMessage.success('已粘贴链接编号')
|
||||
}
|
||||
|
||||
// 自动触发查询(可按需注释)
|
||||
if (searchForm.codeNo) handleSearch()
|
||||
} catch (err) {
|
||||
@@ -311,16 +336,75 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 从URL中提取代码编号
|
||||
const extractCodeFromInput = (input) => {
|
||||
if (!input) return ''
|
||||
|
||||
// 移除首尾空格
|
||||
const trimmed = input.trim()
|
||||
|
||||
// 如果是URL,尝试提取code参数
|
||||
try {
|
||||
if (trimmed.includes('play?code=')) {
|
||||
const url = new URL(trimmed)
|
||||
const code = url.searchParams.get('code')
|
||||
if (code) return code
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果URL解析失败,继续下面的处理
|
||||
}
|
||||
|
||||
// 尝试从各种URL格式中提取代码
|
||||
if (trimmed.includes('code=')) {
|
||||
const match = trimmed.match(/code=([^&\s]+)/)
|
||||
if (match) return match[1]
|
||||
}
|
||||
|
||||
// 如果包含路径分隔符,取最后一个部分
|
||||
if (trimmed.includes('/')) {
|
||||
const parts = trimmed.split('/')
|
||||
const lastPart = parts[parts.length - 1]
|
||||
if (lastPart && lastPart.length >= 6) {
|
||||
return lastPart
|
||||
}
|
||||
}
|
||||
|
||||
// 直接返回输入(假设就是代码)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// 查询
|
||||
const handleSearch = async () => {
|
||||
if (!searchForm.codeNo.trim()) {
|
||||
ElMessage.warning('请输入链接编号')
|
||||
return
|
||||
}
|
||||
|
||||
// 从输入中提取实际的代码编号
|
||||
const actualCode = extractCodeFromInput(searchForm.codeNo)
|
||||
if (!actualCode) {
|
||||
ElMessage.warning('无法从输入中提取有效的链接编号')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果提取出的代码与输入不同,更新输入框显示
|
||||
if (actualCode !== searchForm.codeNo.trim()) {
|
||||
searchForm.codeNo = actualCode
|
||||
ElMessage.info(`已自动提取链接编号: ${actualCode}`)
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getLinkStatus(searchForm.codeNo.trim())
|
||||
const response = await getLinkStatus(actualCode)
|
||||
linkInfo.value = response.data
|
||||
|
||||
// 调试:检查返回的数据结构
|
||||
console.log('getLinkStatus response:', response.data)
|
||||
|
||||
// 确保 codeNo 字段存在
|
||||
if (linkInfo.value && !linkInfo.value.codeNo && !linkInfo.value.code) {
|
||||
linkInfo.value.codeNo = actualCode
|
||||
}
|
||||
|
||||
// 如果选择了状态筛选,仅做前端提示
|
||||
if (linkInfo.value && searchForm.status && linkInfo.value.status !== searchForm.status) {
|
||||
@@ -363,10 +447,22 @@ export default {
|
||||
|
||||
// 确认退单
|
||||
const confirmRefund = async () => {
|
||||
if (!linkInfo.value) return
|
||||
if (!linkInfo.value) {
|
||||
ElMessage.error('链接信息不存在')
|
||||
return
|
||||
}
|
||||
|
||||
// 获取实际的代码编号,支持多种字段名
|
||||
const codeNo = linkInfo.value.codeNo || linkInfo.value.code || searchForm.codeNo
|
||||
if (!codeNo) {
|
||||
ElMessage.error('无法获取链接编号,请重新查询')
|
||||
console.error('linkInfo:', linkInfo.value)
|
||||
return
|
||||
}
|
||||
|
||||
refunding.value = true
|
||||
try {
|
||||
await refundLink(linkInfo.value.codeNo)
|
||||
await refundLink(codeNo)
|
||||
// 更新本地状态
|
||||
const nowISO = new Date().toISOString()
|
||||
linkInfo.value = {
|
||||
@@ -569,6 +665,15 @@ export default {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 代码示例样式 */
|
||||
.help-list code.mono {
|
||||
background: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 退单确认对话框样式 */
|
||||
.refund-confirm {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user