矿机租赁系统代码更新

This commit is contained in:
2025-09-26 16:40:38 +08:00
parent d02c7dccf6
commit d1b3357a8e
79 changed files with 22401 additions and 1 deletions

View File

@@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

View File

@@ -0,0 +1,12 @@
# 页面标题
VUE_APP_TITLE = m2pool
# 开发环境配置
ENV = 'development'
#开发环境
VUE_APP_BASE_API = 'https://test.m2pool.com/api/'
# VUE_APP_BASE_API = 'http://18.183.240.108:8080/api/'
VUE_APP_BASE_URL = 'https://test.m2pool.com/'
# 路由懒加载
VUE_CLI_BABEL_TRANSPILE_MODULES = true

View File

@@ -0,0 +1,12 @@
# 页面标题
VUE_APP_TITLE = m2pool
# 生产环境配置
ENV = 'production'
# 生产环境
VUE_APP_BASE_API = 'https://m2pool.com/api/'
VUE_APP_BASE_URL = 'https://m2pool.com/'
# 路由懒加载
VUE_CLI_BABEL_TRANSPILE_MODULES = true

View File

@@ -0,0 +1,15 @@
# 页面标题
VUE_APP_TITLE = m2pool
NODE_ENV = production
# 测试环境配置
ENV = 'staging'
# 测试环境
# VUE_APP_BASE_API = 'http://18.183.240.108:8080/api/'
VUE_APP_BASE_API = 'https://test.m2pool.com/api/'
VUE_APP_BASE_URL = 'https://test.m2pool.com/'
# 路由懒加载
VUE_CLI_BABEL_TRANSPILE_MODULES = true

View File

@@ -0,0 +1,29 @@
module.exports = {
root: true,
env: {
node: true
},
'extends': [
'plugin:vue/essential',
'eslint:recommended'
],
parserOptions: {
parser: '@babel/eslint-parser'
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-redeclare': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-unused-vars': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-undef': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'vue/no-unused-components': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-mixed-spaces-and-tabs': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-unreachable': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-const-assign': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'vue/multi-word-component-names': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'vue/no-parsing-error': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-empty': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
}
}

23
power_leasing/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

292
power_leasing/README.md Normal file
View File

@@ -0,0 +1,292 @@
# Power Leasing - 电商系统
一个基于 Vue 2 + Element UI 的轻量级电商系统,包含商品展示、购物车、结算等完整功能。
## 🚀 功能特性
### 核心功能
- **商品列表页面** - 展示所有商品,支持添加到购物车
- **商品详情页面** - 商品详细信息展示,数量选择
- **购物车页面** - 商品管理、数量修改、删除
- **结算页面** - 订单摘要、收货信息填写、订单提交
### 技术特点
- 轻量级架构,不依赖 Vuex 状态管理
- 使用 localStorage 持久化购物车数据
- 响应式设计,支持移动端
- 完整的无障碍访问支持
- 错误处理和用户反馈
## 🏗️ 项目结构
```
src/
├── components/ # 公共组件
│ ├── header.vue # 顶部导航栏(含面包屑导航)
│ └── content.vue # 内容容器
├── Layout/ # 布局组件
│ └── idnex.vue # 主布局
├── views/ # 页面组件
│ ├── productList/ # 商品列表
│ │ └── index.vue
│ ├── productDetail/ # 商品详情
│ │ └── index.vue
│ ├── cart/ # 购物车
│ │ └── index.vue
│ └── checkout/ # 结算页面
│ └── index.vue
├── utils/ # 工具函数
│ ├── productService.js # 商品数据服务
│ ├── cartManager.js # 购物车管理
│ ├── navigation.js # 导航配置
│ └── routeTest.js # 路由测试工具
├── router/ # 路由配置
│ ├── index.js # 主路由文件
│ └── routes.js # 路由配置文件
├── store/ # 状态管理(轻量使用)
│ └── index.js
├── App.vue # 根组件
└── main.js # 入口文件
```
## 🔧 技术栈
- **前端框架**: Vue 2.6.14
- **UI 组件库**: Element UI 2.15.14
- **路由**: Vue Router 3.5.1
- **状态管理**: Vuex 3.6.2(轻量使用)
- **样式**: SCSS + CSS Grid/Flexbox
- **构建工具**: Vue CLI 5.0
## 🧭 路由配置
### 完整路由列表
| 路径 | 名称 | 描述 | 权限 |
|------|------|------|------|
| `/productList` | 商品列表 | 浏览所有可用商品 | all |
| `/product/:id` | 商品详情 | 查看商品详细信息 | all |
| `/cart` | 购物车 | 管理购物车商品 | all |
| `/checkout` | 订单结算 | 完成订单结算 | all |
### 路由特性
- **嵌套路由**: 所有页面都在 Layout 组件内渲染
- **动态路由**: 商品详情页支持动态 ID 参数
- **路由守卫**: 自动设置页面标题和权限检查
- **错误处理**: 404 页面自动重定向到商品列表
- **面包屑导航**: 自动生成页面导航路径
### 路由文件结构
```
src/router/
├── index.js # 主路由文件,包含路由守卫和错误处理
└── routes.js # 路由配置文件,按功能模块组织
```
## 📱 页面说明
### 1. 商品列表页面 (`/productList`)
- 网格布局展示所有商品
- 商品卡片包含图片、标题、描述、价格
- 支持点击查看详情
- 一键添加到购物车
### 2. 商品详情页面 (`/product/:id`)
- 商品图片和详细信息展示
- **自定义数量选择器**美观的加减按钮设计支持1-99数量范围
- 数量选择器特性:
- 减号按钮(-减少数量最小值为1时自动禁用
- 数量输入框:支持直接输入,实时验证范围
- 加号按钮(+增加数量最大值为99时自动禁用
- 悬停效果和焦点状态
- 响应式设计,移动端适配
- 添加到购物车功能
- 返回商品列表
### 3. 购物车页面 (`/cart`)
- 购物车商品列表
- 数量修改和删除功能
- 实时计算总价和商品数量
- 清空购物车功能
- 跳转到结算页面
### 4. 结算页面 (`/checkout`)
- 订单摘要展示
- 收货信息表单(姓名、电话、地址、备注)
- 表单验证
- 订单提交功能
## 🛠️ 安装和运行
```bash
# 安装依赖
npm install
# 开发环境运行
npm run serve
# 生产环境构建
npm run build
# 代码检查
npm run lint
```
## 🧪 路由测试
项目包含完整的路由测试工具,可以在浏览器控制台中运行:
```javascript
// 导入测试工具
import { runFullTest } from './src/utils/routeTest'
// 运行完整测试
runFullTest()
// 或者单独测试
import { testRoutes, testNavigation } from './src/utils/routeTest'
testRoutes()
testNavigation()
```
## 🔍 问题修复记录
### 页面重复渲染问题
**问题描述**: 页面出现重复渲染,显示两次相同内容
**根本原因**:
1. 路由配置存在冲突 - 两个相同路径的路由
2. Layout 组件嵌套问题 - 多层 router-view 嵌套
3. Vue 版本不匹配 - header.vue 使用了 Vue 3 语法
**解决方案**:
1. 修复路由配置,移除重复路由
2. 简化 Layout 组件结构,避免多层嵌套
3. 将 header.vue 改为 Vue 2 语法
4. 优化组件渲染逻辑
### 路由配置完善
**新增功能**:
1. 完整的路由配置文件 (`routes.js`)
2. 导航配置工具 (`navigation.js`)
3. 路由测试工具 (`routeTest.js`)
4. 面包屑导航支持
5. 路由守卫和错误处理
### 具体修复内容
- `src/router/index.js`: 清理重复路由,添加电商页面路由
- `src/router/routes.js`: 新增路由配置文件
- `src/utils/navigation.js`: 新增导航配置工具
- `src/utils/routeTest.js`: 新增路由测试工具
- `src/components/header.vue`: Vue 3 → Vue 2 语法转换,添加面包屑导航
- `src/components/content.vue`: 简化组件,移除不必要的 router-view
- 新增完整的电商页面组件
## 🎨 设计原则
- **DRY 原则**: 避免代码重复,提取公共组件和工具函数
- **KISS 原则**: 保持代码简单易懂
- **SOLID 原则**: 单一职责,开闭原则
- **YAGNI 原则**: 只实现当前需要的功能
- **无障碍访问**: 支持键盘导航和屏幕阅读器
## 🔢 数量选择器组件
### 组件特性
- **现代化设计**:圆角边框、阴影效果、悬停状态
- **交互反馈**:按钮点击动画、焦点状态高亮
- **数量验证**自动限制范围1-99输入验证
- **无障碍支持**包含aria-label属性支持键盘操作
- **响应式布局**:移动端和桌面端自适应
### 使用方法
```vue
<template>
<div class="quantity-selector">
<button
class="quantity-btn minus-btn"
@click="handleDecreaseQuantity"
:disabled="quantity <= 1"
aria-label="减少数量"
>
<span class="btn-icon"></span>
</button>
<input
type="number"
class="quantity-input"
v-model.number="quantity"
:min="1"
:max="99"
aria-label="数量输入"
/>
<button
class="quantity-btn plus-btn"
@click="handleIncreaseQuantity"
:disabled="quantity >= 99"
aria-label="增加数量"
>
<span class="btn-icon">+</span>
</button>
</div>
</template>
```
### 样式定制
支持以下CSS类名和状态
- `.quantity-selector`:主容器样式
- `.quantity-btn`:按钮基础样式
- `.minus-btn` / `.plus-btn`:减号/加号按钮
- `.quantity-input`:数量输入框
- `.btn-icon`:按钮图标样式
- 悬停状态、焦点状态、禁用状态
## 📱 响应式设计
- 使用 CSS Grid 和 Flexbox 布局
- 移动端优先的响应式设计
- 支持触摸操作和手势
- 适配不同屏幕尺寸
## 🔒 数据安全
- 购物车数据本地存储
- 表单验证和错误处理
- 用户输入过滤和清理
- 安全的订单提交流程
## 🚀 未来优化
- [ ] 用户登录注册系统
- [ ] 订单历史记录
- [ ] 商品搜索和筛选
- [ ] 支付集成
- [ ] 商品评价系统
- [ ] 库存管理
- [ ] 后台管理系统
## 📝 更新日志
### v1.2.0 - 数量选择器组件优化 (2024)
- ✨ 新增自定义数量选择器组件替换Element UI数字输入框
- 🎨 现代化UI设计圆角边框、阴影效果、悬停状态
- 🔧 功能增强支持1-99数量范围实时验证
- ♿ 无障碍优化添加aria-label属性支持键盘操作
- 📱 响应式设计:移动端和桌面端自适应
- 🐛 修复:移除重复代码,优化组件结构
- 📚 文档更新:添加组件使用说明和样式定制指南
## 📄 许可证
MIT License
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
---
**注意**: 这是一个演示项目,商品数据为静态数据,实际使用时需要连接后端 API。

View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

8468
power_leasing/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
{
"name": "power_leasing",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"test": "vue-cli-service build --mode staging --dest test",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^1.11.0",
"core-js": "^3.8.3",
"element-ui": "^2.15.14",
"vue": "^2.6.14",
"vue-router": "^3.5.1",
"vuex": "^3.6.2"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.32.7",
"sass-loader": "^12.0.0",
"vue-template-compiler": "^2.6.14"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

35
power_leasing/src/App.vue Normal file
View File

@@ -0,0 +1,35 @@
<template>
<div id="app">
<router-view/>
</div>
</template>
<style lang="scss">
body{
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
nav {
padding: 6px;
a {
font-weight: bold;
color: #2c3e50;
&.router-link-exact-active {
color: #42b983;
}
}
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<el-container style="width: 100vw; height: 100vh" class="containerApp">
<el-header class="el-header">
<comHeard />
</el-header>
<el-main class="el-main">
<appMain />
</el-main>
</el-container>
</template>
<script>
export default {
components: {
comHeard: () => import("../components/header.vue"),
appMain: () => import("../components/content.vue"),
},
};
</script>
<style lang="scss">
body,html,*{
margin: 0;
padding: 0;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,62 @@
import request from '../utils/request'
//新增机器
export function addSingleOrBatchMachine(data) {
return request({
url: `/lease/product/machine/addSingleOrBatchMachine`,
method: 'post',
data
})
}
//根据矿机id 删除商品矿机
export function deleteMachine(data) {
return request({
url: `/lease/product/machine/delete`,
method: 'post',
data
})
}
//根据挖矿账户获取矿机列表
export function getUserMachineList(data) {
return request({
url: `/lease/product/machine/getUserMachineList`,
method: 'post',
data
})
}
//根据 登录账户 获取挖矿账户及挖矿币种集合
export function getUserMinersList(data) {
return request({
url: `/lease/product/machine/getUserMinersList`,
method: 'post',
data
})
}
//编辑矿机 + 矿机上下架
export function updateMachine(data) {
return request({
url: `/lease/product/machine/updateMachine`,
method: 'post',
data
})
}
//获取矿机列表
export function getMachineListForUpdate(data) {
return request({
url: `/lease/product/machine/getMachineListForUpdate`,
method: 'post',
data
})
}

View File

@@ -0,0 +1,49 @@
import request from '../utils/request'
//创建订单及订单详情
export function addOrders(data) {
return request({
url: `/lease/order/info/addOrders`,
method: 'post',
data
})
}
//取消订单
export function cancelOrder(data) {
return request({
url: `/lease/order/info/cancelOrder`,
method: 'post',
data
})
}
//根据订单id查询订单信息
export function getOrdersByIds(data) {
return request({
url: `/lease/order/info/getOrdersByIds`,
method: 'post',
data
})
}
//查询订单列表(买家侧)
export function getOrdersByStatus(data) {
return request({
url: `/lease/order/info/getOrdersByStatus`,
method: 'post',
data
})
}
//查询订单列表(卖家侧)
export function getOrdersByStatusForSeller(data) {
return request({
url: `/lease/order/info/getOrdersByStatusForSeller`,
method: 'post',
data
})
}

View File

@@ -0,0 +1,89 @@
import request from '../utils/request'
//商品列表
export function getList(data) {
return request({
url: `/lease/product/getList`,
method: 'get',
data
})
}
//创建商品 新增商品
export function createProduct(data) {
return request({
url: `/lease/product/add`,
method: 'post',
data
})
}
//获取商品列表
export function getProductList(data) {
return request({
url: `/lease/product/getList`,
method: 'post',
data
})
}
// 更新商品
export function updateProduct(data) {
return request({
url: `/lease/product/update`,
method: 'post',
data
})
}
// 删除商品
export function deleteProduct(id) {
return request({
url: `/lease/product/delete`,
method: 'post',
data: { id }
})
}
// 查询单个商品详情
export function getMachineInfo(data) {
return request({
url: `/lease/product/getMachineInfo`,
method: 'post',
data
})
}
// 已购商品
export function getOwnedList(data) {
return request({
url: `/lease/product/getOwnedList`,
method: 'post',
data
})
}
// 已购商品详情
export function getOwnedById(data) {
return request({
url: `/lease/product/getOwnedById`,
method: 'post',
data
})
}
// 查商品详情里面的商品信息
export function getMachineInfoById(data) {
return request({
url: `/lease/product/getMachineInfoById`,
method: 'post',
data
})
}

View File

@@ -0,0 +1,35 @@
import request from '../utils/request'
//加入购物车
export function addCart(data) {
return request({
url: `/lease/shopping/cart/addGoods`,
method: 'post',
data
})
}
//查询购物车列表
export function getGoodsList(data) {
return request({
url: `/lease/shopping/cart/getGoodsList`,
method: 'post',
data
})
}
//删除购物车商品 批量
export function deleteBatchGoods(data) {
return request({
url: `/lease/shopping/cart/deleteBatchGoods`,
method: 'post',
data
})
}

View File

@@ -0,0 +1,94 @@
import request from '../utils/request'
//商品列表
export function getAddShop(data) {
return request({
url: `/lease/shop/addShop`,
method: 'post',
data
})
}
// 我的店铺(获取当前用户店铺信息)
export function getMyShop(params) {
return request({
url: `/lease/shop/getShopByUserEmail`,
method: 'get',
params
})
}
// 更新店铺
export function updateShop(data) {
return request({
url: `/lease/shop/updateShop`,
method: 'post',
data
})
}
// 删除店铺
export function deleteShop(id) {
return request({
url: `/lease/shop/deleteShop`,
method: 'post',
data: { id }
})
}
// 查询店铺信息根据ID
export function queryShop(data) {
return request({
url: `/lease/shop/getShopById`,
method: 'post',
data
})
}
// 关闭店铺
export function closeShop(id) {
return request({
url: `/lease/shop/closeShop`,
method: 'post',
data: { id }
})
}
// 根据 店铺id 查询店铺商品配置信息列表
export function getShopConfig(id) {
return request({
url: `/lease/shop/getShopConfig`,
method: 'post',
data: { id }
})
}
// 新增商铺配置
export function addShopConfig(data) {
return request({
url: `/lease/shop/addShopConfig`,
method: 'post',
data
})
}
// 根据配置id 修改配置
export function updateShopConfig(data) {
return request({
url: `/lease/shop/updateShopConfig`,
method: 'post',
data
})
}
// 根据配置id 删除配置
export function deleteShopConfig(data) {
return request({
url: `/lease/shop/deleteShopConfig`,
method: 'post',
data
})
}

View File

@@ -0,0 +1,40 @@
import request from '../utils/request'
//钱包余额
export function getWalletInfo(data) {
return request({
url: `/lease/user/getWalletInfo`,
method: 'post',
data
})
}
//余额提现
export function withdrawBalance(data) {
return request({
url: `/lease/user/withdrawBalance`,
method: 'post',
data
})
}
//余额充值记录
export function balanceRechargeList(data) {
return request({
url: `/lease/user/balanceRechargeList`,
method: 'post',
data
})
}
//提现记录
export function balanceWithdrawList(data) {
return request({
url: `/lease/user/balanceWithdrawList`,
method: 'post',
data
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,60 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br>
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router" target="_blank" rel="noopener">router</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
</ul>
<h3>Essential Links</h3>
<ul>
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
</ul>
</div>
</template>
<script>
export default {
name: 'HelloWorld',
props: {
msg: String
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<div class="content-container">
<router-view />
</div>
</template>
<script>
export default {
name: 'Content'
}
</script>
<style scoped>
.content-container {
padding: 20px;
min-height: calc(100vh - 120px);
}
</style>

View File

@@ -0,0 +1,267 @@
<template>
<div class="header-container">
<!-- 顶部导航栏 -->
<nav class="navbar">
<router-link
v-for="nav in navigation"
:key="nav.path"
:to="nav.path"
class="nav-btn"
active-class="active"
:title="nav.description"
>
<span class="nav-icon">{{ nav.icon }}</span>
<span class="nav-text">{{ nav.name }}</span>
<span v-if="nav.path === '/cart'" class="cart-count">({{ cartItemCount }})</span>
</router-link>
</nav>
<!-- 面包屑导航 -->
<!-- <div class="breadcrumb">
<router-link
v-for="(crumb, index) in breadcrumbs"
:key="index"
:to="getBreadcrumbPath(index)"
class="breadcrumb-item"
:class="{ active: index === breadcrumbs.length - 1 }"
>
{{ crumb }}
</router-link>
</div> -->
</div>
</template>
<script>
import { readCart } from '../utils/cartManager'
import { mainNavigation, getBreadcrumb } from '../utils/navigation'
import { getGoodsList } from '../api/shoppingCart'
export default {
name: 'Header',
data() {
return {
user: null,
cart: [],
// 服务端购物车数量(统计 productMachineDtoList 的总条数)
cartServerCount: 0,
navigation: mainNavigation
}
},
computed: {
cartItemCount() {
// 只使用服务端数量,避免与本地缓存数量不一致
return Number.isFinite(this.cartServerCount) ? this.cartServerCount : 0
},
breadcrumbs() {
return getBreadcrumb(this.$route.path)
}
},
watch: {},
mounted() {
this.loadCart()
// 监听购物车变化
window.addEventListener('storage', this.handleStorageChange)
// 首次加载服务端购物车数量
this.loadServerCartCount()
// 监听应用内购物车更新事件
window.addEventListener('cart-updated', this.handleCartUpdated)
},
beforeDestroy() {
window.removeEventListener('storage', this.handleStorageChange)
window.removeEventListener('cart-updated', this.handleCartUpdated)
},
methods: {
loadCart() {
this.cart = readCart()
},
async loadServerCartCount() {
try {
const res = await getGoodsList()
// 统一提取 rows/数组
const primary = Array.isArray(res && res.rows)
? res.rows
: Array.isArray(res && res.data && res.data.rows)
? res.data.rows
: Array.isArray(res && res.data)
? res.data
: (Array.isArray(res) ? res : [])
let groups = []
if (Array.isArray(primary) && primary.length) {
// 情况Ashop -> shoppingCartInfoDtoList -> productMachineDtoList
if (Array.isArray(primary[0] && primary[0].shoppingCartInfoDtoList)) {
primary.forEach(shop => {
if (Array.isArray(shop && shop.shoppingCartInfoDtoList)) {
groups.push(...shop.shoppingCartInfoDtoList)
}
})
} else {
// 情况B直接就是商品分组数组
groups = primary
}
} else if (Array.isArray(res && res.shoppingCartInfoDtoList)) {
// 情况C返回对象直接有 shoppingCartInfoDtoList
groups = res.shoppingCartInfoDtoList
}
let total = 0
if (groups.length) {
total = groups.reduce((sum, g) => sum + (Array.isArray(g && g.productMachineDtoList) ? g.productMachineDtoList.length : 0), 0)
} else if (Array.isArray(res && res.productMachineDtoList)) {
// 情况D根对象直接是机器列表
total = res.productMachineDtoList.length
}
this.cartServerCount = Number.isFinite(total) ? total : 0
} catch (e) {
// 忽略错误,保持当前显示
}
},
handleStorageChange(event) {
if (event.key === 'power_leasing_cart_v1') {
this.loadCart()
this.loadServerCartCount()
}
},
handleCartUpdated(event) {
// 支持事件携带数量 { detail: { count } }
try {
const next = event && event.detail && Number(event.detail.count)
if (Number.isFinite(next)) {
this.cartServerCount = next
return
}
} catch (e) { /* ignore malformed event detail */ }
// 无显式数量则主动刷新
this.loadServerCartCount()
},
handleLogout() {
this.user = null
this.cart = []
},
getBreadcrumbPath(index) {
const paths = ['/productList', '/cart', '/checkout']
if (index === 0) return '/productList'
if (index < paths.length) return paths[index - 1]
return '/productList'
}
}
}
</script>
<style scoped>
.header-container {
width: 100%;
}
.navbar {
display: flex;
justify-content: center;
gap: 24px;
background: #fff;
border-bottom: 1px solid #eee;
padding: 16px 0;
margin-bottom: 16px;
}
.nav-btn {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
font-size: 16px;
color: #2c3e50;
cursor: pointer;
padding: 12px 20px;
border-radius: 8px;
transition: all 0.3s ease;
text-decoration: none;
outline: none;
position: relative;
}
.nav-btn:hover {
background: #f8f9fa;
transform: translateY(-2px);
}
.nav-btn.active {
background: #42b983;
color: #fff;
}
.nav-icon {
font-size: 18px;
}
.nav-text {
font-weight: 600;
}
.cart-count {
background: #e74c3c;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
min-width: 20px;
text-align: center;
}
/* 面包屑导航样式 */
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: #f8f9fa;
border-radius: 8px;
margin: 0 20px 20px 20px;
font-size: 14px;
}
.breadcrumb-item {
color: #666;
text-decoration: none;
transition: color 0.3s ease;
}
.breadcrumb-item:hover {
color: #42b983;
}
.breadcrumb-item.active {
color: #2c3e50;
font-weight: 600;
}
.breadcrumb-item:not(:last-child)::after {
content: '>';
margin-left: 8px;
color: #ccc;
}
/* 响应式设计 */
@media (max-width: 768px) {
.navbar {
flex-direction: column;
gap: 12px;
padding: 12px 0;
}
.nav-btn {
width: 100%;
justify-content: center;
padding: 16px 20px;
}
.breadcrumb {
margin: 0 12px 16px 12px;
padding: 8px 16px;
font-size: 12px;
}
}
</style>

23
power_leasing/src/main.js Normal file
View File

@@ -0,0 +1,23 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
// 引入登录信息处理
import './utils/loginInfo.js';
// 全局输入防表情守卫(极简、无侵入)
import { initNoEmojiGuard } from './utils/noEmojiGuard.js';
// console.log = ()=>{} //全局关闭打印
Vue.config.productionTip = false
Vue.use(ElementUI);
// 初始化全局防表情拦截器
initNoEmojiGuard();
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

View File

@@ -0,0 +1,38 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import { mainRoutes } from './routes'
Vue.use(VueRouter)
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes: mainRoutes
})
// 路由守卫 - 设置页面标题和权限检查
router.beforeEach((to, from, next) => {
// 设置页面标题
if (to.meta && to.meta.title) {
document.title = `${to.meta.title} - Power Leasing`
} else {
document.title = 'Power Leasing - 电商系统'
}
// 检查权限
if (to.meta && to.meta.allAuthority) {
// 这里可以添加权限检查逻辑
// 目前所有页面都是 ['all'] 权限,所以直接通过
console.log(`访问页面: ${to.meta.title}, 权限: ${to.meta.allAuthority.join(', ')}`)
}
next()
})
// 路由错误处理
router.onError((error) => {
console.error('路由错误:', error)
// 可以在这里添加错误处理逻辑,比如跳转到错误页面
})
export default router

View File

@@ -0,0 +1,250 @@
/**
* @file 路由配置文件
* @description 定义所有电商页面的路由配置
*/
// 商品相关路由
export const productRoutes = [
{
path: '/productList',
name: 'productList',
component: () => import('../views/productList/index.vue'),
meta: {
title: '商品列表',
description: '浏览所有可用商品',
allAuthority: ['all']
}
},
{
path: '/product/:id',
name: 'productDetail',
component: () => import('../views/productDetail/index.vue'),
meta: {
title: '商品详情',
description: '查看商品详细信息',
allAuthority: ['all']
}
}
]
// 购物车相关路由
export const cartRoutes = [
{
path: '/cart',
name: 'cart',
component: () => import('../views/cart/index.vue'),
meta: {
title: '购物车',
description: '管理购物车商品',
allAuthority: ['all']
}
}
]
// 结算相关路由
export const checkoutRoutes = [
{
path: '/checkout',
name: 'checkout',
component: () => import('../views/checkout/index.vue'),
meta: {
title: '订单结算',
description: '完成订单结算',
allAuthority: ['all']
}
}
]
// 个人中心相关路由
export const accountRoutes = [
{
path: '/account',
name: 'account',
component: () => import('../views/account/index.vue'),
redirect: '/account/wallet',
meta: {
title: '个人中心',
description: '管理个人资料和店铺',
allAuthority: ['all']
},
children: [
{
path: 'wallet',
name: 'Wallet',
component: () => import('../views/account/wallet.vue'),
meta: {
title: '我的钱包',
description: '查看钱包余额、充值和提现',
allAuthority: ['all']
}
},
{//充值记录
path: 'rechargeRecord',
name: 'RechargeRecord',
component: () => import('../views/account/rechargeRecord.vue'),
meta: {
title: '充值记录',
description: '查看充值记录',
allAuthority: ['all']
}
},
{//提现记录
path: 'withdrawalHistory',
name: 'WithdrawalHistory',
component: () => import('../views/account/withdrawalHistory.vue'),
meta: {
title: '提现记录',
description: '查看提现记录',
allAuthority: ['all']
}
},
{
path: 'shop-new',
name: 'accountShopNew',
component: () => import('../views/account/shopNew.vue'),
meta: {
title: '新增店铺',
description: '创建新的店铺',
allAuthority: ['all']
}
},
{
path: 'shop-config',
name: 'accountShopConfig',
component: () => import('../views/account/shopConfig.vue'),
meta: {
title: '店铺配置',
description: '配置店铺收款和支付方式',
allAuthority: ['all']
}
},
{
path: 'shops',
name: 'accountMyShops',
component: () => import('../views/account/myShops.vue'),
meta: {
title: '我的店铺',
description: '查看我的店铺信息',
allAuthority: ['all']
}
},
{
path: 'product-new',
name: 'accountProductNew',
component: () => import('../views/account/productNew.vue'),
meta: {
title: '新增商品',
description: '创建新的商品',
allAuthority: ['all']
}
},
{
path: 'products',
name: 'accountProducts',
component: () => import('../views/account/products.vue'),
meta: {
title: '商品列表',
description: '管理店铺下的商品列表',
allAuthority: ['all']
}
},
{
path: 'purchased',
name: 'accountPurchased',
component: () => import('../views/account/purchased.vue'),
meta: {
title: '已购商品',
description: '查看已购买的商品列表',
allAuthority: ['all']
}
},
{
path: 'purchased-detail/:orderItemId',
name: 'PurchasedDetail',
component: () => import('../views/account/purchasedDetail.vue'),
meta: {
title: '已购商品详情',
description: '查看已购商品详细信息',
allAuthority: ['all']
}
},
{
path: 'orders',
name: 'accountOrders',
component: () => import('../views/account/orders.vue'),
meta: {
title: '订单列表',
description: '查看与管理订单(按状态筛选)',
allAuthority: ['all']
}
},
{
path: 'seller-orders',
name: 'accountSellerOrders',
component: () => import('../views/account/SellerOrders.vue'),
meta: {
title: '已售出订单',
description: '卖家侧订单列表',
allAuthority: ['all']
}
},
{
path: 'order-detail/:id',
name: 'accountOrderDetail',
component: () => import('../views/account/orderDetail.vue'),
meta: {
title: '订单详情',
description: '查看订单详细信息',
allAuthority: ['all']
}
},
{
path: 'product-detail/:id',
name: 'accountProductDetail',
component: () => import('../views/account/productDetail.vue'),
meta: {
title: '商品详情',
description: '个人中心 - 商品详情',
allAuthority: ['all']
}
},
{
path: 'product-machine-add',
name: 'accountProductMachineAdd',
component: () => import('../views/account/productMachineAdd.vue'),
meta: {
title: '添加出售机器',
description: '为商品添加出售机器',
allAuthority: ['all']
}
}
]
}
]
// 所有子路由
export const childrenRoutes = [
...productRoutes,
...cartRoutes,
...checkoutRoutes,
...accountRoutes
]
// 主路由配置
export const mainRoutes = [
{
path: '/',
name: 'Home',
component: () => import('../Layout/idnex.vue'),
redirect: '/productList',
children: childrenRoutes
},
// 404页面重定向到商品列表
{
path: '*',
redirect: '/productList'
}
]
export default mainRoutes

View File

@@ -0,0 +1,17 @@
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
},
getters: {
},
mutations: {
},
actions: {
},
modules: {
}
})

View File

@@ -0,0 +1,128 @@
/**
* @file 购物车管理(轻量,无 Vuex
* @description 提供添加、更新、删除、清空、查询购物车的函数。使用 localStorage 持久化。
*/
const STORAGE_KEY = 'power_leasing_cart_v1';
/**
* @typedef {Object} CartItem
* @property {string} id - 商品ID
* @property {string} title - 商品标题
* @property {number} price - 单价
* @property {number} quantity - 数量
* @property {string} image - 图片URL
*/
/**
* 读取本地购物车
* @returns {CartItem[]}
*/
export const readCart = () => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(Boolean);
} catch (error) {
console.error('[cartManager] readCart error:', error);
return [];
}
}
/**
* 持久化购物车
* @param {CartItem[]} cart
*/
const writeCart = (cart) => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cart));
// 同步派发购物车更新事件(总数量),用于头部徽标等全局更新
try {
const count = cart.reduce((s, c) => s + Number(c.quantity || 0), 0)
window.dispatchEvent(new CustomEvent('cart-updated', { detail: { count } }))
} catch (e) { /* noop */ }
} catch (error) {
console.error('[cartManager] writeCart error:', error);
}
}
/**
* 添加到购物车(若已存在则数量累加)
* @param {CartItem} item
* @returns {CartItem[]}
*/
export const addToCart = (item) => {
if (!item || !item.id) return readCart();
const cart = readCart();
const index = cart.findIndex((c) => c.id === item.id);
if (index >= 0) {
const next = [...cart];
next[index] = {
...next[index],
quantity: Math.max(1, Number(next[index].quantity || 0) + Number(item.quantity || 1))
};
writeCart(next);
return next;
}
const next = [...cart, { ...item, quantity: Math.max(1, Number(item.quantity || 1)) }];
writeCart(next);
return next;
}
/**
* 更新数量
* @param {string} productId
* @param {number} quantity
* @returns {CartItem[]}
*/
export const updateQuantity = (productId, quantity) => {
const cart = readCart();
const next = cart
.map((c) => (c.id === productId ? { ...c, quantity: Math.max(1, Number(quantity) || 1) } : c));
writeCart(next);
return next;
}
/**
* 移除商品
* @param {string} productId
* @returns {CartItem[]}
*/
export const removeFromCart = (productId) => {
const cart = readCart();
const next = cart.filter((c) => c.id !== productId);
writeCart(next);
return next;
}
/**
* 清空购物车
* @returns {CartItem[]}
*/
export const clearCart = () => {
writeCart([]);
return [];
}
/**
* 计算总价
* @returns {{ totalQuantity: number, totalPrice: number }}
*/
export const computeSummary = () => {
const cart = readCart();
const totalQuantity = cart.reduce((sum, cur) => sum + Number(cur.quantity || 0), 0);
const totalPrice = cart.reduce((sum, cur) => sum + Number(cur.quantity || 0) * Number(cur.price || 0), 0);
return { totalQuantity, totalPrice };
}
export default {
readCart,
addToCart,
updateQuantity,
removeFromCart,
clearCart,
computeSummary
}

View File

@@ -0,0 +1,95 @@
export const coinList = [
{
path: "nexaAccess",
value: "nexa",
label: "nexa",
imgUrl: `https://m2pool.com/img/nexa.png`,
name: "course.NEXAcourse",
show: true,
amount: 10000,
},
{
path: "grsAccess",
value: "grs",
label: "grs",
imgUrl: `https://m2pool.com/img/grs.svg`,
name: "course.GRScourse",
show: true,
amount: 1,
},
{
path: "monaAccess",
value: "mona",
label: "mona",
imgUrl: `https://m2pool.com/img/mona.svg`,
name: "course.MONAcourse",
show: true,
amount: 1,
},
{
path: "dgbsAccess",
value: "dgbs",
// label: "dgb-skein-pool1",
label: "dgb(skein)",
imgUrl: `https://m2pool.com/img/dgb.svg`,
name: "course.dgbsCourse",
show: true,
amount: 1,
},
{
path: "dgbqAccess",
value: "dgbq",
// label: "dgb(qubit-pool1)",
label: "dgb(qubit)",
imgUrl: `https://m2pool.com/img/dgb.svg`,
name: "course.dgbqCourse",
show: true,
amount: 1,
},
{
path: "dgboAccess",
value: "dgbo",
// label: "dgb-odocrypt-pool1",
label: "dgb(odocrypt)",
imgUrl: `https://m2pool.com/img/dgb.svg`,
name: "course.dgboCourse",
show: true,
amount: 1,
},
{
path: "rxdAccess",
value: "rxd",
label: "radiant(rxd)",
imgUrl: `https://m2pool.com/img/rxd.png`,
name: "course.RXDcourse",
show: true,
amount: 100,
},
{
path: "enxAccess",
value: "enx",
label: "Entropyx(enx)",
imgUrl: `https://m2pool.com/img/enx.svg`,
name: "course.ENXcourse",
show: true,
amount: 5000,
},
{
path: "alphminingPool",
value: "alph",
label: "alephium",
imgUrl: `https://m2pool.com/img/alph.svg`,
name: "course.alphCourse",
show: true,
amount: 1,
},
]

View File

@@ -0,0 +1,6 @@
export default {
'401': '认证失败,无法访问系统资源,请重新登录',
'403': '当前操作没有权限',
'404': '访问资源不存在',
'default': '系统未知错误,请反馈给管理员'
}

View File

@@ -0,0 +1,74 @@
/**
* 错误提示管理器
* 用于控制错误提示的频率,避免短时间内重复显示相同类型的错误
*/
class ErrorNotificationManager {
constructor() {
// 记录最近显示的错误信息
this.recentErrors = new Map();
// 默认节流时间 (30秒)
this.throttleTime = 3000;
// 错误类型映射
this.errorTypes = {
'Network Error': 'network',
'timeout': 'timeout',
'Request failed with status code': 'statusCode',
// 添加网络状态类型
'networkReconnected': 'networkStatus',
'NetworkError': 'network'
};
}
/**
* 获取错误类型
* @param {String} message 错误信息
* @returns {String} 错误类型
*/
getErrorType(message) {
for (const [key, type] of Object.entries(this.errorTypes)) {
if (message.includes(key)) {
return type;
}
}
return 'unknown';
}
/**
* 检查是否可以显示错误
* @param {String} message 错误信息
* @returns {Boolean} 是否可以显示
*/
canShowError(message) {
const errorType = this.getErrorType(message);
const now = Date.now();
// 检查同类型的错误是否最近已经显示过
if (this.recentErrors.has(errorType)) {
const lastTime = this.recentErrors.get(errorType);
if (now - lastTime < this.throttleTime) {
console.log(`[错误提示] 已抑制重复错误: ${errorType}`);
return false;
}
}
// 更新最后显示时间
this.recentErrors.set(errorType, now);
return true;
}
/**
* 清理过期的错误记录
*/
cleanup() {
const now = Date.now();
this.recentErrors.forEach((time, type) => {
if (now - time > this.throttleTime) {
this.recentErrors.delete(type);
}
});
}
}
// 创建单例实例
const errorNotificationManager = new ErrorNotificationManager();
export default errorNotificationManager;

View File

@@ -0,0 +1,68 @@
// 全局 loading 状态管理器
class LoadingManager {
constructor() {
this.loadingStates = new Map(); // 存储所有 loading 状态
this.setupListeners();
}
setupListeners() {
// 监听网络重试完成事件
window.addEventListener('network-retry-complete', () => {
this.resetAllLoadingStates();
});
}
// 设置 loading 状态
setLoading(componentId, stateKey, value) {
const key = `${componentId}:${stateKey}`;
this.loadingStates.set(key, {
value,
timestamp: Date.now()
});
}
// 获取 loading 状态
getLoading(componentId, stateKey) {
const key = `${componentId}:${stateKey}`;
const state = this.loadingStates.get(key);
return state ? state.value : false;
}
// 重置所有 loading 状态
resetAllLoadingStates() {
// 清除所有处于加载状态的组件
const componentsToUpdate = [];
this.loadingStates.forEach((state, key) => {
if (state.value === true) {
const [componentId, stateKey] = key.split(':');
componentsToUpdate.push({ componentId, stateKey });
this.loadingStates.set(key, { value: false, timestamp: Date.now() });
}
});
// 使用事件通知各组件更新
window.dispatchEvent(new CustomEvent('reset-loading-states', {
detail: { componentsToUpdate }
}));
}
// 重置特定组件的所有 loading 状态
resetComponentLoadingStates(componentId) {
const componentsToUpdate = [];
this.loadingStates.forEach((state, key) => {
if (key.startsWith(`${componentId}:`) && state.value === true) {
const stateKey = key.split(':')[1];
componentsToUpdate.push({ componentId, stateKey });
this.loadingStates.set(key, { value: false, timestamp: Date.now() });
}
});
return componentsToUpdate;
}
}
// 创建单例实例
const loadingManager = new LoadingManager();
export default loadingManager;

View File

@@ -0,0 +1,113 @@
/**
* 解密函数(与发送端保持一致)
* @param {string} encryptedText - 加密的文本
* @param {string} secretKey - 密钥
* @returns {string} 解密后的字符串
*/
function decryptData(encryptedText, secretKey) {
try {
// Base64解码
const encrypted = atob(encryptedText);
let decrypted = '';
for (let i = 0; i < encrypted.length; i++) {
decrypted += String.fromCharCode(encrypted.charCodeAt(i) ^ secretKey.charCodeAt(i % secretKey.length));
}
return decrypted;
} catch (error) {
console.error('解密失败:', error);
return null;
}
}
/**
* 获取并解密URL参数
*/
function getDecryptedParams() {
const urlParams = new URLSearchParams(window.location.search);
const encryptedData = urlParams.get('data');
const language = urlParams.get('language');
const username = urlParams.get('username');
const source = urlParams.get('source');
const version = urlParams.get('version');
// 解密敏感数据
const secretKey = 'mining-pool-secret-key-2024'; // 必须与发送端保持一致
let sensitiveData = null;
if (encryptedData) {
try {
const decryptedJson = decryptData(encryptedData, secretKey);
sensitiveData = JSON.parse(decryptedJson);
} catch (error) {
console.error('解密或解析数据失败:', error);
}
}
return {
// 敏感数据(已解密)
token: sensitiveData?.token || '',
userEmail: sensitiveData?.userEmail || '',
userId: sensitiveData?.userId || '',
timestamp: sensitiveData?.timestamp || null,
// 非敏感数据(明文)
language: language || 'zh',
username: username || '',
source: source || '',
version: version || '1.0'
};
}
/**
* 执行自动登录
*/
function performAutoLogin(token, userId, userEmail) {
console.log('执行自动登录:', { userId, userEmail: userEmail ? '***' : '' });
// 这里可以添加自动登录的逻辑
// 例如:设置全局状态、跳转页面等
}
/**
* 设置界面语言
*/
function setLanguage(language) {
console.log('设置语言:', language);
// 这里可以添加语言设置的逻辑
// 例如:设置 i18n 语言、更新界面等
}
// 使用示例
document.addEventListener('DOMContentLoaded', function() {
const params = getDecryptedParams();
if (params.token) {
console.log(params.token,"params.token 存入");
localStorage.setItem('token', params.token);
localStorage.setItem('userEmail', params.userEmail);
localStorage.setItem('userId', params.userId);
localStorage.setItem('language', params.language);
localStorage.setItem('username', params.username);
localStorage.setItem('source', params.source);
localStorage.setItem('version', params.version);
}
console.log('接收到的参数:', {
userId: params.userId ? '***' : '',
userEmail: params.userEmail ? '***' : '',
token: params.token ? '***' : '',
language: params.language,
username: params.username,
source: params.source
});
// 根据参数执行相应操作
if (params.token && params.userId) {
// 执行自动登录
performAutoLogin(params.token, params.userId, params.userEmail);
}
if (params.language) {
// 设置界面语言
setLanguage(params.language);
}
});

View File

@@ -0,0 +1,101 @@
/**
* @file 导航配置文件
* @description 定义所有可用的导航链接和菜单结构
*/
// 主导航配置
export const mainNavigation = [
{
path: '/productList',
name: '商城',
icon: '🛍️',
description: '浏览所有商品'
},
{
path: '/cart',
name: '购物车',
icon: '🛒',
description: '管理购物车商品'
},
// {
// path: '/checkout',
// name: '结算',
// icon: '💳',
// description: '完成订单结算'
// },
{
path: '/account',
name: '个人中心',
icon: '👤',
description: '管理个人资料和店铺'
}
]
// 面包屑导航配置
export const breadcrumbConfig = {
'/productList': ['首页', '商品列表'],
'/product': ['首页', '商品列表', '商品详情'],
'/cart': ['首页', '购物车'],
'/checkout': ['首页', '购物车', '订单结算'],
'/account': ['首页', '个人中心'],
'/account/wallet': ['首页', '个人中心', '我的钱包'],
'/account/shop-new': ['首页', '个人中心', '新增店铺'],
'/account/shop-config': ['首页', '个人中心', '店铺配置'],
'/account/shops': ['首页', '个人中心', '我的店铺'],
'/account/product-new': ['首页', '个人中心', '新增商品'],
'/account/products': ['首页', '个人中心', '商品列表']
}
// 获取面包屑导航
export const getBreadcrumb = (path) => {
// 处理动态路由
if (path.startsWith('/product/')) {
return breadcrumbConfig['/product']
}
return breadcrumbConfig[path] || ['首页']
}
// 检查路由权限
export const checkRoutePermission = (route, userPermissions = []) => {
if (!route.meta || !route.meta.allAuthority) {
return true
}
const requiredPermissions = route.meta.allAuthority
// 如果权限要求是 'all',则所有人都可以访问
if (requiredPermissions.includes('all')) {
return true
}
// 检查用户是否有所需权限
return requiredPermissions.some(permission =>
userPermissions.includes(permission)
)
}
// 获取页面标题
export const getPageTitle = (route) => {
if (route.meta && route.meta.title) {
return `${route.meta.title} - Power Leasing`
}
return 'Power Leasing - 电商系统'
}
// 获取页面描述
export const getPageDescription = (route) => {
if (route.meta && route.meta.description) {
return route.meta.description
}
return 'Power Leasing 电商系统 - 专业的电力设备租赁平台'
}
export default {
mainNavigation,
breadcrumbConfig,
getBreadcrumb,
checkRoutePermission,
getPageTitle,
getPageDescription
}

View File

@@ -0,0 +1,87 @@
/**
* 全局输入表情符号拦截守卫(极简,无侵入)
* 作用:拦截所有原生 input/textarea 的输入事件,移除 Emoji并重新派发 input 事件以同步 v-model
* 注意:
* - 跳过正在输入法合成阶段compositionstart ~ compositionend避免影响中文输入
* - 默认对所有可编辑 input/textarea 生效;如需个别放行,可在元素上加 data-allow-emoji="true"
*/
export const initNoEmojiGuard = () => {
if (typeof window === 'undefined') return
if (window.__noEmojiGuardInitialized) return
window.__noEmojiGuardInitialized = true
// 覆盖常见 Emoji、旗帜、杂项符号、ZWJ、变体选择符、组合键帽
const emojiPattern = /[\u{1F300}-\u{1FAFF}]|[\u{1F1E6}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}]|[\u{200D}]|[\u{20E3}]/gu
/**
* 判断是否是需要拦截的可编辑元素
* @param {EventTarget} el 事件目标
* @returns {boolean}
*/
const isEditableTarget = (el) => {
if (!el || !(el instanceof Element)) return false
if (el.getAttribute && el.getAttribute('data-allow-emoji') === 'true') return false
const tag = el.tagName
if (tag === 'INPUT') {
const type = (el.getAttribute('type') || 'text').toLowerCase()
// 排除不会产生文本的类型
const disallow = ['checkbox', 'radio', 'file', 'hidden', 'button', 'submit', 'reset', 'range', 'color', 'date', 'datetime-local', 'month', 'time', 'week']
return disallow.indexOf(type) === -1
}
if (tag === 'TEXTAREA') return true
return false
}
// 记录输入法合成状态
const setComposing = (el, composing) => {
try { el.__noEmojiComposing = composing } catch (e) {}
}
const isComposing = (el) => !!(el && el.__noEmojiComposing)
// 结束合成时做一次清洗
document.addEventListener('compositionstart', (e) => {
if (!isEditableTarget(e.target)) return
setComposing(e.target, true)
}, true)
document.addEventListener('compositionend', (e) => {
if (!isEditableTarget(e.target)) return
setComposing(e.target, false)
sanitizeAndRedispatch(e.target)
}, true)
// 主输入拦截:捕获阶段尽早处理
document.addEventListener('input', (e) => {
const target = e.target
if (!isEditableTarget(target)) return
if (isComposing(target)) return
sanitizeAndRedispatch(target)
}, true)
/**
* 清洗目标元素的值并在变更时重新派发 input 事件
* @param {HTMLInputElement|HTMLTextAreaElement} target
*/
function sanitizeAndRedispatch(target) {
const before = String(target.value ?? '')
if (!before) return
if (!emojiPattern.test(before)) return
const selectionStart = target.selectionStart
const selectionEnd = target.selectionEnd
const after = before.replace(emojiPattern, '')
if (after === before) return
target.value = after
try {
// 重置光标,尽量贴近原位置
if (typeof selectionStart === 'number' && typeof selectionEnd === 'number') {
const removed = before.length - after.length
const nextPos = Math.max(0, selectionStart - removed)
target.setSelectionRange(nextPos, nextPos)
}
} catch (e) {}
// 重新派发 input 事件以同步 v-model
const evt = new Event('input', { bubbles: true })
target.dispatchEvent(evt)
}
}

View File

@@ -0,0 +1,73 @@
/**
* @file 商品数据服务(轻量静态数据源)
* @description 提供商品列表与详情查询。无需后端即可演示。
*/
/**
* @typedef {Object} Product
* @property {string} id - 商品唯一标识
* @property {string} title - 商品标题
* @property {string} description - 商品描述
* @property {number} price - 商品单价(元)
* @property {string} image - 商品图片URL此处使用占位图
*/
/**
* 内置演示商品数据
* 使用简短且清晰的字段,满足演示所需
* @type {Product[]}
*/
const products = [
{
id: 'p1001',
title: '新能源充电桩(家用)',
description: '7kW 单相,智能预约,支持远程监控。',
price: 1299,
image: 'https://via.placeholder.com/300x200?text=%E5%85%85%E7%94%B5%E6%A1%A9'
},
{
id: 'p1002',
title: '工业电能表',
description: '三相四线远程抄表Modbus 通信。',
price: 899,
image: 'https://via.placeholder.com/300x200?text=%E7%94%B5%E8%83%BD%E8%A1%A8'
},
{
id: 'p1003',
title: '配电柜(入门版)',
description: 'IP54 防护,内置断路器与防雷模块。',
price: 5599,
image: 'https://via.placeholder.com/300x200?text=%E9%85%8D%E7%94%B5%E6%9F%9C'
},
{
id: 'p1004',
title: '工矿照明灯',
description: '120W 高亮,耐腐蚀,适配多场景。',
price: 329,
image: 'https://via.placeholder.com/300x200?text=%E7%85%A7%E6%98%8E%E7%81%AF'
}
]
/**
* 获取全部商品
* @returns {Promise<Product[]>}
*/
export const listProducts = async () => {
return Promise.resolve(products);
}
/**
* 根据ID获取商品
* @param {string} productId - 商品ID
* @returns {Promise<Product | undefined>}
*/
export const getProductById = async (productId) => {
const product = products.find((p) => p.id === productId);
return Promise.resolve(product);
}
export default {
listProducts,
getProductById
}

View File

@@ -0,0 +1,458 @@
import axios from 'axios'
import errorCode from './errorCode'
import { Notification, MessageBox, Message } from 'element-ui'
import loadingManager from './loadingManager';
import errorNotificationManager from './errorNotificationManager';
const pendingRequestMap = new Map(); //处理Request aborted 错误
function getRequestKey(config) { //处理Request aborted 错误 生成唯一 key 的函数
const { url, method, params, data } = config;
return [url, method, JSON.stringify(params), JSON.stringify(data)].join('&');
}
// 创建axios实例
const service = axios.create({
// axios中请求配置有baseURL选项表示请求URL公共部分
baseURL: process.env.VUE_APP_BASE_API,
// 超时
timeout: 10000,
})
// 网络错误相关配置
const NETWORK_ERROR_THROTTLE_TIME = 5000; // 错误提示节流时间
const RETRY_DELAY = 2000; // 重试间隔时间
const MAX_RETRY_TIMES = 3; // 最大重试次数
const RETRY_WINDOW = 60000; // 60秒重试窗口
let lastNetworkErrorTime = 0; // 上次网络错误提示时间
let pendingRequests = new Map();
// 网络状态监听器
// 网络状态最后提示时间
let lastNetworkStatusTime = {
online: 0,
offline: 0
};
// 创建一个全局标志,确保每次网络恢复只显示一次提示
let networkRecoveryInProgress = false;
// 网络状态监听器
window.addEventListener('online', () => {
const now = Date.now();
// 避免短时间内多次触发
if (networkRecoveryInProgress) {
console.log('[网络] 网络恢复处理已在进行中,忽略重复事件');
return;
}
networkRecoveryInProgress = true;
// 严格检查是否应该显示提示
if (now - lastNetworkStatusTime.online > 30000) { // 30秒内不重复提示
lastNetworkStatusTime.online = now;
try {
if (window.vm && window.vm.$message) {
// 确保消息只显示一次
window.vm.$message({
message: window.vm.$i18n.t('home.networkReconnected') || '网络已重新连接,正在恢复数据...',
type: 'success',
duration: 5000,
showClose: true,
});
console.log('[网络] 显示网络恢复提示, 时间:', new Date().toLocaleTimeString());
}
} catch (e) {
console.error('[网络] 显示网络恢复提示失败:', e);
}
} else {
console.log('[网络] 抑制重复的网络恢复提示, 间隔过短:', now - lastNetworkStatusTime.online + 'ms');
}
// 网络恢复时,重试所有待处理的请求
const pendingPromises = [];
pendingRequests.forEach(async (request, key) => {
if (now - request.timestamp <= RETRY_WINDOW) {
try {
// 获取新的响应数据
const response = await service(request.config);
pendingPromises.push(response);
// 执行请求特定的回调
if (request.callback && typeof request.callback === 'function') {
request.callback(response);
}
// 处理特定类型的请求
if (window.vm) {
// 处理图表数据请求
if (request.config.url.includes('getPoolPower') && response && response.data) {
// 触发图表更新事件
window.dispatchEvent(new CustomEvent('chart-data-updated', {
detail: { type: 'poolPower', data: response.data }
}));
}
else if (request.config.url.includes('getNetPower') && response && response.data) {
window.dispatchEvent(new CustomEvent('chart-data-updated', {
detail: { type: 'netPower', data: response.data }
}));
}
else if (request.config.url.includes('getBlockInfo') && response && response.rows) {
window.dispatchEvent(new CustomEvent('chart-data-updated', {
detail: { type: 'blockInfo', data: response.rows }
}));
}
}
pendingRequests.delete(key);
} catch (error) {
console.error('重试请求失败:', error);
pendingRequests.delete(key);
}
} else {
pendingRequests.delete(key);
}
});
// 等待所有请求完成
Promise.allSettled(pendingPromises).then(() => {
// 重置所有 loading 状态
if (loadingManager) {
loadingManager.resetAllLoadingStates();
}
// 手动重置一些关键的 loading 状态
if (window.vm) {
// 常见的加载状态
const commonLoadingProps = [
'minerChartLoading', 'reportBlockLoading', 'apiPageLoading',
'MiningLoading', 'miniLoading', 'bthLoading', 'editLoading'
];
commonLoadingProps.forEach(prop => {
if (typeof window.vm[prop] !== 'undefined') {
window.vm[prop] = false;
}
});
// 重置所有以Loading结尾的状态
Object.keys(window.vm).forEach(key => {
if (key.endsWith('Loading')) {
window.vm[key] = false;
}
});
}
// 触发网络重试完成事件
window.dispatchEvent(new CustomEvent('network-retry-complete'));
// 重置网络恢复标志
setTimeout(() => {
networkRecoveryInProgress = false;
}, 5000); // 5秒后允许再次处理网络恢复
});
});
// 使用错误提示管理器控制网络断开提示
window.addEventListener('offline', () => {
if (window.vm && window.vm.$message && errorNotificationManager.canShowError('networkOffline')) {
window.vm.$message({
message: window.vm.$i18n.t('home.networkOffline') || '网络连接已断开,系统将在恢复连接后自动重试',
type: 'error',
duration: 5000,
showClose: true,
});
}
});
service.defaults.retry = 2;// 重试次数
service.defaults.retryDelay = 2000;
service.defaults.shouldRetry = (error) => {
// 只有网络错误或超时错误才进行重试
return error.message === "Network Error" || error.message.includes("timeout");
};
localStorage.setItem('superReportError', "")
let superReportError = localStorage.getItem('superReportError')
window.addEventListener("setItem", () => {
superReportError = localStorage.getItem('superReportError')
});
// request拦截器
service.interceptors.request.use(config => {
superReportError = ""
// retryCount =0
localStorage.setItem('superReportError', "")
// 是否需要设置 token
let token
try {
token = JSON.parse(localStorage.getItem('token'))
} catch (e) {
console.log(e);
}
if (token) {
config.headers['Authorization'] = token
}
console.log(token,"if就覅飞机飞机");
if (config.method == 'get' && config.data) {
config.params = config.data
}
// get请求映射params参数
if (config.method === 'get' && config.params) {
let url = config.url + '?';
for (const propName of Object.keys(config.params)) {
const value = config.params[propName];
var part = encodeURIComponent(propName) + "=";
if (value !== null && typeof (value) !== "undefined") {
if (typeof value === 'object') {
for (const key of Object.keys(value)) {
if (value[key] !== null && typeof (value[key]) !== 'undefined') {
let params = propName + '[' + key + ']';
let subPart = encodeURIComponent(params) + '=';
url += subPart + encodeURIComponent(value[key]) + '&';
}
}
} else {
url += part + encodeURIComponent(value) + "&";
}
}
}
url = url.slice(0, -1);
config.params = {};
config.url = url;
}
// 生成请求唯一key 处理Request aborted 错误
const requestKey = getRequestKey(config);
// 如果有相同请求,先取消 处理Request aborted 错误
if (pendingRequestMap.has(requestKey)) {
const cancel = pendingRequestMap.get(requestKey);
cancel(); // 取消上一次请求
pendingRequestMap.delete(requestKey);
}
// 创建新的CancelToken 处理Request aborted 错误
config.cancelToken = new axios.CancelToken(cancel => {
pendingRequestMap.set(requestKey, cancel);
});
return config
}, error => {
Promise.reject(error)
})
// 响应拦截器
service.interceptors.response.use(res => {
// 请求完成后移除
const requestKey = getRequestKey(res.config);
pendingRequestMap.delete(requestKey);
// 未设置状态码则默认成功状态
const code = res.data.code || 200;
// 获取错误信息
const msg = errorCode[code] || res.data.msg || errorCode['default']
if (code === 421) {
localStorage.setItem('cs_disconnect_all', Date.now().toString()); //告知客服页面断开连接
localStorage.removeItem('token')
// 系统状态已过期请重新点击SUPPORT按钮进入
superReportError = localStorage.getItem('superReportError')
if (!superReportError) {
superReportError = 421
localStorage.setItem('superReportError', superReportError)
MessageBox.confirm(window.vm.$i18n.t(`user.loginExpired`), window.vm.$i18n.t(`user.overduePrompt`), {
distinguishCancelAndClose: true,
confirmButtonText: window.vm.$i18n.t(`user.login`),
cancelButtonText: window.vm.$i18n.t(`user.Home`),
// showCancelButton: false, // 隐藏取消按钮
closeOnClickModal: false, // 点击空白处不关闭对话框
showClose: false, // 隐藏关闭按钮
type: 'warning'
}
).then(() => {
window.vm.$router.push(`/${window.vm.$i18n.locale}/login`)
localStorage.removeItem('token')
}).catch(() => {
window.vm.$router.push(`/${window.vm.$i18n.locale}/`)
localStorage.removeItem('token')
});
}
return Promise.reject('登录状态已过期')
} else if (code >= 500 && !superReportError) {
superReportError = 500
localStorage.setItem('superReportError', superReportError)
Message({
dangerouslyUseHTMLString: true,
message: msg,
type: 'error',
showClose: true
})
// throw msg; // 抛出错误,中断请求链并触发后续的错误处理逻辑
// return Promise.reject(new Error(msg))
} else if (code !== 200) {
Notification.error({
title: msg
})
return Promise.reject('error')
} else {
return res.data
}
},
error => {
// 主动取消的请求,直接忽略,不提示
if (
error.code === 'ERR_CANCELED' ||
(error.message && error.message.includes('canceled')) ||
error.message?.includes('Request aborted')
) {
// 静默处理,不提示,不冒泡
return new Promise(() => {}); // 返回pending Promise阻止控制台报错
}
// 请求异常也要移除 处理Request aborted 错误
if (error.config) {
const requestKey = getRequestKey(error.config);
pendingRequestMap.delete(requestKey);
}
let { message } = error;
if (message == "Network Error" || message.includes("timeout")) {
if (!navigator.onLine) {
// 断网状态,添加到重试队列
const requestKey = JSON.stringify({
url: error.config.url,
method: error.config.method,
params: error.config.params,
data: error.config.data
});
// 根据URL确定请求类型并记录回调
let callback = null;
if (error.config.url.includes('getPoolPower')) {
callback = (data) => {
if (window.vm) {
// 清除loading状态
window.vm.minerChartLoading = false;
}
};
} else if (error.config.url.includes('getBlockInfo')) {
callback = (data) => {
if (window.vm) {
window.vm.reportBlockLoading = false;
}
};
}
if (!pendingRequests.has(requestKey)) {
pendingRequests.set(requestKey, {
config: error.config,
timestamp: Date.now(),
retryCount: 0,
callback: callback
});
console.log('请求已加入断网重连队列:', error.config.url);
}
} else {
// 网络已连接,但请求失败,尝试重试
// 确保 config 中有 __retryCount 字段
error.config.__retryCount = error.config.__retryCount || 0;
// 判断是否可以重试
if (error.config.__retryCount < service.defaults.retry && service.defaults.shouldRetry(error)) {
// 增加重试计数
error.config.__retryCount += 1;
console.log(`[请求重试] ${error.config.url} - 第 ${error.config.__retryCount} 次重试`);
// 创建新的Promise等待一段时间后重试
return new Promise(resolve => {
setTimeout(() => {
resolve(service(error.config));
}, service.defaults.retryDelay);
});
}
// 达到最大重试次数,不再重试
console.log(`[请求失败] ${error.config.url} - 已达到最大重试次数`);
}
}
if (!superReportError) {
superReportError = "error"
localStorage.setItem('superReportError', superReportError)
//使用错误提示管理器errorNotificationManager
if (errorNotificationManager.canShowError(message)) {
if (message == "Network Error") {
Message({
message: window.vm.$i18n.t(`home.NetworkError`),
type: 'error',
duration: 4 * 1000,
showClose: true
});
}
else if (message.includes("timeout")) {
Message({
message: window.vm.$i18n.t(`home.requestTimeout`),
type: 'error',
duration: 5 * 1000,
showClose: true
});
}
else if (message.includes("Request failed with status code")) {
Message({
message: "系统接口" + message.substr(message.length - 3) + "异常",
type: 'error',
duration: 5 * 1000,
showClose: true
});
} else {
Message({
message: message,
type: 'error',
duration: 5 * 1000,
showClose: true
});
}
} else {
// 避免完全不提示,可以在控制台记录被抑制的错误
console.log('[错误提示] 已抑制重复错误:', message);
}
}
return Promise.reject(error)
}
)
export default service

View File

@@ -0,0 +1,200 @@
/**
* @file 路由测试工具
* @description 用于验证所有路由配置是否正确,便于调试
*/
import { mainRoutes } from '../router/routes'
/**
* 测试路由配置
*/
export const testRoutes = () => {
console.log('🔍 开始测试路由配置...')
const results = {
total: 0,
valid: 0,
invalid: 0,
errors: []
}
// 测试主路由
mainRoutes.forEach((route, index) => {
results.total++
console.log(`\n📋 测试路由 ${index + 1}: ${route.name || '未命名'}`)
try {
// 检查路径
if (!route.path) {
throw new Error('路由缺少 path 属性')
}
console.log(`✅ 路径: ${route.path}`)
// 检查组件
if (route.component) {
console.log(`✅ 组件: ${typeof route.component}`)
} else if (route.redirect) {
console.log(`✅ 重定向: ${route.redirect}`)
} else {
throw new Error('路由缺少 component 或 redirect 属性')
}
// 检查子路由
if (route.children && route.children.length > 0) {
console.log(`✅ 子路由数量: ${route.children.length}`)
route.children.forEach((child, childIndex) => {
console.log(` 📱 子路由 ${childIndex + 1}: ${child.name || '未命名'}`)
console.log(` 路径: ${child.path}`)
console.log(` 组件: ${typeof child.component}`)
if (child.meta) {
console.log(` 标题: ${child.meta.title || '无标题'}`)
console.log(` 权限: ${child.meta.allAuthority?.join(', ') || '无权限要求'}`)
}
})
}
results.valid++
} catch (error) {
results.invalid++
results.errors.push({
route: route.name || route.path,
error: error.message
})
console.error(`❌ 错误: ${error.message}`)
}
})
// 输出测试结果
console.log('\n📊 路由测试结果:')
console.log(`总路由数: ${results.total}`)
console.log(`有效路由: ${results.valid}`)
console.log(`无效路由: ${results.invalid}`)
if (results.errors.length > 0) {
console.log('\n❌ 发现的问题:')
results.errors.forEach((error, index) => {
console.log(`${index + 1}. ${error.route}: ${error.error}`)
})
} else {
console.log('\n🎉 所有路由配置正确!')
}
return results
}
/**
* 测试路由导航
*/
export const testNavigation = () => {
console.log('\n🧭 测试导航配置...')
const testPaths = [
'/productList',
'/product/p1001',
'/cart',
'/checkout'
]
testPaths.forEach(path => {
console.log(`\n🔗 测试路径: ${path}`)
// 查找匹配的路由
const matchedRoute = findRouteByPath(path, mainRoutes)
if (matchedRoute) {
console.log(`✅ 找到路由: ${matchedRoute.name || '未命名'}`)
if (matchedRoute.meta) {
console.log(` 标题: ${matchedRoute.meta.title}`)
console.log(` 描述: ${matchedRoute.meta.description}`)
}
} else {
console.log(`❌ 未找到匹配的路由`)
}
})
}
/**
* 根据路径查找路由
*/
const findRouteByPath = (path, routes) => {
for (const route of routes) {
// 检查当前路由
if (route.path === path) {
return route
}
// 检查子路由
if (route.children) {
for (const child of route.children) {
// 处理动态路由
if (child.path.includes(':')) {
const pattern = new RegExp('^' + child.path.replace(/:[^/]+/g, '[^/]+') + '$')
if (pattern.test(path)) {
return child
}
} else if (child.path === path) {
return child
}
}
}
}
return null
}
/**
* 获取所有可用路径
*/
export const getAllPaths = () => {
const paths = []
const extractPaths = (routes) => {
routes.forEach(route => {
if (route.path && !route.redirect) {
paths.push(route.path)
}
if (route.children) {
extractPaths(route.children)
}
})
}
extractPaths(mainRoutes)
return paths
}
/**
* 运行完整测试
*/
export const runFullTest = () => {
console.log('🚀 开始运行完整路由测试...\n')
// 测试路由配置
const routeResults = testRoutes()
// 测试导航
testNavigation()
// 获取所有路径
const allPaths = getAllPaths()
console.log('\n📋 所有可用路径:')
allPaths.forEach((path, index) => {
console.log(`${index + 1}. ${path}`)
})
console.log('\n✨ 路由测试完成!')
return {
routeResults,
allPaths
}
}
export default {
testRoutes,
testNavigation,
getAllPaths,
runFullTest
}

View File

@@ -0,0 +1,5 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>

View File

@@ -0,0 +1,18 @@
<template>
<div class="home">
<img alt="Vue logo" src="../assets/logo.png">
<HelloWorld msg="Welcome to Your Vue.js App"/>
</div>
</template>
<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'
export default {
name: 'HomeView',
components: {
HelloWorld
}
}
</script>

View File

@@ -0,0 +1,223 @@
<template>
<div v-loading="payLoading">
<div v-if="!safeItems.length" class="empty">{{ emptyText }}</div>
<el-table v-else :data="safeItems" border :header-cell-style="{ textAlign: 'left' }" :cell-style="{ textAlign: 'left' }">
<el-table-column type="expand" width="46">
<template #default="outer">
<el-table :data="outer.row.orderItemDtoList || []" size="small" border :header-cell-style="{ textAlign: 'left' }" :cell-style="{ textAlign: 'left' }" row-key="productMachineId">
<el-table-column prop="productMachineId" label="机器ID" min-width="120" />
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="payCoin" label="币种" min-width="100" />
<el-table-column prop="address" label="收款地址" min-width="240" />
<el-table-column prop="leaseTime" label="租赁天数" min-width="100" />
<el-table-column prop="price" label="单价(USDT)" min-width="240" />
</el-table>
</template>
</el-table-column>
<el-table-column label="订单号" min-width="220">
<template #default="scope"><span class="value mono">{{ scope.row && scope.row.orderNumber || '—' }}</span></template>
</el-table-column>
<el-table-column label="创建时间" min-width="180">
<template #default="scope">{{ formatDateTime(scope.row && scope.row.createTime) }}</template>
</el-table-column>
<el-table-column label="商品数" min-width="100">
<template #default="scope">{{ Array.isArray(scope.row && scope.row.orderItemDtoList) ? scope.row.orderItemDtoList.length : 0 }}</template>
</el-table-column>
<el-table-column label="总金额(USDT)" min-width="140">
<template #default="scope"><span class="value strong">{{ (scope.row && scope.row.totalPrice) != null ? scope.row.totalPrice : '—' }}</span></template>
</el-table-column>
<el-table-column label="已支付金额(USDT)" min-width="140">
<template #default="scope"><span class="value strong">{{ (scope.row && scope.row.payAmount) != null ? scope.row.payAmount : '—' }}</span></template>
</el-table-column>
<el-table-column label="待支付金额(USDT)" min-width="140">
<template #default="scope"><span class="value strong">{{ (scope.row && scope.row.noPayAmount) != null ? scope.row.noPayAmount : '—' }}</span></template>
</el-table-column>
<el-table-column label="操作" min-width="280" fixed="right">
<template #default="scope">
<el-button size="mini" @click="handleGoDetail(scope.row)" style="margin-right:8px;">详情</el-button>
<template v-if="shouldShowActions(scope.row)">
<el-button type="primary" size="mini" @click="handleCheckout(scope.row)">去结算</el-button>
<!-- <el-button type="danger" size="mini" style="margin-left:8px;" @click="handleCancel(scope.row)">取消订单</el-button> -->
</template>
</template>
</el-table-column>
</el-table>
<!-- 修改功能之前 跟购物车弹窗一样
<el-dialog :visible.sync="orderDialog.visible" width="520px" title="请扫码支付">
<div style="text-align:left; margin-bottom:12px; color:#666;">
<div style="margin-bottom:6px;">支付币种<b>{{ orderDialog.coin }}</b></div>
<div style="margin-bottom:6px;">支付金额(USDT)<b class="value strong">{{ orderDialog.amount }}</b></div>
<div style="word-break:break-all;">收款地址<code>{{ orderDialog.address }}</code></div>
</div>
<div style="text-align:left;">
<img v-if="orderDialog.qrContent" :src="orderDialog.qrContent" alt="支付二维码" style="width:240px;height:240px;" />
<div v-else style="color:#666;">未返回支付二维码</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="orderDialog.visible=false">关闭</el-button>
</span>
</el-dialog> -->
<el-dialog :visible.sync="dialogVisible" width="520px" title="请扫码支付">
<div style="text-align:left; margin-bottom:12px; color:#666;">
<div style="margin-bottom:6px;">总金额(USDT)<b>{{ paymentDialog.totalPrice }}</b></div>
<div style="margin-bottom:6px;">已支付金额(USDT)<b class="value strong">{{ paymentDialog.payAmount }}</b></div>
<div style="margin-bottom:6px;">待支付金额(USDT)<b class="value strong">{{ paymentDialog.noPayAmount }}</b></div>
<!-- <div style="word-break:break-all;">收款地址<code>{{ orderDialog.address }}</code></div> -->
</div>
<div style="text-align:center;">
<img v-if="paymentDialog.img" :src="paymentDialog.img" alt="支付二维码" style="width:180px;height:180px;margin-top: 18px;" />
<div v-else style="color:#666;">未返回支付二维码</div>
</div>
<p style="margin-bottom:6px;color:red;text-align:left">注意如果已经支付对应金额不要在重复支付待系统确认后会自动更新订单状态因个人原因重复支付导致无法退款平台不承担任何责任</p>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible=false">关闭</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { addOrders } from '../../api/order'
export default {
name: 'OrderList',
props: {
items: { type: Array, default: () => [] },
emptyText: { type: String, default: '暂无数据' },
showCheckout: { type: Boolean, default: false },
onCancel: { type: Function, default: null }
},
data() {
return {
payLoading: false,
orderDialog: { visible: false, qrContent: '', coin: '', amount: '', address: '' },
dialogVisible: false,
paymentDialog: { totalPrice: "", payAmount: '', noPayAmount: '', img: '', }
}
},
computed: {
safeItems() {
return Array.isArray(this.items) ? this.items : []
}
},
methods: {
buildQrSrc(img) {
if (!img) return ''
try { const s = String(img).trim(); return s.startsWith('data:') ? s : `data:image/png;base64,${s}` } catch (e) { return '' }
},
formatDateTime(value) {
if (!value) return '—'
try { const str = String(value); return str.includes('T') ? str.replace('T', ' ') : str } catch (e) { return String(value) }
},
async handleCheckout(row) {
if (!row) return
try {
this.payLoading = true
this.paymentDialog={
totalPrice: row.totalPrice,
payAmount: row.payAmount,
noPayAmount: row.noPayAmount,
img: row.img
}
if (this.paymentDialog.img) {
this.paymentDialog.img = this.buildQrSrc(this.paymentDialog.img)
this.dialogVisible=true
}else{
this.$message({
message: '未返回支付二维码',
type: 'error',
showClose: true
});
}
// this.dialogVisible=true
// const list = Array.isArray(row.orderItemDtoList) ? row.orderItemDtoList : []
// const payload = list.map(m => {
// const base = {
// leaseTime: Number(m.leaseTime || 1),
// machineId: m.productMachineId != null ? m.productMachineId : (m.machineId != null ? m.machineId : m.id),
// productId: m.productId,
// shopId: m.shopId
// }
// // 尝试从子项或订单行上获取店铺ID并附加
// const sid = m.shopId != null ? m.shopId : (row && (row.shopId != null ? row.shopId : row.shopID))
// return sid != null ? { ...base, shopId: sid } : base
// }).filter(p => p.machineId != null && p.productId != null)
// if (!payload.length) { this.$message.warning('该订单没有可支付的机器项'); return }
// const res = await addOrders(payload)
// if (!res || Number(res.code) !== 200) {
// // 全局拦截器已弹错误,这里只记录并中断,避免重复弹窗
// console.warn('创建支付订单失败:', res)
// return
// }
// const first = Array.isArray(res.data) && res.data.length ? res.data[0] : null
// if (!first || !first.img) { this.$message.error('未返回支付二维码'); return }
// this.orderDialog.coin = first.payCoin || ''
// this.orderDialog.amount = first.amount != null ? String(first.amount) : ''
// this.orderDialog.address = first.payAddress || ''
// this.orderDialog.qrContent = this.buildQrSrc(first.img)
// this.orderDialog.visible = true
} catch (e) {
console.log(e,'创建支付订单失败');
} finally { this.payLoading = false }
},
handleGoDetail(row) {
const id = row && (row.id != null ? row.id : row.orderId)
if (id == null) {
this.$message({
message: '订单ID缺失',
type: 'error',
showClose: true
});
return
}
try { this.$router.push(`/account/order-detail/${id}`) } catch (e) {
this.$message({
message: '无法跳转到详情页',
type: 'error',
showClose: true
})
}
},
handleCancel(row) {
if (!row || !this.onCancel) return
const id = row.id
if (id == null) {
this.$message({
message: '订单ID缺失',
type: 'error',
showClose: true
});
return
}
this.$confirm('确认取消该订单吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(() => { try { this.onCancel({ orderId: id }) } catch (e) { void 0 } })
.catch(() => { return null })
},
shouldShowActions(row) {
console.log(row,'飞机飞机覅附件s');
if (!this.showCheckout) return false
const s = Number(row && row.status)
console.log(s,'飞机飞机覅附件s');
// 支持在 待支付(0) 与 支付中(6) 部分已支付(10) 三个状态显示按钮
return s === 0 || s === 6 || s === 10
}
}
}
</script>
<style scoped>
.empty { color: #888; padding: 24px; text-align: center; }
.value.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; word-break: break-all; }
.value.strong { font-weight: 700; color: #e74c3c; }
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="orders-page">
<h2 class="title">已售出订单</h2>
<el-tabs v-model="active" @tab-click="handleTabClick">
<el-tab-pane label="订单进行中" name="7">
<order-list :items="orders[7]" :show-checkout="false" empty-text="暂无进行中的订单" />
</el-tab-pane>
<el-tab-pane label="订单已完成" name="8">
<order-list :items="orders[8]" :show-checkout="false" empty-text="暂无已完成的订单" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script>
/**
* 卖家侧订单列表页(个人中心)
* - 与买家 orders.vue 结构一致,接口改为 getOrdersByStatusForSeller
* - 标签状态7 订单进行中8 订单已完成
*/
import { getOrdersByStatusForSeller } from '../../api/order'
import OrderList from './OrderList.vue'
export default {
name: 'AccountSellerOrders',
components: { OrderList },
data() {
return {
active: '7',
orders: { 7: [], 8: [] },
loading: false
}
},
created() {
const urlStatus = this.$route && this.$route.query && this.$route.query.status ? String(this.$route.query.status) : null
const savedStatus = localStorage.getItem('sellerOrderListActiveTab')
const initial = urlStatus || savedStatus || '7'
this.active = initial
this.fetchOrders(initial)
},
methods: {
handleTabClick(tab) {
const name = tab && tab.name ? String(tab.name) : this.active
try {
localStorage.setItem('sellerOrderListActiveTab', name)
} catch (e) {}
this.fetchOrders(name)
},
async fetchOrders(status) {
const key = String(status)
try {
this.loading = true
const res = await getOrdersByStatusForSeller({ status: Number(status) })
const payload = (res && res.data) != null ? res.data : res
const list = Array.isArray(payload)
? payload
: (Array.isArray(payload && payload.rows) ? payload.rows : [])
this.$set(this.orders, key, list)
} catch (e) {
console.error('获取卖家订单失败', e)
} finally {
this.loading = false
}
}
}
}
</script>
<style scoped>
.orders-page { padding: 12px; }
.title { margin: 0 0 12px 0; font-weight: 600; color: #2c3e50; }
.empty { color: #888; padding: 24px; text-align: center; }
.order-list { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.order-card { border: 1px solid #eee; border-radius: 8px; padding: 0; background: #fff; overflow: hidden; }
.order-header { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; padding: 12px; cursor: pointer; position: relative; }
.order-header:focus { outline: 2px solid #409eff; outline-offset: -2px; }
.order-header.is-open { background: #fafafa; }
.header-row { display: flex; gap: 8px; line-height: 1.8; align-items: center; }
.chevron { position: absolute; right: 12px; top: 12px; width: 10px; height: 10px; border-right: 2px solid #666; border-bottom: 2px solid #666; transform: rotate(-45deg); transition: transform 0.2s ease; }
.chevron.chevron-open { transform: rotate(45deg); }
.order-details { border-top: 1px solid #eee; padding: 12px; }
.machine-list { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.machine-card { border: 1px dashed #e2e2e2; border-radius: 6px; padding: 10px; background: #fff; }
.row { display: flex; gap: 8px; line-height: 1.8; }
.label { color: #666; }
.value { color: #333; }
.value.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; word-break: break-all; }
.value.strong { font-weight: 700; color: #e74c3c; }
@media (max-width: 960px) {
.order-list { grid-template-columns: 1fr; }
.order-header { grid-template-columns: 1fr; }
.machine-list { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,220 @@
<template>
<div class="account-page">
<!-- <div class="account-header">
<h1 class="title">个人中心</h1>
</div> -->
<div class="account-layout">
<!-- 左侧导航 -->
<aside class="sidebar">
<nav class="side-nav">
<div class="user-info-card" role="region" v-show="userEmail" aria-label="用户信息" tabindex="0">
<div class="user-meta">
<div class="user-email" :title="userEmail || '未登录'">{{ userEmail || '未登录' }}</div>
</div>
</div>
<div class="user-role">
<button>买家相关</button>
<button>卖家相关</button>
</div>
<router-link
to="/account/wallet"
class="side-link"
active-class="active"
>我的钱包</router-link
>
<!-- <router-link
to="/account/shop-new"
class="side-link"
active-class="active"
>新增店铺</router-link
> -->
<router-link
to="/account/shop-config"
class="side-link"
active-class="active"
>钱包绑定</router-link
>
<router-link
to="/account/shops"
class="side-link"
active-class="active"
>我的店铺</router-link
>
<router-link
to="/account/products"
class="side-link"
active-class="active"
>商品列表</router-link
>
<router-link
to="/account/purchased"
class="side-link"
active-class="active"
>已购商品</router-link
>
<router-link
to="/account/seller-orders"
class="side-link"
active-class="active"
>已售出订单</router-link>
<router-link
to="/account/orders"
class="side-link"
active-class="active"
>已购买订单列表</router-link>
<router-link
to="/account/rechargeRecord"
class="side-link"
active-class="active"
>充值记录</router-link>
<router-link
to="/account/withdrawalHistory"
class="side-link"
active-class="active"
>提现记录</router-link>
</nav>
</aside>
<!-- 右侧内容 -->
<section class="content">
<router-view />
</section>
</div>
</div>
</template>
<script>
export default {
name: "AccountPage",
data() {
return {
activeIndex: '1',
userEmail: '',
}
},
computed: {
/**
* 计算用户邮箱首字母(用于头像)
* @returns {string}
*/
userInitial() {
const email = (this.userEmail || '').trim()
return email ? email[0].toUpperCase() : '?'
},
},
mounted() {
const getVal = (key) => {
const raw = localStorage.getItem(key)
if (raw == null) return null
try { return JSON.parse(raw) } catch (e) { return raw }
}
const val = getVal('userName') || getVal('userEmail') || ''
this.userEmail = typeof val === 'string' ? val : String(val)
},
};
</script>
<style scoped>
.account-page {
padding: 20px;
}
.account-header {
background: #fff;
/* border: 1px solid #eee; */
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
text-align: left;
padding-left: 3vw;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #2c3e50;
}
.account-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 16px;
}
.sidebar {
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
padding: 12px;
min-height: 80vh;
}
.side-nav {
display: flex;
flex-direction: column;
gap: 8px;
}
/* 用户信息卡片:置于导航最前,展示邮箱与首字母头像 */
.user-info-card {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
gap: 10px;
padding: 12px;
background: #f8fafc;
border: 1px solid #eee;
border-radius: 8px;
margin-bottom: 4px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #42b983, #67c23a);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
}
.user-email {
font-size: 14px;
color: #2c3e50;
font-weight: 600;
}
.side-link {
display: block;
padding: 10px 12px;
color: #2c3e50;
text-decoration: none;
border-radius: 6px;
transition: background 0.2s;
}
.side-link:hover {
background: #f6f8fa;
}
.side-link.active {
background: #42b983;
color: #fff;
}
.content {
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
padding: 16px;
min-height: 420px;
}
@media (max-width: 768px) {
.account-layout {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,517 @@
<template>
<div class="panel" >
<h2 class="panel-title">我的店铺</h2>
<div class="panel-body">
<el-card v-if="loaded && hasShop" class="shop-card" shadow="hover">
<div class="shop-row">
<div class="shop-cover">
<img :src="shop.image || defaultCover" alt="店铺封面" />
</div>
<div class="shop-info">
<div class="shop-title">
<span class="name">{{ shop.name || '未命名店铺' }}</span>
<el-tag size="small" :type="shopStateTagType">
{{ shopStateText }}
</el-tag>
</div>
<div class="desc">{{ shop.description || '这家店还没有描述~' }}</div>
<!-- <div class="meta">
<span>店铺ID{{ shop.id || '-' }}</span>
<span>可删除{{ shop.del ? '是' : '否' }}</span>
</div> -->
<div class="actions">
<el-button size="small" type="primary" @click="handleOpenEdit">修改店铺</el-button>
<el-button size="small" type="warning" @click="handleToggleShop">
{{ shop.state === 2 ? '开启店铺' : '关闭店铺' }}
</el-button>
<el-button size="small" type="danger" @click="handleDelete">删除店铺</el-button>
<el-button size="small" type="success" @click="handleAddProduct">新增商品</el-button>
</div>
</div>
</div>
</el-card>
<!-- 店铺配置表格 -->
<!-- <el-card v-if="loaded && hasShop" class="shop-config-card" shadow="never" style="margin-top: 16px;">
<div slot="header" class="clearfix">
<span>店铺配置</span>
</div>
<el-table :data="shopConfigs" border style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="商品" width="120">
<template slot-scope="scope">{{ scope.row.productId === 0 ? '所有商品' : scope.row.productId }}</template>
</el-table-column>
<el-table-column prop="payType" label="币种类型" width="120">
<template slot-scope="scope">{{ scope.row.payType === 1 ? '稳定币' : '虚拟币' }}</template>
</el-table-column>
<el-table-column prop="payCoin" label="支付币种" width="140" />
<el-table-column prop="payAddress" label="收款钱包地址" />
<el-table-column label="操作" width="180" fixed="right">
<template slot-scope="scope">
<el-button type="text" @click="handleEditConfig(scope.row)">修改</el-button>
<el-divider direction="vertical"></el-divider>
<el-button type="text" style="color:#e74c3c" @click="handleDeleteConfig(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card> -->
<div v-else-if="loaded && !hasShop" class="no-shop">
<el-empty description="暂无店铺">
<el-button type="primary" @click="handleGoNew">新建店铺</el-button>
</el-empty>
</div>
<el-empty v-else description="正在加载店铺信息..." />
<!-- 修改店铺弹窗 -->
<el-dialog title="修改店铺" :visible.sync="visibleEdit" width="520px">
<div class="row">
<label class="label">店铺名称</label>
<el-input v-model="editForm.name" placeholder="请输入店铺名称" :maxlength="30" show-word-limit />
</div>
<!-- <div class="row">
<label class="label">店铺封面</label>
<el-input v-model="editForm.image" placeholder="请输入图片地址" />
</div> -->
<div class="row">
<label class="label">店铺描述</label>
<el-input type="textarea" :rows="3" v-model="editForm.description" placeholder="请输入描述" :maxlength="300" show-word-limit />
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="visibleEdit=false">取消</el-button>
<el-button type="primary" @click="submitEdit">保存</el-button>
</span>
</el-dialog>
<!-- 修改店铺配置弹窗 -->
<el-dialog title="修改配置" :visible.sync="visibleConfigEdit" width="560px">
<div class="row">
<label class="label">适用商品</label>
<el-select v-model="configForm.productId" placeholder="请选择商品">
<el-option :value="0" label="所有商品" />
<el-option v-for="p in productOptions" :key="p.id" :value="p.id" :label="`${p.id} - ${p.name}`" />
</el-select>
</div>
<div class="row">
<label class="label">收款地址</label>
<el-input v-model="configForm.payAddress" placeholder="请输入钱包地址" />
</div>
<div class="row">
<label class="label">币种类型</label>
<el-radio-group v-model="configForm.payType">
<el-radio :label="0">虚拟币</el-radio>
<el-radio :label="1">稳定币</el-radio>
</el-radio-group>
</div>
<div class="row">
<label class="label">支付币种</label>
<el-select
class="input"
size="middle"
ref="screen"
v-model="configForm.payCoin"
placeholder="请选择"
>
<el-option
v-for="item in coinOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>
<div style="display: flex; align-items: center">
<img :src="item.imgUrl" style="float: left; width: 20px" />
<span style="float: left; margin-left: 5px">
{{ item.label }}</span
>
</div>
</el-option>
</el-select>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="visibleConfigEdit=false">取消</el-button>
<el-button type="primary" @click="submitConfigEdit">保存</el-button>
</span>
</el-dialog>
</div>
</div>
</template>
<script>
import { getMyShop, updateShop, deleteShop, queryShop, closeShop, getShopConfig ,updateShopConfig,deleteShopConfig} from '@/api/shops'
import { coinList } from '@/utils/coinList'
export default {
name: 'AccountMyShops',
data() {
return {
loaded: false,
defaultCover: 'https://dummyimage.com/120x120/eee/999.png&text=Shop',
shop: {
id: 0,
name: '',
image: '',
description: '',
del: true,
state: 0
},
visibleEdit: false,
editForm: { id: '', name: '', image: '', description: '' },
// 店铺配置列表
shopConfigs: [],
visibleConfigEdit: false,
configForm: { id: '', payAddress: '', payCoin: '', payType: 0, productId: 0 },
productOptions: [],
coinOptions: coinList || [],
shopLoading: false
}
},
computed: {
shopStateText() {
// 0 待审核 1 审核通过(店铺开启) 2 店铺关闭
if (this.shop.state === 0) return '待审核'
if (this.shop.state === 1) return '店铺开启'
if (this.shop.state === 2) return '店铺关闭'
return '未知状态'
},
shopStateTagType() {
// 标签配色:待审核=warning开启=success关闭=info
if (this.shop.state === 0) return 'warning'
if (this.shop.state === 1) return 'success'
if (this.shop.state === 2) return 'info'
return 'info'
},
hasShop() {
return !!(this.shop && Number(this.shop.id) > 0)
},
canCreateShop() {
return !this.hasShop
}
},
created() {
this.fetchMyShop()
},
methods: {
// 简单的emoji检测覆盖常见表情平面与符号范围
hasEmoji(str) {
if (!str || typeof str !== 'string') return false
const emojiRegex = /[\u{1F300}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA70}-\u{1FAFF}\u2600-\u27BF]/u
return emojiRegex.test(str)
},
/**
* 重置店铺状态为无店铺状态
*/
resetShopState() {
this.shop = {
id: 0,
name: '',
image: '',
description: '',
del: true,
state: 0
}
this.shopConfigs = []
},
async fetchMyShop() {
try {
const res = await getMyShop()
// 预期格式:{"code":0,"data":{"del":true,"description":"","id":0,"image":"","name":"","state":0},"msg":""}
if (res && (res.code === 0 || res.code === 200) && res.data) {
this.shop = {
id: res.data.id,
name: res.data.name,
image: res.data.image,
description: res.data.description,
del: !!res.data.del,
state: Number(res.data.state || 0)
}
// 同步加载店铺配置
this.fetchShopConfigs(res.data.id)
} else {
// 当接口返回错误或没有数据时,重置店铺状态
this.resetShopState()
if (res && res.msg) {
console.warn('获取店铺数据失败:', res.msg)
}
}
} catch (error) {
console.error('获取店铺信息失败:', error)
// 当接口报错如500错误重置店铺状态
this.resetShopState()
} finally {
this.loaded = true
}
},
async fetchShopConfigs(shopId) {
// 如果店铺ID无效直接清空配置
if (!shopId || shopId <= 0) {
this.shopConfigs = []
return
}
try {
const res = await getShopConfig(shopId)
if (res && (res.code === 0 || res.code === 200) && Array.isArray(res.data)) {
this.shopConfigs = res.data
} else {
this.shopConfigs = []
}
} catch (e) {
console.warn('获取店铺配置失败:', e)
this.shopConfigs = []
}
},
async updateShopConfig(params) {
const res = await updateShopConfig(params)
if (res && (res.code === 0 || res.code === 200)) {
this.$message.success('保存成功')
this.visibleConfigEdit = false
this.fetchShopConfigs(this.shop.id)
} else {
this.$message.error(res && res.msg ? res.msg : '保存失败')
}
},
async deleteShopConfig(params) {
const res = await deleteShopConfig(params)
if (res && (res.code === 0 || res.code === 200)) {
this.$message.success('删除成功')
this.fetchShopConfigs(this.shop.id)
}
},
handleEditConfig(row) {
this.configForm = { ...row }
this.visibleConfigEdit = true
},
async handleDeleteConfig(row) {
this.deleteShopConfig({id:row.id})
},
submitConfigEdit() {
this.updateShopConfig(this.configForm)
},
async handleOpenEdit() {
try {
// 先打开弹窗,提供更快的视觉反馈
this.visibleEdit = true
// 查询最新店铺详情
const res = await queryShop({ id: this.shop.id })
if (res && (res.code === 0 || res.code === 200) && res.data) {
this.editForm = {
id: res.data.id,
name: res.data.name,
image: res.data.image,
description: res.data.description
}
} else {
// 回退到当前展示的数据
this.editForm = {
id: this.shop.id,
name: this.shop.name,
image: this.shop.image,
description: this.shop.description
}
this.$message.warning(res && res.msg ? res.msg : '未获取到店铺详情')
}
} catch (error) {
// 出错时回退到当前展示的数据
this.editForm = {
id: this.shop.id,
name: this.shop.name,
image: this.shop.image,
description: this.shop.description
}
console.error('查询店铺详情失败:', error)
}
},
/**
* 提交店铺修改
* 规则:允许输入空格,但不允许内容为“全是空格”
*/
async submitEdit() {
try {
const { name, image, description } = this.editForm
const isOnlySpaces = (v) => typeof v === 'string' && v.length > 0 && v.trim().length === 0
if (isOnlySpaces(name)) {
this.$message.error('店铺名称不能全是空格')
return
}
if (!name) {
this.$message.error('店铺名称不能为空')
return
}
if (this.hasEmoji(name)) {
this.$message.warning('店铺名称不能包含表情符号')
return
}
if (isOnlySpaces(image)) {
this.$message.error('店铺封面不能全是空格')
return
}
if (isOnlySpaces(description)) {
this.$message.error('店铺描述不能全是空格')
return
}
// 长度限制名称≤30描述≤300
if (name && name.length > 30) {
this.$message.warning('店铺名称不能超过30个字符')
return
}
if (description && description.length > 300) {
this.$message.warning('店铺描述不能超过300个字符')
return
}
const payload = { ...this.editForm }
const res = await updateShop(payload)
if (res && (res.code === 0 || res.code === 200)) {
this.$message({
message: '已保存',
type: 'success',
showClose: true
})
this.visibleEdit = false
this.fetchMyShop()
} else {
this.$message({
message: res.msg || '保存失败',
type: 'error',
showClose: true
})
}
} catch (error) {
console.error('更新店铺失败:', error)
console.log('更新店铺失败,请稍后重试')
}
},
async handleDelete() {
try {
await this.$confirm('确定删除该店铺吗?此操作不可恢复', '提示', { type: 'warning' })
const res = await deleteShop(this.shop.id)
if (res && (res.code === 0 || res.code === 200)) {
this.$message({
message: '删除成功',
type: 'success',
showClose: true
})
// 删除成功后,先重置店铺状态,然后尝试重新获取
this.resetShopState()
this.loaded = false
// 延迟一下再尝试获取,给服务器时间处理删除操作
setTimeout(() => {
this.fetchMyShop()
}, 500)
}
} catch (e) {
// 用户取消
}
},
async handleToggleShop() {
try {
const isClosed = this.shop.state === 2
const confirmMsg = isClosed ? '确定开启店铺吗?' : '确定关闭该店铺吗?关闭后用户将无法访问'
await this.$confirm(confirmMsg, '提示', { type: 'warning' })
const res = await closeShop(this.shop.id)
if (res && (res.code === 0 || res.code === 200)) {
this.$message({
message: isClosed ? '店铺已开启' : '店铺已关闭',
type: 'success',
showClose: true
})
this.fetchMyShop()
} else {
// this.$message.error(res && res.msg ? res.msg : '操作失败')
console.log(`操作失败`);
}
} catch (e) {
// 用户取消
}
},
handleGoNew() {
if (!this.canCreateShop) {
this.$message({
message: '每个用户仅允许一个店铺,无法新建',
type: 'warning',
showClose: true
})
return
}
this.$router.push('/account/shop-new')
},
/**
* 跳转到新增商品页面
*/
handleAddProduct() {
if (!this.hasShop) {
this.$message({
message: '请先创建店铺',
type: 'warning',
showClose: true
})
return
}
// 跳转到新增商品页面并传递店铺ID
this.$router.push({
path: '/account/product-new',
query: { shopId: this.shop.id }
})
}
}
}
</script>
<style scoped>
.panel-title { margin: 0 0 12px 0; font-size: 18px; font-weight: 700; }
.shop-card { border-radius: 8px; }
.shop-row { display: grid; grid-template-columns: 120px 1fr; gap: 16px; align-items: center; }
.shop-cover img { width: 120px; height: 120px; object-fit: cover; border-radius: 8px; border: 1px solid #eee; }
.shop-info { display: flex; flex-direction: column; gap: 8px; }
.shop-title { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; }
.desc { color: #666; }
.meta { color: #999; display: flex; gap: 16px; font-size: 12px; }
.actions { margin-top: 8px; display: flex; gap: 8px; }
</style>
<style>
/* 全局弹窗宽度微调(仅当前页面生效)*/
.el-dialog__body .row { margin-bottom: 12px; }
/* 弹窗表单统一对齐与留白优化 */
.el-dialog__body .row {
display: grid;
grid-template-columns: 96px 1fr;
column-gap: 12px;
align-items: center;
}
.el-dialog__body .row .el-radio-group {
display: inline-flex;
align-items: center;
gap: 24px;
padding-left: 0; /* 与输入框左边缘对齐 */
margin-left: 0; /* 去除可能的默认缩进 */
}
.el-dialog__body .label {
text-align: right;
color: #666;
font-weight: 500;
}
.el-dialog__footer {
padding-top: 4px;
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<div class="order-detail-page">
<h2 class="title">订单详情</h2>
<div v-if="loading" class="loading">加载中...</div>
<div v-else>
<el-card class="section">
<div class="row"><span class="label">订单ID</span><span class="value mono">{{ order.id || '—' }}</span></div>
<div class="row"><span class="label">订单号</span><span class="value mono">{{ order.orderNumber || '—' }}</span></div>
<div class="row"><span class="label">状态</span><span class="value">{{ order.status }}</span></div>
<div class="row"><span class="label">金额(USDT)</span><span class="value strong">{{ order.totalPrice }}</span></div>
<div class="row"><span class="label">创建时间</span><span class="value">{{ formatDateTime(order.createTime) }}</span></div>
</el-card>
<el-card class="section" style="margin-top:12px;">
<div class="sub-title">机器列表</div>
<el-table :data="items" border size="small" style="width:100%"
:header-cell-style="{ textAlign: 'left' }" :cell-style="{ textAlign: 'left' }">
<el-table-column prop="productMachineId" label="机器ID" min-width="120" />
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="payCoin" label="币种" min-width="100" />
<el-table-column prop="leaseTime" label="租赁天数" min-width="100" />
<el-table-column prop="price" label="单价(USDT)" min-width="120" />
<el-table-column prop="address" label="收款地址" min-width="240" />
</el-table>
</el-card>
<div class="actions">
<el-button @click="$router.back()">返回</el-button>
</div>
</div>
</div>
</template>
<script>
import { getOrdersByIds } from '../../api/order'
export default {
name: 'AccountOrderDetail',
data() {
return {
loading: false,
order: {},
items: []
}
},
created() {
this.load()
},
methods: {
async load() {
const id = this.$route.params.id
if (!id) {
this.$message({
message: '订单ID缺失',
type: 'error',
showClose: true
})
return
}
try {
this.loading = true
const res = await getOrdersByIds({ orderId: id })
const payload = (res && res.data) != null ? res.data : res
let one = {}
if (Array.isArray(payload) && payload.length) one = payload[0]
else if (payload && typeof payload === 'object') one = payload
else if (Array.isArray(res && res.rows) && res.rows.length) one = res.rows[0]
this.order = one || {}
this.items = Array.isArray(one && one.orderItemDtoList) ? one.orderItemDtoList : []
} catch (e) {
console.log('获取订单详情失败')
} finally {
this.loading = false
}
}
,
formatDateTime(value) {
if (!value) return '—'
try {
const str = String(value)
return str.includes('T') ? str.replace('T', ' ') : str
} catch (e) {
return String(value)
}
}
}
}
</script>
<style scoped>
.order-detail-page { padding: 12px; }
.title { margin: 0 0 12px 0; font-weight: 600; color: #2c3e50; }
.sub-title { font-weight: 600; margin-bottom: 8px; }
.section { margin-bottom: 12px; }
.row { display: flex; gap: 8px; line-height: 1.8; }
.label { color: #666; }
.value { color: #333; }
.value.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; word-break: break-all; }
.value.strong { font-weight: 700; color: #e74c3c; }
.actions { margin-top: 12px; }
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="orders-page">
<h2 class="title">订单列表</h2>
<el-tabs v-model="active" @tab-click="handleTabClick">
<el-tab-pane label="订单进行中" name="7">
<order-list :items="orders[7]" :show-checkout="true" :on-cancel="handleCancelOrder" empty-text="暂无进行中的订单" />
</el-tab-pane>
<el-tab-pane label="订单已完成" name="8">
<order-list :items="orders[8]" :show-checkout="false" empty-text="暂无已完成的订单" />
</el-tab-pane>
<!-- <el-tab-pane label="余额不足,订单已取消" name="9">
<order-list :items="orders[9]" :show-checkout="false" empty-text="暂无因余额不足取消的订单" />
</el-tab-pane> -->
</el-tabs>
</div>
</template>
<script>
/**
* 订单列表页(个人中心)
* - 使用标签页按状态展示订单
* - 列表项可展开以显示 orderItemDtoList机器列表
* - 通过 getOrdersByStatus(status) 拉取,兼容 { data: { rows: [] } } 或 { rows: [] }
*/
import { getOrdersByStatus, cancelOrder } from '../../api/order'
import OrderList from './OrderList.vue'
// 使用外部 SFC 组件,避免 runtime-only 构建下的模板编译警告
export default {
name: 'AccountOrders',
components: { OrderList },
data() {
return {
active: '7', // 默认值改为 '7'(订单进行中)
orders: { 7: [], 8: [], 9: [] },
loading: false
}
},
created() {
// 优先从 URL 参数获取状态,其次从 localStorage 获取,最后使用默认值
const urlStatus = this.$route && this.$route.query && this.$route.query.status ? String(this.$route.query.status) : null
const savedStatus = localStorage.getItem('orderListActiveTab')
const initial = urlStatus || savedStatus || '7'
this.active = initial
this.fetchOrders(initial)
},
methods: {
async fetchCancelOrder(params) {
const res = await cancelOrder(params)
if (res && Number(res.code) === 200) {
this.$message({
message: '取消订单成功',
type: 'success',
showClose: true
})
this.fetchOrders(this.active)
} else {
this.$message({
message: (res && res.msg) || '取消失败',
type: 'error',
showClose: true
})
}
},
handleCancelOrder({ orderId }) {
if (!orderId) return
this.fetchCancelOrder({ orderId })
},
handleTabClick(tab) {
const name = tab && tab.name ? String(tab.name) : this.active
// 保存用户选择的标签页状态到 localStorage
try {
localStorage.setItem('orderListActiveTab', name)
} catch (e) {
console.warn('保存标签页状态失败:', e)
}
this.fetchOrders(name)
},
async fetchOrders(status) {
const key = String(status)
try {
this.loading = true
const res = await getOrdersByStatus({ status: Number(status) })
const payload = (res && res.data) != null ? res.data : res
const list = Array.isArray(payload)
? payload
: (Array.isArray(payload && payload.rows) ? payload.rows : [])
this.$set(this.orders, key, list)
} catch (e) {
console.log(e,'获取订单失败');
} finally {
this.loading = false
}
}
}
}
</script>
<style scoped>
.orders-page { padding: 12px; }
.title { margin: 0 0 12px 0; font-weight: 600; color: #2c3e50; }
.empty { color: #888; padding: 24px; text-align: center; }
.order-list { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.order-card { border: 1px solid #eee; border-radius: 8px; padding: 0; background: #fff; overflow: hidden; }
.order-header { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; padding: 12px; cursor: pointer; position: relative; }
.order-header:focus { outline: 2px solid #409eff; outline-offset: -2px; }
.order-header.is-open { background: #fafafa; }
.header-row { display: flex; gap: 8px; line-height: 1.8; align-items: center; }
.chevron { position: absolute; right: 12px; top: 12px; width: 10px; height: 10px; border-right: 2px solid #666; border-bottom: 2px solid #666; transform: rotate(-45deg); transition: transform 0.2s ease; }
.chevron.chevron-open { transform: rotate(45deg); }
.order-details { border-top: 1px solid #eee; padding: 12px; }
.machine-list { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.machine-card { border: 1px dashed #e2e2e2; border-radius: 6px; padding: 10px; background: #fff; }
.row { display: flex; gap: 8px; line-height: 1.8; }
.label { color: #666; }
.value { color: #333; }
.value.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; word-break: break-all; }
.value.strong { font-weight: 700; color: #e74c3c; }
@media (max-width: 960px) {
.order-list { grid-template-columns: 1fr; }
.order-header { grid-template-columns: 1fr; }
.machine-list { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,591 @@
<template>
<div class="account-product-detail">
<div class="header">
<el-button type="text" @click="handleBack">返回</el-button>
<h2 class="title">商品详情</h2>
</div>
<!-- 基础信息采用工单详情的两列表单风格 -->
<el-card shadow="never" class="detail-card">
<el-form :model="product" label-width="90px" class="detail-form" size="small">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="商品ID">
<el-input :value="product && product.id" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="店铺ID">
<el-input :value="product && product.shopId" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="名称">
<el-input :value="product && product.name" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="币种">
<el-input :value="product && product.coin" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="算法">
<el-input :value="product && product.algorithm" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="价格范围">
<el-input :value="product && product.priceRange" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="类型">
<el-input :value="product && (product.type === 1 ? '算力套餐' : '挖矿机器')" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-input :value="product && (product.state === 1 ? '下架' : '上架')" disabled />
</el-form-item>
</el-col>
<!-- <el-col :span="24">
<el-form-item label="图片">
<div class="image-row">
<el-image v-if="product && product.image" :src="product.image" fit="cover" class="cover" />
<span v-else class="placeholder">暂无图片</span>
</div>
</el-form-item>
</el-col> -->
<el-col :span="24">
<el-form-item label="描述">
<el-input type="textarea" :rows="3" :value="product && product.description" disabled />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
<!-- 机器组合信息模拟处理记录块状区域 -->
<el-card shadow="never" class="detail-card" v-loading="updateLoading">
<div slot="header" class="section-title">机器组合</div>
<div v-if="machineList && machineList.length">
<el-table :data="machineList" border stripe style="width: 100%">
<el-table-column prop="user" label="挖矿账户" min-width="80" />
<el-table-column prop="id" label="矿机ID" min-width="60" />
<el-table-column prop="miner" label="机器编号" min-width="100" />
<el-table-column label="实时算力">
<template #default="scope">{{ scope.row.computingPower }} {{ scope.row.unit || '' }}</template>
</el-table-column>
<el-table-column label="理论算力" min-width="140">
<template #default="scope">
<el-input
v-model="scope.row.theoryPower"
size="small"
inputmode="decimal"
:disabled="isRowDisabled(scope.row)"
@input="handleTheoryPowerInput(scope.$index)"
@blur="handleTheoryPowerBlur(scope.$index)"
:class="{ 'changed-input': isCellChanged(scope.row, 'theoryPower') }"
style="max-width: 260px;"
>
<template slot="append">{{ scope.row.unit || '' }}</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="功耗(kw/h)" min-width="140">
<template #default="scope">
<el-input
v-model="scope.row.powerDissipation"
size="small"
inputmode="decimal"
:disabled="isRowDisabled(scope.row)"
@input="handleNumericCell(scope.$index, 'powerDissipation')"
@blur="handlePowerDissipationBlur(scope.$index)"
:class="{ 'changed-input': isCellChanged(scope.row, 'powerDissipation') }"
style="max-width: 260px;"
>
<template slot="append">kw/h</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="型号" min-width="140">
<template #default="scope">
<el-input
v-model="scope.row.type"
size="small"
placeholder="矿机型号"
:maxlength="20"
:disabled="isRowDisabled(scope.row)"
@input="handleTypeCell(scope.$index)"
:class="{ 'changed-input': isCellChanged(scope.row, 'type') }"
style="max-width: 180px;"
/>
</template>
</el-table-column>
<el-table-column label="单价(USDT)" min-width="140">
<template #default="scope">
<el-input
v-model="scope.row.price"
size="small"
inputmode="decimal"
:disabled="isRowDisabled(scope.row)"
@input="handleNumericCell(scope.$index, 'price')"
@blur="handlePriceBlur(scope.$index)"
:class="{ 'changed-input': isCellChanged(scope.row, 'price') }"
style="max-width: 260px;"
>
<template slot="append">USDT</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="上下架" min-width="140">
<template #default="scope">
<el-switch
v-model="scope.row.state"
:active-value="0"
:inactive-value="1"
active-text="上架"
inactive-text="下架"
:disabled="isRowDisabled(scope.row)"
@change="handleStateChange(scope.$index)"
/>
</template>
</el-table-column>
<el-table-column label="售出状态" min-width="100">
<template #default="scope">
<el-tag :type="scope.row.saleState === 0 ? 'info' : (scope.row.saleState === 1 ? 'danger' : 'warning')">
{{ scope.row.saleState === 0 ? '未售出' : (scope.row.saleState === 1 ? '已售出' : '售出中') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" min-width="120">
<template #default="scope">
<el-button type="text" size="small" style="color:#f56c6c" :disabled="isRowDisabled(scope.row)" @click="handleDeleteMachine(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div v-else class="empty-text">暂无组合数据</div>
</el-card>
<div class="actions" v-if="machineList && machineList.length">
<el-button type="primary" @click="handleOpenConfirm">提交修改机器</el-button>
</div>
<!-- 提交确认弹窗 -->
<el-dialog
title="确认提交修改"
:visible.sync="confirmVisible"
width="520px"
>
<div>
<p>请仔细确认已选择机器机器组合里的机器价格及相关参数定义</p>
<p>机器修改上架后一经售出在机器出售期间不能修改价格及机器参数</p>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="confirmVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitMachines">确认提交修改</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { getMachineInfoById } from '../../api/products'
import { getMachineListForUpdate,updateMachine,deleteMachine } from '../../api/machine'
export default {
name: 'AccountProductDetail',
data() {
return {
loading: false,
product: null,
ranges: [],
machineList: [],
productId:null,
confirmVisible: false,
// 机器“上下架状态”快照用于失败回滚key 为机器 idvalue 为提交前的 state
stateSnapshot: {},
// 可编辑字段快照(用于变更高亮)
fieldSnapshot: {},
updateLoading:false,
}
},
created() {
this.productId= Number(this.$route.params.id)
if (this.productId) {
this.fetchDetail({ id:this.productId })
this.fetchMachineList({ id:this.productId })
}
},
methods: {
/**
* 判断行是否不可编辑(已售出则禁用)
* @param {Object} row - 当前行数据
* @returns {boolean}
*/
isRowDisabled(row) {
if (!row) return false
return Number(row.saleState) === 1
},
handleOpenConfirm() {
if (!this.machineList || !this.machineList.length) {
this.$message.warning('没有可提交的数据')
return
}
this.confirmVisible = true
},
async fetchDetail(params) {
this.loading = true
try {
const res = await getMachineInfoById(params)
const data = res?.data || {}
this.product = data
this.ranges = Array.isArray(data.productMachineRangeList) ? data.productMachineRangeList : []
} catch (e) {
console.error('获取商品详情失败', e)
console.log('获取商品详情失败')
} finally {
this.loading = false
}
},
async fetchMachineList(params) {
const res = await getMachineListForUpdate(params)
if (res && res.code === 200) {
this.machineList =res.rows
this.refreshStateSnapshot()
this.refreshFieldSnapshot()
}
},
/**
* 刷新状态快照:以当前列表为准记录每条机器的 state
* @returns {void}
*/
refreshStateSnapshot() {
const snapshot = {}
const list = Array.isArray(this.machineList) ? this.machineList : []
for (let i = 0; i < list.length; i += 1) {
const row = list[i]
if (row && typeof row.id !== 'undefined') {
snapshot[row.id] = row.state
}
}
this.stateSnapshot = snapshot
},
/**
* 刷新可编辑字段快照,用于“变更高亮”对比
* @returns {void}
*/
refreshFieldSnapshot() {
const snapshot = {}
const list = Array.isArray(this.machineList) ? this.machineList : []
for (let i = 0; i < list.length; i += 1) {
const row = list[i]
if (!row || typeof row.id === 'undefined') continue
snapshot[row.id] = {
theoryPower: String(row.theoryPower ?? ''),
powerDissipation: String(row.powerDissipation ?? ''),
type: String(row.type ?? ''),
price: String(row.price ?? ''),
}
}
this.fieldSnapshot = snapshot
},
/**
* 判断单元格是否被修改(与快照对比)
* @param {Object} row
* @param {string} key
* @returns {boolean}
*/
isCellChanged(row, key) {
if (!row || typeof row.id === 'undefined') return false
const snap = this.fieldSnapshot[row.id] || {}
const current = String(row[key] ?? '')
const original = String(snap[key] ?? '')
return current !== original
},
/**
* 回滚上下架状态:当提交失败时将每行的 state 恢复为提交前
* @returns {void}
*/
restoreStateSnapshot() {
if (!this.machineList || !this.machineList.length) return
for (let i = 0; i < this.machineList.length; i += 1) {
const currentRow = this.machineList[i]
if (!currentRow || typeof currentRow.id === 'undefined') continue
const prevState = this.stateSnapshot[currentRow.id]
if (typeof prevState !== 'undefined') {
this.$set(this.machineList[i], 'state', prevState)
}
}
},
/**
* 提交机器修改;失败或异常时回滚上下架状态
* @param {Array} params - 要更新的机器列表 payload
* @returns {Promise<void>}
*/
async updateMachineList(params) {
this.updateLoading = true
try {
const res = await updateMachine(params)
if (res && res.code === 200) {
this.$message.success('更新成功')
// 成功后刷新列表(并在列表刷新后更新快照)
this.fetchMachineList({ id:this.productId })
} else {
// 失败回滚上下架状态
this.restoreStateSnapshot()
}
} catch (e) {
// 异常回滚上下架状态
this.restoreStateSnapshot()
}
this.updateLoading = false
},
async deleteMachine(params) {
const res = await deleteMachine(params)
if (res && res.code === 200) {
this.$message.success('删除成功')
this.fetchMachineList({ id:this.productId })
}
},
handleTheoryPowerInput(index) {
const rowItem = this.machineList && this.machineList[index]
if (!rowItem || this.isRowDisabled(rowItem)) return
// 输入阶段限制整数最多6位小数最多4位允许结尾小数点
let v = String(this.machineList[index].theoryPower ?? '')
v = v.replace(/[^0-9.]/g, '')
const firstDot = v.indexOf('.')
if (firstDot !== -1) {
v = v.slice(0, firstDot + 1) + v.slice(firstDot + 1).replace(/\./g, '')
}
const endsWithDot = v.endsWith('.')
const parts = v.split('.')
let intPart = parts[0] || ''
let decPart = parts[1] || ''
if (intPart.length > 6) { intPart = intPart.slice(0, 6) }
if (decPart) { decPart = decPart.slice(0, 4) }
v = decPart.length ? `${intPart}.${decPart}` : (endsWithDot ? `${intPart}.` : intPart)
this.$set(this.machineList, index, { ...this.machineList[index], theoryPower: v })
},
handleNumericCell(index, key) {
const rowItem = this.machineList && this.machineList[index]
if (!rowItem || this.isRowDisabled(rowItem)) return
// 输入阶段限制:
// - 功耗6 位整数 + 4 位小数
// - 价格12 位整数 + 2 位小数
// - 其他保持原逻辑6 位小数)
let v = String(this.machineList[index][key] ?? '')
v = v.replace(/[^0-9.]/g, '')
const firstDot = v.indexOf('.')
if (firstDot !== -1) {
v = v.slice(0, firstDot + 1) + v.slice(firstDot + 1).replace(/\./g, '')
}
if (key === 'powerDissipation') {
const endsWithDot = v.endsWith('.')
const parts = v.split('.')
let intPart = parts[0] || ''
let decPart = parts[1] || ''
if (intPart.length > 6) { intPart = intPart.slice(0, 6) }
if (decPart) { decPart = decPart.slice(0, 4) }
v = decPart.length ? `${intPart}.${decPart}` : (endsWithDot ? `${intPart}.` : intPart)
} else if (key === 'price') {
const endsWithDot = v.endsWith('.')
const parts = v.split('.')
let intPart = parts[0] || ''
let decPart = parts[1] || ''
if (intPart.length > 12) { intPart = intPart.slice(0, 12) }
if (decPart) { decPart = decPart.slice(0, 2) }
v = decPart.length ? `${intPart}.${decPart}` : (endsWithDot ? `${intPart}.` : intPart)
} else {
if (firstDot !== -1) {
const [i, d] = v.split('.')
v = i + '.' + (d ? d.slice(0, 6) : '')
}
}
const row = { ...this.machineList[index], [key]: v }
this.$set(this.machineList, index, row)
},
handlePriceBlur(index) {
const raw = String(this.machineList[index].price ?? '')
const pattern = /^\d{1,12}(\.\d{1,2})?$/
if (!raw || Number(raw) <= 0 || !pattern.test(raw)) {
this.$message.warning('单价必须大于0整数最多12位小数最多2位')
const row = { ...this.machineList[index], price: '' }
this.$set(this.machineList, index, row)
}
},
handleTheoryPowerBlur(index) {
const raw = String(this.machineList[index].theoryPower ?? '')
const pattern = /^\d{1,6}(\.\d{1,4})?$/
if (!raw || Number(raw) <= 0 || !pattern.test(raw)) {
this.$message.warning('理论算力必须大于0')
const row = { ...this.machineList[index], theoryPower: '' }
this.$set(this.machineList, index, row)
}
},
handlePowerDissipationBlur(index) {
const raw = String(this.machineList[index].powerDissipation ?? '')
const pattern = /^\d{1,6}(\.\d{1,4})?$/
if (!raw || Number(raw) <= 0 || !pattern.test(raw)) {
this.$message.warning('功耗必须大于0')
const row = { ...this.machineList[index], powerDissipation: '' }
this.$set(this.machineList, index, row)
}
},
handleTypeCell(index) {
const rowItem = this.machineList && this.machineList[index]
if (!rowItem || this.isRowDisabled(rowItem)) return
const row = { ...this.machineList[index], type: this.machineList[index].type }
this.$set(this.machineList, index, row)
},
handleStateChange(index) {
const rowItem = this.machineList && this.machineList[index]
if (!rowItem || this.isRowDisabled(rowItem)) return
const row = { ...this.machineList[index], state: this.machineList[index].state }
this.$set(this.machineList, index, row)
},
async handleDeleteMachine(row) {
if (!row || !row.id) return
if (this.isRowDisabled(row)) {
this.$message.warning('该矿机已售出,无法删除')
return
}
try {
await this.$confirm('确定删除该矿机吗?删除后不可恢复', '提示', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
})
const res = await deleteMachine({ id: row.id })
if (res && res.code === 200) {
this.$message.success('删除成功')
this.fetchMachineList({ id: this.productId })
}
} catch (e) {
// 用户取消
}
},
async handleSubmitMachines() {
if (!this.machineList || !this.machineList.length) {
this.$message.warning('没有可提交的数据')
return
}
try {
// 提交前强校验:理论算力、功耗、单价必须填写且格式正确(全部行都校验)
const powerPattern = /^\d{1,6}(\.\d{1,4})?$/
const pricePattern = /^\d{1,12}(\.\d{1,2})?$/
const isOnlySpaces = (v) => typeof v === 'string' && v.trim().length === 0 && v.length > 0
for (let i = 0; i < this.machineList.length; i += 1) {
const row = this.machineList[i]
const rowLabel = row && (row.miner || row.id || i + 1)
const theoryRaw = String(row.theoryPower ?? '')
const priceRaw = String(row.price ?? '')
const typeRaw = String(row.type ?? '')
const dissRaw = String(row.powerDissipation ?? '')
if (!theoryRaw || Number(theoryRaw) <= 0 || !powerPattern.test(theoryRaw)) {
this.$message.warning(`${i + 1}行(机器:${rowLabel}) 理论算力必须大于0整数最多6位小数最多4位`)
return
}
if (!dissRaw || Number(dissRaw) <= 0 || !powerPattern.test(dissRaw)) {
this.$message.warning(`${i + 1}行(机器:${rowLabel}) 功耗必须大于0整数最多6位小数最多4位`)
return
}
if (!priceRaw || Number(priceRaw) <= 0 || !pricePattern.test(priceRaw)) {
this.$message.warning(`${i + 1}行(机器:${rowLabel}) 单价必须大于0整数最多12位小数最多2位`)
return
}
// 型号允许为空,但如果填写则不能全空格
if (typeRaw && isOnlySpaces(typeRaw)) {
this.$message.warning(`${i + 1}行(机器:${rowLabel}) 型号不能全是空格`)
return
}
}
const payload = this.machineList.map(m => ({
id: m.id,
powerDissipation: Number(m.powerDissipation ?? 0),
price: Number(m.price ?? 0),
state: Number(m.state ?? 0),
theoryPower: Number(m.theoryPower ?? 0),
type: m.type || '',
unit: m.unit || ''
}))
// 关闭外层确认弹窗,直接提交
this.confirmVisible = false
console.log(payload, 'payload');
await this.updateMachineList(payload)
} catch (e) {
// 异常处理
}
},
handleBack() {
this.$router.back()
}
}
}
</script>
<style scoped>
.account-product-detail { padding: 8px; }
.header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.title { margin: 0; font-size: 18px; font-weight: 600; }
.detail-card { margin-bottom: 12px; }
.detail-form { padding: 4px 8px; }
.image-row { display: flex; align-items: center; min-height: 120px; }
.cover { width: 200px; height: 120px; object-fit: cover; border-radius: 4px; background: #f5f5f5; border: 1px solid #eee; }
.placeholder { color: #999; }
.section-title { font-weight: 600; }
.ranges-wrapper { display: grid; gap: 12px; }
.range-block { border: 1px solid #f0f0f0; background: #fcfcfc; border-radius: 6px; padding: 10px; }
.item { color: #444; line-height: 24px; }
.machines-box { margin-top: 8px; border-top: 1px dashed #e5e5e5; padding-top: 8px; }
.machine-row { display: flex; flex-wrap: wrap; gap: 8px; color: #555; line-height: 22px; }
.split { width: 8px; }
.empty-text { color: #909399; text-align: center; padding: 12px 0; }
</style>
<style>
.el-input-group__append, .el-input-group__prepend{
padding: 0 5px !important;
}
/* 变化高亮:为输入框外层添加红色边框,视觉醒目但不改变布局 */
.changed-input .el-input__inner,
.changed-input input.el-input__inner {
border-color: #f56c6c !important;
}
</style>

View File

@@ -0,0 +1,668 @@
<template>
<div class="product-machine-add">
<div class="header">
<el-button type="text" @click="handleBack">返回</el-button>
<h2 class="title">添加出售机器</h2>
</div>
<el-card shadow="never" class="form-card">
<el-form ref="machineForm" :model="form" :rules="rules" label-width="160px" size="small">
<el-form-item label="商品名称">
<el-input v-model="form.productName" disabled />
</el-form-item>
<el-form-item label="功耗" prop="powerDissipation">
<el-input
v-model="form.powerDissipation"
placeholder="示例0.01"
inputmode="decimal"
@input="handleNumeric('powerDissipation')"
>
<template slot="append">kw/h</template>
</el-input>
</el-form-item>
<el-form-item label="机器成本价格" prop="cost">
<el-input
v-model="form.cost"
placeholder="请输入成本USDT"
inputmode="decimal"
@input="handleNumeric('cost')"
>
<template slot="append">USDT</template>
</el-input>
</el-form-item>
<el-form-item label="矿机型号">
<el-input v-model="form.type" placeholder="示例:龍珠" :maxlength="20" @input="handleTypeInput" />
</el-form-item>
<el-form-item label="理论算力" prop="theoryPower">
<el-input
v-model="form.theoryPower"
placeholder="请输入单机理论算力"
inputmode="decimal"
@input="handleNumeric('theoryPower')"
/>
</el-form-item>
<el-form-item label="算力单位" prop="unit">
<el-select v-model="form.unit" placeholder="请选择算力单位">
<el-option label="MH/S" value="MH/S" />
<el-option label="GH/S" value="GH/S" />
<el-option label="TH/S" value="TH/S" />
<el-option label="PH/S" value="PH/S" />
</el-select>
</el-form-item>
<el-form-item label="选择挖矿账户">
<el-select v-model="selectedMiner" filterable clearable placeholder="请选择挖矿账户" @change="handleMinerChange" :loading="minersLoading">
<el-option v-for="m in miners" :key="m.user + '_' + m.coin" :label="m.user + '' + m.coin + ''" :value="m.user + '|' + m.coin" />
</el-select>
</el-form-item>
<el-form-item label="选择机器(可多选)">
<el-select v-model="selectedMachines" multiple filterable collapse-tags placeholder="请选择机器" :loading="machinesLoading" :disabled="!selectedMiner">
<el-option v-for="m in machineOptions" :key="m.user + '_' + m.miner" :label="m.miner + '' + m.user + ''" :value="m.miner" />
</el-select>
</el-form-item>
</el-form>
</el-card>
<!-- 已选择机器列表 -->
<el-card shadow="never" class="form-card" v-if="selectedMachineRows.length">
<div slot="header" class="section-title">已选择机器</div>
<el-table :data="selectedMachineRows" border stripe style="width: 100%">
<el-table-column prop="user" label="挖矿账户" min-width="160" />
<el-table-column prop="miner" label="机器编号" min-width="160" />
<el-table-column label="价格(USDT)" min-width="220">
<template #default="scope">
<el-input
v-model="scope.row.price"
placeholder="价格"
inputmode="decimal"
@input="handleRowPriceInput(scope.$index)"
@blur="handleRowPriceBlur(scope.$index)"
style="width: 70%;"
>
<template slot="append">USDT</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="矿机型号" min-width="200">
<template #default="scope">
<el-input
v-model="scope.row.type"
placeholder="矿机型号"
@input="handleRowTypeInput(scope.$index)"
@blur="handleRowTypeBlur(scope.$index)"
:maxlength="20"
style="width: 70%;"
/>
</template>
</el-table-column>
<el-table-column label="上下架状态" min-width="120">
<template #default="scope">
<el-button
:type="scope.row.state === 0 ? 'success' : 'info'"
size="mini"
@click="handleToggleState(scope.$index)"
>
{{ scope.row.state === 0 ? '上架' : '下架' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<div class="actions">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">确认添加</el-button>
</div>
<!-- 上架确认弹窗 -->
<el-dialog
title="请确认上架信息"
:visible.sync="confirmVisible"
width="400px"
>
<div>
<p>请仔细确认已选择机器列表价格及相关参数定义</p>
<p style="text-align: left;">机器上架后一经售出在机器出售期间不能修改价格及机器参数</p>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="confirmVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="doSubmit">确认上架已选择机器</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { getUserMinersList, getUserMachineList, addSingleOrBatchMachine } from '../../api/machine'
export default {
name: 'AccountProductMachineAdd',
data() {
return {
form: {
productId: Number(this.$route.query.productId) || null,
coin: this.$route.query.coin || '',
productName: this.$route.query.name || '',
powerDissipation: null,
theoryPower: null,
type: '',
unit: 'TH/S',
cost: ''
},
confirmVisible: false,
rules: {
productName: [ { required: true, message: '商品名称不能为空', trigger: 'change' } ],
coin: [ { required: true, message: '币种不能为空', trigger: 'change' } ],
powerDissipation: [
{ required: true, message: '功耗不能为空', trigger: 'blur' },
{
validator: (rule, value, callback) => {
const str = String(value || '')
if (!str) { callback(new Error('功耗不能为空')); return }
const pattern = /^\d{1,6}(\.\d{1,4})?$/
if (!pattern.test(str)) { callback(new Error('功耗整数最多6位小数最多4位')); return }
if (Number(str) <= 0) { callback(new Error('功耗必须大于0')); return }
callback()
},
trigger: 'blur'
}
],
theoryPower: [
{ required: true, message: '理论算力不能为空', trigger: 'blur' },
{
validator: (rule, value, callback) => {
const str = String(value || '')
if (!str) { callback(new Error('理论算力不能为空')); return }
const pattern = /^\d{1,6}(\.\d{1,4})?$/
if (!pattern.test(str)) { callback(new Error('理论算力整数最多6位小数最多4位')); return }
if (Number(str) <= 0) { callback(new Error('理论算力必须大于0')); return }
callback()
},
trigger: 'blur'
}
],
unit: [ { required: true, message: '请选择算力单位', trigger: 'change' } ],
cost: [
{ required: true, message: '请填写机器成本USDT', trigger: 'blur' },
{
validator: (rule, value, callback) => {
const str = String(value || '')
if (!str) {
callback(new Error('请填写机器成本USDT'))
return
}
// 整数最多12位小数最多2位
const pattern = /^\d{1,12}(\.\d{1,2})?$/
if (!pattern.test(str)) {
callback(new Error('成本整数最多12位小数最多2位'))
return
}
if (Number(str) <= 0) {
callback(new Error('成本必须大于 0'))
return
}
callback()
},
trigger: 'blur'
}
]
},
miners: [
// {
// "user": "lx_888",
// "miner": null,
// "coin": "nexa"
// },
// {
// "user": "lx999",
// "miner": null,
// "coin": "nexa"
// },
// {
// "user": "lx88",
// "miner": null,
// "coin": "nexa"
// },
// {
// "user": "lx6666",
// "miner": null,
// "coin": "nexa"
// },
// {
// "user": "lx_999",
// "miner": null,
// "coin": "nexa"
// },
// {
// "user": "Lx_6966",
// "miner": null,
// "coin": "nexa"
// },
// {
// "user": "LX_666",
// "miner": null,
// "coin": "nexa"
// },
],
minersLoading: false,
selectedMiner: '', // 格式 user|coin
machineOptions: [
// {
// "user": "lx_888",
// "miner": `iusfhufhu`,
// "coin": "nexa"
// },
// {
// "user": "lx999",
// "miner": `iusfhufhu2`,
// "coin": "nexa"
// },
// {
// "user": "lx88",
// "miner": `iusfhufhu3`,
// "coin": "nexa"
// },
// {
// "user": "lx6666",
// "miner": `iusfhufhu4`,
// "coin": "nexa"
// },
// {
// "user": "lx_999",
// "miner": `iusfhufhu5`,
// "coin": "nexa"
// },
// {
// "user": "Lx_6966",
// "miner": `iusfhufhu6`,
// "coin": "nexa"
// },
// {
// "user": "LX_666",
// "miner": `iusfhufhu7`,
// "coin": "nexa"
// },
],
machinesLoading: false,
selectedMachines: [],
selectedMachineRows: [],
saving: false,
lastCostBaseline: 0,
lastTypeBaseline: '',
params:{
cost:353400,
powerDissipation:0.01,
theoryPower:1000,
type:"",
unit:"TH/S",
productId:1,
productMachineURDVos:[
{
"user":"lx_888",
"miner":"iusfhufhu",
"price":353400,
"type":"",
"state":0
},
{
"user":"lx_888",
"miner":"iusfhufhu2",
"price":353400,
"type":"",
"state":0
},
]
}
}
},
created() {
this.fetchMiners()
this.lastTypeBaseline = this.form.type
},
methods: {
handleBack() {
this.$router.back()
},
handleNumeric(key) {
// 仅允许数字和一个小数点
let v = String(this.form[key] ?? '')
// 清理非法字符
v = v.replace(/[^0-9.]/g, '')
// 保留第一个小数点
const firstDot = v.indexOf('.')
if (firstDot !== -1) {
v = v.slice(0, firstDot + 1) + v.slice(firstDot + 1).replace(/\./g, '')
}
const endsWithDot = v.endsWith('.')
if (key === 'cost') {
// 成本整数最多12位小数最多2位
const parts = v.split('.')
let intPart = parts[0] || ''
let decPart = parts[1] || ''
if (intPart.length > 12) {
intPart = intPart.slice(0, 12)
}
if (decPart) {
decPart = decPart.slice(0, 2)
}
v = decPart.length ? `${intPart}.${decPart}` : (endsWithDot ? `${intPart}.` : intPart)
} else if (key === 'powerDissipation' || key === 'theoryPower') {
// 功耗/理论算力整数最多6位小数最多4位
const parts = v.split('.')
let intPart = parts[0] || ''
let decPart = parts[1] || ''
if (intPart.length > 6) {
intPart = intPart.slice(0, 6)
}
if (decPart) {
decPart = decPart.slice(0, 4)
}
v = decPart.length ? `${intPart}.${decPart}` : (endsWithDot ? `${intPart}.` : intPart)
} else {
// 其他最多6位小数保持原有逻辑
if (firstDot !== -1) {
const [intPart, decPart] = v.split('.')
v = intPart + '.' + (decPart ? decPart.slice(0, 6) : '')
}
}
this.form[key] = v
if (key === 'cost') {
this.syncCostToRows()
}
},
/**
* 顶部矿机型号输入限制20字符
*/
handleTypeInput() {
if (typeof this.form.type === 'string' && this.form.type.length > 20) {
this.form.type = this.form.type.slice(0, 20)
}
},
syncCostToRows() {
const newCost = Number(this.form.cost)
if (!Number.isFinite(newCost)) {
return
}
const oldBaseline = this.lastCostBaseline
this.selectedMachineRows = this.selectedMachineRows.map(row => {
const priceNum = Number(row.price)
if (!Number.isFinite(priceNum) || priceNum === oldBaseline) {
return { ...row, price: newCost }
}
return row
})
this.lastCostBaseline = newCost
},
updateMachineType() {
// 当外层矿机型号变动时,同步更新机器列表中的型号
// 但如果用户手动改过某行型号,则不覆盖
this.selectedMachineRows = this.selectedMachineRows.map(row => {
// 如果该行型号为空或等于旧型号,则更新为新型号
if (!row.type || row.type === this.lastTypeBaseline) {
return { ...row, type: this.form.type }
}
return row
})
this.lastTypeBaseline = this.form.type
},
updateSelectedMachineRows() {
// 依据 selectedMachines 与 machineOptions 同步生成行数据
const map = new Map()
this.machineOptions.forEach(m => {
map.set(m.miner, m)
})
const nextRows = []
this.selectedMachines.forEach(minerId => {
const m = map.get(minerId)
if (m) {
// 若已存在,沿用已编辑的价格、型号和状态
const existed = this.selectedMachineRows.find(r => r.miner === minerId)
nextRows.push({
user: m.user,
coin: m.coin,
miner: m.miner,
price: existed ? existed.price : this.form.cost,
type: existed ? existed.type : this.form.type,
state: existed ? existed.state : 0 // 默认上架
})
}
})
this.selectedMachineRows = nextRows
},
handleRowPriceInput(index) {
// 价格输入整数最多12位小数最多2位允许尾随小数点
let v = String(this.selectedMachineRows[index].price ?? '')
v = v.replace(/[^0-9.]/g, '')
const firstDot = v.indexOf('.')
if (firstDot !== -1) {
v = v.slice(0, firstDot + 1) + v.slice(firstDot + 1).replace(/\./g, '')
}
const endsWithDot = v.endsWith('.')
const parts = v.split('.')
let intPart = parts[0] || ''
let decPart = parts[1] || ''
if (intPart.length > 12) { intPart = intPart.slice(0, 12) }
if (decPart) { decPart = decPart.slice(0, 2) }
v = decPart.length ? `${intPart}.${decPart}` : (endsWithDot ? `${intPart}.` : intPart)
this.$set(this.selectedMachineRows[index], 'price', v)
},
handleRowPriceBlur(index) {
const raw = String(this.selectedMachineRows[index].price ?? '')
const pattern = /^\d{1,12}(\.\d{1,2})?$/
if (!raw || Number(raw) <= 0 || !pattern.test(raw)) {
this.$message.warning('价格必须大于0整数最多12位小数最多2位')
this.$set(this.selectedMachineRows[index], 'price', '')
}
},
handleRowTypeInput(index) {
// 处理矿机型号输入
const raw = String(this.selectedMachineRows[index].type || '')
const v = raw.length > 20 ? raw.slice(0, 20) : raw
this.$set(this.selectedMachineRows[index], 'type', v)
},
handleRowTypeBlur(index) {
const raw = this.selectedMachineRows[index].type
const isOnlySpaces = (v) => typeof v === 'string' && v.length > 0 && v.trim().length === 0
if (isOnlySpaces(raw)) {
this.$message.warning('矿机型号不能全是空格')
this.$set(this.selectedMachineRows[index], 'type', '')
}
},
handleToggleState(index) {
// 切换上下架状态0上架1下架
const currentState = this.selectedMachineRows[index].state
this.$set(this.selectedMachineRows[index], 'state', currentState === 0 ? 1 : 0)
},
async fetchMiners() {
this.minersLoading = true
try {
// 按商品币种筛选挖矿账户
const res = await getUserMinersList({ coin: this.form.coin || "" })
const data = res?.data
let list = []
if (Array.isArray(data)) {
list = data
} else if (data && typeof data === 'object') {
// 现在的结构是 { coin: [ { user, coin }, ... ], coin2: [...] }
Object.keys(data).forEach(coinKey => {
const arr = Array.isArray(data[coinKey]) ? data[coinKey] : []
arr.forEach(item => {
if (item && item.user && item.coin) {
list.push({ user: item.user, coin: item.coin, miner: item.miner || null })
}
})
})
} else if (data && data.additionalProperties1) {
list = [data.additionalProperties1]
}
// 如页面带了 product coin则仅展示该币种的账户
if (this.form.coin) {
list = list.filter(i => i.coin === this.form.coin)
}
this.miners = list
} catch (e) {
console.error('获取挖矿账户失败', e)
} finally {
this.minersLoading = false
}
},
async handleMinerChange(val) {
this.selectedMachines = []
if (!val) {
this.machineOptions = []
return
}
const [user, coin] = val.split('|')
this.machinesLoading = true
try {
// 按照API文档要求传递 userMinerVo 对象
const userMinerVo = {
coin: coin,
user: user
}
const res = await getUserMachineList(userMinerVo)
const data = res?.data || []
this.machineOptions = Array.isArray(data) ? data : []
// 调试信息
console.log('选择挖矿账户:', { user, coin })
console.log('获取机器列表响应:', res)
console.log('机器列表数据:', this.machineOptions)
} catch (e) {
console.error('获取机器列表失败', e)
this.$message.error('获取机器列表失败,请重试')
} finally {
this.machinesLoading = false
}
},
async handleSave() {
// 表单校验(除矿机型号外其他必填)
try {
const ok = await this.$refs.machineForm.validate()
if (!ok) {
return
}
} catch (e) {
return
}
if (!this.form.productId) {
this.$message.warning('缺少商品ID')
return
}
if (!this.selectedMiner) {
this.$message.warning('请先选择挖矿账户')
return
}
if (!this.selectedMachines.length) {
this.$message.warning('请至少选择一台机器')
return
}
// 校验:矿机型号不可全空格(允许为空或包含空格的正常文本)
const isOnlySpaces = (v) => typeof v === 'string' && v.length > 0 && v.trim().length === 0
if (isOnlySpaces(this.form.type)) {
this.$message.warning('矿机型号不能全是空格')
return
}
const invalidTypeRowIndex = this.selectedMachineRows.findIndex(r => isOnlySpaces(r.type))
if (invalidTypeRowIndex !== -1) {
this.$message.warning('存在行的矿机型号全是空格,请修正后再试')
return
}
// 校验已选择机器的价格必须大于0
for (let i = 0; i < this.selectedMachineRows.length; i += 1) {
const row = this.selectedMachineRows[i]
const priceNum = Number(row && row.price)
if (!Number.isFinite(priceNum) || priceNum <= 0) {
const label = (row && (row.miner || row.user)) || i + 1
this.$message.warning(`${i + 1}行(机器:${label}) 价格必须大于0`)
return
}
}
// 通过所有预校验后,弹出确认框
this.confirmVisible = true
}
,
async doSubmit() {
const [user, coin] = this.selectedMiner.split('|')
this.saving = true
try {
const payload = {
productId: this.form.productId,
powerDissipation: this.form.powerDissipation,
theoryPower: this.form.theoryPower,
type: this.form.type,
unit: this.form.unit,
cost: this.form.cost,
productMachineURDVos: this.selectedMachineRows.map(r => ({
miner: r.miner,
price: Number(r.price) || 0,
state: r.state || 0,
type: r.type || this.form.type,
user: r.user
}))
}
console.log(payload,"请求参数")
const res = await addSingleOrBatchMachine(payload)
if (res && (res.code === 0 || res.code === 200)) {
this.$message.success('添加成功')
this.confirmVisible = false
this.$router.back()
} else {
this.$message.error(res?.msg || '添加失败')
}
} catch (e) {
console.error('添加出售机器失败', e)
console.log('添加失败')
} finally {
this.saving = false
}
}
}
,
watch: {
'form.cost': function() { this.syncCostToRows() },
'form.type': function() { this.updateMachineType() },
selectedMachines() {
this.updateSelectedMachineRows()
}
}
}
</script>
<style scoped>
.product-machine-add { padding: 8px; }
.header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.title { margin: 0; font-size: 18px; font-weight: 600; }
.form-card { margin-bottom: 12px; }
.actions { text-align: right; }
/* 统一左对齐,控件宽度 50% */
.product-machine-add :deep(.el-form-item__content) {
justify-content: flex-start;
}
.product-machine-add :deep(.el-input),
.product-machine-add :deep(.el-select),
.product-machine-add :deep(.el-textarea) {
width: 50%;
}
.product-machine-add :deep(.el-input-group__append) {
background: #f5f7fa;
color: #606266;
border-left: 1px solid #dcdfe6;
}
::v-deep .el-form-item__content{
text-align: left;
padding-left: 18px !important;
}
</style>

View File

@@ -0,0 +1,443 @@
<template>
<div class="product-new">
<el-card class="product-form-card">
<div slot="header" class="card-header">
<h2>新增商品</h2>
<p class="subtitle">创建新的商品信息</p>
</div>
<el-form
ref="productForm"
:model="form"
:rules="rules"
label-width="120px"
class="product-form"
>
<!-- 商品名称 -->
<el-form-item label="商品名称" prop="name">
<el-input
v-model="form.name"
placeholder="请输入商品名称Nexa-M2-Miner"
maxlength="30"
show-word-limit
/>
</el-form-item>
<!-- 商品类型 -->
<el-form-item label="商品类型" prop="type" class="align-like-input">
<el-radio-group v-model="form.type">
<el-radio :label="0">矿机</el-radio>
<el-radio :label="1">算力</el-radio>
</el-radio-group>
</el-form-item>
<!-- 矿机挖矿币种 -->
<el-form-item label="挖矿币种" prop="coin">
<el-select
v-model="form.coin"
placeholder="请选择挖矿币种"
style="width: 100%"
>
<el-option
v-for="coin in coinOptions"
:key="coin.value"
:label="coin.label"
:value="coin.value"
/>
</el-select>
</el-form-item>
<!-- 商品描述 -->
<el-form-item label="商品描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="4"
placeholder="请输入商品描述"
maxlength="100"
show-word-limit
/>
</el-form-item>
<!-- 商品图片非必填 -->
<!-- <el-form-item label="商品图片" prop="image">
<el-input
v-model="form.image"
placeholder="请输入商品图片路径"
/>
</el-form-item> -->
<!-- 单机算力 -->
<!-- <el-form-item label="单机算力" prop="power">
<el-input
v-model.number="form.power"
type="number"
placeholder="请输入单机算力"
>
<el-select
slot="append"
v-model="form.unit"
placeholder="单位"
style="width: 110px"
>
<el-option label="GH/S" value="GH/S" />
<el-option label="TH/S" value="TH/S" />
<el-option label="PH/S" value="PH/S" />
</el-select>
</el-input>
</el-form-item> -->
<!-- 功耗 -->
<!-- <el-form-item label="功耗" prop="powerDissipation">
<el-input
v-model.number="form.powerDissipation"
type="number"
placeholder="请输入功耗"
>
<template slot="append">kw/h</template>
</el-input>
</el-form-item> -->
<!-- 电费 -->
<!-- <el-form-item label="电费" prop="electricityBill">
<el-input
v-model.number="form.electricityBill"
type="number"
placeholder="请输入电费"
>
<template slot="append">$/</template>
</el-input>
</el-form-item> -->
<!-- 收益率 -->
<!-- <el-form-item label="收益率" prop="incomeRate">
<el-input
v-model.number="form.incomeRate"
type="number"
placeholder="请输入收益率"
>
<template slot="append">%</template>
</el-input>
</el-form-item> -->
<!-- 上下架状态 -->
<el-form-item label="商品状态" prop="state" class="align-like-input">
<el-radio-group v-model="form.state">
<el-radio :label="0">上架</el-radio>
<el-radio :label="1">下架</el-radio>
</el-radio-group>
</el-form-item>
<!-- 操作按钮 -->
<el-form-item class="actions-row">
<div class="form-actions">
<el-button type="primary" size="medium" @click="handleSubmit" :loading="submitting">创建商品</el-button>
<el-button size="medium" @click="handleReset">重置</el-button>
<el-button size="medium" @click="handleCancel">取消</el-button>
</div>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script>
import { coinList } from '@/utils/coinList'
import { createProduct } from '../../api/products'
export default {
name: 'AccountProductNew',
data() {
const notOnlySpaces = (rule, value, callback) => {
if (typeof value === 'string' && value.trim().length === 0) {
callback(new Error('内容不能全是空格'))
return
}
callback()
}
/**
* 检测字符串是否包含表情符号
* @param {string} text - 待检测文本
* @returns {boolean} 是否包含表情符号
*/
const containsEmoji = (text) => {
if (typeof text !== 'string' || text.length === 0) {
return false
}
// 覆盖常见 Emoji 范围、旗帜、杂项符号、交通、补充符号、ZWJ、变体选择符与组合字符等
const emojiPattern = /[\u{1F300}-\u{1FAFF}]|[\u{1F1E6}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}]|[\u{200D}]|[\u{20E3}]/u
return emojiPattern.test(text)
}
/**
* 不允许包含表情符号的名称校验器
* @param {object} rule - 校验规则对象
* @param {string} value - 输入值
* @param {Function} callback - 回调
* @returns {void}
*/
const noEmoji = (rule, value, callback) => {
if (typeof value === 'string' && containsEmoji(value)) {
callback(new Error('商品名称不能包含表情符号'))
return
}
callback()
}
return {
submitting: false,
form: {
name: '',
type: 0,
coin: '',
description: '',//商品描述
image: '',
// power: null,
// unit: 'GH/S',
// powerDissipation: null,
// electricityBill: null,//电费
// incomeRate: null,//收益率
state: 0,
shopId: null
},
rules: {
name: [
{ required: true, message: '请输入商品名称', trigger: 'blur' },
{ validator: notOnlySpaces, trigger: 'blur' },
{ validator: noEmoji, trigger: 'blur' },
{ min: 1, max: 30, message: '商品名称长度在 2 到 30 个字符', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择商品类型', trigger: 'change' }
],
coin: [
{ required: true, message: '请选择挖矿币种', trigger: 'change' }
],
description: [
{ required: true, message: '请输入商品描述', trigger: 'blur' },
{ validator: notOnlySpaces, trigger: 'blur' },
{ min: 1, max: 100, message: '商品描述长度在 1 到 100 个字符', trigger: 'blur' }
],
image: [],
// power: [
// { required: true, message: '请输入单机算力', trigger: 'blur' },
// { type: 'number', min: 0.01, message: '算力必须大于0', trigger: 'blur' }
// ],
// unit: [ { required: true, message: '请选择算力单位', trigger: 'change' } ],
// powerDissipation: [
// { required: true, message: '请输入功耗', trigger: 'blur' },
// { type: 'number', min: 0.01, message: '功耗必须大于0', trigger: 'blur' }
// ],
// electricityBill: [
// { required: true, message: '请输入电费', trigger: 'blur' },
// { type: 'number', min: 0.01, message: '电费必须大于0', trigger: 'blur' }
// ],
// incomeRate: [
// { required: true, message: '请输入收益率', trigger: 'blur' },
// { type: 'number', min: 0.0001, max: 1, message: '收益率必须在0.0001到1之间', trigger: 'blur' }
// ],
state: [
{ required: true, message: '请选择商品状态', trigger: 'change' }
]
}
}
},
computed: {
coinOptions() {
return coinList || [
{ value: 'nexa', label: 'NEXA' },
{ value: 'rxd', label: 'RXD' },
{ value: 'dgbo', label: 'DGBO' },
{ value: 'dgbq', label: 'DGBQ' },
{ value: 'dgbs', label: 'DGBS' },
{ value: 'alph', label: 'ALPH' },
{ value: 'enx', label: 'ENX' },
{ value: 'grs', label: 'GRS' },
{ value: 'mona', label: 'MONA' }
]
}
},
created() {
// 从路由参数获取店铺ID
const shopId = this.$route.query.shopId
if (shopId) {
this.form.shopId = Number(shopId)
}
},
methods: {
async fetchAddProduct(params) {
const res = await createProduct(params)
if (res && (res.code === 0 || res.code === 200)) {
this.$message({
message: '商品创建成功',
type: 'success',
showClose: true
})
this.$router.push('/account/shops')
}else {
this.$message({
message: res && res.msg ? res.msg : '创建失败',
type: 'error',
showClose: true
})
}
},
/**
* 提交表单
*/
async handleSubmit() {
try {
// 表单验证
const valid = await this.$refs.productForm.validate()
if (!valid) {
return
}
// 检查是否有店铺ID
if (!this.form.shopId) {
this.$message({
message: '缺少店铺ID请从我的店铺页面进入',
type: 'error',
showClose: true
})
return
}
this.submitting = true
this.fetchAddProduct(this.form)
} catch (error) {
console.error('创建商品失败:', error)
} finally {
this.submitting = false
}
},
/**
* 重置表单
*/
handleReset() {
this.$refs.productForm.resetFields()
// 保持店铺ID
const shopId = this.$route.query.shopId
if (shopId) {
this.form.shopId = Number(shopId)
}
},
/**
* 取消操作
*/
handleCancel() {
this.$router.push('/account/shops')
}
}
}
</script>
<style scoped>
.product-new {
padding: 20px;
max-width: 60vw;
margin: 0 auto;
}
.product-form-card {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.card-header {
text-align: center;
}
.card-header h2 {
margin: 0 0 8px 0;
color: #303133;
font-size: 24px;
font-weight: 600;
}
.subtitle {
margin: 0;
color: #909399;
font-size: 14px;
}
.product-form {
margin-top: 20px;
}
/* 单选组左对齐,和输入框对齐 */
.product-form .el-form-item .el-radio-group {
display: inline-flex;
align-items: center;
gap: 24px;
padding-left: 0;
margin-left: 0;
}
/* 让单选组整体与输入框内容左边缘对齐考虑到Element默认label-width=120px
输入框内部有一定内边距,这里为内容区域添加与输入框相同的内边距) */
.product-form .align-like-input .el-form-item__content {
padding-left: 15px; /* 与 el-input 默认内边距对齐 */
}
.unit-text {
margin-left: 10px;
color: #909399;
font-size: 14px;
}
/* 操作按钮:居中对齐,等宽美观 */
.actions-row .el-form-item__content {
text-align: center;
}
.form-actions {
/* display: grid; */
grid-auto-flow: column;
text-align: center;
}
.form-actions .el-button {
min-width: auto; /* 自适应文字宽度 */
white-space: nowrap;
padding: 8px 20px !important;
min-width: 160px;
}
/* 隐藏数字输入框的上下箭头(各浏览器兼容) */
::v-deep input[type='number']::-webkit-outer-spin-button,
::v-deep input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
::v-deep input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
/* 响应式设计 */
@media (max-width: 768px) {
.product-new {
padding: 15px;
}
.product-form-card {
margin: 0 10px;
}
.el-form-item {
margin-bottom: 18px;
}
}
::v-deep .el-form-item__content{
text-align: left;
}
</style>

View File

@@ -0,0 +1,406 @@
<template>
<div class="account-products">
<!-- 标题与操作栏 -->
<div class="toolbar">
<div class="left-area">
<h2 class="page-title">商品列表</h2>
</div>
<div class="right-area">
<el-input
v-model="searchKeyword"
placeholder="输入币种或算法关键字后回车/搜索"
size="small"
clearable
class="mr-12"
style="width: 280px"
@keyup.enter.native="handleSearch"
@clear="handleClear"
/>
<el-button type="primary" size="small" @click="handleSearch">搜索</el-button>
<el-button size="small" class="ml-8" @click="handleReset">重置</el-button>
</div>
</div>
<!-- 表格列表 -->
<el-table
:data="tableData"
v-loading="loading"
border
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="coin" label="币种" width="100" />
<el-table-column prop="priceRange" label="价格范围" width="150" />
<!-- <el-table-column label="算力" min-width="140">
<template #default="scope">
<span>{{ scope.row.power }} {{ scope.row.unit }}</span>
</template>
</el-table-column> -->
<el-table-column prop="algorithm" label="算法" min-width="120" />
<!-- <el-table-column prop="electricityBill" label="电费" width="100" /> -->
<!-- <el-table-column prop="incomeRate" label="收益率" width="100" /> -->
<el-table-column prop="type" label="商品类型" width="130">
<template #default="scope">
<el-tag :type="scope.row.type === 1 ? 'success' : 'warning'">
{{ scope.row.type === 1 ? '算力套餐' : '挖矿机器' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="state" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.state === 1 ? 'info' : 'success'">
{{ scope.row.state === 1 ? '下架' : '上架' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="220">
<template #default="scope">
<el-button type="text" size="small" @click="handleView(scope.row)">详情</el-button>
<el-button type="text" size="small" @click="handleEdit(scope.row)">修改</el-button>
<el-button type="text" size="small" style="color:#f56c6c" @click="handleDelete(scope.row)">删除</el-button>
<el-button type="text" size="small" @click="handleAddMachine(scope.row)">添加出售机器</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:current-page.sync="pagination.pageNum"
:page-sizes="[10, 20, 50, 100]"
:page-size.sync="pagination.pageSize"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 编辑弹窗 -->
<el-dialog :visible.sync="editDialog.visible" :close-on-click-modal="false" width="620px" :title="'编辑商品 - ' + ((editDialog.form && editDialog.form.name) ? editDialog.form.name : '')">
<el-form v-if="editDialog.form" :model="editDialog.form" label-width="100px" ref="editForm" class="edit-form">
<el-form-item label="名称">
<el-input v-model="editDialog.form.name" maxlength="30" show-word-limit />
</el-form-item>
<!-- <el-form-item label="图片路径">
<el-input v-model="editDialog.form.image" />
</el-form-item> -->
<el-form-item label="状态" class="align-like-input">
<el-radio-group v-model="editDialog.form.state">
<el-radio :label="0">上架</el-radio>
<el-radio :label="1">下架</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="描述">
<el-input type="textarea" :rows="4" v-model="editDialog.form.description" maxlength="100" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="editDialog.saving" @click="handleSaveEdit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
/**
* 商品列表管理页(账户侧)
* - 表格展示,通过 getProductList 获取数据
* - 支持查看详情、修改、删除
*/
import { getProductList, updateProduct, deleteProduct, getMachineInfo} from '../../api/products'
export default {
name: 'AccountProducts',
data() {
return {
loading: false,
searchKeyword: '',
tableData: [],
pagination: {
pageNum: 1,
pageSize: 10,
total: 0,
},
coinOptions: [],
editDialog: {
visible: false,
saving: false,
form: null,
},
total: 0,
userEmail:"",
}
},
created() {
this.initOptions()
this.fetchTableData()
},
methods: {
/** 初始化筛选选项 */
initOptions() {
try {
// 动态导入,避免打包时循环引用
const { coinList } = require('../../utils/coinList')
this.coinOptions = Array.isArray(coinList) ? coinList : []
} catch (e) {
this.coinOptions = []
}
},
async fetchMachineInfo(params) {
const res = await getMachineInfo(params)
if (res && (res.code === 0 || res.code === 200)) {
this.editDialog.form = res.data
console.log(res.data,'res.data');
}
},
/**
* 获取商品表格数据
*/
async fetchTableData() {
this.loading = true
try {
// 按“单一关键字”搜索:若关键字命中币种,则只传 coin否则只传 algorithm
const keyword = (this.searchKeyword || '').trim()
let coinParam
let algorithmParam
if (keyword) {
const lower = keyword.toLowerCase()
const hitCoin = (this.coinOptions || []).some(c =>
(c.value && String(c.value).toLowerCase() === lower) ||
(c.label && String(c.label).toLowerCase() === lower)
)
if (hitCoin) {
coinParam = keyword
} else {
algorithmParam = keyword
}
}
try {
this.userEmail=JSON.parse(localStorage.getItem('userEmail'))
} catch (error) {
console.log(error);
}
const params = {
pageNum: this.pagination.pageNum,
pageSize: this.pagination.pageSize,
coin: coinParam || undefined,
algorithm: algorithmParam || undefined,
userEmail: this.userEmail
}
const res = await getProductList(params)
// 兼容不同返回结构
const list = res?.data?.records || res?.data?.list || res?.rows || res?.list || res?.data || []
this.tableData = Array.isArray(list) ? list : []
this.total= res.total
console.log(this.tableData)
} catch (error) {
console.error('获取商品列表失败', error)
console.log('获取商品列表失败')
} finally {
this.loading = false
}
},
/** 搜索 */
handleSearch() {
this.pagination.pageNum = 1
this.fetchTableData()
},
/** 重置 */
handleReset() {
this.searchKeyword = ''
this.pagination.pageNum = 1
this.pagination.pageSize = 10
this.fetchTableData()
},
/** 查看详情 */
handleView(row) {
if (!row || !row.id) {
this.$message({
message: '缺少商品ID',
type: 'warning',
showClose: true
})
return
}
this.$router.push(`/account/product-detail/${row.id}`)
},
/** 编辑 */
handleEdit(row) {
this.editDialog.form = { ...row }
this.editDialog.visible = true
},
/** 保存编辑 */
async handleSaveEdit() {
if (!this.editDialog.form) return
this.editDialog.saving = true
try {
// 简单内容校验:不能为空且不能全是空格(不影响其它逻辑)
const notEmpty = (v) => typeof v === 'string' && v.trim().length > 0
const name = String(this.editDialog.form.name || '')
const description = String(this.editDialog.form.description || '')
if (!notEmpty(name)) {
this.$message.warning('名称不能为空或全是空格')
this.editDialog.saving = false
return
}
if (!notEmpty(description)) {
this.$message.warning('描述不能为空或全是空格')
this.editDialog.saving = false
return
}
// 去除首尾空格后再提交
this.editDialog.form.name = name.trim()
this.editDialog.form.description = description.trim()
// 仅提交指定字段
const payload = {
id: this.editDialog.form.id,
shopId: this.editDialog.form.shopId,
name: this.editDialog.form.name,
image: this.editDialog.form.image,
coin: this.editDialog.form.coin,
description: this.editDialog.form.description,
state: this.editDialog.form.state,
}
const res = await updateProduct(payload)
if (res && (res.code === 0 || res.code === 200)) {
this.$message({
message: '保存成功',
type: 'success',
showClose: true
})
this.editDialog.visible = false
this.fetchTableData()
} else {
this.$message({
message: res?.msg || '保存失败',
type: 'error',
showClose: true
})
}
} catch (error) {
console.error('保存商品失败', error)
console.log('保存失败')
} finally {
this.editDialog.saving = false
}
},
/** 删除 */
handleDelete(row) {
if (!row || !row.id) return
this.$confirm('确认删除该商品吗?删除后不可恢复', '提示', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteProduct(row.id)
if (res && (res.code === 0 || res.code === 200)) {
this.$message.success('删除成功')
// 若当前页仅一条且删除后为空,回退一页
if (this.tableData.length === 1 && this.pagination.pageNum > 1) {
this.pagination.pageNum -= 1
}
this.fetchTableData()
}
} catch (error) {
console.error('删除商品失败', error)
console.log('删除失败')
}
}).catch(() => {})
},
/** 分页 - 每页条数变化 */
handleSizeChange(size) {
this.pagination.pageSize = size
this.pagination.pageNum = 1
this.fetchTableData()
},
/** 分页 - 当前页变化 */
handleCurrentChange(page) {
this.pagination.pageNum = page
this.fetchTableData()
},
/** 清空搜索框时恢复默认列表 */
handleClear() {
this.searchKeyword = ''
this.pagination.pageNum = 1
this.fetchTableData()
},
/** 跳转添加出售机器 */
handleAddMachine(row) {
if (!row || !row.id) {
this.$message.warning('缺少商品ID')
return
}
this.$router.push({ path: '/account/product-machine-add', query: { productId: row.id, coin: row.coin, name: row.name } })
}
}
}
</script>
<style scoped>
.account-products {
padding: 4px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.left-area {
display: flex;
align-items: center;
}
.right-area {
display: flex;
align-items: center;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.mr-12 { margin-right: 12px; }
.ml-8 { margin-left: 8px; }
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
/* 编辑弹窗内:让下拉/单选与输入框左边缘对齐 */
.edit-form .align-like-input .el-form-item__content {
padding-left: 12px; /* 对齐到上方 el-input 的内边距视觉效果 */
}
::v-deep .el-form-item__content{
text-align: left;
}
</style>

View File

@@ -0,0 +1,234 @@
<template>
<div class="account-purchased">
<div class="toolbar">
<div class="left-area">
<h2 class="page-title">已购商品</h2>
</div>
<!-- <div class="right-area">
<el-input
v-model="searchKeyword"
placeholder="输入币种或算法后搜索(可留空)"
size="small"
clearable
class="mr-12"
style="width: 280px"
@keyup.enter.native="handleSearch"
@clear="handleClear"
/>
<el-button type="primary" size="small" @click="handleSearch"
>搜索</el-button
>
<el-button size="small" class="ml-8" @click="handleReset"
>重置</el-button
>
</div> -->
</div>
<el-table
:data="tableData"
v-loading="loading"
border
stripe
style="width: 100%"
>
<el-table-column prop="userId" label="用户" width="180" />
<el-table-column prop="productMachineId" label="机器ID" width="80" />
<!-- <el-table-column
prop="purchasedComputingPower"
label="购买算力"
min-width="120"
/> -->
<el-table-column prop="type" label="类型" width="100">
<template #default="scope">
<el-tag :type="scope.row.type === 1 ? 'success' : 'info'">
{{ scope.row.type === 1 ? "算力套餐" : "挖矿机器" }}
</el-tag>
</template>
</el-table-column>
<!-- <el-table-column prop="currentIncome" label="当前收入" min-width="120" />
<el-table-column
prop="currentUsdtIncome"
label="当前USDT收入"
min-width="140"
/> -->
<el-table-column
prop="estimatedEndIncome"
label="预计总收益"
min-width="120"
/>
<el-table-column
prop="estimatedEndUsdtIncome"
label="预计USDT总收益"
min-width="160"
/>
<el-table-column prop="startTime" label="开始时间" min-width="160" >
<template #default="scope">
<span>{{ formatDateTime(scope.row.startTime) }}</span>
</template>
</el-table-column>
<el-table-column prop="endTime" label="结束时间" min-width="160" >
<template #default="scope">
<span>{{ formatDateTime(scope.row.endTime) }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.status === 0 ? 'success' : 'info'">
{{ scope.row.status === 0 ? "运行中" : "已过期" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="120">
<template #default="scope">
<el-button type="text" size="small" @click="handleView(scope.row)"
>详情</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:current-page.sync="pagination.pageNum"
:page-sizes="[10, 20, 50, 100]"
:page-size.sync="pagination.pageSize"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script>
import { getOwnedList } from "../../api/products";
import { coinList } from "../../utils/coinList";
export default {
name: "AccountPurchased",
data() {
return {
loading: false,
searchKeyword: "",
tableData: [],
pagination: {
pageNum: 1,
pageSize: 10,
},
coins: coinList || [],
total:0,
currentPage:1,
};
},
created() {
this.fetchTableData(this.pagination);
},
methods: {
async fetchTableData(params) {
this.loading = true;
try {
const res = await getOwnedList(params);
if (res && (res.code === 0 || res.code === 200)) {
this.tableData = res.rows;
this.total = res.total;
}
} catch (e) {
console.error("获取已购商品失败", e);
} finally {
this.loading = false;
}
},
handleSearch() {
this.pagination.pageNum = 1;
this.fetchTableData();
},
handleReset() {
this.searchKeyword = "";
this.pagination.pageNum = 1;
this.pagination.pageSize = 10;
this.fetchTableData(this.pagination);
},
handleClear() {
this.searchKeyword = "";
this.pagination.pageNum = 1;
this.fetchTableData(this.pagination);
},
handleSizeChange(size) {
this.pagination.pageSize = size;
this.pagination.pageNum = 1;
this.fetchTableData(this.pagination);
},
handleCurrentChange(page) {
this.pagination.pageNum = page;
this.fetchTableData(this.pagination);
},
handleView(row) {
// 跳转到已购商品详情页面
this.$router.push({
name: 'PurchasedDetail',
params: { id: row.id }
})
},
formatDateTime(value) {
if (!value) return '—'
try {
const str = String(value)
return str.includes('T') ? str.replace('T', ' ') : str
} catch (e) {
return String(value)
}
}
},
};
</script>
<style scoped>
.account-purchased {
padding: 4px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.left-area {
display: flex;
align-items: center;
}
.right-area {
display: flex;
align-items: center;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.mr-12 {
margin-right: 12px;
}
.ml-8 {
margin-left: 8px;
}
.thumb {
width: 72px;
height: 48px;
object-fit: cover;
border-radius: 4px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,217 @@
<template>
<div class="purchased-detail-page">
<h2 class="title">已购商品详情</h2>
<div v-if="loading" class="loading">加载中...</div>
<div v-else>
<!-- 基本信息 -->
<el-card class="section">
<div class="sub-title">基本信息</div>
<div class="row">
<span class="label">用户</span>
<span class="value mono">{{ detail.userId || '—' }}</span>
</div>
<div class="row">
<span class="label">订单项ID</span>
<span class="value mono">{{ detail.orderItemId || '—' }}</span>
</div>
<div class="row">
<span class="label">机器ID</span>
<span class="value mono">{{ detail.productMachineId || '—' }}</span>
</div>
<div class="row">
<span class="label">商品类型</span>
<span class="value">
<el-tag :type="detail.type === 1 ? 'success' : 'info'">
{{ detail.type === 1 ? "算力套餐" : "挖矿机器" }}
</el-tag>
</span>
</div>
<div class="row">
<span class="label">状态</span>
<span class="value">
<el-tag :type="detail.status === 0 ? 'success' : 'info'">
{{ detail.status === 0 ? "运行中" : "已过期" }}
</el-tag>
</span>
</div>
<div class="row" v-show="detail.type === 1">
<span class="label" >购买算力</span>
<span class="value strong">{{ detail.purchasedComputingPower }}</span>
</div>
<div class="row">
<span class="label">购买时间</span>
<span class="value">{{ formatDateTime(detail.createTime) }}</span>
</div>
<div class="row">
<span class="label">开始时间</span>
<span class="value">{{ formatDateTime(detail.startTime) }}</span>
</div>
<div class="row">
<span class="label">结束时间</span>
<span class="value">{{ formatDateTime(detail.endTime) }}</span>
</div>
</el-card>
<!-- 收益信息 -->
<el-card class="section" style="margin-top: 12px;">
<div class="sub-title">收益信息</div>
<div class="row">
<span class="label">当前实时算力</span>
<span class="value strong">{{ detail.currentComputingPower || '0' }}</span>
</div>
<div class="row">
<span class="label">币种收益</span>
<span class="value strong">{{ detail.currentIncome || '0' }}</span>
</div>
<div class="row">
<span class="label">当前USDT收益</span>
<span class="value strong">{{ detail.currentUsdtIncome || '0' }} USDT</span>
</div>
<div class="row">
<span class="label">预估结束总收益</span>
<span class="value strong">{{ detail.estimatedEndIncome || '0' }}</span>
</div>
<div class="row">
<span class="label">预估结束USDT总收益</span>
<span class="value strong">{{ detail.estimatedEndUsdtIncome || '0' }} USDT</span>
</div>
</el-card>
<!-- 操作按钮 -->
<div class="actions">
<el-button @click="$router.back()">返回</el-button>
</div>
</div>
</div>
</template>
<script>
import { getOwnedList,getOwnedById } from '../../api/products'
export default {
name: 'PurchasedDetail',
data() {
return {
loading: false,
detail: {}
}
},
created() {
this.load()
},
methods: {
async load() {
const paramsId = this.$route.params.id
if (!paramsId) {
this.$message({
message: '订单项ID缺失,请重新进入',
type: 'error',
showClose: true
})
return
}
try {
this.loading = true
const res = await getOwnedById({id:paramsId})
if (res && (res.code === 0 || res.code === 200)) {
// 从列表中查找对应的订单项
// const items = res.rows || []
// const targetItem = items.find(item => item.orderItemId == orderItemId)
this.detail = res.data
// if (targetItem) {
// this.detail = targetItem
// } else {
// this.$message({
// message: '未找到对应的商品信息',
// type: 'error',
// showClose: true
// })
// }
}
} catch (e) {
console.error('获取已购商品详情失败', e)
} finally {
this.loading = false
}
},
formatDateTime(value) {
if (!value) return '—'
try {
const str = String(value)
return str.includes('T') ? str.replace('T', ' ') : str
} catch (e) {
return String(value)
}
}
}
}
</script>
<style scoped>
.purchased-detail-page {
padding: 12px;
}
.title {
margin: 0 0 12px 0;
font-weight: 600;
color: #2c3e50;
}
.sub-title {
font-weight: 600;
margin-bottom: 12px;
color: #333;
font-size: 16px;
}
.section {
margin-bottom: 12px;
}
.row {
display: flex;
gap: 8px;
line-height: 1.8;
margin-bottom: 8px;
}
.label {
color: #666;
min-width: 170px;
font-weight: 500;
text-align: right;
/* background: palegoldenrod; */
}
.value {
color: #333;
flex: 1;
text-align: left;
margin-left: 18px;
}
.value.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
word-break: break-all;
}
.value.strong {
font-weight: 700;
color: #e74c3c;
}
.actions {
margin-top: 20px;
text-align: center;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,168 @@
<template>
<div class="panel">
<h2 class="panel-title page-title">钱包绑定</h2>
<div class="panel-body">
<el-form :model="form" label-width="120px" class="config-form">
<el-form-item label="适用商品">
<el-select v-model="form.productId" placeholder="请选择商品">
<el-option :value="0" label="全部商品" />
<el-option
v-for="p in productOptions"
:key="p.id"
:value="p.id"
:label="`${p.id} - ${p.name}`"
/>
</el-select>
</el-form-item>
<el-form-item label="收款钱包地址">
<el-input
v-model="form.payAddress"
placeholder="示例nexa:nqtsq5g50jkkmklvjyaflg46c4nwuy46z9gzswqe3l0csc7g"
/>
</el-form-item>
<el-form-item label="币种类型">
<el-radio-group v-model="form.payType" class="radio-group">
<el-radio :label="0">虚拟币</el-radio>
<el-radio :label="1">稳定币</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="支付币种">
<el-select v-model="form.payCoin" placeholder="请选择支付币种" filterable clearable>
<el-option v-for="c in coinOptions" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存配置</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script>
import { addShopConfig ,getMyShop} from '@/api/shops'
// 币种集合
const VIRTUAL_COINS = ['nexa', 'rxd', 'dgbo', 'dgbq', 'dgbs', 'alph', 'enx', 'grs', 'mona']
const STABLE_COINS = ['usdt', 'usdc', 'busd']
export default {
name: 'AccountShopConfig',
data() {
return {
VIRTUAL_COINS,
STABLE_COINS,
productOptions: [],
form: {
payAddress: 'nexa:nqtsq5g50jkkmklvjyaflg46c4nwuy46z9gzswqe3l0csc7g',
payCoin: '',
payType: 0, // 0 虚拟币 1 稳定币
productId: 0, // 0 代表所有商品
shopId: 0
},
shop: {
id: 0,
name: '',
image: '',
description: '',
del: true,
state: 0
},
}
},
mounted() {
this.fetchMyShop()
},
methods: {
async fetchMyShop() {
try {
const res = await getMyShop()
// 预期格式:{"code":0,"data":{"del":true,"description":"","id":0,"image":"","name":"","state":0},"msg":""}
if (res && (res.code === 0 || res.code === 200) && res.data) {
this.shop = {
id: res.data.id,
name: res.data.name,
image: res.data.image,
description: res.data.description,
del: !!res.data.del,
state: Number(res.data.state || 0)
}
this.form.shopId = this.shop.id
} else {
this.$message.warning(res.msg || '未获取到店铺数据')
}
} catch (error) {
console.error('获取店铺信息失败:', error)
} finally {
this.loaded = true
}
},
async addShopConfig(params) {
const res = await addShopConfig(params)
if (res && (res.code === 0 || res.code === 200)) {
this.$message.success('已保存配置(示例)')
} else {
this.$message.error(res.msg || '保存失败')
}
},
handleSave() {
this.form.shopId = this.shop.id
if (!this.form.shopId) {
this.$message.warning(`未查询到店铺信息`)
return
}
this.addShopConfig(this.form)
},
handleReset() {
this.form = { payAddress: '', payCoin: '', payType: 0, productId: 0 }
}
},
computed: {
// 根据币种类型动态展示可选币种
coinOptions() {
return this.form.payType === 1 ? STABLE_COINS : VIRTUAL_COINS
}
},
watch: {
'form.payType'(val) {
// 切换类型时清空已选币种,避免类型与币种不匹配
this.form.payCoin = ''
}
}
}
</script>
<style scoped>
.page-title { text-align: left; margin-bottom: 16px; font-size: 20px; padding-left: 4px; }
.config-form {
max-width: 720px;
margin: 0;
background: #fff;
padding: 8px 12px;
}
.config-form .el-form-item { margin-bottom: 18px; }
.config-form .el-select,
.config-form .el-input { width: 420px; }
.radio-group {
display: inline-flex;
align-items: center;
gap: 24px;
width: 420px; /* 与上方输入框等宽 */
height: 40px; /* 与输入框高度接近,视觉更整齐 */
padding-left: 12px; /* 留出与输入框一致的内边距感 */
box-sizing: border-box;
}
.tip { color: #999; font-size: 12px; margin-top: 6px; }
</style>

View File

@@ -0,0 +1,183 @@
<template>
<div class="panel">
<h2 class="panel-title">新增店铺</h2>
<div class="panel-body">
<div class="row">
<label class="label">店铺名称</label>
<el-input v-model="form.name" placeholder="请输入店铺名称" :maxlength="30" show-word-limit />
</div>
<!-- <div class="row">
<label class="label">主营类目</label>
<el-input v-model="form.category" placeholder="请输入主营类目" />
</div> -->
<div class="row">
<label class="label">店铺描述</label>
<div class="textarea-wrapper">
<el-input
type="textarea"
v-model="form.description"
:rows="4"
:maxlength="300"
placeholder="请输入店铺描述"
show-word-limit
@input="handleDescriptionInput"
/>
<!-- <div class="char-count" :class="{ 'char-limit': form.description.length >= 300 }">
{{ form.description.length }}/300
</div> -->
</div>
</div>
<div class="row">
<el-button type="primary" @click="handleCreate">创建店铺</el-button>
</div>
</div>
</div>
</template>
<script>
import { getAddShop } from "@/api/shops";
export default {
data() {
return {
form: { name: "", description: "", image: "" },
};
},
mounted() {},
methods: {
// 简单的emoji检测覆盖常见表情平面与符号范围
hasEmoji(str) {
if (!str || typeof str !== 'string') return false
const emojiRegex = /[\u{1F300}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA70}-\u{1FAFF}\u2600-\u27BF]/u
return emojiRegex.test(str)
},
async fetchAddShop() {
const res = await getAddShop(this.form);
if (res && res.code==200) {
this.$message({
message: '店铺创建成功',
type: 'success',
showClose: true
})
this.$router.push('/account/shops')
}
},
handleDescriptionInput(value) {
// 确保不超过300个字符
if (value && value.length > 300) {
this.form.description = value.substring(0, 300)
this.$message({
message: '店铺描述不能超过300个字符',
type: 'warning',
showClose: true
})
}
},
handleCreate() {
const isOnlySpaces = (v) => typeof v === 'string' && v.length > 0 && v.trim().length === 0
if (!this.form.name || isOnlySpaces(this.form.name)) {
this.$message({
message: '店铺名称不能为空或全是空格',
type: 'warning',
showClose: true
});
return;
}
if (this.hasEmoji(this.form.name)) {
this.$message({
message: '店铺名称不能包含表情符号',
type: 'warning',
showClose: true
});
return;
}
if (this.form.name && this.form.name.length > 30) {
this.$message({
message: '店铺名称不能超过30个字符',
type: 'warning',
showClose: true
});
return;
}
if (isOnlySpaces(this.form.description)) {
this.$message({
message: '店铺描述不能全是空格',
type: 'warning',
showClose: true
});
return;
}
// 检查店铺描述长度
if (this.form.description && this.form.description.length > 300) {
this.$message({
message: '店铺描述不能超过300个字符',
type: 'warning',
showClose: true
});
return;
}
// 不允许在已有店铺时新建。这里通过路由跳转来源检查或请求前端缓存判断。
// 更稳妥:可在进入本页前做守卫拦截;此处做一次兜底校验。
if (this.$route.query && this.$route.query.hasShop === '1') {
this.$message({
message: '每个用户仅允许一个店铺,无法新建',
type: 'warning',
showClose: true
});
this.$router.replace('/account/shops');
return;
}
if (!this.form.name) {
this.$message.error('店铺名称不能为空')
return
}
this.fetchAddShop(this.form)
},
},
};
</script>
<style scoped>
.panel-title {
margin: 0 0 12px 0;
font-size: 18px;
font-weight: 700;
}
.row {
display: grid;
grid-template-columns: 100px 1fr;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.label {
color: #666;
text-align: right;
}
.textarea-wrapper {
position: relative;
}
.char-count {
position: absolute;
bottom: 8px;
right: 12px;
font-size: 12px;
color: #999;
background: rgba(255, 255, 255, 0.8);
padding: 2px 6px;
border-radius: 4px;
pointer-events: none;
}
.char-count.char-limit {
color: #f56c6c;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="panel">
<h2 class="panel-title">店铺设置</h2>
<div class="panel-body">
<div class="row">
<label class="label">店铺Logo</label>
<el-input v-model="form.logo" placeholder="请输入Logo图片地址" />
</div>
<div class="row">
<label class="label">联系人</label>
<el-input v-model="form.contact" placeholder="请输入联系人" />
</div>
<div class="row">
<label class="label">联系电话</label>
<el-input v-model="form.phone" placeholder="请输入联系电话" />
</div>
<div class="row">
<el-button type="primary" @click="handleSave">保存设置</el-button>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'AccountShopSettings',
data() {
return {
form: { logo: '', contact: '', phone: '' }
}
},
methods: {
handleSave() {
this.$message.success('店铺设置已保存(示例)')
}
}
}
</script>
<style scoped>
.panel-title { margin: 0 0 12px 0; font-size: 18px; font-weight: 700; }
.row { display: grid; grid-template-columns: 100px 1fr; gap: 12px; align-items: center; margin-bottom: 12px; }
.label { color: #666; text-align: right; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,969 @@
<template>
<div class="withdrawal-history-container">
<!-- 页面标题 -->
<div class="page-header">
<h1 class="page-title">提现记录</h1>
<p class="page-subtitle">查看您的提现申请和交易状态</p>
</div>
<!-- 状态标签页 -->
<div class="tab-container">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane label="提现中" name="pending">
<div class="tab-content">
<div class="list-header">
<span class="list-title">提现中 ({{ total}})</span>
<el-button type="primary" size="small" @click="refreshData">
<i class="el-icon-refresh"></i>
刷新
</el-button>
</div>
<div class="withdrawal-list" v-loading="loading">
<div
v-for="item in pendingWithdrawals"
:key="item.id"
class="withdrawal-item pending"
@click="showDetail(item)"
>
<div class="item-main">
<div class="item-left">
<div class="amount">{{ item.amount }} {{ item.toSymbol || 'USDT' }}</div>
<div class="chain">{{ getChainName(item.toChain) }}</div>
</div>
<div class="item-right">
<div class="status pending-status">
<i class="el-icon-loading"></i>
{{ getStatusText(item.status) }}
</div>
<div class="time">{{ formatTime(item.createTime) }}</div>
</div>
</div>
<div class="item-footer">
<div class="footer-left">
<span class="address">{{ formatAddress(item.toAddress) }}</span>
<span class="tx-hash" v-if="item.txHash">
<i class="el-icon-link"></i>
{{ formatAddress(item.txHash) }}
</span>
</div>
<i class="el-icon-arrow-right"></i>
</div>
</div>
<div v-if="pendingWithdrawals.length === 0" class="empty-state">
<i class="el-icon-document"></i>
<p>暂无提现中的记录</p>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="提现成功" name="success">
<div class="tab-content">
<div class="list-header">
<span class="list-title">提现成功 ({{total }})</span>
<el-button type="primary" size="small" @click="refreshData">
<i class="el-icon-refresh"></i>
刷新
</el-button>
</div>
<div class="withdrawal-list" v-loading="loading">
<div
v-for="item in successWithdrawals"
:key="item.id"
class="withdrawal-item success"
@click="showDetail(item)"
>
<div class="item-main">
<div class="item-left">
<div class="amount">{{ item.amount }} {{ item.toSymbol || 'USDT' }}</div>
<div class="chain">{{ getChainName(item.toChain) }}</div>
</div>
<div class="item-right">
<div class="status success-status">
<i class="el-icon-check"></i>
{{ getStatusText(item.status) }}
</div>
<div class="time">{{ formatTime(item.createTime) }}</div>
</div>
</div>
<div class="item-footer">
<div class="footer-left">
<span class="address">{{ formatAddress(item.toAddress) }}</span>
<span class="tx-hash" v-if="item.txHash">
<i class="el-icon-link"></i>
{{ formatAddress(item.txHash) }}
</span>
</div>
<i class="el-icon-arrow-right"></i>
</div>
</div>
<div v-if="successWithdrawals.length === 0" class="empty-state">
<i class="el-icon-document"></i>
<p>暂无提现成功的记录</p>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="提现失败" name="failed">
<div class="tab-content">
<div class="list-header">
<span class="list-title">提现失败 ({{ total }})</span>
<el-button type="primary" size="small" @click="refreshData">
<i class="el-icon-refresh"></i>
刷新
</el-button>
</div>
<div class="withdrawal-list" v-loading="loading">
<div
v-for="item in failedWithdrawals"
:key="item.id"
class="withdrawal-item failed"
@click="showDetail(item)"
>
<div class="item-main">
<div class="item-left">
<div class="amount">{{ item.amount }} {{ item.toSymbol || 'USDT' }}</div>
<div class="chain">{{ getChainName(item.toChain) }}</div>
</div>
<div class="item-right">
<div class="status failed-status">
<i class="el-icon-close"></i>
{{ getStatusText(item.status) }}
</div>
<div class="time">{{ formatTime(item.createTime) }}</div>
</div>
</div>
<div class="item-footer">
<div class="footer-left">
<span class="address">{{ formatAddress(item.toAddress) }}</span>
<span class="tx-hash" v-if="item.txHash">
<i class="el-icon-link"></i>
{{ formatAddress(item.txHash) }}
</span>
</div>
<i class="el-icon-arrow-right"></i>
</div>
</div>
<div v-if="failedWithdrawals.length === 0" class="empty-state">
<i class="el-icon-document"></i>
<p>暂无提现失败的记录</p>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<el-row>
<el-col :span="24" style="display: flex; justify-content: center">
<el-pagination
style="margin: 0 auto; margin-top: 10px"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page.sync="currentPage"
:page-sizes="pageSizes"
:page-size="pagination.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
>
</el-pagination>
</el-col>
</el-row>
</div>
<!-- 详情弹窗 -->
<el-dialog
title="提现详情"
:visible.sync="detailDialogVisible"
width="600px"
@close="closeDetail"
>
<div v-if="selectedItem" class="detail-content">
<!-- 基本信息 -->
<div class="detail-section">
<h3 class="section-title">基本信息</h3>
<div class="detail-list">
<div class="detail-row">
<span class="detail-label">提现ID</span>
<span class="detail-value">{{ selectedItem.id }}</span>
</div>
<div class="detail-row">
<span class="detail-label">提现金额</span>
<span class="detail-value amount">{{ selectedItem.amount }} {{ selectedItem.toSymbol || 'USDT' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">区块链网络</span>
<span class="detail-value">{{ getChainName(selectedItem.toChain) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">提现状态</span>
<span class="detail-value">
<el-tag :type="getStatusType(selectedItem.status)">
{{ getStatusText(selectedItem.status) }}
</el-tag>
</span>
</div>
</div>
</div>
<!-- 地址信息 -->
<div class="detail-section">
<h3 class="section-title">地址信息</h3>
<div class="detail-list">
<div class="detail-row">
<span class="detail-label">收款地址</span>
<div class="address-container">
<span class="detail-value address">{{ selectedItem.toAddress }}</span>
<el-button
type="text"
size="small"
@click="copyAddress(selectedItem.toAddress)"
>
复制
</el-button>
</div>
</div>
<!-- v-if="selectedItem.txHash" -->
<div class="detail-row">
<span class="detail-label">交易哈希</span>
<div class="address-container">
<span class="detail-value address">{{ selectedItem.txHash }}</span>
<el-button
v-if="selectedItem.txHash"
type="text"
size="small"
@click="copyAddress(selectedItem.txHash)"
>
复制
</el-button>
<!-- <el-button
type="text"
size="small"
@click="viewOnExplorer(selectedItem.txHash, selectedItem.toChain)"
v-if="selectedItem.txHash"
>
查看
</el-button> -->
</div>
</div>
</div>
</div>
<!-- 时间信息 -->
<div class="detail-section">
<h3 class="section-title">时间信息</h3>
<div class="detail-list">
<div class="detail-row">
<span class="detail-label">提现时间</span>
<span class="detail-value">{{ formatFullTime(selectedItem.createTime) }}</span>
</div>
<div class="detail-row" v-if="selectedItem.updateTime">
<span class="detail-label">完成时间</span>
<span class="detail-value">{{ formatFullTime(selectedItem.updateTime) }}</span>
</div>
</div>
</div>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="closeDetail">关闭</el-button>
<!-- <el-button type="primary" @click="refreshData" v-if="selectedItem && selectedItem.status === 2">
刷新状态
</el-button> -->
</div>
</el-dialog>
</div>
</template>
<script>
import { balanceWithdrawList } from '../../api/wallet'
export default {
name: 'WithdrawalHistory',
data() {
return {
activeTab: 'pending',
detailDialogVisible: false,
selectedItem: null,
// 提现记录数据
withdrawalRecords: [
// {
// id: 1,
// amount: 100,
// toSymbol: 'USDT',
// toChain: 'tron',
// status: 2,
// createTime: '2024-01-15 14:30:25',
// toAddress: 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE',
// updateTime: '2024-01-15 14:30:25',
// fromAddress: 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE',
// fromChain: 'tron',
// fromSymbol: 'USDT',
// },
// {
// id: 2,
// amount: 100,
// toSymbol: 'USDT',
// toChain: 'tron',
// status: 1,
// createTime: '2024-01-15 14:30:25',
// toAddress: 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE',
// updateTime: '2024-01-15 14:30:25',
// fromAddress: 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE',
// fromChain: 'tron',
// fromSymbol: 'USDT',
// },
// {
// id: 3,
// amount: 100,
// toSymbol: 'USDT',
// toChain: 'tron',
// status: 0,
// createTime: '2024-01-15 14:30:25',
// toAddress: 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE',
// updateTime: '2024-01-15 14:30:25',
// fromAddress: 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE',
// fromChain: 'tron',
// fromSymbol: 'USDT',
// },
],
// 分页和筛选参数
pagination: {
pageNum: 1,
pageSize: 10,
totalPage: 0
},
// 加载状态
loading: false,
// 状态筛选
statusFilter: '',
total: 0,
pageSizes: [10, 20, 50],
currentPage: 1,
}
},
computed: {
/**
* 提现中的记录 (status = 2)
*/
pendingWithdrawals() {
return this.withdrawalRecords.filter(item => item.status === 2)
},
/**
* 提现成功的记录 (status = 1)
*/
successWithdrawals() {
return this.withdrawalRecords.filter(item => item.status === 1)
},
/**
* 提现失败的记录 (status = 0)
*/
failedWithdrawals() {
return this.withdrawalRecords.filter(item => item.status === 0)
}
},
mounted() {
// 设置默认选中提现中状态
this.activeTab = 'pending'
this.statusFilter = 2
this.loadWithdrawalRecords()
},
methods: {
/**
* 获取提现记录列表
* @param {Object} params - 请求参数
* @param {number} params.pageNum - 当前页码默认为1
* @param {number} params.pageSize - 每页条数默认为20
* @param {number} params.status - 记录状态0-提现失败1-提现成功2-提现中
*/
async fetchBalanceWithdrawList(params = {}) {
try {
// 设置默认参数
const requestParams = {
pageNum: 1,
pageSize: 20,
...params
}
console.log('获取提现记录参数:', requestParams)
const res = await balanceWithdrawList(requestParams)
if (res && (res.code === 0 || res.code === 200)) {
// 更新提现记录数据
this.withdrawalRecords = res.rows || []
// 更新分页信息
this.pagination.totalPage = res.totalPage || 0
this.total = res.total || 0
console.log('提现记录获取成功:', {
records: this.withdrawalRecords,
pagination: this.pagination
})
} else {
this.$message({
message: res?.msg || '获取提现记录失败',
type: 'error',
showClose: true
})
}
} catch (error) {
console.error('获取提现记录失败:', error)
}
},
/**
* 加载提现记录
*/
async loadWithdrawalRecords() {
this.loading = true
try {
const params = {
pageNum: this.pagination.pageNum,
pageSize: this.pagination.pageSize
}
// 如果选择了状态筛选,添加状态参数
if (this.statusFilter !== '') {
params.status = this.statusFilter
}
await this.fetchBalanceWithdrawList(params)
} finally {
this.loading = false
}
},
/**
* 标签页切换
*/
handleTabClick(tab) {
this.activeTab = tab.name
// 根据标签页设置状态筛选
if (tab.name === 'pending') {
this.statusFilter = 2 // 提现中
} else if (tab.name === 'success') {
this.statusFilter = 1 // 提现成功
} else if (tab.name === 'failed') {
this.statusFilter = 0 // 提现失败
}
this.currentPage = 1;
this.pagination.pageSize = 10;
// 重置分页并重新加载数据
this.pagination.pageNum = 1
this.loadWithdrawalRecords()
},
/**
* 显示详情
*/
showDetail(item) {
this.selectedItem = item
this.detailDialogVisible = true
},
/**
* 关闭详情
*/
closeDetail() {
this.detailDialogVisible = false
this.selectedItem = null
},
/**
* 获取链名称
*/
getChainName(chain) {
const chainNames = {
tron: 'Tron (TRC20)',
ethereum: 'Ethereum (ERC20)',
bsc: 'BSC (BEP20)',
polygon: 'Polygon (MATIC)'
}
return chainNames[chain] || chain
},
/**
* 获取状态类型
*/
getStatusType(status) {
const statusTypeMap = {
0: 'danger', // 提现失败
1: 'success', // 提现成功
2: 'warning' // 提现中
}
return statusTypeMap[status] || 'info'
},
/**
* 格式化地址
*/
formatAddress(address) {
if (!address) return ''
return address.length > 20 ? `${address.slice(0, 10)}...${address.slice(-10)}` : address
},
/**
* 格式化时间
*/
formatTime(timeStr) {
if (!timeStr) return ''
const date = new Date(timeStr)
const now = new Date()
const diff = now - date
if (diff < 60000) { // 1分钟内
return '刚刚'
} else if (diff < 3600000) { // 1小时内
return `${Math.floor(diff / 60000)}分钟前`
} else if (diff < 86400000) { // 1天内
return `${Math.floor(diff / 3600000)}小时前`
} else {
return date.toLocaleDateString()
}
},
/**
* 格式化完整时间
*/
formatFullTime(timeStr) {
if (!timeStr) return ''
return new Date(timeStr).toLocaleString('zh-CN')
},
/**
* 复制地址
*/
copyAddress(address) {
if (navigator.clipboard) {
navigator.clipboard.writeText(address).then(() => {
this.$message.success('地址已复制到剪贴板')
}).catch(() => {
this.fallbackCopyAddress(address)
})
} else {
this.fallbackCopyAddress(address)
}
},
/**
* 备用复制方法
*/
fallbackCopyAddress(address) {
const textArea = document.createElement('textarea')
textArea.value = address
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
this.$message.success('地址已复制到剪贴板')
} catch (err) {
this.$message.error('复制失败,请手动复制')
}
document.body.removeChild(textArea)
},
/**
* 在浏览器中查看交易
*/
viewOnExplorer(txHash, chain) {
const explorers = {
tron: `https://tronscan.org/#/transaction/${txHash}`,
ethereum: `https://etherscan.io/tx/${txHash}`,
bsc: `https://bscscan.com/tx/${txHash}`,
polygon: `https://polygonscan.com/tx/${txHash}`
}
const url = explorers[chain]
if (url) {
window.open(url, '_blank')
} else {
this.$message.error('不支持的区块链网络')
}
},
/**
* 刷新数据
*/
refreshData() {
this.loadWithdrawalRecords()
},
/**
* 格式化提现状态显示
*/
getStatusText(status) {
const statusMap = {
0: '提现失败',
1: '提现成功',
2: '提现中'
}
return statusMap[status] || '未知状态'
},
handleSizeChange(val) {
console.log(`每页 ${val}`);
this.pagination.pageSize = val;
this.pagination.pageNum = 1;
this.currentPage = 1;
this.loadWithdrawalRecords();
},
handleCurrentChange(val) {
console.log(`当前页: ${val}`);
this.pagination.pageNum = val;
this.loadWithdrawalRecords();
},
}
}
</script>
<style scoped>
/* 页面容器 */
.withdrawal-history-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
/* 页面标题 */
.page-header {
margin-bottom: 24px;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: #333;
margin: 0 0 8px 0;
}
.page-subtitle {
font-size: 14px;
color: #666;
margin: 0;
}
/* 标签页容器 */
.tab-container {
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
padding: 18px;
}
.tab-content {
padding: 20px;
}
/* 列表头部 */
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.list-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
/* 提现列表 */
.withdrawal-list {
display: flex;
flex-direction: column;
gap: 12px;
height: 400px;
overflow-y: auto;
}
.withdrawal-item {
background: #f8f9fa;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid transparent;
}
.withdrawal-item:hover {
background: #e9ecef;
border-color: #409eff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
}
.withdrawal-item.pending {
border-left: 4px solid #e6a23c;
}
.withdrawal-item.success {
border-left: 4px solid #67c23a;
}
.withdrawal-item.failed {
border-left: 4px solid #f56c6c;
}
.item-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.item-left .amount {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.item-left .chain {
font-size: 12px;
color: #666;
}
.item-right {
text-align: right;
}
.status {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.pending-status {
color: #e6a23c;
}
.success-status {
color: #67c23a;
}
.failed-status {
color: #f56c6c;
}
.time {
font-size: 12px;
color: #999;
}
.item-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-left {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.address {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: #666;
text-align: left;
}
.tx-hash {
display: flex;
align-items: center;
gap: 4px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 11px;
color: #409eff;
background: rgba(64, 158, 255, 0.1);
padding: 2px 6px;
border-radius: 4px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tx-hash i {
font-size: 10px;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 40px 20px;
color: #999;
}
.empty-state i {
font-size: 48px;
margin-bottom: 16px;
display: block;
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* 详情弹窗样式 */
.detail-content {
max-height: 500px;
overflow-y: auto;
}
.detail-section {
margin-bottom: 24px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-row {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
font-size: 14px;
color: #666;
font-weight: 500;
min-width: 80px;
flex-shrink: 0;
text-align: right;
}
.detail-value {
font-size: 14px;
color: #333;
flex: 1;
word-break: break-all;
text-align: left;
}
.detail-value.amount {
font-weight: 600;
font-family: 'Monaco', 'Menlo', monospace;
color: #e74c3c;
}
.detail-value.address {
font-family: 'Monaco', 'Menlo', monospace;
word-break: break-all;
}
.address-container {
display: flex;
align-items: center;
gap: 8px;
}
.address-container .detail-value {
flex: 1;
word-break: break-all;
}
/* 响应式设计 */
@media (max-width: 768px) {
.withdrawal-history-container {
padding: 16px;
}
.page-title {
font-size: 24px;
}
.detail-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.detail-label {
min-width: auto;
}
.item-main {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.item-right {
text-align: left;
}
}
/* 滚动条样式 */
.detail-content::-webkit-scrollbar {
width: 6px;
}
.detail-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.detail-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.detail-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,600 @@
<template>
<div class="checkout-page">
<h1 class="page-title">订单结算</h1>
<div v-if="loading" class="loading">
<el-loading-spinner></el-loading-spinner>
加载中...
</div>
<div v-else-if="cartItems.length === 0" class="empty-cart">
<div class="empty-icon">🛒</div>
<h2>购物车是空的</h2>
<p>请先添加商品到购物车</p>
<router-link to="/productList" class="shop-now-btn">
去购物
</router-link>
</div>
<div v-else class="checkout-content">
<!-- 订单摘要 -->
<div class="order-summary">
<h2 class="section-title">订单摘要</h2>
<div class="order-items">
<div
v-for="item in cartItems"
:key="item.id"
class="order-item"
>
<div class="item-image">
<img :src="item.image" :alt="item.title" />
</div>
<div class="item-info">
<h3 class="item-title">{{ item.title }}</h3>
<div class="item-price">¥{{ item.price }}</div>
</div>
<div class="item-quantity">
<span class="quantity-label">数量</span>
<span class="quantity-value">{{ item.quantity }}</span>
</div>
<div class="item-total">
<span class="total-label">小计</span>
<span class="total-price">¥{{ (item.price * item.quantity).toFixed(2) }}</span>
</div>
</div>
</div>
<div class="order-total">
<div class="total-row">
<span>商品总数</span>
<span>{{ summary.totalQuantity }} </span>
</div>
<div class="total-row">
<span>商品种类</span>
<span>{{ cartItems.length }} </span>
</div>
<div class="total-row final-total">
<span>订单总计</span>
<span class="final-amount">¥{{ summary.totalPrice.toFixed(2) }}</span>
</div>
</div>
</div>
<!-- 结算表单 -->
<div class="checkout-form">
<h2 class="section-title">收货信息</h2>
<form @submit.prevent="handleSubmit" class="form">
<div class="form-row">
<div class="form-group">
<label for="name" class="form-label">收货人姓名 *</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-input"
required
placeholder="请输入收货人姓名"
aria-describedby="name-error"
/>
<div v-if="errors.name" id="name-error" class="error-message">
{{ errors.name }}
</div>
</div>
<div class="form-group">
<label for="phone" class="form-label">联系电话 *</label>
<input
id="phone"
v-model="form.phone"
type="tel"
class="form-input"
required
placeholder="请输入联系电话"
aria-describedby="phone-error"
/>
<div v-if="errors.phone" id="phone-error" class="error-message">
{{ errors.phone }}
</div>
</div>
</div>
<div class="form-group">
<label for="address" class="form-label">收货地址 *</label>
<textarea
id="address"
v-model="form.address"
class="form-textarea"
rows="3"
required
placeholder="请输入详细收货地址"
aria-describedby="address-error"
></textarea>
<div v-if="errors.address" id="address-error" class="error-message">
{{ errors.address }}
</div>
</div>
<div class="form-group">
<label for="note" class="form-label">备注</label>
<textarea
id="note"
v-model="form.note"
class="form-textarea"
rows="2"
placeholder="可选:订单备注信息"
></textarea>
</div>
<div class="form-actions">
<router-link to="/cart" class="back-btn">
返回购物车
</router-link>
<button
type="submit"
class="submit-btn"
:disabled="submitting"
aria-label="提交订单"
>
<span v-if="submitting">提交中...</span>
<span v-else>提交订单</span>
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script>
import { readCart, clearCart, computeSummary } from '../../utils/cartManager'
export default {
name: 'Checkout',
data() {
return {
cartItems: [],
loading: false,
submitting: false,
form: {
name: '',
phone: '',
address: '',
note: ''
},
errors: {}
}
},
computed: {
summary() {
return computeSummary()
}
},
mounted() {
this.loadCart()
},
methods: {
/**
* 加载购物车数据
*/
loadCart() {
try {
this.loading = true
this.cartItems = readCart()
if (this.cartItems.length === 0) {
this.$message.warning('购物车为空,请先添加商品')
}
} catch (error) {
console.error('加载购物车失败:', error)
console.log('加载购物车失败,请稍后重试')
} finally {
this.loading = false
}
},
/**
* 验证表单
*/
validateForm() {
this.errors = {}
if (!this.form.name.trim()) {
this.errors.name = '请输入收货人姓名'
}
if (!this.form.phone.trim()) {
this.errors.phone = '请输入联系电话'
} else if (!/^1[3-9]\d{9}$/.test(this.form.phone.trim())) {
this.errors.phone = '请输入正确的手机号码'
}
if (!this.form.address.trim()) {
this.errors.address = '请输入收货地址'
}
return Object.keys(this.errors).length === 0
},
/**
* 处理表单提交
*/
async handleSubmit() {
if (!this.validateForm()) {
this.$message.error('请完善收货信息')
return
}
try {
this.submitting = true
// 模拟订单提交
await new Promise(resolve => setTimeout(resolve, 2000))
// 构建订单数据
const order = {
id: `ORDER_${Date.now()}`,
items: this.cartItems,
total: this.summary.totalPrice,
customer: {
name: this.form.name,
phone: this.form.phone,
address: this.form.address,
note: this.form.note
},
createTime: new Date().toISOString()
}
console.log('订单提交成功:', order)
// 清空购物车
clearCart()
// 显示成功消息
this.$message.success('订单提交成功!')
// 跳转到成功页面或商品列表
setTimeout(() => {
this.$router.push('/productList')
}, 1500)
} catch (error) {
console.error('提交订单失败:', error)
console.log('提交订单失败,请稍后重试')
} finally {
this.submitting = false
}
}
}
}
</script>
<style scoped>
.checkout-page {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.page-title {
text-align: center;
color: #2c3e50;
margin-bottom: 30px;
font-size: 28px;
font-weight: 600;
}
.loading {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-cart {
text-align: center;
padding: 80px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.empty-icon {
font-size: 64px;
margin-bottom: 20px;
}
.empty-cart h2 {
color: #2c3e50;
margin-bottom: 12px;
font-size: 24px;
}
.empty-cart p {
color: #666;
margin-bottom: 24px;
font-size: 16px;
}
.shop-now-btn {
display: inline-block;
background: #42b983;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
transition: background 0.3s ease;
}
.shop-now-btn:hover {
background: #3aa876;
}
.checkout-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
}
.section-title {
font-size: 20px;
font-weight: 600;
color: #2c3e50;
margin: 0 0 20px 0;
padding-bottom: 12px;
border-bottom: 2px solid #eee;
}
/* 订单摘要样式 */
.order-summary {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 24px;
height: fit-content;
}
.order-items {
margin-bottom: 24px;
}
.order-item {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 16px;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #eee;
}
.order-item:last-child {
border-bottom: none;
}
.item-image img {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 6px;
}
.item-title {
font-size: 14px;
font-weight: 600;
color: #2c3e50;
margin: 0 0 4px 0;
}
.item-price {
font-size: 16px;
font-weight: 700;
color: #e74c3c;
}
.item-quantity {
text-align: center;
}
.quantity-label {
font-size: 12px;
color: #666;
}
.quantity-value {
font-size: 14px;
font-weight: 600;
color: #2c3e50;
}
.item-total {
text-align: right;
}
.total-label {
font-size: 12px;
color: #666;
}
.total-price {
font-size: 16px;
font-weight: 700;
color: #e74c3c;
}
.order-total {
border-top: 2px solid #eee;
padding-top: 20px;
}
.total-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 14px;
color: #666;
}
.final-total {
font-size: 18px;
font-weight: 600;
color: #2c3e50;
border-top: 1px solid #eee;
padding-top: 16px;
margin-top: 16px;
}
.final-amount {
color: #e74c3c;
font-size: 24px;
}
/* 结算表单样式 */
.checkout-form {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 24px;
}
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
font-size: 14px;
font-weight: 600;
color: #2c3e50;
}
.form-input,
.form-textarea {
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #42b983;
box-shadow: 0 0 0 3px rgba(66, 185, 131, 0.1);
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
.error-message {
color: #e74c3c;
font-size: 12px;
margin-top: 4px;
}
.form-actions {
display: flex;
gap: 16px;
margin-top: 20px;
}
.back-btn {
background: #6c757d;
color: white;
text-decoration: none;
padding: 14px 24px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
transition: background 0.3s ease;
text-align: center;
flex: 1;
}
.back-btn:hover {
background: #5a6268;
}
.submit-btn {
background: #42b983;
color: white;
border: none;
padding: 14px 24px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
flex: 2;
}
.submit-btn:hover:not(:disabled) {
background: #3aa876;
transform: translateY(-2px);
}
.submit-btn:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
/* 响应式设计 */
@media (max-width: 768px) {
.checkout-content {
grid-template-columns: 1fr;
gap: 20px;
}
.form-row {
grid-template-columns: 1fr;
gap: 16px;
}
.form-actions {
flex-direction: column;
}
.checkout-page {
padding: 16px;
}
.page-title {
font-size: 24px;
margin-bottom: 24px;
}
.order-item {
grid-template-columns: 1fr;
gap: 12px;
text-align: center;
}
.item-image img {
width: 80px;
height: 80px;
}
}
</style>

View File

@@ -0,0 +1,580 @@
import { getProductById } from '../../utils/productService'
import { addToCart } from '../../utils/cartManager'
import { getMachineInfo } from '../../api/products'
import { addCart, getGoodsList } from '../../api/shoppingCart'
export default {
name: 'ProductDetail',
data() {
return {
product: null,
loading: false,
// 默认展开的行keys
expandedRowKeys: [],
selectedMap: {},
params: {
id: "",
},
confirmAddDialog: {
visible: false,
items: []
},
// 购物车中已存在的当前商品机器集合id 与 user|miner 组合键
cartMachineIdSet: new Set(),
cartCompositeKeySet: new Set(),
cartLoaded: false,
machinesLoaded: false,
/**
* 可展开的产品系列数据
* 每个系列(group)包含多个可选条目(variants)
*/
productListData: [
// {
// id: 'grp-1',
// group: 'A系列',
// summary: {
// theoryPower: '56T',
// computingPower: '54T',
// powerDissipation: '3200W',
// algorithm: 'power',
// type: 'A-Pro',
// count: 3,
// price: '¥1000+'
// },
// variants: [
// { id: 'A-1', model: 'A1', theoryPower: '14T', computingPower: '13.5T', powerDissipation: '780W', algorithm: 'power', stock: 50, price: 999, quantity: 1 },
// { id: 'A-2', model: 'A2', theoryPower: '18T', computingPower: '17.2T', powerDissipation: '900W', algorithm: 'power', stock: 40, price: 1299, quantity: 1 },
// { id: 'A-3', model: 'A3', theoryPower: '24T', computingPower: '23.1T', powerDissipation: '1520W', algorithm: 'power', stock: 30, price: 1699, quantity: 1 }
// ]
// },
// {
// id: 'grp-2',
// group: 'B系列',
// summary: {
// theoryPower: '72T',
// computingPower: '70T',
// powerDissipation: '4100W',
// algorithm: 'power',
// type: 'B-Max',
// count: 2,
// price: '¥2000+'
// },
// variants: [
// { id: 'B-1', model: 'B1', theoryPower: '32T', computingPower: '31.2T', powerDissipation: '1800W', algorithm: 'power', stock: 28, price: 2199, quantity: 1 },
// { id: 'B-2', model: 'B2', theoryPower: '40T', computingPower: '38.8T', powerDissipation: '2300W', algorithm: 'power', stock: 18, price: 2699, quantity: 1 }
// ]
// }
],
tableData: [
// {
// theoryPower: "55656",//理论算力
// computingPower: "44545",//实际算力
// powerDissipation: "5565",//功耗
// algorithm: "power",//算法
// type: "型号1",//矿机型号
// number:2001,
// cost:"1000",//价格
// },
// {
// theoryPower: "55656",//理论算力
// computingPower: "44545",//实际算力
// powerDissipation: "5565",//功耗
// algorithm: "power",//算法
// type: "型号1",//矿机型号
// number:2001,
// cost:"1000",//价格
// },
// {
// theoryPower: "55656",//理论算力
// computingPower: "44545",//实际算力
// powerDissipation: "5565",//功耗
// algorithm: "power",//算法
// type: "型号1",//矿机型号
// number:2001,
// cost:"1000",//价格
// },
// {
// theoryPower: "55656",//理论算力
// computingPower: "44545",//实际算力
// powerDissipation: "5565",//功耗
// algorithm: "power",//算法
// type: "型号1",//矿机型号
// number:2001,
// cost:"1000",//价格
// },
],
productDetailLoading:false
}
},
mounted() {
console.log(this.$route.params.id, "i叫哦附加费")
if (this.$route.params.id) {
this.params.id = this.$route.params.id
this.product = true
// 默认展开第一行
if (this.productListData && this.productListData.length) {
this.expandedRowKeys = [this.productListData[0].id]
}
this.fetchGetMachineInfo(this.params)
} else {
this.$message.error('商品不存在')
this.product = false
}
this.fetchGetGoodsList()
},
methods: {
async fetchGetMachineInfo(params) {
this.productDetailLoading = true
const res = await getMachineInfo(params)
console.log(res)
if (res && res.code === 200) {
console.log(res.data, 'res.rows');
const list = Array.isArray(res.data) ? res.data : []
const withKeys = list.map((group, idx) => {
const fallbackId = `grp-${idx}`
const groupId = group.id || group.onlyKey || (group.productMachineRangeGroupDto && group.productMachineRangeGroupDto.id)
const firstMachineId = Array.isArray(group.productMachines) && group.productMachines.length > 0 ? group.productMachines[0].id : undefined
// 为机器行设置默认租赁天数为1并确保未选中状态
const normalizedMachines = Array.isArray(group.productMachines)
? group.productMachines.map(m => ({
...m,
leaseTime: (m && m.leaseTime && Number(m.leaseTime) > 0) ? Number(m.leaseTime) : 1,
_selected: false // 确保所有机器行初始状态为未选中
}))
: []
return { ...group, id: groupId || (firstMachineId ? `m-${firstMachineId}` : fallbackId), productMachines: normalizedMachines }
})
this.productListData = withKeys
if (this.productListData.length && (!this.expandedRowKeys || !this.expandedRowKeys.length)) {
this.expandedRowKeys = [this.productListData[0].id]
}
// 产品机器加载完成后,依据购物车集合执行一次本地禁用与勾选
this.$nextTick(() => {
this.machinesLoaded = true
// 已取消与购物车对比:不再自动禁用或勾选
})
}
this.productDetailLoading = false
},
/**
* 加载商品详情
*/
async loadProduct() {
try {
this.loading = true
const productId = this.$route.params.id
this.product = await getProductById(productId)
if (!this.product) {
this.$message({
message: '商品不存在',
type: 'error',
showClose: true
})
}
} catch (error) {
console.error('加载商品详情失败:', error)
this.$message({
message: '加载商品详情失败,请稍后重试',
type: 'error',
showClose: true
})
} finally {
this.loading = false
}
},
//加入购物车
async fetchAddCart(params) {
const res = await addCart(params)
return res
},
//查询购物车列表
async fetchGetGoodsList(params) {
const res = await getGoodsList(params)
// 统计当前商品在购物车中已有的机器ID用于禁用和默认勾选
try {
const productId = this.params && this.params.id ? Number(this.params.id) : Number(this.$route.params.id)
// 兼容两种返回结构1) 旧:直接是商品分组数组 2) 新:店铺数组 → shoppingCartInfoDtoList
const rawRows = Array.isArray(res && res.rows)
? res.rows
: Array.isArray(res && res.data && res.data.rows)
? res.data.rows
: Array.isArray(res && res.data)
? res.data
: []
// 扁平化为商品分组
const groups = rawRows.length && rawRows[0] && Array.isArray(rawRows[0].shoppingCartInfoDtoList)
? rawRows.flatMap(shop => Array.isArray(shop.shoppingCartInfoDtoList) ? shop.shoppingCartInfoDtoList : [])
: rawRows
const matched = groups.filter(g => Number(g.productId) === productId)
const ids = new Set()
const compositeKeys = new Set()
matched.forEach(r => {
const list = Array.isArray(r.productMachineDtoList) ? r.productMachineDtoList : []
list.forEach(m => {
if (!m) return
if (m.id !== undefined && m.id !== null) ids.add(String(m.id))
if (m.user && m.miner) compositeKeys.add(`${String(m.user)}|${String(m.miner)}`)
})
})
this.cartMachineIdSet = ids
this.cartCompositeKeySet = compositeKeys
// 计算购物车总数量并通知头部避免页面初次加载时徽标显示为0
try {
const totalCount = groups.reduce((sum, g) => sum + (Array.isArray(g && g.productMachineDtoList) ? g.productMachineDtoList.length : 0), 0)
if (Number.isFinite(totalCount)) {
window.dispatchEvent(new CustomEvent('cart-updated', { detail: { count: totalCount } }))
}
} catch (e) { /* noop */ }
// 展开表格渲染后,默认勾选并禁用这些行
this.$nextTick(() => {
this.cartLoaded = true
this.autoSelectAndDisable()
})
} catch (e) {
console.warn('解析购物车数据失败', e)
}
},
/**
* 处理返回
*/
handleBack() {
this.$router.push('/productList')
},
/**
* 点击系列行:切换展开/收起
* @param {Object} row - 当前行
*/
handleSeriesRowClick(row) {
const key = row.id
const lockedIds = Object.keys(this.selectedMap).filter(k => (this.selectedMap[k] || []).length > 0)
const opened = this.expandedRowKeys.includes(key)
if (opened) {
// 关闭当前行,仅保留已勾选的行展开
this.expandedRowKeys = lockedIds
} else {
// 打开当前行,同时保留已勾选的行展开
this.expandedRowKeys = Array.from(new Set([key, ...lockedIds]))
}
},
/**
* 外层系列行样式
*/
handleGetSeriesRowClassName() {
return 'series-clickable-row'
},
// 子表选择变化
handleInnerSelectionChange(parentRow, selections) {
const key = parentRow.id
this.$set(this.selectedMap, key, selections)
const lockedIds = Object.keys(this.selectedMap).filter(k => (this.selectedMap[k] || []).length > 0)
// 更新展开:锁定的行始终展开
const openedSet = new Set(this.expandedRowKeys)
lockedIds.forEach(id => openedSet.add(id))
// 清理不再勾选且不是当前展开的行
this.expandedRowKeys = Array.from(openedSet).filter(id => lockedIds.includes(id) || id === key || this.expandedRowKeys.includes(id))
},
// 展开行变化时:已取消自动与购物车对比,无需勾选/禁用
handleExpandChange(row, expandedRows) {
// no-op
},
// 已取消对比购物车的自动勾选/禁用逻辑
autoSelectAndDisable() {},
// 选择器可选控制:已在购物车中的机器不可再选
isSelectable(row, index) {
// 不再通过 selectable 禁用,以便勾选可见;通过行样式和交互阻止点击
return true
},
// 判断在特定父行下是否已选择配合自定义checkbox使用
isSelectedByParent(parentRow, row) {
const key = parentRow && parentRow.id
const list = (key && this.selectedMap[key]) || []
return !!list.find(it => it && it.id === row.id)
},
// 手动切换选择自定义checkbox与 selectedMap 同步),并维护每行的 _selected 状态
handleManualSelect(parentRow, row, checked) {
const key = parentRow.id
const list = (this.selectedMap[key] && [...this.selectedMap[key]]) || []
const idx = list.findIndex(it => it && it.id === row.id)
if (checked && idx === -1) list.push(row)
if (!checked && idx > -1) list.splice(idx, 1)
this.$set(this.selectedMap, key, list)
this.$set(row, '_selected', !!checked)
},
// 为子表中已在购物车的行添加只读样式,并阻止点击取消
getInnerRowClass() {
return ''
},
/**
* 子行:减少数量
* @param {number} groupIndex - 系列索引
* @param {number} variantIndex - 变体索引
*/
handleDecreaseVariantQuantity(groupIndex, variantIndex) {
const item = this.productListData[groupIndex].variants[variantIndex]
if (item.quantity > 1) {
item.quantity--
}
},
/**
* 子行:增加数量
* @param {number} groupIndex - 系列索引
* @param {number} variantIndex - 变体索引
*/
handleIncreaseVariantQuantity(groupIndex, variantIndex) {
const item = this.productListData[groupIndex].variants[variantIndex]
if (item.quantity < 99) {
item.quantity++
}
},
/**
* 子行:输入数量校验
* @param {number} groupIndex - 系列索引
* @param {number} variantIndex - 变体索引
*/
handleVariantQuantityInput(groupIndex, variantIndex) {
const item = this.productListData[groupIndex].variants[variantIndex]
const q = Number(item.quantity)
if (!q || q < 1) item.quantity = 1
if (q > 99) item.quantity = 99
},
/**
* 子行:加入购物车
* @param {Object} variant - 子项行数据
*/
handleAddVariantToCart(variant) {
if (!variant || !variant.onlyKey) return
try {
addToCart({
id: variant.onlyKey,
title: variant.model,
price: variant.price,
quantity: variant.quantity
})
this.$message.success(`已添加 ${variant.quantity}${variant.model} 到购物车`)
variant.quantity = 1
} catch (error) {
console.error('添加到购物车失败:', error)
}
},
// 统一加入购物车
handleAddSelectedToCart() {
const allSelected = Object.values(this.selectedMap).flat().filter(Boolean)
if (!allSelected.length) {
this.$message.warning('请先勾选至少一台矿机')
return
}
try {
allSelected.forEach(item => {
addToCart({
id: item.onlyKey || item.id,
title: item.type || item.model || '矿机',
price: item.price,
quantity: 1,
leaseTime: Number(item.leaseTime || 1)
})
})
this.$message.success(`已加入 ${allSelected.length} 台矿机到购物车`)
this.selectedMap = {}
} catch (e) {
console.error('统一加入购物车失败', e)
}
},
// 打开确认弹窗:以当前界面勾选(_selected)为准,并在打开后清空左侧勾选状态
handleOpenAddToCartDialog() {
// 扫描当前所有系列下被勾选的机器
const groups = Array.isArray(this.productListData) ? this.productListData : []
const picked = groups.flatMap(g => Array.isArray(g.productMachines) ? g.productMachines.filter(m => !!m && !!m._selected) : [])
if (!picked.length) {
this.$message.warning('请先勾选至少一台矿机')
return
}
// 使用弹窗中的固定快照,避免后续清空勾选影响弹窗显示
this.confirmAddDialog.items = picked.slice()
this.confirmAddDialog.visible = true
// 打开后立即把左侧复选框清空,避免“勾选了两个但弹窗只有一条”的不一致问题
this.$nextTick(() => {
try { this.clearAllSelections() } catch (e) { /* noop */ }
})
},
// 确认加入:调用后端购物车接口,传入裸数组 [{ productId, productMachineId }]
async handleConfirmAddSelectedToCart() {
// 以弹窗中的列表为准,避免与左侧勾选状态不一致
const allSelected = Array.isArray(this.confirmAddDialog.items) ? this.confirmAddDialog.items.filter(Boolean) : []
if (!allSelected.length) {
this.$message.warning('请先勾选至少一台矿机')
return
}
const productId = this.params && this.params.id ? this.params.id : (this.$route && this.$route.params && this.$route.params.id)
if (!productId) {
this.$message.error('商品ID缺失无法加入购物车')
return
}
// 裸数组,仅包含后端要求的两个字段
const payload = allSelected.map(item => ({
productId: productId,
productMachineId: item.id,
leaseTime: Number(item.leaseTime || 1)
}))
try {
const res = await this.fetchAddCart(payload)
// 若后端返回码存在,这里做一下兜底提示
if (!res || (res.code && Number(res.code) !== 200)) {
this.$message.error(res && res.msg ? res.msg : '加入购物车失败,请稍后重试')
return
}
// 立即本地更新禁用状态把刚加入的机器ID合并进本地集合
try {
allSelected.forEach(item => {
if (item && item.id) this.cartMachineIdSet.add(item.id)
this.$set(item, '_selected', false)
this.$set(item, '_inCart', true)
if (!item.leaseTime || Number(item.leaseTime) <= 0) this.$set(item, 'leaseTime', 1)
})
this.$nextTick(() => this.autoSelectAndDisable())
} catch (e) { /* noop */ }
this.$message({
message: `已加入 ${allSelected.length} 台矿机到购物车`,
type: 'success',
duration: 3000,
showClose: true,
});
this.confirmAddDialog.visible = false
// 清空选中映射,然后重新加载数据(数据加载时会自动设置 _selected: false
this.selectedMap = {}
// 重新加载机器信息和购物车数据
this.fetchGetMachineInfo(this.params)
this.fetchGetGoodsList()
// 通知头部刷新服务端购物车数量
try {
// 如果没有传数量header 会主动拉取服务端数量
window.dispatchEvent(new CustomEvent('cart-updated'))
} catch (e) { /* noop */ }
} catch (e) {
console.error('加入购物车失败: ', e)
this.$message.error('加入购物车失败,请稍后重试')
}
},
// 取消所有商品勾选(内层表格的自定义 checkbox
clearAllSelections() {
try {
// 清空选中映射
this.selectedMap = {}
// 遍历所有系列与机器,复位 _selected
const groups = Array.isArray(this.productListData) ? this.productListData : []
groups.forEach(g => {
const list = Array.isArray(g.productMachines) ? g.productMachines : []
list.forEach(m => { if (m) this.$set(m, '_selected', false) })
})
} catch (e) { /* noop */ }
},
/**
* 减少数量
* @param {number} rowIndex - 表格行索引
*/
handleDecreaseQuantity(rowIndex) {
if (this.tableData[rowIndex].quantity > 1) {
this.tableData[rowIndex].quantity--
}
},
/**
* 增加数量
* @param {number} rowIndex - 表格行索引
*/
handleIncreaseQuantity(rowIndex) {
if (this.tableData[rowIndex].quantity < 99) {
this.tableData[rowIndex].quantity++
}
},
/**
* 处理数量输入
* @param {number} rowIndex - 表格行索引
*/
handleQuantityInput(rowIndex) {
const quantity = this.tableData[rowIndex].quantity
if (quantity < 1) {
this.tableData[rowIndex].quantity = 1
} else if (quantity > 99) {
this.tableData[rowIndex].quantity = 99
}
},
/**
* 处理数量输入框失焦
* @param {number} rowIndex - 表格行索引
*/
handleQuantityBlur(rowIndex) {
const quantity = this.tableData[rowIndex].quantity
if (!quantity || quantity < 1) {
this.tableData[rowIndex].quantity = 1
} else if (quantity > 99) {
this.tableData[rowIndex].quantity = 99
}
},
/**
* 添加到购物车
* @param {Object} rowData - 表格行数据
*/
handleAddToCart(rowData) {
if (!rowData || rowData.quantity < 1) {
this.$message.warning('请选择有效的数量')
return
}
try {
addToCart({
id: rowData.date, // 使用矿机名称作为ID
title: rowData.date,
price: rowData.price,
quantity: rowData.quantity,
leaseTime: Number(rowData.leaseTime || 1)
})
this.$message.success(`已添加 ${rowData.quantity}${rowData.date} 到购物车`)
// 重置数量
rowData.quantity = 1
} catch (error) {
console.error('添加到购物车失败:', error)
this.$message.error('添加到购物车失败,请稍后重试')
}
}
}
}

View File

@@ -0,0 +1,354 @@
<template>
<div class="product-detail" v-loading="productDetailLoading">
<div v-if="loading" class="loading">
<i class="el-icon-loading" aria-label="加载中" role="img"></i>
加载中...
</div>
<div v-else-if="product" class="detail-container">
<h2 style="margin: 10px; text-align: left;margin-top: 28px;">商品详情-选择矿机</h2>
<section class="productList">
<!-- 产品列表可展开 -->
<el-table
ref="seriesTable"
:data="productListData"
row-key="id"
:expand-row-keys="expandedRowKeys"
@expand-change="handleExpandChange"
@row-click="handleSeriesRowClick"
:row-class-name="handleGetSeriesRowClassName"
:header-cell-style="{ textAlign: 'left' }"
:cell-style="{ textAlign: 'left' }"
style="width: 100%"
>
<el-table-column type="expand" width="46">
<template #default="outer">
<!-- 子表格展开后显示该行的多个可选条目来自 productMachines -->
<el-table :data="outer.row.productMachines" size="small" style="width: 100%" :show-header="true" :ref="'innerTable-' + outer.row.id" :row-key="'id'" :reserve-selection="false" :header-cell-style="{ textAlign: 'left' }" :cell-style="{ textAlign: 'left' }">
<el-table-column width="46">
<template #default="scope">
<el-checkbox v-model="scope.row._selected" @change="checked => handleManualSelect(outer.row, scope.row, checked)" />
</template>
</el-table-column>
<!-- 调整列顺序使理论算力实际算力与上层对齐并且指定相同宽度 -->
<el-table-column prop="theoryPower" label="理论算力" width="280" header-align="left" align="left" />
<el-table-column label="实际算力" width="230" header-align="left" align="left">
<template #default="scope">{{ scope.row.computingPower }} {{ scope.row.unit }}</template>
</el-table-column>
<el-table-column prop="powerDissipation" label="功耗(kw/h)" width="230" header-align="left" align="left" />
<el-table-column prop="algorithm" label="算法" width="180" header-align="left" align="left" />
<el-table-column prop="theoryIncome" width="220" header-align="left" align="left">
<template #header>单机理论收入(每日) <span v-show="outer.row.coin">{{ outer.row.coin || '' }}</span></template>
</el-table-column>
<el-table-column prop="theoryUsdtIncome" label="单机理论收入(每日/USDT)" width="240" header-align="left" align="left" />
<!-- 矿机型号置于最后不影响上层对齐 -->
<el-table-column prop="type" label="矿机型号" header-align="left" align="left" />
<el-table-column label="租赁天数(天)" width="200" header-align="left" align="left">
<template #default="scope">
<el-input-number
v-model="scope.row.leaseTime"
:min="1"
:max="365"
size="mini"
controls-position="right"
/>
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>
<!-- 外层与内层列宽保持一致160/160/160/120/160 -->
<el-table-column label="价格" header-align="left" align="left">
<template slot-scope="scope">{{ scope.row.productMachineRangeGroupDto && scope.row.productMachineRangeGroupDto.price }}</template>
</el-table-column>
<el-table-column label="理论算力范围" width="280" header-align="left" align="left">
<template slot-scope="scope">{{ scope.row.productMachineRangeGroupDto && scope.row.productMachineRangeGroupDto.theoryPowerRange }}</template>
</el-table-column>
<el-table-column label="实际算力范围" width="230" header-align="left" align="left">
<template slot-scope="scope">{{ scope.row.productMachineRangeGroupDto && scope.row.productMachineRangeGroupDto.computingPowerRange }}</template>
</el-table-column>
<el-table-column label="功耗范围" width="230" header-align="left" align="left">
<template slot-scope="scope">{{ scope.row.productMachineRangeGroupDto && scope.row.productMachineRangeGroupDto.powerRange }}</template>
</el-table-column>
<el-table-column label="数量" width="180" header-align="left" align="left">
<template slot-scope="scope">{{ scope.row.productMachineRangeGroupDto && scope.row.productMachineRangeGroupDto.number }}</template>
</el-table-column>
</el-table>
</section>
<div style="margin: 18px; text-align: right;">
<el-button type="primary" size="small" @click="handleOpenAddToCartDialog">加入购物车</el-button>
</div>
<!-- 确认加入购物车弹窗 -->
<el-dialog :visible.sync="confirmAddDialog.visible" width="60vw" :title="`确认加入购物车(共 ${confirmAddDialog.items.length} 台)`">
<div>
<el-table :data="confirmAddDialog.items" height="360" border stripe :header-cell-style="{ textAlign: 'left' }" :cell-style="{ textAlign: 'left' }">
<el-table-column prop="type" label="型号" width="160" header-align="left" align="left" />
<el-table-column prop="theoryPower" label="理论算力" width="160" header-align="left" align="left" />
<el-table-column label="算力" width="160" header-align="left" align="left">
<template #default="scope">{{ scope.row.computingPower }} {{ scope.row.unit }}</template>
</el-table-column>
<el-table-column prop="algorithm" label="算法" width="120" header-align="left" align="left" />
<el-table-column prop="powerDissipation" label="功耗(kw/h)" width="160" header-align="left" align="left" />
<el-table-column label="租赁天数(天)" width="160" header-align="left" align="left">
<template #default="scope">{{ Number(scope.row.leaseTime || 1) }}</template>
</el-table-column>
<el-table-column prop="price" label="单价(USDT)" width="160" header-align="left" align="left" />
</el-table>
</div>
<template #footer>
<el-button @click="confirmAddDialog.visible = false">取消</el-button>
<el-button type="primary" @click="handleConfirmAddSelectedToCart">确认加入</el-button>
</template>
</el-dialog>
</div>
<div v-else class="not-found">
<h2>商品不存在</h2>
<p>抱歉您查找的商品不存在或已被删除</p>
<button @click="handleBack" class="back-btn">返回商品列表</button>
</div>
</div>
</template>
<script>
import Index from './index'
export default {
name: 'ProductDetail',
mixins: [Index],
}
</script>
<style scoped>
.product-detail {
width: 100%;
margin: 0 auto;
}
/* 子表内:已在购物车的行高亮并禁用指针事件到复选框外区域,保留复选框样式显示为勾选 */
:deep(.in-cart-row) {
background: #fafafa;
}
/* 强制让被标记为 in-cart 的行的复选框始终为勾选视觉,并禁用鼠标交互 */
:deep(.in-cart-row .el-checkbox.is-disabled .el-checkbox__inner) {
background-color: #f5f7fa;
border-color: #dcdfe6;
}
.loading {
text-align: center;
padding: 60px 20px;
color: #666;
}
.back-section {
margin-bottom: 24px;
text-align: left;
margin: 8px;
}
.back-btn {
background: #6c757d;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s ease;
}
.back-btn:hover {
background: #5a6268;
}
.detail-container {
width: 100%;
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.product-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
padding: 40px;
}
.product-image-section {
display: flex;
justify-content: center;
align-items: center;
}
.product-image {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.product-info-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.product-title {
font-size: 28px;
font-weight: 700;
color: #2c3e50;
margin: 0;
line-height: 1.3;
}
.product-description {
font-size: 16px;
color: #666;
line-height: 1.6;
margin: 0;
}
.product-price-section {
display: flex;
align-items: center;
gap: 12px;
}
.price-label {
font-size: 16px;
color: #666;
}
.product-price {
font-size: 32px;
font-weight: 700;
color: #e74c3c;
}
/* 外层系列行:整行可点击的视觉提示 */
:deep(.series-clickable-row) {
cursor: pointer;
}
.quantity-section {
display: flex;
align-items: center;
gap: 16px;
}
.quantity-label {
font-size: 16px;
color: #666;
min-width: 60px;
}
.quantity-controls {
display: flex;
align-items: center;
border: 1px solid #ddd;
border-radius: 6px;
overflow: hidden;
}
.quantity-btn {
background: #f8f9fa;
border: none;
padding: 12px 16px;
cursor: pointer;
font-size: 18px;
font-weight: 600;
color: #495057;
transition: background 0.3s ease;
}
.quantity-btn:hover:not(:disabled) {
background: #e9ecef;
}
.quantity-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quantity-input {
width: 80px;
padding: 12px;
border: none;
text-align: center;
font-size: 16px;
outline: none;
}
.quantity-input::-webkit-outer-spin-button,
.quantity-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.quantity-input[type=number] {
/* 标准属性,隐藏默认的数字输入控件样式 */
appearance: textfield;
/* 浏览器前缀,兼容性处理 */
-webkit-appearance: none;
-moz-appearance: textfield;
}
.quantity-input:focus {
background: #f8f9fa;
}
/* 响应式设计 */
@media (max-width: 768px) {
.product-content {
grid-template-columns: 1fr;
gap: 24px;
padding: 24px;
}
.product-detail {
padding: 16px;
}
.product-title {
font-size: 24px;
}
.product-price {
font-size: 28px;
}
.quantity-selector {
width: 100px;
height: 32px;
}
.quantity-btn {
width: 32px;
height: 32px;
}
.quantity-input {
height: 32px;
font-size: 13px;
}
.btn-icon {
font-size: 16px;
}
}
</style>

View File

@@ -0,0 +1,311 @@
import { getProductList } from '../../api/products'
export default {
name: 'ProductList',
data() {
return {
products: [
// {
// id: 1,
// name: "Nexa",
// price: `10000~20000`,
// image: "https://img.yzcdn.cn/vant/apple-1.jpg",
// desc: "NexaPow",
// },
// {
// id: 2,
// name: "grs",
// price: `10000~20000`,
// image: "https://img.yzcdn.cn/vant/apple-1.jpg",
// desc: "groestl",
// },
// {
// id: 3,
// name: "mona",
// price: `10000~20000`,
// image: "https://img.yzcdn.cn/vant/apple-1.jpg",
// desc: "Lyra2REv2",
// },
// {
// id: 4,
// name: "dgb",
// price: `10000~20000`,
// image: "https://img.yzcdn.cn/vant/apple-1.jpg",
// desc: "DigiByte(Skein)",
// },
],
loading: false,
powerList: [
{
value: 1,
label: "NexaPow",
children: [
{
value: 1 - 1,
label: "挖矿账户1",
},
{
value: 1 - 2,
label: "挖矿账户2",
},
],
},
{
value: 2,
label: "Grepow",
children: [
{
value: 2 - 1,
label: "挖矿账户1",
},
{
value: 2 - 2,
label: "挖矿账户2",
},
],
},
{
value: 3,
label: "mofang",
children: [
{
value: 3 - 1,
label: "挖矿账户1",
},
],
},
],
currencyList: [
{
path: "nexaAccess",
value: "nexa",
label: "nexa",
imgUrl: `https://m2pool.com/img/nexa.png`,
name: "course.NEXAcourse",
show: true,
amount: 10000,
},
{
path: "grsAccess",
value: "grs",
label: "grs",
imgUrl: `https://m2pool.com/img/grs.svg`,
name: "course.GRScourse",
show: true,
amount: 1,
},
{
path: "monaAccess",
value: "mona",
label: "mona",
imgUrl: `https://m2pool.com/img/mona.svg`,
name: "course.MONAcourse",
show: true,
amount: 1,
},
{
path: "dgbsAccess",
value: "dgbs",
// label: "dgb-skein-pool1",
label: "dgb(skein)",
imgUrl: `https://m2pool.com/img/dgb.svg`,
name: "course.dgbsCourse",
show: true,
amount: 1,
},
{
path: "dgbqAccess",
value: "dgbq",
// label: "dgb(qubit-pool1)",
label: "dgb(qubit)",
imgUrl: `https://m2pool.com/img/dgb.svg`,
name: "course.dgbqCourse",
show: true,
amount: 1,
},
{
path: "dgboAccess",
value: "dgbo",
// label: "dgb-odocrypt-pool1",
label: "dgb(odocrypt)",
imgUrl: `https://m2pool.com/img/dgb.svg`,
name: "course.dgboCourse",
show: true,
amount: 1,
},
{
path: "rxdAccess",
value: "rxd",
label: "radiant(rxd)",
imgUrl: `https://m2pool.com/img/rxd.png`,
name: "course.RXDcourse",
show: true,
amount: 100,
},
{
path: "enxAccess",
value: "enx",
label: "Entropyx(enx)",
imgUrl: `https://m2pool.com/img/enx.svg`,
name: "course.ENXcourse",
show: true,
amount: 5000,
},
{
path: "alphminingPool",
value: "alph",
label: "alephium",
imgUrl: `https://m2pool.com/img/alph.svg`,
name: "course.alphCourse",
show: true,
amount: 1,
},
],
screenCurrency: "",
searchAlgorithm: "",
params:{
coin: "",
algorithm: ""
},
productListLoading:false,
}
},
mounted() {
this.fetchGetList()
},
methods: {
/**
* 价格裁剪为两位小数(不四舍五入)
* 兼容区间字符串:"min-max" 或 单值
*/
formatPriceRange(input) {
try {
if (input === null || input === undefined) return '0.00'
const raw = String(input)
if (raw.includes('-')) {
const [lo, hi] = raw.split('-')
return `${this._truncate2(lo)}-${this._truncate2(hi)}`
}
return this._truncate2(raw)
} catch (e) {
return '0.00'
}
},
/**
* 将任意数字字符串截断为 2 位小数(不四舍五入)。
*/
_truncate2(val) {
if (val === null || val === undefined) return '0.00'
const str = String(val).trim()
if (!str) return '0.00'
const [intPart, decPart = ''] = str.split('.')
const two = decPart.slice(0, 2)
return `${intPart}.${two.padEnd(2, '0')}`
},
handleCurrencyChange(val){
try{
// 清空时el-select 的 clear 同时触发 change避免重复请求交由 handleCurrencyClear 处理
if (val === undefined || val === null || val === '') return
// 选择具体币种时,合并算法关键词一起查询
this.params.coin = val
const keyword = (this.searchAlgorithm || '').trim()
const req = keyword ? { coin: val, algorithm: keyword } : { coin: val }
this.fetchGetList(req)
// 可在此发起接口getProductList({ coin: val })
// this.fetchGetList({ coin: val })
}catch(e){
console.error('处理币种变更失败', e)
}
},
async fetchGetList(params) {
this.productListLoading = true
try {
const res = await getProductList(params)
console.log('API响应:', res)
if (res && res.code === 200) {
this.products = res.rows || []
console.log('商品数据:', this.products)
} else {
console.error('API返回错误:', res)
this.products = []
}
} catch (error) {
console.error('获取商品列表失败:', error)
this.products = []
// 添加一些测试数据,避免页面空白
this.products = [
// {
// id: 1,
// name: "测试商品1",
// algorithm: "测试算法1",
// priceRange: "100-200",
// image: "https://img.yzcdn.cn/vant/apple-1.jpg"
// },
// {
// id: 2,
// name: "测试商品2",
// algorithm: "测试算法2",
// priceRange: "200-300",
// image: "https://img.yzcdn.cn/vant/apple-1.jpg"
// }
]
}
this.productListLoading = false
},
// 算法搜索(使用同一接口,传入 algorithm 参数)
handleAlgorithmSearch() {
const keyword = (this.searchAlgorithm || '').trim()
const next = { ...this.params }
if (keyword) {
next.algorithm = keyword
this.params.algorithm = keyword
} else {
delete next.algorithm
this.params.algorithm = ""
}
// 不重置下拉,只根据算法关键词查询
if (next.algorithm) this.fetchGetList({ ...next, coin: this.screenCurrency || undefined })
else this.fetchGetList(this.screenCurrency ? { coin: this.screenCurrency } : undefined)
},
// 清空下拉时:只清 coin保留算法条件
handleCurrencyClear() {
this.screenCurrency = ""
this.params.coin = ""
const keyword = (this.searchAlgorithm || '').trim()
if (keyword) this.fetchGetList({ algorithm: keyword })
else this.fetchGetList()
},
// 清空算法时:只清 algorithm保留下拉 coin
handleAlgorithmClear() {
this.searchAlgorithm = ""
this.params.algorithm = ""
const coin = this.screenCurrency
if (coin) this.fetchGetList({ coin })
else this.fetchGetList()
},
handleProductClick(product) {
if (product.id || product.id == 0) {
this.$router.push(`/product/${product.id}`);
}
},
}
}

View File

@@ -0,0 +1,222 @@
<template>
<div class="product-list" v-loading="productListLoading">
<section class="container">
<h1 class="page-title">商品列表</h1>
<section class="filter-section">
<label class="required" style="margin-bottom: 10px">币种选择</label>
<div class="filter-row">
<!-- 币种下拉 -->
<el-select
class="input"
size="middle"
ref="screen"
v-model="screenCurrency"
placeholder="请选择"
@change="handleCurrencyChange"
@clear="handleCurrencyClear"
clearable
>
<el-option
v-for="item in currencyList"
:key="item.value"
:label="item.label"
:value="item.value"
>
<div style="display: flex; align-items: center">
<img :src="item.imgUrl" style="float: left; width: 20px" />
<span style="float: left; margin-left: 5px">{{ item.label }}</span>
</div>
</el-option>
</el-select>
<!-- 算法搜索框 -->
<el-input
v-model="searchAlgorithm"
size="middle"
placeholder="输入算法关键词"
clearable
@clear="handleAlgorithmClear"
@keyup.enter.native="handleAlgorithmSearch"
style="width: 240px;"
>
<template #append>
<el-button type="primary" @click="handleAlgorithmSearch">搜索</el-button>
</template>
</el-input>
</div>
</section>
<div class="product-list-grid">
<div
v-for="product in products"
:key="product.id"
class="product-item"
@click="handleProductClick(product)"
tabindex="0"
aria-label="查看详情"
>
<img :src="product.image || 'https://img.yzcdn.cn/vant/apple-1.jpg'" :alt="product.name" class="product-image" />
<div class="product-info">
<h4>商品: {{ product.name }}</h4>
<p style="font-size: 16px;margin-top: 10px;font-weight: bold;">算法: {{ product.algorithm }}</p>
<div class="product-footer">
<span class="product-price">价格: {{ formatPriceRange(product.priceRange) }}</span> <span style="color: #999;font-size: 12px;">USDT</span>
</div>
</div>
</div>
<div v-if="products.length === 0 && !productListLoading" class="empty-state">
<i class="el-icon-goods"></i>
<p>暂无商品数据</p>
<p style="font-size: 12px; color: #999; margin-top: 8px;">请检查网络连接或联系管理员</p>
</div>
</div>
</section>
</div>
</template>
<script>
import { listProducts } from "../../utils/productService";
import { addToCart } from "../../utils/cartManager";
import Index from "./index";
export default {
mixins: [Index],
name: "ProductList",
mounted() {},
methods: {
/**
* 处理商品点击 - 跳转到详情页
*/
/**
* 处理添加到购物车
*/
handleAddToCart(product) {
try {
addToCart({
id: product.id,
title: product.title,
price: product.price,
image: product.image,
quantity: 1,
});
this.$message({
message: "已添加到购物车",
type: "success",
showClose: true
});
} catch (error) {
console.error("添加到购物车失败:", error);
console.log("添加到购物车失败,请稍后重试");
}
},
},
};
</script>
<style scoped lang="scss">
.product-list {
background: #f5f5f5;
padding: 24px;
}
.container {
width: 80%;
margin: 0 auto;
text-align: left;
h1 {
font-size: 24px;
font-weight: bold;
margin-bottom: 20px;
}
}
.filter-section {
display: flex;
flex-direction: column;
margin-bottom: 20px;
width: 80%;
margin-top: 18px;
}
.filter-row {
display: flex;
gap: 12px;
align-items: center;
}
.product-list-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 30px;
margin-top: 100px;
// background: palegoldenrod;
display: flex;
flex-wrap: wrap;
}
.product-item {
width: 400px;
border: 1px solid #eee;
border-radius: 8px;
padding: 18px;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
height: 35vh;
}
.product-image {
width: 90%;
height:65%;
object-fit: cover;
margin-bottom: 12px;
}
.product-info {
width: 100%;
}
.product-footer {
// display: flex;
// justify-content: space-between;
// align-items: center;
margin-top: 8px;
}
.product-price {
color: #e53e3e;
font-weight: bold;
max-width:90%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.add-cart-btn {
background: #42b983;
color: #fff;
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
transition: background 0.2s;
}
.add-cart-btn:hover {
background: #369870;
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state i {
font-size: 48px;
margin-bottom: 16px;
color: #ddd;
}
.empty-state p {
margin: 8px 0;
font-size: 16px;
}
</style>

BIN
power_leasing/test.zip Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
<!doctype html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>power_leasing</title><script defer="defer" src="/js/chunk-vendors.4487a7bc.js"></script><script defer="defer" src="/js/app.37d3fb13.js"></script><link href="/css/chunk-vendors.10dd4e95.css" rel="stylesheet"><link href="/css/app.1af80271.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but power_leasing doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true
})
module.exports = {
devServer: {
client: {
overlay: false
}
}
}

View File

@@ -75,7 +75,7 @@
</header>
<!-- ...头部已写好... -->
<main>
<!-- 1. Banner区 -->