| | |
| | | class="w-5 h-3" /> |
| | | <div v-if="showOptions" class="options tabBackground w-36 absolute top-24 left-0 z-10"> |
| | | <div class="w-full" @click.stop="handleChoose(item)" |
| | | :class="{ 'option-active': form.lever_rate === item.lever_rate }" :value="item.lever_rate" |
| | | :class="{ 'option-active': Number(form.lever_rate) === Number(item.lever_rate) }" :value="item.lever_rate" |
| | | v-for="item in initData.lever" :key="item.id"> |
| | | {{ item.lever_rate }}x |
| | | </div> |
| | |
| | | <div class="h-18 greyBg mb-8 flex pr-5 justify-center rounded-lg textColor items-center" |
| | | v-if="selectIndex == 1"> |
| | | <input placeholder="" class="greyBg w-full pl-5 h-16 border-none text-left rounded-lg" |
| | | :disabled="type / 1 === 1" @focus="focus = true" v-model="form.price" /> |
| | | :disabled="type / 1 === 1" @focus="focus = true" v-model="form.price" |
| | | @input="onLimitPriceInput" /> |
| | | <span class="ml-5">{{ getCurrentUnit() }}</span> |
| | | </div> |
| | | |
| | |
| | | @click="onReduce"> |
| | | <img src="../../../assets/image/public/reduce.png" alt="add" class="w-8 h-2" /> |
| | | </div> |
| | | <input v-if="selectIndex == 1" :placeholder="$t('张数')" class="border-none greyBg text-center textColor" |
| | | <input v-if="selectIndex == 1" :placeholder="symbolUpper" class="border-none greyBg text-center textColor" |
| | | :class="['HK-stocks', 'JP-stocks','UK-stocks','DE-stocks','BZ-stocks'].includes(queryType) ? 'full-input' : 'half-input'" |
| | | v-model="form.amount" type="number" @input="onInput" /> |
| | | v-model="form.amount" type="number" @input="onInput" @blur="onAmountBlur" /> |
| | | <input v-if="selectIndex == 2 && Array.isArray(initFutrue.para) && initFutrue.para.length" |
| | | :placeholder="($t('最少') + (initFutrue && Array.isArray(initFutrue.para) && initFutrue.para[paraIndex] ? $t('最小金额') + initFutrue.para[paraIndex].buy_min : ''))" |
| | | class="border-none greyBg text-center textColor" |
| | |
| | | </div> |
| | | <template v-if="selectIndex == 1"> |
| | | <div class="mt-10 mb-8 w-full flex justify-between items-center"> |
| | | <span class="text-22 text-grey">{{ $t("可开张数") }}</span> |
| | | <span class="text-22 text-grey">{{ $t("最大数量") }} ({{ symbolUpper }})</span> |
| | | <span class="text-22 textColor"> |
| | | {{ initData.volume }} |
| | | {{ $t("张") }} |
| | | {{ maxOpenLotsFormatted }} |
| | | {{ symbolUpper }} |
| | | </span> |
| | | </div> |
| | | <!-- <vue-slider v-bind="options" v-model="form.amount"></vue-slider> --> |
| | | <!-- <vue-slider class="mainBox" v-bind="options" :marks="marks" v-model="form.volume" :hide-label="true" width="90%" |
| | | :railStyle="{ background: '#404040', height: '4px' }" |
| | | :processStyle="{ background: '#266BFF', height: '4px' }"> |
| | | <template v-slot:step="{ active }"> |
| | | <div :class="['custom-step', { active }]"></div> |
| | | </template> |
| | | </vue-slider> |
| | | <div style="color: #868D9A" class="mt-36 text-24 w-full flex justify-between items-center"> |
| | | <span class="flex-1 text-left">0%</span> |
| | | <span class="flex-1 text-left">25%</span> |
| | | <span class="flex-1 text-center">50%</span> |
| | | <span class="flex-1 text-right">75%</span> |
| | | <span class="flex-1 text-right">100%</span> |
| | | </div> --> |
| | | <!-- 张数输入 --> |
| | | <amount-slider ref="sliderRef" :maxAmount="getVolumnByLever()" @getAmount="getAmount"></amount-slider> |
| | | <!-- 仅保留一个数量滑块;上限随市价/限价委托价对应的 maxOpenLots 变化 --> |
| | | <amount-slider ref="sliderRef" :maxAmount="maxOpenLots" :amountValue="form.amount" |
| | | @getAmount="getAmount"></amount-slider> |
| | | </template> |
| | | <template v-if="selectIndex == 1 && userInfo && userInfo.token"> |
| | | <div class="flex justify-between mt-8"> |
| | | <div class="text-grey">{{ $t('合约金额') }}</div> |
| | | <div class="textColor">{{ initData.amount * (form.amount / 1) * form.lever_rate }} |
| | | <div class="textColor">{{ formatNum4(perpNotionalUsdt) }} |
| | | <span>{{ getCurrentUnit() }}</span> |
| | | </div> |
| | | </div> |
| | | <div class="flex justify-between mt-8"> |
| | | <div class="text-grey">{{ $t("保证金") }}</div> |
| | | <div class="textColor"> |
| | | {{ (initData.amount * (form.amount / 1)) }} {{ getCurrentUnit() }} |
| | | {{ formatNum4(perpMarginUsdt) }} {{ getCurrentUnit() }} |
| | | </div> |
| | | </div> |
| | | <div class="flex justify-between mt-8"> |
| | | <div class="text-grey">{{ $t("建仓手续费") }}</div> |
| | | <div class="textColor">{{ |
| | | userInfo.perpetual_contracts_status === '1' ? initData.fee * |
| | | (form.amount / 1) : initData.fee * (form.amount / 1) * form.lever_rate |
| | | }} {{ getCurrentUnit() }} |
| | | <div class="textColor">{{ formatNum4(perpOpenFeeUsdt) }} {{ getCurrentUnit() }} |
| | | </div> |
| | | </div> |
| | | </template> |
| | |
| | | |
| | | <script> |
| | | import config from "@/config"; |
| | | import { Popup, showToast } from 'vant'; |
| | | import { closeDialog, Popup, showToast } from 'vant'; |
| | | import { mapGetters } from 'vuex' |
| | | import VueSlider from 'vue-slider-component/dist-css/vue-slider-component.umd.js' |
| | | import 'vue-slider-component/theme/default.css' |
| | | import { _orderOpen, _futrueOrder, _futrueOrderDetail, _getBalance, _futrueOrderInit } from '@/service/trade.api' |
| | | import ContractFutrue from '@/components/Transform/contract-futrue/index.vue' |
| | | import PopupDelivery from "@/components/Transform/popup-delivery/index.vue"; |
| | | import { fixDate, throttle } from "@/utils/index.js"; |
| | | import AmountSlider from "./amountSlider.vue"; |
| | | import "vue-slider-component/theme/default.css"; |
| | | import { _getHomeList } from "@/service/home.api"; |
| | | import { themeStore } from '@/store/theme'; |
| | | import { currentUnit } from '@/utils/coinUnit.js' |
| | |
| | | export default { |
| | | name: "perpetualPosition", |
| | | components: { |
| | | VueSlider, |
| | | ContractFutrue, |
| | | PopupDelivery, |
| | | AmountSlider, |
| | |
| | | if (JSON.stringify(this.initFutrue.para) != '[]') { |
| | | this.form.para_id = this.initFutrue.para && this.initFutrue.para[this.paraIndex].para_id // 不优雅,不可靠 |
| | | } |
| | | |
| | | if (this.selectIndex / 1 === 1) { |
| | | this.handleInitSliderOption(true) |
| | | } |
| | | }, |
| | | 'form.price'() { |
| | | if (this.selectIndex / 1 === 1) { |
| | | this.handleInitSliderOption(true) |
| | | } |
| | | }, |
| | | 'form.lever_rate'() { |
| | | if (this.selectIndex / 1 === 1) { |
| | | this.handleInitSliderOption(true) |
| | | } |
| | | }, |
| | | type() { |
| | | if (this.selectIndex / 1 === 1) { |
| | | this.handleInitSliderOption(true) |
| | | } |
| | | }, |
| | | initOpen: { // 处理滚动条初始值 |
| | | deep: true, |
| | |
| | | } |
| | | return this.initFutrue |
| | | }, |
| | | coudBuyVolume() { // 可买数量 |
| | | return Math.floor((this.initOpen.volume / 1) / this.form.lever_rate) |
| | | /** |
| | | * 永续下单可用 USDT:与行情页「账户余额」一致,优先 userInfo.balance(钱包 USDT); |
| | | * openview 返回的 volume 在部分接口里并非可用保证金,仅用 initOpen.volume 会导致可开数量极小。 |
| | | */ |
| | | contractAvailableUsdt() { |
| | | const fromUser = parseFloat(this.userInfo && this.userInfo.balance) |
| | | if (fromUser > 0 && isFinite(fromUser)) return fromUser |
| | | const fromOpen = parseFloat(this.initOpen.volume) |
| | | if (fromOpen > 0 && isFinite(fromOpen)) return fromOpen |
| | | return 0 |
| | | }, |
| | | /** |
| | | * 最大可开数量(币本位): |
| | | * - 市价:按最新成交价/行情价折算; |
| | | * - 限价:按输入委托价折算。 |
| | | * 公式:maxQty = balance * lever / (price * (1 + feeRate)) |
| | | * 其中 feeRate 来自开仓初始化接口(initOpen.fee),不再写死 0.01。 |
| | | */ |
| | | maxOpenLots() { |
| | | if (this.selectIndex / 1 !== 1) { |
| | | return Math.max(0, Number(this.initOpen.volume) || 0) |
| | | } |
| | | if (!this.userInfo || !this.userInfo.token) return 0 |
| | | const balance = this.contractAvailableUsdt |
| | | if (!(balance > 0) || !isFinite(balance)) return 0 |
| | | |
| | | const lever = Math.max(1, Number(this.form.lever_rate) || 1) |
| | | if (!(lever > 0) || !isFinite(lever)) return 0 |
| | | |
| | | const markPrice = parseFloat(this.price) |
| | | const limitP = parseFloat(this.form.price) |
| | | const isLimit = String(this.type) === '2' || Number(this.type) === 2 |
| | | |
| | | let priceForMax |
| | | if (isLimit) { |
| | | if (limitP > 0 && isFinite(limitP)) { |
| | | priceForMax = limitP |
| | | } else { |
| | | return 0 |
| | | } |
| | | } else if (markPrice > 0 && isFinite(markPrice)) { |
| | | priceForMax = markPrice |
| | | } else if (limitP > 0 && isFinite(limitP)) { |
| | | priceForMax = limitP |
| | | } else { |
| | | return 0 |
| | | } |
| | | |
| | | // 与 maxOpenableBaseQuantity 对齐: |
| | | // costPerUnit = marginPerUnit + feePerUnit |
| | | // marginPerUnit = price / L |
| | | const marginPerUnit = priceForMax / lever |
| | | const feeNum = Number(this.initOpen && this.initOpen.fee) |
| | | let feePerUnit = 0 |
| | | if (Number.isFinite(feeNum) && feeNum > 0) { |
| | | // 小于 0.05 按费率(名义 × 费率 × 杠杆);否则按每单位绝对费用 × 杠杆 |
| | | feePerUnit = feeNum < 0.05 ? priceForMax * feeNum * lever : feeNum * lever |
| | | } |
| | | const costPerUnit = marginPerUnit + feePerUnit |
| | | if (!Number.isFinite(costPerUnit) || costPerUnit <= 0) return 0 |
| | | |
| | | let maxQty = balance / costPerUnit |
| | | const serverVol = Number(this.initOpen && this.initOpen.volume) |
| | | // fee 为小数费率时,沿用快捷式与旧逻辑一致 |
| | | if ( |
| | | Number.isFinite(serverVol) && |
| | | serverVol > 0 && |
| | | Number.isFinite(priceForMax) && |
| | | priceForMax > 0 && |
| | | Number.isFinite(feeNum) && |
| | | feeNum > 0 && |
| | | feeNum < 0.05 |
| | | ) { |
| | | maxQty = ((balance * lever) / (1 + feeNum * lever)) / priceForMax |
| | | } |
| | | |
| | | if (!Number.isFinite(maxQty) || maxQty <= 0) return 0 |
| | | if (maxQty <= 1e-10) return 0 |
| | | |
| | | const minLot = Number( |
| | | this.initOpen && ( |
| | | this.initOpen.buy_min ?? |
| | | this.initOpen.min_qty ?? |
| | | this.initOpen.min_volume ?? |
| | | this.initOpen.min |
| | | ) |
| | | ) |
| | | if (Number.isFinite(minLot) && minLot > 0 && maxQty < minLot) return 0 |
| | | |
| | | // 保留计算精度,不做四舍五入、不做截断 |
| | | return maxQty |
| | | }, |
| | | /** 最大可开数量展示为四位小数 */ |
| | | maxOpenLotsFormatted() { |
| | | const n = this.maxOpenLots |
| | | if (!(n >= 0) || !isFinite(n)) return '0' |
| | | // 展示原始精度(不四舍五入、不截断) |
| | | return String(n) |
| | | }, |
| | | coudBuyVolume() { |
| | | return this.maxOpenLots |
| | | }, |
| | | /** 当前交易品种大写,作数量单位展示 */ |
| | | symbolUpper() { |
| | | const s = this.symbol || (this.$route && this.$route.params && this.$route.params.symbol) || '' |
| | | return String(s).trim().toUpperCase() || '--' |
| | | }, |
| | | /** 永续展示用有效价:限价用委托价;市价用行情价(与 maxOpenLots 口径一致) */ |
| | | perpCalcPrice() { |
| | | if (this.selectIndex / 1 !== 1) return 0 |
| | | const isLimit = String(this.type) === '2' || Number(this.type) === 2 |
| | | const fp = parseFloat(this.form.price) |
| | | const mp = parseFloat(this.price) |
| | | if (isLimit) { |
| | | if (fp > 0 && isFinite(fp)) return fp |
| | | return (mp > 0 && isFinite(mp)) ? mp : 0 |
| | | } |
| | | if (mp > 0 && isFinite(mp)) return mp |
| | | return (fp > 0 && isFinite(fp)) ? fp : 0 |
| | | }, |
| | | perpCalcQty() { |
| | | const q = parseFloat(this.form.amount) |
| | | return (q > 0 && isFinite(q)) ? q : 0 |
| | | }, |
| | | perpCalcLever() { |
| | | const L = parseFloat(this.form.lever_rate) |
| | | return (L > 0 && isFinite(L)) ? L : 1 |
| | | }, |
| | | |
| | | /** 扣除建仓手续费后的有效数量(按当前价格折算) */ |
| | | perpNetQtyAfterFee() { |
| | | const qty = this.perpCalcQty |
| | | const p = this.perpCalcPrice |
| | | if (!(qty > 0) || !(p > 0) || !isFinite(qty) || !isFinite(p)) return 0 |
| | | const feeInQty = this.perpOpenFeeUsdt / p |
| | | const netQty = qty - feeInQty |
| | | return netQty > 0 && isFinite(netQty) ? netQty : 0 |
| | | }, |
| | | /** 合约名义价值 ≈ 价格 × 扣费后数量(USDT) */ |
| | | perpNotionalUsdt() { |
| | | return this.perpCalcPrice * this.perpNetQtyAfterFee |
| | | }, |
| | | /** 保证金 ≈ 名义价值 ÷ 杠杆,切换杠杆会立刻变 */ |
| | | perpMarginUsdt() { |
| | | const L = this.perpCalcLever |
| | | return L > 0 ? (this.perpNotionalUsdt / L) : 0 |
| | | }, |
| | | /** 建仓手续费:与接口约定一致,依赖数量与杠杆 */ |
| | | perpOpenFeeUsdt() { |
| | | const qty = this.perpCalcQty |
| | | const fee = parseFloat(this.initOpen.fee) || 0 |
| | | const lever = this.perpCalcLever |
| | | const price = this.perpCalcPrice |
| | | if (!(qty > 0) || !isFinite(qty)) return 0 |
| | | if (String(this.userInfo.perpetual_contracts_status) === '1') { |
| | | return fee * qty |
| | | } |
| | | return fee * qty * price / lever |
| | | }, |
| | | }, |
| | | data() { |
| | |
| | | this.clearTimeout() |
| | | }, |
| | | methods: { |
| | | /** 限价委托价变化:刷新滑块上限与 options.max(maxOpenLots 为计算属性会随 form.price 自动变) */ |
| | | onLimitPriceInput() { |
| | | this.$nextTick(() => { |
| | | if (this.selectIndex / 1 !== 1) return |
| | | this.handleInitSliderOption(true) |
| | | }) |
| | | }, |
| | | //获取张数 |
| | | getAmount(val) { |
| | | if (!val) { |
| | |
| | | getCurrentUnit() { |
| | | return currentUnit(this.queryType) |
| | | }, |
| | | formatNum4(n) { |
| | | const v = parseFloat(n) |
| | | if (!isFinite(v)) return '0.0000' |
| | | const factor = 10000 |
| | | const truncated = Math.trunc(v * factor) / factor |
| | | return truncated.toFixed(4) |
| | | }, |
| | | onSelect(item) { |
| | | this.actions.map((item) => { |
| | | item.className = '' |
| | |
| | | this.showType = item.value; |
| | | this.$emit("changeValueBack", this.showType); |
| | | }, |
| | | // 获取张数,数据转换 |
| | | getVolumnByLever() { |
| | | let vol; |
| | | vol = this.initOpen.volume / 1; |
| | | return Math.floor(vol); |
| | | return this.maxOpenLots |
| | | }, |
| | | selectItemArry(item) { |
| | | this.dataArrValue = item.value; |
| | |
| | | }, |
| | | handleInitSliderOption(amount) { |
| | | if (!amount) { |
| | | // 金额是否需要变动 |
| | | this.form.amount = ""; |
| | | } |
| | | console.log(this.initOpen.volume, this.form.lever_rate); |
| | | let vol; |
| | | vol = this.initOpen.volume / 1; |
| | | this.options.max = Math.floor(vol); |
| | | console.log("this.options.max", this.options.max); |
| | | if (this.options.max > 0) { |
| | | this.options.disabled = false; |
| | | let vol |
| | | if (this.selectIndex / 1 === 1) { |
| | | vol = this.maxOpenLots |
| | | } else { |
| | | this.options.disabled = true; |
| | | vol = Math.floor(Number(this.initOpen.volume) || 0) |
| | | } |
| | | this.options.max = vol |
| | | if (this.options.max > 0) { |
| | | this.options.disabled = false |
| | | } else { |
| | | this.options.disabled = true |
| | | } |
| | | if (this.selectIndex / 1 === 1) { |
| | | const amt = parseFloat(this.form.amount) |
| | | const mx = parseFloat(this.options.max) |
| | | if (isFinite(amt) && isFinite(mx) && amt > mx + 1e-10) { |
| | | this.form.amount = mx |
| | | } |
| | | } |
| | | }, |
| | | handleChoose(item) { |
| | | this.showOptions = !this.showOptions |
| | | this.form.lever_rate = item.lever_rate |
| | | console.log('handleChoose') |
| | | this.handleInitSliderOption() |
| | | // 选中后关闭下拉;杠杆统一为数字,避免 "100" 与 100 比较不一致 |
| | | this.showOptions = false |
| | | this.form.lever_rate = Number(item.lever_rate) |
| | | this.getAmount() |
| | | // 勿调用 handleInitSliderOption() 无参:会清空 form.amount,导致合约金额/与价格相关展示错乱 |
| | | // 杠杆变化由 watch form.lever_rate 触发 handleInitSliderOption(true),保留已填数量并刷新上限 |
| | | }, |
| | | onAdd() { // + |
| | | if (this.options.max === 0) { |
| | | return |
| | | } |
| | | if (this.form.amount === this.options.max) { |
| | | return; |
| | | } |
| | | this.form.amount++ |
| | | const cur = parseFloat(this.form.amount) || 0 |
| | | const mx = parseFloat(this.options.max) || 0 |
| | | if (cur >= mx - 1e-8) return |
| | | const next = Math.min(Math.floor((cur + 1) * 10000) / 10000, mx) |
| | | this.form.amount = next |
| | | }, |
| | | onReduce() { // - |
| | | if (this.form.amount > 1) { |
| | | this.form.amount-- |
| | | const cur = parseFloat(this.form.amount) || 0 |
| | | if (cur > 1) { |
| | | this.form.amount = Math.floor((cur - 1) * 10000) / 10000 |
| | | } else if (cur > 0) { |
| | | this.form.amount = 0 |
| | | } |
| | | }, |
| | | onParaId(evt) { // 交割日期 |
| | |
| | | // console.log(1111111, this.form.amount, this.selectIndex) |
| | | if (this.selectIndex == 1 && this.options.max == 0) { |
| | | this.form.amount = this.form.amount / 1 |
| | | } else if (this.selectIndex == 1 && this.form.amount / 1 > this.options.max / 1) { |
| | | this.form.amount = this.options.max / 1 |
| | | } else if (this.selectIndex == 1) { |
| | | const amt = parseFloat(this.form.amount) |
| | | const mx = parseFloat(this.options.max) |
| | | if (isFinite(amt) && isFinite(mx) && amt > mx) { |
| | | this.form.amount = mx |
| | | } |
| | | } |
| | | }, |
| | | /** 数量展示:截断四位小数(市价/限价均按当前 perpCalcPrice 算下方合约金额) */ |
| | | onAmountBlur() { |
| | | if (this.selectIndex / 1 !== 1) return |
| | | const raw = this.form.amount |
| | | if (raw === '' || raw === null || raw === undefined) return |
| | | const v = parseFloat(raw) |
| | | if (!isFinite(v)) { |
| | | this.form.amount = '' |
| | | return |
| | | } |
| | | const factor = 10000 |
| | | const truncated = Math.trunc(v * factor) / factor |
| | | this.form.amount = truncated === 0 ? '' : truncated |
| | | }, |
| | | //价格类型下拉框切换 |
| | | selectBtn() { |
| | |
| | | } |
| | | if (!this.form.amount) { |
| | | if (this.selectIndex == 1) { |
| | | showToast(this.$t('请输入合约张数')) |
| | | showToast(`${this.$t('请输入').trim()} ${this.symbolUpper}`) |
| | | } else { |
| | | showToast(this.$t('请输入金额')) |
| | | } |