更新 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

@@ -1,7 +1,8 @@
{
"permissions": {
"allow": [
"Bash(git add:*)"
"Bash(git add:*)",
"Bash(git commit:*)"
],
"deny": [],
"ask": []

View File

@@ -1,117 +1,259 @@
# 公告管理使用说明
基础信息
## 功能概述
- 基础路径: /api/admin/announcement
- 认证方式: JWT Token必须在Header中添加Authorization: Bearer <token>
- 内容类型: application/json
公告管理模块提供了完整的公告信息管理功能,包括公告的创建、编辑、删除、启用/禁用等操作。
---
1. 创建公告
## 功能特性
POST /api/admin/announcement
- ✅ 公告列表查看(支持分页)
- ✅ 按标题/内容关键词搜索
- ✅ 按启用状态筛选
- ✅ 创建新公告
- ✅ 编辑现有公告
- ✅ 删除公告
- ✅ 一键启用/禁用公告
- ✅ 支持跳转链接设置
- ✅ 权限控制(管理员和代理商不同权限)
请求头
## 权限说明
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
### 管理员权限
- 查看公告列表
- 创建新公告
- 编辑现有公告
- 删除公告
- 启用/禁用公告
请求参数
### 代理商权限
- 查看公告列表(只读)
{
"title": "string", // 必填 - 公告标题最大长度100字符
"content": "string", // 必填 - 公告内容
"enabled": boolean, // 必填 - 是否启用
"jumpUrl": "string" // 可选 - 跳转链接
}
## 使用步骤
注意: belongId 字段已从请求参数中移除系统会自动从JWT token中解析当前用户ID并设置为 belongId
### 1. 访问公告管理
- 登录管理后台
- 在左侧导航菜单中点击"公告管理"
请求示例
### 2. 查看公告列表
- 公告列表显示所有公告信息
- 支持按关键词搜索(标题或内容)
- 支持按启用状态筛选
- 支持分页浏览
{
"title": "系统维护通知",
"content": "系统将于今晚22:00-24:00进行维护升级请提前保存工作内容",
"enabled": true,
"jumpUrl": "https://example.com/maintenance"
}
### 3. 创建新公告
1. 点击"新增公告"按钮
2. 填写公告信息:
- **标题**:必填,公告标题
- **内容**:必填,公告详细内容
- **跳转链接**可选点击公告后跳转的URL
- **状态**:选择是否启用
3. 点击"保存"完成创建
响应结果
### 4. 编辑公告
1. 在公告列表中找到要编辑的公告
2. 点击"编辑"按钮
3. 修改公告信息
4. 点击"保存"完成修改
{
"success": true,
"message": "公告创建成功",
"id": 1
}
### 5. 删除公告
1. 在公告列表中找到要删除的公告
2. 点击"删除"按钮
3. 确认删除操作
错误响应
### 6. 启用/禁用公告
- 在公告列表中,直接点击状态开关即可快速启用或禁用公告
- 只有启用的公告才会对用户可见
{
"success": false,
"message": "用户未认证"
}
## 字段说明
---
2. 获取公告列表(分页)
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 标题 | 文本 | 是 | 公告标题,用于列表显示 |
| 内容 | 文本 | 是 | 公告详细内容 |
| 跳转链接 | URL | 否 | 点击公告后跳转的链接地址 |
| 状态 | 布尔 | 是 | 是否启用,只有启用的公告用户才能看到 |
GET /api/admin/announcement/list
## 注意事项
查询参数
1. **权限控制**:只有具备相应权限的用户才能进行相应操作
2. **数据验证**:标题和内容为必填字段
3. **URL验证**跳转链接必须是有效的URL格式以http://或https://开头)
4. **状态控制**:只有启用的公告才会在前端显示给用户
5. **操作记录**:所有操作都会记录创建时间和更新时间
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|----------|---------|-----|-----|---------|
| page | integer | 否 | 1 | 页码 |
| size | integer | 否 | 20 | 每页大小 |
| enabled | boolean | 否 | - | 按启用状态筛选 |
| belongId | integer | 否 | - | 按归属ID筛选 |
## API接口
请求示例
公告管理使用以下API接口
GET /api/admin/announcement/list?page=1&size=10&enabled=true&belongId=123
- `GET /api/admin/announcement/list` - 获取公告列表
- `POST /api/admin/announcement` - 创建公告
- `PUT /api/admin/announcement/{id}` - 更新公告
- `DELETE /api/admin/announcement/{id}` - 删除公告
- `PUT /api/admin/announcement/{id}/enabled` - 更新启用状态
- `GET /api/admin/announcement/enabled` - 获取启用的公告
响应结果
详细的API文档请参考项目根目录的接口文档。
{
"items": [
{
"id": 1,
"title": "系统维护通知",
"content": "系统将于今晚22:00-24:00进行维护升级",
"enabled": true,
"jumpUrl": "https://example.com/maintenance",
"belongId": 123,
"createdAt": "2025-08-29T10:30:00",
"updatedAt": "2025-08-29T10:30:00"
}
],
"total": 1,
"page": 1,
"size": 10
}
## 故障排除
---
3. 获取公告详情
### 常见问题
GET /api/admin/announcement/{id}
1. **无法访问公告管理页面**
- 检查用户是否有相应权限
- 确认已正确登录
路径参数
2. **创建公告失败**
- 检查标题和内容是否已填写
- 检查跳转链接格式是否正确
| 参数名 | 类型 | 必填 | 说明 |
|-----|------|-----|------|
| id | long | 是 | 公告ID |
3. **状态切换失败**
- 检查网络连接
- 确认用户有编辑权限
请求示例
4. **搜索无结果**
- 检查搜索关键词是否正确
- 尝试重置搜索条件
GET /api/admin/announcement/1
如有其他问题,请联系系统管理员。
响应结果
{
"id": 1,
"title": "系统维护通知",
"content": "系统将于今晚22:00-24:00进行维护升级请提前保存工作内容",
"enabled": true,
"jumpUrl": "https://example.com/maintenance",
"belongId": 123,
"createdAt": "2025-08-29T10:30:00",
"updatedAt": "2025-08-29T10:30:00"
}
---
4. 更新公告
PUT /api/admin/announcement/{id}
请求头
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
路径参数
| 参数名 | 类型 | 必填 | 说明 |
|-----|------|-----|------|
| id | long | 是 | 公告ID |
请求参数
{
"title": "string", // 可选 - 公告标题
"content": "string", // 可选 - 公告内容
"enabled": boolean, // 可选 - 是否启用
"jumpUrl": "string" // 可选 - 跳转链接
}
注意: 更新时 belongId 会自动从JWT token中获取并更新
请求示例
{
"title": "系统维护通知(更新)",
"content": "系统维护时间调整为23:00-01:00",
"enabled": true
}
响应结果
{
"success": true,
"message": "公告更新成功"
}
---
5. 删除公告
DELETE /api/admin/announcement/{id}
路径参数
| 参数名 | 类型 | 必填 | 说明 |
|-----|------|-----|------|
| id | long | 是 | 公告ID |
请求示例
DELETE /api/admin/announcement/1
响应结果
{
"success": true,
"message": "公告删除成功"
}
---
6. 更新公告启用状态
PUT /api/admin/announcement/{id}/enabled
路径参数
| 参数名 | 类型 | 必填 | 说明 |
|-----|------|-----|------|
| id | long | 是 | 公告ID |
查询参数
| 参数名 | 类型 | 必填 | 说明 |
|---------|---------|-----|------|
| enabled | boolean | 是 | 启用状态 |
请求示例
PUT /api/admin/announcement/1/enabled?enabled=false
响应结果
{
"success": true,
"message": "公告已禁用"
}
---
7. 获取启用的公告
GET /api/admin/announcement/enabled
查询参数
| 参数名 | 类型 | 必填 | 说明 |
|----------|---------|-----|---------|
| belongId | integer | 否 | 按归属ID筛选 |
请求示例
GET /api/admin/announcement/enabled?belongId=123
响应结果
[
{
"id": 1,
"title": "系统维护通知",
"content": "系统将于今晚22:00-24:00进行维护升级",
"enabled": true,
"jumpUrl": "https://example.com/maintenance",
"belongId": 123,
"createdAt": "2025-08-29T10:30:00",
"updatedAt": "2025-08-29T10:30:00"
}
]
---
主要变更说明
🔒 安全改进
- 自动用户识别: 创建和更新公告时系统自动从JWT token中解析当前用户ID
- 防止伪造: 前端无法伪造 belongId确保公告只能归属于当前登录用户
📝 请求参数变更
- 移除: 创建和更新公告的请求中不再需要传递 belongId 参数
- 简化: 前端调用更加简洁无需关心用户ID的传递
🎯 使用场景
1. 管理员: 创建的公告 belongId 为管理员用户ID
2. 代理商: 创建的公告 belongId 为代理商用户ID
3. 权限隔离: 通过 belongId 实现不同角色的公告隔离
这样的设计既保证了安全性,又提供了灵活的公告管理能力!

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,43 +80,52 @@ 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']
}
@@ -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,17 +336,76 @@ 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) {
ElMessage.info(`已找到链接,但状态为「${getStatusText(linkInfo.value.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;