优化移动端界面,新增退单管理功能,添加相关API接口,更新权限设置,调整布局以支持响应式设计,提升用户体验。
This commit is contained in:
179
docs/退单功能使用说明.md
Normal file
179
docs/退单功能使用说明.md
Normal 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月*
|
||||
@@ -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>
|
||||
|
||||
458
src/App.vue
458
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
137
src/plugins/mobile.js
Normal 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
328
src/utils/mobile.js
Normal 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
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
|
||||
@@ -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
548
src/views/refund/RefundManagement.vue
Normal file
548
src/views/refund/RefundManagement.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user