优化移动端界面,新增退单管理功能,添加相关API接口,更新权限设置,调整布局以支持响应式设计,提升用户体验。

This commit is contained in:
zyh
2025-08-27 21:20:05 +08:00
parent 4060a1c6e9
commit 7f4c2ca831
15 changed files with 3595 additions and 198 deletions

View File

@@ -0,0 +1,179 @@
# 退单功能使用说明
## 🎯 功能概述
退单管理功能允许管理员和代理用户对链接进行退单操作,提供了完整的退单流程管理和状态追踪。
## 📁 文件结构
```
src/
├── views/refund/
│ └── RefundManagement.vue # 退单管理页面
├── api/
│ └── links.js # 退单相关API接口
├── router/
│ └── index.js # 路由配置
├── layouts/
│ └── AdminLayout.vue # 管理后台布局(包含退单菜单)
└── utils/
└── permission.js # 权限配置
```
## 🔑 权限配置
### 新增权限
- `REFUND_MANAGE` - 退单管理权限
- `REFUND_EXECUTE` - 退单执行权限
- `REFUND_VIEW` - 退单查看权限
### 角色权限分配
- **管理员 (ADMIN)**: 拥有所有退单权限
- **代理 (AGENT)**: 拥有查看和执行退单权限
## 🚀 功能特性
### 1. 链接查询
- 支持按链接编号搜索
- 支持按状态筛选
- 实时显示链接详细信息
### 2. 退单操作
- 智能判断是否可以执行退单
- 支持的退单状态:`NEW``USING``LOGGED_IN`
- 不支持的状态:`REFUNDED``EXPIRED``COMPLETED`
### 3. 安全确认
- 退单前二次确认对话框
- 显示链接详细信息确认
- 防止误操作
### 4. 状态追踪
- 实时更新链接状态
- 记录退单时间
- 显示操作历史
## 🎨 页面功能
### 搜索区域
- **链接编号输入框**: 输入要查询的链接编号
- **状态筛选器**: 选择特定状态的链接
- **查询按钮**: 执行搜索操作
- **重置按钮**: 清空搜索条件
### 链接信息卡片
显示找到的链接详细信息:
- 链接编号
- 当前状态(带状态标签)
- 创建时间和更新时间
- 代理ID和关联设备
- 总点数和当前点数
- 退单时间(如果已退单)
### 操作按钮
- **执行退单**: 当链接状态允许时显示,点击后弹出确认对话框
- **已退单**: 当链接已经退单时显示的禁用状态
- **不允许退单**: 当链接状态不允许退单时显示的提示
### 使用说明
提供了详细的帮助信息:
- 可退单的状态说明
- 不可退单的状态说明
- 退单流程介绍
- 注意事项提醒
## 🔧 API接口
### 新增接口函数
#### `refundLink(codeNo)`
执行退单操作
- **参数**: `codeNo` - 链接编号
- **返回**: Promise 对象
- **HTTP方法**: POST
- **路径**: `/api/link/{codeNo}/refund`
#### `getLinkStatus(codeNo)`
查询链接状态
- **参数**: `codeNo` - 链接编号
- **返回**: Promise 对象,包含链接详细信息
- **HTTP方法**: GET
- **路径**: `/api/link/{codeNo}/status`
## 🎯 使用步骤
### 1. 访问退单管理页面
- 登录管理后台
- 在左侧菜单中点击 "退单管理"
### 2. 查询链接
1. 在搜索框中输入链接编号
2. (可选)选择状态筛选
3. 点击 "查询" 按钮
### 3. 执行退单
1. 确认链接信息正确
2. 点击 "执行退单" 按钮
3. 在确认对话框中确认操作
4. 等待退单完成
## ⚠️ 注意事项
1. **权限要求**: 只有管理员和代理用户可以访问退单功能
2. **状态限制**: 只有特定状态的链接才能执行退单
3. **不可逆操作**: 退单操作一旦执行无法撤销
4. **代理限制**: 代理用户只能退单自己创建的链接
5. **外部接口**: 系统会调用外部脚本接口,即使失败也会更新本地状态
## 🎨 界面特性
### 响应式设计
- 支持桌面端和移动端访问
- 自适应不同屏幕尺寸
- 移动端优化的交互体验
### 状态标签
- 新建: 蓝色标签
- 使用中: 橙色标签
- 已登录: 蓝色标签
- 已完成: 绿色标签
- 已退单: 灰色标签
- 已过期: 红色标签
### 深色主题支持
- 自动适配系统深色主题
- 保持良好的视觉对比度
- 所有组件都支持深色模式
## 🚨 错误处理
### 常见错误及解决方案
1. **链接不存在**
- 检查链接编号是否正确
- 确认链接是否已被删除
2. **无权限查看**
- 确认用户角色权限
- 代理用户只能查看自己的链接
3. **退单失败**
- 检查链接当前状态
- 确认网络连接正常
- 联系技术支持
4. **网络错误**
- 检查网络连接
- 刷新页面重试
- 联系技术支持
## 📞 技术支持
如果在使用过程中遇到问题,请联系技术支持团队:
- 提供具体的错误信息
- 说明操作步骤
- 提供链接编号(如适用)
---
*最后更新时间: 2024年1月*

View File

@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Vue3 App</title>
</head>
<body>

View File

@@ -5,6 +5,464 @@
<script setup>
</script>
<style>
/* 全局响应式基础样式 */
* {
box-sizing: border-box;
}
html {
/* 防止iOS设备字体缩放 */
-webkit-text-size-adjust: 100%;
/* 平滑滚动 */
scroll-behavior: smooth;
}
body {
margin: 0;
padding: 0;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
/* 移动端优化 */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* 防止手机横屏时字体放大 */
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
/* 去除移动端默认的触摸高亮 */
-webkit-tap-highlight-color: transparent;
}
#app {
min-height: 100vh;
}
/* 移动端常用断点 */
:root {
--mobile-xs: 480px;
--mobile-sm: 576px;
--tablet: 768px;
--desktop: 1024px;
--desktop-lg: 1200px;
--desktop-xl: 1440px;
/* 移动端专用变量 */
--mobile-header-height: 50px;
--mobile-navbar-height: 60px;
--mobile-padding: 16px;
--mobile-border-radius: 8px;
--mobile-touch-target: 44px; /* 符合人体工程学的最小触摸目标 */
}
/* 响应式工具类 */
.mobile-only {
display: none;
}
.desktop-only {
display: block;
}
.mobile-hide {
display: block;
}
.mobile-show {
display: none;
}
@media (max-width: 768px) {
.mobile-only {
display: block;
}
.desktop-only {
display: none;
}
.mobile-hide {
display: none;
}
.mobile-show {
display: block;
}
/* 移动端基础布局优化 */
.mobile-container {
padding: 0 var(--mobile-padding);
max-width: 100%;
overflow-x: hidden;
}
.mobile-full-width {
width: 100vw;
margin-left: calc(-50vw + 50%);
}
/* 移动端表单优化 */
.mobile-form .el-form-item {
margin-bottom: 20px;
}
.mobile-form .el-form-item__label {
padding-bottom: 8px;
line-height: 1.4;
}
.mobile-form .el-input__inner,
.mobile-form .el-textarea__inner {
min-height: var(--mobile-touch-target);
font-size: 16px; /* 防止iOS设备缩放 */
}
/* 移动端按钮优化 */
.mobile-btn {
min-height: var(--mobile-touch-target);
padding: 12px 20px;
font-size: 16px;
}
/* 移动端表格响应式 */
.mobile-table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.mobile-table-responsive table {
min-width: 600px;
}
/* 移动端卡片样式 */
.mobile-card {
margin: 0 var(--mobile-padding) 16px;
border-radius: var(--mobile-border-radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 移动端导航优化 */
.mobile-nav-item {
min-height: var(--mobile-touch-target);
display: flex;
align-items: center;
padding: 0 var(--mobile-padding);
}
}
/* 超小屏幕优化 */
@media (max-width: 480px) {
:root {
--mobile-padding: 12px;
}
.mobile-xs-hidden {
display: none;
}
.mobile-xs-full {
width: 100%;
margin: 0;
}
}
/* 横屏模式优化 */
@media (max-height: 500px) and (orientation: landscape) {
.landscape-compact .el-form-item {
margin-bottom: 12px;
}
.landscape-compact .el-card {
margin-bottom: 12px;
}
}
/* Element Plus 移动端覆盖样式 */
@media (max-width: 768px) {
/* 对话框移动端优化 */
.el-dialog {
width: 95% !important;
margin: 0 auto !important;
margin-top: 5vh !important;
max-height: 90vh;
overflow-y: auto;
}
.el-dialog__body {
padding: 15px 20px;
}
/* 抽屉组件移动端优化 */
.el-drawer {
width: 80% !important;
}
/* 分页组件移动端优化 */
.el-pagination {
text-align: center;
justify-content: center;
}
.el-pagination .el-pager li {
min-width: 32px;
height: 32px;
line-height: 32px;
}
/* 表格移动端优化 */
.el-table {
font-size: 14px;
}
.el-table th,
.el-table td {
padding: 8px 5px;
}
.el-table .cell {
padding: 0 5px;
}
/* 卡片移动端优化 */
.el-card {
margin: 0 0 16px 0;
border-radius: var(--mobile-border-radius);
}
.el-card__header {
padding: 15px 20px;
}
.el-card__body {
padding: 15px 20px;
}
/* 菜单移动端优化 */
.el-menu-item {
min-height: var(--mobile-touch-target);
}
/* 标签页移动端优化 */
.el-tabs__item {
padding: 0 15px;
min-height: var(--mobile-touch-target);
line-height: var(--mobile-touch-target);
}
/* 表单移动端优化 */
.el-form--label-top .el-form-item__label {
padding-bottom: 8px;
line-height: 1.4;
}
/* 输入框移动端优化 */
.el-input__inner {
height: var(--mobile-touch-target);
line-height: var(--mobile-touch-target);
font-size: 16px;
}
.el-textarea__inner {
font-size: 16px;
}
/* 按钮移动端优化 */
.el-button {
min-height: var(--mobile-touch-target);
padding: 10px 15px;
font-size: 14px;
border-radius: 8px;
transition: all 0.2s ease;
}
.el-button:active {
transform: scale(0.98);
}
.el-button--large {
min-height: 48px;
padding: 12px 20px;
font-size: 16px;
border-radius: 10px;
}
.el-button--small {
min-height: 36px;
padding: 8px 12px;
font-size: 13px;
border-radius: 6px;
}
/* 选择器移动端优化 */
.el-select-dropdown {
max-height: 60vh !important;
}
.el-select-dropdown .el-select-dropdown__item {
height: var(--mobile-touch-target);
line-height: var(--mobile-touch-target);
padding: 0 16px;
font-size: 16px;
}
/* 日期选择器移动端优化 */
.el-date-picker {
width: 100%;
}
.el-picker-panel {
max-height: 70vh;
overflow-y: auto;
}
.el-date-table td {
height: 36px;
width: 36px;
}
.el-date-table .el-date-table__cell {
height: 36px;
width: 36px;
line-height: 36px;
}
/* 消息提示移动端优化 */
.el-message {
min-width: 280px;
margin: 0 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.el-notification {
width: calc(100vw - 32px);
max-width: 330px;
margin: 0 16px;
border-radius: 8px;
}
/* 加载效果移动端优化 */
.el-loading-mask {
backdrop-filter: blur(2px);
}
.el-loading-spinner {
margin-top: -40px;
}
.el-loading-text {
margin-top: 16px;
font-size: 14px;
}
/* 步骤条移动端优化 */
.el-steps {
padding: 0 8px;
}
.el-step__title {
font-size: 14px;
line-height: 1.3;
}
.el-step__description {
font-size: 12px;
}
/* 折叠面板移动端优化 */
.el-collapse-item__header {
height: var(--mobile-touch-target);
line-height: var(--mobile-touch-target);
padding: 0 16px;
font-size: 16px;
}
.el-collapse-item__content {
padding: 16px;
}
/* 滑块移动端优化 */
.el-slider__runway {
height: 8px;
margin: 16px 0;
}
.el-slider__button {
width: 24px;
height: 24px;
border: 3px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* 评分组件移动端优化 */
.el-rate {
height: var(--mobile-touch-target);
line-height: var(--mobile-touch-target);
}
.el-rate__item {
font-size: 20px;
margin-right: 8px;
}
/* 穿梭框移动端优化 */
.el-transfer {
font-size: 14px;
}
.el-transfer-panel {
width: 180px;
}
.el-transfer-panel__item {
height: var(--mobile-touch-target);
line-height: var(--mobile-touch-target);
padding: 0 12px;
}
/* 时间线移动端优化 */
.el-timeline-item__timestamp {
font-size: 12px;
}
.el-timeline-item__content {
padding-left: 28px;
}
/* 返回顶部移动端优化 */
.el-backtop {
right: 20px;
bottom: 80px;
width: 48px;
height: 48px;
border-radius: 24px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
/* 图片预览移动端优化 */
.el-image-viewer__wrapper {
background: rgba(0, 0, 0, 0.8);
}
.el-image-viewer__actions {
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
margin: 0 16px 20px;
}
.el-image-viewer__actions__inner {
padding: 8px;
}
.el-image-viewer__actions .el-image-viewer__action {
width: 48px;
height: 48px;
font-size: 20px;
margin: 0 4px;
border-radius: 6px;
}
}
</style>
<style scoped>
/* 根组件仅承载路由视图 */
</style>

View File

@@ -22,4 +22,14 @@ export function batchDeleteLinks(codeNos) {
return http.post('/api/link/batch-delete', { codeNos })
}
// 退单接口
export function refundLink(codeNo) {
return http.post(`/api/link/${codeNo}/refund`)
}
// 查询链接状态
export function getLinkStatus(codeNo) {
return http.get(`/api/link/${codeNo}/status`)
}

View File

@@ -1,18 +1,26 @@
<template>
<div class="admin-layout">
<aside class="sider">
<!-- 移动端遮罩层 -->
<div
v-if="isMobile && !collapsed"
class="mobile-overlay"
@click="collapsed = true"
></div>
<aside :class="['sider', { 'sider-mobile': isMobile, 'sider-collapsed': isMobile && collapsed }]">
<div class="brand">
<img class="logo" src="https://vuejs.org/images/logo.png" alt="logo" />
<span class="name">管理后台</span>
<span v-show="!collapsed || !isMobile" class="name">管理后台</span>
</div>
<el-menu
class="menu"
router
:default-active="$route.name"
:collapse="collapsed"
:collapse="collapsed && !isMobile"
background-color="#001529"
text-color="#bfcbd9"
active-text-color="#fff"
@select="onMenuSelect"
>
<el-menu-item v-if="canAccessUsers" index="Users" :route="{ name: 'Users' }">
@@ -26,6 +34,11 @@
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M10.59 13.41c.41.39.41 1.03 0 1.42c-.39.39-1.03.39-1.42 0a5.003 5.003 0 0 1 0-7.07l3.54-3.54a5.003 5.003 0 0 1 7.07 0a5.003 5.003 0 0 1 0 7.07l-1.49 1.49c.01-.82-.12-1.64-.4-2.42l.47-.48a2.982 2.982 0 0 0 0-4.24a2.982 2.982 0 0 0-4.24 0l-3.53 3.53a2.982 2.982 0 0 0 0-4.24zm2.82-4.24c.39-.39 1.03-.39 1.42 0a5.003 5.003 0 0 1 0 7.07l-3.54 3.54a5.003 5.003 0 0 1-7.07 0a5.003 5.003 0 0 1 0-7.07l1.49-1.49c-.01.82.12 1.64.4 2.43l-.47.47a2.982 2.982 0 0 0 0 4.24a2.982 2.982 0 0 0 4.24 0l3.53-3.53a2.982 2.982 0 0 0 0-4.24a.973.973 0 0 1 0-1.42z"/></svg></i>
<span>链接管理</span>
</el-menu-item>
<el-menu-item v-if="canAccessRefund" index="Refund" :route="{ name: 'Refund' }">
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/></svg></i>
<span>退单管理</span>
</el-menu-item>
<el-menu-item v-if="canAccessAnnouncements" index="Announcements" :route="{ name: 'Announcements' }">
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></i>
<span>公告管理</span>
@@ -40,13 +53,17 @@
</aside>
<section class="main">
<header class="header">
<el-button text @click="collapsed = !collapsed" class="collapse-btn">
<el-button text @click="toggleSidebar" class="collapse-btn">
<svg width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M3 6h18v2H3V6m0 5h12v2H3v-2m0 5h18v2H3v-2Z"/></svg>
</el-button>
<div class="spacer" />
<!-- 移动端显示当前页面标题 -->
<span v-if="isMobile" class="mobile-page-title">{{ currentPageTitle }}</span>
<div v-if="!isMobile" class="spacer" />
<el-dropdown>
<span class="el-dropdown-link">
{{ currentUser?.username || '用户' }}<i class="el-icon el-icon--right"><svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M7 10l5 5 5-5z"/></svg></i>
<span v-if="!isMobile">{{ currentUser?.username || '用户' }}</span>
<i class="el-icon el-icon--right"><svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M7 10l5 5 5-5z"/></svg></i>
</span>
<template #dropdown>
<el-dropdown-menu>
@@ -65,23 +82,61 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { clearTokens } from '@/utils/auth'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { canAccessRoute, getCurrentUser } from '@/utils/permission'
const collapsed = ref(false)
const isMobile = ref(false)
const router = useRouter()
const route = useRoute()
// 检测移动端
const checkMobile = () => {
isMobile.value = window.innerWidth <= 768
// 移动端默认收起侧边栏
if (isMobile.value) {
collapsed.value = true
}
}
// 页面标题映射
const pageTitleMap = {
'Users': '用户管理',
'Links': '链接管理',
'Refund': '退单管理',
'Announcements': '公告管理',
'Settings': '系统设置'
}
// 获取当前用户信息
const currentUser = computed(() => getCurrentUser())
// 获取当前页面标题
const currentPageTitle = computed(() => {
return pageTitleMap[route.name] || '管理后台'
})
// 权限检查
const canAccessUsers = computed(() => canAccessRoute('Users'))
const canAccessLinks = computed(() => canAccessRoute('Links'))
const canAccessRefund = computed(() => canAccessRoute('Refund'))
const canAccessAnnouncements = computed(() => canAccessRoute('Announcements'))
const canAccessSettings = computed(() => canAccessRoute('Settings'))
// 切换侧边栏
const toggleSidebar = () => {
collapsed.value = !collapsed.value
}
// 菜单选择事件(移动端选择后自动收起)
const onMenuSelect = () => {
if (isMobile.value) {
collapsed.value = true
}
}
function onProfile() {
// 可跳转到个人中心占位页
}
@@ -90,20 +145,61 @@ function onLogout() {
clearTokens()
router.replace({ name: 'Login' })
}
// 监听窗口大小变化
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
<style scoped>
.admin-layout {
display: flex;
height: 100vh;
position: relative;
}
/* 移动端遮罩层 */
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: block;
}
.sider {
width: 220px;
background: #001529;
color: #bfcbd9;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
z-index: 1000;
}
/* 移动端侧边栏样式 */
.sider-mobile {
position: fixed;
top: 0;
left: 0;
height: 100vh;
transform: translateX(0);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
}
.sider-mobile.sider-collapsed {
transform: translateX(-100%);
}
.brand {
height: 56px;
display: flex;
@@ -111,10 +207,34 @@ function onLogout() {
gap: 10px;
padding: 0 16px;
color: #fff;
border-bottom: 1px solid #334050;
}
.logo { width: 24px; height: 24px; }
.menu { border-right: none; flex: 1; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.logo {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.name {
font-size: 16px;
font-weight: 600;
}
.menu {
border-right: none;
flex: 1;
overflow-y: auto;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
transition: all 0.3s ease;
}
.header {
height: 56px;
display: flex;
@@ -122,9 +242,110 @@ function onLogout() {
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
position: relative;
z-index: 100;
}
.collapse-btn {
margin-right: 8px;
min-width: 32px;
height: 32px;
border-radius: 4px;
}
.collapse-btn:hover {
background-color: #f5f5f5;
}
.spacer {
flex: 1;
}
.mobile-page-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.el-dropdown-link {
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
color: #606266;
font-size: 14px;
}
.el-dropdown-link:hover {
color: #409eff;
}
.content {
padding: 16px;
overflow: auto;
background: #f5f7fa;
height: calc(100vh - 56px);
flex: 1;
}
/* 移动端响应式布局 */
@media (max-width: 768px) {
.main {
width: 100%;
}
.header {
padding: 0 12px;
}
.content {
padding: 12px;
}
.collapse-btn {
margin-right: 12px;
}
.mobile-page-title {
font-size: 15px;
}
.el-dropdown-link {
font-size: 16px;
min-height: var(--mobile-touch-target);
padding: 8px;
}
}
@media (max-width: 480px) {
.header {
padding: 0 8px;
}
.content {
padding: 8px;
}
.brand {
padding: 0 12px;
}
.name {
font-size: 15px;
}
}
/* 桌面端样式 */
@media (min-width: 769px) {
.sider-mobile {
position: relative;
transform: none !important;
box-shadow: none;
}
.mobile-overlay {
display: none;
}
}
.collapse-btn { margin-right: 8px; }
.spacer { flex: 1; }
.content { padding: 16px; overflow: auto; background: #f5f7fa; height: calc(100vh - 56px); }
</style>

View File

@@ -4,9 +4,16 @@ import 'element-plus/dist/index.css'
import router from '@/router'
import App from './App.vue'
import { setupPermissionDirective } from './directives/permission'
import mobilePlugin from './plugins/mobile'
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
app.use(mobilePlugin, {
preventZoom: true,
handleKeyboard: true,
networkMonitor: true,
touchFeedback: true
})
setupPermissionDirective(app)
app.mount('#app')

137
src/plugins/mobile.js Normal file
View File

@@ -0,0 +1,137 @@
/**
* Vue移动端优化插件
*/
import { preventIOSZoom, handleVirtualKeyboard, onNetworkChange } from '@/utils/mobile'
export default {
install(app, options = {}) {
// 移动端优化选项
const {
preventZoom = true,
handleKeyboard = true,
networkMonitor = true,
touchFeedback = true
} = options
let cleanupFunctions = []
// 防止iOS缩放
if (preventZoom) {
const cleanup = preventIOSZoom()
cleanupFunctions.push(cleanup)
}
// 处理虚拟键盘
if (handleKeyboard) {
const cleanup = handleVirtualKeyboard()
cleanupFunctions.push(cleanup)
}
// 网络状态监控
if (networkMonitor) {
const cleanup = onNetworkChange((status) => {
// 发送全局事件
app.config.globalProperties.$bus?.emit('network-change', status)
// 或者使用provide/inject
if (app._context.provides.networkStatus) {
app._context.provides.networkStatus.value = status
}
})
cleanupFunctions.push(cleanup)
}
// 触摸反馈
if (touchFeedback) {
// 为所有按钮添加触摸反馈
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const buttons = node.querySelectorAll?.('.el-button, button') || []
buttons.forEach((button) => {
if (!button.classList.contains('touch-feedback-added')) {
button.classList.add('touch-feedback-added')
button.addEventListener('touchstart', () => {
button.style.transform = 'scale(0.98)'
button.style.transition = 'transform 0.1s ease'
}, { passive: true })
button.addEventListener('touchend', () => {
setTimeout(() => {
button.style.transform = ''
}, 100)
}, { passive: true })
}
})
}
})
})
})
observer.observe(document.body, {
childList: true,
subtree: true
})
cleanupFunctions.push(() => {
observer.disconnect()
})
}
// 添加全局属性
app.config.globalProperties.$mobile = {
// 安全滚动到顶部
scrollToTop: () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
},
// 安全振动
vibrate: (pattern = 50) => {
if ('vibrate' in navigator) {
navigator.vibrate(pattern)
}
},
// 获取设备信息
getDeviceInfo: () => ({
userAgent: navigator.userAgent,
platform: navigator.platform,
language: navigator.language,
cookieEnabled: navigator.cookieEnabled,
onLine: navigator.onLine,
screen: {
width: screen.width,
height: screen.height,
availWidth: screen.availWidth,
availHeight: screen.availHeight,
colorDepth: screen.colorDepth,
pixelDepth: screen.pixelDepth
},
viewport: {
width: window.innerWidth,
height: window.innerHeight
}
})
}
// 提供清理函数
app.config.globalProperties.$mobile.cleanup = () => {
cleanupFunctions.forEach(cleanup => {
if (typeof cleanup === 'function') {
cleanup()
}
})
cleanupFunctions = []
}
// 在应用卸载时清理
app.mixin({
beforeUnmount() {
if (this === this.$root && this.$mobile?.cleanup) {
this.$mobile.cleanup()
}
}
})
}
}

View File

@@ -9,6 +9,7 @@ const UserList = () => import('@/views/users/UserList.vue')
const Settings = () => import('@/views/settings/Settings.vue')
const LinkGenerate = () => import('@/views/links/LinkGenerate.vue')
const AnnouncementList = () => import('@/views/announcements/AnnouncementList.vue')
const RefundManagement = () => import('@/views/refund/RefundManagement.vue')
const Play = () => import('@/views/Play.vue')
const NotFound = () => import('@/views/NotFound.vue')
@@ -23,6 +24,7 @@ export const routes = [
{ path: 'users', name: 'Users', component: UserList, meta: { title: '用户管理' } },
{ path: 'settings', name: 'Settings', component: Settings, meta: { title: '系统设置' } },
{ path: 'links', name: 'Links', component: LinkGenerate, meta: { title: '链接管理' } },
{ path: 'refund', name: 'Refund', component: RefundManagement, meta: { title: '退单管理' } },
{ path: 'announcements', name: 'Announcements', component: AnnouncementList, meta: { title: '公告管理' } },
],
},

328
src/utils/mobile.js Normal file
View File

@@ -0,0 +1,328 @@
/**
* 移动端交互优化工具
*/
// 检测移动设备
export const isMobileDevice = () => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
}
// 检测触摸设备
export const isTouchDevice = () => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0
}
// 检测设备方向
export const getOrientation = () => {
return window.innerWidth > window.innerHeight ? 'landscape' : 'portrait'
}
// 监听方向变化
export const onOrientationChange = (callback) => {
let orientation = getOrientation()
const handleResize = () => {
const newOrientation = getOrientation()
if (newOrientation !== orientation) {
orientation = newOrientation
callback(newOrientation)
}
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}
// 防止iOS缩放
export const preventIOSZoom = () => {
let lastTouchEnd = 0
const preventZoom = (e) => {
const now = Date.now()
if (now - lastTouchEnd <= 300) {
e.preventDefault()
}
lastTouchEnd = now
}
document.addEventListener('touchend', preventZoom, { passive: false })
return () => {
document.removeEventListener('touchend', preventZoom)
}
}
// 添加触摸反馈
export const addTouchFeedback = (element, options = {}) => {
const {
className = 'touch-feedback',
duration = 150,
scale = 0.98
} = options
if (!element) return
const style = document.createElement('style')
style.textContent = `
.${className} {
transform: scale(${scale});
transition: transform ${duration}ms ease;
}
`
document.head.appendChild(style)
const addFeedback = () => {
element.classList.add(className)
setTimeout(() => {
element.classList.remove(className)
}, duration)
}
element.addEventListener('touchstart', addFeedback, { passive: true })
return () => {
element.removeEventListener('touchstart', addFeedback)
if (style.parentNode) {
style.parentNode.removeChild(style)
}
}
}
// 滚动到顶部
export const scrollToTop = (smooth = true) => {
window.scrollTo({
top: 0,
behavior: smooth ? 'smooth' : 'auto'
})
}
// 滚动到元素
export const scrollToElement = (element, options = {}) => {
const {
behavior = 'smooth',
block = 'start',
inline = 'nearest',
offset = 0
} = options
if (!element) return
const elementTop = element.offsetTop + offset
window.scrollTo({
top: elementTop,
behavior
})
}
// 获取安全区域
export const getSafeArea = () => {
const style = getComputedStyle(document.documentElement)
return {
top: parseInt(style.getPropertyValue('env(safe-area-inset-top)')) || 0,
right: parseInt(style.getPropertyValue('env(safe-area-inset-right)')) || 0,
bottom: parseInt(style.getPropertyValue('env(safe-area-inset-bottom)')) || 0,
left: parseInt(style.getPropertyValue('env(safe-area-inset-left)')) || 0
}
}
// 处理键盘弹起
export const handleVirtualKeyboard = () => {
let initialViewportHeight = window.innerHeight
const handleResize = () => {
const currentHeight = window.innerHeight
const heightDifference = initialViewportHeight - currentHeight
// 键盘弹起时的处理高度差超过150px认为是键盘弹起
if (heightDifference > 150) {
document.body.classList.add('keyboard-open')
document.body.style.height = `${currentHeight}px`
} else {
document.body.classList.remove('keyboard-open')
document.body.style.height = ''
}
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
document.body.classList.remove('keyboard-open')
document.body.style.height = ''
}
}
// 防抖函数
export const debounce = (func, wait) => {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
// 节流函数
export const throttle = (func, limit) => {
let inThrottle
return function(...args) {
if (!inThrottle) {
func.apply(this, args)
inThrottle = true
setTimeout(() => inThrottle = false, limit)
}
}
}
// 复制到剪贴板
export const copyToClipboard = async (text) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return true
} else {
// 降级方案
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
return true
} catch (err) {
console.error('复制失败:', err)
return false
} finally {
textArea.remove()
}
}
} catch (err) {
console.error('复制失败:', err)
return false
}
}
// 震动反馈
export const vibrate = (pattern = 50) => {
if ('vibrate' in navigator) {
navigator.vibrate(pattern)
}
}
// 全屏显示
export const requestFullscreen = (element = document.documentElement) => {
if (element.requestFullscreen) {
return element.requestFullscreen()
} else if (element.webkitRequestFullscreen) {
return element.webkitRequestFullscreen()
} else if (element.msRequestFullscreen) {
return element.msRequestFullscreen()
}
}
// 退出全屏
export const exitFullscreen = () => {
if (document.exitFullscreen) {
return document.exitFullscreen()
} else if (document.webkitExitFullscreen) {
return document.webkitExitFullscreen()
} else if (document.msExitFullscreen) {
return document.msExitFullscreen()
}
}
// 检测网络状态
export const getNetworkStatus = () => {
if ('connection' in navigator) {
const connection = navigator.connection
return {
online: navigator.onLine,
effectiveType: connection.effectiveType,
downlink: connection.downlink,
rtt: connection.rtt,
saveData: connection.saveData
}
}
return {
online: navigator.onLine
}
}
// 监听网络变化
export const onNetworkChange = (callback) => {
const handleOnline = () => callback({ online: true })
const handleOffline = () => callback({ online: false })
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}
// PWA安装提示
export const handlePWAInstall = () => {
let deferredPrompt
const beforeInstallPrompt = (e) => {
e.preventDefault()
deferredPrompt = e
}
const showInstallPrompt = async () => {
if (deferredPrompt) {
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
deferredPrompt = null
return outcome === 'accepted'
}
return false
}
window.addEventListener('beforeinstallprompt', beforeInstallPrompt)
return {
showInstallPrompt,
cleanup: () => {
window.removeEventListener('beforeinstallprompt', beforeInstallPrompt)
}
}
}
export default {
isMobileDevice,
isTouchDevice,
getOrientation,
onOrientationChange,
preventIOSZoom,
addTouchFeedback,
scrollToTop,
scrollToElement,
getSafeArea,
handleVirtualKeyboard,
debounce,
throttle,
copyToClipboard,
vibrate,
requestFullscreen,
exitFullscreen,
getNetworkStatus,
onNetworkChange,
handlePWAInstall
}

View File

@@ -43,6 +43,11 @@ export const PERMISSIONS = {
ANNOUNCEMENT_UPDATE: 'announcement:update',
ANNOUNCEMENT_DELETE: 'announcement:delete',
ANNOUNCEMENT_VIEW: 'announcement:view',
// 退单管理权限
REFUND_MANAGE: 'refund:manage',
REFUND_EXECUTE: 'refund:execute',
REFUND_VIEW: 'refund:view',
}
// 角色权限映射
@@ -67,12 +72,17 @@ export const ROLE_PERMISSIONS = {
PERMISSIONS.ANNOUNCEMENT_UPDATE,
PERMISSIONS.ANNOUNCEMENT_DELETE,
PERMISSIONS.ANNOUNCEMENT_VIEW,
PERMISSIONS.REFUND_MANAGE,
PERMISSIONS.REFUND_EXECUTE,
PERMISSIONS.REFUND_VIEW,
],
AGENT: [
// 代理商只有查看权限,没有管理权限
PERMISSIONS.LINK_VIEW,
PERMISSIONS.QR_VIEW,
PERMISSIONS.ANNOUNCEMENT_VIEW,
PERMISSIONS.REFUND_VIEW,
PERMISSIONS.REFUND_EXECUTE, // 代理可以执行退单操作
]
}
@@ -81,6 +91,7 @@ export const ROUTE_PERMISSIONS = {
'Users': [PERMISSIONS.USER_VIEW],
'Settings': [PERMISSIONS.SETTING_MANAGE],
'Links': [PERMISSIONS.LINK_VIEW],
'Refund': [PERMISSIONS.REFUND_VIEW],
'Announcements': [PERMISSIONS.ANNOUNCEMENT_VIEW],
}

View File

@@ -8,7 +8,15 @@
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px" @keyup.enter.native="onSubmit">
<el-form
ref="formRef"
:model="form"
:rules="rules"
:label-position="isMobile ? 'top' : 'left'"
:label-width="isMobile ? 'auto' : '80px'"
class="login-form"
@keyup.enter.native="onSubmit"
>
<el-form-item label="用户名" prop="username">
<el-input v-model.trim="form.username" placeholder="请输入用户名" clearable />
</el-form-item>
@@ -33,7 +41,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { login } from '../api/auth'
import { setTokens } from '../utils/auth'
@@ -43,6 +51,12 @@ import { showErrorMessage, showSuccessMessage } from '@/utils/error'
const formRef = ref()
const loading = ref(false)
const remember = ref(false)
const isMobile = ref(false)
// 检测移动端
const checkMobile = () => {
isMobile.value = window.innerWidth <= 768
}
const form = ref({
username: 'admin',
@@ -66,6 +80,14 @@ onMounted(() => {
if (remember.value && savedUser) {
form.value.username = savedUser
}
// 检测移动端
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
function onForget() {
@@ -123,22 +145,168 @@ async function onSubmit() {
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f2f6fc 0%, #ffffff 100%);
padding: 20px;
box-sizing: border-box;
}
.login-card {
width: 420px;
width: 100%;
max-width: 420px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
justify-content: center;
}
.logo { width: 32px; height: 32px; }
.title { font-size: 18px; font-weight: 600; }
.logo {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.title {
font-size: 18px;
font-weight: 600;
}
.login-form {
width: 100%;
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
margin: -4px 0 8px;
flex-wrap: wrap;
gap: 8px;
}
.submit {
width: 100%;
min-height: 44px;
font-size: 16px;
}
/* 移动端优化 */
@media (max-width: 768px) {
.login-page {
padding: 16px;
align-items: flex-start;
padding-top: 10vh;
}
.login-card {
max-width: 100%;
box-shadow: none;
border: none;
background: transparent;
}
.login-card :deep(.el-card__header) {
padding: 20px 0;
border-bottom: none;
background: transparent;
}
.login-card :deep(.el-card__body) {
padding: 0;
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card-header {
margin-bottom: 8px;
}
.title {
font-size: 20px;
color: #303133;
}
.login-form :deep(.el-form-item) {
margin-bottom: 20px;
}
.login-form :deep(.el-form-item__label) {
padding-bottom: 8px;
font-weight: 500;
color: #606266;
line-height: 1.4;
}
.login-form :deep(.el-input__wrapper) {
min-height: 44px;
border-radius: 8px;
}
.login-form :deep(.el-input__inner) {
font-size: 16px;
height: 44px;
line-height: 44px;
}
.actions {
justify-content: center;
text-align: center;
margin: 8px 0 20px;
}
.actions .el-link {
font-size: 14px;
}
.submit {
min-height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
}
}
@media (max-width: 480px) {
.login-page {
padding: 12px;
padding-top: 8vh;
}
.login-card :deep(.el-card__body) {
padding: 20px;
}
.card-header {
flex-direction: column;
gap: 8px;
}
.logo {
width: 40px;
height: 40px;
}
.title {
font-size: 18px;
}
}
/* 横屏模式优化 */
@media (max-height: 500px) and (orientation: landscape) {
.login-page {
padding-top: 5vh;
}
.login-card :deep(.el-card__body) {
padding: 16px;
}
.login-form :deep(.el-form-item) {
margin-bottom: 16px;
}
}
.submit { width: 100%; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,548 @@
<template>
<div class="refund-management">
<div class="page-header">
<h2>退单管理</h2>
<p class="page-description">管理用户的退单申请支持按链接编号查询和执行退单操作</p>
</div>
<!-- 搜索区域 -->
<div class="search-section">
<el-card>
<el-row :gutter="20">
<el-col :span="8">
<el-input
v-model="searchForm.codeNo"
placeholder="请输入链接编号"
clearable
@keyup.enter="handleSearch"
>
<template #prepend>链接编号</template>
</el-input>
</el-col>
<el-col :span="8">
<el-select
v-model="searchForm.status"
placeholder="选择状态"
clearable
style="width: 100%"
>
<el-option label="全部状态" value="" />
<el-option label="新建" value="NEW" />
<el-option label="使用中" value="USING" />
<el-option label="已登录" value="LOGGED_IN" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="已退单" value="REFUNDED" />
<el-option label="已过期" value="EXPIRED" />
</el-select>
</el-col>
<el-col :span="8">
<el-button type="primary" @click="handleSearch" :loading="loading">
<el-icon><Search /></el-icon>
查询
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-col>
</el-row>
</el-card>
</div>
<!-- 链接信息卡片 -->
<div v-if="linkInfo" class="link-info-section">
<el-card>
<template #header>
<div class="card-header">
<span>链接信息</span>
<el-tag :type="getStatusTagType(linkInfo.status)">
{{ getStatusText(linkInfo.status) }}
</el-tag>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="链接编号">{{ linkInfo.codeNo }}</el-descriptions-item>
<el-descriptions-item label="当前状态">
<el-tag :type="getStatusTagType(linkInfo.status)">
{{ getStatusText(linkInfo.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(linkInfo.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(linkInfo.updatedAt) }}</el-descriptions-item>
<el-descriptions-item label="代理ID">{{ linkInfo.agentId || '-' }}</el-descriptions-item>
<el-descriptions-item label="关联设备">{{ linkInfo.machineId || '-' }}</el-descriptions-item>
<el-descriptions-item label="总点数">{{ linkInfo.totalPoints || '-' }}</el-descriptions-item>
<el-descriptions-item label="当前点数">{{ linkInfo.currentPoints || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="linkInfo.refundAt" label="退单时间">
{{ formatDateTime(linkInfo.refundAt) }}
</el-descriptions-item>
</el-descriptions>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button
v-if="canRefund(linkInfo.status)"
type="danger"
@click="handleRefund"
:loading="refunding"
>
<el-icon><RefreshLeft /></el-icon>
执行退单
</el-button>
<el-button
v-else-if="linkInfo.status === 'REFUNDED'"
disabled
type="info"
>
<el-icon><Check /></el-icon>
已退单
</el-button>
<el-button
v-else
disabled
type="info"
>
<el-icon><Warning /></el-icon>
当前状态不允许退单
</el-button>
</div>
</el-card>
</div>
<!-- 空状态提示 -->
<div v-if="!linkInfo && !loading && searchForm.codeNo" class="empty-state">
<el-empty description="未找到相关链接信息">
<el-button type="primary" @click="handleReset">重新查询</el-button>
</el-empty>
</div>
<!-- 使用说明 -->
<div class="help-section">
<el-card>
<template #header>
<span>退单说明</span>
</template>
<el-collapse>
<el-collapse-item title="可退单的状态" name="1">
<ul class="help-list">
<li><el-tag type="success">NEW</el-tag> - 新建状态尚未开始使用</li>
<li><el-tag type="warning">USING</el-tag> - 使用中状态正在等待登录</li>
<li><el-tag type="primary">LOGGED_IN</el-tag> - 已登录状态游戏进行中</li>
</ul>
</el-collapse-item>
<el-collapse-item title="不可退单的状态" name="2">
<ul class="help-list">
<li><el-tag type="info">REFUNDED</el-tag> - 已退单避免重复操作</li>
<li><el-tag type="danger">EXPIRED</el-tag> - 已过期无法进行退单</li>
<li><el-tag type="success">COMPLETED</el-tag> - 已完成游戏已结束</li>
</ul>
</el-collapse-item>
<el-collapse-item title="退单流程" name="3">
<ol class="help-list">
<li>系统验证用户权限和链接状态</li>
<li>如果链接关联了设备调用外部脚本退单接口</li>
<li>更新链接状态为 <el-tag type="info">REFUNDED</el-tag></li>
<li>记录退单时间</li>
</ol>
</el-collapse-item>
<el-collapse-item title="注意事项" name="4">
<ul class="help-list">
<li>退单操作不可逆请谨慎操作</li>
<li>只有代理用户可以退单自己创建的链接</li>
<li>即使外部接口调用失败本地状态仍会更新</li>
<li>退单后的链接无法继续使用</li>
</ul>
</el-collapse-item>
</el-collapse>
</el-card>
</div>
<!-- 退单确认对话框 -->
<el-dialog
v-model="refundDialogVisible"
title="确认退单"
width="500px"
:before-close="handleRefundDialogClose"
>
<div class="refund-confirm">
<el-icon class="warning-icon"><WarningFilled /></el-icon>
<div class="confirm-content">
<h3>确认要对以下链接执行退单操作吗</h3>
<p class="link-code">链接编号<strong>{{ linkInfo?.codeNo }}</strong></p>
<p class="warning-text">
<el-icon><Warning /></el-icon>
退单操作不可逆执行后链接将无法继续使用
</p>
</div>
</div>
<template #footer>
<el-button @click="refundDialogVisible = false">取消</el-button>
<el-button
type="danger"
@click="confirmRefund"
:loading="refunding"
>
确认退单
</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, RefreshLeft, Check, Warning, WarningFilled } from '@element-plus/icons-vue'
import { getLinkStatus, refundLink } from '@/api/links'
export default {
name: 'RefundManagement',
components: {
Search,
Refresh,
RefreshLeft,
Check,
Warning,
WarningFilled
},
setup() {
// 响应式数据
const loading = ref(false)
const refunding = ref(false)
const refundDialogVisible = ref(false)
const linkInfo = ref(null)
const searchForm = reactive({
codeNo: '',
status: ''
})
// 查询链接信息
const handleSearch = async () => {
if (!searchForm.codeNo.trim()) {
ElMessage.warning('请输入链接编号')
return
}
loading.value = true
try {
const response = await getLinkStatus(searchForm.codeNo)
linkInfo.value = response.data
if (!linkInfo.value) {
ElMessage.warning('未找到相关链接信息')
}
} catch (error) {
console.error('查询链接失败:', error)
linkInfo.value = null
// 根据错误状态显示不同消息
if (error.response?.status === 404) {
ElMessage.error('链接不存在')
} else if (error.response?.status === 403) {
ElMessage.error('无权限查看此链接')
} else {
ElMessage.error('查询失败,请稍后重试')
}
} finally {
loading.value = false
}
}
// 重置搜索
const handleReset = () => {
searchForm.codeNo = ''
searchForm.status = ''
linkInfo.value = null
}
// 判断是否可以退单
const canRefund = (status) => {
return ['NEW', 'USING', 'LOGGED_IN'].includes(status)
}
// 处理退单按钮点击
const handleRefund = () => {
if (!linkInfo.value) return
refundDialogVisible.value = true
}
// 确认退单
const confirmRefund = async () => {
if (!linkInfo.value) return
refunding.value = true
try {
await refundLink(linkInfo.value.codeNo)
// 更新本地状态
linkInfo.value.status = 'REFUNDED'
linkInfo.value.refundAt = new Date().toISOString()
linkInfo.value.updatedAt = new Date().toISOString()
refundDialogVisible.value = false
ElMessage.success('退单操作成功')
} catch (error) {
console.error('退单失败:', error)
// 根据错误状态显示不同消息
if (error.response?.status === 400) {
const errorCode = error.response.data?.code
switch (errorCode) {
case 'LINK_003':
ElMessage.error('链接已经退过单了')
break
case 'LINK_004':
ElMessage.error('过期链接无法退单')
break
case 'LINK_005':
ElMessage.error('已完成的游戏无法退单')
break
default:
ElMessage.error(error.response.data?.message || '退单失败')
}
} else if (error.response?.status === 403) {
ElMessage.error('无权限操作此链接')
} else {
ElMessage.error('退单失败,请稍后重试')
}
} finally {
refunding.value = false
}
}
// 关闭退单对话框
const handleRefundDialogClose = () => {
if (!refunding.value) {
refundDialogVisible.value = false
}
}
// 获取状态标签类型
const getStatusTagType = (status) => {
const statusTypes = {
'NEW': 'info',
'USING': 'warning',
'LOGGED_IN': 'primary',
'COMPLETED': 'success',
'REFUNDED': 'info',
'EXPIRED': 'danger'
}
return statusTypes[status] || 'info'
}
// 获取状态文本
const getStatusText = (status) => {
const statusTexts = {
'NEW': '新建',
'USING': '使用中',
'LOGGED_IN': '已登录',
'COMPLETED': '已完成',
'REFUNDED': '已退单',
'EXPIRED': '已过期'
}
return statusTexts[status] || status
}
// 格式化时间
const formatDateTime = (dateTime) => {
if (!dateTime) return '-'
try {
const date = new Date(dateTime)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
} catch (error) {
return dateTime
}
}
return {
loading,
refunding,
refundDialogVisible,
linkInfo,
searchForm,
handleSearch,
handleReset,
canRefund,
handleRefund,
confirmRefund,
handleRefundDialogClose,
getStatusTagType,
getStatusText,
formatDateTime
}
}
}
</script>
<style scoped>
.refund-management {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
}
.page-header h2 {
margin: 0 0 8px 0;
color: #303133;
font-size: 24px;
font-weight: 600;
}
.page-description {
margin: 0;
color: #909399;
font-size: 14px;
}
.search-section {
margin-bottom: 20px;
}
.link-info-section {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.action-buttons {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ebeef5;
text-align: right;
}
.empty-state {
margin-bottom: 20px;
}
.help-section {
margin-bottom: 20px;
}
.help-list {
margin: 0;
padding-left: 20px;
}
.help-list li {
margin-bottom: 8px;
line-height: 1.6;
}
.help-list .el-tag {
margin-right: 8px;
}
/* 退单确认对话框样式 */
.refund-confirm {
display: flex;
align-items: flex-start;
gap: 16px;
}
.warning-icon {
font-size: 24px;
color: #e6a23c;
flex-shrink: 0;
margin-top: 4px;
}
.confirm-content {
flex: 1;
}
.confirm-content h3 {
margin: 0 0 12px 0;
font-size: 16px;
color: #303133;
}
.link-code {
margin: 8px 0;
font-size: 14px;
color: #606266;
}
.link-code strong {
color: #409eff;
font-family: 'Courier New', monospace;
}
.warning-text {
margin: 12px 0 0 0;
color: #e6a23c;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.refund-management {
padding: 12px;
}
.page-header h2 {
font-size: 20px;
}
.action-buttons {
text-align: center;
}
.el-descriptions {
font-size: 12px;
}
.refund-confirm {
flex-direction: column;
text-align: center;
}
.warning-icon {
align-self: center;
}
}
/* 深色主题适配 */
.dark .page-header h2 {
color: #e5eaf3;
}
.dark .page-description {
color: #a3a6ad;
}
.dark .confirm-content h3 {
color: #e5eaf3;
}
.dark .link-code {
color: #a3a6ad;
}
</style>

View File

@@ -31,7 +31,12 @@
</div>
<!-- 配置分类标签页 -->
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tabs
v-model="activeTab"
@tab-change="handleTabChange"
:tab-position="isMobile ? 'top' : 'top'"
:class="{ 'mobile-tabs': isMobile }"
>
<el-tab-pane label="链接配置" name="link">
<config-group
:configs="linkConfigs"
@@ -78,8 +83,9 @@
ref="formRef"
:model="form"
:rules="formRules"
label-width="120px"
label-position="right"
:label-width="isMobile ? 'auto' : '120px'"
:label-position="isMobile ? 'top' : 'right'"
:class="{ 'mobile-form': isMobile }"
>
<el-form-item label="配置键" prop="configKey">
<el-input
@@ -174,7 +180,7 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
@@ -204,6 +210,12 @@ const loading = ref(false)
const activeTab = ref('link')
const allConfigs = ref([])
const changedConfigs = ref(new Map())
const isMobile = ref(false)
// 检测移动端
const checkMobile = () => {
isMobile.value = window.innerWidth <= 768
}
// 对话框相关
const dialogVisible = ref(false)
@@ -262,9 +274,15 @@ const userConfigs = computed(() => {
// 生命周期
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
loadConfigs()
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
// 方法
async function loadConfigs() {
try {
@@ -545,5 +563,256 @@ function handleRefresh() {
:deep(.el-dialog__body) {
padding-top: 10px;
}
/* 移动端样式 */
@media (max-width: 768px) {
.system-config {
padding: 12px;
}
.page-header {
margin-bottom: 20px;
text-align: center;
}
.page-header h2 {
font-size: 20px;
}
.page-header p {
font-size: 13px;
}
.action-bar {
margin-bottom: 20px;
flex-direction: column;
gap: 8px;
}
.action-bar .el-button {
width: 100%;
height: 44px;
font-size: 16px;
justify-content: center;
}
/* 移动端标签页优化 */
.mobile-tabs :deep(.el-tabs__header) {
margin-bottom: 16px;
}
.mobile-tabs :deep(.el-tabs__nav-wrap) {
padding: 0 12px;
}
.mobile-tabs :deep(.el-tabs__item) {
padding: 0 12px;
height: 44px;
line-height: 44px;
font-size: 14px;
}
.mobile-tabs :deep(.el-tabs__content) {
padding-top: 16px;
}
/* 移动端表单优化 */
.mobile-form :deep(.el-form-item) {
margin-bottom: 20px;
}
.mobile-form :deep(.el-form-item__label) {
padding-bottom: 8px;
line-height: 1.4;
}
.mobile-form :deep(.el-input),
.mobile-form :deep(.el-textarea),
.mobile-form :deep(.el-select) {
width: 100%;
}
.mobile-form :deep(.el-input__wrapper) {
min-height: 44px;
}
.mobile-form :deep(.el-input__inner) {
height: 44px;
line-height: 44px;
font-size: 16px;
}
.mobile-form :deep(.el-textarea__inner) {
font-size: 16px;
min-height: 80px;
}
.mobile-form :deep(.el-select .el-input) {
height: 44px;
}
.mobile-form :deep(.el-select .el-input__inner) {
height: 44px;
line-height: 44px;
}
.mobile-form :deep(.el-switch) {
height: 24px;
}
.form-tip {
font-size: 13px;
margin-top: 6px;
}
/* 对话框移动端优化 */
:deep(.el-dialog) {
width: 95% !important;
margin: 0 auto !important;
margin-top: 5vh !important;
max-height: 90vh;
overflow-y: auto;
border-radius: 12px;
}
:deep(.el-dialog__header) {
padding: 16px 20px 12px;
}
:deep(.el-dialog__body) {
padding: 12px 20px 20px;
}
:deep(.el-dialog__footer) {
padding: 12px 20px 16px;
}
:deep(.el-dialog__title) {
font-size: 18px;
}
/* 上传组件移动端优化 */
.upload-demo :deep(.el-upload) {
width: 100%;
}
.upload-demo :deep(.el-upload-dragger) {
width: 100%;
height: 120px;
}
.upload-demo :deep(.el-icon--upload) {
font-size: 48px;
margin-bottom: 12px;
}
.upload-demo :deep(.el-upload__text) {
font-size: 14px;
}
.upload-demo :deep(.el-upload__tip) {
font-size: 12px;
margin-top: 8px;
}
/* 卡片移动端优化 */
:deep(.el-card) {
margin-bottom: 16px;
border-radius: 12px;
}
:deep(.el-card__header) {
padding: 16px;
}
:deep(.el-card__body) {
padding: 16px;
}
}
@media (max-width: 480px) {
.system-config {
padding: 8px;
}
.page-header {
margin-bottom: 16px;
}
.page-header h2 {
font-size: 18px;
}
.action-bar {
margin-bottom: 16px;
}
.mobile-tabs :deep(.el-tabs__item) {
padding: 0 8px;
font-size: 13px;
}
.mobile-form :deep(.el-form-item) {
margin-bottom: 16px;
}
.mobile-form :deep(.el-textarea__inner) {
min-height: 70px;
}
:deep(.el-dialog__header),
:deep(.el-dialog__body),
:deep(.el-dialog__footer) {
padding-left: 16px;
padding-right: 16px;
}
:deep(.el-card__header),
:deep(.el-card__body) {
padding: 12px;
}
}
/* 横屏模式优化 */
@media (max-height: 600px) and (orientation: landscape) {
.system-config {
padding: 8px;
}
.page-header {
margin-bottom: 12px;
}
.page-header h2 {
font-size: 16px;
margin-bottom: 4px;
}
.action-bar {
margin-bottom: 12px;
flex-direction: row;
gap: 8px;
}
.action-bar .el-button {
flex: 1;
height: 36px;
font-size: 14px;
}
.mobile-tabs :deep(.el-tabs__item) {
height: 36px;
line-height: 36px;
}
.mobile-tabs :deep(.el-tabs__content) {
padding-top: 12px;
}
:deep(.el-dialog) {
margin-top: 2vh !important;
max-height: 95vh;
}
}
</style>

View File

@@ -15,6 +15,120 @@
<!-- 用户管理内容 -->
<div v-else>
<!-- 移动端卡片列表 -->
<div v-if="isMobile" class="mobile-user-list">
<!-- 移动端搜索栏 -->
<el-card class="mobile-search-card" shadow="never">
<el-form :model="query" @submit.prevent>
<el-form-item>
<el-input
v-model.trim="query.keyword"
placeholder="搜索用户名"
clearable
style="width: 100%"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-select v-model="query.userType" clearable placeholder="用户类型" style="width: 100%">
<el-option value="ADMIN" label="管理员" />
<el-option value="AGENT" label="代理" />
</el-select>
</el-col>
<el-col :span="12">
<el-select v-model="query.status" clearable placeholder="状态" style="width: 100%">
<el-option value="ENABLED" label="启用" />
<el-option value="DISABLED" label="禁用" />
</el-select>
</el-col>
</el-row>
</el-form-item>
<el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-button type="primary" :loading="loading" @click="onSearch" style="width: 100%">查询</el-button>
</el-col>
<el-col :span="12">
<el-button @click="onReset" style="width: 100%">重置</el-button>
</el-col>
</el-row>
</el-form-item>
<el-form-item v-if="canCreateUser">
<el-button type="success" @click="openCreate" style="width: 100%">新增用户</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 移动端用户卡片列表 -->
<div v-loading="loading" class="mobile-cards">
<el-card
v-for="(user, index) in list"
:key="user.id"
class="mobile-user-card"
shadow="hover"
>
<div class="mobile-user-info">
<div class="user-header">
<div class="user-name">
<span class="index-number">#{{ (query.page - 1) * query.pageSize + index + 1 }}</span>
<span class="username">{{ user.username }}</span>
</div>
<el-switch
:model-value="user.status === 'ENABLED'"
@change="(v)=>onToggle(user, v)"
size="small"
/>
</div>
<div class="user-details">
<div class="detail-item">
<span class="label">类型:</span>
<el-tag :type="user.userType === 'ADMIN' ? 'danger' : 'success'" size="small">
{{ getUserTypeDisplayName(user.userType) }}
</el-tag>
</div>
<div v-if="user.userType === 'AGENT'" class="detail-item">
<span class="label">积分余额:</span>
<span class="value">{{ user.pointsBalance || 0 }}</span>
</div>
<div class="detail-item">
<span class="label">创建时间:</span>
<span class="value">{{ user.createdAt }}</span>
</div>
</div>
<div class="user-actions">
<el-button v-if="canEditUser" size="small" @click="openEdit(user)">编辑</el-button>
<el-button v-if="canResetPassword" size="small" type="warning" @click="onResetPwd(user)">重置密码</el-button>
<el-button v-if="canDeleteUser" size="small" type="danger" @click="onRemove(user)">删除</el-button>
</div>
</div>
</el-card>
</div>
<!-- 移动端分页 -->
<div class="mobile-pagination">
<el-pagination
background
layout="prev, pager, next"
:total="total"
:page-size="query.pageSize"
:current-page="query.page"
@current-change="(p)=>{ query.page=p; load(); }"
small
/>
</div>
</div>
<!-- 桌面端表格 -->
<div v-else>
<el-card class="mb16" shadow="never">
<el-form :inline="true" :model="query" @submit.prevent>
<el-form-item label="关键词">
@@ -118,8 +232,9 @@
</template>
<script setup>
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { ref, reactive, onMounted, onUnmounted, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { fetchUsers, createUser, updateUser, deleteUser, setUserStatus, resetUserPassword } from '@/api/users'
import { showErrorMessage, showSuccessMessage } from '@/utils/error'
import { hasPermission, PERMISSIONS } from '@/utils/permission'
@@ -127,6 +242,12 @@ import { hasPermission, PERMISSIONS } from '@/utils/permission'
const loading = ref(false)
const list = ref([])
const total = ref(0)
const isMobile = ref(false)
// 检测移动端
const checkMobile = () => {
isMobile.value = window.innerWidth <= 768
}
const query = reactive({
page: 1,
@@ -345,11 +466,221 @@ watch(() => form.userType, (newType) => {
}
})
onMounted(load)
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
load()
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
<style scoped>
.users-page {
padding: 0;
}
.mb16 { margin-bottom: 16px; }
.mr4 { margin-right: 4px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
/* 移动端样式 */
.mobile-user-list {
display: flex;
flex-direction: column;
height: 100%;
}
.mobile-search-card {
margin-bottom: 16px;
border-radius: 12px;
}
.mobile-search-card :deep(.el-card__body) {
padding: 16px;
}
.mobile-search-card .el-form-item {
margin-bottom: 16px;
}
.mobile-search-card .el-form-item:last-child {
margin-bottom: 0;
}
.mobile-cards {
flex: 1;
overflow-y: auto;
}
.mobile-user-card {
margin-bottom: 12px;
border-radius: 12px;
transition: all 0.3s ease;
}
.mobile-user-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.mobile-user-card:last-child {
margin-bottom: 0;
}
.mobile-user-card :deep(.el-card__body) {
padding: 16px;
}
.mobile-user-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.user-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.user-name {
display: flex;
align-items: center;
gap: 8px;
}
.index-number {
font-size: 12px;
color: #909399;
background: #f5f7fa;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.username {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.user-details {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0;
}
.detail-item .label {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.detail-item .value {
font-size: 14px;
color: #303133;
}
.user-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
.user-actions .el-button {
flex: 1;
min-width: 0;
}
.mobile-pagination {
padding: 16px 0;
display: flex;
justify-content: center;
background: #fff;
border-top: 1px solid #f0f0f0;
margin-top: 16px;
}
/* 桌面端隐藏移动端组件 */
@media (min-width: 769px) {
.mobile-user-list {
display: none;
}
}
/* 移动端隐藏桌面端组件 */
@media (max-width: 768px) {
.users-page > div:not(.mobile-user-list) {
display: none;
}
.users-page .permission-denied {
display: block;
}
.mobile-search-card .el-input {
height: 44px;
}
.mobile-search-card .el-input .el-input__inner {
height: 44px;
line-height: 44px;
font-size: 16px;
}
.mobile-search-card .el-select {
height: 44px;
}
.mobile-search-card .el-button {
height: 44px;
font-size: 16px;
}
}
@media (max-width: 480px) {
.mobile-search-card :deep(.el-card__body) {
padding: 12px;
}
.mobile-user-card :deep(.el-card__body) {
padding: 12px;
}
.user-actions {
flex-direction: column;
}
.user-actions .el-button {
flex: none;
width: 100%;
}
.detail-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.username {
font-size: 15px;
}
.detail-item .label,
.detail-item .value {
font-size: 13px;
}
}
</style>