最新需求修改中

This commit is contained in:
2025-11-28 15:30:36 +08:00
parent 868632400a
commit 485226d9dc
8 changed files with 2070 additions and 738 deletions

View File

@@ -12,51 +12,73 @@ export function addSingleOrBatchMachine(data) {
//根据矿机id 删除商品矿机
export function deleteMachine(data) {
return request({
url: `/lease/product/machine/delete`,
method: 'post',
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
})
}
return request({
url: `/lease/product/machine/getUserMachineList`,
method: 'post',
data
})
}
//根据 登录账户 获取挖矿账户及挖矿币种集合
//根据 登录账户 获取挖矿账户及挖矿币种集合
export function getUserMinersList(data) {
return request({
url: `/lease/product/machine/getUserMinersList`,
method: 'post',
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
})
}
return request({
url: `/lease/product/machine/updateMachine`,
method: 'post',
data
})
}
//获取矿机列表
//获取矿机列表
export function getMachineListForUpdate(data) {
return request({
url: `/lease/product/machine/getMachineListForUpdate`,
method: 'post',
data
})
}
return request({
url: `/lease/product/machine/getMachineListForUpdate`,
method: 'post',
data
})
}
//GPU下载客户端
export function downloadClient() {
return request({
url: `/lease/user/downloadClient`,
method: 'get',
})
}
//卖家页面---新增ASIC矿机
export function addAsicMachine(data) {
return request({
url: `/lease/v2/product/machine/addAsicMachine`,
method: 'post',
data
})
}

View File

@@ -97,3 +97,83 @@ export function getPayTypes(data) {
}
// 卖家页面---矿机列表
export function getShopMachineListForSeller(data) {
return request({
url: `/lease/v2/product/machine/getShopMachineListForSeller`,
method: 'post',
data
})
}
// 更新设置GPU商品列表的信息
export function updateGpuMachine(data) {
return request({
url: `/lease/v2/product/machine/updateGpuMachine`,
method: 'post',
data
})
}
// 修改商品列表ASIC商品信息
export function updateAsicMachine(data) {
return request({
url: `/lease/v2/product/machine/updateAsicMachine`,
method: 'post',
data
})
}
// 删除ASIC 或者GPU
export function deleteMachine(data) {
return request({
url: `/lease/v2/product/machine/deleteMachine`,
method: 'post',
data
})
}
// 获取商场页面的店铺列表
export function getShopList(data) {
return request({
url: `/lease/v2/product/machine/getShopList`,
method: 'post',
data
})
}
// 获取店铺详情
export function getShopMachineList(data) {
return request({
url: `/lease/v2/product/machine/getShopMachineList`,
method: 'post',
data
})
}
// 获取店铺详情
export function addGoodsV2(data) {
return request({
url: `/lease/v2/shopping/cart/addGoodsV2`,
method: 'post',
data
})
}

View File

@@ -28,6 +28,7 @@
v-model="form.coinsInput"
placeholder="例如USDT, BTC, ETH"
style="width: 50%;"
@input="handleCoinsInput"
/>
</el-form-item>
<el-form-item label="算法(多个用逗号隔开)" prop="algorithmsInput" :required="form.machineCategory === 'ASIC'">
@@ -35,13 +36,14 @@
v-model="form.algorithmsInput"
placeholder="例如SHA-256, ETHASH"
style="width: 50%;"
@input="handleAlgorithmsInput"
/>
</el-form-item>
<div style="text-align:left; color:#909399; font-size:12px; margin:-6px 0 10px 160px;">
输入多个用逗号隔开
</div>
<el-form-item label="矿机型号">
<el-form-item label="矿机型号" prop="type" :required="true">
<el-input style="width: 50%;" v-model="form.type" placeholder="示例:龍珠" :maxlength="20" @input="handleTypeInput" />
</el-form-item>
<el-form-item label="理论算力" prop="theoryPower">
@@ -84,7 +86,11 @@
</el-input>
</el-form-item>
<el-form-item label="统一售价" prop="cost" :required="true">
<el-form-item
label="统一售价"
:prop="(payTypeDefs && payTypeDefs.length) ? 'costMap' : 'cost'"
:required="true"
>
<span slot="label">统一售价</span>
<!-- 若商品定义了多个结算币种则按链-币种动态生成多个售价输入否则回退为旧的 USDT 单价 -->
<div v-if="payTypeDefs && payTypeDefs.length" class="cost-multi">
@@ -136,15 +142,33 @@
<el-dialog
title="请确认上架信息"
:visible.sync="confirmVisible"
width="400px"
width="560px"
>
<div>
<p>请仔细确认已选择机器列表价格及相关参数定义</p>
<p style="text-align: left;">机器上架后一经售出在机器出售期间不能修改价格及机器参数</p>
<div style="text-align:left;line-height:1.9;">
<div>币种<b>{{ confirmData.coin }}</b></div>
<div>算法<b>{{ confirmData.algorithm }}</b></div>
<div>最大租赁天数<b>{{ confirmData.maxLeaseDays || '-' }}</b></div>
<div>出售机器数量<b>{{ confirmData.saleNumbers || '-' }}</b></div>
<div style="margin-top:8px;">售价</div>
<el-table
:data="confirmData.priceList"
border
size="mini"
style="width:100%;"
>
<el-table-column prop="chain" label="链" width="120" />
<el-table-column prop="coin" label="币种" width="120" />
<el-table-column label="价格">
<template slot-scope="scope">
{{ scope.row.price }}
</template>
</el-table-column>
</el-table>
<p style="color:#666;margin-top:12px;">请仔细确认以上参数无误后提交</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>
<el-button type="primary" :loading="saving" @click="doSubmit">确认提交</el-button>
</span>
</el-dialog>
<!-- GPU 引导弹窗 -->
@@ -171,8 +195,8 @@
</template>
<script>
import { addSingleOrBatchMachine } from '../../api/machine'
import { addSingleOrBatchMachine ,downloadClient,addAsicMachine} from '../../api/machine'
import { getPayTypes } from '../../api/products'
export default {
name: 'AccountProductMachineAdd',
data() {
@@ -197,14 +221,47 @@ export default {
maxLeaseDays: ''
},
confirmVisible: false,
confirmData: {
coin: '',
algorithm: '',
maxLeaseDays: '',
saleNumbers: '',
priceList: []
},
rules: {
productName: [ { required: true, message: '商品名称不能为空', trigger: 'change' } ],
type: [
{ required: true, message: '矿机型号不能为空', trigger: 'blur' },
{
validator: (rule, value, callback) => {
const s = String(value || '')
// 不允许全是空格
if (s && s.trim().length === 0) {
callback(new Error('矿机型号不能全是空格'))
return
}
callback()
},
trigger: 'blur'
}
],
coinsInput: [
{
validator: (rule, value, callback) => {
if (String(this.form.machineCategory).toUpperCase() !== 'ASIC') { callback(); return }
const s = String(value || '').trim()
if (!s) { callback(new Error('请输入币种,多个用逗号隔开')); return }
// 禁止汉字,且仅允许英数字与逗号/空格/连字符
if (/[\u4e00-\u9fa5]/.test(s)) { callback(new Error('币种不允许输入汉字')); return }
// 逐项校验英文、数字长度1-10
const tokens = s.split(/[,\s、]+/).map(i => i.trim()).filter(Boolean)
const pattern = /^[A-Za-z0-9]{1,10}$/
for (let i = 0; i < tokens.length; i += 1) {
if (!pattern.test(tokens[i])) {
callback(new Error(`币种“${tokens[i]}”格式非法,仅允许字母或数字`))
return
}
}
callback()
},
trigger: 'blur'
@@ -216,6 +273,16 @@ export default {
if (String(this.form.machineCategory).toUpperCase() !== 'ASIC') { callback(); return }
const s = String(value || '').trim()
if (!s) { callback(new Error('请输入算法,多个用逗号隔开')); return }
if (/[\u4e00-\u9fa5]/.test(s)) { callback(new Error('算法不允许输入汉字')); return }
// 逐项校验(字母/数字/连字符2-20
const tokens = s.split(/[,\s、]+/).map(i => i.trim()).filter(Boolean)
const pattern = /^[A-Za-z0-9-]{2,20}$/
for (let i = 0; i < tokens.length; i += 1) {
if (!pattern.test(tokens[i])) {
callback(new Error(`算法“${tokens[i]}”格式非法,仅允许字母、数字或“-”`))
return
}
}
callback()
},
trigger: 'blur'
@@ -400,6 +467,8 @@ export default {
clientDownloadUrl: process.env.VUE_APP_GPU_CLIENT_URL || '',
/** 是否点击过下载客户端(用于控制“已启动客户端”按钮禁用态) */
hasDownloadedClient: false,
/** 支持的支付方式定义(用于动态渲染统一售价输入组) */
payTypeDefs: [],
params:{
cost:353400,
powerDissipation:0.01,
@@ -435,8 +504,88 @@ export default {
if (this.rules && this.rules.cost) {
this.$set(this.rules, 'cost', [{ validator: this.validateCost, trigger: 'blur' }])
}
// 多币种价格专用校验
this.$set(this.rules, 'costMap', [{ validator: this.validateCostMap, trigger: 'blur' }])
this.getPayTypes()
},
methods: {
/** 实时过滤币种输入中的中文字符(仅保留英文/数字/分隔符) */
handleCoinsInput() {
let v = String(this.form.coinsInput || '')
// 去除中文
v = v.replace(/[\u4e00-\u9fa5]/g, '')
this.form.coinsInput = v
},
/** 实时过滤算法输入中的中文字符(仅保留英文/数字/连字符/分隔符) */
handleAlgorithmsInput() {
let v = String(this.form.algorithmsInput || '')
v = v.replace(/[\u4e00-\u9fa5]/g, '')
this.form.algorithmsInput = v
},
/** 将输入按中英文逗号、空格分割,去空,统一英文逗号连接;可选:统一大写 */
normalizeCsv(input, upper = true) {
const arr = String(input || '')
.split(/[,\s、]+/)
.map(s => s.trim())
.filter(Boolean)
const list = upper ? arr.map(s => s.toUpperCase()) : arr
return list.join(',')
},
/** 从 payTypeDefs 与 costMap 生成价格列表 */
buildPriceList() {
const list = []
const defs = Array.isArray(this.payTypeDefs) ? this.payTypeDefs : []
defs.forEach(d => {
const key = d.key
const priceRaw = this.form.costMap ? this.form.costMap[key] : ''
const priceNum = Number(priceRaw)
// 允许为空/非数字则不加入
if (!Number.isFinite(priceNum) || priceNum <= 0) return
list.push({
chain: d.chain,
coin: d.coin,
price: priceNum
})
})
return list
},
async getPayTypes() {
try {
const res = await getPayTypes()
// 期望结构:{ code:200, data:[ { payChain, payCoin, payCoinImage } ] }
if (res && (res.code === 0 || res.code === 200)) {
const list = Array.isArray(res.data) ? res.data : []
const defs = []
const seen = new Set()
list.forEach(it => {
const chain = String(it && it.payChain ? it.payChain : '').toUpperCase()
const coin = String(it && it.payCoin ? it.payCoin : '').toUpperCase()
if (!chain && !coin) return
const key = [chain, coin].filter(Boolean).join('-')
if (seen.has(key)) return
seen.add(key)
defs.push({
chain,
coin,
key,
label: key,
image: it && it.payCoinImage ? String(it.payCoinImage) : ''
})
})
// 根据接口结果渲染“统一售价”输入组
this.payTypeDefs = defs
const nextCostMap = {}
this.payTypeDefs.forEach(d => {
// 保留已输入的数值;否则置空
nextCostMap[d.key] = (this.form.costMap && this.form.costMap[d.key]) || ''
})
this.form.costMap = nextCostMap
}
} catch (e) {
// 忽略错误,维持当前 payTypeDefs
}
},
/**
* ASIC 模式:出售机器数量输入,仅允许 0-9999 的整数
*/
@@ -479,24 +628,35 @@ export default {
* 下载 GPU 客户端
*/
handleDownloadClient() {
const url = (this.clientDownloadUrl || '').trim()
if (!url) {
this.$message.warning('暂无客户端下载地址,请联系管理员')
return
}
try {
window.open(url, '_blank')
} catch (e) {
this.$message.error('打开下载链接失败,请稍后重试')
}
// 点击下载后即可启用“已启动客户端”按钮
this.hasDownloadedClient = true
// 走后端接口下载客户端程序
downloadClient()
.then((res) => {
// 处理 blob 下载(兼容封装返回 data 或直接返回 Blob
const data = (res && res.data) ? res.data : res
const blob = data instanceof Blob ? data : new Blob([data], { type: 'application/octet-stream' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
// 默认文件名(可由后端 Content-Disposition 提供,这里简单兜底)
a.download = 'gpu-client.zip'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
this.$message.success('客户端下载已开始')
this.hasDownloadedClient = true
})
.catch(() => {
this.$message.error('下载失败,请稍后重试')
})
},
/**
* GPU 客户端已启动:跳转至商品列表
*/
handleGpuClientStarted() {
this.$router.push('/productList')
// 跳转到“个人中心-商品列表”页面
this.$router.push('/account/products')
},
/**
* GPU 弹窗关闭:自动恢复为 ASIC
@@ -507,9 +667,9 @@ export default {
},
/** 统一售价校验:多结算币种时跳过,单价时按 USDT 校验 */
validateCost(rule, value, callback) {
// 多支付方式:逐个校验 costMap
if (Array.isArray(this.payTypeDefs) && this.payTypeDefs.length > 0) {
callback()
return
return this.validateCostMap(rule, value, callback)
}
const str = String(value || '')
if (!str) { callback(new Error('请填写机器成本USDT')); return }
@@ -518,6 +678,25 @@ export default {
if (Number(str) <= 0) { callback(new Error('成本必须大于 0')); return }
callback()
},
// 多支付方式下的价格校验:要求每个支付方式都需填写有效价格
validateCostMap(rule, value, callback) {
try {
const defs = Array.isArray(this.payTypeDefs) ? this.payTypeDefs : []
if (!defs.length) { callback(); return }
const pattern = /^\d{1,12}(\.\d{1,2})?$/
for (let i = 0; i < defs.length; i += 1) {
const d = defs[i]
const key = d.key
const v = this.form && this.form.costMap ? String(this.form.costMap[key] || '') : ''
if (!v) { callback(new Error(`请填写 ${d.label} 的价格`)); return }
if (!pattern.test(v)) { callback(new Error(`${d.label} 价格整数最多12位小数最多2位`)); return }
if (Number(v) <= 0) { callback(new Error(`${d.label} 价格必须大于0`)); return }
}
callback()
} catch (e) {
callback(new Error('价格填写有误,请检查'))
}
},
/** 解析路由参数中的支付方式,生成标准定义 */
initPayTypesFromRoute() {
this.payTypeDefs = []
@@ -895,10 +1074,10 @@ export default {
} catch (e) {
return
}
if (!this.form.productId) {
this.$message.warning('缺少商品ID')
return
}
// if (!this.form.productId) {
// this.$message.warning('缺少商品ID')
// return
// }
// 现在统一按出售数量提交GPU 模式不在本页提交)
{
// ASIC校验出售机器数量允许 0-9999为 0 则提示)
@@ -929,47 +1108,41 @@ export default {
return
}
// 统一售价与最大租赁天数已在表单级校验中处理,无需逐机校验
// 通过所有预校验后,弹出确认
// 组装确认数据并弹
const coinStr = this.normalizeCsv(this.form.coinsInput || this.form.coin, true)
const algoStr = this.normalizeCsv(this.form.algorithmsInput, true)
this.confirmData = {
coin: coinStr || '-',
algorithm: algoStr || '-',
maxLeaseDays: this.form.maxLeaseDays,
saleNumbers: this.form.sellCount,
priceList: this.buildPriceList()
}
this.confirmVisible = true
}
,
async doSubmit() {
const [user, coin] = ['','']
this.saving = true
try {
// 若是多结算币种,组装 priceList否则沿用单价字段
const count = Number(this.form.sellCount)
const productMachineURDVos = Array.from({ length: count }).map(() => ({
price: this.payTypeDefs && this.payTypeDefs.length ? undefined : (Number(this.form.cost) || 0),
priceList: this.payTypeDefs && this.payTypeDefs.length ? this.payTypeDefs.map(d => ({
chain: d.chain,
coin: d.coin,
price: Number(this.form.costMap && this.form.costMap[d.key]) || 0
})) : undefined,
state: 0,
type: this.form.type,
// 统一售卖新增接口参数
const payload = {
// 逗号分隔(中英文逗号都兼容),统一为英文逗号并大写
coin: this.normalizeCsv(this.form.coinsInput || this.form.coin, true),
algorithm: this.normalizeCsv(this.form.algorithmsInput, true),
maxLeaseDays: Number(this.form.maxLeaseDays) || 0,
name: this.form.type,
powerDissipation: Number(this.form.powerDissipation) || 0,
theoryPower: Number(this.form.theoryPower) || 0,
unit: this.form.unit
}))
const payload = {
productId: this.form.productId,
// 逗号分隔:后台若需要数组可在服务端拆分
coin: (this.form.coinsInput || this.form.coin || '').toString(),
algorithm: (this.form.algorithmsInput || '').toString(),
powerDissipation: this.form.powerDissipation,
theoryPower: this.form.theoryPower,
type: this.form.type,
unit: this.form.unit,
cost: this.payTypeDefs && this.payTypeDefs.length ? Number(this.form.costMap && this.form.costMap[this.payTypeDefs[0].key]) || 0 : this.form.cost,
maxLeaseDays: this.form.maxLeaseDays,
productMachineURDVos
saleNumbers: Number(this.form.sellCount) || 0,
priceList: this.buildPriceList()
}
// 过滤空价目
payload.priceList = (payload.priceList || []).filter(p => Number(p.price) > 0)
console.log(payload,"请求参数")
const res = await addSingleOrBatchMachine(payload)
const res = await addAsicMachine(payload)
if (res && (res.code === 0 || res.code === 200)) {
this.$message({
@@ -979,7 +1152,7 @@ export default {
type: 'success'
})
this.confirmVisible = false
this.$router.back()
this.$router.push('/account/products')
}
} catch (e) {
console.error('添加出售机器失败', e)

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,6 @@
import { getProductById } from '../../utils/productService'
import { addToCart } from '../../utils/cartManager'
import { getMachineInfo, getPayTypes } from '../../api/products'
import { addCart, getGoodsList } from '../../api/shoppingCart'
import { getMachineInfo, getPayTypes,getShopMachineList,addGoodsV2 } from '../../api/products'
import { truncateAmountByCoin, truncateTo6 } from '../../utils/amount'
export default {
@@ -142,27 +140,445 @@ export default {
pageSizes: [10, 20, 50],
currentPage: 1,
total: 0,
// 动态列表(模拟渲染)
dynamicMeta: {},
dynamicColumns: [],
dynamicRows: [],
dynamicSearch: {
visible: false,
keyword: ''
},
// 矿机种类0-ASIC1-GPU默认GPU
machineType: 1,
}
},
mounted() {
if (this.$route.params.id) {
this.params.id = this.$route.params.id
// 读取用户上次选择的矿机种类0: ASIC, 1: GPU
try{
const savedType = Number(window && window.localStorage ? window.localStorage.getItem('pl_machineType') : NaN)
if (savedType === 0 || savedType === 1) {
this.machineType = savedType
}
}catch(e){/* noop */}
let arr={
"meta": {
"pageNum": 1, // 当前页码(后端分页用,可选)
"pageSize": 10, // 每页条数(后端分页用,可选)
"total": 123 // 总条数(后端分页用,可选)
},
"columns": [
{
"key": "model", // 列字段名:与 rows 中同名字段映射
"label": "型号", // 表头显示文本
"type": "text", // 列类型text/amount/hashrate/days
"fixed": "left", // 是否左固定列left/right/不传
"width": 100 // 列宽(可选)
},
{
"key": "price", // 价格列字段名
"label": "价格", // 表头显示文本
"type": "amount", // 金额类型:仅渲染数值;单位来自行级 priceList.coin
"width": 100 // 列宽(可选)
},
// 动态币种/算法算力列(示例:仅第一列带注释,其余同结构)
{
"key": "XTM", // 币种/算法代码作为列 key
"label": "XTM", // 表头显示名
"type": "hashrate", // 列类型:算力
"unit": "MH/s", // 单元格单位(表头不显示单位)
"icon": "https://cdn.xxx/coin/xtm.png", // 表头图标(可选)
},
{
"key": "NXNA",
"label": "NXNA",
"type": "hashrate",
"unit": "MH/s",
"icon": "https://cdn.xxx/coin/nxna.png"
},
{
"key": "CLORE",
"label": "CLORE",
"type": "hashrate",
"unit": "MH/s",
"icon": "https://cdn.xxx/coin/clore.png"
},
{
"key": "CFX",
"label": "CFX",
"type": "hashrate",
"unit": "MH/s",
"icon": "https://cdn.xxx/coin/cfx.png"
},
{
"key": "IRON",
"label": "IRON",
"type": "hashrate",
"unit": "MH/s",
"icon": "https://cdn.xxx/coin/iron.png"
},
{
"key": "NEXA",
"label": "NEXA",
"type": "hashrate",
"unit": "MH/s",
"icon": "https://cdn.xxx/coin/nexa.png"
},
{
"key": "KLS",
"label": "KLS",
"type": "hashrate",
"unit": "MH/s",
"icon": "https://cdn.xxx/coin/kls.png"
},
{
"key": "RVN",
"label": "RVN",
"type": "hashrate",
"unit": "MH/s",
"icon": "https://cdn.xxx/coin/rvn.png"
},
{
"key": "ERG",
"label": "ERG",
"type": "hashrate",
"unit": "MH/s",
"icon": "https://cdn.xxx/coin/erg.png"
},
{
"key": "XEL",
"label": "XEL",
"type": "hashrate",
"unit": "kH/s",
"icon": "https://cdn.xxx/coin/xel.png"
},
// 尾部汇总列
{
"key": "monthIncome",
"label": "最大月收益",
"type": "amount",
"currency": "USDT",//固定显示单位
"period": "month",
"width": 110
},
{
"key": "maxLeaseDays",
"label": "最大租赁天数",
"type": "days",
"width": 80
}
],
"rows": [
{
"id": "gpu_100_hbm3", // 唯一标识(加入购物车传 id
"model": "H100 80GB HBM3", // 型号,对应 columns.key = model
"price": 142160.54, // 行价格基准值(当无 priceList 时展示)
"priceList": [ // 价格列表:随支付方式变更价格与单位
{
"chain": "TRON", // 支付链
"coin": "USDT", // 币种(价格单位来源)
"price": 142160.54 // 该支付方式下的单价
},
{ "chain": "TRON", "coin": "NEXA", "price": 142050.00 },
{ "chain": "ETH", "coin": "USDT", "price": 142100.00 },
{ "chain": "ETH", "coin": "ETH", "price": 142000.00 }
],
"saleNumbers": 120, // 总机器数(仅 ASIC 展示,后端只读返回)
"saleOutNumbers": 18, // 已售数量(仅 ASIC 展示,后端只读返回)
"XTM": 255.0, // 对应列 key 的算力数值(可为 null
"NXNA": 255.0,
"CLORE": 343.0,
"CFX": null,
"IRON": null,
"NEXA": null,
"KLS": null,
"RVN": 255.0,
"ERG": 900.0,
"XEL": null,
"monthIncome": 425.01, // 最大月收益(列定义 currency=USDT固定以 USDT 展示)
"maxLeaseDays": 10368 // 最大租赁天数(单位:天)
},
{
"id": "gpu_rtx_5090",
"model": "RTX 5090",
"price": 14216.05,
"priceList": [
{ "chain": "TRON", "coin": "USDT", "price": 14216.05 },
{ "chain": "TRON", "coin": "NEXA", "price": 14199.00 },
{ "chain": "ETH", "coin": "USDT", "price": 14180.00 },
{ "chain": "ETH", "coin": "ETH", "price": 14150.00 }
],
"saleNumbers": 80,
"saleOutNumbers": 22,
"XTM": 28.7,
"NXNA": 100.5,
"CLORE": 100.5,
"CFX": 210.0,
"IRON": 133.5,
"NEXA": 450.0,
"KLS": 133.5,
"RVN": 100.5,
"ERG": 575.0,
"XEL": 120.0,
"monthIncome": 267.84,
"maxLeaseDays": 1645
},
{
"id": "gpu_rtx_4090",
"model": "RTX 4090",
"price": 12083.65,
"priceList": [
{ "chain": "TRON", "coin": "USDT", "price": 12083.65 },
{ "chain": "TRON", "coin": "NEXA", "price": 12050.00 },
{ "chain": "ETH", "coin": "USDT", "price": 12020.00 },
{ "chain": "ETH", "coin": "ETH", "price": 11999.00 }
],
"saleNumbers": 64,
"saleOutNumbers": 9,
"XTM": 16.6,
"NXNA": 65.0,
"CLORE": 65.0,
"CFX": 130.0,
"IRON": 82.5,
"NEXA": 320.0,
"KLS": 82.5,
"RVN": 65.0,
"ERG": 265.0,
"XEL": 40.6,
"monthIncome": 155.00,
"maxLeaseDays": 2418
}
]
}
// 模拟数据写入动态表格(仅演示)
try{
this.dynamicMeta = arr.meta || {}
this.dynamicColumns = Array.isArray(arr.columns) ? arr.columns : []
this.dynamicRows = (Array.isArray(arr.rows) ? arr.rows : []).map(r => ({
saleNumbers: 0,
saleOutNumbers: 0,
leaseTime: 1,
purchaseQuantity: 0,
...r
}))
// 根据价格列表设置默认支付方式,确保筛选框与价格展示一致
this.ensureDefaultPayFilterFromPrices()
}catch(e){/* noop */}
// 仅当路由携带 shopId 时,才发起店铺商品请求
const routeShopId =
(this.$route && this.$route.params && (this.$route.params.shopId || this.$route.params.id)) ||
(this.$route && this.$route.query && this.$route.query.shopId)
if (routeShopId) {
this.params.id = routeShopId
this.product = true
// 默认展开第一行
if (this.productListData && this.productListData.length) {
this.expandedRowKeys = [this.productListData[0].id]
}
this.fetchGetMachineInfo(this.params)//priceSort 价格powerSort 算力功耗powerDissipationSort 布尔类型true 升序false降序
this.fetchGetMachineInfo(this.buildQueryParams())
this.fetchPayTypes()
} else {
this.$message.error('商品不存在')
this.$message.warning('缺少店铺IDshopId无法加载商品列表')
this.product = false
}
this.fetchGetGoodsList()
},
methods: {
// 动态表格单元格格式化(金额/算力/天数/文本)- 统一最多显示6位小数hover展示完整
formatDynamicCell(row, col) {
try{
let val = row[col.key]
if (val === null || val === undefined || val === '') return { text: '—', full: '—', truncated: false }
if (col.type === 'amount') {
// 价格列:单位取 priceList 里的 coin默认取第一条选择支付方式后按选择展示
if (col.key === 'price') {
if (Array.isArray(row.priceList) && row.priceList.length) {
const pv = this.getDisplayPrice(row)
const pc = this.getDisplayPriceCoin(row)
if (pv !== null && pv !== undefined) val = pv
const nPrice = val
const t = truncateTo6(nPrice)
const coinUnit = (pc || '').toString().toUpperCase()
return {
text: coinUnit ? `${t.text} ${coinUnit}` : t.text,
full: coinUnit ? `${t.full} ${coinUnit}` : t.full,
truncated: t.truncated
}
}
// 无 priceList仅展示数值不附加任何单位
const t = truncateTo6(val)
return { text: t.text, full: t.full, truncated: t.truncated }
}
// 列级优先:若列声明 currency=USDT则固定展示为 "xx.xx USDT"
const colCurrency = (col.currency || '').toString().toUpperCase()
if (colCurrency === 'USDT') {
const t = truncateTo6(val)
return { text: `${t.text} USDT`, full: `${t.full} USDT`, truncated: t.truncated }
}
// 兜底:不再使用 meta 的货币符号,直接返回数值
const t = truncateTo6(val)
return { text: t.text, full: t.full, truncated: t.truncated }
}
if (col.type === 'hashrate') {
const unit = col.unit ? ` ${col.unit}` : ''
const t = truncateTo6(val)
return { text: `${t.text}${unit}`, full: `${t.full}${unit}`, truncated: t.truncated }
}
if (col.type === 'days') {
const n = Number(val)
if (!Number.isFinite(n)) return { text: String(val), full: String(val), truncated: false }
const s = `${Math.floor(n)}`
return { text: s, full: s, truncated: false }
}
const s = String(val)
return { text: s, full: s, truncated: false }
}catch(e){ return { text: '—', full: '—', truncated: false } }
},
/**
* 如果存在 priceList则用第一条的 chain|coin 作为默认筛选值,
* 使“支付方式筛选”与价格列默认展示一致
*/
ensureDefaultPayFilterFromPrices() {
try{
if (this.payFilterDefaultApplied) return
const rows = Array.isArray(this.dynamicRows) ? this.dynamicRows : []
const firstWithPriceList = rows.find(r => Array.isArray(r && r.priceList) && r.priceList.length)
if (!firstWithPriceList) return
const first = firstWithPriceList.priceList[0]
const chain = String(first && first.chain || '').trim()
const coin = String(first && first.coin || '').trim()
if (!chain && !coin) return
this.selectedPayKey = `${chain}|${coin}`
this.filters.chain = chain
this.filters.coin = coin
this.payFilterDefaultApplied = true
}catch(e){ /* noop */ }
},
/**
* 获取行在当前支付方式下的展示价格
* 优先匹配 selectedPayKeychain|coin否则回退 priceList[0];再否则回退 row.price
*/
getDisplayPrice(row){
try{
const list = Array.isArray(row && row.priceList) ? row.priceList : []
if (!list.length) return row && row.price
const key = this.selectedPayKey
if (key) {
const [chainRaw, coinRaw] = String(key).split('|')
const chain = String(chainRaw || '').toUpperCase().trim()
const coin = String(coinRaw || '').toUpperCase().trim()
const hit = list.find(it =>
String(it && it.chain).toUpperCase().trim() === chain &&
String(it && it.coin).toUpperCase().trim() === coin
)
if (hit && hit.price !== undefined && hit.price !== null) return hit.price
}
const first = list[0]
if (first && first.price !== undefined && first.price !== null) return first.price
return row && row.price
}catch(e){ return row && row.price }
},
/**
* 获取行在当前支付方式下价格的币种coin
*/
getDisplayPriceCoin(row){
try{
const list = Array.isArray(row && row.priceList) ? row.priceList : []
if (!list.length) return ''
const key = this.selectedPayKey
if (key) {
const [chainRaw, coinRaw] = String(key).split('|')
const chain = String(chainRaw || '').toUpperCase().trim()
const coin = String(coinRaw || '').toUpperCase().trim()
const hit = list.find(it =>
String(it && it.chain).toUpperCase().trim() === chain &&
String(it && it.coin).toUpperCase().trim() === coin
)
if (hit && hit.coin) return String(hit.coin)
}
const first = list[0]
return first && first.coin ? String(first.coin) : ''
}catch(e){ return '' }
},
_truncate(num, decimals=2){
try{
const f = Math.pow(10, decimals)
return (Math.floor(Number(num)*f)/f).toFixed(decimals)
}catch(e){ return String(num) }
},
// 判断是否为“框出来部分”的最后一列(最后一个 hashrate 列)
isLastHashrateColumn(colIdx){
try{
const cols = this.getRenderedColumns()
for (let i = cols.length - 1; i >= 0; i--) {
if (String(cols[i] && cols[i].type).toLowerCase() === 'hashrate') {
return i === colIdx
}
}
return false
}catch(e){ return false }
},
// 仅渲染前 8 个算力列,后接其它非算力列(如收益、回收期)
getRenderedColumns(){
try{
const cols = Array.isArray(this.dynamicColumns) ? this.dynamicColumns : []
const hashrate = cols.filter(c => String(c && c.type).toLowerCase() === 'hashrate').slice(0, 8)
const others = cols.filter(c => String(c && c.type).toLowerCase() !== 'hashrate')
return [...hashrate, ...others]
}catch(e){ return [] }
},
// 打开动态搜索弹窗
handleOpenDynamicSearch(){
this.dynamicSearch.visible = true
this.dynamicSearch.keyword = ''
},
// 确认搜索:向后端请求新的 columns/rows替换动态表格
async handleConfirmDynamicSearch(){
const keyword = (this.dynamicSearch.keyword || '').trim()
this.dynamicSearch.visible = false
await this.fetchDynamicTable({ shopId: this.params.id, type: 1, keyword })
},
// 拉取动态表格数据(占位实现:如果后端已就绪,直接替换为真实接口)
async fetchDynamicTable(params){
try{
// 这里预留与后端对接:
// 期待返回格式:{ code, data: { meta, columns, rows } }
// 示例中用本地 mock 演示:根据 keyword 过滤/调整列
// 如果没有 keyword就还原初始 mock
if (!params || !params.keyword) {
return
}
// 简单模拟:当 keyword 命中 'ERG',只保留 ERG + 价格/型号/回收期 等少量列
const kw = String(params.keyword).toUpperCase()
const baseCols = (this.dynamicColumns || []).filter(c => ['model','price','maxLeaseDays','monthIncome'].includes(c.key))
const hitCols = (this.dynamicColumns || []).filter(c => String(c.label || c.key).toUpperCase().includes(kw))
const nextCols = [...(baseCols.length?baseCols:[this.dynamicColumns[0]||[]]), ...hitCols]
if (nextCols.length) {
this.dynamicColumns = nextCols
// 行数据无需特别处理(真实环境后端会按列同步返回),这里保留原 rows
}
}catch(e){
// eslint-disable-next-line no-console
console.warn('fetchDynamicTable mock error', e)
}
},
// 切换矿机种类0-ASIC1-GPU
handleMachineTypeChange(){
// 变更类型后,重新请求数据
this.fetchGetMachineInfo(this.buildQueryParams())
this.fetchPayTypes()
// 本地记住用户选择
try{
if (window && window.localStorage) {
window.localStorage.setItem('pl_machineType', String(this.machineType))
}
}catch(e){/* noop */}
},
// 行币种:优先行内 payCoin > coin其次取全局表头币种
getRowCoin(row) {
try {
@@ -217,9 +633,9 @@ export default {
this.fetchGetMachineInfo(params)
} catch (e) { /* noop */ }
},
// 组合查询参数(带上商品 id 与筛选条件
// 组合查询参数:店铺入口,必须包含 shopId 与 type0-ASIC1-GPU
buildQueryParams() {
const q = { id: this.params.id }
const q = { shopId: this.params.id, type: this.machineType }
// 分页参数始终透传
try {
if (this.params && this.params.pageNum != null) q.pageNum = this.params.pageNum
@@ -251,10 +667,12 @@ export default {
} catch (e) { /* noop */ }
return q
},
// 拉取支付方式
// 拉取支付方式(兼容 shopId
async fetchPayTypes() {
try {
const res = await getPayTypes({ productId: this.params.id })
// 现规则:商品详情由店铺入口进入,使用 shopId 查询支付方式
// 为兼容后端两种入参,优先传 shopId后端若仍使用 productId 也能兼容处理
const res = await getPayTypes({ shopId: this.params.id, productId: this.params.id })
// 接口示例:{ code: 0, data: [ { payChain, payCoin, payCoinImage, shopId } ], msg: '' }
if (res && (res.code === 0 || res.code === 200)) {
const list = Array.isArray(res.data) ? res.data : []
@@ -270,34 +688,21 @@ export default {
async fetchGetMachineInfo(params) {
this.productDetailLoading = true
const res = await getMachineInfo(params)
// 改为使用店铺机器列表接口
const res = await getShopMachineList(params)
console.log(res)
if (res && res.code === 200) {
console.log(res.data, 'res.rows');
this.total = res.total||0;
// 新数据结构:机器为扁平 rows 列表;仅当后端返回有效支付方式时才覆盖,避免清空 getPayTypes 的结果
// 若后端同步返回支付方式,刷新本地支付方式
try {
const payList = res && res.data && res.data.payConfigList
if (Array.isArray(payList) && payList.length) {
this.paymentMethodList = payList
}
} catch (e) { /* keep existing paymentMethodList */ }
const rows = (res && res.data && (res.data.rows || res.data.list)) || (res && res.rows) || []
const normalized = (Array.isArray(rows) ? rows : []).map((m, idx) => ({
...m,
id: m && (m.id !== undefined && m.id !== null) ? m.id : `m-${idx}`,
leaseTime: (m && m.leaseTime && Number(m.leaseTime) > 0) ? Number(m.leaseTime) : 1,
_selected: false
}))
this.machineList = normalized
// 清空旧的两层结构数据,避免误用
this.productListData = []
this.expandedRowKeys = []
// 机器加载后尝试设置默认筛选
this.ensureDefaultPayFilterSelection()
this.$nextTick(() => {
this.machinesLoaded = true
})
} catch (e) { /* noop */ }
// 动态表格数据(如后端有对应 rows/columns可在此接入
// 此处保留现有动态表格的模拟/替换逻辑,不再维护旧表格数据结构
}
this.productDetailLoading = false
@@ -552,88 +957,56 @@ export default {
}
},
// 打开确认弹窗:以当前界面勾选(_selected)为准,并在打开后清空左侧勾选状态
// 打开确认弹窗(基于动态表格的勾选行)
handleOpenAddToCartDialog() {
// 扫描当前所有系列下被勾选的机器
const groups = Array.isArray(this.productListData) ? this.productListData : []
const pickedAll = groups.flatMap(g => Array.isArray(g.productMachines) ? g.productMachines.filter(m => !!m && !!m._selected) : [])
const picked = pickedAll.filter(m => m && (m.saleState === 0 || m.saleState === undefined || m.saleState === null))
const rows = Array.isArray(this.dynamicRows) ? this.dynamicRows : []
const picked = rows.filter(r => !!r && !!r._selected)
if (!picked.length) {
this.$message.warning('请先勾选至少一台矿机')
return
}
if (picked.length < pickedAll.length) {
this.$message.warning('部分机器已售出或售出中,已自动为您排除')
}
// 使用弹窗中的固定快照,避免后续清空勾选影响弹窗显示
this.confirmAddDialog.items = picked.slice()
this.confirmAddDialog.items = picked.map(r => ({
...r,
leaseTime: Number(r.leaseTime || 1),
purchaseQuantity: Number(r.purchaseQuantity || 0)
}))
this.confirmAddDialog.visible = true
// 打开后立即把左侧复选框清空,避免“勾选了两个但弹窗只有一条”的不一致问题
this.$nextTick(() => {
try { this.clearAllSelections() } catch (e) { /* noop */ }
})
},
// 确认加入:调用后端购物车接口,传入裸数组 [{ productId, productMachineId }]
// 确认加入:调用 addGoodsV2按条提交GPU 不传 numbers
async handleConfirmAddSelectedToCart() {
// 以弹窗中的列表为准,避免与左侧勾选状态不一致
const allSelected = Array.isArray(this.confirmAddDialog.items) ? this.confirmAddDialog.items.filter(Boolean) : []
if (!allSelected.length) {
const items = Array.isArray(this.confirmAddDialog.items) ? this.confirmAddDialog.items.filter(Boolean) : []
if (!items.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
// 按接口要求:一次性传数组,每个对象代表一个勾选商品
const payload = items.map(it => {
const obj = {
id: it.id,
leaseTime: Number(it.leaseTime || 1)
}
if (this.machineType === 0) {
obj.numbers = Number(it.purchaseQuantity || 1)
}
return obj
})
const res = await addGoodsV2(payload)
if (!res || !(res.code === 0 || res.code === 200)) {
this.$message.error('部分商品加入购物车失败,请重试')
} else {
this.$message.success(`已加入 ${items.length} 台矿机到购物车`)
}
// 立即本地更新禁用状态把刚加入的机器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'))
(this.dynamicRows || []).forEach(r => { if (r) this.$set(r, '_selected', false) })
} catch (e) { /* noop */ }
// 通知头部刷新
try { window.dispatchEvent(new CustomEvent('cart-updated')) } catch (e) { /* noop */ }
} catch (e) {
console.error('加入购物车失败: ', e)
// eslint-disable-next-line no-console
console.error('addGoodsV2 error:', e)
this.$message.error('加入购物车失败,请稍后重试')
}
},

View File

@@ -35,6 +35,16 @@
<!-- 筛选栏 -->
<section class="filter-bar" aria-label="筛选条件">
<div class="filter-grid">
<!-- 矿机种类放在支付方式筛选前面 -->
<div class="filter-cell">
<label class="filter-title">矿机种类</label>
<div style="display:inline-flex;align-items:center;">
<el-radio-group v-model="machineType" size="small" @change="handleMachineTypeChange">
<el-radio-button :label="1">GPU</el-radio-button>
<el-radio-button :label="0">ASIC</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 支付方式筛选选择即触发查询 -->
<div class="filter-cell">
<label class="filter-title" for="payFilter">支付方式筛选</label>
@@ -47,6 +57,7 @@
size="small"
class="filter-control"
@change="handlePayFilterChange"
style="max-width: 260px;"
>
<template #prefix>
<img
@@ -80,292 +91,148 @@
</div>
</div>
<!-- 实际算力区间 -->
<div class="filter-cell center-title">
<label class="filter-title">实际算力</label>
<div class="range-controls">
<el-input-number v-model="filters.minPower" :min="0" :step="0.1" :precision="2" :controls="false" size="small" class="filter-control" />
<span class="filter-sep">-</span>
<el-input-number v-model="filters.maxPower" :min="0" :step="0.1" :precision="2" :controls="false" size="small" class="filter-control" />
<el-select v-model="filters.unit" placeholder="单位" size="small" class="filter-control" style="max-width: 140px;">
<el-option v-for="u in powerUnitOptions" :key="u" :label="u" :value="u" />
</el-select>
</div>
</div>
<!-- 功耗区间第二行左侧占两列 -->
<div class="filter-cell filter-cell--span-2 center-title">
<label class="filter-title">功耗(kw/h)</label>
<div class="range-controls">
<el-input-number v-model="filters.minPowerDissipation" :min="0" :step="0.1" :precision="2" :controls="false" size="small" class="filter-control" />
<span class="filter-sep">-</span>
<el-input-number v-model="filters.maxPowerDissipation" :min="0" :step="0.1" :precision="2" :controls="false" size="small" class="filter-control" />
<div class="filter-actions-inline">
<!-- 操作按钮 -->
<div class="filter-cell filter-actions">
<div class="action-row">
<el-button type="primary" size="small" @click="handleSearchFilters" aria-label="执行筛选">筛选查询</el-button>
<el-button size="small" @click="handleResetFilters" aria-label="重置筛选">重置</el-button>
</div>
</div>
</div>
<!-- 操作区已合并到功耗区间后面 -->
</div>
</section>
<section class="productList">
<!-- 单层产品列表 -->
<!-- 动态表格基于后端返回的 columns/rows 渲染 -->
<section v-if="dynamicColumns && dynamicColumns.length" class="dynamic-hashrate" aria-label="动态收益表">
<el-table
ref="machineTable"
class="series-table"
:data="machineList"
row-key="id"
:row-class-name="handleGetRowClass"
:data="dynamicRows"
border
stripe
size="small"
class="dynamic-table"
:header-cell-style="{ textAlign: 'left' }"
:cell-style="{ textAlign: 'left' }"
style="width: 100%"
>
<el-table-column width="46">
<template #default="scope">
<!-- 勾选框列首列 -->
<el-table-column width="46" fixed="left">
<template #default="{ row }">
<el-checkbox
v-model="scope.row._selected"
:disabled="scope.row.saleState === 1 || scope.row.saleState === 2"
:title="(scope.row.saleState === 1 || scope.row.saleState === 2) ? '该机器已售出或售出中,无法选择' : ''"
@change="checked => handleManualSelectFlat(scope.row, checked)"
v-model="row._selected"
:title="'选择该矿机'"
@change="checked => handleManualSelectFlat(row, checked)"
/>
</template>
</el-table-column>
<el-table-column prop="theoryPower" label="理论算力" header-align="left" align="left" show-overflow-tooltip>
<template #default="scope">
<el-table-column
v-for="(col, colIdx) in getRenderedColumns()"
:key="col.key || colIdx"
:prop="col.key"
:label="col.label"
:fixed="col.fixed || false"
show-overflow-tooltip
>
<template #header>
<div class="col-header" :title="col.label">
<img v-if="col.icon" :src="col.icon" class="col-icon" alt="" />
<span>{{ col.label }}</span>
<!-- 算力列标题不再展示单位仅展示图标和名称 -->
<el-button
v-if="isLastHashrateColumn(colIdx)"
type="text"
class="more-action"
@click.stop="handleOpenDynamicSearch"
>更多</el-button>
</div>
</template>
<template #default="{ row }">
<span class="num-strong">
<el-tooltip
v-if="formatNum6(scope.row.theoryPower).truncated"
:content="formatNum6(scope.row.theoryPower).full"
v-if="formatDynamicCell(row, col).truncated"
:content="formatDynamicCell(row, col).full"
placement="top"
>
<span>
{{ formatNum6(scope.row.theoryPower).text }}
<i class="el-icon-more amount-more"></i>
</span>
<span>{{ formatDynamicCell(row, col).text }}</span>
</el-tooltip>
<span v-else>{{ formatNum6(scope.row.theoryPower).text }}</span>
<span v-else>{{ formatDynamicCell(row, col).text }}</span>
</span>
{{ scope.row.unit }}
</template>
</el-table-column>
<el-table-column header-align="left" align="left" show-overflow-tooltip>
<template #header>
<span class="sortable" :class="{ active: activeSortField==='powerSort' }" @click="handleToggleSort('powerSort')">
实际算力
<i class="sort-arrow" :class="[(sortStates && sortStates.powerSort) ? 'asc' : 'desc', activeSortField==='powerSort' ? 'active' : '']"></i>
</span>
</template>
<!-- 仅在 ASIC 时显示总机器数量已售数量 -->
<el-table-column v-if="machineType === 0" prop="saleNumbers" label="总机器数">
<template #default="scope">
<span class="num-strong">
<el-tooltip
v-if="formatNum6(scope.row.computingPower).truncated"
:content="formatNum6(scope.row.computingPower).full"
placement="top"
>
<span>
{{ formatNum6(scope.row.computingPower).text }}
<i class="el-icon-more amount-more"></i>
</span>
</el-tooltip>
<span v-else>{{ formatNum6(scope.row.computingPower).text }}</span>
</span>
{{ scope.row.unit }}
<span>{{ scope.row.saleNumbers != null ? scope.row.saleNumbers : '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="powerDissipation" header-align="left" align="left">
<template #header>
<span class="sortable" :class="{ active: activeSortField==='powerDissipationSort' }" @click="handleToggleSort('powerDissipationSort')">
功耗(kw/h)
<i class="sort-arrow" :class="[(sortStates && sortStates.powerDissipationSort) ? 'asc' : 'desc', activeSortField==='powerDissipationSort' ? 'active' : '']"></i>
</span>
</template>
</el-table-column>
<el-table-column prop="algorithm" label="算法" header-align="left" align="left" />
<el-table-column prop="theoryIncome" header-align="left" align="left" show-overflow-tooltip>
<template #header>
单机理论收入(每日)
<span v-if="getFirstCoinSymbol()">{{ getFirstCoinSymbol() }}</span>
</template>
<el-table-column v-if="machineType === 0" prop="saleOutNumbers" label="已售数量">
<template #default="scope">
<span class="num-strong">
<el-tooltip
v-if="formatNum6(scope.row.theoryIncome).truncated"
:content="formatNum6(scope.row.theoryIncome).full"
placement="top"
>
<span>
{{ formatNum6(scope.row.theoryIncome).text }}
<i class="el-icon-more amount-more"></i>
</span>
</el-tooltip>
<span v-else>{{ formatNum6(scope.row.theoryIncome).text }}</span>
</span>
<span>{{ scope.row.saleOutNumbers != null ? scope.row.saleOutNumbers : '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="theoryUsdtIncome" label="单机理论收入(每日/USDT)" header-align="left" align="left">
<!-- 租赁天数始终显示用户手动填写 -->
<el-table-column prop="leaseTime" label="租赁天数(天)">
<template #default="scope">
<span class="num-strong">
<el-tooltip
v-if="formatNum6(scope.row.theoryUsdtIncome).truncated"
:content="formatNum6(scope.row.theoryUsdtIncome).full"
placement="top"
>
<span>
{{ formatNum6(scope.row.theoryUsdtIncome).text }}
<i class="el-icon-more amount-more"></i>
</span>
</el-tooltip>
<span v-else>{{ formatNum6(scope.row.theoryUsdtIncome).text }}</span>
</span>
<el-input-number class="input-full" v-model="scope.row.leaseTime" :min="1" :precision="0" :step="1" :controls="false" size="mini" />
</template>
</el-table-column>
<el-table-column prop="type" label="矿机型号" header-align="left" align="left" />
<el-table-column label="最大可租赁(天)" header-align="left" align="left">
<template #default="scope">{{ getRowMaxLeaseDays(scope.row) }}</template>
</el-table-column>
<el-table-column label="租赁天数(天)" header-align="left" align="left">
<!-- ASIC 专用购买数量用户输入 -->
<el-table-column v-if="machineType === 0" prop="purchaseQuantity" label="购买数量">
<template #default="scope">
<el-input-number
v-model="scope.row.leaseTime"
:min="1"
:max="getRowMaxLeaseDays(scope.row)"
:step="1"
:precision="0"
size="mini"
:disabled="scope.row.saleState === 1 || scope.row.saleState === 2"
controls-position="right"
@change="val => handleLeaseDaysChange(scope.row, val)"
/>
</template>
</el-table-column >
<el-table-column prop="price" header-align="left" align="center" >
<template #header>
<span class="sortable" :class="{ active: activeSortField==='priceSort' }" @click="handleToggleSort('priceSort')">
单价 <span v-if="getPriceCoinSymbol()">{{ getPriceCoinSymbol() }}</span>
<i class="sort-arrow" :class="[(sortStates && sortStates.priceSort) ? 'asc' : 'desc', activeSortField==='priceSort' ? 'active' : '']"></i>
</span>
</template>
<template #default="scope">
<span class="price-strong">
<el-tooltip
v-if="formatAmount(scope.row.price, getRowCoin(scope.row)).truncated"
:content="formatAmount(scope.row.price, getRowCoin(scope.row)).full"
placement="top"
>
<span>
{{ formatAmount(scope.row.price, getRowCoin(scope.row)).text }}
<i class="el-icon-more amount-more"></i>
</span>
</el-tooltip>
<span v-else>{{ formatAmount(scope.row.price, getRowCoin(scope.row)).text }}</span>
</span>
</template>
</el-table-column>
<el-table-column prop="saleState" label="售出状态" width="110" header-align="left" align="left" >
<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>
<el-input-number class="input-full" v-model="scope.row.purchaseQuantity" :min="0" :precision="0" :step="1" :controls="false" size="mini" />
</template>
</el-table-column>
</el-table>
<!-- 动态表格 - 搜索弹窗 -->
<el-dialog
title="搜索币种/算法"
:visible.sync="dynamicSearch.visible"
width="420px"
>
<div style="display:flex;gap:10px;align-items:center;">
<el-input
v-model="dynamicSearch.keyword"
placeholder="输入币种代码或算法关键词"
clearable
@keyup.enter.native="handleConfirmDynamicSearch"
/>
<el-button type="primary" @click="handleConfirmDynamicSearch">搜索</el-button>
</div>
<template #footer>
<el-button @click="dynamicSearch.visible=false">取消</el-button>
<el-button type="primary" @click="handleConfirmDynamicSearch">确定</el-button>
</template>
</el-dialog>
</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="80vw" :title="`确认加入购物车(共 ${confirmAddDialog.items.length} 台)`">
<el-dialog :visible.sync="confirmAddDialog.visible" width="50vw" :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="theoryPower" label="理论算力" header-align="left" align="left">
<template #default="scope">
<span class="num-strong">
<el-tooltip
v-if="formatNum6(scope.row.theoryPower).truncated"
:content="formatNum6(scope.row.theoryPower).full"
placement="top"
>
<span>
{{ formatNum6(scope.row.theoryPower).text }}
<i class="el-icon-more amount-more"></i>
</span>
</el-tooltip>
<span v-else>{{ formatNum6(scope.row.theoryPower).text }}</span>
</span>
{{ scope.row.unit }}
</template>
</el-table-column>
<el-table-column label="实际算力" header-align="left" align="left">
<template #default="scope">
<span class="num-strong">
<el-tooltip
v-if="formatNum6(scope.row.computingPower).truncated"
:content="formatNum6(scope.row.computingPower).full"
placement="top"
>
<span>
{{ formatNum6(scope.row.computingPower).text }}
<i class="el-icon-more amount-more"></i>
</span>
</el-tooltip>
<span v-else>{{ formatNum6(scope.row.computingPower).text }}</span>
</span>
{{ 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)" header-align="left" align="left">
<template #default="scope">
<span class="num-strong">
<el-tooltip
v-if="formatNum6(scope.row.powerDissipation).truncated"
:content="formatNum6(scope.row.powerDissipation).full"
placement="top"
>
<span>
{{ formatNum6(scope.row.powerDissipation).text }}
<i class="el-icon-more amount-more"></i>
</span>
</el-tooltip>
<span v-else>{{ formatNum6(scope.row.powerDissipation).text }}</span>
</span>
</template>
</el-table-column>
<el-table-column label="租赁天数(天)" header-align="left" align="left">
<template #default="scope">{{ Number(scope.row.leaseTime || 1) }}</template>
</el-table-column>
<el-table-column prop="price" header-align="left" align="left">
<template #header>
单价 <span v-if="getPriceCoinSymbol()">{{ getPriceCoinSymbol() }}</span>
</template>
<el-table-column prop="model" label="型号" header-align="left" align="left" />
<el-table-column prop="price" label="价格" header-align="left" align="left">
<template #default="scope">
<span class="price-strong">
<el-tooltip
v-if="formatAmount(scope.row.price, getRowCoin(scope.row)).truncated"
:content="formatAmount(scope.row.price, getRowCoin(scope.row)).full"
v-if="formatDynamicCell(scope.row, { key: 'price', type: 'amount' }).truncated"
:content="formatDynamicCell(scope.row, { key: 'price', type: 'amount' }).full"
placement="top"
>
<span>
{{ formatAmount(scope.row.price, getRowCoin(scope.row)).text }}
<i class="el-icon-more amount-more"></i>
</span>
<span>{{ formatDynamicCell(scope.row, { key: 'price', type: 'amount' }).text }}</span>
</el-tooltip>
<span v-else>{{ formatAmount(scope.row.price, getRowCoin(scope.row)).text }}</span>
<span v-else>{{ formatDynamicCell(scope.row, { key: 'price', type: 'amount' }).text }}</span>
</span>
</template>
</el-table-column>
<el-table-column prop="leaseTime" label="租赁天数(天)" header-align="left" align="left">
<template #default="scope">{{ Number(scope.row.leaseTime || 1) }}</template>
</el-table-column>
<el-table-column v-if="machineType === 0" prop="purchaseQuantity" label="购买数量" header-align="left" align="left">
<template #default="scope">{{ Number(scope.row.purchaseQuantity || 0) }}</template>
</el-table-column>
</el-table>
</div>
<template #footer>
@@ -593,21 +460,18 @@ export default {
* 覆盖 mixin 的多层版本:基于单层勾选打开确认弹窗
*/
handleOpenAddToCartDialog() {
const list = Array.isArray(this.machineList) ? this.machineList : []
const pickedAll = list.filter(it => !!it && !!it._selected)
const picked = pickedAll.filter(m => m && (m.saleState === 0 || m.saleState === undefined || m.saleState === null))
const list = Array.isArray(this.dynamicRows) ? this.dynamicRows : []
const picked = list.filter(it => !!it && !!it._selected)
if (!picked.length) {
this.$message.warning('请先勾选至少一台矿机')
return
}
if (picked.length < pickedAll.length) {
this.$message.warning('部分机器已售出或售出中,已自动为您排除')
}
this.confirmAddDialog.items = picked.slice()
this.confirmAddDialog.items = picked.map(r => ({
...r,
leaseTime: Number(r.leaseTime || 1),
purchaseQuantity: Number(r.purchaseQuantity || 0)
}))
this.confirmAddDialog.visible = true
this.$nextTick(() => {
try { (this.machineList || []).forEach(m => this.$set(m, '_selected', false)) } catch (e) { /* noop */ }
})
}
}
}
@@ -802,10 +666,11 @@ export default {
margin: 0 10px 16px 10px;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(3, minmax(260px, 1fr));
gap: 14px 18px;
align-items: end;
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
align-items: flex-end;
justify-content: flex-start;
}
.filter-cell {
display: flex;
@@ -837,11 +702,11 @@ export default {
color: #9aa4b2;
}
.filter-actions {
display: flex;
display: inline-flex;
align-items: center;
gap: 10px;
grid-column: 2 / 3; /* 放到中间这一格 */
}
.action-row { display: inline-flex; align-items: center; gap: 10px; }
.filter-actions-inline {
display: inline-flex;
@@ -850,6 +715,24 @@ export default {
margin-left: 12px;
}
/* 动态表格样式 */
.dynamic-hashrate {
margin: 10px;
background: #fff;
border: 1px solid #eef2f7;
border-radius: 8px;
padding: 10px;
}
.dynamic-table :deep(.el-table__header th) {
background: #fafcff;
}
.col-header { display: inline-flex; align-items: center; gap: 6px; }
.col-icon { width: 16px; height: 16px; border-radius: 3px; }
.col-unit { color: #94a3b8; font-size: 12px; }
.more-action { margin-left: 8px; color: #2563eb; font-weight: 600; }
.input-full { width: 100%; }
:deep(.el-input-number.input-full) { width: 100%; }
@media (max-width: 1200px) {
.filter-grid { grid-template-columns: repeat(2, minmax(220px, 1fr)); }
.filter-cell--span-2 { grid-column: 1 / span 1; }

View File

@@ -1,4 +1,4 @@
import { getProductList } from '../../api/products'
import { getShopList } from '../../api/products'
export default {
name: 'ProductList',
data() {
@@ -172,134 +172,60 @@ export default {
screenCurrency: "",
searchAlgorithm: "",
params:{
coin: "",
algorithm: ""
pageNum: "1",
pageSize: "10",
keyword:""
},
productListLoading:false,
}
},
mounted() {
this.fetchGetList()
this.fetchShopList()
},
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)
// 获取商场页面的店铺列表
async fetchShopList(params) {
this.productListLoading = true
try{
// 仅允许 pageNum、pageSize、keyword 三个参数
const payload = {
pageNum: this.params.pageNum,
pageSize: this.params.pageSize
}
const kw = params && typeof params === 'object' ? params.keyword : this.params.keyword
if (kw) payload.keyword = kw
const res = await getShopList(payload)
if (res && (res.code === 0 || res.code === 200)) {
// 这里直接将店铺列表赋值到 products用于页面渲染
this.products = Array.isArray(res.rows) ? res.rows : []
} else {
this.products = []
}
}catch(e){
console.error('获取店铺列表失败:', e)
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
},
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)
this.params.keyword = keyword
this.fetchShopList(keyword ? { keyword } : 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()
this.params.keyword = ""
this.fetchShopList()
},
handleProductClick(product) {
if (product.id || product.id == 0) {
this.$router.push(`/product/${product.id}`);
const id = (product && (product.shopId != null ? product.shopId : product.id))
if (id !== undefined && id !== null) {
this.$router.push(`/product/${id}`);
}

View File

@@ -3,41 +3,15 @@
<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="输入算法关键词"
placeholder="输入币种或算法搜索"
clearable
@clear="handleAlgorithmClear"
@keyup.enter.native="handleAlgorithmSearch"
style="width: 240px;"
class="search-input"
>
<template #append>
<el-button type="primary" @click="handleAlgorithmSearch">搜索</el-button>
@@ -47,8 +21,8 @@
</section>
<div class="product-list-grid">
<div
v-for="product in products"
:key="product.id"
v-for="(product, idx) in products"
:key="product.shopId || product.id || idx"
class="product-item"
@click="handleProductClick(product)"
tabindex="0"
@@ -57,8 +31,22 @@
<!-- <img :src="product.image || 'https://img.yzcdn.cn/vant/apple-1.jpg'" :alt="product.name" class="product-image" /> -->
<img src="../../assets/imgs/commodity.png" :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>
<h4 class="title-line">
<span class="label">店铺</span>
<span class="value ellipsis" :title="product.shopName || product.name">{{ product.shopName || product.name }}</span>
</h4>
<p class="info-line coin-line">
<span class="label">币种</span>
<el-tooltip :content="product.coin" placement="top" :open-delay="80">
<span class="value ellipsis" tabindex="0" :aria-label="`币种 ${product.coin}`">{{ product.coin }}</span>
</el-tooltip>
</p>
<p class="info-line algorithm-line">
<span class="label">算法</span>
<el-tooltip :content="product.algorithm" placement="top" :open-delay="80">
<span class="value ellipsis" tabindex="0" :aria-label="`算法 ${product.algorithm}`">{{ product.algorithm }}</span>
</el-tooltip>
</p>
<div class="product-footer">
<div class="paytypes">
<span class="paytypes-label">支付方式</span>
@@ -74,7 +62,6 @@
</div>
<div class="right-meta">
<span class="product-sold" aria-label="已售数量">已售{{ product && product.saleNumber != null ? product.saleNumber : 0 }}</span>
<span class="shop-name">店铺{{ product && (product.shopName || product.name) }}</span>
</div>
</div>
</div>
@@ -90,7 +77,6 @@
</template>
<script>
import { listProducts } from "../../utils/productService";
import { addToCart } from "../../utils/cartManager";
import Index from "./index";
@@ -194,20 +180,70 @@ export default {
height: 40vh;
}
.product-image {
width: 68%;
height:65%;
width: 57%;
height:55%;
object-fit: cover;
margin-bottom: 12px;
}
.product-info {
width: 100%;
}
.title-line{
display: flex;
align-items: baseline;
gap: 6px;
font-size: 14px;
margin: 0 0 4px 0;
font-weight: normal;
}
.title-line .label{
font-weight: 700;
}
.info-line {
display: flex;
align-items: center;
gap: 6px;
}
.info-line .label { color: #334155; font-weight: 700; }
.info-line .value {
flex: 1;
min-width: 0;
}
.ellipsis {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.coin-line { font-size: 14px; margin-top: 8px; }
.algorithm-line { font-size: 14px; margin-top: 6px; }
.algorithm-line .value.bold { font-weight: bold; }
.product-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.search-input{
width: 420px;
}
/* 轻度统一高度与圆角,保留 Element 默认视觉 */
::v-deep .search-input .el-input__inner{
height: 40px;
line-height: 40px;
border-radius: 6px 0 0 6px;
padding: 0 14px;
}
::v-deep .search-input .el-input__inner::placeholder{
color: #9aa4b2; /* 略浅的占位符 */
}
::v-deep .search-input .el-input__inner:focus{
box-shadow: 0 0 0 2px rgba(64,158,255,.12);
}
::v-deep .search-input .el-input-group__append .el-button{
height: 40px;
border-radius: 0 6px 6px 0;
padding: 0 16px;
}
.right-meta{
display: flex;
flex-direction: column;