| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763 |
- <script setup lang="ts">
- import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
- import { useRoute, useRouter } from 'vue-router'
- import { storeToRefs } from 'pinia'
- import { init, dispose } from 'klinecharts'
- import { useFuturesStore, type KlineResolution, type Position, type ContractOrder, type HistoryPosition, type HistoryOrder } from '@/stores/futures'
- import { useCoinsStore } from '@/stores/coins'
- import { useAuthStore } from '@/stores/auth'
- import { useMarketWs, type WsTradeItem, BAR_MS, BAR_COUNT } from '@/composables/useMarketWs'
- import { get, post, postForm } from '@/utils/request'
- import { useI18n } from 'vue-i18n'
- import { normalizeTransferPair } from '@/stores/transfer'
- import { walletTypeLabel } from '@/utils/walletTypeLabel'
- import { extractUsdtAvailableFromSpotAssets } from '@/utils/spotAsset'
- import { futuresPairToRouteParam } from '@/utils/futuresSymbol'
- import AppModal from '@/components/common/AppModal.vue'
- const route = useRoute()
- const router = useRouter()
- const { t, te, locale } = useI18n()
- const store = useFuturesStore()
- const coinsStore = useCoinsStore()
- const authStore = useAuthStore()
- const {
- symbol, displaySymbol, ticker, orderBook, klines, klineResolution,
- symbolInfo, positions, currentOrders, leverage, loading, orderLoading, error,
- allTickers, priceUp, spotAvailBalance, swapAvailBalance,
- historyPositions, historyOrders,
- fundingRecords,
- coinExtInfo, coinExtLoading,
- fundingRateCurrent, fundingRateHistory,
- marginMode, amountUnit, accountInfo, isSwitchingMode,
- } = storeToRefs(store)
- const { isLoggedIn } = storeToRefs(authStore)
- // ── UI state ───────────────────────────────────────────
- const subNavTab = ref<'chart' | 'coin-info' | 'funding-rate' | 'rules' | 'tiers'>('chart')
- const bookTab = ref<'orderbook' | 'trades'>('orderbook')
- const bookLayout = ref<'full' | 'asks' | 'bids'>('full')
- const bookColRef = ref<HTMLElement | null>(null)
- const bookTabsRef = ref<HTMLElement | null>(null)
- const bookCtrlRef = ref<HTMLElement | null>(null)
- const bookHeadRef = ref<HTMLElement | null>(null)
- const bookMidRef = ref<HTMLElement | null>(null)
- const bsRatioRef = ref<HTMLElement | null>(null)
- const dynamicBookRows = ref(20)
- const showPairSelector = ref(false)
- const showLeveragePanel = ref(false)
- const showMarginDrop = ref(false)
- const showCondDrop = ref(false)
- const showPrecDrop = ref(false)
- const showAmountUnitDrop = ref(false)
- const pairSearch = ref('')
- const fundingCountdown = ref('00:00:00')
- // ── 资金费率折线图 ──────────────────────────────────────
- const frHoverIdx = ref(-1)
- const frChartRef = ref<SVGSVGElement | null>(null)
- // SVG 画布常量
- const FR_W = 600, FR_H = 200
- const FR_PL = 8, FR_PR = 62, FR_PT = 12, FR_PB = 24
- const FR_PW = FR_W - FR_PL - FR_PR
- const FR_PH = FR_H - FR_PT - FR_PB
- function frTimeToMs(t: number | string | undefined): number {
- if (!t) return 0
- return typeof t === 'string' ? new Date(t).getTime() : Number(t)
- }
- function frFmtMMDD(ts: number): string {
- const d = new Date(ts)
- return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`
- }
- function frFmtDatetime(ts: number): string {
- const d = new Date(ts)
- return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
- }
- const frItems = computed(() => {
- const cutoff = Date.now() - 7 * 24 * 3600 * 1000
- return fundingRateHistory.value
- .map(item => ({
- time: frTimeToMs(item.fundingTime),
- rate: Number(item.fundingRate ?? item.rate ?? 0),
- }))
- .filter(d => d.time > 0 && d.time >= cutoff)
- .sort((a, b) => a.time - b.time)
- })
- const frChart = computed(() => {
- const items = frItems.value
- if (items.length < 2) return null
- const rates = items.map(d => d.rate)
- const minR = Math.min(...rates)
- const maxR = Math.max(...rates)
- const rangePad = Math.max((maxR - minR) * 0.25, 0.000001)
- const yMin = minR - rangePad
- const yMax = maxR + rangePad
- const n = items.length
- const toX = (i: number) => FR_PL + (i / Math.max(n - 1, 1)) * FR_PW
- const toY = (r: number) => FR_PT + (1 - (r - yMin) / (yMax - yMin)) * FR_PH
- const pts = items.map((d, i) => ({ x: toX(i), y: toY(d.rate), time: d.time, rate: d.rate }))
- const linePath = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
- const zeroY = toY(0)
- const baseY = Math.max(Math.min(zeroY, FR_PT + FR_PH), FR_PT)
- const areaPath = linePath + ` L${(FR_PL + FR_PW).toFixed(1)},${baseY.toFixed(1)} L${FR_PL.toFixed(1)},${baseY.toFixed(1)} Z`
- const showZero = zeroY > FR_PT && zeroY < FR_PT + FR_PH
- const xStep = Math.max(1, Math.floor(n / 5))
- const xLabels = pts.filter((_, i) => i % xStep === 0 || i === n - 1).map(p => ({ x: p.x, label: frFmtMMDD(p.time) }))
- const yLabels: { y: number; label: string }[] = []
- const hGrid: number[] = []
- for (let i = 0; i <= 4; i++) {
- const r = yMin + (yMax - yMin) * (1 - i / 4)
- const y = toY(r)
- if (y > FR_PT + 2 && y < FR_PT + FR_PH - 2) {
- yLabels.push({ y, label: (r * 100).toFixed(3) + '%' })
- hGrid.push(y)
- }
- }
- return { pts, linePath, areaPath, zeroY, showZero, xLabels, yLabels, hGrid }
- })
- // 优先用 HTTP 接口费率(预测下期),降级到 WS 实时值
- const frCurrentRate = computed<number | null>(() => {
- const h = fundingRateCurrent.value
- if (h) {
- const v = Number(h.fundingRate ?? h.rate ?? NaN)
- if (!isNaN(v)) return v
- }
- return wsFundingRate.value ? parseFloat(wsFundingRate.value) : null
- })
- const frCurrentRateStr = computed(() => {
- const r = frCurrentRate.value
- if (r === null) return '--'
- return (r >= 0 ? '+' : '') + (r * 100).toFixed(4) + '%'
- })
- // 从历史数据的时间间隔推算结算周期(对齐 Flutter 的 '8H' 逻辑)
- const frPeriodLabel = computed(() => {
- const items = frItems.value
- if (items.length < 2) return '8H'
- const diffs: number[] = []
- for (let i = 1; i < Math.min(items.length, 10); i++) {
- diffs.push(Math.round((items[i].time - items[i - 1].time) / 3600000))
- }
- const counts = new Map<number, number>()
- diffs.forEach(d => counts.set(d, (counts.get(d) || 0) + 1))
- let maxCount = 0, mode = 8
- counts.forEach((cnt, val) => { if (cnt > maxCount) { maxCount = cnt; mode = val } })
- return `${mode}H`
- })
- function onFrMouseMove(e: MouseEvent) {
- const svg = frChartRef.value
- const chart = frChart.value
- if (!svg || !chart) { frHoverIdx.value = -1; return }
- const rect = svg.getBoundingClientRect()
- const svgX = (e.clientX - rect.left) / rect.width * FR_W
- let best = 0, bestDist = Infinity
- chart.pts.forEach((p, i) => {
- const d = Math.abs(p.x - svgX)
- if (d < bestDist) { bestDist = d; best = i }
- })
- frHoverIdx.value = best
- }
- function onFrMouseLeave() { frHoverIdx.value = -1 }
- // 列表高度跟踪:用 ResizeObserver 动态计算可见行数
- const frListRef = ref<HTMLElement | null>(null)
- const frListHeight = ref(300)
- const FR_ROW_H = 35 // 每行高度(padding 8+8 + 字号12*行高1.3 + 1px边框)
- const FR_HEAD_H = 31 // 表头高度
- let frListRo: ResizeObserver | null = null
- watch(frListRef, (el) => {
- frListRo?.disconnect()
- if (el) {
- frListRo = new ResizeObserver(() => { frListHeight.value = el.clientHeight })
- frListRo.observe(el)
- frListHeight.value = el.clientHeight
- }
- })
- const frVisibleItems = computed(() => {
- const count = Math.max(1, Math.floor((frListHeight.value - FR_HEAD_H) / FR_ROW_H))
- return [...frItems.value].reverse().slice(0, count)
- })
- // ── WebSocket real-time data ────────────────────────────
- const wsMarkPrice = ref('')
- const wsFundingRate = ref('')
- const wsFundingNextTime = ref(0)
- const recentTrades = ref<WsTradeItem[]>([])
- const rawBookAsks = ref<[number, number][]>([])
- const rawBookBids = ref<[number, number][]>([])
- const klineRealtimeClose = ref<number | null>(null)
- let countdownTimer: ReturnType<typeof setInterval> | null = null
- // 逐笔价格方向:当前价格 vs 上一次价格
- const tickPriceUp = ref(true)
- let prevTickClose = 0
- // klinecharts v10 real-time update callback (set via subscribeBar)
- let klineBarCallback: ((bar: any) => void) | null = null
- let bookRo: ResizeObserver | null = null
- function updateCountdown() {
- if (!wsFundingNextTime.value) return
- const diff = wsFundingNextTime.value - Date.now()
- if (diff <= 0) { fundingCountdown.value = '00:00:00'; return }
- const h = Math.floor(diff / 3_600_000)
- const m = Math.floor((diff % 3_600_000) / 60_000)
- const s = Math.floor((diff % 60_000) / 1000)
- fundingCountdown.value = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
- }
- const ws = useMarketWs({
- onConnected() {
- ws.requestKlines()
- },
- onKlineHistory(bars) {
- if (!bars.length || !chart) return
- currentBars = bars
- const period = RESOLUTION_PERIOD[klineResolution.value] ?? { type: 'minute', span: 1 }
- if (JSON.stringify(chart.getPeriod()) !== JSON.stringify(period)) {
- chart.setPeriod(period)
- } else {
- chart.resetData()
- }
- },
- onKline(bar) {
- // 与图表蜡烛方向同步:当前周期 close >= open 则绿,否则红
- tickPriceUp.value = bar.close >= bar.open
- klineRealtimeClose.value = bar.close
- klineBarCallback?.({
- timestamp: bar.timestamp,
- open: bar.open, high: bar.high, low: bar.low, close: bar.close, volume: bar.volume,
- })
- },
- onTicker(data) {
- const closeN = parseFloat(data.close)
- const openN = parseFloat(data.open)
- // kline 未到达前用 24h open/close 初始化方向
- if (prevTickClose === 0) tickPriceUp.value = closeN >= openN
- prevTickClose = closeN
- const chg = openN > 0 ? String((closeN - openN) / openN) : (ticker.value?.chg ?? '0')
- const change = openN > 0 ? String(closeN - openN) : (ticker.value?.change ?? '0')
- ticker.value = {
- symbol: symbol.value,
- close: data.close,
- open: data.open,
- high: data.high,
- low: data.low,
- volume: data.volume,
- turnover: data.turnover,
- chg,
- change,
- }
- },
- onMark(data) {
- wsMarkPrice.value = data.markPrice
- wsFundingRate.value = data.fundingRate
- wsFundingNextTime.value = data.nextFundingTime
- updateCountdown()
- },
- onTrade(item) {
- recentTrades.value.unshift(item)
- if (recentTrades.value.length > 40) recentTrades.value.length = 40
- },
- onKlineHistoryMore(bars) {
- const cb = loadMoreCallback
- loadMoreCallback = null
- if (!cb) return
- if (!bars.length) {
- cb([], false) // 服务端没有更早数据,more=false 告知图表停止请求
- return
- }
- const firstTs = currentBars[0]?.timestamp ?? Infinity
- const newBars = bars
- .filter(b => b.timestamp < firstTs)
- .sort((a, b) => a.timestamp - b.timestamp)
- if (newBars.length > 0) {
- currentBars = [...newBars, ...currentBars]
- }
- // 仅传 newBars,klinecharts forward 回调会将它们 prepend 到现有数据前面
- // more=true 继续允许加载更早数据,more=false 表示没有更早数据了
- cb(newBars, newBars.length > 0)
- },
- onBook(data) {
- const toPair = (rows: [string, string][]) =>
- rows
- .map(([p, q]) => [parseFloat(p), parseFloat(q)] as [number, number])
- .filter(([p, q]) => p > 0 && q > 0 && Number.isFinite(p) && Number.isFinite(q))
- rawBookAsks.value = toPair(data.asks).sort((a, b) => a[0] - b[0])
- rawBookBids.value = toPair(data.bids).sort((a, b) => b[0] - a[0])
- const addTotal = (rows: [string, string][]) => {
- let acc = 0
- return rows.map(([price, qty]) => {
- acc += parseFloat(qty)
- return { price, amount: qty, total: String(acc) }
- })
- }
- orderBook.value = {
- ask: { items: addTotal(data.asks) },
- bid: { items: addTotal(data.bids) },
- }
- },
- })
- // ── Chart ──────────────────────────────────────────────
- const chartEl = ref<HTMLDivElement | null>(null)
- let chart: ReturnType<typeof init> | null = null
- let ro: ResizeObserver | null = null
- // 向左滚动加载更多历史数据的待回调
- let loadMoreCallback: ((bars: any[], noMore: boolean) => void) | null = null
- const showMA = ref(true)
- const showVol = ref(true)
- const crosshairData = ref<any>(null)
- const activeDrawTool = ref('cursor')
- const chartWrapWidth = ref(0)
- const tooltipStyle = computed(() => {
- const x = crosshairData.value?.x ?? 0
- if (x > chartWrapWidth.value / 2) {
- return { left: '4px', right: 'auto' }
- }
- return { left: 'auto', right: '4px' }
- })
- const TF_OPTIONS = computed<{ label: string; value: KlineResolution }[]>(() => [
- { label: t('futuresPage.tf.m1'), value: '1' },
- { label: t('futuresPage.tf.m5'), value: '5' },
- { label: t('futuresPage.tf.m15'), value: '15' },
- { label: t('futuresPage.tf.m30'), value: '30' },
- { label: t('futuresPage.tf.h1'), value: '60' },
- { label: t('futuresPage.tf.h4'), value: '240' },
- { label: t('futuresPage.tf.d1'), value: '1D' },
- ])
- const DRAW_TOOLS: Record<string, string> = {
- trend: 'segment',
- hline: 'horizontalStraightLine',
- ray: 'rayLine',
- channel: 'priceChannelLine',
- fib: 'fibonacciLine',
- text: 'simpleAnnotation',
- }
- let currentBars: any[] = []
- const RESOLUTION_PERIOD: Record<string, { type: 'minute' | 'hour' | 'day'; span: number }> = {
- '1': { type: 'minute', span: 1 },
- '5': { type: 'minute', span: 5 },
- '15': { type: 'minute', span: 15 },
- '30': { type: 'minute', span: 30 },
- '60': { type: 'hour', span: 1 },
- '240': { type: 'hour', span: 4 },
- '1D': { type: 'day', span: 1 },
- }
- function convertKlines() {
- const seen = new Set<number>()
- return klines.value
- .map(b => ({
- timestamp: b.time > 1e10 ? b.time : b.time * 1000,
- open: parseFloat(String(b.open)),
- high: parseFloat(String(b.high)),
- low: parseFloat(String(b.low)),
- close: parseFloat(String(b.close)),
- volume: b.volume > 0 ? b.volume : Math.max(0.01, parseFloat(ticker.value?.volume || '1') / 200),
- }))
- .sort((a, b) => a.timestamp - b.timestamp)
- .filter(b => { if (seen.has(b.timestamp)) return false; seen.add(b.timestamp); return true })
- }
- function destroyChart() {
- ro?.disconnect(); ro = null
- if (chartEl.value) dispose(chartEl.value)
- chart = null; currentBars = []; klineBarCallback = null; loadMoreCallback = null
- }
- function initChart() {
- if (!chartEl.value) return
- chart = init(chartEl.value, {
- locale: locale.value.startsWith('zh') ? 'zh-CN' : 'en-US',
- timezone: 'Asia/Shanghai',
- styles: {
- grid: {
- horizontal: { color: 'rgba(255,255,255,0.04)', size: 1 },
- vertical: { color: 'rgba(255,255,255,0.04)', size: 1 },
- },
- candle: {
- type: 'candle_solid',
- bar: {
- upColor: '#02c076', downColor: '#f6465d', noChangeColor: '#888',
- upBorderColor: '#02c076', downBorderColor: '#f6465d', noChangeBorderColor: '#888',
- upWickColor: '#02c076', downWickColor: '#f6465d', noChangeWickColor: '#888',
- },
- priceMark: {
- high: { show: false },
- low: { show: false },
- last: {
- show: true,
- upColor: '#02c076', downColor: '#f6465d', noChangeColor: '#888',
- line: { show: true, style: 'dashed', dashedValue: [4, 4], size: 1 },
- text: { show: true, size: 11 },
- },
- },
- tooltip: { showRule: 'none', showType: 'standard' },
- },
- crosshair: {
- show: true,
- horizontal: {
- show: true,
- line: { show: true, style: 'dashed', dashedValue: [4, 2], size: 1, color: 'rgba(255,255,255,0.2)' },
- text: { show: true, size: 11, color: '#d1d1d2', backgroundColor: '#1d1d1d' },
- },
- vertical: {
- show: true,
- line: { show: true, style: 'dashed', dashedValue: [4, 2], size: 1, color: 'rgba(255,255,255,0.2)' },
- text: { show: true, size: 11, color: '#d1d1d2', backgroundColor: '#1d1d1d' },
- },
- },
- xAxis: {
- axisLine: { show: true, color: 'rgba(255,255,255,0.07)', size: 1 },
- tickText: { show: true, color: '#6a7182', size: 11 },
- tickLine: { show: true, color: 'rgba(255,255,255,0.07)', size: 1, length: 3 },
- },
- yAxis: {
- axisLine: { show: true, color: 'rgba(255,255,255,0.07)', size: 1 },
- tickText: { show: true, color: '#6a7182', size: 11 },
- tickLine: { show: true, color: 'rgba(255,255,255,0.07)', size: 1, length: 3 },
- },
- separator: { color: '#2a2a2a', size: 3 },
- indicator: { tooltip: { showRule: 'none' } },
- overlay: {
- point: {
- color: '#f0b90b', borderColor: 'rgba(240,185,11,0.35)',
- activeColor: '#f0b90b', activeBorderColor: 'rgba(240,185,11,0.6)',
- borderSize: 1, radius: 4, activeBorderSize: 2, activeRadius: 5,
- },
- line: { color: '#f0b90b', size: 1, style: 'solid' },
- text: { color: '#f0b90b', size: 12 },
- },
- },
- })
- if (!chart) return
- if (showMA.value) {
- chart.createIndicator({
- name: 'MA',
- calcParams: [5, 10, 30],
- styles: { lines: [{ color: '#f0b90b', size: 1 }, { color: '#5d9cf5', size: 1 }, { color: '#e84d5b', size: 1 }] },
- }, { isStack: true, pane: { id: 'candle_pane' } })
- }
- if (showVol.value) {
- chart.createIndicator(
- { name: 'VOL', calcParams: [], styles: {
- bars: [{ style: 'fill' }, { style: 'fill' }],
- lines: [{ size: 0, color: 'rgba(0,0,0,0)' }, { size: 0, color: 'rgba(0,0,0,0)' }, { size: 0, color: 'rgba(0,0,0,0)' }],
- lastValueMark: { show: false },
- tooltip: { showRule: 'none' },
- }},
- { isStack: false, pane: { id: 'vol-pane', height: 80, minHeight: 40 } },
- )
- }
- chart.setSymbol({
- name: displaySymbolNoDash.value,
- pricePrecision: futuresPriceDecimals(),
- volumePrecision: 4,
- })
- chart.setPeriod(RESOLUTION_PERIOD[klineResolution.value] ?? { type: 'minute', span: 1 })
- currentBars = convertKlines()
- chart.setDataLoader({
- getBars: (params: any) => {
- if (params.type === 'init') {
- // 初始加载:backward=false(实时由subscribeBar处理),forward=true(可向左请求更多历史)
- params.callback(currentBars, { backward: false, forward: true })
- return
- }
- if (params.type === 'backward') {
- // 用户滚到右侧最新位置,subscribeBar 已处理实时,这里不重复
- params.callback([], false)
- return
- }
- // type === 'forward':用户滚到左侧最早位置,params.timestamp 是最旧 bar 的时间戳
- if (!params.timestamp) {
- params.callback([], false)
- return
- }
- const to = params.timestamp as number
- const from = to - (BAR_MS[klineResolution.value] ?? BAR_MS['60']) * BAR_COUNT
- loadMoreCallback = params.callback
- ws.requestKlinesRange(from, to)
- },
- subscribeBar: (params: any) => { klineBarCallback = params.callback },
- unsubscribeBar: () => { klineBarCallback = null },
- })
- chart.subscribeAction('onCrosshairChange', (data: any) => {
- if (!data?.x || !data?.paneId) { crosshairData.value = null; return }
- // v10-beta bug: kLineData is not passed to the action callback, retrieve it manually
- const pts = (chart as any).convertFromPixel([{ x: data.x, y: data.y ?? 0 }])
- const ts: number | undefined = Array.isArray(pts) ? pts[0]?.timestamp : (pts as any)?.timestamp
- if (!ts) { crosshairData.value = null; return }
- const dataList: any[] = (chart as any).getDataList?.() ?? []
- const kLineData = dataList.find((b: any) => b.timestamp === ts)
- crosshairData.value = kLineData ? { ...data, kLineData } : null
- })
- ro = new ResizeObserver(() => {
- chart?.resize()
- chartWrapWidth.value = chartEl.value?.offsetWidth ?? 0
- })
- ro.observe(chartEl.value!)
- chartWrapWidth.value = chartEl.value.offsetWidth
- // 限制向右最多留 2 根 K 线的空白(约 20px),防止用户拖出当前时间太远
- chart.setMaxOffsetRightDistance(20)
- }
- function updateChart() {
- if (!chart || !klines.value.length) return
- currentBars = convertKlines()
- const period = RESOLUTION_PERIOD[klineResolution.value] ?? { type: 'minute', span: 1 }
- if (JSON.stringify(chart.getPeriod()) !== JSON.stringify(period)) {
- chart.setPeriod(period)
- } else {
- chart.resetData()
- }
- }
- function clearOverlays() { chart?.removeOverlay() }
- function zoomOut() { (chart as any)?.zoomAtCoordinate?.(0.8) }
- function activateDrawTool(tool: string) {
- activeDrawTool.value = tool
- if (!chart || tool === 'cursor') return
- const name = DRAW_TOOLS[tool]
- if (name) chart.createOverlay({ name })
- }
- watch(klines, updateChart)
- watch(showMA, (show) => {
- if (!chart) return
- if (show) {
- chart.createIndicator({ name: 'MA', calcParams: [5, 10, 30], styles: { lines: [{ color: '#f0b90b', size: 1 }, { color: '#5d9cf5', size: 1 }, { color: '#e84d5b', size: 1 }] } }, { isStack: true, pane: { id: 'candle_pane' } })
- } else {
- chart.removeIndicator({ name: 'MA', paneId: 'candle_pane' })
- }
- })
- watch(showVol, (show) => {
- if (!chart) return
- if (show) {
- chart.createIndicator(
- { name: 'VOL', calcParams: [], styles: {
- bars: [{ style: 'fill' }, { style: 'fill' }],
- lines: [{ size: 0, color: 'rgba(0,0,0,0)' }, { size: 0, color: 'rgba(0,0,0,0)' }, { size: 0, color: 'rgba(0,0,0,0)' }],
- lastValueMark: { show: false },
- tooltip: { showRule: 'none' },
- }},
- { isStack: false, pane: { id: 'vol-pane', height: 80, minHeight: 40 } },
- )
- } else {
- chart.removeIndicator({ name: 'VOL', paneId: 'vol-pane' })
- }
- })
- // ── Pair selector ──────────────────────────────────────
- const filteredTickers = computed(() => {
- const q = pairSearch.value.toLowerCase()
- return allTickers.value.filter(t => t.symbol.toLowerCase().includes(q))
- })
- function coinIcon(sym: string): string {
- return coinsStore.coins.find(c => c.symbol === sym)?.icon ?? ''
- }
- const currentCoinIcon = computed(() => coinIcon(symbol.value))
- function selectPair(sym: string) {
- showPairSelector.value = false
- pairSearch.value = ''
- const routeSym = futuresPairToRouteParam(sym)
- router.push({ name: 'futures', params: { symbol: routeSym } })
- }
- // ── Trading panel ──────────────────────────────────────
- const side = ref<'buy' | 'sell'>('buy')
- const orderType = ref<'limit' | 'market' | 'conditional'>('limit')
- const conditionalSubType = ref<'market' | 'limit'>('market')
- const entrustPrice = ref('')
- const triggerPrice = ref('')
- const volume = ref('')
- const pctSlider = ref(0)
- const activeTab = ref<'positions' | 'orders' | 'history-positions' | 'history-orders' | 'trades' | 'funding' | 'assets'>('positions')
- /**
- * 限价委托价:用全市场行情表里当前币对的 close。
- * 切换币对时合约 WS 的 ticker 往往还是上一币对的快照,不能直接用 ticker.close 回填。
- */
- function syncEntrustPriceFromCoinList(): boolean {
- const sym = symbol.value
- const row = allTickers.value.find((x) => x.symbol === sym)
- if (row?.close && parseFloat(row.close) > 0) {
- entrustPrice.value = row.close
- return true
- }
- return false
- }
- // ── 底部标签页无限滚动 ──────────────────────────────────
- const tabScrollRef = ref<HTMLElement | null>(null)
- let tabScrollLoading = false
- function onTabScroll() {
- const el = tabScrollRef.value
- if (!el || tabScrollLoading) return
- if (el.scrollHeight - el.scrollTop - el.clientHeight < 100) {
- tabScrollLoading = true
- const tab = activeTab.value
- let promise: Promise<void> | undefined
- if (tab === 'history-positions') promise = store.fetchHistoryPositions()
- else if (tab === 'history-orders') promise = store.fetchHistoryOrders()
- else if (tab === 'trades') promise = store.fetchHistoryOrders()
- else if (tab === 'funding') promise = store.fetchFundingRecords()
- ;(promise ?? Promise.resolve()).finally(() => { tabScrollLoading = false })
- }
- }
- // ── 轮询:登录时每 3 秒刷新持仓和当前委托 ──────────────
- let pollTimer: ReturnType<typeof setInterval> | null = null
- function startPolling() {
- if (pollTimer !== null) return
- pollTimer = setInterval(() => {
- store.fetchPositions()
- store.fetchCurrentOrders()
- }, 3000)
- }
- function stopPolling() {
- if (pollTimer !== null) { clearInterval(pollTimer); pollTimer = null }
- }
- // ── Mobile ──────────────────────────────────────────────
- const isMobile = ref(typeof window !== 'undefined' && window.innerWidth <= 768)
- const mobileTab = ref<'chart' | 'trade'>('chart')
- function onWindowResize() {
- isMobile.value = window.innerWidth <= 768
- recalcBookRowCount()
- }
- // ── Transfer modal ──────────────────────────────────────
- // 资金 ↔ 合约 / 跟单(与资产页、transfer store 一致)
- type AccountType = 'SPOT' | 'SWAP' | 'FOLLOW' | 'SPOT_TRADING'
- const TRANSFER_FOLLOW_IDX = 1
- /** 合约页划转可选的非资金账户(不含 iBit 质押) */
- const FUTURES_OTHER_WALLET_TYPES: Exclude<AccountType, 'SPOT'>[] = ['SWAP', 'FOLLOW', 'SPOT_TRADING']
- function transferWalletLabel(kind: AccountType): string {
- return walletTypeLabel(t, kind)
- }
- const showTransfer = ref(false)
- const transferFrom = ref<AccountType>('SPOT')
- const transferTo = ref<AccountType>('SWAP')
- const transferAmount = ref('')
- const transferAvail = ref('0.00000000')
- const transferLoading = ref(false)
- const transferSubmitting = ref(false)
- const transferError = ref('')
- const showFromDrop = ref(false)
- const showToDrop = ref(false)
- function selectFromOther(v: AccountType) {
- transferFrom.value = v
- transferTo.value = 'SPOT'
- showFromDrop.value = false
- transferAmount.value = ''
- fetchTransferAvail()
- }
- function selectToOther(v: AccountType) {
- transferTo.value = v
- transferFrom.value = 'SPOT'
- showToDrop.value = false
- transferAmount.value = ''
- fetchTransferAvail()
- }
- function swapTransferDirection() {
- const [a, b] = normalizeTransferPair(transferTo.value, transferFrom.value)
- transferFrom.value = a
- transferTo.value = b
- transferAmount.value = ''
- showFromDrop.value = false
- showToDrop.value = false
- fetchTransferAvail()
- }
- // 统一用 /swap/wallet-new/get(含 SPOT / SWAP / 跟单 accountInfoList);现货账户单独请求
- async function fetchTransferAvail() {
- if (transferFrom.value === 'SPOT') {
- transferAvail.value = spotAvailBalance.value
- } else if (transferFrom.value === 'SWAP') {
- transferAvail.value = swapAvailBalance.value
- } else {
- transferAvail.value = '0'
- }
- transferLoading.value = true
- try {
- const data = await get<any>('/swap/wallet-new/get')
- if (transferFrom.value === 'SPOT') {
- transferAvail.value = data?.spotWallet?.balance ?? spotAvailBalance.value
- } else if (transferFrom.value === 'SWAP') {
- transferAvail.value = data?.availableBalance ?? swapAvailBalance.value
- } else if (transferFrom.value === 'SPOT_TRADING') {
- try {
- const raw = await get<unknown>('spot/v1/account/asset/list')
- transferAvail.value = extractUsdtAvailableFromSpotAssets(raw)
- } catch {
- transferAvail.value = '0'
- }
- } else {
- transferAvail.value = data?.accountInfoList?.[TRANSFER_FOLLOW_IDX]?.balance ?? '0'
- }
- } catch (e) {
- console.error('[transfer] fetchTransferAvail error:', e)
- } finally {
- transferLoading.value = false
- }
- }
- watch(showTransfer, (v) => {
- if (v) {
- transferAmount.value = ''
- transferError.value = ''
- fetchTransferAvail()
- }
- })
- async function confirmTransfer() {
- const [fromWallet, toWallet] = normalizeTransferPair(transferFrom.value, transferTo.value)
- transferFrom.value = fromWallet
- transferTo.value = toWallet
- const amt = parseFloat(transferAmount.value)
- if (!transferAmount.value || isNaN(amt) || amt <= 0) {
- transferError.value = t('spot.transferModal.invalidAmount')
- return
- }
- const avail = parseFloat(transferAvail.value)
- if (amt > avail) {
- transferError.value = t('spot.transferModal.insufficientBalance')
- return
- }
- transferSubmitting.value = true
- transferError.value = ''
- try {
- if (fromWallet === 'SPOT_TRADING' || toWallet === 'SPOT_TRADING') {
- const direction = fromWallet === 'SPOT' ? 1 : 2
- await post('/spot/v1/account/transfer', {
- symbol: 'USDT',
- amount: amt,
- direction,
- })
- } else {
- await postForm('/uc/asset/transfer', {
- amount: transferAmount.value,
- from: fromWallet,
- to: toWallet,
- })
- }
- transferAmount.value = ''
- showTransfer.value = false
- // 刷新合约钱包余额
- store.fetchPositions?.().catch(() => {})
- } catch (e: any) {
- transferError.value = e?.message ?? t('spot.transferModal.transferFailed')
- } finally {
- transferSubmitting.value = false
- }
- }
- // ── TP/SL modify modal ──────────────────────────────────
- const showTpSlModal = ref(false)
- const tpSlPositionId = ref('')
- const tpSlProfitPrice = ref('')
- const tpSlLossPrice = ref('')
- const tpSlError = ref('')
- // 当前正在编辑的仓位(从 positions 中反查)
- const tpSlPosition = computed(() => positions.value.find(p => p.id === tpSlPositionId.value) ?? null)
- /** 止盈止损弹窗:仅当前页合约与仓位一致时使用 WS 标记价,否则用仓位接口自带的标记价 */
- const tpSlMarkPriceStr = computed(() => {
- const pos = tpSlPosition.value
- if (!pos) return ''
- const posSym = pos.coin?.symbol ?? ''
- const samePair = posSym.length > 0 && futuresPairKey(posSym) === futuresPairKey(symbol.value)
- if (samePair && wsMarkPrice.value) {
- return wsMarkPrice.value
- }
- return pos.currentPrice ?? ''
- })
- function openTpSlModal(positionId: string, fallbackProfit?: string, fallbackLoss?: string) {
- tpSlPositionId.value = positionId
- const pos = positions.value.find(p => p.id === positionId)
- const cut0 = pos?.cutList?.[0]
- // 优先从仓位 cutList 读;没有则用 fallback(从委托行传入)
- tpSlProfitPrice.value = (cut0?.profitPrice && parseFloat(String(cut0.profitPrice)) > 0)
- ? String(cut0.profitPrice)
- : (fallbackProfit && parseFloat(fallbackProfit) > 0 ? fallbackProfit : '')
- tpSlLossPrice.value = (cut0?.lossPrice && parseFloat(String(cut0.lossPrice)) > 0)
- ? String(cut0.lossPrice)
- : (fallbackLoss && parseFloat(fallbackLoss) > 0 ? fallbackLoss : '')
- tpSlError.value = ''
- showTpSlModal.value = true
- }
- async function confirmTpSl() {
- tpSlError.value = ''
- const ok = await store.modifyTpSl(tpSlPositionId.value, tpSlProfitPrice.value, tpSlLossPrice.value)
- if (ok) {
- showTpSlModal.value = false
- } else {
- tpSlError.value = store.error || t('futuresPage.errors.modifyTpSlFailed')
- }
- }
- // ── 详情弹窗 ────────────────────────────────────────────
- const showPosDetail = ref(false)
- const showOrdDetail = ref(false)
- const showHistPosDetail = ref(false)
- const showHistOrdDetail = ref(false)
- const detailPos = ref<Position | null>(null)
- const detailOrd = ref<ContractOrder | null>(null)
- const detailHistPos = ref<HistoryPosition | null>(null)
- const detailHistOrd = ref<HistoryOrder | null>(null)
- function openPosDetail(pos: Position) { detailPos.value = pos; showPosDetail.value = true }
- function openOrdDetail(ord: ContractOrder) { detailOrd.value = ord; showOrdDetail.value = true }
- function openHistPosDetail(pos: HistoryPosition) { detailHistPos.value = pos; showHistPosDetail.value = true }
- function openHistOrdDetail(ord: HistoryOrder) { detailHistOrd.value = ord; showHistOrdDetail.value = true }
- // 仓位类型标签(全仓/分仓/体验金)
- function posTypeLabel(positionType: any, patterns: any, experienceRecordId?: any): string {
- if (experienceRecordId && String(experienceRecordId) !== 'null' && String(experienceRecordId) !== '') {
- return t('futuresPage.margin.experience')
- }
- const pt = String(positionType ?? '').toUpperCase()
- if (pt === '0' || pt === 'INTEGRAL') {
- return t('futuresPage.margin.cross')
- }
- if (pt === '1' || pt === 'SEPARATE') {
- return t('futuresPage.margin.split')
- }
- const p = String(patterns ?? '').toUpperCase()
- if (p === 'CROSSED') {
- return t('futuresPage.margin.cross')
- }
- if (p === 'ISOLATED' || p === 'SEPARATE') {
- return t('futuresPage.margin.split')
- }
- return t('futuresPage.margin.cross')
- }
- // 历史仓位符号:兼容嵌套 coin 对象和平铺字段
- function histPosSymbol(pos: HistoryPosition): string {
- const s = (pos.coin?.symbol ?? pos.symbol ?? pos.coinSymbol ?? '').replace('/', '-')
- return s || '--'
- }
- // 历史委托符号
- function histOrdSymbol(ord: HistoryOrder): string {
- return (ord.symbol ?? ord.coinSymbol ?? '').replace('/', '-') || '--'
- }
- // 历史仓位开仓均价
- function histPosOpenPrice(pos: HistoryPosition): string {
- return fmtPrice(pos.openPrice ?? pos.usdtOpenPrice ?? '0')
- }
- // 历史仓位平仓均价
- function histPosClosePrice(pos: HistoryPosition): string {
- const p = pos.closePrice ?? pos.tradedPrice ?? '0'
- return parseFloat(p) > 0 ? fmtPrice(p) : '--'
- }
- // 历史仓位委托总量
- function histPosTotalVol(pos: HistoryPosition): string {
- return String(pos.totalPosition ?? pos.volume ?? pos.tradedVolume ?? '--')
- }
- // 历史仓位已成交量
- function histPosDealVol(pos: HistoryPosition): string {
- return String(pos.closedPosition ?? pos.tradedVolume ?? pos.dealVolume ?? '--')
- }
- // 历史仓位盈亏
- function histPosProfit(pos: HistoryPosition): number {
- return parseFloat(String(pos.usdtProfit ?? pos.profitAndLoss ?? '0'))
- }
- // 历史仓位保证金
- function histPosPrincipal(pos: HistoryPosition): number {
- return parseFloat(String(pos.totalPrincipalAmount ?? pos.principalAmount ?? '0'))
- }
- // 历史仓位开仓时间
- function histPosOpenTime(pos: HistoryPosition): string {
- const t = pos.openTime ?? pos.usdtOpenTime ?? pos.createTime
- return t ? fmtTime(t) : '--'
- }
- // 历史仓位平仓时间
- function histPosCloseTime(pos: HistoryPosition): string {
- const t = pos.closeTime ?? pos.dealTime
- return t ? fmtTime(t) : '--'
- }
- // 历史仓位收益率
- function histPosProfitRate(pos: HistoryPosition): string {
- const pnl = histPosProfit(pos)
- const margin = histPosPrincipal(pos)
- if (!margin) return '--'
- const r = pnl / margin * 100
- return (r >= 0 ? '+' : '') + r.toFixed(2) + '%'
- }
- // 历史委托开仓均价(开仓单用 tradedPrice;平仓单用 usdtOpenPrice)
- function histOrdAvgOpenPrice(ord: HistoryOrder): string {
- const isClose = String(ord.entrustType ?? '').toUpperCase() === 'CLOSE' || String(ord.entrustType) === '1'
- const p = isClose ? (ord.usdtOpenPrice ?? ord.openPrice ?? '0') : (ord.tradedPrice ?? '0')
- return parseFloat(p) > 0 ? fmtPrice(p) : '--'
- }
- // 历史委托委托价格(市价→"市价";限价/计划→显示价格)
- function histOrdEntrustPriceDisplay(ord: HistoryOrder): string {
- return histOrdEntrustPrice(ord)
- }
- // 基础货币(BTC/ETH 等)
- function baseCoinFromSym(sym: string): string {
- if (sym.includes('/')) return sym.split('/')[0]
- return sym.replace(/USDT$|BUSD$/, '') || sym
- }
- function txTypeName(ty: number) {
- const key = `futuresPage.fundingTx.${ty}`
- if (te(key)) {
- return t(key)
- }
- return t('futuresPage.errors.txFallback', { n: ty })
- }
- /** 资金流水币种:后端 symbol 含「永续」,按七种语言展示 */
- function formatFundingSymbol(sym?: string): string {
- const raw = (sym || 'USDT').trim()
- return raw.replace(/永续/g, t('futuresPage.perpetualTag'))
- }
- // 合约页资金流水:前端兜底移除现货相关类型
- const SPOT_RELATED_TX_TYPES = new Set<number>([3, 43, 44, 45, 46, 48])
- const contractFundingRecords = computed(() =>
- fundingRecords.value.filter(rec => !SPOT_RELATED_TX_TYPES.has(Number(rec.type))),
- )
- const baseCurrency = computed(() => symbol.value.split('/')[0] ?? 'BTC')
- const quoteCurrency = computed(() => symbol.value.split('/')[1] ?? 'USDT')
- /** 合约代码规范化比对(BTC/USDT、BTC-USDT → BTCUSDT) */
- function futuresPairKey(sym: string): string {
- return sym.replace(/\//g, '').replace(/-/g, '').trim().toUpperCase()
- }
- const displaySymbolNoDash = computed(() => symbol.value.replace('/', ''))
- async function submitOrder() {
- if (!volume.value) return
- const baseVol = toBaseVolume(volume.value)
- if (!baseVol) {
- store.error = orderRefPrice() <= 0
- ? (orderType.value === 'conditional' && conditionalSubType.value === 'market'
- ? t('futuresPage.errors.enterTriggerPrice')
- : orderType.value !== 'market' ? t('futuresPage.errors.enterPrice') : t('futuresPage.errors.priceNotReadyConvert'))
- : t('futuresPage.errors.invalidQtyCheckMin')
- return
- }
- // type: 0=市价 1=限价 2=计划委托(与 Android OpenEntrustType 一致)
- let type: 0 | 1 | 2 = 1
- let ep: string | undefined
- let tp: string | undefined
- if (orderType.value === 'limit') {
- type = 1; ep = entrustPrice.value
- } else if (orderType.value === 'market') {
- type = 0
- } else {
- // 条件委托:触发价必填
- if (!triggerPrice.value) {
- store.error = t('futuresPage.errors.enterTriggerPrice')
- return
- }
- tp = triggerPrice.value
- if (conditionalSubType.value === 'limit') {
- type = 2; ep = entrustPrice.value
- } else {
- // 市价委托:entrustPrice = triggerPrice(后端要求必填,触发时以该价格挂限价单)
- type = 2; ep = triggerPrice.value
- }
- }
- const ok = await store.placeOrder({
- direction: side.value === 'buy' ? 0 : 1, // 0=开多 1=开空
- type,
- entrustPrice: ep,
- triggerPrice: tp,
- volume: baseVol,
- })
- if (ok) { volume.value = ''; triggerPrice.value = '' }
- }
- function setLatestPrice() {
- if (ticker.value) entrustPrice.value = ticker.value.close
- }
- // 订单簿点击 → 回填到当前编辑的价格输入框:
- // - 平仓 sheet 打开时,回填到平仓价
- // - 否则若选中的是限价/条件限价,回填到 entrustPrice;条件市价回填到触发价
- function onBookClick(price: string) {
- if (showCloseSheet.value && !closeIsMarket.value) {
- closeSheetPrice.value = price
- return
- }
- if (orderType.value === 'limit') {
- entrustPrice.value = price
- } else if (orderType.value === 'conditional') {
- if (conditionalSubType.value === 'limit') entrustPrice.value = price
- else triggerPrice.value = price
- }
- }
- // ── Close position sheet (从仓位卡片调起) ──────────────────
- const showCloseSheet = ref(false)
- const closeTargetPos = ref<import('@/stores/futures').Position | null>(null)
- const closeIsMarket = ref(true)
- const closeSheetPrice = ref('')
- const closeSheetVolume = ref('')
- const closeSheetPct = ref(0)
- const closeSheetError = ref('')
- function openCloseSheet(pos: import('@/stores/futures').Position) {
- closeTargetPos.value = pos
- closeIsMarket.value = true
- const posSym = pos.coin?.symbol ?? ''
- const samePair = posSym.length > 0 && futuresPairKey(posSym) === futuresPairKey(symbol.value)
- closeSheetPrice.value = samePair ? (ticker.value?.close ?? pos.currentPrice ?? '') : (pos.currentPrice ?? '')
- closeSheetVolume.value = ''
- closeSheetPct.value = 0
- closeSheetError.value = ''
- showCloseSheet.value = true
- }
- watch(closeSheetPct, (pct) => {
- if (!closeTargetPos.value) return
- const avail = parseFloat(closeTargetPos.value.availablePosition) || 0
- if (!avail || pct === 0) { closeSheetVolume.value = ''; return }
- closeSheetVolume.value = ((pct / 100) * avail).toFixed(4)
- })
- /** 平仓弹窗展示的 base/quote 以目标仓位为准,避免多仓时沿用当前 K 线交易对 */
- const closeSheetBaseCurrency = computed(() => {
- const raw = closeTargetPos.value?.coin?.symbol ?? ''
- const b = raw.split('/')[0]?.trim()
- return b || baseCurrency.value
- })
- const closeSheetQuoteCurrency = computed(() => {
- const raw = closeTargetPos.value?.coin?.symbol ?? ''
- const q = raw.split('/')[1]?.trim()
- return q || quoteCurrency.value
- })
- /** 平仓弹窗标记价:仅 WS 订阅与待平仓位合约一致时使用 wsMarkPrice,否则用仓位 currentPrice */
- const closeSheetMarkPriceStr = computed(() => {
- const pos = closeTargetPos.value
- if (!pos) return ''
- const posSym = pos.coin?.symbol ?? ''
- const samePair = posSym.length > 0 && futuresPairKey(posSym) === futuresPairKey(symbol.value)
- if (samePair && wsMarkPrice.value) {
- return wsMarkPrice.value
- }
- return pos.currentPrice ?? ''
- })
- const closeSheetEstPnl = computed(() => {
- const pos = closeTargetPos.value
- if (!pos) return 0
- const vol = parseFloat(closeSheetVolume.value) || 0
- if (!vol) return 0
- const isLong = pos.direction === 'BUY'
- const refPrice = closeIsMarket.value
- ? (parseFloat(closeSheetMarkPriceStr.value) || parseFloat(pos.currentPrice) || 0)
- : (parseFloat(closeSheetPrice.value) || 0)
- if (!refPrice) return 0
- const open = parseFloat(pos.openPrice) || 0
- const diff = isLong ? (refPrice - open) : (open - refPrice)
- return diff * vol
- })
- async function confirmClose() {
- const pos = closeTargetPos.value
- if (!pos) return
- const vol = closeSheetVolume.value
- const volNum = parseFloat(vol)
- const avail = parseFloat(pos.availablePosition) || 0
- if (!vol || isNaN(volNum) || volNum <= 0) {
- closeSheetError.value = t('futuresPage.errors.enterQty')
- return
- }
- if (volNum > avail) {
- closeSheetError.value = t('futuresPage.errors.qtyExceedAvail')
- return
- }
- let ok = false
- if (closeIsMarket.value) {
- ok = await store.closePosition(pos.id, vol)
- } else {
- if (!closeSheetPrice.value) {
- closeSheetError.value = t('futuresPage.errors.enterPrice')
- return
- }
- ok = await store.closeLimitPosition(pos.id, closeSheetPrice.value, vol)
- }
- if (ok) {
- showCloseSheet.value = false
- closeTargetPos.value = null
- } else {
- closeSheetError.value = store.error || t('futuresPage.errors.closeFailed')
- }
- }
- // ── 二次确认(替代原生 confirm,与主题一致)────────────────
- const showActionConfirm = ref(false)
- const actionConfirmTitle = ref('')
- const actionConfirmMessage = ref('')
- const actionConfirmLoading = ref(false)
- let pendingActionConfirm: (() => Promise<unknown>) | null = null
- function openActionConfirm(title: string, message: string, action: () => Promise<unknown>) {
- actionConfirmTitle.value = title || t('futuresPage.modal.confirmDefaultTitle')
- actionConfirmMessage.value = message
- pendingActionConfirm = action
- showActionConfirm.value = true
- }
- function closeActionConfirm() {
- showActionConfirm.value = false
- pendingActionConfirm = null
- actionConfirmLoading.value = false
- }
- async function onActionConfirmOk() {
- const fn = pendingActionConfirm
- if (!fn) {
- closeActionConfirm()
- return
- }
- actionConfirmLoading.value = true
- try {
- await fn()
- } finally {
- closeActionConfirm()
- }
- }
- function onActionConfirmCancel() {
- if (actionConfirmLoading.value) return
- closeActionConfirm()
- }
- // ── Reverse position confirm ────────────────────────────
- function reversePosition(pos: import('@/stores/futures').Position) {
- openActionConfirm(
- t('futuresPage.confirm.reverseTitle'),
- t('futuresPage.confirm.reverseBody', {
- pair: pos.coin?.symbol ?? '--',
- side: pos.direction === 'BUY' ? t('futuresPage.confirm.sideLongWord') : t('futuresPage.confirm.sideShortWord'),
- }),
- () => store.reversePosition(pos.id),
- )
- }
- // ── Cancel all orders confirm ───────────────────────────
- function cancelAllOrders() {
- if (!currentOrders.value.length) return
- const n = currentOrders.value.length
- openActionConfirm(
- t('futuresPage.bottom.cancelAllOrders'),
- t('futuresPage.confirm.cancelAllBody', { n }),
- () => store.cancelAllOrders(),
- )
- }
- // ── Close all positions confirm ─────────────────────────
- function confirmCloseAll() {
- if (!positions.value.length) return
- const n = positions.value.length
- openActionConfirm(
- t('futuresPage.bottom.closeAllPositions'),
- t('futuresPage.confirm.closeAllBody', { n }),
- () => store.closeAllPositions(),
- )
- }
- // ── Margin mode (全仓/分仓;对齐 flutter,后端只支持 positionType=0/1) ──
- const MARGIN_MODES = computed<{ value: 'cross' | 'split'; label: string }[]>(() => [
- { value: 'cross', label: t('futuresPage.margin.cross') },
- { value: 'split', label: t('futuresPage.margin.split') },
- ])
- const marginModeLabel = computed(() =>
- MARGIN_MODES.value.find(m => m.value === marginMode.value)?.label ?? t('futuresPage.margin.cross'))
- async function selectMarginMode(mode: 'cross' | 'split') {
- showMarginDrop.value = false
- if (mode === marginMode.value) return
- await store.setMarginMode(mode)
- }
- // ── Amount unit (张/USDT/币) ─────────────────────────────
- const AMOUNT_UNITS = computed<{ value: 'lots' | 'usdt' | 'base'; label: string }[]>(() => [
- { value: 'lots', label: t('futuresPage.amountUnit.lots') },
- { value: 'usdt', label: t('futuresPage.amountUnit.usdt') },
- { value: 'base', label: 'BASE' },
- ])
- const amountUnitLabel = computed(() => {
- const u = amountUnit.value
- if (u === 'lots') {
- return t('futuresPage.amountUnit.lots')
- }
- if (u === 'usdt') {
- return t('futuresPage.amountUnit.usdt')
- }
- return baseCurrency.value
- })
- function selectAmountUnit(u: 'lots' | 'usdt' | 'base') {
- showAmountUnitDrop.value = false
- store.setAmountUnit(u)
- volume.value = ''
- pctSlider.value = 0
- }
- /** 与 flutter futures_screen._refPrice 一致:市价→最新价;限价/条件限价→委托价;条件市价→触发价 */
- function orderRefPrice(): number {
- if (orderType.value === 'market') {
- return parseFloat(ticker.value?.close ?? '0') || 0
- }
- if (orderType.value === 'limit') {
- return parseFloat(entrustPrice.value) || 0
- }
- if (conditionalSubType.value === 'limit') {
- return parseFloat(entrustPrice.value) || 0
- }
- return parseFloat(triggerPrice.value) || 0
- }
- const futuresContractSize = computed(() => {
- const n = parseFloat(symbolInfo.value?.shareNumber ?? '1')
- return n > 0 ? n : 1
- })
- const futuresCoinPrecision = computed(() => {
- const s = symbolInfo.value?.minScale
- if (s == null || !Number.isFinite(Number(s))) return 4
- return Math.min(18, Math.max(0, Math.floor(Number(s))))
- })
- /** 与 flutter:usdtPrecision < 2 用 usdtPrecision,否则展示 2 位 */
- const futuresUsdtDisplayDp = computed(() => {
- const u = symbolInfo.value?.baseCoinScale
- const prec = u == null || !Number.isFinite(Number(u)) ? 4 : Math.floor(Number(u))
- return prec < 2 ? prec : 2
- })
- const futuresOpenFeeRate = computed(() => {
- const f = parseFloat(symbolInfo.value?.openFee ?? '0.0005')
- return f > 0 ? f : 0.0005
- })
- /** 最大可开(当前单位),与 flutter _calcMaxOpenAmount 一致 */
- const maxOpenAmountDisplay = computed(() => {
- const info = symbolInfo.value
- const refP = orderRefPrice()
- const avail = parseFloat(swapAvailBalance.value) || 0
- const lev = leverage.value || 1
- const cs = futuresContractSize.value
- const feeRate = futuresOpenFeeRate.value
- if (!info || avail <= 0 || refP <= 0) return '--'
- const feeMult = 1 + feeRate * lev
- const maxNotional = (avail / feeMult) * lev
- const u = amountUnit.value
- const coinPrec = futuresCoinPrecision.value
- if (u === 'usdt') {
- const f = 100
- const t = Math.trunc(maxNotional * f) / f
- return t.toFixed(2)
- }
- if (u === 'lots') {
- return String(Math.floor(maxNotional / cs + 1e-12))
- }
- const f = 10 ** coinPrec
- const t = Math.trunc((maxNotional / refP) * f) / f
- return t.toFixed(coinPrec)
- })
- function floorToDecimals(val: number, dp: number): number {
- const f = 10 ** dp
- return Math.floor(val * f + 1e-12) / f
- }
- function truncateToCoinPrecision(val: number): number {
- const dp = futuresCoinPrecision.value
- const f = 10 ** dp
- return Math.trunc(val * f) / f
- }
- /** 换算为下单用的基础币数量(与 flutter placeOrder 中 volumeInBtc + floor 一致) */
- function toBaseVolume(input: string): string {
- const v = parseFloat(input)
- if (isNaN(v) || v <= 0) return ''
- const price = orderRefPrice()
- if (price <= 0) return ''
- const cs = futuresContractSize.value
- const u = amountUnit.value
- let volumeInBase = 0
- if (u === 'base') volumeInBase = v
- else if (u === 'usdt') volumeInBase = v / price
- else volumeInBase = (v * cs) / price
- const finalVol = truncateToCoinPrecision(volumeInBase)
- if (finalVol <= 0) return ''
- const dp = futuresCoinPrecision.value
- return finalVol.toFixed(dp)
- }
- function setPctSlider(p: number) {
- error.value = ''
- if (p > 0 && orderRefPrice() <= 0) {
- if (orderType.value === 'conditional' && conditionalSubType.value === 'market') {
- error.value = t('futuresPage.errors.enterTriggerPrice')
- } else if (orderType.value !== 'market') {
- error.value = t('futuresPage.errors.enterPrice')
- } else {
- error.value = t('futuresPage.errors.priceNotReadyRetry')
- }
- return
- }
- pctSlider.value = p
- }
- // ── Leverage panel (步进 + 滑块 + min/max) ────────────────
- const leverageOptions = computed(() => {
- const raw = symbolInfo.value?.leverage
- if (!raw) return [1, 5, 10, 20, 50, 100]
- return raw.split(',').map(s => parseInt(s, 10)).filter(n => !isNaN(n) && n > 0)
- })
- const leverageMin = computed(() => leverageOptions.value[0] ?? 1)
- const leverageMax = computed(() => leverageOptions.value[leverageOptions.value.length - 1] ?? 125)
- /** leverageType=1:固定档位(仅 pill 选择);leverageType=2:区间任意值(步进+滑块) */
- const isDiscreteLeverage = computed(() => symbolInfo.value?.leverageType === 1)
- const leverageDraft = ref(leverage.value)
- watch(showLeveragePanel, (v) => { if (v) leverageDraft.value = leverage.value })
- watch(leverage, (v) => { leverageDraft.value = v })
- function leverageStep(delta: number) {
- const next = Math.max(leverageMin.value, Math.min(leverageMax.value, leverageDraft.value + delta))
- leverageDraft.value = next
- }
- async function confirmLeverage() {
- await store.modifyLeverage(leverageDraft.value)
- showLeveragePanel.value = false
- }
- // ── Helpers ────────────────────────────────────────────
- function pct(v: string) {
- const n = parseFloat(v)
- return isNaN(n) ? '--' : (n >= 0 ? '+' : '') + (n * 100).toFixed(2) + '%'
- }
- // 截断到小数点后2位,不四舍五入
- function trunc2(v: string | number): string {
- const s = Number(v).toFixed(10)
- const dot = s.indexOf('.')
- return dot === -1 ? s + '.00' : s.slice(0, dot + 3)
- }
- /** 合约 USDT 价格展示小数位:与后端 symbolInfo.coinScale(无则 minScale)一致,默认 2 */
- function futuresPriceDecimals(): number {
- const info = symbolInfo.value
- const raw = info?.coinScale ?? info?.minScale
- if (raw == null || !Number.isFinite(Number(raw))) return 2
- return Math.min(18, Math.max(0, Math.floor(Number(raw))))
- }
- function fmtPrice(v: string | number | null | undefined) {
- if (v == null) return '--'
- const n = typeof v === 'string' ? parseFloat(v) : v
- if (isNaN(n)) return '--'
- const dp = futuresPriceDecimals()
- return n.toLocaleString('en-US', { minimumFractionDigits: dp, maximumFractionDigits: dp })
- }
- function fmtVol(v: string) {
- const n = parseFloat(v)
- if (isNaN(n)) return '--'
- if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'
- if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'
- if (n >= 1e3) return (n / 1e3).toFixed(2) + 'K'
- return n.toFixed(2)
- }
- function fmtTime(ts: number | string | null | undefined) {
- if (!ts) return '--'
- let ms = typeof ts === 'string' ? Number(ts) : ts
- if (isNaN(ms) || ms <= 0) return '--'
- // 秒级时间戳(< 1e12)转毫秒
- if (ms < 1_000_000_000_000) ms *= 1000
- const d = new Date(ms)
- if (isNaN(d.getTime())) return '--'
- const M = String(d.getMonth() + 1).padStart(2, '0')
- const D = String(d.getDate()).padStart(2, '0')
- const h = String(d.getHours()).padStart(2, '0')
- const m = String(d.getMinutes()).padStart(2, '0')
- return `${M}/${D} ${h}:${m}`
- }
- function fmtTimeShort(ts: number) {
- const d = new Date(ts)
- return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
- }
- function fmtDateTime(ts: number) {
- const d = new Date(ts)
- const Y = d.getFullYear(), M = String(d.getMonth()+1).padStart(2,'0'), D = String(d.getDate()).padStart(2,'0')
- const h = String(d.getHours()).padStart(2,'0'), m = String(d.getMinutes()).padStart(2,'0'), s = String(d.getSeconds()).padStart(2,'0')
- return `${Y}/${M}/${D} ${h}:${m}:${s}`
- }
- function profitClass(v: string | number) { return parseFloat(String(v)) >= 0 ? 'up' : 'dn' }
- // 用实时 ticker 价格计算浮动盈亏
- function calcLivePnl(pos: import('@/stores/futures').Position): number {
- // 优先使用API返回的实时盈亏,回退到本地计算
- if (pos.currentProfit && parseFloat(pos.currentProfit) !== 0) return parseFloat(pos.currentProfit)
- const livePrice = ticker.value?.close ? parseFloat(ticker.value.close) : parseFloat(pos.currentPrice)
- const openPrice = parseFloat(pos.openPrice)
- const vol = parseFloat(pos.currentPosition)
- if (isNaN(livePrice) || isNaN(openPrice) || isNaN(vol)) return 0
- return pos.direction === 'BUY'
- ? (livePrice - openPrice) * vol
- : (openPrice - livePrice) * vol
- }
- function calcLivePnlPct(pos: import('@/stores/futures').Position): number {
- // ROE = unrealizedPnl / currentMargin * 100(与flutter一致)
- const margin = parseFloat(pos.currentMargin)
- if (margin > 0) return calcLivePnl(pos) / margin * 100
- // fallback:用开仓价×持仓量/杠杆估算保证金
- const openPrice = parseFloat(pos.openPrice)
- const vol = parseFloat(pos.currentPosition)
- if (!openPrice || !vol || !pos.leverage) return 0
- const fallbackMargin = openPrice * vol / pos.leverage
- return fallbackMargin > 0 ? calcLivePnl(pos) / fallbackMargin * 100 : 0
- }
- function dirLabel(d: string) {
- return d === 'BUY' ? t('futuresPage.directions.long') : t('futuresPage.directions.short')
- }
- // 开多/开空/平多/平空(与flutter _directionInfo一致)
- function orderDirLabel(direction: string, entrustType: string) {
- const isBuy = direction === 'BUY' || direction === '0'
- const isClose = entrustType === 'CLOSE' || entrustType === '1'
- if (!isClose) {
- return isBuy ? t('futuresPage.directions.openLong') : t('futuresPage.directions.openShort')
- }
- return isBuy ? t('futuresPage.directions.closeShort') : t('futuresPage.directions.closeLong')
- }
- function orderDirClass(direction: string, entrustType: string) {
- const isBuy = direction === 'BUY' || direction === '0'
- const isClose = entrustType === 'CLOSE' || entrustType === '1'
- // 开多(绿)/平空(绿)/开空(红)/平多(红)
- return (!isClose && isBuy) || (isClose && isBuy) ? 'up' : 'dn'
- }
- function typeLabel(tRaw: number | string | null | undefined) {
- const s = String(tRaw ?? '')
- if (s === '1' || s === 'LIMIT_PRICE' || s === 'LIMIT') {
- return t('futuresPage.orderTypes.limit')
- }
- if (s === '2' || s === 'SPOT_LIMIT' || s === 'PLAN' || s === 'STOP') {
- return t('futuresPage.orderTypes.plan')
- }
- if (s === '3') {
- return t('futuresPage.orderTypes.takeProfit')
- }
- if (s === '4') {
- return t('futuresPage.orderTypes.stopLoss')
- }
- return t('futuresPage.orderTypes.marketShort')
- }
- // 委托价格显示:市价/计划市价→"市价",限价/计划限价→显示原始价格
- function orderPriceDisplay(entrustPrice: string, openType: number, _triggerPrice?: string) {
- if (openType === 0) {
- return t('futuresPage.orderTypes.marketShort')
- }
- if (openType === 2) {
- // 计划委托:entrustPrice=0 时为计划市价
- const ep = parseFloat(entrustPrice)
- if (!ep || ep === 0) {
- return t('futuresPage.orderTypes.marketShort')
- }
- }
- return fmtPrice(entrustPrice)
- }
- function histDirLabel(d: string, entrustType: string) {
- return orderDirLabel(d, entrustType)
- }
- function histDirClass(d: string, entrustType: string) {
- return orderDirClass(d, entrustType)
- }
- // 历史状态标签
- function statusLabel(status: string | number) {
- const s = String(status ?? '').toUpperCase()
- if (s === 'ENTRUST_SUCCESS' || s === 'FILLED' || s === 'DONE' || s === 'PARTLYDONE') {
- return t('futuresPage.orderStatus.filled')
- }
- if (s === 'ENTRUST_ING' || s === 'OPEN' || s === 'STARTED') {
- return t('futuresPage.orderStatus.open')
- }
- if (s === 'ENTRUST_CANCEL' || s === 'CANCELLED') {
- return t('futuresPage.orderStatus.cancelled')
- }
- if (s === 'ENTRUST_FAILURE' || s === 'FAILED') {
- return t('futuresPage.orderStatus.failed')
- }
- return status ? String(status) : t('futuresPage.orderStatus.filled')
- }
- function statusClass(status: string | number) {
- const s = String(status ?? '').toUpperCase()
- if (s === 'ENTRUST_CANCEL' || s === 'CANCELLED' || s === 'ENTRUST_FAILURE' || s === 'FAILED') return 'dn'
- return 'up'
- }
- // 历史仓位平仓类型
- function closeTypeLabel(closeType: number | string) {
- const ty = Number(closeType)
- if (ty === 1) {
- return t('futuresPage.closeTypes.market')
- }
- if (ty === 2) {
- return t('futuresPage.closeTypes.oneClick')
- }
- if (ty === 3) {
- return t('futuresPage.closeTypes.reverseOpen')
- }
- if (ty === 4) {
- return t('futuresPage.closeTypes.tpSl')
- }
- if (ty === 5) {
- return t('futuresPage.closeTypes.liquidation')
- }
- if (ty === 6) {
- return t('futuresPage.closeTypes.admin')
- }
- return ''
- }
- // 历史委托价格显示(区分开仓/平仓单)
- function histOrdOpenPrice(ord: import('@/stores/futures').HistoryOrder) {
- const isClose = ord.entrustType === 'CLOSE' || String(ord.entrustType) === '1'
- return isClose ? fmtPrice(ord.usdtOpenPrice) : fmtPrice(ord.tradedPrice)
- }
- function histOrdEntrustPrice(ord: import('@/stores/futures').HistoryOrder) {
- const s = String(ord.type ?? '')
- const isMarket = s === '0' || s === 'MARKET_PRICE' || s === 'MARKET'
- const isPlan = s === '2' || s === 'SPOT_LIMIT' || s === 'PLAN'
- if (isMarket) {
- return t('futuresPage.orderTypes.marketShort')
- }
- const ep = parseFloat(ord.entrustPrice || '0')
- if (isPlan && (!ep || ep === 0)) {
- return t('futuresPage.orderTypes.marketShort')
- }
- return ep > 0 ? fmtPrice(ord.entrustPrice) : '--'
- }
- function barWidth(items: any[], total: string) {
- const maxTotal = Math.max(...items.map((i: any) => parseFloat(i.total) || 0))
- if (!maxTotal) return '0%'
- return Math.min((parseFloat(total) / maxTotal) * 100, 100) + '%'
- }
- const crosshairMa = computed(() => {
- const kd = crosshairData.value?.kLineData
- if (!kd || !currentBars.length) return null
- const idx = currentBars.findIndex((b: any) => b.timestamp === kd.timestamp)
- if (idx < 0) return null
- const calcMa = (period: number): string | null => {
- if (idx < period - 1) return null
- const sum = currentBars.slice(idx - period + 1, idx + 1).reduce((s: number, b: any) => s + b.close, 0)
- return (sum / period).toFixed(futuresPriceDecimals())
- }
- return { ma5: calcMa(5), ma10: calcMa(10), ma30: calcMa(30) }
- })
- const bsRatio = computed(() => {
- const bidTotal = orderBook.value.bid.items.reduce((s, i) => s + parseFloat(i.amount || '0'), 0)
- const askTotal = orderBook.value.ask.items.reduce((s, i) => s + parseFloat(i.amount || '0'), 0)
- const total = bidTotal + askTotal
- if (!total) return { buy: 50, sell: 50 }
- return { buy: Math.round((bidTotal / total) * 100), sell: Math.round((askTotal / total) * 100) }
- })
- function recalcBookRowCount() {
- const col = bookColRef.value
- if (!col) return
- const rowEl = col.querySelector('.book-row') as HTMLElement | null
- const rowH = Math.max(1, rowEl?.offsetHeight || 22)
- const tabsH = bookTabsRef.value?.offsetHeight || 40
- const ctrlH = bookCtrlRef.value?.offsetHeight || 34
- const headH = bookHeadRef.value?.offsetHeight || 24
- const ratioH = bsRatioRef.value?.offsetHeight || 28
- const midH = bookLayout.value === 'full' ? (bookMidRef.value?.offsetHeight || 32) : 0
- const usable = col.clientHeight - tabsH - ctrlH - headH - ratioH - midH
- if (usable <= 0) return
- const rows = Math.floor((usable + rowH * 0.5) / rowH)
- dynamicBookRows.value = Math.min(60, Math.max(4, rows))
- }
- const bookRowCount = computed(() => {
- const rows = dynamicBookRows.value
- return bookLayout.value === 'full' ? Math.max(1, Math.ceil(rows / 2)) : rows
- })
- function addDepthTotals(rows: [number, number][]) {
- let acc = 0
- return rows.map(([price, qty]) => {
- acc += qty
- return { price: String(price), amount: String(qty), total: String(acc) }
- })
- }
- const displayAskItems = computed(() => {
- const n = bookRowCount.value
- const take = bookLayout.value === 'bids' ? 0 : n
- if (take <= 0) return []
- // 卖盘:从最小价开始取(最接近成交价),展示按价格从大到小
- const sliceAsc = rawBookAsks.value.slice(0, Math.min(take, rawBookAsks.value.length))
- const display = [...sliceAsc].sort((a, b) => b[0] - a[0])
- return addDepthTotals(display)
- })
- const displayBidItems = computed(() => {
- const n = bookRowCount.value
- const take = bookLayout.value === 'asks' ? 0 : n
- if (take <= 0) return []
- const display = rawBookBids.value.slice(0, Math.min(take, rawBookBids.value.length))
- return addDepthTotals(display)
- })
- const bookMidPrice = computed(() => {
- if (klineRealtimeClose.value !== null && Number.isFinite(klineRealtimeClose.value)) {
- return fmtPrice(klineRealtimeClose.value)
- }
- return ticker.value ? fmtPrice(ticker.value.close) : '--'
- })
- // ── Lifecycle ──────────────────────────────────────────
- onMounted(async () => {
- const sym = String(route.params.symbol)
- await store.init(sym)
- if (isLoggedIn.value) {
- store.fetchPositions()
- store.fetchCurrentOrders()
- store.fetchLeverage()
- startPolling()
- }
- await nextTick()
- initChart(); updateChart()
- recalcBookRowCount()
- if (typeof ResizeObserver !== 'undefined' && bookColRef.value) {
- bookRo = new ResizeObserver(() => recalcBookRowCount())
- bookRo.observe(bookColRef.value)
- }
- if (!syncEntrustPriceFromCoinList() && ticker.value && !entrustPrice.value) {
- entrustPrice.value = ticker.value.close
- }
- ws.connect(sym, klineResolution.value)
- countdownTimer = setInterval(updateCountdown, 1000)
- window.addEventListener('resize', onWindowResize)
- })
- watch(() => route.params.symbol, async (sym) => {
- if (!sym) return
- // 切换币对:重置下单输入,避免沿用上一币对的数量与百分比
- entrustPrice.value = ''
- triggerPrice.value = ''
- prevTickClose = 0
- volume.value = ''
- pctSlider.value = 0
- recentTrades.value = []
- rawBookAsks.value = []
- rawBookBids.value = []
- klineRealtimeClose.value = null
- destroyChart()
- await store.init(String(sym))
- if (isLoggedIn.value) {
- store.fetchPositions()
- store.fetchCurrentOrders()
- store.fetchLeverage()
- }
- await nextTick()
- initChart(); updateChart()
- recalcBookRowCount()
- syncEntrustPriceFromCoinList()
- ws.changeSymbol(String(sym))
- })
- watch([bookLayout, bookTab], async () => {
- await nextTick()
- recalcBookRowCount()
- })
- watch(isLoggedIn, (loggedIn) => {
- if (loggedIn) {
- store.fetchPositions()
- store.fetchCurrentOrders()
- store.fetchLeverage()
- startPolling()
- } else {
- stopPolling()
- }
- })
- watch(ticker, (t) => {
- if (t && !entrustPrice.value) entrustPrice.value = t.close
- if (t) document.title = `${symbol.value} ${fmtPrice(t.close)} | iBit`
- })
- function applyPctToVolume(pct: number) {
- const refP = orderRefPrice()
- const avail = parseFloat(swapAvailBalance.value) || 0
- const lev = leverage.value || 1
- const cs = futuresContractSize.value
- const feeRate = futuresOpenFeeRate.value
- if (pct === 0 || avail <= 0) {
- volume.value = ''
- return
- }
- if (refP <= 0) {
- volume.value = ''
- return
- }
- // 与 flutter _PercentSlider 开仓分支:feeMultiplier、effectiveMargin、maxNotional
- const feeMult = 1 + feeRate * lev
- const effectiveMargin = (avail / feeMult) * (pct / 100)
- const maxNotional = effectiveMargin * lev
- const u = amountUnit.value
- if (u === 'usdt') {
- const dp = futuresUsdtDisplayDp.value
- volume.value = floorToDecimals(maxNotional, dp).toFixed(dp)
- } else if (u === 'base') {
- const rawBtc = maxNotional / refP
- volume.value = truncateToCoinPrecision(rawBtc).toFixed(futuresCoinPrecision.value)
- } else {
- const maxL = Math.floor((effectiveMargin * lev) / cs + 1e-12)
- volume.value = maxL > 0 ? String(maxL) : ''
- }
- }
- watch(pctSlider, (pct) => {
- applyPctToVolume(pct)
- })
- // 币对、价格、杠杆、手续费相关变化时,百分比数量按当前参照价重算(对齐 flutter _refreshMaxAmount)
- watch([
- amountUnit, swapAvailBalance, leverage, ticker, entrustPrice, triggerPrice,
- orderType, conditionalSubType, symbolInfo,
- ], () => {
- if (pctSlider.value > 0) applyPctToVolume(pctSlider.value)
- })
- // 与 flutter:切换下单类型时清空数量与百分比
- watch(orderType, () => {
- volume.value = ''
- pctSlider.value = 0
- })
- watch(conditionalSubType, () => {
- volume.value = ''
- pctSlider.value = 0
- })
- // 切到子导航 tab 时加载数据 / 恢复图表
- watch(subNavTab, (tab) => {
- // 图表 canvas 始终保持 position:absolute 渲染,尺寸不会归零
- // 切回图表时只需 resize 更新布局即可
- if (tab === 'chart') {
- nextTick(() => { chart?.resize() })
- }
- if (tab === 'coin-info' && !coinExtInfo.value) {
- store.fetchCoinExtInfo()
- }
- if (tab === 'funding-rate') {
- store.fetchFundingRateCurrent()
- store.fetchFundingRateHistory(true)
- }
- })
- onUnmounted(() => {
- destroyChart()
- ws.disconnect()
- stopPolling()
- if (countdownTimer !== null) clearInterval(countdownTimer)
- window.removeEventListener('resize', onWindowResize)
- frListRo?.disconnect()
- bookRo?.disconnect()
- bookRo = null
- })
- </script>
- <template>
- <div class="ft-page" @click="showPairSelector = false; showLeveragePanel = false; showMarginDrop = false; showPrecDrop = false; showCondDrop = false; showAmountUnitDrop = false">
- <!-- ═══ TICKER BAR ═══ -->
- <div class="ticker-bar">
- <div class="pair-trigger" @click.stop="showPairSelector = !showPairSelector">
- <div class="coin-icon">
- <img v-if="currentCoinIcon" :src="currentCoinIcon" :alt="symbol" />
- <span v-else class="coin-icon-fb">{{ symbol.split('/')[0]?.[0] }}</span>
- </div>
- <div class="pair-name-col">
- <div class="pair-name-row">
- <span class="pair-name">{{ symbol }}</span>
- <svg class="caret" :class="{ open: showPairSelector }" width="10" height="6" viewBox="0 0 10 6">
- <path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/>
- </svg>
- </div>
- <span class="pair-sub">{{ t('futuresPage.perpetual') }}</span>
- </div>
- <Transition name="pop">
- <div v-if="showPairSelector" class="pair-drop" @click.stop>
- <div class="pair-drop-search">
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#6a7182" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
- <input v-model="pairSearch" class="pair-search-input" :placeholder="t('futuresPage.pairSearchPlaceholder')" autofocus />
- </div>
- <div class="pair-list">
- <div
- v-for="t in filteredTickers" :key="t.symbol"
- class="pair-item" :class="{ active: t.symbol === symbol }"
- @click="selectPair(t.symbol)"
- >
- <div class="pi-icon">
- <img v-if="coinIcon(t.symbol)" :src="coinIcon(t.symbol)" :alt="t.symbol" />
- <span v-else class="pi-icon-fb">{{ t.symbol[0] }}</span>
- </div>
- <span class="pi-sym">{{ t.symbol }}</span>
- <span class="pi-price">{{ fmtPrice(t.close) }}</span>
- <span class="pi-chg" :class="parseFloat(t.chg) >= 0 ? 'up' : 'dn'">{{ pct(t.chg) }}</span>
- </div>
- <div v-if="!filteredTickers.length" class="pair-empty">{{ t('futuresPage.pairEmpty') }}</div>
- </div>
- </div>
- </Transition>
- </div>
- <div class="ticker-price-wrap">
- <div class="ticker-price" :class="priceUp ? 'up' : 'dn'">{{ ticker ? fmtPrice(ticker.close) : '--' }}</div>
- <div class="ticker-usd">≈ ${{ ticker ? fmtPrice(ticker.close) : '--' }}</div>
- </div>
- <div class="ticker-stats">
- <div class="ts-item">
- <span class="ts-lbl">{{ t('futuresPage.stats.change24h') }}</span>
- <span class="ts-val" :class="priceUp ? 'up' : 'dn'">{{ ticker ? pct(ticker.chg) : '--' }}</span>
- </div>
- <div class="ts-item">
- <span class="ts-lbl">{{ t('futuresPage.stats.markPrice') }}</span>
- <span class="ts-val">{{ wsMarkPrice ? fmtPrice(wsMarkPrice) : (ticker ? fmtPrice(ticker.close) : '--') }}</span>
- </div>
- <div class="ts-item">
- <span class="ts-lbl">{{ t('futuresPage.stats.high24h') }}</span>
- <span class="ts-val">{{ ticker ? fmtPrice(ticker.high) : '--' }}</span>
- </div>
- <div class="ts-item">
- <span class="ts-lbl">{{ t('futuresPage.stats.low24h') }}</span>
- <span class="ts-val">{{ ticker ? fmtPrice(ticker.low) : '--' }}</span>
- </div>
- <div class="ts-item">
- <span class="ts-lbl">{{ t('futuresPage.stats.volume24h') }}</span>
- <span class="ts-val">{{ ticker ? fmtVol(ticker.turnover) : '--' }} USDT</span>
- </div>
- <div class="ts-item">
- <span class="ts-lbl">{{ t('futuresPage.stats.fundingCountdown') }}</span>
- <span class="ts-val">
- <span :class="wsFundingRate && parseFloat(wsFundingRate) < 0 ? 'dn' : 'up'">
- {{ wsFundingRate ? (parseFloat(wsFundingRate) >= 0 ? '+' : '') + (parseFloat(wsFundingRate) * 100).toFixed(4) + '%' : '--' }}
- </span>
- / {{ fundingCountdown }}
- </span>
- </div>
- </div>
- </div>
- <!-- ═══ MOBILE TAB BAR ═══ -->
- <div v-if="isMobile" class="mobile-trade-tabs">
- <button class="mtt-btn" :class="{ act: mobileTab === 'chart' }" @click="mobileTab = 'chart'">{{ t('futuresPage.mobileTabs.chart') }}</button>
- <button class="mtt-btn" :class="{ act: mobileTab === 'trade' }" @click="mobileTab = 'trade'">{{ t('futuresPage.mobileTabs.trade') }}</button>
- </div>
- <!-- ═══ PAGE BODY (CSS Grid) ═══ -->
- <div class="page-body">
- <!-- LEFT + MIDDLE: chart-area -->
- <div class="chart-area" :class="{ 'mobile-hidden': isMobile && mobileTab !== 'chart' }">
- <!-- Loading -->
- <div v-if="loading" class="chart-loading">
- <div class="spinner"></div>
- </div>
- <div v-if="!loading" class="chart-plus-book">
- <!-- COL 1: Chart -->
- <div class="chart-col">
- <!-- Drawing sidebar -->
- <div class="draw-side">
- <button class="db" :class="{ 'db--act': activeDrawTool === 'cursor' }" :title="t('futuresPage.draw.cursor')" @click="activateDrawTool('cursor')">
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M5 3l14 9-7 1-3 7z"/></svg>
- </button>
- <div class="db-sep"/>
- <button class="db" :class="{ 'db--act': activeDrawTool === 'trend' }" :title="t('futuresPage.draw.trend')" @click="activateDrawTool('trend')">
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="4" y1="20" x2="20" y2="4"/><circle cx="4" cy="20" r="1.8" fill="currentColor"/><circle cx="20" cy="4" r="1.8" fill="currentColor"/></svg>
- </button>
- <button class="db" :class="{ 'db--act': activeDrawTool === 'hline' }" :title="t('futuresPage.draw.hline')" @click="activateDrawTool('hline')">
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="3" y1="12" x2="21" y2="12"/><line x1="5" y1="9" x2="5" y2="15"/><line x1="19" y1="9" x2="19" y2="15"/></svg>
- </button>
- <button class="db" :class="{ 'db--act': activeDrawTool === 'ray' }" :title="t('futuresPage.draw.ray')" @click="activateDrawTool('ray')">
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="4" y1="20" x2="20" y2="4"/><circle cx="4" cy="20" r="1.8" fill="currentColor"/></svg>
- </button>
- <button class="db" :class="{ 'db--act': activeDrawTool === 'channel' }" :title="t('futuresPage.draw.channel')" @click="activateDrawTool('channel')">
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><line x1="3" y1="17" x2="21" y2="7"/><line x1="3" y1="21" x2="21" y2="11"/></svg>
- </button>
- <button class="db" :class="{ 'db--act': activeDrawTool === 'fib' }" :title="t('futuresPage.draw.fib')" @click="activateDrawTool('fib')">
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
- <line x1="3" y1="19" x2="21" y2="19"/><line x1="3" y1="14" x2="21" y2="14"/>
- <line x1="3" y1="10" x2="21" y2="10"/><line x1="3" y1="6" x2="21" y2="6"/>
- </svg>
- </button>
- <div class="db-sep"/>
- <button class="db" :class="{ 'db--act': activeDrawTool === 'text' }" :title="t('futuresPage.draw.text')" @click="activateDrawTool('text')">
- <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><text x="4" y="18" font-size="14" font-weight="700" font-family="Microsoft YaHei, sans-serif">T</text></svg>
- </button>
- <div class="db-sep"/>
- <button class="db" :title="t('futuresPage.draw.clearAll')" @click="clearOverlays()">
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
- </button>
- <div class="db-spacer"/>
- <button class="db" :title="t('futuresPage.draw.zoomOut')" @click="zoomOut()">
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="10" cy="10" r="6"/><line x1="14.5" y1="14.5" x2="20" y2="20"/><line x1="7" y1="10" x2="13" y2="10"/></svg>
- </button>
- </div>
- <!-- Chart main -->
- <div class="chart-main">
- <!-- Sub-nav tabs -->
- <div class="sub-nav">
- <button class="snav-tab" :class="{ act: subNavTab === 'chart' }" @click="subNavTab = 'chart'">{{ t('futuresPage.chartSub.chart') }}</button>
- <button class="snav-tab" :class="{ act: subNavTab === 'coin-info' }" @click="subNavTab = 'coin-info'">{{ t('futuresPage.chartSub.coinInfo') }}</button>
- <button class="snav-tab" :class="{ act: subNavTab === 'funding-rate' }" @click="subNavTab = 'funding-rate'">{{ t('futuresPage.chartSub.fundingHistory') }}</button>
- </div>
- <!-- TF bar -->
- <div v-show="subNavTab === 'chart'" class="tf-bar">
- <button v-for="opt in TF_OPTIONS" :key="opt.value"
- class="tf-btn" :class="{ act: klineResolution === opt.value }"
- @click="klineResolution = opt.value; ws.changeResolution(opt.value)">{{ opt.label }}</button>
- <div class="tf-spacer"/>
- <template v-if="showMA && klines.length">
- <span class="ma-leg ma5">MA5</span>
- <span class="ma-leg ma10">MA10</span>
- <span class="ma-leg ma30">MA30</span>
- </template>
- <button class="tf-ind" :class="{ act: showMA }" @click="showMA = !showMA">MA</button>
- <button class="tf-ind" :class="{ act: showVol }" @click="showVol = !showVol">VOL</button>
- <div class="tf-div"/>
- <button class="tf-icon" :title="t('futuresPage.chartTools.fullscreen')">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M21 16v3a2 2 0 0 1-2 2h-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
- </button>
- <button class="tf-icon" :title="t('futuresPage.chartTools.alert')">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 0 1-3.46 0"/></svg>
- </button>
- </div>
- <!-- Canvas + overlays -->
- <div class="chart-area-rel">
- <div class="chart-canvas-wrap" @mouseleave="crosshairData = null">
- <!-- Top strip: symbol/period -->
- <div class="ohlcv-overlay">
- <span class="ohlcv-sym">{{ displaySymbol }} · {{ TF_OPTIONS.find(t => t.value === klineResolution)?.label }} · iBit</span>
- <template v-if="!crosshairData?.kLineData && ticker">
- <span class="ohlcv-item">{{ t('futuresPage.ohlcv.open') }}=<em>{{ fmtPrice(ticker.open) }}</em></span>
- <span class="ohlcv-item">{{ t('futuresPage.ohlcv.high') }}=<em class="up">{{ fmtPrice(ticker.high) }}</em></span>
- <span class="ohlcv-item">{{ t('futuresPage.ohlcv.low') }}=<em class="dn">{{ fmtPrice(ticker.low) }}</em></span>
- <span class="ohlcv-item">{{ t('futuresPage.ohlcv.close') }}=<em>{{ fmtPrice(ticker.close) }}</em></span>
- </template>
- </div>
- <!-- Hover detail panel -->
- <div class="kline-tooltip" v-if="crosshairData?.kLineData" :style="tooltipStyle">
- <div class="kt-time">{{ fmtDateTime(crosshairData.kLineData.timestamp) }}</div>
- <div class="kt-row">
- <span class="kt-lbl">{{ t('futuresPage.ohlcv.open') }}</span>
- <span class="kt-val" :class="crosshairData.kLineData.close >= crosshairData.kLineData.open ? 'up' : 'dn'">{{ fmtPrice(crosshairData.kLineData.open) }}</span>
- </div>
- <div class="kt-row">
- <span class="kt-lbl">{{ t('futuresPage.ohlcv.high') }}</span>
- <span class="kt-val up">{{ fmtPrice(crosshairData.kLineData.high) }}</span>
- </div>
- <div class="kt-row">
- <span class="kt-lbl">{{ t('futuresPage.ohlcv.low') }}</span>
- <span class="kt-val dn">{{ fmtPrice(crosshairData.kLineData.low) }}</span>
- </div>
- <div class="kt-row">
- <span class="kt-lbl">{{ t('futuresPage.ohlcv.close') }}</span>
- <span class="kt-val" :class="crosshairData.kLineData.close >= crosshairData.kLineData.open ? 'up' : 'dn'">{{ fmtPrice(crosshairData.kLineData.close) }}</span>
- </div>
- <div class="kt-row">
- <span class="kt-lbl">{{ t('futuresPage.ohlcv.volume') }}</span>
- <span class="kt-val">{{ crosshairData.kLineData.volume?.toFixed(4) }}</span>
- </div>
- <div class="kt-row">
- <span class="kt-lbl">{{ t('futuresPage.ohlcv.changePct') }}</span>
- <span class="kt-val" :class="crosshairData.kLineData.close >= crosshairData.kLineData.open ? 'up' : 'dn'">
- {{ ((crosshairData.kLineData.close - crosshairData.kLineData.open) / crosshairData.kLineData.open * 100).toFixed(2) }}%
- </span>
- </div>
- <div class="kt-row">
- <span class="kt-lbl">{{ t('futuresPage.ohlcv.amplitude') }}</span>
- <span class="kt-val">{{ ((crosshairData.kLineData.high - crosshairData.kLineData.low) / crosshairData.kLineData.open * 100).toFixed(2) }}%</span>
- </div>
- <template v-if="crosshairMa">
- <div class="kt-row" v-if="crosshairMa.ma5 !== null">
- <span class="kt-lbl kt-c-ma5">MA5</span>
- <span class="kt-val kt-c-ma5">{{ crosshairMa.ma5 }}</span>
- </div>
- <div class="kt-row" v-if="crosshairMa.ma10 !== null">
- <span class="kt-lbl kt-c-ma10">MA10</span>
- <span class="kt-val kt-c-ma10">{{ crosshairMa.ma10 }}</span>
- </div>
- <div class="kt-row" v-if="crosshairMa.ma30 !== null">
- <span class="kt-lbl kt-c-ma30">MA30</span>
- <span class="kt-val kt-c-ma30">{{ crosshairMa.ma30 }}</span>
- </div>
- </template>
- </div>
- <div ref="chartEl" class="kline-canvas"/>
- </div><!-- /chart-canvas-wrap -->
- <!-- 币种资料面板 -->
- <div v-show="subNavTab === 'coin-info'" class="coin-info-panel">
- <div v-if="coinExtLoading" class="ci-loading"><span class="spin-sm"/></div>
- <template v-else-if="coinExtInfo">
- <div class="cip-header">
- <img v-if="coinExtInfo.icon" :src="coinExtInfo.icon" class="cip-icon" alt="icon"/>
- <div class="cip-names">
- <span class="cip-name-cn">{{ coinExtInfo.nameCn || coinExtInfo.name || symbol.split('/')[0] }}</span>
- <span class="cip-name-en">{{ coinExtInfo.nameEn || symbol.split('/')[0] }}</span>
- </div>
- <span v-if="coinExtInfo.rank" class="cip-rank">#{{ coinExtInfo.rank }}</span>
- </div>
- <div class="cip-grid">
- <div class="cip-item">
- <span class="cip-lbl">{{ t('futuresPage.coinInfo.marketCap') }}</span>
- <span class="cip-val">{{ coinExtInfo.marketCap || '--' }}</span>
- </div>
- <div class="cip-item">
- <span class="cip-lbl">{{ t('futuresPage.coinInfo.circulating') }}</span>
- <span class="cip-val">{{ coinExtInfo.circulatingSupply || '--' }}</span>
- </div>
- <div class="cip-item">
- <span class="cip-lbl">{{ t('futuresPage.coinInfo.issuePrice') }}</span>
- <span class="cip-val">{{ coinExtInfo.issuePrice != null ? '$' + Number(coinExtInfo.issuePrice).toFixed(6) : '--' }}</span>
- </div>
- <div class="cip-item">
- <span class="cip-lbl">{{ t('futuresPage.coinInfo.ath') }}</span>
- <span class="cip-val up">{{ coinExtInfo.athPrice != null ? '$' + Number(coinExtInfo.athPrice).toFixed(2) : '--' }}</span>
- </div>
- <div v-if="coinExtInfo.athDate" class="cip-item">
- <span class="cip-lbl">{{ t('futuresPage.coinInfo.athDate') }}</span>
- <span class="cip-val">{{ coinExtInfo.athDate }}</span>
- </div>
- <div v-if="coinExtInfo.whitepaper" class="cip-item cip-item--full">
- <span class="cip-lbl">{{ t('futuresPage.coinInfo.whitepaper') }}</span>
- <a :href="coinExtInfo.whitepaper" target="_blank" class="cip-link">{{ coinExtInfo.whitepaper }}</a>
- </div>
- </div>
- </template>
- <div v-else class="cip-empty">
- <span>{{ t('futuresPage.coinInfo.empty') }}</span>
- <button class="cip-retry" @click="store.fetchCoinExtInfo()">{{ t('futuresPage.coinInfo.reload') }}</button>
- </div>
- </div>
- <!-- 历史资金费率面板 -->
- <div v-show="subNavTab === 'funding-rate'" class="fr-panel">
- <!-- 左右分栏 -->
- <div class="fr-body">
- <!-- 左侧:info bar + 折线图 -->
- <div class="fr-left">
- <div class="fr-info-bar">
- <div class="fr-info-item">
- <span class="fr-info-lbl">{{ t('futuresPage.fundingPanel.rate') }}</span>
- <span class="fr-info-val" :class="(frCurrentRate ?? 0) < 0 ? 'dn' : 'up'">
- {{ frCurrentRateStr }}
- </span>
- </div>
- <div class="fr-info-item">
- <span class="fr-info-lbl">{{ t('futuresPage.fundingPanel.period') }}</span>
- <span class="fr-info-val">{{ frPeriodLabel }}</span>
- </div>
- <div class="fr-info-item">
- <span class="fr-info-lbl">{{ t('futuresPage.fundingPanel.nextCountdown') }}</span>
- <span class="fr-info-val">{{ fundingCountdown }}</span>
- </div>
- </div>
- <div class="fr-chart-wrap">
- <div v-if="!frChart" class="fr-chart-empty">{{ t('futuresPage.fundingPanel.noChart') }}</div>
- <svg v-else
- ref="frChartRef"
- :viewBox="`0 0 ${600} ${200}`"
- preserveAspectRatio="none"
- class="fr-chart-svg"
- @mousemove="onFrMouseMove"
- @mouseleave="onFrMouseLeave"
- >
- <defs>
- <linearGradient id="frAreaGrad" x1="0" y1="0" x2="0" y2="1">
- <stop offset="0%" stop-color="#4a8fff" stop-opacity="0.22"/>
- <stop offset="100%" stop-color="#4a8fff" stop-opacity="0"/>
- </linearGradient>
- </defs>
- <!-- 水平网格线 -->
- <line v-for="(y, i) in frChart.hGrid" :key="`hg${i}`"
- :x1="FR_PL" :y1="y.toFixed(1)" :x2="FR_PL+FR_PW" :y2="y.toFixed(1)"
- stroke="rgba(255,255,255,0.07)" stroke-width="0.5"/>
- <!-- 零轴虚线 -->
- <line v-if="frChart.showZero"
- :x1="FR_PL" :y1="frChart.zeroY.toFixed(1)"
- :x2="FR_PL+FR_PW" :y2="frChart.zeroY.toFixed(1)"
- stroke="rgba(255,255,255,0.3)" stroke-width="0.8" stroke-dasharray="4,4"/>
- <!-- 渐变填充 -->
- <path :d="frChart.areaPath" fill="url(#frAreaGrad)"/>
- <!-- 折线 -->
- <path :d="frChart.linePath" fill="none" stroke="#4a8fff" stroke-width="1.5"/>
- <!-- X轴标签 -->
- <text v-for="(lb, i) in frChart.xLabels" :key="`xl${i}`"
- :x="lb.x" :y="FR_PT+FR_PH+18"
- text-anchor="middle" font-size="9" fill="#6a7182">{{ lb.label }}</text>
- <!-- Y轴标签 -->
- <text v-for="(lb, i) in frChart.yLabels" :key="`yl${i}`"
- :x="FR_PL+FR_PW+4" :y="lb.y+3"
- text-anchor="start" font-size="9" fill="#6a7182">{{ lb.label }}</text>
- <!-- 悬停十字线 + 圆点 + tooltip -->
- <template v-if="frHoverIdx >= 0 && frChart.pts[frHoverIdx]">
- <line
- :x1="frChart.pts[frHoverIdx].x.toFixed(1)" :y1="FR_PT"
- :x2="frChart.pts[frHoverIdx].x.toFixed(1)" :y2="FR_PT+FR_PH"
- stroke="rgba(255,255,255,0.4)" stroke-width="0.5"/>
- <circle
- :cx="frChart.pts[frHoverIdx].x.toFixed(1)"
- :cy="frChart.pts[frHoverIdx].y.toFixed(1)"
- r="3.5" fill="#4a8fff" stroke="white" stroke-width="1.5"/>
- <!-- tooltip box -->
- <g :transform="`translate(${Math.min(frChart.pts[frHoverIdx].x + 8, FR_W - 120)},${Math.max(frChart.pts[frHoverIdx].y - 32, FR_PT)})`">
- <rect x="0" y="0" width="112" height="36" rx="4" fill="#1a2035" opacity="0.9"/>
- <text x="8" y="14" font-size="10" fill="#9ca3b0">{{ frFmtDatetime(frChart.pts[frHoverIdx].time) }}</text>
- <text x="8" y="28" font-size="11" font-weight="600"
- :fill="frChart.pts[frHoverIdx].rate >= 0 ? '#02c076' : '#f6465d'">
- {{ (frChart.pts[frHoverIdx].rate >= 0 ? '+' : '') + (frChart.pts[frHoverIdx].rate * 100).toFixed(4) + '%' }}
- </text>
- </g>
- </template>
- </svg>
- </div><!-- /fr-chart-wrap -->
- </div><!-- /fr-left -->
- <!-- 历史列表 -->
- <div ref="frListRef" class="fr-list">
- <div class="fr-head">
- <span>{{ t('futuresPage.table.settlementTime') }}</span><span class="tr">{{ t('futuresPage.table.fundingRate') }}</span>
- </div>
- <div v-if="!frItems.length" class="fr-empty">{{ t('futuresPage.fundingPanel.empty7d') }}</div>
- <template v-else>
- <div v-for="(item, i) in frVisibleItems" :key="i" class="fr-row">
- <span>{{ frFmtDatetime(item.time) }}</span>
- <span class="tr" :class="item.rate < 0 ? 'dn' : 'up'">
- {{ (item.rate >= 0 ? '+' : '') + (item.rate * 100).toFixed(4) + '%' }}
- </span>
- </div>
- </template>
- </div>
- </div><!-- /fr-body -->
- </div><!-- /fr-panel -->
- </div><!-- /chart-area-rel -->
- </div>
- </div>
- <!-- COL 2: Order book -->
- <div ref="bookColRef" class="book-col">
- <div ref="bookTabsRef" class="book-hd-tabs">
- <button class="book-tab" :class="{ act: bookTab === 'orderbook' }" @click="bookTab = 'orderbook'">{{ t('futuresPage.book.orderBook') }}</button>
- <button class="book-tab" :class="{ act: bookTab === 'trades' }" @click="bookTab = 'trades'">{{ t('futuresPage.book.trades') }}</button>
- </div>
- <div ref="bookCtrlRef" class="book-hd-ctrl">
- <div v-if="bookTab === 'orderbook'" class="book-layouts">
- <button class="bl-btn" :class="{ act: bookLayout === 'full' }" @click="bookLayout = 'full'">
- <svg width="16" height="14" viewBox="0 0 16 14">
- <rect x="1" y="1" width="6" height="2" rx="0.5" fill="#f6465d"/>
- <rect x="1" y="4.5" width="6" height="2" rx="0.5" fill="#f6465d"/>
- <rect x="9" y="1" width="6" height="2" rx="0.5" fill="currentColor" opacity="0.3"/>
- <rect x="9" y="4.5" width="6" height="2" rx="0.5" fill="currentColor" opacity="0.3"/>
- <rect x="1" y="8" width="6" height="2" rx="0.5" fill="#02c076"/>
- <rect x="1" y="11" width="6" height="2" rx="0.5" fill="#02c076"/>
- <rect x="9" y="8" width="6" height="2" rx="0.5" fill="currentColor" opacity="0.3"/>
- <rect x="9" y="11" width="6" height="2" rx="0.5" fill="currentColor" opacity="0.3"/>
- </svg>
- </button>
- <button class="bl-btn" :class="{ act: bookLayout === 'asks' }" @click="bookLayout = 'asks'">
- <svg width="16" height="14" viewBox="0 0 16 14">
- <rect x="1" y="1" width="14" height="2" rx="0.5" fill="#f6465d"/>
- <rect x="1" y="4.5" width="14" height="2" rx="0.5" fill="#f6465d"/>
- <rect x="1" y="8" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.2"/>
- <rect x="1" y="11" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.2"/>
- </svg>
- </button>
- <button class="bl-btn" :class="{ act: bookLayout === 'bids' }" @click="bookLayout = 'bids'">
- <svg width="16" height="14" viewBox="0 0 16 14">
- <rect x="1" y="1" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.2"/>
- <rect x="1" y="4.5" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.2"/>
- <rect x="1" y="8" width="14" height="2" rx="0.5" fill="#02c076"/>
- <rect x="1" y="11" width="14" height="2" rx="0.5" fill="#02c076"/>
- </svg>
- </button>
- </div>
- </div>
- <template v-if="bookTab === 'orderbook'">
- <div ref="bookHeadRef" class="book-col-head">
- <span>{{ t('futuresPage.book.priceQuote') }}</span>
- <span class="tr">{{ t('futuresPage.book.amountBase', { base: baseCurrency }) }}</span>
- <span class="tr">{{ t('futuresPage.book.totalBase', { base: baseCurrency }) }}</span>
- </div>
- <div v-if="bookLayout !== 'bids'" class="book-asks">
- <div v-for="(item, i) in displayAskItems" :key="`a${i}`" class="book-row ask-row" @click="onBookClick(item.price)">
- <div class="br-depth br-depth--ask" :style="{ width: barWidth(displayAskItems, item.total) }"/>
- <span class="br-p dn">{{ fmtPrice(item.price) }}</span>
- <span class="br-a">{{ parseFloat(item.amount).toFixed(4) }}</span>
- <span class="br-t">{{ parseFloat(item.total).toFixed(2) }}</span>
- </div>
- </div>
- <div v-if="bookLayout === 'full'" ref="bookMidRef" class="book-mid" :class="tickPriceUp ? 'up' : 'dn'">
- <span class="bm-price">{{ bookMidPrice }} USDT</span>
- <svg v-if="tickPriceUp" width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><path d="M6 1l5 9H1z"/></svg>
- <svg v-else width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><path d="M6 11L1 2h10z"/></svg>
- </div>
- <div v-if="bookLayout !== 'asks'" class="book-bids">
- <div v-for="(item, i) in displayBidItems" :key="`b${i}`" class="book-row bid-row" @click="onBookClick(item.price)">
- <div class="br-depth br-depth--bid" :style="{ width: barWidth(displayBidItems, item.total) }"/>
- <span class="br-p up">{{ fmtPrice(item.price) }}</span>
- <span class="br-a">{{ parseFloat(item.amount).toFixed(4) }}</span>
- <span class="br-t">{{ parseFloat(item.total).toFixed(2) }}</span>
- </div>
- </div>
- <div ref="bsRatioRef" class="bs-ratio">
- <div class="bs-bar">
- <div class="bs-b" :style="{ width: bsRatio.buy + '%' }"/>
- <div class="bs-s" :style="{ width: bsRatio.sell + '%' }"/>
- </div>
- <div class="bs-lbl">
- <span class="up">{{ t('futuresPage.book.buyRatio', { n: bsRatio.buy }) }}</span>
- <span class="dn">{{ t('futuresPage.book.sellRatio', { n: bsRatio.sell }) }}</span>
- </div>
- </div>
- </template>
- <template v-else>
- <div class="book-col-head">
- <span>{{ t('futuresPage.book.priceQuote') }}</span>
- <span class="tr">{{ t('futuresPage.book.amountBase', { base: baseCurrency }) }}</span>
- <span class="tr">{{ t('futuresPage.book.time') }}</span>
- </div>
- <div v-if="!recentTrades.length" class="empty-tip">{{ t('futuresPage.book.noTrades') }}</div>
- <div v-else class="trades-list">
- <div v-for="(tr, i) in recentTrades.slice(0, 12)" :key="i" class="trade-row">
- <span :class="tr.isSell ? 'dn' : 'up'">{{ fmtPrice(tr.price) }}</span>
- <span class="tr">{{ parseFloat(tr.qty).toFixed(4) }}</span>
- <span class="tr trade-time">{{ fmtTimeShort(tr.time) }}</span>
- </div>
- </div>
- </template>
- </div>
- </div>
- </div>
- <!-- RIGHT: Trading panel -->
- <div class="trade-panel" :class="{ 'mobile-show': isMobile && mobileTab === 'trade' }" @click.stop>
- <!-- Margin mode + leverage -->
- <div class="tp-hd">
- <div class="margin-wrap">
- <button class="margin-btn" :disabled="isSwitchingMode" @click.stop="showMarginDrop = !showMarginDrop">
- {{ marginModeLabel }}
- <svg v-if="!isSwitchingMode" width="8" height="5" viewBox="0 0 8 5"><path d="M1 1l3 3 3-3" stroke="currentColor" stroke-width="1.2" fill="none"/></svg>
- <span v-else class="spin-sm"/>
- </button>
- <Transition name="pop">
- <div v-if="showMarginDrop" class="margin-drop" @click.stop>
- <button v-for="m in MARGIN_MODES" :key="m.value"
- class="mdrop-item" :class="{ act: marginMode === m.value }"
- @click="selectMarginMode(m.value)">{{ m.label }}</button>
- </div>
- </Transition>
- </div>
- <div class="tp-hd-sp"/>
- <div class="lev-wrap">
- <button class="lev-btn" @click.stop="showLeveragePanel = !showLeveragePanel">
- {{ leverage }}x
- <svg width="8" height="5" viewBox="0 0 8 5"><path d="M1 1l3 3 3-3" stroke="currentColor" stroke-width="1.2" fill="none"/></svg>
- </button>
- <Transition name="pop">
- <div v-if="showLeveragePanel" class="lev-panel" @click.stop>
- <div class="lp-title">{{ t('futuresPage.leverage.adjustTitle') }}</div>
- <!-- 固定档位模式:只显示 pill 选择 -->
- <template v-if="isDiscreteLeverage">
- <div class="lp-pills lp-pills--full">
- <button v-for="lv in leverageOptions" :key="lv"
- class="lp-pill" :class="{ act: leverageDraft === lv }"
- @click="leverageDraft = lv">{{ lv }}x</button>
- </div>
- </template>
- <!-- 区间任意值模式:步进 + 滑块 -->
- <template v-else>
- <div class="lp-stepper">
- <button class="lp-step-btn" :disabled="leverageDraft <= leverageMin" @click="leverageStep(-1)">-</button>
- <div class="lp-step-val">{{ leverageDraft }}x</div>
- <button class="lp-step-btn" :disabled="leverageDraft >= leverageMax" @click="leverageStep(1)">+</button>
- </div>
- <input type="range" class="lp-range"
- :min="leverageMin" :max="leverageMax" step="1"
- v-model.number="leverageDraft"/>
- <div class="lp-range-ticks">
- <span>{{ leverageMin }}x</span>
- <span>{{ leverageMax }}x</span>
- </div>
- </template>
- <div class="lp-actions">
- <button class="lp-cancel" @click="showLeveragePanel = false">{{ t('common.cancel') }}</button>
- <button class="lp-confirm" @click="confirmLeverage">{{ t('common.confirm') }}</button>
- </div>
- </div>
- </Transition>
- </div>
- </div>
- <!-- ══ OPEN PANEL ══ -->
- <!-- Order type -->
- <div class="ot-row">
- <button class="ot-btn" :class="{ act: orderType === 'limit' }" @click="orderType = 'limit'">{{ t('futuresPage.order.limit') }}</button>
- <button class="ot-btn" :class="{ act: orderType === 'market' }" @click="orderType = 'market'">{{ t('futuresPage.order.market') }}</button>
- <div class="ot-cond-wrap" @click.stop>
- <button class="ot-btn ot-cond" :class="{ act: orderType === 'conditional' }"
- @click="orderType = 'conditional'; showCondDrop = !showCondDrop">
- {{ orderType === 'conditional'
- ? (conditionalSubType === 'market' ? t('futuresPage.order.condMarket') : t('futuresPage.order.condLimit'))
- : t('futuresPage.order.conditional')
- }}
- <svg width="7" height="5" viewBox="0 0 8 5"><path d="M1 1l3 3 3-3" stroke="currentColor" stroke-width="1.2" fill="none"/></svg>
- </button>
- <div v-if="showCondDrop" class="cond-drop">
- <div class="cond-drop-item" :class="{ act: conditionalSubType === 'market' }"
- @mousedown.prevent="conditionalSubType = 'market'; showCondDrop = false">{{ t('futuresPage.order.condMarket') }}</div>
- <div class="cond-drop-item" :class="{ act: conditionalSubType === 'limit' }"
- @mousedown.prevent="conditionalSubType = 'limit'; showCondDrop = false">{{ t('futuresPage.order.condLimit') }}</div>
- </div>
- </div>
- <button class="ot-info">
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
- </button>
- </div>
- <div class="tp-body">
- <!-- Available -->
- <div class="avail-row">
- <span class="avail-lbl">{{ t('futuresPage.order.available') }}</span>
- <span class="avail-val">{{ trunc2(swapAvailBalance) }} {{ quoteCurrency }}</span>
- <div class="avail-acts">
- <button class="avail-act" :title="t('futuresPage.order.transferTitle')" @click.stop="showTransfer = true">
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 8h14M5 8l4-4M5 8l4 4M19 16H5M19 16l-4-4M19 16l-4 4"/></svg>
- </button>
- </div>
- </div>
- <div v-if="isLoggedIn && symbolInfo" class="avail-row max-open-row">
- <span class="avail-lbl">{{ t('futuresPage.order.maxOpen') }}</span>
- <span class="avail-val">{{ maxOpenAmountDisplay }} {{ amountUnit === 'base' ? baseCurrency : amountUnitLabel }}</span>
- </div>
- <!-- Trigger price (条件委托) -->
- <div v-if="orderType === 'conditional'" class="inp-block">
- <div class="inp-wrap">
- <span class="inp-lbl">{{ t('futuresPage.order.triggerPriceLabel', { q: quoteCurrency }) }}</span>
- <div class="inp-row">
- <input v-model="triggerPrice" type="number" class="inp" :placeholder="t('futuresPage.placeholders.triggerPrice')"/>
- <button class="inp-quick" @click="triggerPrice = ticker?.close ?? ''">{{ t('futuresPage.order.latestBtn') }}</button>
- </div>
- </div>
- </div>
- <!-- Price input -->
- <div class="inp-block">
- <div v-if="orderType === 'limit' || (orderType === 'conditional' && conditionalSubType === 'limit')" class="inp-wrap">
- <span class="inp-lbl">{{ t('futuresPage.order.priceLabel', { q: quoteCurrency }) }}</span>
- <div class="inp-row">
- <input v-model="entrustPrice" type="number" class="inp" :placeholder="t('futuresPage.placeholders.price')"/>
- <button class="inp-quick" @click="setLatestPrice">{{ t('futuresPage.order.latestBtn') }}</button>
- </div>
- </div>
- <div v-else-if="orderType === 'market'" class="inp-market">{{ t('futuresPage.order.marketExe') }}</div>
- <div v-else-if="orderType === 'conditional' && conditionalSubType === 'market'" class="inp-market">{{ t('futuresPage.order.condMarketExe') }}</div>
- </div>
- <!-- Volume input -->
- <div class="inp-block">
- <div class="inp-wrap">
- <div class="inp-lbl-row">
- <span class="inp-lbl">{{ t('futuresPage.order.qtyLabel', { u: amountUnitLabel }) }}</span>
- <div class="vol-unit-wrap" @click.stop>
- <button class="vol-unit-btn" @click="showAmountUnitDrop = !showAmountUnitDrop">
- {{ amountUnitLabel }}
- <svg width="7" height="5" viewBox="0 0 8 5"><path d="M1 1l3 3 3-3" stroke="currentColor" stroke-width="1.2" fill="none"/></svg>
- </button>
- <Transition name="pop">
- <div v-if="showAmountUnitDrop" class="vol-unit-drop">
- <button v-for="u in AMOUNT_UNITS" :key="u.value"
- class="vud-item" :class="{ act: amountUnit === u.value }"
- @click="selectAmountUnit(u.value)">
- {{ u.value === 'base' ? baseCurrency : u.label }}
- </button>
- </div>
- </Transition>
- </div>
- </div>
- <input v-model="volume" type="number" class="inp" :placeholder="t('futuresPage.placeholders.amount')"/>
- </div>
- </div>
- <!-- PCT buttons (replaces slider) -->
- <div class="pct-btns">
- <button v-for="p in [25,50,75,100]" :key="p" class="pct-btn" @click="setPctSlider(p)">{{ p }}%</button>
- </div>
- <p v-if="error" class="trade-err">{{ error }}</p>
- <!-- Submit buttons -->
- <div v-if="isLoggedIn" class="submit-row">
- <button class="sub-btn sub-buy" :disabled="orderLoading || !volume"
- @click="side = 'buy'; submitOrder()">
- <span v-if="!orderLoading || side !== 'buy'">{{ t('futuresPage.order.openLong') }}</span>
- <span v-else class="spin-sm"/>
- </button>
- <button class="sub-btn sub-sell" :disabled="orderLoading || !volume"
- @click="side = 'sell'; submitOrder()">
- <span v-if="!orderLoading || side !== 'sell'">{{ t('futuresPage.order.openShort') }}</span>
- <span v-else class="spin-sm"/>
- </button>
- </div>
- <div v-else class="auth-btns">
- <router-link to="/register" class="auth-btn auth-register">{{ t('header.register') }}</router-link>
- <router-link to="/login" class="auth-btn auth-login">{{ t('header.login') }}</router-link>
- </div>
- <!-- Contract info -->
- <div v-if="symbolInfo" class="contract-info">
- <div class="ci-title">{{ t('futuresPage.order.contractInfoTitle') }}</div>
- <div class="ci-row"><span>{{ t('futuresPage.order.indexSource') }}</span><span>Huobi,Binance,Okex</span></div>
- <div class="ci-row"><span>{{ t('futuresPage.order.marginCoin') }}</span><span>{{ quoteCurrency }}</span></div>
- <div class="ci-row"><span>{{ t('futuresPage.order.maxLeverage') }}</span><span>{{ symbolInfo.leverage.split(',').pop() }}x</span></div>
- <div class="ci-row"><span>{{ t('futuresPage.order.maintMargin') }}</span><span>{{ (parseFloat(symbolInfo.maintenanceMarginRate) * 100).toFixed(2) }}%</span></div>
- <div class="ci-row"><span>{{ t('futuresPage.order.openFee') }}</span><span>{{ (parseFloat(symbolInfo.openFee) * 100).toFixed(3) }}%</span></div>
- <div class="ci-row"><span>{{ t('futuresPage.order.closeFee') }}</span><span>{{ (parseFloat(symbolInfo.closeFee) * 100).toFixed(3) }}%</span></div>
- <div v-if="symbolInfo.shareNumber" class="ci-row"><span>{{ t('futuresPage.order.contractValue') }}</span><span>{{ symbolInfo.shareNumber }} {{ quoteCurrency }}</span></div>
- <div class="ci-row"><span>{{ t('futuresPage.order.minOrder') }}</span><span>{{ symbolInfo.minShare }} {{ baseCurrency }}</span></div>
- <div class="ci-row"><span>{{ t('futuresPage.order.maxOrder') }}</span><span>{{ symbolInfo.maxShare }} {{ baseCurrency }}</span></div>
- </div>
- </div>
- </div>
- <!-- ═══ BOTTOM AREA (inside page-body grid col 1) ═══ -->
- <div v-if="!loading" class="bottom-area">
- <div class="btab-bar">
- <button class="btab" :class="{ act: activeTab === 'positions' }" @click="activeTab = 'positions'">
- {{ t('futuresPage.bottom.positions') }} <span class="badge">{{ positions.length }}</span>
- </button>
- <button class="btab" :class="{ act: activeTab === 'orders' }" @click="activeTab = 'orders'">
- {{ t('futuresPage.bottom.openOrders') }} <span class="badge">{{ currentOrders.length }}</span>
- </button>
- <button class="btab" :class="{ act: activeTab === 'history-orders' }" @click="activeTab = 'history-orders'; store.fetchHistoryOrders(true)">{{ t('futuresPage.bottom.historyOrders') }}</button>
- <button class="btab" :class="{ act: activeTab === 'history-positions' }" @click="activeTab = 'history-positions'; store.fetchHistoryPositions(true)">{{ t('futuresPage.bottom.historyPositions') }}</button>
- <button class="btab" :class="{ act: activeTab === 'trades' }" @click="activeTab = 'trades'; store.fetchHistoryOrders(true)">{{ t('futuresPage.bottom.tradeDetails') }}</button>
- <button class="btab" :class="{ act: activeTab === 'funding' }" @click="activeTab = 'funding'; store.fetchFundingRecords(true)">{{ t('futuresPage.bottom.fundingFlow') }}</button>
- <button class="btab" :class="{ act: activeTab === 'assets' }" @click="activeTab = 'assets'">{{ t('futuresPage.bottom.assets') }}</button>
- <div class="btab-sp"/>
- <button v-if="activeTab === 'orders'" class="close-all" :disabled="orderLoading || !currentOrders.length" @click="cancelAllOrders">{{ t('futuresPage.bottom.cancelAllOrders') }}</button>
- <button v-else class="close-all" :disabled="orderLoading || !positions.length" @click="confirmCloseAll">{{ t('futuresPage.bottom.closeAllPositions') }}</button>
- </div>
- <div ref="tabScrollRef" class="tab-scroll-area" @scroll.passive="onTabScroll">
- <template v-if="activeTab === 'positions'">
- <div v-if="!positions.length" class="empty-tip">
- <template v-if="!isLoggedIn">
- <router-link to="/login" class="tip-link">{{ t('header.login') }}</router-link>
- {{ t('futuresPage.bottom.loginPromptOr') }}
- <router-link to="/register" class="tip-link">{{ t('futuresPage.bottom.registerNow') }}</router-link>
- {{ t('futuresPage.bottom.startTrading') }}
- </template>
- <template v-else>{{ t('futuresPage.empty.noPositions') }}</template>
- </div>
- <div v-else class="data-tbl">
- <div class="dt-head dt-head--pos">
- <span>{{ t('futuresPage.table.contract') }}</span><span>{{ t('futuresPage.table.dirLeverage') }}</span><span>{{ t('futuresPage.table.openPrice') }}</span><span>{{ t('futuresPage.table.markPrice') }}</span>
- <span>{{ t('futuresPage.table.positionSize') }}</span><span>{{ t('futuresPage.table.margin') }}</span><span>{{ t('futuresPage.table.marginRate') }}</span>
- <span>{{ t('futuresPage.table.unrealizedPnl') }}</span><span>{{ t('futuresPage.table.roe') }}</span><span>{{ t('futuresPage.table.liqPrice') }}</span><span>{{ t('futuresPage.table.openTime') }}</span><span>{{ t('futuresPage.table.action') }}</span>
- </div>
- <div v-for="pos in positions" :key="pos.id" class="dt-row dt-row--pos dt-row--clickable" @click="openPosDetail(pos)">
- <span class="fw6">{{ pos.coin?.symbol?.replace('/', '-') ?? '--' }}</span>
- <span :class="pos.direction === 'BUY' ? 'up' : 'dn'">{{ dirLabel(pos.direction) }} {{ pos.leverage }}x</span>
- <span>{{ fmtPrice(pos.openPrice) }}</span>
- <span>{{ fmtPrice(pos.currentPrice) }}</span>
- <span>{{ pos.currentPosition }}</span>
- <span>{{ parseFloat(pos.currentMargin || '0').toFixed(4) }}</span>
- <span>{{ parseFloat(pos.marginRate || '0') > 0 ? parseFloat(pos.marginRate).toFixed(2) + '%' : '--' }}</span>
- <span :class="calcLivePnl(pos) >= 0 ? 'up' : 'dn'">
- {{ calcLivePnl(pos) >= 0 ? '+' : '' }}{{ calcLivePnl(pos).toFixed(4) }}
- </span>
- <span :class="calcLivePnlPct(pos) >= 0 ? 'up' : 'dn'">
- {{ calcLivePnlPct(pos) >= 0 ? '+' : '' }}{{ calcLivePnlPct(pos).toFixed(2) }}%
- </span>
- <span :class="parseFloat(pos.estimatedBlastPrice) > 0 ? 'dn' : ''">{{ parseFloat(pos.estimatedBlastPrice) > 0 ? fmtPrice(pos.estimatedBlastPrice) : '--' }}</span>
- <span>{{ fmtTime(pos.openTime) }}</span>
- <span class="op-col">
- <button class="act-btn act-btn--sec" :disabled="orderLoading" @click.stop="openTpSlModal(pos.id)">{{ t('futuresPage.actions.tpSl') }}</button>
- <button class="act-btn act-btn--sec" :disabled="orderLoading" @click.stop="reversePosition(pos)">{{ t('futuresPage.actions.reverse') }}</button>
- <button class="act-btn" :disabled="orderLoading" @click.stop="openCloseSheet(pos)">{{ t('futuresPage.actions.close') }}</button>
- <button class="act-btn act-btn--market" :disabled="orderLoading" @click.stop="store.closePosition(pos.id, pos.availablePosition)">{{ t('futuresPage.actions.marketCloseAll') }}</button>
- </span>
- </div>
- </div>
- </template>
- <template v-if="activeTab === 'orders'">
- <div v-if="!currentOrders.length" class="empty-tip">{{ t('futuresPage.empty.noOrders') }}</div>
- <div v-else class="data-tbl">
- <div class="dt-head dt-head--ord">
- <span>{{ t('futuresPage.table.contract') }}</span><span>{{ t('futuresPage.table.dirLeverage') }}</span><span>{{ t('futuresPage.table.type') }}</span><span>{{ t('futuresPage.table.orderPrice') }}</span><span>{{ t('futuresPage.table.triggerPrice') }}</span>
- <span>{{ t('futuresPage.table.orderAmt') }}</span><span>{{ t('futuresPage.table.filled') }}</span><span>{{ t('futuresPage.table.tpPrice') }}</span><span>{{ t('futuresPage.table.slPrice') }}</span><span>{{ t('futuresPage.table.time') }}</span><span>{{ t('futuresPage.table.action') }}</span>
- </div>
- <div v-for="ord in currentOrders" :key="ord.id" class="dt-row dt-row--ord dt-row--clickable" @click="openOrdDetail(ord)">
- <span class="fw6">{{ ord.symbol?.replace('/', '-') ?? '--' }}</span>
- <span :class="orderDirClass(ord.direction, ord.entrustType)">
- {{ orderDirLabel(ord.direction, ord.entrustType) }} {{ ord.leverage }}x
- </span>
- <span>{{ typeLabel(ord.openType ?? ord.type) }}</span>
- <span>{{ orderPriceDisplay(ord.entrustPrice, ord.openType ?? 0) }}</span>
- <span>{{ parseFloat(ord.triggerPrice) > 0 ? fmtPrice(ord.triggerPrice) : '--' }}</span>
- <span>{{ ord.volume }}</span>
- <span>{{ ord.tradedVolume }}</span>
- <span class="up">{{ ord.profitPrice && parseFloat(String(ord.profitPrice)) > 0 ? fmtPrice(String(ord.profitPrice)) : '--' }}</span>
- <span class="dn">{{ ord.lossPrice && parseFloat(String(ord.lossPrice)) > 0 ? fmtPrice(String(ord.lossPrice)) : '--' }}</span>
- <span>{{ fmtTime(ord.time || ord.createTime) }}</span>
- <span class="op-col">
- <button class="act-btn" :disabled="orderLoading" @click.stop="store.cancelOrder(ord.id)">{{ t('futuresPage.actions.cancelOrder') }}</button>
- <template v-if="ord.profitPrice && parseFloat(String(ord.profitPrice)) > 0 || ord.lossPrice && parseFloat(String(ord.lossPrice)) > 0">
- <button class="act-btn act-btn--sec" :disabled="orderLoading"
- @click.stop="openTpSlModal(ord.positionId ?? '', String(ord.profitPrice ?? ''), String(ord.lossPrice ?? ''))">{{ t('futuresPage.actions.tpSl') }}</button>
- </template>
- </span>
- </div>
- </div>
- </template>
- <!-- ── 历史仓位 ── -->
- <template v-if="activeTab === 'history-positions'">
- <div v-if="!isLoggedIn" class="empty-tip">
- <router-link to="/login" class="tip-link">{{ t('header.login') }}</router-link> {{ t('futuresPage.bottom.loginPromptOr') }}
- <router-link to="/register" class="tip-link">{{ t('futuresPage.bottom.registerNow') }}</router-link>
- {{ t('futuresPage.bottom.startTrading') }}
- </div>
- <div v-else-if="!historyPositions.length" class="empty-tip">{{ t('futuresPage.empty.noHistPositions') }}</div>
- <div v-else class="data-tbl">
- <div class="dt-head dt-head--hist">
- <span>{{ t('futuresPage.table.contract') }}</span><span>{{ t('futuresPage.table.dirLeverage') }}</span><span>{{ t('futuresPage.table.status') }}</span>
- <span>{{ t('futuresPage.table.orderAmt') }}</span><span>{{ t('futuresPage.table.avgOpenPrice') }}</span><span>{{ t('futuresPage.table.profitUsdt') }}</span>
- <span>{{ t('futuresPage.table.tradedVol') }}</span><span>{{ t('futuresPage.table.closeAvg') }}</span><span>{{ t('futuresPage.table.profitRate') }}</span>
- <span>{{ t('futuresPage.table.openTime') }}</span><span>{{ t('futuresPage.table.closeTime') }}</span>
- </div>
- <div v-for="pos in historyPositions" :key="pos.id" class="dt-row dt-row--hist dt-row--clickable" @click="openHistPosDetail(pos)">
- <span class="fw6">{{ histPosSymbol(pos) }}</span>
- <span :class="pos.direction === 'BUY' ? 'up' : 'dn'">{{ dirLabel(pos.direction) }} {{ pos.leverage }}x</span>
- <span :class="statusClass(pos.status)">
- {{ statusLabel(pos.status) }}
- <template v-if="pos.closeType > 0"><br><small style="color:#6a7182">{{ closeTypeLabel(pos.closeType) }}</small></template>
- </span>
- <span>{{ histPosTotalVol(pos) }}</span>
- <span>{{ histPosOpenPrice(pos) }}</span>
- <span :class="profitClass(String(histPosProfit(pos)))">
- {{ histPosProfit(pos) >= 0 ? '+' : '' }}{{ histPosProfit(pos).toFixed(4) }}
- </span>
- <span>{{ histPosDealVol(pos) }}</span>
- <span>{{ histPosClosePrice(pos) }}</span>
- <span :class="profitClass(String(histPosProfit(pos)))">{{ histPosProfitRate(pos) }}</span>
- <span>{{ histPosOpenTime(pos) }}</span>
- <span>{{ histPosCloseTime(pos) }}</span>
- </div>
- </div>
- </template>
- <!-- ── 历史委托 ── -->
- <template v-if="activeTab === 'history-orders'">
- <div v-if="!historyOrders.length" class="empty-tip">{{ t('futuresPage.empty.noHistOrders') }}</div>
- <div v-else class="data-tbl">
- <div class="dt-head dt-head--hist-ord">
- <span>{{ t('futuresPage.table.contract') }}</span><span>{{ t('futuresPage.table.dirLeverage') }}</span><span>{{ t('futuresPage.table.type') }}</span><span>{{ t('futuresPage.table.status') }}</span>
- <span>{{ t('futuresPage.table.orderAmt') }}</span><span>{{ t('futuresPage.table.tradedVol') }}</span><span>{{ t('futuresPage.table.avgOpenPrice') }}</span><span>{{ t('futuresPage.table.entrustPriceHist') }}</span>
- <span>{{ t('futuresPage.table.orderTime') }}</span><span>{{ t('futuresPage.table.fillTime') }}</span>
- </div>
- <div v-for="ord in historyOrders" :key="ord.id" class="dt-row dt-row--hist-ord dt-row--clickable" @click="openHistOrdDetail(ord)">
- <span class="fw6">{{ histOrdSymbol(ord) }}</span>
- <span :class="orderDirClass(ord.direction, ord.entrustType)">{{ orderDirLabel(ord.direction, ord.entrustType) }} {{ ord.leverage }}x</span>
- <span>{{ typeLabel(ord.type) }}</span>
- <span :class="statusClass(ord.status)">{{ statusLabel(ord.status) }}</span>
- <span>{{ ord.volume ?? ord.tradedVolume }}</span>
- <span>{{ ord.tradedVolume }}</span>
- <span>{{ histOrdOpenPrice(ord) }}</span>
- <span>{{ histOrdEntrustPrice(ord) }}</span>
- <span>{{ ord.createTime ? fmtTime(ord.createTime) : '--' }}</span>
- <span>{{ ord.dealTime ? fmtTime(ord.dealTime) : '--' }}</span>
- </div>
- </div>
- </template>
- <!-- ── 成交明细 ── -->
- <template v-if="activeTab === 'trades'">
- <div v-if="!historyOrders.length" class="empty-tip">{{ t('futuresPage.empty.noTradeRecords') }}</div>
- <div v-else class="data-tbl">
- <div class="dt-head dt-head--trade">
- <span>{{ t('futuresPage.table.contract') }}</span><span>{{ t('futuresPage.table.directionOpenClose') }}</span><span>{{ t('futuresPage.table.dealPrice') }}</span><span>{{ t('futuresPage.table.tradedVol') }}</span>
- <span>{{ t('futuresPage.table.fee') }}</span><span>{{ t('futuresPage.table.fillTime') }}</span>
- </div>
- <div v-for="ord in historyOrders" :key="ord.id" class="dt-row dt-row--trade">
- <span class="fw6">{{ ord.symbol?.replace('/', '-') ?? '--' }}</span>
- <span :class="histDirClass(ord.direction, ord.entrustType)">
- {{ histDirLabel(ord.direction, ord.entrustType) }}
- <span class="entrust-type-tag">{{ ord.entrustType === 'OPEN' ? t('futuresPage.directions.openTag') : t('futuresPage.directions.closeTag') }}</span>
- </span>
- <span>{{ ord.tradedPrice ? fmtPrice(ord.tradedPrice) : '--' }}</span>
- <span>{{ ord.tradedVolume || '--' }}</span>
- <span class="dn">{{ ord.entrustType === 'OPEN' ? (ord.openFee ? '-' + parseFloat(ord.openFee).toFixed(4) : '--') : (ord.closeFee ? '-' + parseFloat(ord.closeFee).toFixed(4) : '--') }}</span>
- <span>{{ ord.dealTime ? fmtTime(ord.dealTime) : (ord.createTime ? fmtTime(ord.createTime) : '--') }}</span>
- </div>
- </div>
- </template>
- <!-- ── 资金流水 ── -->
- <template v-if="activeTab === 'funding'">
- <div v-if="!contractFundingRecords.length" class="empty-tip">{{ t('futuresPage.empty.noFunding') }}</div>
- <div v-else class="data-tbl">
- <div class="dt-head dt-head--funding">
- <span>{{ t('futuresPage.table.fundType') }}</span><span>{{ t('futuresPage.table.amount') }}</span><span>{{ t('futuresPage.table.coin') }}</span><span>{{ t('futuresPage.table.time') }}</span>
- </div>
- <div v-for="rec in contractFundingRecords" :key="rec.id" class="dt-row dt-row--funding">
- <span>{{ txTypeName(rec.type) }}</span>
- <span :class="parseFloat(String(rec.amount)) >= 0 ? 'up' : 'dn'">
- {{ parseFloat(String(rec.amount)) >= 0 ? '+' : '' }}{{ parseFloat(String(rec.amount)).toFixed(4) }}
- </span>
- <span>{{ formatFundingSymbol(rec.symbol) }}</span>
- <span>{{ rec.createTime ? fmtTime(new Date(String(rec.createTime).replace(' ', 'T')).getTime()) : '--' }}</span>
- </div>
- </div>
- </template>
- <!-- ── 资产 ── -->
- <template v-if="activeTab === 'assets'">
- <div v-if="!isLoggedIn" class="empty-tip">
- <router-link to="/login" class="tip-link">{{ t('header.login') }}</router-link> {{ t('futuresPage.bottom.loginPromptOr') }}
- <router-link to="/register" class="tip-link">{{ t('futuresPage.bottom.registerNow') }}</router-link>
- {{ t('futuresPage.bottom.startTrading') }}
- </div>
- <div v-else class="assets-grid">
- <div class="asset-card">
- <div class="asset-lbl">{{ t('futuresPage.table.totalBal') }}</div>
- <div class="asset-val">{{ accountInfo.totalBalance.toFixed(4) }} <span class="asset-cur">USDT</span></div>
- </div>
- <div class="asset-card">
- <div class="asset-lbl">{{ t('futuresPage.table.availMargin') }}</div>
- <div class="asset-val">{{ accountInfo.availableMargin.toFixed(4) }} <span class="asset-cur">USDT</span></div>
- </div>
- <div class="asset-card">
- <div class="asset-lbl">{{ t('futuresPage.table.usedMargin') }}</div>
- <div class="asset-val">{{ accountInfo.usedMargin.toFixed(4) }} <span class="asset-cur">USDT</span></div>
- </div>
- <div class="asset-card">
- <div class="asset-lbl">{{ t('futuresPage.table.unrealized') }}</div>
- <div class="asset-val" :class="accountInfo.unrealizedPnl >= 0 ? 'up' : 'dn'">
- {{ accountInfo.unrealizedPnl >= 0 ? '+' : '' }}{{ accountInfo.unrealizedPnl.toFixed(4) }} <span class="asset-cur">USDT</span>
- </div>
- </div>
- <div class="asset-card">
- <div class="asset-lbl">{{ t('futuresPage.table.spotWalletBal') }}</div>
- <div class="asset-val">{{ parseFloat(spotAvailBalance).toFixed(4) }} <span class="asset-cur">USDT</span></div>
- </div>
- <div class="asset-card asset-card--actions">
- <button class="asset-btn" @click="showTransfer = true">{{ t('futuresPage.table.fundTransfer') }}</button>
- </div>
- </div>
- </template>
- </div><!-- /tab-scroll-area -->
- </div><!-- /bottom-area -->
- </div><!-- /page-body -->
- <!-- ═══ 仓位详情弹窗 ═══ -->
- <Transition name="modal">
- <div v-if="showPosDetail && detailPos" class="modal-mask" @click.self="showPosDetail = false">
- <div class="modal-box detail-modal" @click.stop>
- <div class="modal-hd">
- <span class="modal-title">{{ t('futuresPage.modal.positionDetailTitle') }}</span>
- <button class="modal-close" @click="showPosDetail = false">✕</button>
- </div>
- <div class="detail-modal-body">
- <!-- 标的行 -->
- <div class="detail-header">
- <span class="detail-dir-chip" :class="detailPos.direction === 'BUY' ? 'chip--up' : 'chip--dn'">
- {{ detailPos.direction === 'BUY' ? t('futuresPage.directions.long') : t('futuresPage.directions.short') }}
- </span>
- <span class="detail-symbol">{{ detailPos.coin?.symbol?.replace('/', '-') ?? '--' }}</span>
- <span class="detail-tag">{{ posTypeLabel(detailPos.positionType, detailPos.patterns, detailPos.experienceRecordId) }}</span>
- <span class="detail-tag detail-tag--lev">{{ detailPos.leverage }}x</span>
- </div>
- <!-- 价格 -->
- <div class="detail-section">
- <div class="detail-row"><span class="detail-lbl">{{ t('futuresPage.table.openPrice') }}</span><span class="detail-val">{{ fmtPrice(detailPos.openPrice) }}</span></div>
- <div class="detail-row"><span class="detail-lbl">{{ t('futuresPage.table.markPrice') }}</span><span class="detail-val">{{ fmtPrice(detailPos.currentPrice) }}</span></div>
- </div>
- <div class="detail-section-gap"/>
- <!-- 持仓数据 -->
- <div class="detail-section">
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.entrustVolCoin', { base: baseCoinFromSym(detailPos.coin?.symbol ?? '') }) }}</span>
- <span class="detail-val">{{ detailPos.currentPosition }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.notionalUsdt') }}</span>
- <span class="detail-val">{{ (parseFloat(detailPos.currentPosition||'0') * parseFloat(detailPos.currentPrice||'0')).toFixed(4) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.estLiqPx') }}</span>
- <span class="detail-val" :class="parseFloat(detailPos.estimatedBlastPrice) > 0 ? 'dn' : ''">
- {{ parseFloat(detailPos.estimatedBlastPrice) > 0 ? fmtPrice(detailPos.estimatedBlastPrice) : '--' }}
- </span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.marginUsdtBracket') }}</span>
- <span class="detail-val">{{ parseFloat(detailPos.currentMargin||'0').toFixed(4) }}</span>
- </div>
- </div>
- <div class="detail-section-gap"/>
- <!-- 盈亏 -->
- <div class="detail-section">
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.profitRate') }}</span>
- <span class="detail-val" :class="calcLivePnlPct(detailPos) >= 0 ? 'up' : 'dn'">
- {{ calcLivePnlPct(detailPos) >= 0 ? '+' : '' }}{{ calcLivePnlPct(detailPos).toFixed(2) }}%
- </span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.unrealized') }}</span>
- <span class="detail-val" :class="calcLivePnl(detailPos) >= 0 ? 'up' : 'dn'">
- {{ calcLivePnl(detailPos) >= 0 ? '+' : '' }}{{ calcLivePnl(detailPos).toFixed(4) }}
- </span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.marginRate') }}</span>
- <span class="detail-val">{{ parseFloat(detailPos.marginRate||'0') > 0 ? parseFloat(detailPos.marginRate).toFixed(2) + '%' : '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.tpPrice') }}</span>
- <span class="detail-val up">{{ detailPos.profitPrice && parseFloat(detailPos.profitPrice) > 0 ? fmtPrice(detailPos.profitPrice) : '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.slPrice') }}</span>
- <span class="detail-val dn">{{ detailPos.lossPrice && parseFloat(detailPos.lossPrice) > 0 ? fmtPrice(detailPos.lossPrice) : '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.openTime') }}</span>
- <span class="detail-val">{{ fmtTime(detailPos.openTime) }}</span>
- </div>
- </div>
- </div>
- </div>
- </div>
- </Transition>
- <!-- ═══ 当前委托详情弹窗 ═══ -->
- <Transition name="modal">
- <div v-if="showOrdDetail && detailOrd" class="modal-mask" @click.self="showOrdDetail = false">
- <div class="modal-box detail-modal" @click.stop>
- <div class="modal-hd">
- <span class="modal-title">{{ t('futuresPage.modal.orderDetailTitle') }}</span>
- <button class="modal-close" @click="showOrdDetail = false">✕</button>
- </div>
- <div class="detail-modal-body">
- <!-- 标的行 -->
- <div class="detail-header">
- <span class="detail-dir-chip" :class="orderDirClass(detailOrd.direction, detailOrd.entrustType)">
- {{ orderDirLabel(detailOrd.direction, detailOrd.entrustType) }}
- </span>
- <span class="detail-symbol">{{ detailOrd.symbol?.replace('/', '-') ?? '--' }}</span>
- <span class="detail-tag">{{ typeLabel(detailOrd.openType ?? detailOrd.type) }}</span>
- <span class="detail-tag detail-tag--lev">{{ detailOrd.leverage }}x</span>
- </div>
- <!-- 委托信息 -->
- <div class="detail-section">
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.orderPrice') }}</span>
- <span class="detail-val">{{ orderPriceDisplay(detailOrd.entrustPrice, detailOrd.openType ?? 0) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.triggerPrice') }}</span>
- <span class="detail-val">{{ parseFloat(detailOrd.triggerPrice) > 0 ? fmtPrice(detailOrd.triggerPrice) : '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.entrustVolCoin', { base: baseCoinFromSym(detailOrd.symbol ?? '') }) }}</span>
- <span class="detail-val">{{ detailOrd.volume }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.filledVolCoin', { base: baseCoinFromSym(detailOrd.symbol ?? '') }) }}</span>
- <span class="detail-val">{{ detailOrd.tradedVolume }}</span>
- </div>
- </div>
- <div class="detail-section-gap"/>
- <!-- 止盈止损 -->
- <div class="detail-section">
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.tpPrice') }}</span>
- <span class="detail-val up">{{ detailOrd.profitPrice && parseFloat(String(detailOrd.profitPrice)) > 0 ? fmtPrice(String(detailOrd.profitPrice)) : '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.slPrice') }}</span>
- <span class="detail-val dn">{{ detailOrd.lossPrice && parseFloat(String(detailOrd.lossPrice)) > 0 ? fmtPrice(String(detailOrd.lossPrice)) : '--' }}</span>
- </div>
- </div>
- <div class="detail-section-gap"/>
- <!-- 时间与状态 -->
- <div class="detail-section">
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.orderTime') }}</span>
- <span class="detail-val">{{ fmtTime(detailOrd.time || detailOrd.createTime) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.status') }}</span>
- <span class="detail-val" :class="statusClass(String(detailOrd.status))">{{ statusLabel(String(detailOrd.status)) }}</span>
- </div>
- </div>
- </div>
- </div>
- </div>
- </Transition>
- <!-- ═══ 历史仓位详情弹窗 ═══ -->
- <Transition name="modal">
- <div v-if="showHistPosDetail && detailHistPos" class="modal-mask" @click.self="showHistPosDetail = false">
- <div class="modal-box detail-modal" @click.stop>
- <div class="modal-hd">
- <span class="modal-title">{{ t('futuresPage.modal.histPositionDetailTitle') }}</span>
- <button class="modal-close" @click="showHistPosDetail = false">✕</button>
- </div>
- <div class="detail-modal-body">
- <!-- 状态图标 -->
- <div class="detail-status-block">
- <div class="detail-status-icon">
- <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="9" y1="7" x2="15" y2="7"/><line x1="9" y1="11" x2="15" y2="11"/><line x1="9" y1="15" x2="13" y2="15"/></svg>
- </div>
- <div class="detail-status-label" :class="statusClass(detailHistPos.status)">{{ statusLabel(detailHistPos.status) }}</div>
- <div v-if="detailHistPos.closeType > 0" class="detail-close-type">{{ closeTypeLabel(detailHistPos.closeType) }}</div>
- </div>
- <!-- 基本信息 -->
- <div class="detail-section">
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.modal.marginSplitMode') }}</span>
- <span class="detail-val">{{ posTypeLabel(detailHistPos.positionType ?? detailHistPos.type, detailHistPos.patterns, detailHistPos.experienceRecordId) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.modal.directionLabel') }}</span>
- <span class="detail-val" :class="detailHistPos.direction === 'BUY' ? 'up' : 'dn'">{{ dirLabel(detailHistPos.direction) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.modal.entrustTypeLabel') }}</span>
- <span class="detail-val">{{ typeLabel(detailHistPos.openType ?? detailHistPos.type) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.modal.leverageLabel') }}</span>
- <span class="detail-val">{{ detailHistPos.leverage }}x</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.openTime') }}</span>
- <span class="detail-val">{{ histPosOpenTime(detailHistPos) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.closeTime') }}</span>
- <span class="detail-val">{{ histPosCloseTime(detailHistPos) }}</span>
- </div>
- </div>
- <div class="detail-section-gap"/>
- <!-- 价格信息 -->
- <div class="detail-section">
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.triggerUsdt') }}</span>
- <span class="detail-val">{{ detailHistPos.triggerPrice && parseFloat(detailHistPos.triggerPrice) > 0 ? fmtPrice(detailHistPos.triggerPrice) : '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.orderPriceUsdt') }}</span>
- <span class="detail-val">{{ detailHistPos.entrustPrice && parseFloat(detailHistPos.entrustPrice) > 0 ? fmtPrice(detailHistPos.entrustPrice) : t('futuresPage.orderTypes.marketShort') }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.openAvgUsdt') }}</span>
- <span class="detail-val">{{ histPosOpenPrice(detailHistPos) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.closeAvgUsdt') }}</span>
- <span class="detail-val">{{ histPosClosePrice(detailHistPos) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.tpUsdt') }}</span>
- <span class="detail-val up">{{ detailHistPos.profitPrice && parseFloat(detailHistPos.profitPrice) > 0 ? fmtPrice(detailHistPos.profitPrice) : '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.slUsdt') }}</span>
- <span class="detail-val dn">{{ detailHistPos.lossPrice && parseFloat(detailHistPos.lossPrice) > 0 ? fmtPrice(detailHistPos.lossPrice) : '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.marginUsdtBracket') }}</span>
- <span class="detail-val">{{ histPosPrincipal(detailHistPos) > 0 ? histPosPrincipal(detailHistPos).toFixed(2) : '--' }}</span>
- </div>
- </div>
- <div class="detail-section-gap"/>
- <!-- 盈亏信息 -->
- <div class="detail-section">
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.entrustVolCoin', { base: baseCoinFromSym(histPosSymbol(detailHistPos)) }) }}</span>
- <span class="detail-val">{{ histPosTotalVol(detailHistPos) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.profitBracketUsdt') }}</span>
- <span class="detail-val" :class="profitClass(String(histPosProfit(detailHistPos)))">
- {{ histPosProfit(detailHistPos) !== 0 ? (histPosProfit(detailHistPos) >= 0 ? '+' : '') + histPosProfit(detailHistPos).toFixed(4) : '--' }}
- </span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.profitRate') }}</span>
- <span class="detail-val" :class="profitClass(String(histPosProfit(detailHistPos)))">{{ histPosProfitRate(detailHistPos) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.feeHint') }}</span>
- <span class="detail-val">{{ (parseFloat(detailHistPos.openFee||'0') + parseFloat(detailHistPos.closeFee||'0') + parseFloat(detailHistPos.commissionFee||'0')) > 0 ? (parseFloat(detailHistPos.openFee||'0') + parseFloat(detailHistPos.closeFee||'0') + parseFloat(detailHistPos.commissionFee||'0')).toFixed(4) : '--' }}</span>
- </div>
- </div>
- </div>
- </div>
- </div>
- </Transition>
- <!-- ═══ 历史委托详情弹窗 ═══ -->
- <Transition name="modal">
- <div v-if="showHistOrdDetail && detailHistOrd" class="modal-mask" @click.self="showHistOrdDetail = false">
- <div class="modal-box detail-modal" @click.stop>
- <div class="modal-hd">
- <span class="modal-title">{{ t('futuresPage.modal.histOrderDetailTitle') }}</span>
- <button class="modal-close" @click="showHistOrdDetail = false">✕</button>
- </div>
- <div class="detail-modal-body">
- <!-- 状态图标 -->
- <div class="detail-status-block">
- <div class="detail-status-icon">
- <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="9" y1="7" x2="15" y2="7"/><line x1="9" y1="11" x2="15" y2="11"/><line x1="9" y1="15" x2="13" y2="15"/></svg>
- </div>
- <div class="detail-status-label" :class="statusClass(detailHistOrd.status)">{{ statusLabel(detailHistOrd.status) }}</div>
- </div>
- <!-- 基本信息 -->
- <div class="detail-section">
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.modal.marginSplitMode') }}</span>
- <span class="detail-val">{{ posTypeLabel(detailHistOrd.positionType, detailHistOrd.patterns, detailHistOrd.experienceRecordId) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.modal.directionLabel') }}</span>
- <span class="detail-val" :class="orderDirClass(detailHistOrd.direction, detailHistOrd.entrustType)">{{ orderDirLabel(detailHistOrd.direction, detailHistOrd.entrustType) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.modal.entrustTypeLabel') }}</span>
- <span class="detail-val">{{ typeLabel(detailHistOrd.type) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.modal.leverageLabel') }}</span>
- <span class="detail-val">{{ detailHistOrd.leverage }}x</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.orderTime') }}</span>
- <span class="detail-val">{{ detailHistOrd.createTime ? fmtTime(detailHistOrd.createTime) : '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.fillTime') }}</span>
- <span class="detail-val">{{ detailHistOrd.dealTime ? fmtTime(detailHistOrd.dealTime) : '--' }}</span>
- </div>
- </div>
- <div class="detail-section-gap"/>
- <!-- 价格与数量 -->
- <div class="detail-section">
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.triggerUsdt') }}</span>
- <span class="detail-val">{{ detailHistOrd.triggerPrice && parseFloat(String(detailHistOrd.triggerPrice)) > 0 ? fmtPrice(String(detailHistOrd.triggerPrice)) : '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.orderPriceUsdt') }}</span>
- <span class="detail-val">{{ histOrdEntrustPriceDisplay(detailHistOrd) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.openAvgUsdt') }}</span>
- <span class="detail-val">{{ histOrdAvgOpenPrice(detailHistOrd) }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.closeAvgUsdt') }}</span>
- <span class="detail-val">{{ (() => { const isC = String(detailHistOrd.entrustType ?? '').toUpperCase() === 'CLOSE' || String(detailHistOrd.entrustType) === '1'; const p = isC ? detailHistOrd.tradedPrice : '0'; return parseFloat(p||'0') > 0 ? fmtPrice(p) : '--' })() }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.tpUsdt') }}</span>
- <span class="detail-val up">{{ (detailHistOrd.profitPrice ?? detailHistOrd.stopProfitPrice) && parseFloat(String(detailHistOrd.profitPrice ?? detailHistOrd.stopProfitPrice ?? '0')) > 0 ? fmtPrice(String(detailHistOrd.profitPrice ?? detailHistOrd.stopProfitPrice ?? '')) : '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.slUsdt') }}</span>
- <span class="detail-val dn">{{ (detailHistOrd.lossPrice ?? detailHistOrd.stopLossPrice) && parseFloat(String(detailHistOrd.lossPrice ?? detailHistOrd.stopLossPrice ?? '0')) > 0 ? fmtPrice(String(detailHistOrd.lossPrice ?? detailHistOrd.stopLossPrice ?? '')) : '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.marginUsdtBracket') }}</span>
- <span class="detail-val">{{ detailHistOrd.principalAmount && parseFloat(detailHistOrd.principalAmount) > 0 ? parseFloat(detailHistOrd.principalAmount).toFixed(2) : '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.entrustVolCoin', { base: baseCoinFromSym(histOrdSymbol(detailHistOrd)) }) }}</span>
- <span class="detail-val">{{ detailHistOrd.volume ?? detailHistOrd.size ?? '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.filledVolCoin', { base: baseCoinFromSym(histOrdSymbol(detailHistOrd)) }) }}</span>
- <span class="detail-val">{{ detailHistOrd.tradedVolume ?? '--' }}</span>
- </div>
- <div class="detail-row">
- <span class="detail-lbl">{{ t('futuresPage.table.feeHint') }}</span>
- <span class="detail-val">{{ (parseFloat(detailHistOrd.openFee||'0') + parseFloat(detailHistOrd.closeFee||'0')) > 0 ? (parseFloat(detailHistOrd.openFee||'0') + parseFloat(detailHistOrd.closeFee||'0')).toFixed(4) : '--' }}</span>
- </div>
- </div>
- </div>
- </div>
- </div>
- </Transition>
- <!-- ═══ 划转弹窗 ═══ -->
- <Transition name="modal">
- <div v-if="showTransfer" class="modal-mask" @click.self="showTransfer = false; showFromDrop = false; showToDrop = false">
- <div class="modal-box" @click.stop>
- <div class="modal-hd">
- <span class="modal-title">{{ t('spot.transferModal.title') }}</span>
- <button class="modal-close" @click="showTransfer = false">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
- </button>
- </div>
- <div class="modal-body">
- <!-- 币种 -->
- <div class="tf-field">
- <div class="tf-label">{{ t('common.coin') }}</div>
- <div class="tf-select tf-select--static">
- <span>USDT</span>
- </div>
- </div>
- <!-- 从 / 到:一侧固定资金,另一侧仅合约 / 跟单可选 -->
- <div class="tf-direction">
- <!-- 从 -->
- <div class="tf-dir-col">
- <div class="tf-dir-lbl">{{ t('common.from') }}</div>
- <div v-if="transferFrom === 'SPOT'" class="tf-select tf-select--static">
- <span>{{ transferWalletLabel('SPOT') }}</span>
- </div>
- <div v-else class="tf-select tf-select--btn" @click="showFromDrop = !showFromDrop; showToDrop = false">
- <span>{{ transferWalletLabel(transferFrom) }}</span>
- <svg width="10" height="6" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>
- <div v-if="showFromDrop" class="tf-dropdown">
- <div
- v-for="opt in FUTURES_OTHER_WALLET_TYPES"
- :key="opt"
- class="tf-dropdown-item"
- :class="{ active: transferFrom === opt }"
- @click.stop="selectFromOther(opt)"
- >{{ transferWalletLabel(opt) }}</div>
- </div>
- </div>
- </div>
- <!-- 交换按钮 -->
- <button class="tf-swap" @click="swapTransferDirection">
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
- </button>
- <!-- 到 -->
- <div class="tf-dir-col">
- <div class="tf-dir-lbl">{{ t('common.to') }}</div>
- <div
- v-if="transferFrom === 'SWAP' || transferFrom === 'FOLLOW' || transferFrom === 'SPOT_TRADING'"
- class="tf-select tf-select--static"
- >
- <span>{{ transferWalletLabel('SPOT') }}</span>
- </div>
- <div v-else class="tf-select tf-select--btn" @click="showToDrop = !showToDrop; showFromDrop = false">
- <span>{{ transferWalletLabel(transferTo) }}</span>
- <svg width="10" height="6" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>
- <div v-if="showToDrop" class="tf-dropdown">
- <div
- v-for="opt in FUTURES_OTHER_WALLET_TYPES"
- :key="opt"
- class="tf-dropdown-item"
- :class="{ active: transferTo === opt }"
- @click.stop="selectToOther(opt)"
- >{{ transferWalletLabel(opt) }}</div>
- </div>
- </div>
- </div>
- </div>
- <!-- 划转数量 -->
- <div class="tf-field">
- <div class="tf-label">{{ t('spot.transferModal.amount') }}</div>
- <div class="tf-amount-wrap">
- <input
- v-model="transferAmount"
- type="number"
- class="tf-amount-inp"
- :placeholder="t('spot.transferModal.amountPlaceholder')"
- :disabled="transferSubmitting"
- @input="transferError = ''"
- />
- <div class="tf-amount-right">
- <span class="tf-currency">USDT</span>
- <span class="tf-sep">|</span>
- <button class="tf-all" @click="transferAmount = transferAvail">{{ t('common.all') }}</button>
- </div>
- </div>
- <div class="tf-avail">
- {{ t('spot.transferModal.availableUsdt') }}
- <span v-if="transferLoading" class="tf-avail-loading">{{ t('common.loading') }}</span>
- <span v-else>{{ transferAvail }}</span>
- </div>
- <div v-if="transferError" class="tf-error">{{ transferError }}</div>
- </div>
- </div>
- <div class="modal-ft">
- <button class="tf-cancel" :disabled="transferSubmitting" @click="showTransfer = false">{{ t('common.cancel') }}</button>
- <button class="tf-confirm" :disabled="transferSubmitting || transferLoading" @click="confirmTransfer">
- {{ transferSubmitting ? t('spot.transferModal.transferring') : t('common.confirm') }}
- </button>
- </div>
- </div>
- </div>
- </Transition>
- <!-- ── TP/SL Modify Modal ── -->
- <Transition name="modal">
- <div v-if="showTpSlModal" class="modal-mask" @click.self="showTpSlModal = false">
- <div class="modal-box" @click.stop>
- <div class="modal-hd">
- <span class="modal-title">{{ tpSlPosition?.cutList?.length ? t('futuresPage.modal.modifyTpSl') : t('futuresPage.modal.setTpSl') }}</span>
- <button class="modal-close" @click="showTpSlModal = false">✕</button>
- </div>
- <div class="modal-body">
- <!-- 仓位信息摘要 -->
- <div class="tpsl-pos-summary">
- <span class="fw6">{{ tpSlPosition?.coin?.symbol?.replace('/', '-') }}</span>
- <span :class="tpSlPosition?.direction === 'BUY' ? 'up' : 'dn'" style="margin-left:8px">
- {{ tpSlPosition?.direction === 'BUY' ? t('futuresPage.directions.long') : t('futuresPage.directions.short') }} {{ tpSlPosition?.leverage }}x
- </span>
- <span style="margin-left:auto;color:#8a92a8;font-size:11px">{{ t('futuresPage.modal.posQtyLabel') }} {{ tpSlPosition?.currentPosition }}</span>
- </div>
- <!-- 关键价格信息 -->
- <div v-if="tpSlPosition" class="tpsl-info-rows">
- <div class="tpsl-info-row"><span>{{ t('futuresPage.table.openPrice') }}</span><span>{{ fmtPrice(tpSlPosition.openPrice) }}</span></div>
- <div class="tpsl-info-row"><span>{{ t('futuresPage.table.markPrice') }}</span><span>{{ fmtPrice(tpSlMarkPriceStr || tpSlPosition.currentPrice) }}</span></div>
- <div class="tpsl-info-row"><span>{{ t('futuresPage.table.liqPrice') }}</span><span>{{ parseFloat(tpSlPosition.estimatedBlastPrice) > 0 ? fmtPrice(tpSlPosition.estimatedBlastPrice) : '--' }}</span></div>
- </div>
- <!-- 当前止盈止损委托列表 -->
- <template v-if="tpSlPosition?.cutList?.length">
- <div class="tpsl-section-title">{{ t('futuresPage.modal.currentTpSlSection') }}</div>
- <div class="tpsl-current-list">
- <div v-for="cut in tpSlPosition.cutList" :key="cut.id" class="tpsl-current-item">
- <div class="tpsl-current-row">
- <span v-if="cut.profitPrice && parseFloat(String(cut.profitPrice)) > 0" class="tpsl-badge tpsl-badge--profit">{{ t('futuresPage.modal.tpBadge') }}</span>
- <span v-if="cut.lossPrice && parseFloat(String(cut.lossPrice)) > 0" class="tpsl-badge tpsl-badge--loss">{{ t('futuresPage.modal.slBadge') }}</span>
- <span class="tpsl-current-detail">
- <template v-if="cut.profitPrice && parseFloat(String(cut.profitPrice)) > 0">
- {{ t('futuresPage.modal.tpPriceWord') }}<strong>{{ fmtPrice(String(cut.profitPrice)) }}</strong>
- </template>
- <template v-if="cut.lossPrice && parseFloat(String(cut.lossPrice)) > 0">
- <span style="margin:0 6px;color:#3a3e4a">|</span>
- {{ t('futuresPage.modal.slPriceWord') }}<strong>{{ fmtPrice(String(cut.lossPrice)) }}</strong>
- </template>
- </span>
- <span v-if="cut.volume" class="tpsl-current-vol">{{ t('futuresPage.modal.volLabel') }} {{ cut.volume }}</span>
- </div>
- </div>
- </div>
- </template>
- <!-- 修改输入区 -->
- <div class="tpsl-section-title">{{ tpSlPosition?.cutList?.length ? t('futuresPage.modal.modifyToSection') : t('futuresPage.modal.setPricesSection') }}</div>
- <div class="tf-row">
- <label class="tf-label">{{ t('futuresPage.modal.tpTriggerLabel') }}<span style="color:#3a3e4a;font-weight:400">{{ t('futuresPage.modal.tpHintOptionalClear') }}</span></label>
- <input v-model="tpSlProfitPrice" type="number" class="tf-inp tf-inp--profit" :placeholder="t('futuresPage.modal.placeholderTpTrigger')" />
- </div>
- <div class="tf-row">
- <label class="tf-label">{{ t('futuresPage.modal.slTriggerLabel') }}<span style="color:#3a3e4a;font-weight:400">{{ t('futuresPage.modal.slHintOptionalClear') }}</span></label>
- <input v-model="tpSlLossPrice" type="number" class="tf-inp tf-inp--loss" :placeholder="t('futuresPage.modal.placeholderSlTrigger')" />
- </div>
- <div v-if="tpSlError" class="tf-err">{{ tpSlError }}</div>
- </div>
- <div class="modal-ft">
- <button class="modal-cancel" @click="showTpSlModal = false">{{ t('common.cancel') }}</button>
- <button class="modal-confirm" :disabled="orderLoading" @click="confirmTpSl">{{ t('common.confirm') }}</button>
- </div>
- </div>
- </div>
- </Transition>
- <!-- ── 平仓 Sheet ── -->
- <Transition name="modal">
- <div v-if="showCloseSheet && closeTargetPos" class="modal-mask" @click.self="showCloseSheet = false">
- <div class="modal-box" @click.stop>
- <div class="modal-hd">
- <span class="modal-title">{{ t('futuresPage.modal.closePositionTitlePrefix') }} · {{ closeTargetPos.coin?.symbol?.replace('/', '-') }}</span>
- <button class="modal-close" @click="showCloseSheet = false">✕</button>
- </div>
- <div class="modal-body close-sheet-stack">
- <div class="tpsl-pos-summary">
- <span :class="closeTargetPos.direction === 'BUY' ? 'up' : 'dn'">
- {{ closeTargetPos.direction === 'BUY' ? t('futuresPage.directions.long') : t('futuresPage.directions.short') }} {{ closeTargetPos.leverage }}x
- </span>
- <span style="margin-left:auto;color:#8a92a8;font-size:11px">{{ t('futuresPage.table.closeAvail') }} {{ closeTargetPos.availablePosition }} {{ closeSheetBaseCurrency }}</span>
- </div>
- <div class="tpsl-info-rows">
- <div class="tpsl-info-row"><span>{{ t('futuresPage.table.openPrice') }}</span><span>{{ fmtPrice(closeTargetPos.openPrice) }}</span></div>
- <div class="tpsl-info-row"><span>{{ t('futuresPage.table.markPrice') }}</span><span>{{ fmtPrice(closeSheetMarkPriceStr || closeTargetPos.currentPrice) }}</span></div>
- </div>
- <!-- 市价/限价切换 -->
- <div class="cs-tabs">
- <button class="cs-tab" :class="{ act: closeIsMarket }" @click="closeIsMarket = true">{{ t('futuresPage.order.market') }}</button>
- <button class="cs-tab" :class="{ act: !closeIsMarket }" @click="closeIsMarket = false">{{ t('futuresPage.order.limit') }}</button>
- </div>
- <!-- 价格 -->
- <div v-if="!closeIsMarket" class="inp-block">
- <div class="inp-wrap">
- <span class="inp-lbl">{{ t('futuresPage.table.orderPrice') }} ({{ closeSheetQuoteCurrency }})</span>
- <div class="inp-row">
- <input v-model="closeSheetPrice" type="number" class="inp" :placeholder="t('futuresPage.placeholders.price')"/>
- <button class="inp-quick" @click="closeSheetPrice = closeSheetMarkPriceStr || closeTargetPos.currentPrice">{{ t('futuresPage.table.markPriceBtn') }}</button>
- </div>
- </div>
- </div>
- <div v-else class="inp-block">
- <div class="inp-market">{{ t('futuresPage.order.marketExe') }}</div>
- </div>
- <!-- 数量 -->
- <div class="inp-block">
- <div class="inp-wrap">
- <span class="inp-lbl">{{ t('futuresPage.order.qtyLabel', { u: closeSheetBaseCurrency }) }}</span>
- <div class="inp-row">
- <input v-model="closeSheetVolume" type="number" class="inp" :placeholder="t('futuresPage.placeholders.amount')"
- @input="() => { const a = parseFloat(closeTargetPos!.availablePosition); if (a>0) closeSheetPct = Math.min(100, Math.round(parseFloat(closeSheetVolume)/a*100)) || 0 }"/>
- <button class="inp-quick" @click="closeSheetVolume = closeTargetPos.availablePosition; closeSheetPct = 100">{{ t('common.all') }}</button>
- </div>
- </div>
- </div>
- <!-- PCT 滑块 -->
- <div class="pct-wrap">
- <div class="pct-track-wrap">
- <input type="range" v-model="closeSheetPct" min="0" max="100" step="1" class="pct-slider"/>
- <div class="pct-dots">
- <span v-for="d in [0,25,50,75,100]" :key="d" class="pct-dot" :class="{ fill: closeSheetPct >= d }"/>
- </div>
- </div>
- <div class="pct-ticks">
- <span>0%</span><span>25%</span><span>50%</span><span>75%</span><span>100%</span>
- </div>
- </div>
- <!-- 预计盈亏 -->
- <div class="tpsl-info-rows">
- <div class="tpsl-info-row">
- <span>{{ t('futuresPage.table.estimatedPnlRow') }}</span>
- <span :class="closeSheetEstPnl >= 0 ? 'up' : 'dn'">
- {{ closeSheetEstPnl >= 0 ? '+' : '' }}{{ closeSheetEstPnl.toFixed(4) }} {{ closeSheetQuoteCurrency }}
- </span>
- </div>
- </div>
- <div v-if="closeSheetError" class="tf-err">{{ closeSheetError }}</div>
- </div>
- <div class="modal-ft">
- <button class="modal-cancel" @click="showCloseSheet = false">{{ t('common.cancel') }}</button>
- <button class="modal-confirm" :disabled="orderLoading" @click="confirmClose">{{ t('futuresPage.modal.confirmCloseBtn') }}</button>
- </div>
- </div>
- </div>
- </Transition>
- <AppModal
- v-if="showActionConfirm"
- :title="actionConfirmTitle"
- :confirm-text="t('futuresPage.modal.ok')"
- :cancel-text="t('common.cancel')"
- :loading="actionConfirmLoading"
- @confirm="onActionConfirmOk"
- @cancel="onActionConfirmCancel"
- >
- <p class="action-confirm-msg">{{ actionConfirmMessage }}</p>
- </AppModal>
- </div><!-- /ft-page -->
- </template>
- <style scoped>
- /* 二次确认弹窗文案 */
- .action-confirm-msg {
- margin: 0;
- font-size: 14px;
- line-height: 1.55;
- color: var(--color-text-secondary, #9ca3b0);
- }
- /* ─── Reset / Base ─── */
- * { box-sizing: border-box; }
- .ft-page {
- min-height: 100vh;
- padding-top: var(--header-height, 60px);
- box-sizing: border-box;
- background: #141414;
- color: #d1d1d2;
- display: grid;
- grid-template-columns: 1fr 283px;
- grid-template-rows: 58px 420px 260px;
- column-gap: 1px;
- font-size: 13px;
- font-family: var(--font-family);
- }
- /* ─── Ticker bar ─── */
- .ticker-bar {
- grid-column: 1;
- grid-row: 1;
- display: flex;
- align-items: center;
- height: 58px;
- padding: 0 12px 0 8px;
- background: #000;
- border-bottom: 1px solid #212121;
- z-index: 30;
- min-width: 0;
- }
- .pair-trigger {
- display: flex;
- align-items: center;
- gap: 8px;
- cursor: pointer;
- padding: 5px 10px 5px 6px;
- border-radius: 6px;
- position: relative;
- flex-shrink: 0;
- margin-right: 4px;
- transition: background .15s;
- }
- .pair-trigger:hover { background: rgba(255,255,255,0.04); }
- .coin-icon {
- width: 32px; height: 32px;
- border-radius: 50%;
- display: flex; align-items: center; justify-content: center;
- flex-shrink: 0; overflow: hidden; background: #2a2a2a;
- }
- .coin-icon img { width: 100%; height: 100%; object-fit: cover; }
- .coin-icon-fb {
- font-size: 13px; font-weight: 700; color: #f0b90b;
- }
- .pair-name-col { display: flex; flex-direction: column; gap: 1px; }
- .pair-name-row { display: flex; align-items: center; gap: 4px; }
- .pair-name { font-size: 18px; font-weight: 700; color: #e8ecf4; letter-spacing: 0.3px; }
- .pair-sub { font-size: 10px; color: #5a6278; }
- .caret { color: #6a7182; transition: transform .2s; flex-shrink: 0; }
- .caret.open { transform: rotate(180deg); }
- /* Pair dropdown */
- .pair-drop {
- position: absolute; top: calc(100% + 6px); left: 0;
- width: 340px; background: #1d1d1d;
- border: 1px solid rgba(255,255,255,0.1); border-radius: 10px;
- box-shadow: 0 20px 60px rgba(0,0,0,.9); overflow: hidden; z-index: 200;
- }
- .pair-drop-search {
- display: flex; align-items: center; gap: 8px;
- padding: 10px 14px; border-bottom: 1px solid #212121;
- }
- .pair-search-input { flex: 1; background: none; color: #c7cce4; font-size: 13px; }
- .pair-search-input::placeholder { color: #5a6278; }
- .pair-list { max-height: 360px; overflow-y: auto; }
- .pair-list::-webkit-scrollbar { width: 4px; }
- .pair-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 2px; }
- .pair-item {
- display: grid; grid-template-columns: 22px 1fr 1fr 80px;
- align-items: center; gap: 6px; padding: 9px 14px;
- cursor: pointer; transition: background .12s;
- }
- .pair-item:hover, .pair-item.active { background: rgba(255,255,255,0.04); }
- .pi-icon {
- width: 20px; height: 20px; border-radius: 50%; overflow: hidden;
- flex-shrink: 0; background: #2a2a2a;
- display: flex; align-items: center; justify-content: center;
- }
- .pi-icon img { width: 100%; height: 100%; object-fit: cover; }
- .pi-icon-fb { font-size: 10px; font-weight: 700; color: #f0b90b; }
- .pi-sym { font-weight: 600; font-size: 12px; }
- .pi-price { font-size: 12px; color: #9298ab; text-align: right; }
- .pi-chg { font-size: 12px; text-align: right; }
- .pair-empty { padding: 24px; text-align: center; color: #5a6278; font-size: 13px; }
- /* Ticker price */
- .ticker-price-wrap {
- flex-shrink: 0; padding: 0 20px;
- border-right: 1px solid #212121;
- }
- .ticker-price { font-size: 24px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
- .ticker-usd { font-size: 11px; color: #5a6278; margin-top: 2px; }
- /* Ticker stats */
- .ticker-stats { display: flex; align-items: center; flex: 1; overflow: hidden; padding-left: 8px; }
- .ts-item { display: flex; flex-direction: column; gap: 3px; flex-shrink: 0; padding: 0 16px; }
- .ts-lbl { font-size: 11px; color: #5a6278; white-space: nowrap; }
- .ts-val { font-size: 12px; font-weight: 500; white-space: nowrap; color: #c7cce4; }
- /* ─── Page body (display:contents so children participate in ft-page grid) ─── */
- .page-body {
- display: contents;
- }
- /* ─── Chart area ─── */
- .chart-area {
- grid-column: 1;
- grid-row: 2;
- min-width: 0;
- min-height: 0;
- display: flex;
- flex-direction: column;
- background: #000;
- }
- /* Sub-nav */
- .sub-nav {
- display: flex;
- align-items: center;
- height: 38px;
- padding: 0 8px;
- border-bottom: 1px solid #212121;
- flex-shrink: 0;
- }
- .snav-tab {
- padding: 0 14px; height: 100%;
- font-size: 13px; color: #6a7182;
- border-bottom: 2px solid transparent; margin-bottom: -1px;
- transition: all .15s; white-space: nowrap;
- }
- .snav-tab:hover { color: #c7cce4; }
- .snav-tab.act { color: #e8ecf4; border-bottom-color: #f0b90b; }
- /* Loading */
- .chart-loading {
- flex: 1; display: flex; align-items: center; justify-content: center;
- }
- .spinner {
- width: 28px; height: 28px;
- border: 2px solid rgba(255,255,255,0.07);
- border-top-color: #f0b90b; border-radius: 50%;
- animation: spin .8s linear infinite;
- }
- @keyframes spin { to { transform: rotate(360deg); } }
- /* Chart + book side by side */
- .chart-plus-book {
- flex: 1; min-height: 0;
- display: flex;
- }
- /* ─── Chart column ─── */
- .chart-col {
- flex: 1; min-width: 0;
- display: flex;
- border-right: 1px solid #212121;
- }
- /* Drawing sidebar */
- .draw-side {
- width: 44px; flex-shrink: 0;
- display: flex; flex-direction: column; align-items: center;
- padding: 44px 0 8px; gap: 2px;
- border-right: 1px solid #212121;
- }
- .db {
- width: 34px; height: 32px;
- display: flex; align-items: center; justify-content: center;
- color: #6a7182; border-radius: 4px; transition: all .12s; flex-shrink: 0;
- }
- .db:hover, .db--act { color: #e8ecf4; background: rgba(255,255,255,0.08); }
- .db-sep { width: 20px; height: 1px; background: rgba(255,255,255,0.07); margin: 3px 0; }
- .db-spacer { flex: 1; }
- /* Chart main */
- .chart-main {
- flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0;
- }
- /* TF bar */
- .tf-bar {
- display: flex; align-items: center; gap: 1px;
- padding: 0 8px; height: 36px;
- border-bottom: 1px solid #212121;
- flex-shrink: 0;
- }
- .tf-btn {
- padding: 3px 7px; font-size: 12px; color: #6a7182;
- border-radius: 3px; transition: all .12s; white-space: nowrap;
- }
- .tf-btn:hover { color: #c7cce4; background: rgba(255,255,255,0.05); }
- .tf-btn.act { color: #f0b90b; }
- .tf-more {
- padding: 4px 6px; color: #6a7182; border-radius: 3px;
- display: flex; align-items: center; justify-content: center;
- transition: all .12s;
- }
- .tf-more:hover { color: #c7cce4; }
- .tf-div { width: 1px; height: 14px; background: rgba(255,255,255,0.08); margin: 0 4px; flex-shrink: 0; }
- .tf-spacer { flex: 1; }
- .tf-icon {
- width: 28px; height: 28px;
- display: flex; align-items: center; justify-content: center;
- color: #6a7182; border-radius: 3px; transition: all .12s; flex-shrink: 0;
- }
- .tf-icon:hover { color: #c7cce4; background: rgba(255,255,255,0.06); }
- .tf-price-mode {
- display: flex; align-items: center; gap: 3px;
- padding: 3px 7px; font-size: 12px; color: #6a7182;
- border-radius: 3px; transition: all .12s; white-space: nowrap;
- }
- .tf-price-mode:hover { color: #c7cce4; }
- .ma-leg { font-size: 11px; font-weight: 600; margin-right: 2px; }
- .ma5 { color: #f0b90b; }
- .ma10 { color: #5d9cf5; }
- .ma30 { color: #e84d5b; }
- .tf-ind {
- padding: 2px 6px; font-size: 11px; font-weight: 600;
- border: 1px solid rgba(255,255,255,0.1); border-radius: 3px;
- color: #6a7182; transition: all .12s; flex-shrink: 0; margin-left: 1px;
- }
- .tf-ind.act { color: #f0b90b; border-color: rgba(240,185,11,0.4); background: rgba(240,185,11,0.07); }
- /* Chart canvas */
- .chart-area-rel { flex: 1; position: relative; min-height: 0; overflow: hidden; }
- /* z-index:1 使 chart-canvas-wrap 形成独立层叠上下文,内部 ohlcv-overlay(z:10)/tooltip(z:20) 被隔离,
- 不会穿透到 coin-info-panel(z:2) / fr-panel(z:2) 之上 */
- .chart-canvas-wrap { position: absolute; inset: 0; overflow: hidden; z-index: 1; }
- .kline-canvas { width: 100%; height: 100%; }
- .ohlcv-overlay {
- position: absolute; top: 4px; left: 4px; right: 0; z-index: 10;
- display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
- font-size: 11px; color: #6a7182; pointer-events: none; padding: 2px 6px;
- }
- .ohlcv-sym { color: #9298ab; font-weight: 500; }
- .ohlcv-item em { font-style: normal; color: #9298ab; }
- /* Hover detail tooltip */
- .kline-tooltip {
- position: absolute; top: 24px; z-index: 20;
- background: rgba(16, 17, 22, 0.88);
- border: 1px solid rgba(255,255,255,0.07);
- border-radius: 4px; padding: 6px 10px;
- font-size: 11px; pointer-events: none; min-width: 160px;
- }
- .kt-time { color: #6a7182; margin-bottom: 3px; font-size: 10.5px; }
- .kt-row { display: flex; justify-content: space-between; gap: 16px; line-height: 1.75; }
- .kt-lbl { color: #6a7182; }
- .kt-val { color: #d1d4dc; }
- .kt-c-ma5 { color: #f0b90b; }
- .kt-c-ma10 { color: #5d9cf5; }
- .kt-c-ma30 { color: #e84d5b; }
- /* ─── Order book ─── */
- .book-col {
- width: 214px; flex-shrink: 0;
- background: #000;
- display: flex; flex-direction: column; overflow: hidden;
- }
- .book-hd-tabs {
- display: flex; align-items: center;
- padding: 0 12px; height: 40px;
- border-bottom: 1px solid #212121;
- flex-shrink: 0;
- }
- .book-hd-ctrl {
- display: flex; align-items: center;
- padding: 0 12px; height: 34px;
- border-bottom: 1px solid #212121;
- flex-shrink: 0; gap: 6px;
- }
- .book-tab {
- padding: 0 6px; height: 100%;
- font-size: 13px; font-weight: 500; color: #45454a;
- position: relative; transition: color .12s; white-space: nowrap;
- }
- .book-tab:hover { color: #d1d1d2; }
- .book-tab.act { color: #fff; }
- .book-tab.act::after {
- content: ''; position: absolute; bottom: 0; left: 50%;
- transform: translateX(-50%); width: 16px; height: 3px;
- background: #fff; border-radius: 2px 2px 0 0;
- }
- .book-hd-sp { flex: 1; }
- .book-layouts { display: flex; align-items: center; gap: 4px; }
- .bl-btn {
- width: 24px; height: 24px;
- display: flex; align-items: center; justify-content: center;
- color: #5a6278; border-radius: 3px; transition: all .12s;
- }
- .bl-btn:hover { background: rgba(255,255,255,0.06); color: #c7cce4; }
- .bl-btn.act { background: rgba(255,255,255,0.08); color: #c7cce4; }
- .prec-wrap { position: relative; margin-left: auto; }
- .book-precision {
- font-size: 11px; color: #6a7182;
- padding: 3px 6px; border: 1px solid rgba(255,255,255,0.1);
- border-radius: 3px; display: flex; align-items: center; gap: 3px; transition: all .12s;
- }
- .book-precision:hover { color: #c7cce4; border-color: rgba(255,255,255,0.2); }
- .prec-drop {
- position: absolute; top: calc(100% + 4px); right: 0;
- min-width: 80px; background: #1d1d1d;
- border: 1px solid rgba(255,255,255,0.1); border-radius: 8px;
- box-shadow: 0 8px 24px rgba(0,0,0,.7); z-index: 100; overflow: hidden;
- }
- .pdrop-item {
- display: block; width: 100%; padding: 8px 14px;
- font-size: 12px; color: #9298ab; text-align: left; transition: all .12s;
- }
- .pdrop-item:hover { background: rgba(255,255,255,0.06); color: #e8ecf4; }
- .pdrop-item.act { color: #fff; }
- .book-col-head {
- display: grid; grid-template-columns: 1fr 1fr 1fr;
- padding: 4px 8px; font-size: 10px; color: #5a6278;
- flex-shrink: 0;
- }
- .book-asks, .book-bids { overflow: hidden; }
- .book-row {
- position: relative; display: grid; grid-template-columns: 1fr 1fr 1fr;
- padding: 2px 8px; font-size: 11px; align-items: center; cursor: pointer;
- }
- .book-row:hover { background: rgba(255,255,255,0.02); }
- .br-depth {
- position: absolute; top: 0; right: 0; bottom: 0;
- opacity: 0.12; pointer-events: none; transition: width .3s;
- }
- .br-depth--ask { background: #f6465d; }
- .br-depth--bid { background: #02c076; }
- .br-p { font-weight: 500; z-index: 1; font-variant-numeric: tabular-nums; }
- .br-a { text-align: right; color: #7a8096; z-index: 1; font-size: 10.5px; }
- .br-t { text-align: right; color: #5a6278; z-index: 1; font-size: 10.5px; }
- .tr { text-align: right; }
- .book-mid {
- display: flex; align-items: center; gap: 6px;
- padding: 6px 8px; flex-shrink: 0;
- }
- .book-mid.up { background: rgba(2,192,118,0.06); }
- .book-mid.dn { background: rgba(246,70,93,0.06); }
- .bm-price {
- font-size: 16px; font-weight: 700;
- font-variant-numeric: tabular-nums; letter-spacing: 0.02em;
- }
- .bm-mark-price {
- margin-left: auto; font-size: 11px; color: #6a7182;
- font-variant-numeric: tabular-nums;
- }
- .bs-ratio { margin-top: auto; padding: 6px 8px; border-top: 1px solid #212121; flex-shrink: 0; }
- .bs-bar { display: flex; height: 3px; border-radius: 2px; overflow: hidden; margin-bottom: 4px; }
- .bs-b { background: #02c076; transition: width .5s; }
- .bs-s { background: #f6465d; transition: width .5s; }
- .bs-lbl { display: flex; justify-content: space-between; font-size: 11px; }
- /* ─── Trade panel ─── */
- .trade-panel {
- grid-column: 2;
- grid-row: 1 / span 3;
- background: #000;
- display: flex; flex-direction: column;
- overflow-y: auto;
- min-height: 0;
- }
- .trade-panel::-webkit-scrollbar { width: 3px; }
- .trade-panel::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
- /* 上线初期:交易禁用提示 */
- /* TP header */
- .tp-hd {
- display: flex; align-items: center;
- padding: 8px 12px 8px; gap: 6px;
- border-bottom: 1px solid #212121;
- position: relative;
- }
- .margin-wrap { position: relative; }
- .margin-btn {
- display: flex; align-items: center; gap: 3px;
- font-size: 12px; color: #9298ab;
- padding: 4px 10px; border-radius: 4px;
- border: 1px solid rgba(255,255,255,0.12);
- transition: all .12s;
- }
- .margin-btn:hover { border-color: rgba(255,255,255,0.25); color: #c7cce4; }
- .margin-drop {
- position: absolute; top: calc(100% + 4px); left: 0;
- min-width: 100px; background: #1d1d1d;
- border: 1px solid rgba(255,255,255,0.1); border-radius: 8px;
- box-shadow: 0 8px 24px rgba(0,0,0,.7); z-index: 100; overflow: hidden;
- }
- .mdrop-item {
- display: block; width: 100%; padding: 10px 14px;
- font-size: 13px; color: #9298ab; text-align: left; transition: all .12s;
- }
- .mdrop-item:hover { background: rgba(255,255,255,0.06); color: #e8ecf4; }
- .mdrop-item.act { color: #fff; }
- .tp-hd-sp { flex: 1; }
- .lev-wrap { position: relative; }
- .lev-btn {
- display: flex; align-items: center; gap: 3px;
- font-size: 13px; font-weight: 700; color: #f0b90b;
- padding: 4px 10px; border-radius: 4px;
- border: 1px solid rgba(240,185,11,0.4);
- background: rgba(240,185,11,0.06);
- transition: all .12s;
- }
- .lev-btn:hover { background: rgba(240,185,11,0.12); }
- .lev-panel {
- position: absolute; top: calc(100% + 4px); right: 0;
- width: 200px; background: #1d1d1d;
- border: 1px solid rgba(255,255,255,0.1); border-radius: 8px;
- box-shadow: 0 8px 24px rgba(0,0,0,.7); z-index: 100; padding: 14px;
- }
- .lp-title { font-size: 12px; color: #5a6278; margin-bottom: 10px; }
- .lp-pills { display: flex; flex-wrap: wrap; gap: 6px; }
- .lp-pills--full { width: 100%; }
- .lp-pill {
- padding: 5px 12px; font-size: 12px;
- border-radius: 4px; border: 1px solid rgba(255,255,255,0.1);
- color: #6a7182; transition: all .12s;
- }
- .lp-pill:hover { border-color: #f0b90b; color: #f0b90b; }
- .lp-pill.act { background: rgba(240,185,11,0.1); border-color: #f0b90b; color: #f0b90b; }
- .lp-stepper {
- display: flex; align-items: center; gap: 8px;
- margin-bottom: 10px;
- }
- .lp-step-btn {
- width: 28px; height: 28px;
- font-size: 16px; font-weight: 700; color: #c7cce4;
- background: rgba(255,255,255,0.06);
- border-radius: 4px;
- transition: all .12s;
- }
- .lp-step-btn:hover:not(:disabled) { background: rgba(255,255,255,0.12); }
- .lp-step-btn:disabled { opacity: 0.4; cursor: not-allowed; }
- .lp-step-val {
- flex: 1; text-align: center;
- font-size: 16px; font-weight: 700; color: #f0b90b;
- }
- .lp-range {
- width: 100%; height: 4px; cursor: pointer;
- accent-color: #f0b90b; margin-bottom: 4px;
- }
- .lp-range-ticks {
- display: flex; justify-content: space-between;
- font-size: 10px; color: #5a6278; margin-bottom: 10px;
- }
- .lp-actions {
- display: grid; grid-template-columns: 1fr 1fr;
- gap: 8px; margin-top: 10px;
- }
- .lp-cancel, .lp-confirm {
- height: 32px; border-radius: 4px;
- font-size: 13px; font-weight: 600;
- transition: all .12s;
- }
- .lp-cancel { background: rgba(255,255,255,0.06); color: #c7cce4; }
- .lp-cancel:hover { background: rgba(255,255,255,0.12); }
- .lp-confirm { background: #f0b90b; color: #1a1a1a; }
- .lp-confirm:hover { opacity: 0.9; }
- /* Vol unit dropdown */
- .vol-unit-wrap { position: relative; }
- .vol-unit-drop {
- position: absolute; top: calc(100% + 4px); right: 0;
- min-width: 80px; background: #1d1d1d;
- border: 1px solid rgba(255,255,255,0.1); border-radius: 6px;
- box-shadow: 0 8px 24px rgba(0,0,0,.7); z-index: 100; overflow: hidden;
- }
- .vud-item {
- display: block; width: 100%; padding: 6px 12px;
- font-size: 12px; color: #9298ab; text-align: left;
- transition: all .12s;
- }
- .vud-item:hover { background: rgba(255,255,255,0.06); color: #e8ecf4; }
- .vud-item.act { color: #f0b90b; }
- /* Close sheet tabs */
- .cs-tabs {
- display: flex; gap: 6px; padding: 8px 0 4px;
- }
- .cs-tab {
- flex: 1; height: 30px; font-size: 13px; font-weight: 500;
- color: #8b8b8e; background: rgba(255,255,255,0.06);
- border-radius: 6px; transition: all .12s;
- }
- .cs-tab:hover { color: #c7cce4; }
- .cs-tab.act { color: #1a1a1a; background: #f0b90b; }
- /* 平仓弹窗:区块左右对齐 + 比例滑块/刻度(原 pct-* 未定义样式会导致刻度粘连) */
- .close-sheet-stack .tpsl-pos-summary {
- padding: 10px 12px;
- }
- .close-sheet-stack .tpsl-info-rows {
- padding: 10px 12px;
- margin: 0;
- }
- .close-sheet-stack .inp-block {
- padding-left: 0;
- padding-right: 0;
- }
- .close-sheet-stack .cs-tabs {
- padding-left: 0;
- padding-right: 0;
- }
- .close-sheet-stack .pct-wrap {
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: 2px 0 0;
- width: 100%;
- }
- .close-sheet-stack .pct-track-wrap {
- position: relative;
- padding: 6px 10px 20px;
- box-sizing: border-box;
- }
- .close-sheet-stack .pct-slider {
- display: block;
- width: 100%;
- margin: 0;
- height: 20px;
- cursor: pointer;
- -webkit-appearance: none;
- appearance: none;
- background: transparent;
- }
- .close-sheet-stack .pct-slider:focus {
- outline: none;
- }
- .close-sheet-stack .pct-slider::-webkit-slider-runnable-track {
- height: 4px;
- border-radius: 2px;
- background: rgba(255, 255, 255, 0.12);
- }
- .close-sheet-stack .pct-slider::-webkit-slider-thumb {
- -webkit-appearance: none;
- width: 14px;
- height: 14px;
- margin-top: -5px;
- border-radius: 50%;
- background: #fff;
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
- }
- .close-sheet-stack .pct-slider::-moz-range-track {
- height: 4px;
- border-radius: 2px;
- background: rgba(255, 255, 255, 0.12);
- }
- .close-sheet-stack .pct-slider::-moz-range-thumb {
- width: 14px;
- height: 14px;
- border: none;
- border-radius: 50%;
- background: #fff;
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
- }
- .close-sheet-stack .pct-dots {
- display: flex;
- justify-content: space-between;
- position: absolute;
- left: 10px;
- right: 10px;
- bottom: 8px;
- pointer-events: none;
- }
- .close-sheet-stack .pct-dot {
- width: 5px;
- height: 5px;
- border-radius: 50%;
- background: rgba(255, 255, 255, 0.18);
- }
- .close-sheet-stack .pct-dot.fill {
- background: #f0b90b;
- }
- .close-sheet-stack .pct-ticks {
- display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
- gap: 4px;
- padding: 0 10px;
- font-size: 11px;
- color: #6a7182;
- line-height: 1.3;
- }
- .close-sheet-stack .pct-ticks span:nth-child(1) {
- text-align: left;
- }
- .close-sheet-stack .pct-ticks span:nth-child(2),
- .close-sheet-stack .pct-ticks span:nth-child(3),
- .close-sheet-stack .pct-ticks span:nth-child(4) {
- text-align: center;
- }
- .close-sheet-stack .pct-ticks span:nth-child(5) {
- text-align: right;
- }
- /* TP/SL info rows */
- .tpsl-info-rows {
- display: flex; flex-direction: column; gap: 4px;
- margin: 8px 0;
- padding: 8px 10px;
- background: rgba(255,255,255,0.04);
- border-radius: 6px;
- }
- .tpsl-info-row {
- display: flex; justify-content: space-between;
- font-size: 12px;
- }
- .tpsl-info-row span:first-child { color: #6a7182; }
- .tpsl-info-row span:last-child { color: #c7cce4; font-weight: 500; }
- /* Assets tab */
- .assets-grid {
- display: grid; grid-template-columns: repeat(3, 1fr);
- gap: 12px; padding: 16px;
- }
- .asset-card {
- padding: 14px 16px;
- background: rgba(255,255,255,0.04);
- border: 1px solid rgba(255,255,255,0.06);
- border-radius: 8px;
- }
- .asset-lbl { font-size: 12px; color: #6a7182; margin-bottom: 6px; }
- .asset-val {
- font-size: 18px; font-weight: 700; color: #e8ecf4;
- font-variant-numeric: tabular-nums;
- }
- .asset-val.up { color: #02c076; }
- .asset-val.dn { color: #f6465d; }
- .asset-cur { font-size: 11px; color: #8b8b8e; font-weight: 500; margin-left: 4px; }
- .asset-card--actions {
- display: flex; align-items: center; justify-content: center;
- }
- .asset-btn {
- padding: 8px 18px; border-radius: 6px;
- background: #f0b90b; color: #1a1a1a;
- font-size: 13px; font-weight: 600;
- transition: opacity .12s;
- }
- .asset-btn:hover { opacity: 0.9; }
- /* Order type */
- .ot-row {
- display: flex; align-items: center;
- padding: 2px 10px 8px; gap: 2px;
- border-bottom: 1px solid #212121;
- }
- .ot-btn {
- padding: 5px 9px; font-size: 12px; font-weight: 500;
- border-radius: 4px; color: #6a7182; transition: all .12s;
- }
- .ot-btn.act { background: rgba(255,255,255,0.08); color: #e8ecf4; }
- .ot-cond { display: flex; align-items: center; gap: 3px; }
- .ot-cond-wrap { position: relative; }
- .cond-drop {
- position: absolute; top: calc(100% + 4px); left: 0; right: 0;
- background: #1e2028; border: 1px solid #2a2a2a; border-radius: 4px;
- overflow: hidden; z-index: 200;
- }
- .cond-drop-item {
- padding: 8px 10px; font-size: 12px; color: #8a91a0; cursor: pointer;
- transition: background .12s, color .12s;
- }
- .cond-drop-item:hover { background: rgba(255,255,255,0.06); color: #e8ecf4; }
- .cond-drop-item.act { color: var(--color-primary); }
- .ot-info {
- margin-left: auto; color: #6a7182;
- width: 24px; height: 24px;
- display: flex; align-items: center; justify-content: center;
- border-radius: 50%; transition: all .12s;
- }
- .ot-info:hover { color: #c7cce4; }
- /* Panel body */
- .tp-body { display: flex; flex-direction: column; padding-bottom: 10px; }
- .avail-row {
- display: flex; align-items: center;
- font-size: 12px; padding: 8px 12px;
- }
- .avail-lbl { color: #6a7182; margin-right: 6px; flex-shrink: 0; }
- .avail-val { color: #c7cce4; font-weight: 500; flex: 1; }
- .avail-acts { display: flex; gap: 4px; }
- .avail-act {
- width: 22px; height: 22px;
- display: flex; align-items: center; justify-content: center;
- color: #6a7182; border-radius: 3px; border: 1px solid rgba(255,255,255,0.1);
- transition: all .12s;
- }
- .avail-act:hover { color: #c7cce4; border-color: rgba(255,255,255,0.25); }
- /* Input blocks */
- .inp-block { padding: 6px 12px 0; }
- .inp-wrap {
- background: rgba(255,255,255,0.12);
- border-radius: 8px;
- padding: 8px 10px 6px;
- transition: background .15s;
- }
- .inp-wrap:focus-within { background: rgba(255,255,255,0.15); }
- .inp-lbl { display: block; font-size: 12px; color: #8b8b8e; margin-bottom: 2px; }
- .inp-lbl-row {
- display: flex; align-items: center; justify-content: space-between;
- margin-bottom: 2px;
- }
- .inp-lbl-row .inp-lbl { margin-bottom: 0; }
- .inp-row { display: flex; align-items: center; gap: 4px; }
- .inp {
- flex: 1; background: none; color: #fff;
- font-size: 18px; min-width: 0; padding: 0; font-weight: 700;
- border: none; outline: none;
- -webkit-appearance: none; appearance: none;
- }
- .inp::placeholder { color: #5a6278; font-size: 12px; font-weight: 400; }
- .inp::-webkit-inner-spin-button, .inp::-webkit-outer-spin-button { display: none; }
- .inp-quick {
- padding: 2px 8px; font-size: 12px; font-weight: 700; color: #f0b90b;
- border-radius: 4px; flex-shrink: 0; white-space: nowrap;
- transition: all .12s;
- }
- .inp-quick:hover { background: rgba(240,185,11,0.1); }
- .inp-bbo {
- background: rgba(255,255,255,0.12); color: #fff; font-weight: 400;
- padding: 3px 8px; border-radius: 6px;
- }
- .inp-bbo:hover { background: rgba(255,255,255,0.18); }
- .inp-unit {
- font-size: 12px; color: #8b8b8e; flex-shrink: 0;
- }
- .inp-market {
- height: 42px; display: flex; align-items: center;
- padding: 0 10px; background: rgba(255,255,255,0.12);
- border-radius: 8px; color: #6a7182; font-size: 13px;
- }
- .vol-unit-btn {
- display: flex; align-items: center; gap: 3px;
- font-size: 11px; color: #9298ab; transition: all .12s; flex-shrink: 0;
- }
- .vol-unit-btn:hover { color: #c7cce4; }
- /* PCT buttons */
- .pct-btns { display: flex; gap: 4px; padding: 10px 12px 4px; }
- .pct-btn {
- flex: 1; font-size: 12px; padding: 4px 0; border-radius: 4px;
- border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
- color: #8b8b8e; cursor: pointer; transition: all .15s;
- }
- .pct-btn:hover { background: rgba(255,255,255,0.1); color: #e8ecf4; }
- .trade-err { font-size: 12px; color: #f6465d; padding: 0 12px 4px; }
- /* Submit buttons (logged in) */
- .submit-row {
- display: grid; grid-template-columns: 1fr 1fr;
- gap: 8px; padding: 4px 12px;
- }
- .sub-btn {
- height: 44px; border-radius: 6px; font-size: 14px; font-weight: 700;
- display: flex; align-items: center; justify-content: center;
- transition: opacity .15s;
- }
- .sub-btn:disabled { opacity: 0.35; cursor: not-allowed; }
- .sub-buy { background: #02c076; color: #0b0e16; }
- .sub-sell { background: #f6465d; color: #fff; }
- .sub-buy:not(:disabled):hover, .sub-sell:not(:disabled):hover { opacity: 0.9; }
- .spin-sm {
- display: inline-block; width: 16px; height: 16px;
- border: 2px solid rgba(255,255,255,0.3); border-top-color: currentColor;
- border-radius: 50%; animation: spin .7s linear infinite;
- }
- /* Auth buttons (not logged in) */
- .auth-btns {
- display: flex; flex-direction: column; gap: 8px;
- padding: 4px 12px;
- }
- .auth-btn {
- width: 100%; height: 40px; border-radius: 9999px;
- font-size: 14px; font-weight: 500;
- display: flex; align-items: center; justify-content: center;
- text-decoration: none; transition: all .15s;
- }
- .auth-register {
- background: #fff; color: #000;
- }
- .auth-register:hover { background: rgba(255,255,255,0.9); }
- .auth-login {
- background: rgba(255,255,255,0.12); color: #fff;
- }
- .auth-login:hover { background: rgba(255,255,255,0.18); }
- /* Contract info */
- .contract-info {
- border-top: 1px solid #212121;
- margin: 6px 12px 0; padding-top: 10px;
- }
- .ci-title { font-size: 12px; font-weight: 600; color: #9298ab; margin-bottom: 8px; }
- .ci-row { display: flex; justify-content: space-between; font-size: 11px; color: #5a6278; margin-bottom: 6px; }
- .ci-row span:last-child { color: #9298ab; text-align: right; }
- /* ─── Bottom area ─── */
- .bottom-area {
- grid-column: 1;
- grid-row: 3;
- background: #000;
- border-top: 1px solid #212121;
- display: flex;
- flex-direction: column;
- min-height: 260px;
- min-width: 0;
- }
- .tab-scroll-area {
- flex: 1;
- overflow-y: auto;
- min-height: 0;
- min-width: 0;
- }
- .tab-scroll-area::-webkit-scrollbar { width: 3px; }
- .tab-scroll-area::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
- .btab-bar {
- display: flex; align-items: center;
- padding: 0 12px; height: 42px;
- border-bottom: 1px solid #212121;
- }
- .btab-sp { flex: 1; }
- .btab {
- padding: 0 12px; height: 100%;
- font-size: 12.5px; color: #6a7182;
- border-bottom: 2px solid transparent; margin-bottom: -1px;
- transition: all .15s; display: flex; align-items: center; gap: 4px;
- white-space: nowrap;
- }
- .btab:hover { color: #c7cce4; }
- .btab.act { color: #f0b90b; border-bottom-color: #f0b90b; }
- .badge {
- font-size: 10px; background: rgba(240,185,11,0.15); color: #f0b90b;
- border-radius: 10px; padding: 0 5px; min-width: 16px; text-align: center;
- }
- .only-cur {
- display: flex; align-items: center; gap: 5px;
- font-size: 12px; color: #6a7182; cursor: pointer; padding: 0 8px;
- white-space: nowrap;
- }
- .only-cur input { accent-color: #f0b90b; }
- .close-all {
- padding: 5px 12px; font-size: 12px; font-weight: 500;
- background: transparent; color: #f0b90b;
- border: 1px solid rgba(240,185,11,0.45); border-radius: 4px;
- transition: all .12s; white-space: nowrap; margin-left: 6px;
- }
- .close-all:hover { background: rgba(240,185,11,0.1); }
- /* Data tables */
- .empty-tip {
- padding: 28px 16px; text-align: center; color: #6a7182; font-size: 13px;
- display: flex; align-items: center; justify-content: center; gap: 6px;
- }
- .trades-list { overflow-y: auto; flex: 1; }
- .trades-list::-webkit-scrollbar { width: 3px; height: 3px; }
- .trades-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
- .trade-row {
- display: grid; grid-template-columns: 1fr 1fr 1fr;
- padding: 3px 10px; font-size: 12px; line-height: 1.6;
- }
- .trade-time { color: #6a7182; }
- .tip-link { color: #f0b90b; text-decoration: none; }
- .tip-link:hover { text-decoration: underline; }
- /* 表格容器:列少时右侧留白,内容超宽才出滚动条 */
- .data-tbl { overflow-x: auto; position: relative; }
- .data-tbl::-webkit-scrollbar { width: 3px; height: 3px; }
- .data-tbl::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
- /* 行通用:宽度自适应但不小于容器 */
- .dt-head, .dt-row {
- display: grid;
- padding: 6px 10px;
- align-items: center; gap: 0; font-size: 12px;
- min-width: 100%; width: max-content;
- box-sizing: border-box;
- }
- /* 直接子 span 居中(> 只选直接子元素,不影响嵌套 span 如 entrust-type-tag) */
- .dt-head > span, .dt-row > span {
- display: block;
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
- text-align: center; padding: 0 5px;
- }
- .dt-head > span { color: #6a7182; }
- /* 首列左对齐 */
- .dt-head > span:first-child, .dt-row > span:first-child { text-align: left; }
- /* 操作列:flex 布局,不截断 */
- .dt-row > span.op-col {
- display: flex; overflow: visible; white-space: nowrap;
- text-align: left; padding: 0;
- }
- /* ── 统一基准列宽 ──
- 普通列: 84px 宽列(大数字/盈亏): 92px 时间列: 104px
- 细列(状态/类型): 70px 合约列: 84px 操作(仓位): 160px 操作(委托): 82px */
- /* 仓位:合约|方向杠杆|开仓价|标记价|持仓量|保证金|保证金率|浮动盈亏|收益率|强平价|开仓时间|操作 */
- .dt-head--pos, .dt-row--pos {
- grid-template-columns: 84px 84px 84px 84px 70px 92px 70px 92px 70px 84px 104px 160px;
- }
- /* 当前委托:合约|方向杠杆|类型|委托价|触发价|委托量|已成交|止盈价|止损价|时间|操作 */
- .dt-head--ord, .dt-row--ord {
- grid-template-columns: 84px 84px 70px 84px 84px 70px 70px 84px 84px 104px 82px;
- }
- /* 固定操作列吸附右侧 */
- .dt-head--pos > span:last-child,
- .dt-head--ord > span:last-child {
- position: sticky; right: 0; z-index: 2; background: #000;
- }
- .dt-row--pos, .dt-row--ord { --row-bg: #000; }
- .dt-row--pos:hover, .dt-row--ord:hover { --row-bg: #111; }
- .dt-row--pos > span:last-child,
- .dt-row--ord > span:last-child {
- position: sticky; right: 0; z-index: 1; background: var(--row-bg, #000);
- }
- /* 历史仓位:合约|方向杠杆|状态|委托量|开仓均价|收益|已成交量|平仓均价|收益率|开仓时间|平仓时间 */
- .dt-head--hist, .dt-row--hist {
- grid-template-columns: 84px 84px 70px 70px 84px 92px 70px 84px 70px 104px 104px;
- }
- /* 历史委托:合约|方向杠杆|类型|状态|委托量|已成交量|开仓均价|委托价格|委托时间|成交时间 */
- .dt-head--hist-ord, .dt-row--hist-ord {
- grid-template-columns: 84px 84px 70px 70px 70px 70px 84px 84px 104px 104px;
- }
- /* 成交明细:合约|方向|成交价|成交量|手续费|成交时间 */
- .dt-head--trade, .dt-row--trade {
- grid-template-columns: 84px 84px 84px 70px 84px 104px;
- }
- /* 资金流水:类型|数量|币种|时间 */
- .dt-head--funding, .dt-row--funding {
- grid-template-columns: 120px 100px 100px 104px;
- }
- .entrust-type-tag {
- display: inline-block; font-size: 10px; padding: 1px 4px; border-radius: 2px;
- background: rgba(255,255,255,0.08); color: #8a92a8; margin-left: 3px;
- }
- .load-more-row { display: flex; justify-content: center; padding: 10px 0; }
- .load-more-btn {
- font-size: 12px; padding: 4px 20px;
- border: 1px solid #333; border-radius: 3px; color: #9298ab;
- transition: all .12s;
- }
- .load-more-btn:hover { border-color: #555; color: #d1d4dc; }
- .dt-head { border-bottom: 1px solid #212121; }
- .dt-row { color: #9298ab; border-bottom: 1px solid #1a1a1a; transition: background .12s; }
- .dt-row:hover { background: #111; }
- .fw6 { font-weight: 600; color: #e8ecf4 !important; }
- .act-btn {
- font-size: 10px; padding: 2px 6px;
- border: 1px solid rgba(240,185,11,0.35); border-radius: 3px;
- color: #f0b90b; transition: all .12s; white-space: nowrap;
- }
- .act-btn:hover:not(:disabled) { background: rgba(240,185,11,0.08); }
- .act-btn:disabled { opacity: 0.4; cursor: not-allowed; }
- .act-btn--sec {
- border-color: rgba(100,180,255,0.35); color: #64b4ff;
- }
- .act-btn--sec:hover:not(:disabled) { background: rgba(100,180,255,0.08); }
- .act-btn--market {
- border-color: rgba(246,70,93,0.35); color: #f6465d;
- }
- .act-btn--market:hover:not(:disabled) { background: rgba(246,70,93,0.08); }
- /* 操作列:按钮横向排列,不换行 */
- .op-col {
- display: flex; flex-direction: row; flex-wrap: nowrap; gap: 3px; align-items: center;
- }
- /* ─── Colors ─── */
- .up { color: #02c076 !important; }
- .dn { color: #f6465d !important; }
- /* ─── Transitions ─── */
- .pop-enter-active, .pop-leave-active { transition: opacity .15s, transform .15s; }
- .pop-enter-from, .pop-leave-to { opacity: 0; transform: translateY(-6px); }
- /* ─── Transfer Modal ─── */
- .modal-mask {
- position: fixed; inset: 0; z-index: 1000;
- background: rgba(0,0,0,0.6);
- display: flex; align-items: center; justify-content: center;
- }
- .modal-box {
- width: 480px; background: #1e2026; border-radius: 12px;
- display: flex; flex-direction: column; overflow: hidden;
- box-shadow: 0 20px 60px rgba(0,0,0,0.5);
- }
- .modal-hd {
- display: flex; align-items: center; justify-content: space-between;
- padding: 20px 24px 16px; border-bottom: 1px solid #2a2d35;
- }
- .modal-title { font-size: 16px; font-weight: 600; color: #e8ecf4; }
- .modal-close {
- color: #6a7182; padding: 4px; border-radius: 4px; transition: color .15s;
- }
- .modal-close:hover { color: #c7cce4; }
- .modal-body { padding: 20px 24px; display: flex; flex-direction: column; gap: 18px; }
- .modal-ft {
- display: flex; gap: 12px; padding: 16px 24px 24px;
- }
- .modal-cancel {
- flex: 1; height: 40px; border-radius: 6px;
- background: #2a2d35; color: #c7cce4; font-size: 14px;
- }
- .modal-cancel:hover { background: #363a45; }
- .modal-confirm {
- flex: 1; height: 40px; border-radius: 6px;
- background: #f0b90b; color: #111; font-weight: 600; font-size: 14px;
- }
- .modal-confirm:hover:not(:disabled) { background: #f5c842; }
- .modal-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
- .tf-label { font-size: 12px; color: #6a7182; margin-bottom: 8px; }
- .tf-row { display: flex; flex-direction: column; gap: 6px; }
- .tf-inp {
- background: #2a2d35; border: 1px solid #3a3e4a; border-radius: 6px;
- padding: 10px 12px; color: #e8ecf4; font-size: 14px; width: 100%;
- }
- .tf-inp:focus { outline: none; border-color: #f0b90b; }
- .tf-inp--profit:focus { border-color: #02c076; }
- .tf-inp--loss:focus { border-color: #f6465d; }
- .tf-err { font-size: 12px; color: #f6465d; margin-top: 4px; }
- /* TP/SL modal 内部元素 */
- .tpsl-pos-summary {
- display: flex; align-items: center; padding: 10px 14px;
- background: #1e2026; border-radius: 6px; font-size: 13px;
- }
- .tpsl-section-title {
- font-size: 11px; color: #6a7182; font-weight: 500;
- margin: 14px 0 8px; text-transform: uppercase; letter-spacing: .5px;
- }
- .tpsl-current-list { display: flex; flex-direction: column; gap: 6px; }
- .tpsl-current-item {
- background: #1e2026; border-radius: 6px; padding: 10px 12px;
- }
- .tpsl-current-row {
- display: flex; align-items: center; gap: 8px; font-size: 12px;
- }
- .tpsl-badge {
- padding: 2px 7px; border-radius: 3px; font-size: 11px; font-weight: 600;
- flex-shrink: 0;
- }
- .tpsl-badge--profit { background: rgba(2,192,118,0.15); color: #02c076; }
- .tpsl-badge--loss { background: rgba(246,70,93,0.15); color: #f6465d; }
- .tpsl-current-detail { flex: 1; color: #c7cce4; }
- .tpsl-current-detail strong { color: #e8ecf4; }
- .tpsl-current-vol { color: #6a7182; font-size: 11px; flex-shrink: 0; }
- .tf-select {
- display: flex; align-items: center; justify-content: space-between;
- background: #2a2d35; border-radius: 8px; padding: 12px 14px;
- color: #e8ecf4; font-size: 14px; cursor: pointer;
- }
- .tf-direction { display: flex; align-items: flex-end; gap: 10px; }
- .tf-dir-col { flex: 1; }
- .tf-dir-lbl { font-size: 12px; color: #6a7182; margin-bottom: 8px; }
- .tf-swap {
- flex-shrink: 0; width: 36px; height: 44px; margin-bottom: 0;
- background: #2a2d35; border-radius: 8px; color: #c7cce4;
- display: flex; align-items: center; justify-content: center;
- transition: background .15s;
- }
- .tf-swap:hover { background: #363a45; }
- .tf-amount-wrap {
- display: flex; align-items: center;
- background: #2a2d35; border-radius: 8px; padding: 0 14px;
- height: 46px;
- }
- .tf-amount-inp {
- flex: 1; background: none; color: #e8ecf4; font-size: 14px;
- outline: none; border: none;
- }
- .tf-amount-inp::placeholder { color: #3a3d45; }
- .tf-amount-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
- .tf-currency { font-size: 13px; color: #6a7182; }
- .tf-sep { color: #3a3d45; }
- .tf-all { font-size: 13px; color: #f0b90b; }
- .tf-all:hover { opacity: 0.8; }
- .tf-select--static { cursor: default; }
- .tf-select--btn { position: relative; cursor: pointer; user-select: none; }
- .tf-select--btn:hover { background: #363a45; }
- .tf-dropdown {
- position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 100;
- background: #2a2d35; border: 1px solid #363a45; border-radius: 8px;
- overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.4);
- }
- .tf-dropdown-item {
- padding: 10px 14px; font-size: 14px; color: #c7cce4; cursor: pointer;
- transition: background .12s;
- }
- .tf-dropdown-item:hover { background: #363a45; }
- .tf-dropdown-item.active { color: #f0b90b; }
- .tf-avail { font-size: 12px; color: #6a7182; margin-top: 8px; }
- .tf-avail span { color: #c7cce4; }
- .tf-avail-loading { color: #6a7182; font-style: italic; }
- .tf-error { font-size: 12px; color: #f6465d; margin-top: 6px; }
- .tf-cancel {
- flex: 1; height: 44px; border-radius: 8px;
- background: #2a2d35; color: #c7cce4; font-size: 14px; font-weight: 500;
- transition: background .15s;
- }
- .tf-cancel:hover { background: #363a45; }
- .tf-confirm {
- flex: 1; height: 44px; border-radius: 8px;
- background: #f0b90b; color: #000; font-size: 14px; font-weight: 600;
- transition: opacity .15s;
- }
- .tf-confirm:hover { opacity: 0.9; }
- .tf-cancel:disabled, .tf-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
- .tf-amount-inp:disabled { opacity: 0.6; }
- .modal-enter-active, .modal-leave-active { transition: opacity .2s; }
- .modal-enter-from, .modal-leave-to { opacity: 0; }
- .modal-enter-active .modal-box, .modal-leave-active .modal-box { transition: transform .2s; }
- .modal-enter-from .modal-box, .modal-leave-to .modal-box { transform: scale(0.95); }
- /* ─── Responsive (desktop) ─── */
- @media (max-width: 1200px) {
- .book-col { width: 200px; }
- }
- @media (max-width: 960px) {
- .book-col { display: none; }
- }
- @media (max-width: 860px) {
- .trade-panel:not(.mobile-show) { display: none; }
- }
- /* ─── Mobile H5 (≤ 768px) ─── */
- @media (max-width: 768px) {
- /* Page: single-column flex */
- .ft-page {
- display: flex;
- flex-direction: column;
- min-height: 100vh;
- grid-template-columns: unset;
- grid-template-rows: unset;
- }
- /* page-body keeps display:contents so children are ft-page flex items */
- /* Ticker bar: compact */
- .ticker-bar {
- grid-column: unset; grid-row: unset;
- height: auto; min-height: 54px;
- padding: 8px 10px;
- flex-shrink: 0;
- position: relative;
- }
- .ticker-stats { display: none; }
- .ticker-price-wrap { border-right: none; padding: 0 12px; }
- .ticker-price { font-size: 20px; }
- .ticker-usd { display: none; }
- .pair-name { font-size: 16px; }
- .coin-icon { width: 26px; height: 26px; }
- .pair-drop { width: calc(100vw - 20px); }
- /* Mobile tab bar */
- .mobile-trade-tabs {
- display: flex;
- height: 40px;
- flex-shrink: 0;
- background: #000;
- border-bottom: 1px solid #212121;
- }
- .mtt-btn {
- flex: 1;
- font-size: 14px;
- font-weight: 500;
- color: #6a7182;
- border-bottom: 2px solid transparent;
- margin-bottom: -1px;
- transition: all .15s;
- }
- .mtt-btn.act { color: #f0b90b; border-bottom-color: #f0b90b; }
- /* Chart area */
- .chart-area {
- grid-column: unset; grid-row: unset;
- height: 280px;
- flex-shrink: 0;
- }
- .chart-area.mobile-hidden { display: none; }
- /* Drawing sidebar: hidden on mobile */
- .draw-side { display: none; }
- /* Sub-nav: horizontal scroll */
- .sub-nav { overflow-x: auto; -webkit-overflow-scrolling: touch; }
- .sub-nav::-webkit-scrollbar { display: none; }
- /* TF bar: horizontal scroll */
- .tf-bar { overflow-x: auto; -webkit-overflow-scrolling: touch; padding-right: 8px; }
- .tf-bar::-webkit-scrollbar { display: none; }
- .tf-spacer { min-width: 8px; flex: unset; }
- /* OHLCV overlay: smaller */
- .ohlcv-overlay { font-size: 10px; gap: 4px; padding: 2px 4px; }
- /* Kline tooltip: smaller */
- .kline-tooltip { font-size: 10px; padding: 4px 8px; min-width: 120px; }
- /* Trade panel: hidden by default, full-width when active */
- .trade-panel {
- grid-column: unset; grid-row: unset;
- display: none;
- flex-shrink: 0;
- width: 100%;
- max-height: none;
- overflow-y: auto;
- }
- .trade-panel.mobile-show { display: flex; }
- /* Bottom area */
- .bottom-area {
- grid-column: unset; grid-row: unset;
- min-height: 220px;
- flex-shrink: 0;
- flex: 1;
- }
- /* Bottom tab bar: horizontal scroll */
- .btab-bar { overflow-x: auto; -webkit-overflow-scrolling: touch; padding: 0 6px; }
- .btab-bar::-webkit-scrollbar { display: none; }
- .btab { padding: 0 8px; font-size: 12px; }
- /* Data tables: touch scroll */
- .data-tbl { -webkit-overflow-scrolling: touch; }
- /* Transfer modal: narrower */
- .modal-box { width: calc(100vw - 24px); }
- .modal-body { padding: 16px; gap: 14px; }
- .modal-ft { padding: 12px 16px 20px; }
- /* Detail modal: full width on mobile */
- .detail-modal { width: calc(100vw - 24px); max-height: 92vh; }
- }
- /* ─── Detail Modal ─── */
- .detail-modal {
- width: 500px;
- max-height: 88vh;
- display: flex;
- flex-direction: column;
- }
- .detail-modal-body {
- overflow-y: auto;
- flex: 1;
- padding-bottom: 16px;
- }
- .dt-row--clickable { cursor: pointer; }
- .dt-row--clickable:hover { background: #111 !important; }
- .detail-header {
- display: flex; align-items: center; gap: 8px;
- padding: 14px 20px 14px;
- border-bottom: 1px solid #2a2d35;
- }
- .detail-dir-chip {
- padding: 2px 8px; border-radius: 4px;
- font-size: 12px; font-weight: 700;
- flex-shrink: 0;
- }
- .chip--up { background: rgba(2,192,118,0.15); color: #02c076; }
- .chip--dn { background: rgba(246,70,93,0.15); color: #f6465d; }
- .detail-symbol {
- font-size: 15px; font-weight: 700; color: #e8ecf4; flex: 1;
- }
- .detail-tag {
- padding: 2px 7px; border-radius: 4px;
- font-size: 11px; font-weight: 500;
- background: rgba(100,180,255,0.12); color: #64b4ff;
- flex-shrink: 0;
- }
- .detail-tag--lev {
- background: rgba(255,255,255,0.08); color: #9298ab;
- }
- .detail-status-block {
- display: flex; flex-direction: column; align-items: center;
- padding: 28px 20px 20px;
- }
- .detail-status-icon {
- width: 52px; height: 52px; border-radius: 14px;
- background: rgba(100,180,255,0.1);
- display: flex; align-items: center; justify-content: center;
- color: #64b4ff; margin-bottom: 12px;
- }
- .detail-status-label {
- font-size: 16px; font-weight: 600;
- }
- .detail-close-type {
- font-size: 12px; color: #6a7182; margin-top: 4px;
- }
- .detail-section {
- background: #161a23;
- border-top: 1px solid #2a2d35;
- border-bottom: 1px solid #2a2d35;
- }
- .detail-row {
- display: flex; align-items: center; justify-content: space-between;
- padding: 11px 20px;
- border-bottom: 1px solid rgba(255,255,255,0.04);
- font-size: 13px;
- }
- .detail-row:last-child { border-bottom: none; }
- .detail-lbl { color: #6a7182; }
- .detail-val { color: #e8ecf4; font-weight: 500; text-align: right; }
- .detail-section-gap { height: 8px; background: #0d0f14; }
- /* ── 币种资料面板 ─────────────────────────────────── */
- .coin-info-panel {
- position: absolute; inset: 0; overflow-y: auto; padding: 16px;
- background: #0d1117; z-index: 2;
- }
- .ci-loading { display: flex; align-items: center; justify-content: center; height: 100px; }
- .cip-header {
- display: flex; align-items: center; gap: 10px;
- padding-bottom: 14px; border-bottom: 1px solid #1e2330; margin-bottom: 14px;
- }
- .cip-icon { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; }
- .cip-names { flex: 1; display: flex; flex-direction: column; gap: 2px; }
- .cip-name-cn { font-size: 15px; font-weight: 600; color: #e8ecf4; }
- .cip-name-en { font-size: 12px; color: #6a7182; }
- .cip-rank {
- font-size: 11px; font-weight: 600; color: #f0b90b;
- background: rgba(240,185,11,0.12); border-radius: 4px; padding: 2px 7px;
- }
- .cip-grid {
- display: grid; grid-template-columns: 1fr 1fr; gap: 1px;
- background: #1e2330; border-radius: 6px; overflow: hidden;
- }
- .cip-item {
- display: flex; flex-direction: column; gap: 4px;
- background: #131720; padding: 10px 12px;
- }
- .cip-item--full { grid-column: 1 / -1; }
- .cip-lbl { font-size: 11px; color: #6a7182; }
- .cip-val { font-size: 13px; color: #e8ecf4; font-weight: 500; word-break: break-all; }
- .cip-link { font-size: 12px; color: #4a8fff; text-decoration: none; word-break: break-all; }
- .cip-link:hover { text-decoration: underline; }
- .cip-empty {
- display: flex; flex-direction: column; align-items: center; justify-content: center;
- gap: 10px; height: 100px; color: #6a7182; font-size: 13px;
- }
- .cip-retry {
- background: transparent; border: 1px solid #2a2d35; color: #9ca3b0;
- border-radius: 4px; padding: 4px 14px; cursor: pointer; font-size: 12px;
- }
- .cip-retry:hover { border-color: #555; color: #d1d4dc; }
- /* ── 历史资金费率面板 ───────────────────────────────── */
- .fr-panel {
- position: absolute; inset: 0; display: flex; flex-direction: column; overflow-y: auto;
- background: #0d1117; z-index: 2;
- }
- .fr-info-bar {
- display: flex; gap: 0; border-bottom: 1px solid #1e2330; flex-shrink: 0;
- background: #0d1117;
- }
- .fr-info-item {
- flex: 1; display: flex; flex-direction: column; gap: 3px;
- padding: 10px 14px; border-right: 1px solid #1e2330;
- }
- .fr-info-item:last-child { border-right: none; }
- .fr-info-lbl { font-size: 11px; color: #6a7182; }
- .fr-info-val { font-size: 13px; font-weight: 600; color: #e8ecf4; }
- .fr-body {
- flex: 1; display: flex; min-height: 0; overflow: hidden;
- }
- .fr-left {
- flex: 1; min-width: 0; display: flex; flex-direction: column;
- border-right: 1px solid #1e2330;
- }
- .fr-chart-wrap {
- flex: 1; min-height: 0; background: #0d1117; overflow: hidden;
- }
- .fr-chart-svg { width: 100%; height: 100%; display: block; }
- .fr-chart-empty {
- display: flex; align-items: center; justify-content: center;
- height: 100%; color: #6a7182; font-size: 13px;
- }
- .fr-list { width: 240px; flex-shrink: 0; overflow: hidden; }
- .fr-head {
- display: grid; grid-template-columns: 150px 1fr;
- padding: 7px 10px; font-size: 11px; color: #6a7182;
- border-bottom: 1px solid #1e2330; position: sticky; top: 0; background: #0d1117;
- }
- .fr-row {
- display: grid; grid-template-columns: 150px 1fr;
- padding: 8px 10px; font-size: 12px; color: #c9d1dc;
- border-bottom: 1px solid rgba(255,255,255,0.04);
- white-space: nowrap;
- }
- .fr-row:last-child { border-bottom: none; }
- .fr-empty { padding: 20px 12px; color: #6a7182; font-size: 13px; text-align: center; }
- </style>
|