优化移动端界面,新增退单管理功能,添加相关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">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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>
|
<title>Vue3 App</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
458
src/App.vue
458
src/App.vue
@@ -5,6 +5,464 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
</script>
|
</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 scoped>
|
||||||
/* 根组件仅承载路由视图 */
|
/* 根组件仅承载路由视图 */
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -22,4 +22,14 @@ export function batchDeleteLinks(codeNos) {
|
|||||||
return http.post('/api/link/batch-delete', { 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>
|
<template>
|
||||||
<div class="admin-layout">
|
<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">
|
<div class="brand">
|
||||||
<img class="logo" src="https://vuejs.org/images/logo.png" alt="logo" />
|
<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>
|
</div>
|
||||||
<el-menu
|
<el-menu
|
||||||
class="menu"
|
class="menu"
|
||||||
router
|
router
|
||||||
:default-active="$route.name"
|
:default-active="$route.name"
|
||||||
:collapse="collapsed"
|
:collapse="collapsed && !isMobile"
|
||||||
background-color="#001529"
|
background-color="#001529"
|
||||||
text-color="#bfcbd9"
|
text-color="#bfcbd9"
|
||||||
active-text-color="#fff"
|
active-text-color="#fff"
|
||||||
|
@select="onMenuSelect"
|
||||||
>
|
>
|
||||||
|
|
||||||
<el-menu-item v-if="canAccessUsers" index="Users" :route="{ name: 'Users' }">
|
<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>
|
<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>
|
<span>链接管理</span>
|
||||||
</el-menu-item>
|
</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' }">
|
<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>
|
<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>
|
<span>公告管理</span>
|
||||||
@@ -40,13 +53,17 @@
|
|||||||
</aside>
|
</aside>
|
||||||
<section class="main">
|
<section class="main">
|
||||||
<header class="header">
|
<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>
|
<svg width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M3 6h18v2H3V6m0 5h12v2H3v-2m0 5h18v2H3v-2Z"/></svg>
|
||||||
</el-button>
|
</el-button>
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
|
<!-- 移动端显示当前页面标题 -->
|
||||||
|
<span v-if="isMobile" class="mobile-page-title">{{ currentPageTitle }}</span>
|
||||||
|
<div v-if="!isMobile" class="spacer" />
|
||||||
<el-dropdown>
|
<el-dropdown>
|
||||||
<span class="el-dropdown-link">
|
<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>
|
</span>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
@@ -65,23 +82,61 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { clearTokens } from '@/utils/auth'
|
import { clearTokens } from '@/utils/auth'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { canAccessRoute, getCurrentUser } from '@/utils/permission'
|
import { canAccessRoute, getCurrentUser } from '@/utils/permission'
|
||||||
|
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
|
const isMobile = ref(false)
|
||||||
const router = useRouter()
|
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 currentUser = computed(() => getCurrentUser())
|
||||||
|
|
||||||
|
// 获取当前页面标题
|
||||||
|
const currentPageTitle = computed(() => {
|
||||||
|
return pageTitleMap[route.name] || '管理后台'
|
||||||
|
})
|
||||||
|
|
||||||
// 权限检查
|
// 权限检查
|
||||||
const canAccessUsers = computed(() => canAccessRoute('Users'))
|
const canAccessUsers = computed(() => canAccessRoute('Users'))
|
||||||
const canAccessLinks = computed(() => canAccessRoute('Links'))
|
const canAccessLinks = computed(() => canAccessRoute('Links'))
|
||||||
|
const canAccessRefund = computed(() => canAccessRoute('Refund'))
|
||||||
const canAccessAnnouncements = computed(() => canAccessRoute('Announcements'))
|
const canAccessAnnouncements = computed(() => canAccessRoute('Announcements'))
|
||||||
const canAccessSettings = computed(() => canAccessRoute('Settings'))
|
const canAccessSettings = computed(() => canAccessRoute('Settings'))
|
||||||
|
|
||||||
|
// 切换侧边栏
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
collapsed.value = !collapsed.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单选择事件(移动端选择后自动收起)
|
||||||
|
const onMenuSelect = () => {
|
||||||
|
if (isMobile.value) {
|
||||||
|
collapsed.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onProfile() {
|
function onProfile() {
|
||||||
// 可跳转到个人中心占位页
|
// 可跳转到个人中心占位页
|
||||||
}
|
}
|
||||||
@@ -90,20 +145,61 @@ function onLogout() {
|
|||||||
clearTokens()
|
clearTokens()
|
||||||
router.replace({ name: 'Login' })
|
router.replace({ name: 'Login' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听窗口大小变化
|
||||||
|
onMounted(() => {
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-layout {
|
.admin-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
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 {
|
.sider {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
background: #001529;
|
background: #001529;
|
||||||
color: #bfcbd9;
|
color: #bfcbd9;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.brand {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -111,10 +207,34 @@ function onLogout() {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
border-bottom: 1px solid #334050;
|
||||||
}
|
}
|
||||||
.logo { width: 24px; height: 24px; }
|
|
||||||
.menu { border-right: none; flex: 1; }
|
.logo {
|
||||||
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
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 {
|
.header {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -122,9 +242,110 @@ function onLogout() {
|
|||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
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>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ import 'element-plus/dist/index.css'
|
|||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { setupPermissionDirective } from './directives/permission'
|
import { setupPermissionDirective } from './directives/permission'
|
||||||
|
import mobilePlugin from './plugins/mobile'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(ElementPlus)
|
app.use(ElementPlus)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.use(mobilePlugin, {
|
||||||
|
preventZoom: true,
|
||||||
|
handleKeyboard: true,
|
||||||
|
networkMonitor: true,
|
||||||
|
touchFeedback: true
|
||||||
|
})
|
||||||
setupPermissionDirective(app)
|
setupPermissionDirective(app)
|
||||||
app.mount('#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 Settings = () => import('@/views/settings/Settings.vue')
|
||||||
const LinkGenerate = () => import('@/views/links/LinkGenerate.vue')
|
const LinkGenerate = () => import('@/views/links/LinkGenerate.vue')
|
||||||
const AnnouncementList = () => import('@/views/announcements/AnnouncementList.vue')
|
const AnnouncementList = () => import('@/views/announcements/AnnouncementList.vue')
|
||||||
|
const RefundManagement = () => import('@/views/refund/RefundManagement.vue')
|
||||||
const Play = () => import('@/views/Play.vue')
|
const Play = () => import('@/views/Play.vue')
|
||||||
const NotFound = () => import('@/views/NotFound.vue')
|
const NotFound = () => import('@/views/NotFound.vue')
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export const routes = [
|
|||||||
{ path: 'users', name: 'Users', component: UserList, meta: { title: '用户管理' } },
|
{ path: 'users', name: 'Users', component: UserList, meta: { title: '用户管理' } },
|
||||||
{ path: 'settings', name: 'Settings', component: Settings, meta: { title: '系统设置' } },
|
{ path: 'settings', name: 'Settings', component: Settings, meta: { title: '系统设置' } },
|
||||||
{ path: 'links', name: 'Links', component: LinkGenerate, 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: '公告管理' } },
|
{ 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_UPDATE: 'announcement:update',
|
||||||
ANNOUNCEMENT_DELETE: 'announcement:delete',
|
ANNOUNCEMENT_DELETE: 'announcement:delete',
|
||||||
ANNOUNCEMENT_VIEW: 'announcement:view',
|
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_UPDATE,
|
||||||
PERMISSIONS.ANNOUNCEMENT_DELETE,
|
PERMISSIONS.ANNOUNCEMENT_DELETE,
|
||||||
PERMISSIONS.ANNOUNCEMENT_VIEW,
|
PERMISSIONS.ANNOUNCEMENT_VIEW,
|
||||||
|
PERMISSIONS.REFUND_MANAGE,
|
||||||
|
PERMISSIONS.REFUND_EXECUTE,
|
||||||
|
PERMISSIONS.REFUND_VIEW,
|
||||||
],
|
],
|
||||||
AGENT: [
|
AGENT: [
|
||||||
// 代理商只有查看权限,没有管理权限
|
// 代理商只有查看权限,没有管理权限
|
||||||
PERMISSIONS.LINK_VIEW,
|
PERMISSIONS.LINK_VIEW,
|
||||||
PERMISSIONS.QR_VIEW,
|
PERMISSIONS.QR_VIEW,
|
||||||
PERMISSIONS.ANNOUNCEMENT_VIEW,
|
PERMISSIONS.ANNOUNCEMENT_VIEW,
|
||||||
|
PERMISSIONS.REFUND_VIEW,
|
||||||
|
PERMISSIONS.REFUND_EXECUTE, // 代理可以执行退单操作
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +91,7 @@ export const ROUTE_PERMISSIONS = {
|
|||||||
'Users': [PERMISSIONS.USER_VIEW],
|
'Users': [PERMISSIONS.USER_VIEW],
|
||||||
'Settings': [PERMISSIONS.SETTING_MANAGE],
|
'Settings': [PERMISSIONS.SETTING_MANAGE],
|
||||||
'Links': [PERMISSIONS.LINK_VIEW],
|
'Links': [PERMISSIONS.LINK_VIEW],
|
||||||
|
'Refund': [PERMISSIONS.REFUND_VIEW],
|
||||||
'Announcements': [PERMISSIONS.ANNOUNCEMENT_VIEW],
|
'Announcements': [PERMISSIONS.ANNOUNCEMENT_VIEW],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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-form-item label="用户名" prop="username">
|
||||||
<el-input v-model.trim="form.username" placeholder="请输入用户名" clearable />
|
<el-input v-model.trim="form.username" placeholder="请输入用户名" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -33,7 +41,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { login } from '../api/auth'
|
import { login } from '../api/auth'
|
||||||
import { setTokens } from '../utils/auth'
|
import { setTokens } from '../utils/auth'
|
||||||
@@ -43,6 +51,12 @@ import { showErrorMessage, showSuccessMessage } from '@/utils/error'
|
|||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const remember = ref(false)
|
const remember = ref(false)
|
||||||
|
const isMobile = ref(false)
|
||||||
|
|
||||||
|
// 检测移动端
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth <= 768
|
||||||
|
}
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
@@ -66,6 +80,14 @@ onMounted(() => {
|
|||||||
if (remember.value && savedUser) {
|
if (remember.value && savedUser) {
|
||||||
form.value.username = savedUser
|
form.value.username = savedUser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测移动端
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
})
|
})
|
||||||
|
|
||||||
function onForget() {
|
function onForget() {
|
||||||
@@ -123,22 +145,168 @@ async function onSubmit() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(135deg, #f2f6fc 0%, #ffffff 100%);
|
background: linear-gradient(135deg, #f2f6fc 0%, #ffffff 100%);
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
width: 420px;
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: -4px 0 8px;
|
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>
|
</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>
|
</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">
|
<el-tab-pane label="链接配置" name="link">
|
||||||
<config-group
|
<config-group
|
||||||
:configs="linkConfigs"
|
:configs="linkConfigs"
|
||||||
@@ -78,8 +83,9 @@
|
|||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="form"
|
:model="form"
|
||||||
:rules="formRules"
|
:rules="formRules"
|
||||||
label-width="120px"
|
:label-width="isMobile ? 'auto' : '120px'"
|
||||||
label-position="right"
|
:label-position="isMobile ? 'top' : 'right'"
|
||||||
|
:class="{ 'mobile-form': isMobile }"
|
||||||
>
|
>
|
||||||
<el-form-item label="配置键" prop="configKey">
|
<el-form-item label="配置键" prop="configKey">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -174,7 +180,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -204,6 +210,12 @@ const loading = ref(false)
|
|||||||
const activeTab = ref('link')
|
const activeTab = ref('link')
|
||||||
const allConfigs = ref([])
|
const allConfigs = ref([])
|
||||||
const changedConfigs = ref(new Map())
|
const changedConfigs = ref(new Map())
|
||||||
|
const isMobile = ref(false)
|
||||||
|
|
||||||
|
// 检测移动端
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth <= 768
|
||||||
|
}
|
||||||
|
|
||||||
// 对话框相关
|
// 对话框相关
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
@@ -262,9 +274,15 @@ const userConfigs = computed(() => {
|
|||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
loadConfigs()
|
loadConfigs()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
async function loadConfigs() {
|
async function loadConfigs() {
|
||||||
try {
|
try {
|
||||||
@@ -545,5 +563,256 @@ function handleRefresh() {
|
|||||||
:deep(.el-dialog__body) {
|
:deep(.el-dialog__body) {
|
||||||
padding-top: 10px;
|
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>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,120 @@
|
|||||||
|
|
||||||
<!-- 用户管理内容 -->
|
<!-- 用户管理内容 -->
|
||||||
<div v-else>
|
<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-card class="mb16" shadow="never">
|
||||||
<el-form :inline="true" :model="query" @submit.prevent>
|
<el-form :inline="true" :model="query" @submit.prevent>
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
@@ -118,8 +232,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import { fetchUsers, createUser, updateUser, deleteUser, setUserStatus, resetUserPassword } from '@/api/users'
|
import { fetchUsers, createUser, updateUser, deleteUser, setUserStatus, resetUserPassword } from '@/api/users'
|
||||||
import { showErrorMessage, showSuccessMessage } from '@/utils/error'
|
import { showErrorMessage, showSuccessMessage } from '@/utils/error'
|
||||||
import { hasPermission, PERMISSIONS } from '@/utils/permission'
|
import { hasPermission, PERMISSIONS } from '@/utils/permission'
|
||||||
@@ -127,6 +242,12 @@ import { hasPermission, PERMISSIONS } from '@/utils/permission'
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const list = ref([])
|
const list = ref([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
const isMobile = ref(false)
|
||||||
|
|
||||||
|
// 检测移动端
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth <= 768
|
||||||
|
}
|
||||||
|
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
page: 1,
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.users-page {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mb16 { margin-bottom: 16px; }
|
.mb16 { margin-bottom: 16px; }
|
||||||
.mr4 { margin-right: 4px; }
|
.mr4 { margin-right: 4px; }
|
||||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
.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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user