更新 RefundManagement.vue 组件,优化链接编号输入提示,新增输入格式说明,支持从URL提取链接编号,提升用户体验。

This commit is contained in:
zyh
2025-08-29 22:36:58 +08:00
parent 7558174e0a
commit 4021afb0f1
5 changed files with 434 additions and 163 deletions

View File

@@ -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 }
})
}
// 按状态批量删除链接

View File

@@ -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)
}

View File

@@ -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;