FuturesView.vue 208 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763
  1. <script setup lang="ts">
  2. import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
  3. import { useRoute, useRouter } from 'vue-router'
  4. import { storeToRefs } from 'pinia'
  5. import { init, dispose } from 'klinecharts'
  6. import { useFuturesStore, type KlineResolution, type Position, type ContractOrder, type HistoryPosition, type HistoryOrder } from '@/stores/futures'
  7. import { useCoinsStore } from '@/stores/coins'
  8. import { useAuthStore } from '@/stores/auth'
  9. import { useMarketWs, type WsTradeItem, BAR_MS, BAR_COUNT } from '@/composables/useMarketWs'
  10. import { get, post, postForm } from '@/utils/request'
  11. import { useI18n } from 'vue-i18n'
  12. import { normalizeTransferPair } from '@/stores/transfer'
  13. import { walletTypeLabel } from '@/utils/walletTypeLabel'
  14. import { extractUsdtAvailableFromSpotAssets } from '@/utils/spotAsset'
  15. import { futuresPairToRouteParam } from '@/utils/futuresSymbol'
  16. import AppModal from '@/components/common/AppModal.vue'
  17. const route = useRoute()
  18. const router = useRouter()
  19. const { t, te, locale } = useI18n()
  20. const store = useFuturesStore()
  21. const coinsStore = useCoinsStore()
  22. const authStore = useAuthStore()
  23. const {
  24. symbol, displaySymbol, ticker, orderBook, klines, klineResolution,
  25. symbolInfo, positions, currentOrders, leverage, loading, orderLoading, error,
  26. allTickers, priceUp, spotAvailBalance, swapAvailBalance,
  27. historyPositions, historyOrders,
  28. fundingRecords,
  29. coinExtInfo, coinExtLoading,
  30. fundingRateCurrent, fundingRateHistory,
  31. marginMode, amountUnit, accountInfo, isSwitchingMode,
  32. } = storeToRefs(store)
  33. const { isLoggedIn } = storeToRefs(authStore)
  34. // ── UI state ───────────────────────────────────────────
  35. const subNavTab = ref<'chart' | 'coin-info' | 'funding-rate' | 'rules' | 'tiers'>('chart')
  36. const bookTab = ref<'orderbook' | 'trades'>('orderbook')
  37. const bookLayout = ref<'full' | 'asks' | 'bids'>('full')
  38. const bookColRef = ref<HTMLElement | null>(null)
  39. const bookTabsRef = ref<HTMLElement | null>(null)
  40. const bookCtrlRef = ref<HTMLElement | null>(null)
  41. const bookHeadRef = ref<HTMLElement | null>(null)
  42. const bookMidRef = ref<HTMLElement | null>(null)
  43. const bsRatioRef = ref<HTMLElement | null>(null)
  44. const dynamicBookRows = ref(20)
  45. const showPairSelector = ref(false)
  46. const showLeveragePanel = ref(false)
  47. const showMarginDrop = ref(false)
  48. const showCondDrop = ref(false)
  49. const showPrecDrop = ref(false)
  50. const showAmountUnitDrop = ref(false)
  51. const pairSearch = ref('')
  52. const fundingCountdown = ref('00:00:00')
  53. // ── 资金费率折线图 ──────────────────────────────────────
  54. const frHoverIdx = ref(-1)
  55. const frChartRef = ref<SVGSVGElement | null>(null)
  56. // SVG 画布常量
  57. const FR_W = 600, FR_H = 200
  58. const FR_PL = 8, FR_PR = 62, FR_PT = 12, FR_PB = 24
  59. const FR_PW = FR_W - FR_PL - FR_PR
  60. const FR_PH = FR_H - FR_PT - FR_PB
  61. function frTimeToMs(t: number | string | undefined): number {
  62. if (!t) return 0
  63. return typeof t === 'string' ? new Date(t).getTime() : Number(t)
  64. }
  65. function frFmtMMDD(ts: number): string {
  66. const d = new Date(ts)
  67. return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`
  68. }
  69. function frFmtDatetime(ts: number): string {
  70. const d = new Date(ts)
  71. 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')}`
  72. }
  73. const frItems = computed(() => {
  74. const cutoff = Date.now() - 7 * 24 * 3600 * 1000
  75. return fundingRateHistory.value
  76. .map(item => ({
  77. time: frTimeToMs(item.fundingTime),
  78. rate: Number(item.fundingRate ?? item.rate ?? 0),
  79. }))
  80. .filter(d => d.time > 0 && d.time >= cutoff)
  81. .sort((a, b) => a.time - b.time)
  82. })
  83. const frChart = computed(() => {
  84. const items = frItems.value
  85. if (items.length < 2) return null
  86. const rates = items.map(d => d.rate)
  87. const minR = Math.min(...rates)
  88. const maxR = Math.max(...rates)
  89. const rangePad = Math.max((maxR - minR) * 0.25, 0.000001)
  90. const yMin = minR - rangePad
  91. const yMax = maxR + rangePad
  92. const n = items.length
  93. const toX = (i: number) => FR_PL + (i / Math.max(n - 1, 1)) * FR_PW
  94. const toY = (r: number) => FR_PT + (1 - (r - yMin) / (yMax - yMin)) * FR_PH
  95. const pts = items.map((d, i) => ({ x: toX(i), y: toY(d.rate), time: d.time, rate: d.rate }))
  96. const linePath = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
  97. const zeroY = toY(0)
  98. const baseY = Math.max(Math.min(zeroY, FR_PT + FR_PH), FR_PT)
  99. const areaPath = linePath + ` L${(FR_PL + FR_PW).toFixed(1)},${baseY.toFixed(1)} L${FR_PL.toFixed(1)},${baseY.toFixed(1)} Z`
  100. const showZero = zeroY > FR_PT && zeroY < FR_PT + FR_PH
  101. const xStep = Math.max(1, Math.floor(n / 5))
  102. const xLabels = pts.filter((_, i) => i % xStep === 0 || i === n - 1).map(p => ({ x: p.x, label: frFmtMMDD(p.time) }))
  103. const yLabels: { y: number; label: string }[] = []
  104. const hGrid: number[] = []
  105. for (let i = 0; i <= 4; i++) {
  106. const r = yMin + (yMax - yMin) * (1 - i / 4)
  107. const y = toY(r)
  108. if (y > FR_PT + 2 && y < FR_PT + FR_PH - 2) {
  109. yLabels.push({ y, label: (r * 100).toFixed(3) + '%' })
  110. hGrid.push(y)
  111. }
  112. }
  113. return { pts, linePath, areaPath, zeroY, showZero, xLabels, yLabels, hGrid }
  114. })
  115. // 优先用 HTTP 接口费率(预测下期),降级到 WS 实时值
  116. const frCurrentRate = computed<number | null>(() => {
  117. const h = fundingRateCurrent.value
  118. if (h) {
  119. const v = Number(h.fundingRate ?? h.rate ?? NaN)
  120. if (!isNaN(v)) return v
  121. }
  122. return wsFundingRate.value ? parseFloat(wsFundingRate.value) : null
  123. })
  124. const frCurrentRateStr = computed(() => {
  125. const r = frCurrentRate.value
  126. if (r === null) return '--'
  127. return (r >= 0 ? '+' : '') + (r * 100).toFixed(4) + '%'
  128. })
  129. // 从历史数据的时间间隔推算结算周期(对齐 Flutter 的 '8H' 逻辑)
  130. const frPeriodLabel = computed(() => {
  131. const items = frItems.value
  132. if (items.length < 2) return '8H'
  133. const diffs: number[] = []
  134. for (let i = 1; i < Math.min(items.length, 10); i++) {
  135. diffs.push(Math.round((items[i].time - items[i - 1].time) / 3600000))
  136. }
  137. const counts = new Map<number, number>()
  138. diffs.forEach(d => counts.set(d, (counts.get(d) || 0) + 1))
  139. let maxCount = 0, mode = 8
  140. counts.forEach((cnt, val) => { if (cnt > maxCount) { maxCount = cnt; mode = val } })
  141. return `${mode}H`
  142. })
  143. function onFrMouseMove(e: MouseEvent) {
  144. const svg = frChartRef.value
  145. const chart = frChart.value
  146. if (!svg || !chart) { frHoverIdx.value = -1; return }
  147. const rect = svg.getBoundingClientRect()
  148. const svgX = (e.clientX - rect.left) / rect.width * FR_W
  149. let best = 0, bestDist = Infinity
  150. chart.pts.forEach((p, i) => {
  151. const d = Math.abs(p.x - svgX)
  152. if (d < bestDist) { bestDist = d; best = i }
  153. })
  154. frHoverIdx.value = best
  155. }
  156. function onFrMouseLeave() { frHoverIdx.value = -1 }
  157. // 列表高度跟踪:用 ResizeObserver 动态计算可见行数
  158. const frListRef = ref<HTMLElement | null>(null)
  159. const frListHeight = ref(300)
  160. const FR_ROW_H = 35 // 每行高度(padding 8+8 + 字号12*行高1.3 + 1px边框)
  161. const FR_HEAD_H = 31 // 表头高度
  162. let frListRo: ResizeObserver | null = null
  163. watch(frListRef, (el) => {
  164. frListRo?.disconnect()
  165. if (el) {
  166. frListRo = new ResizeObserver(() => { frListHeight.value = el.clientHeight })
  167. frListRo.observe(el)
  168. frListHeight.value = el.clientHeight
  169. }
  170. })
  171. const frVisibleItems = computed(() => {
  172. const count = Math.max(1, Math.floor((frListHeight.value - FR_HEAD_H) / FR_ROW_H))
  173. return [...frItems.value].reverse().slice(0, count)
  174. })
  175. // ── WebSocket real-time data ────────────────────────────
  176. const wsMarkPrice = ref('')
  177. const wsFundingRate = ref('')
  178. const wsFundingNextTime = ref(0)
  179. const recentTrades = ref<WsTradeItem[]>([])
  180. const rawBookAsks = ref<[number, number][]>([])
  181. const rawBookBids = ref<[number, number][]>([])
  182. const klineRealtimeClose = ref<number | null>(null)
  183. let countdownTimer: ReturnType<typeof setInterval> | null = null
  184. // 逐笔价格方向:当前价格 vs 上一次价格
  185. const tickPriceUp = ref(true)
  186. let prevTickClose = 0
  187. // klinecharts v10 real-time update callback (set via subscribeBar)
  188. let klineBarCallback: ((bar: any) => void) | null = null
  189. let bookRo: ResizeObserver | null = null
  190. function updateCountdown() {
  191. if (!wsFundingNextTime.value) return
  192. const diff = wsFundingNextTime.value - Date.now()
  193. if (diff <= 0) { fundingCountdown.value = '00:00:00'; return }
  194. const h = Math.floor(diff / 3_600_000)
  195. const m = Math.floor((diff % 3_600_000) / 60_000)
  196. const s = Math.floor((diff % 60_000) / 1000)
  197. fundingCountdown.value = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
  198. }
  199. const ws = useMarketWs({
  200. onConnected() {
  201. ws.requestKlines()
  202. },
  203. onKlineHistory(bars) {
  204. if (!bars.length || !chart) return
  205. currentBars = bars
  206. const period = RESOLUTION_PERIOD[klineResolution.value] ?? { type: 'minute', span: 1 }
  207. if (JSON.stringify(chart.getPeriod()) !== JSON.stringify(period)) {
  208. chart.setPeriod(period)
  209. } else {
  210. chart.resetData()
  211. }
  212. },
  213. onKline(bar) {
  214. // 与图表蜡烛方向同步:当前周期 close >= open 则绿,否则红
  215. tickPriceUp.value = bar.close >= bar.open
  216. klineRealtimeClose.value = bar.close
  217. klineBarCallback?.({
  218. timestamp: bar.timestamp,
  219. open: bar.open, high: bar.high, low: bar.low, close: bar.close, volume: bar.volume,
  220. })
  221. },
  222. onTicker(data) {
  223. const closeN = parseFloat(data.close)
  224. const openN = parseFloat(data.open)
  225. // kline 未到达前用 24h open/close 初始化方向
  226. if (prevTickClose === 0) tickPriceUp.value = closeN >= openN
  227. prevTickClose = closeN
  228. const chg = openN > 0 ? String((closeN - openN) / openN) : (ticker.value?.chg ?? '0')
  229. const change = openN > 0 ? String(closeN - openN) : (ticker.value?.change ?? '0')
  230. ticker.value = {
  231. symbol: symbol.value,
  232. close: data.close,
  233. open: data.open,
  234. high: data.high,
  235. low: data.low,
  236. volume: data.volume,
  237. turnover: data.turnover,
  238. chg,
  239. change,
  240. }
  241. },
  242. onMark(data) {
  243. wsMarkPrice.value = data.markPrice
  244. wsFundingRate.value = data.fundingRate
  245. wsFundingNextTime.value = data.nextFundingTime
  246. updateCountdown()
  247. },
  248. onTrade(item) {
  249. recentTrades.value.unshift(item)
  250. if (recentTrades.value.length > 40) recentTrades.value.length = 40
  251. },
  252. onKlineHistoryMore(bars) {
  253. const cb = loadMoreCallback
  254. loadMoreCallback = null
  255. if (!cb) return
  256. if (!bars.length) {
  257. cb([], false) // 服务端没有更早数据,more=false 告知图表停止请求
  258. return
  259. }
  260. const firstTs = currentBars[0]?.timestamp ?? Infinity
  261. const newBars = bars
  262. .filter(b => b.timestamp < firstTs)
  263. .sort((a, b) => a.timestamp - b.timestamp)
  264. if (newBars.length > 0) {
  265. currentBars = [...newBars, ...currentBars]
  266. }
  267. // 仅传 newBars,klinecharts forward 回调会将它们 prepend 到现有数据前面
  268. // more=true 继续允许加载更早数据,more=false 表示没有更早数据了
  269. cb(newBars, newBars.length > 0)
  270. },
  271. onBook(data) {
  272. const toPair = (rows: [string, string][]) =>
  273. rows
  274. .map(([p, q]) => [parseFloat(p), parseFloat(q)] as [number, number])
  275. .filter(([p, q]) => p > 0 && q > 0 && Number.isFinite(p) && Number.isFinite(q))
  276. rawBookAsks.value = toPair(data.asks).sort((a, b) => a[0] - b[0])
  277. rawBookBids.value = toPair(data.bids).sort((a, b) => b[0] - a[0])
  278. const addTotal = (rows: [string, string][]) => {
  279. let acc = 0
  280. return rows.map(([price, qty]) => {
  281. acc += parseFloat(qty)
  282. return { price, amount: qty, total: String(acc) }
  283. })
  284. }
  285. orderBook.value = {
  286. ask: { items: addTotal(data.asks) },
  287. bid: { items: addTotal(data.bids) },
  288. }
  289. },
  290. })
  291. // ── Chart ──────────────────────────────────────────────
  292. const chartEl = ref<HTMLDivElement | null>(null)
  293. let chart: ReturnType<typeof init> | null = null
  294. let ro: ResizeObserver | null = null
  295. // 向左滚动加载更多历史数据的待回调
  296. let loadMoreCallback: ((bars: any[], noMore: boolean) => void) | null = null
  297. const showMA = ref(true)
  298. const showVol = ref(true)
  299. const crosshairData = ref<any>(null)
  300. const activeDrawTool = ref('cursor')
  301. const chartWrapWidth = ref(0)
  302. const tooltipStyle = computed(() => {
  303. const x = crosshairData.value?.x ?? 0
  304. if (x > chartWrapWidth.value / 2) {
  305. return { left: '4px', right: 'auto' }
  306. }
  307. return { left: 'auto', right: '4px' }
  308. })
  309. const TF_OPTIONS = computed<{ label: string; value: KlineResolution }[]>(() => [
  310. { label: t('futuresPage.tf.m1'), value: '1' },
  311. { label: t('futuresPage.tf.m5'), value: '5' },
  312. { label: t('futuresPage.tf.m15'), value: '15' },
  313. { label: t('futuresPage.tf.m30'), value: '30' },
  314. { label: t('futuresPage.tf.h1'), value: '60' },
  315. { label: t('futuresPage.tf.h4'), value: '240' },
  316. { label: t('futuresPage.tf.d1'), value: '1D' },
  317. ])
  318. const DRAW_TOOLS: Record<string, string> = {
  319. trend: 'segment',
  320. hline: 'horizontalStraightLine',
  321. ray: 'rayLine',
  322. channel: 'priceChannelLine',
  323. fib: 'fibonacciLine',
  324. text: 'simpleAnnotation',
  325. }
  326. let currentBars: any[] = []
  327. const RESOLUTION_PERIOD: Record<string, { type: 'minute' | 'hour' | 'day'; span: number }> = {
  328. '1': { type: 'minute', span: 1 },
  329. '5': { type: 'minute', span: 5 },
  330. '15': { type: 'minute', span: 15 },
  331. '30': { type: 'minute', span: 30 },
  332. '60': { type: 'hour', span: 1 },
  333. '240': { type: 'hour', span: 4 },
  334. '1D': { type: 'day', span: 1 },
  335. }
  336. function convertKlines() {
  337. const seen = new Set<number>()
  338. return klines.value
  339. .map(b => ({
  340. timestamp: b.time > 1e10 ? b.time : b.time * 1000,
  341. open: parseFloat(String(b.open)),
  342. high: parseFloat(String(b.high)),
  343. low: parseFloat(String(b.low)),
  344. close: parseFloat(String(b.close)),
  345. volume: b.volume > 0 ? b.volume : Math.max(0.01, parseFloat(ticker.value?.volume || '1') / 200),
  346. }))
  347. .sort((a, b) => a.timestamp - b.timestamp)
  348. .filter(b => { if (seen.has(b.timestamp)) return false; seen.add(b.timestamp); return true })
  349. }
  350. function destroyChart() {
  351. ro?.disconnect(); ro = null
  352. if (chartEl.value) dispose(chartEl.value)
  353. chart = null; currentBars = []; klineBarCallback = null; loadMoreCallback = null
  354. }
  355. function initChart() {
  356. if (!chartEl.value) return
  357. chart = init(chartEl.value, {
  358. locale: locale.value.startsWith('zh') ? 'zh-CN' : 'en-US',
  359. timezone: 'Asia/Shanghai',
  360. styles: {
  361. grid: {
  362. horizontal: { color: 'rgba(255,255,255,0.04)', size: 1 },
  363. vertical: { color: 'rgba(255,255,255,0.04)', size: 1 },
  364. },
  365. candle: {
  366. type: 'candle_solid',
  367. bar: {
  368. upColor: '#02c076', downColor: '#f6465d', noChangeColor: '#888',
  369. upBorderColor: '#02c076', downBorderColor: '#f6465d', noChangeBorderColor: '#888',
  370. upWickColor: '#02c076', downWickColor: '#f6465d', noChangeWickColor: '#888',
  371. },
  372. priceMark: {
  373. high: { show: false },
  374. low: { show: false },
  375. last: {
  376. show: true,
  377. upColor: '#02c076', downColor: '#f6465d', noChangeColor: '#888',
  378. line: { show: true, style: 'dashed', dashedValue: [4, 4], size: 1 },
  379. text: { show: true, size: 11 },
  380. },
  381. },
  382. tooltip: { showRule: 'none', showType: 'standard' },
  383. },
  384. crosshair: {
  385. show: true,
  386. horizontal: {
  387. show: true,
  388. line: { show: true, style: 'dashed', dashedValue: [4, 2], size: 1, color: 'rgba(255,255,255,0.2)' },
  389. text: { show: true, size: 11, color: '#d1d1d2', backgroundColor: '#1d1d1d' },
  390. },
  391. vertical: {
  392. show: true,
  393. line: { show: true, style: 'dashed', dashedValue: [4, 2], size: 1, color: 'rgba(255,255,255,0.2)' },
  394. text: { show: true, size: 11, color: '#d1d1d2', backgroundColor: '#1d1d1d' },
  395. },
  396. },
  397. xAxis: {
  398. axisLine: { show: true, color: 'rgba(255,255,255,0.07)', size: 1 },
  399. tickText: { show: true, color: '#6a7182', size: 11 },
  400. tickLine: { show: true, color: 'rgba(255,255,255,0.07)', size: 1, length: 3 },
  401. },
  402. yAxis: {
  403. axisLine: { show: true, color: 'rgba(255,255,255,0.07)', size: 1 },
  404. tickText: { show: true, color: '#6a7182', size: 11 },
  405. tickLine: { show: true, color: 'rgba(255,255,255,0.07)', size: 1, length: 3 },
  406. },
  407. separator: { color: '#2a2a2a', size: 3 },
  408. indicator: { tooltip: { showRule: 'none' } },
  409. overlay: {
  410. point: {
  411. color: '#f0b90b', borderColor: 'rgba(240,185,11,0.35)',
  412. activeColor: '#f0b90b', activeBorderColor: 'rgba(240,185,11,0.6)',
  413. borderSize: 1, radius: 4, activeBorderSize: 2, activeRadius: 5,
  414. },
  415. line: { color: '#f0b90b', size: 1, style: 'solid' },
  416. text: { color: '#f0b90b', size: 12 },
  417. },
  418. },
  419. })
  420. if (!chart) return
  421. if (showMA.value) {
  422. chart.createIndicator({
  423. name: 'MA',
  424. calcParams: [5, 10, 30],
  425. styles: { lines: [{ color: '#f0b90b', size: 1 }, { color: '#5d9cf5', size: 1 }, { color: '#e84d5b', size: 1 }] },
  426. }, { isStack: true, pane: { id: 'candle_pane' } })
  427. }
  428. if (showVol.value) {
  429. chart.createIndicator(
  430. { name: 'VOL', calcParams: [], styles: {
  431. bars: [{ style: 'fill' }, { style: 'fill' }],
  432. 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)' }],
  433. lastValueMark: { show: false },
  434. tooltip: { showRule: 'none' },
  435. }},
  436. { isStack: false, pane: { id: 'vol-pane', height: 80, minHeight: 40 } },
  437. )
  438. }
  439. chart.setSymbol({
  440. name: displaySymbolNoDash.value,
  441. pricePrecision: futuresPriceDecimals(),
  442. volumePrecision: 4,
  443. })
  444. chart.setPeriod(RESOLUTION_PERIOD[klineResolution.value] ?? { type: 'minute', span: 1 })
  445. currentBars = convertKlines()
  446. chart.setDataLoader({
  447. getBars: (params: any) => {
  448. if (params.type === 'init') {
  449. // 初始加载:backward=false(实时由subscribeBar处理),forward=true(可向左请求更多历史)
  450. params.callback(currentBars, { backward: false, forward: true })
  451. return
  452. }
  453. if (params.type === 'backward') {
  454. // 用户滚到右侧最新位置,subscribeBar 已处理实时,这里不重复
  455. params.callback([], false)
  456. return
  457. }
  458. // type === 'forward':用户滚到左侧最早位置,params.timestamp 是最旧 bar 的时间戳
  459. if (!params.timestamp) {
  460. params.callback([], false)
  461. return
  462. }
  463. const to = params.timestamp as number
  464. const from = to - (BAR_MS[klineResolution.value] ?? BAR_MS['60']) * BAR_COUNT
  465. loadMoreCallback = params.callback
  466. ws.requestKlinesRange(from, to)
  467. },
  468. subscribeBar: (params: any) => { klineBarCallback = params.callback },
  469. unsubscribeBar: () => { klineBarCallback = null },
  470. })
  471. chart.subscribeAction('onCrosshairChange', (data: any) => {
  472. if (!data?.x || !data?.paneId) { crosshairData.value = null; return }
  473. // v10-beta bug: kLineData is not passed to the action callback, retrieve it manually
  474. const pts = (chart as any).convertFromPixel([{ x: data.x, y: data.y ?? 0 }])
  475. const ts: number | undefined = Array.isArray(pts) ? pts[0]?.timestamp : (pts as any)?.timestamp
  476. if (!ts) { crosshairData.value = null; return }
  477. const dataList: any[] = (chart as any).getDataList?.() ?? []
  478. const kLineData = dataList.find((b: any) => b.timestamp === ts)
  479. crosshairData.value = kLineData ? { ...data, kLineData } : null
  480. })
  481. ro = new ResizeObserver(() => {
  482. chart?.resize()
  483. chartWrapWidth.value = chartEl.value?.offsetWidth ?? 0
  484. })
  485. ro.observe(chartEl.value!)
  486. chartWrapWidth.value = chartEl.value.offsetWidth
  487. // 限制向右最多留 2 根 K 线的空白(约 20px),防止用户拖出当前时间太远
  488. chart.setMaxOffsetRightDistance(20)
  489. }
  490. function updateChart() {
  491. if (!chart || !klines.value.length) return
  492. currentBars = convertKlines()
  493. const period = RESOLUTION_PERIOD[klineResolution.value] ?? { type: 'minute', span: 1 }
  494. if (JSON.stringify(chart.getPeriod()) !== JSON.stringify(period)) {
  495. chart.setPeriod(period)
  496. } else {
  497. chart.resetData()
  498. }
  499. }
  500. function clearOverlays() { chart?.removeOverlay() }
  501. function zoomOut() { (chart as any)?.zoomAtCoordinate?.(0.8) }
  502. function activateDrawTool(tool: string) {
  503. activeDrawTool.value = tool
  504. if (!chart || tool === 'cursor') return
  505. const name = DRAW_TOOLS[tool]
  506. if (name) chart.createOverlay({ name })
  507. }
  508. watch(klines, updateChart)
  509. watch(showMA, (show) => {
  510. if (!chart) return
  511. if (show) {
  512. 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' } })
  513. } else {
  514. chart.removeIndicator({ name: 'MA', paneId: 'candle_pane' })
  515. }
  516. })
  517. watch(showVol, (show) => {
  518. if (!chart) return
  519. if (show) {
  520. chart.createIndicator(
  521. { name: 'VOL', calcParams: [], styles: {
  522. bars: [{ style: 'fill' }, { style: 'fill' }],
  523. 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)' }],
  524. lastValueMark: { show: false },
  525. tooltip: { showRule: 'none' },
  526. }},
  527. { isStack: false, pane: { id: 'vol-pane', height: 80, minHeight: 40 } },
  528. )
  529. } else {
  530. chart.removeIndicator({ name: 'VOL', paneId: 'vol-pane' })
  531. }
  532. })
  533. // ── Pair selector ──────────────────────────────────────
  534. const filteredTickers = computed(() => {
  535. const q = pairSearch.value.toLowerCase()
  536. return allTickers.value.filter(t => t.symbol.toLowerCase().includes(q))
  537. })
  538. function coinIcon(sym: string): string {
  539. return coinsStore.coins.find(c => c.symbol === sym)?.icon ?? ''
  540. }
  541. const currentCoinIcon = computed(() => coinIcon(symbol.value))
  542. function selectPair(sym: string) {
  543. showPairSelector.value = false
  544. pairSearch.value = ''
  545. const routeSym = futuresPairToRouteParam(sym)
  546. router.push({ name: 'futures', params: { symbol: routeSym } })
  547. }
  548. // ── Trading panel ──────────────────────────────────────
  549. const side = ref<'buy' | 'sell'>('buy')
  550. const orderType = ref<'limit' | 'market' | 'conditional'>('limit')
  551. const conditionalSubType = ref<'market' | 'limit'>('market')
  552. const entrustPrice = ref('')
  553. const triggerPrice = ref('')
  554. const volume = ref('')
  555. const pctSlider = ref(0)
  556. const activeTab = ref<'positions' | 'orders' | 'history-positions' | 'history-orders' | 'trades' | 'funding' | 'assets'>('positions')
  557. /**
  558. * 限价委托价:用全市场行情表里当前币对的 close。
  559. * 切换币对时合约 WS 的 ticker 往往还是上一币对的快照,不能直接用 ticker.close 回填。
  560. */
  561. function syncEntrustPriceFromCoinList(): boolean {
  562. const sym = symbol.value
  563. const row = allTickers.value.find((x) => x.symbol === sym)
  564. if (row?.close && parseFloat(row.close) > 0) {
  565. entrustPrice.value = row.close
  566. return true
  567. }
  568. return false
  569. }
  570. // ── 底部标签页无限滚动 ──────────────────────────────────
  571. const tabScrollRef = ref<HTMLElement | null>(null)
  572. let tabScrollLoading = false
  573. function onTabScroll() {
  574. const el = tabScrollRef.value
  575. if (!el || tabScrollLoading) return
  576. if (el.scrollHeight - el.scrollTop - el.clientHeight < 100) {
  577. tabScrollLoading = true
  578. const tab = activeTab.value
  579. let promise: Promise<void> | undefined
  580. if (tab === 'history-positions') promise = store.fetchHistoryPositions()
  581. else if (tab === 'history-orders') promise = store.fetchHistoryOrders()
  582. else if (tab === 'trades') promise = store.fetchHistoryOrders()
  583. else if (tab === 'funding') promise = store.fetchFundingRecords()
  584. ;(promise ?? Promise.resolve()).finally(() => { tabScrollLoading = false })
  585. }
  586. }
  587. // ── 轮询:登录时每 3 秒刷新持仓和当前委托 ──────────────
  588. let pollTimer: ReturnType<typeof setInterval> | null = null
  589. function startPolling() {
  590. if (pollTimer !== null) return
  591. pollTimer = setInterval(() => {
  592. store.fetchPositions()
  593. store.fetchCurrentOrders()
  594. }, 3000)
  595. }
  596. function stopPolling() {
  597. if (pollTimer !== null) { clearInterval(pollTimer); pollTimer = null }
  598. }
  599. // ── Mobile ──────────────────────────────────────────────
  600. const isMobile = ref(typeof window !== 'undefined' && window.innerWidth <= 768)
  601. const mobileTab = ref<'chart' | 'trade'>('chart')
  602. function onWindowResize() {
  603. isMobile.value = window.innerWidth <= 768
  604. recalcBookRowCount()
  605. }
  606. // ── Transfer modal ──────────────────────────────────────
  607. // 资金 ↔ 合约 / 跟单(与资产页、transfer store 一致)
  608. type AccountType = 'SPOT' | 'SWAP' | 'FOLLOW' | 'SPOT_TRADING'
  609. const TRANSFER_FOLLOW_IDX = 1
  610. /** 合约页划转可选的非资金账户(不含 iBit 质押) */
  611. const FUTURES_OTHER_WALLET_TYPES: Exclude<AccountType, 'SPOT'>[] = ['SWAP', 'FOLLOW', 'SPOT_TRADING']
  612. function transferWalletLabel(kind: AccountType): string {
  613. return walletTypeLabel(t, kind)
  614. }
  615. const showTransfer = ref(false)
  616. const transferFrom = ref<AccountType>('SPOT')
  617. const transferTo = ref<AccountType>('SWAP')
  618. const transferAmount = ref('')
  619. const transferAvail = ref('0.00000000')
  620. const transferLoading = ref(false)
  621. const transferSubmitting = ref(false)
  622. const transferError = ref('')
  623. const showFromDrop = ref(false)
  624. const showToDrop = ref(false)
  625. function selectFromOther(v: AccountType) {
  626. transferFrom.value = v
  627. transferTo.value = 'SPOT'
  628. showFromDrop.value = false
  629. transferAmount.value = ''
  630. fetchTransferAvail()
  631. }
  632. function selectToOther(v: AccountType) {
  633. transferTo.value = v
  634. transferFrom.value = 'SPOT'
  635. showToDrop.value = false
  636. transferAmount.value = ''
  637. fetchTransferAvail()
  638. }
  639. function swapTransferDirection() {
  640. const [a, b] = normalizeTransferPair(transferTo.value, transferFrom.value)
  641. transferFrom.value = a
  642. transferTo.value = b
  643. transferAmount.value = ''
  644. showFromDrop.value = false
  645. showToDrop.value = false
  646. fetchTransferAvail()
  647. }
  648. // 统一用 /swap/wallet-new/get(含 SPOT / SWAP / 跟单 accountInfoList);现货账户单独请求
  649. async function fetchTransferAvail() {
  650. if (transferFrom.value === 'SPOT') {
  651. transferAvail.value = spotAvailBalance.value
  652. } else if (transferFrom.value === 'SWAP') {
  653. transferAvail.value = swapAvailBalance.value
  654. } else {
  655. transferAvail.value = '0'
  656. }
  657. transferLoading.value = true
  658. try {
  659. const data = await get<any>('/swap/wallet-new/get')
  660. if (transferFrom.value === 'SPOT') {
  661. transferAvail.value = data?.spotWallet?.balance ?? spotAvailBalance.value
  662. } else if (transferFrom.value === 'SWAP') {
  663. transferAvail.value = data?.availableBalance ?? swapAvailBalance.value
  664. } else if (transferFrom.value === 'SPOT_TRADING') {
  665. try {
  666. const raw = await get<unknown>('spot/v1/account/asset/list')
  667. transferAvail.value = extractUsdtAvailableFromSpotAssets(raw)
  668. } catch {
  669. transferAvail.value = '0'
  670. }
  671. } else {
  672. transferAvail.value = data?.accountInfoList?.[TRANSFER_FOLLOW_IDX]?.balance ?? '0'
  673. }
  674. } catch (e) {
  675. console.error('[transfer] fetchTransferAvail error:', e)
  676. } finally {
  677. transferLoading.value = false
  678. }
  679. }
  680. watch(showTransfer, (v) => {
  681. if (v) {
  682. transferAmount.value = ''
  683. transferError.value = ''
  684. fetchTransferAvail()
  685. }
  686. })
  687. async function confirmTransfer() {
  688. const [fromWallet, toWallet] = normalizeTransferPair(transferFrom.value, transferTo.value)
  689. transferFrom.value = fromWallet
  690. transferTo.value = toWallet
  691. const amt = parseFloat(transferAmount.value)
  692. if (!transferAmount.value || isNaN(amt) || amt <= 0) {
  693. transferError.value = t('spot.transferModal.invalidAmount')
  694. return
  695. }
  696. const avail = parseFloat(transferAvail.value)
  697. if (amt > avail) {
  698. transferError.value = t('spot.transferModal.insufficientBalance')
  699. return
  700. }
  701. transferSubmitting.value = true
  702. transferError.value = ''
  703. try {
  704. if (fromWallet === 'SPOT_TRADING' || toWallet === 'SPOT_TRADING') {
  705. const direction = fromWallet === 'SPOT' ? 1 : 2
  706. await post('/spot/v1/account/transfer', {
  707. symbol: 'USDT',
  708. amount: amt,
  709. direction,
  710. })
  711. } else {
  712. await postForm('/uc/asset/transfer', {
  713. amount: transferAmount.value,
  714. from: fromWallet,
  715. to: toWallet,
  716. })
  717. }
  718. transferAmount.value = ''
  719. showTransfer.value = false
  720. // 刷新合约钱包余额
  721. store.fetchPositions?.().catch(() => {})
  722. } catch (e: any) {
  723. transferError.value = e?.message ?? t('spot.transferModal.transferFailed')
  724. } finally {
  725. transferSubmitting.value = false
  726. }
  727. }
  728. // ── TP/SL modify modal ──────────────────────────────────
  729. const showTpSlModal = ref(false)
  730. const tpSlPositionId = ref('')
  731. const tpSlProfitPrice = ref('')
  732. const tpSlLossPrice = ref('')
  733. const tpSlError = ref('')
  734. // 当前正在编辑的仓位(从 positions 中反查)
  735. const tpSlPosition = computed(() => positions.value.find(p => p.id === tpSlPositionId.value) ?? null)
  736. /** 止盈止损弹窗:仅当前页合约与仓位一致时使用 WS 标记价,否则用仓位接口自带的标记价 */
  737. const tpSlMarkPriceStr = computed(() => {
  738. const pos = tpSlPosition.value
  739. if (!pos) return ''
  740. const posSym = pos.coin?.symbol ?? ''
  741. const samePair = posSym.length > 0 && futuresPairKey(posSym) === futuresPairKey(symbol.value)
  742. if (samePair && wsMarkPrice.value) {
  743. return wsMarkPrice.value
  744. }
  745. return pos.currentPrice ?? ''
  746. })
  747. function openTpSlModal(positionId: string, fallbackProfit?: string, fallbackLoss?: string) {
  748. tpSlPositionId.value = positionId
  749. const pos = positions.value.find(p => p.id === positionId)
  750. const cut0 = pos?.cutList?.[0]
  751. // 优先从仓位 cutList 读;没有则用 fallback(从委托行传入)
  752. tpSlProfitPrice.value = (cut0?.profitPrice && parseFloat(String(cut0.profitPrice)) > 0)
  753. ? String(cut0.profitPrice)
  754. : (fallbackProfit && parseFloat(fallbackProfit) > 0 ? fallbackProfit : '')
  755. tpSlLossPrice.value = (cut0?.lossPrice && parseFloat(String(cut0.lossPrice)) > 0)
  756. ? String(cut0.lossPrice)
  757. : (fallbackLoss && parseFloat(fallbackLoss) > 0 ? fallbackLoss : '')
  758. tpSlError.value = ''
  759. showTpSlModal.value = true
  760. }
  761. async function confirmTpSl() {
  762. tpSlError.value = ''
  763. const ok = await store.modifyTpSl(tpSlPositionId.value, tpSlProfitPrice.value, tpSlLossPrice.value)
  764. if (ok) {
  765. showTpSlModal.value = false
  766. } else {
  767. tpSlError.value = store.error || t('futuresPage.errors.modifyTpSlFailed')
  768. }
  769. }
  770. // ── 详情弹窗 ────────────────────────────────────────────
  771. const showPosDetail = ref(false)
  772. const showOrdDetail = ref(false)
  773. const showHistPosDetail = ref(false)
  774. const showHistOrdDetail = ref(false)
  775. const detailPos = ref<Position | null>(null)
  776. const detailOrd = ref<ContractOrder | null>(null)
  777. const detailHistPos = ref<HistoryPosition | null>(null)
  778. const detailHistOrd = ref<HistoryOrder | null>(null)
  779. function openPosDetail(pos: Position) { detailPos.value = pos; showPosDetail.value = true }
  780. function openOrdDetail(ord: ContractOrder) { detailOrd.value = ord; showOrdDetail.value = true }
  781. function openHistPosDetail(pos: HistoryPosition) { detailHistPos.value = pos; showHistPosDetail.value = true }
  782. function openHistOrdDetail(ord: HistoryOrder) { detailHistOrd.value = ord; showHistOrdDetail.value = true }
  783. // 仓位类型标签(全仓/分仓/体验金)
  784. function posTypeLabel(positionType: any, patterns: any, experienceRecordId?: any): string {
  785. if (experienceRecordId && String(experienceRecordId) !== 'null' && String(experienceRecordId) !== '') {
  786. return t('futuresPage.margin.experience')
  787. }
  788. const pt = String(positionType ?? '').toUpperCase()
  789. if (pt === '0' || pt === 'INTEGRAL') {
  790. return t('futuresPage.margin.cross')
  791. }
  792. if (pt === '1' || pt === 'SEPARATE') {
  793. return t('futuresPage.margin.split')
  794. }
  795. const p = String(patterns ?? '').toUpperCase()
  796. if (p === 'CROSSED') {
  797. return t('futuresPage.margin.cross')
  798. }
  799. if (p === 'ISOLATED' || p === 'SEPARATE') {
  800. return t('futuresPage.margin.split')
  801. }
  802. return t('futuresPage.margin.cross')
  803. }
  804. // 历史仓位符号:兼容嵌套 coin 对象和平铺字段
  805. function histPosSymbol(pos: HistoryPosition): string {
  806. const s = (pos.coin?.symbol ?? pos.symbol ?? pos.coinSymbol ?? '').replace('/', '-')
  807. return s || '--'
  808. }
  809. // 历史委托符号
  810. function histOrdSymbol(ord: HistoryOrder): string {
  811. return (ord.symbol ?? ord.coinSymbol ?? '').replace('/', '-') || '--'
  812. }
  813. // 历史仓位开仓均价
  814. function histPosOpenPrice(pos: HistoryPosition): string {
  815. return fmtPrice(pos.openPrice ?? pos.usdtOpenPrice ?? '0')
  816. }
  817. // 历史仓位平仓均价
  818. function histPosClosePrice(pos: HistoryPosition): string {
  819. const p = pos.closePrice ?? pos.tradedPrice ?? '0'
  820. return parseFloat(p) > 0 ? fmtPrice(p) : '--'
  821. }
  822. // 历史仓位委托总量
  823. function histPosTotalVol(pos: HistoryPosition): string {
  824. return String(pos.totalPosition ?? pos.volume ?? pos.tradedVolume ?? '--')
  825. }
  826. // 历史仓位已成交量
  827. function histPosDealVol(pos: HistoryPosition): string {
  828. return String(pos.closedPosition ?? pos.tradedVolume ?? pos.dealVolume ?? '--')
  829. }
  830. // 历史仓位盈亏
  831. function histPosProfit(pos: HistoryPosition): number {
  832. return parseFloat(String(pos.usdtProfit ?? pos.profitAndLoss ?? '0'))
  833. }
  834. // 历史仓位保证金
  835. function histPosPrincipal(pos: HistoryPosition): number {
  836. return parseFloat(String(pos.totalPrincipalAmount ?? pos.principalAmount ?? '0'))
  837. }
  838. // 历史仓位开仓时间
  839. function histPosOpenTime(pos: HistoryPosition): string {
  840. const t = pos.openTime ?? pos.usdtOpenTime ?? pos.createTime
  841. return t ? fmtTime(t) : '--'
  842. }
  843. // 历史仓位平仓时间
  844. function histPosCloseTime(pos: HistoryPosition): string {
  845. const t = pos.closeTime ?? pos.dealTime
  846. return t ? fmtTime(t) : '--'
  847. }
  848. // 历史仓位收益率
  849. function histPosProfitRate(pos: HistoryPosition): string {
  850. const pnl = histPosProfit(pos)
  851. const margin = histPosPrincipal(pos)
  852. if (!margin) return '--'
  853. const r = pnl / margin * 100
  854. return (r >= 0 ? '+' : '') + r.toFixed(2) + '%'
  855. }
  856. // 历史委托开仓均价(开仓单用 tradedPrice;平仓单用 usdtOpenPrice)
  857. function histOrdAvgOpenPrice(ord: HistoryOrder): string {
  858. const isClose = String(ord.entrustType ?? '').toUpperCase() === 'CLOSE' || String(ord.entrustType) === '1'
  859. const p = isClose ? (ord.usdtOpenPrice ?? ord.openPrice ?? '0') : (ord.tradedPrice ?? '0')
  860. return parseFloat(p) > 0 ? fmtPrice(p) : '--'
  861. }
  862. // 历史委托委托价格(市价→"市价";限价/计划→显示价格)
  863. function histOrdEntrustPriceDisplay(ord: HistoryOrder): string {
  864. return histOrdEntrustPrice(ord)
  865. }
  866. // 基础货币(BTC/ETH 等)
  867. function baseCoinFromSym(sym: string): string {
  868. if (sym.includes('/')) return sym.split('/')[0]
  869. return sym.replace(/USDT$|BUSD$/, '') || sym
  870. }
  871. function txTypeName(ty: number) {
  872. const key = `futuresPage.fundingTx.${ty}`
  873. if (te(key)) {
  874. return t(key)
  875. }
  876. return t('futuresPage.errors.txFallback', { n: ty })
  877. }
  878. /** 资金流水币种:后端 symbol 含「永续」,按七种语言展示 */
  879. function formatFundingSymbol(sym?: string): string {
  880. const raw = (sym || 'USDT').trim()
  881. return raw.replace(/永续/g, t('futuresPage.perpetualTag'))
  882. }
  883. // 合约页资金流水:前端兜底移除现货相关类型
  884. const SPOT_RELATED_TX_TYPES = new Set<number>([3, 43, 44, 45, 46, 48])
  885. const contractFundingRecords = computed(() =>
  886. fundingRecords.value.filter(rec => !SPOT_RELATED_TX_TYPES.has(Number(rec.type))),
  887. )
  888. const baseCurrency = computed(() => symbol.value.split('/')[0] ?? 'BTC')
  889. const quoteCurrency = computed(() => symbol.value.split('/')[1] ?? 'USDT')
  890. /** 合约代码规范化比对(BTC/USDT、BTC-USDT → BTCUSDT) */
  891. function futuresPairKey(sym: string): string {
  892. return sym.replace(/\//g, '').replace(/-/g, '').trim().toUpperCase()
  893. }
  894. const displaySymbolNoDash = computed(() => symbol.value.replace('/', ''))
  895. async function submitOrder() {
  896. if (!volume.value) return
  897. const baseVol = toBaseVolume(volume.value)
  898. if (!baseVol) {
  899. store.error = orderRefPrice() <= 0
  900. ? (orderType.value === 'conditional' && conditionalSubType.value === 'market'
  901. ? t('futuresPage.errors.enterTriggerPrice')
  902. : orderType.value !== 'market' ? t('futuresPage.errors.enterPrice') : t('futuresPage.errors.priceNotReadyConvert'))
  903. : t('futuresPage.errors.invalidQtyCheckMin')
  904. return
  905. }
  906. // type: 0=市价 1=限价 2=计划委托(与 Android OpenEntrustType 一致)
  907. let type: 0 | 1 | 2 = 1
  908. let ep: string | undefined
  909. let tp: string | undefined
  910. if (orderType.value === 'limit') {
  911. type = 1; ep = entrustPrice.value
  912. } else if (orderType.value === 'market') {
  913. type = 0
  914. } else {
  915. // 条件委托:触发价必填
  916. if (!triggerPrice.value) {
  917. store.error = t('futuresPage.errors.enterTriggerPrice')
  918. return
  919. }
  920. tp = triggerPrice.value
  921. if (conditionalSubType.value === 'limit') {
  922. type = 2; ep = entrustPrice.value
  923. } else {
  924. // 市价委托:entrustPrice = triggerPrice(后端要求必填,触发时以该价格挂限价单)
  925. type = 2; ep = triggerPrice.value
  926. }
  927. }
  928. const ok = await store.placeOrder({
  929. direction: side.value === 'buy' ? 0 : 1, // 0=开多 1=开空
  930. type,
  931. entrustPrice: ep,
  932. triggerPrice: tp,
  933. volume: baseVol,
  934. })
  935. if (ok) { volume.value = ''; triggerPrice.value = '' }
  936. }
  937. function setLatestPrice() {
  938. if (ticker.value) entrustPrice.value = ticker.value.close
  939. }
  940. // 订单簿点击 → 回填到当前编辑的价格输入框:
  941. // - 平仓 sheet 打开时,回填到平仓价
  942. // - 否则若选中的是限价/条件限价,回填到 entrustPrice;条件市价回填到触发价
  943. function onBookClick(price: string) {
  944. if (showCloseSheet.value && !closeIsMarket.value) {
  945. closeSheetPrice.value = price
  946. return
  947. }
  948. if (orderType.value === 'limit') {
  949. entrustPrice.value = price
  950. } else if (orderType.value === 'conditional') {
  951. if (conditionalSubType.value === 'limit') entrustPrice.value = price
  952. else triggerPrice.value = price
  953. }
  954. }
  955. // ── Close position sheet (从仓位卡片调起) ──────────────────
  956. const showCloseSheet = ref(false)
  957. const closeTargetPos = ref<import('@/stores/futures').Position | null>(null)
  958. const closeIsMarket = ref(true)
  959. const closeSheetPrice = ref('')
  960. const closeSheetVolume = ref('')
  961. const closeSheetPct = ref(0)
  962. const closeSheetError = ref('')
  963. function openCloseSheet(pos: import('@/stores/futures').Position) {
  964. closeTargetPos.value = pos
  965. closeIsMarket.value = true
  966. const posSym = pos.coin?.symbol ?? ''
  967. const samePair = posSym.length > 0 && futuresPairKey(posSym) === futuresPairKey(symbol.value)
  968. closeSheetPrice.value = samePair ? (ticker.value?.close ?? pos.currentPrice ?? '') : (pos.currentPrice ?? '')
  969. closeSheetVolume.value = ''
  970. closeSheetPct.value = 0
  971. closeSheetError.value = ''
  972. showCloseSheet.value = true
  973. }
  974. watch(closeSheetPct, (pct) => {
  975. if (!closeTargetPos.value) return
  976. const avail = parseFloat(closeTargetPos.value.availablePosition) || 0
  977. if (!avail || pct === 0) { closeSheetVolume.value = ''; return }
  978. closeSheetVolume.value = ((pct / 100) * avail).toFixed(4)
  979. })
  980. /** 平仓弹窗展示的 base/quote 以目标仓位为准,避免多仓时沿用当前 K 线交易对 */
  981. const closeSheetBaseCurrency = computed(() => {
  982. const raw = closeTargetPos.value?.coin?.symbol ?? ''
  983. const b = raw.split('/')[0]?.trim()
  984. return b || baseCurrency.value
  985. })
  986. const closeSheetQuoteCurrency = computed(() => {
  987. const raw = closeTargetPos.value?.coin?.symbol ?? ''
  988. const q = raw.split('/')[1]?.trim()
  989. return q || quoteCurrency.value
  990. })
  991. /** 平仓弹窗标记价:仅 WS 订阅与待平仓位合约一致时使用 wsMarkPrice,否则用仓位 currentPrice */
  992. const closeSheetMarkPriceStr = computed(() => {
  993. const pos = closeTargetPos.value
  994. if (!pos) return ''
  995. const posSym = pos.coin?.symbol ?? ''
  996. const samePair = posSym.length > 0 && futuresPairKey(posSym) === futuresPairKey(symbol.value)
  997. if (samePair && wsMarkPrice.value) {
  998. return wsMarkPrice.value
  999. }
  1000. return pos.currentPrice ?? ''
  1001. })
  1002. const closeSheetEstPnl = computed(() => {
  1003. const pos = closeTargetPos.value
  1004. if (!pos) return 0
  1005. const vol = parseFloat(closeSheetVolume.value) || 0
  1006. if (!vol) return 0
  1007. const isLong = pos.direction === 'BUY'
  1008. const refPrice = closeIsMarket.value
  1009. ? (parseFloat(closeSheetMarkPriceStr.value) || parseFloat(pos.currentPrice) || 0)
  1010. : (parseFloat(closeSheetPrice.value) || 0)
  1011. if (!refPrice) return 0
  1012. const open = parseFloat(pos.openPrice) || 0
  1013. const diff = isLong ? (refPrice - open) : (open - refPrice)
  1014. return diff * vol
  1015. })
  1016. async function confirmClose() {
  1017. const pos = closeTargetPos.value
  1018. if (!pos) return
  1019. const vol = closeSheetVolume.value
  1020. const volNum = parseFloat(vol)
  1021. const avail = parseFloat(pos.availablePosition) || 0
  1022. if (!vol || isNaN(volNum) || volNum <= 0) {
  1023. closeSheetError.value = t('futuresPage.errors.enterQty')
  1024. return
  1025. }
  1026. if (volNum > avail) {
  1027. closeSheetError.value = t('futuresPage.errors.qtyExceedAvail')
  1028. return
  1029. }
  1030. let ok = false
  1031. if (closeIsMarket.value) {
  1032. ok = await store.closePosition(pos.id, vol)
  1033. } else {
  1034. if (!closeSheetPrice.value) {
  1035. closeSheetError.value = t('futuresPage.errors.enterPrice')
  1036. return
  1037. }
  1038. ok = await store.closeLimitPosition(pos.id, closeSheetPrice.value, vol)
  1039. }
  1040. if (ok) {
  1041. showCloseSheet.value = false
  1042. closeTargetPos.value = null
  1043. } else {
  1044. closeSheetError.value = store.error || t('futuresPage.errors.closeFailed')
  1045. }
  1046. }
  1047. // ── 二次确认(替代原生 confirm,与主题一致)────────────────
  1048. const showActionConfirm = ref(false)
  1049. const actionConfirmTitle = ref('')
  1050. const actionConfirmMessage = ref('')
  1051. const actionConfirmLoading = ref(false)
  1052. let pendingActionConfirm: (() => Promise<unknown>) | null = null
  1053. function openActionConfirm(title: string, message: string, action: () => Promise<unknown>) {
  1054. actionConfirmTitle.value = title || t('futuresPage.modal.confirmDefaultTitle')
  1055. actionConfirmMessage.value = message
  1056. pendingActionConfirm = action
  1057. showActionConfirm.value = true
  1058. }
  1059. function closeActionConfirm() {
  1060. showActionConfirm.value = false
  1061. pendingActionConfirm = null
  1062. actionConfirmLoading.value = false
  1063. }
  1064. async function onActionConfirmOk() {
  1065. const fn = pendingActionConfirm
  1066. if (!fn) {
  1067. closeActionConfirm()
  1068. return
  1069. }
  1070. actionConfirmLoading.value = true
  1071. try {
  1072. await fn()
  1073. } finally {
  1074. closeActionConfirm()
  1075. }
  1076. }
  1077. function onActionConfirmCancel() {
  1078. if (actionConfirmLoading.value) return
  1079. closeActionConfirm()
  1080. }
  1081. // ── Reverse position confirm ────────────────────────────
  1082. function reversePosition(pos: import('@/stores/futures').Position) {
  1083. openActionConfirm(
  1084. t('futuresPage.confirm.reverseTitle'),
  1085. t('futuresPage.confirm.reverseBody', {
  1086. pair: pos.coin?.symbol ?? '--',
  1087. side: pos.direction === 'BUY' ? t('futuresPage.confirm.sideLongWord') : t('futuresPage.confirm.sideShortWord'),
  1088. }),
  1089. () => store.reversePosition(pos.id),
  1090. )
  1091. }
  1092. // ── Cancel all orders confirm ───────────────────────────
  1093. function cancelAllOrders() {
  1094. if (!currentOrders.value.length) return
  1095. const n = currentOrders.value.length
  1096. openActionConfirm(
  1097. t('futuresPage.bottom.cancelAllOrders'),
  1098. t('futuresPage.confirm.cancelAllBody', { n }),
  1099. () => store.cancelAllOrders(),
  1100. )
  1101. }
  1102. // ── Close all positions confirm ─────────────────────────
  1103. function confirmCloseAll() {
  1104. if (!positions.value.length) return
  1105. const n = positions.value.length
  1106. openActionConfirm(
  1107. t('futuresPage.bottom.closeAllPositions'),
  1108. t('futuresPage.confirm.closeAllBody', { n }),
  1109. () => store.closeAllPositions(),
  1110. )
  1111. }
  1112. // ── Margin mode (全仓/分仓;对齐 flutter,后端只支持 positionType=0/1) ──
  1113. const MARGIN_MODES = computed<{ value: 'cross' | 'split'; label: string }[]>(() => [
  1114. { value: 'cross', label: t('futuresPage.margin.cross') },
  1115. { value: 'split', label: t('futuresPage.margin.split') },
  1116. ])
  1117. const marginModeLabel = computed(() =>
  1118. MARGIN_MODES.value.find(m => m.value === marginMode.value)?.label ?? t('futuresPage.margin.cross'))
  1119. async function selectMarginMode(mode: 'cross' | 'split') {
  1120. showMarginDrop.value = false
  1121. if (mode === marginMode.value) return
  1122. await store.setMarginMode(mode)
  1123. }
  1124. // ── Amount unit (张/USDT/币) ─────────────────────────────
  1125. const AMOUNT_UNITS = computed<{ value: 'lots' | 'usdt' | 'base'; label: string }[]>(() => [
  1126. { value: 'lots', label: t('futuresPage.amountUnit.lots') },
  1127. { value: 'usdt', label: t('futuresPage.amountUnit.usdt') },
  1128. { value: 'base', label: 'BASE' },
  1129. ])
  1130. const amountUnitLabel = computed(() => {
  1131. const u = amountUnit.value
  1132. if (u === 'lots') {
  1133. return t('futuresPage.amountUnit.lots')
  1134. }
  1135. if (u === 'usdt') {
  1136. return t('futuresPage.amountUnit.usdt')
  1137. }
  1138. return baseCurrency.value
  1139. })
  1140. function selectAmountUnit(u: 'lots' | 'usdt' | 'base') {
  1141. showAmountUnitDrop.value = false
  1142. store.setAmountUnit(u)
  1143. volume.value = ''
  1144. pctSlider.value = 0
  1145. }
  1146. /** 与 flutter futures_screen._refPrice 一致:市价→最新价;限价/条件限价→委托价;条件市价→触发价 */
  1147. function orderRefPrice(): number {
  1148. if (orderType.value === 'market') {
  1149. return parseFloat(ticker.value?.close ?? '0') || 0
  1150. }
  1151. if (orderType.value === 'limit') {
  1152. return parseFloat(entrustPrice.value) || 0
  1153. }
  1154. if (conditionalSubType.value === 'limit') {
  1155. return parseFloat(entrustPrice.value) || 0
  1156. }
  1157. return parseFloat(triggerPrice.value) || 0
  1158. }
  1159. const futuresContractSize = computed(() => {
  1160. const n = parseFloat(symbolInfo.value?.shareNumber ?? '1')
  1161. return n > 0 ? n : 1
  1162. })
  1163. const futuresCoinPrecision = computed(() => {
  1164. const s = symbolInfo.value?.minScale
  1165. if (s == null || !Number.isFinite(Number(s))) return 4
  1166. return Math.min(18, Math.max(0, Math.floor(Number(s))))
  1167. })
  1168. /** 与 flutter:usdtPrecision < 2 用 usdtPrecision,否则展示 2 位 */
  1169. const futuresUsdtDisplayDp = computed(() => {
  1170. const u = symbolInfo.value?.baseCoinScale
  1171. const prec = u == null || !Number.isFinite(Number(u)) ? 4 : Math.floor(Number(u))
  1172. return prec < 2 ? prec : 2
  1173. })
  1174. const futuresOpenFeeRate = computed(() => {
  1175. const f = parseFloat(symbolInfo.value?.openFee ?? '0.0005')
  1176. return f > 0 ? f : 0.0005
  1177. })
  1178. /** 最大可开(当前单位),与 flutter _calcMaxOpenAmount 一致 */
  1179. const maxOpenAmountDisplay = computed(() => {
  1180. const info = symbolInfo.value
  1181. const refP = orderRefPrice()
  1182. const avail = parseFloat(swapAvailBalance.value) || 0
  1183. const lev = leverage.value || 1
  1184. const cs = futuresContractSize.value
  1185. const feeRate = futuresOpenFeeRate.value
  1186. if (!info || avail <= 0 || refP <= 0) return '--'
  1187. const feeMult = 1 + feeRate * lev
  1188. const maxNotional = (avail / feeMult) * lev
  1189. const u = amountUnit.value
  1190. const coinPrec = futuresCoinPrecision.value
  1191. if (u === 'usdt') {
  1192. const f = 100
  1193. const t = Math.trunc(maxNotional * f) / f
  1194. return t.toFixed(2)
  1195. }
  1196. if (u === 'lots') {
  1197. return String(Math.floor(maxNotional / cs + 1e-12))
  1198. }
  1199. const f = 10 ** coinPrec
  1200. const t = Math.trunc((maxNotional / refP) * f) / f
  1201. return t.toFixed(coinPrec)
  1202. })
  1203. function floorToDecimals(val: number, dp: number): number {
  1204. const f = 10 ** dp
  1205. return Math.floor(val * f + 1e-12) / f
  1206. }
  1207. function truncateToCoinPrecision(val: number): number {
  1208. const dp = futuresCoinPrecision.value
  1209. const f = 10 ** dp
  1210. return Math.trunc(val * f) / f
  1211. }
  1212. /** 换算为下单用的基础币数量(与 flutter placeOrder 中 volumeInBtc + floor 一致) */
  1213. function toBaseVolume(input: string): string {
  1214. const v = parseFloat(input)
  1215. if (isNaN(v) || v <= 0) return ''
  1216. const price = orderRefPrice()
  1217. if (price <= 0) return ''
  1218. const cs = futuresContractSize.value
  1219. const u = amountUnit.value
  1220. let volumeInBase = 0
  1221. if (u === 'base') volumeInBase = v
  1222. else if (u === 'usdt') volumeInBase = v / price
  1223. else volumeInBase = (v * cs) / price
  1224. const finalVol = truncateToCoinPrecision(volumeInBase)
  1225. if (finalVol <= 0) return ''
  1226. const dp = futuresCoinPrecision.value
  1227. return finalVol.toFixed(dp)
  1228. }
  1229. function setPctSlider(p: number) {
  1230. error.value = ''
  1231. if (p > 0 && orderRefPrice() <= 0) {
  1232. if (orderType.value === 'conditional' && conditionalSubType.value === 'market') {
  1233. error.value = t('futuresPage.errors.enterTriggerPrice')
  1234. } else if (orderType.value !== 'market') {
  1235. error.value = t('futuresPage.errors.enterPrice')
  1236. } else {
  1237. error.value = t('futuresPage.errors.priceNotReadyRetry')
  1238. }
  1239. return
  1240. }
  1241. pctSlider.value = p
  1242. }
  1243. // ── Leverage panel (步进 + 滑块 + min/max) ────────────────
  1244. const leverageOptions = computed(() => {
  1245. const raw = symbolInfo.value?.leverage
  1246. if (!raw) return [1, 5, 10, 20, 50, 100]
  1247. return raw.split(',').map(s => parseInt(s, 10)).filter(n => !isNaN(n) && n > 0)
  1248. })
  1249. const leverageMin = computed(() => leverageOptions.value[0] ?? 1)
  1250. const leverageMax = computed(() => leverageOptions.value[leverageOptions.value.length - 1] ?? 125)
  1251. /** leverageType=1:固定档位(仅 pill 选择);leverageType=2:区间任意值(步进+滑块) */
  1252. const isDiscreteLeverage = computed(() => symbolInfo.value?.leverageType === 1)
  1253. const leverageDraft = ref(leverage.value)
  1254. watch(showLeveragePanel, (v) => { if (v) leverageDraft.value = leverage.value })
  1255. watch(leverage, (v) => { leverageDraft.value = v })
  1256. function leverageStep(delta: number) {
  1257. const next = Math.max(leverageMin.value, Math.min(leverageMax.value, leverageDraft.value + delta))
  1258. leverageDraft.value = next
  1259. }
  1260. async function confirmLeverage() {
  1261. await store.modifyLeverage(leverageDraft.value)
  1262. showLeveragePanel.value = false
  1263. }
  1264. // ── Helpers ────────────────────────────────────────────
  1265. function pct(v: string) {
  1266. const n = parseFloat(v)
  1267. return isNaN(n) ? '--' : (n >= 0 ? '+' : '') + (n * 100).toFixed(2) + '%'
  1268. }
  1269. // 截断到小数点后2位,不四舍五入
  1270. function trunc2(v: string | number): string {
  1271. const s = Number(v).toFixed(10)
  1272. const dot = s.indexOf('.')
  1273. return dot === -1 ? s + '.00' : s.slice(0, dot + 3)
  1274. }
  1275. /** 合约 USDT 价格展示小数位:与后端 symbolInfo.coinScale(无则 minScale)一致,默认 2 */
  1276. function futuresPriceDecimals(): number {
  1277. const info = symbolInfo.value
  1278. const raw = info?.coinScale ?? info?.minScale
  1279. if (raw == null || !Number.isFinite(Number(raw))) return 2
  1280. return Math.min(18, Math.max(0, Math.floor(Number(raw))))
  1281. }
  1282. function fmtPrice(v: string | number | null | undefined) {
  1283. if (v == null) return '--'
  1284. const n = typeof v === 'string' ? parseFloat(v) : v
  1285. if (isNaN(n)) return '--'
  1286. const dp = futuresPriceDecimals()
  1287. return n.toLocaleString('en-US', { minimumFractionDigits: dp, maximumFractionDigits: dp })
  1288. }
  1289. function fmtVol(v: string) {
  1290. const n = parseFloat(v)
  1291. if (isNaN(n)) return '--'
  1292. if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'
  1293. if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'
  1294. if (n >= 1e3) return (n / 1e3).toFixed(2) + 'K'
  1295. return n.toFixed(2)
  1296. }
  1297. function fmtTime(ts: number | string | null | undefined) {
  1298. if (!ts) return '--'
  1299. let ms = typeof ts === 'string' ? Number(ts) : ts
  1300. if (isNaN(ms) || ms <= 0) return '--'
  1301. // 秒级时间戳(< 1e12)转毫秒
  1302. if (ms < 1_000_000_000_000) ms *= 1000
  1303. const d = new Date(ms)
  1304. if (isNaN(d.getTime())) return '--'
  1305. const M = String(d.getMonth() + 1).padStart(2, '0')
  1306. const D = String(d.getDate()).padStart(2, '0')
  1307. const h = String(d.getHours()).padStart(2, '0')
  1308. const m = String(d.getMinutes()).padStart(2, '0')
  1309. return `${M}/${D} ${h}:${m}`
  1310. }
  1311. function fmtTimeShort(ts: number) {
  1312. const d = new Date(ts)
  1313. return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
  1314. }
  1315. function fmtDateTime(ts: number) {
  1316. const d = new Date(ts)
  1317. const Y = d.getFullYear(), M = String(d.getMonth()+1).padStart(2,'0'), D = String(d.getDate()).padStart(2,'0')
  1318. const h = String(d.getHours()).padStart(2,'0'), m = String(d.getMinutes()).padStart(2,'0'), s = String(d.getSeconds()).padStart(2,'0')
  1319. return `${Y}/${M}/${D} ${h}:${m}:${s}`
  1320. }
  1321. function profitClass(v: string | number) { return parseFloat(String(v)) >= 0 ? 'up' : 'dn' }
  1322. // 用实时 ticker 价格计算浮动盈亏
  1323. function calcLivePnl(pos: import('@/stores/futures').Position): number {
  1324. // 优先使用API返回的实时盈亏,回退到本地计算
  1325. if (pos.currentProfit && parseFloat(pos.currentProfit) !== 0) return parseFloat(pos.currentProfit)
  1326. const livePrice = ticker.value?.close ? parseFloat(ticker.value.close) : parseFloat(pos.currentPrice)
  1327. const openPrice = parseFloat(pos.openPrice)
  1328. const vol = parseFloat(pos.currentPosition)
  1329. if (isNaN(livePrice) || isNaN(openPrice) || isNaN(vol)) return 0
  1330. return pos.direction === 'BUY'
  1331. ? (livePrice - openPrice) * vol
  1332. : (openPrice - livePrice) * vol
  1333. }
  1334. function calcLivePnlPct(pos: import('@/stores/futures').Position): number {
  1335. // ROE = unrealizedPnl / currentMargin * 100(与flutter一致)
  1336. const margin = parseFloat(pos.currentMargin)
  1337. if (margin > 0) return calcLivePnl(pos) / margin * 100
  1338. // fallback:用开仓价×持仓量/杠杆估算保证金
  1339. const openPrice = parseFloat(pos.openPrice)
  1340. const vol = parseFloat(pos.currentPosition)
  1341. if (!openPrice || !vol || !pos.leverage) return 0
  1342. const fallbackMargin = openPrice * vol / pos.leverage
  1343. return fallbackMargin > 0 ? calcLivePnl(pos) / fallbackMargin * 100 : 0
  1344. }
  1345. function dirLabel(d: string) {
  1346. return d === 'BUY' ? t('futuresPage.directions.long') : t('futuresPage.directions.short')
  1347. }
  1348. // 开多/开空/平多/平空(与flutter _directionInfo一致)
  1349. function orderDirLabel(direction: string, entrustType: string) {
  1350. const isBuy = direction === 'BUY' || direction === '0'
  1351. const isClose = entrustType === 'CLOSE' || entrustType === '1'
  1352. if (!isClose) {
  1353. return isBuy ? t('futuresPage.directions.openLong') : t('futuresPage.directions.openShort')
  1354. }
  1355. return isBuy ? t('futuresPage.directions.closeShort') : t('futuresPage.directions.closeLong')
  1356. }
  1357. function orderDirClass(direction: string, entrustType: string) {
  1358. const isBuy = direction === 'BUY' || direction === '0'
  1359. const isClose = entrustType === 'CLOSE' || entrustType === '1'
  1360. // 开多(绿)/平空(绿)/开空(红)/平多(红)
  1361. return (!isClose && isBuy) || (isClose && isBuy) ? 'up' : 'dn'
  1362. }
  1363. function typeLabel(tRaw: number | string | null | undefined) {
  1364. const s = String(tRaw ?? '')
  1365. if (s === '1' || s === 'LIMIT_PRICE' || s === 'LIMIT') {
  1366. return t('futuresPage.orderTypes.limit')
  1367. }
  1368. if (s === '2' || s === 'SPOT_LIMIT' || s === 'PLAN' || s === 'STOP') {
  1369. return t('futuresPage.orderTypes.plan')
  1370. }
  1371. if (s === '3') {
  1372. return t('futuresPage.orderTypes.takeProfit')
  1373. }
  1374. if (s === '4') {
  1375. return t('futuresPage.orderTypes.stopLoss')
  1376. }
  1377. return t('futuresPage.orderTypes.marketShort')
  1378. }
  1379. // 委托价格显示:市价/计划市价→"市价",限价/计划限价→显示原始价格
  1380. function orderPriceDisplay(entrustPrice: string, openType: number, _triggerPrice?: string) {
  1381. if (openType === 0) {
  1382. return t('futuresPage.orderTypes.marketShort')
  1383. }
  1384. if (openType === 2) {
  1385. // 计划委托:entrustPrice=0 时为计划市价
  1386. const ep = parseFloat(entrustPrice)
  1387. if (!ep || ep === 0) {
  1388. return t('futuresPage.orderTypes.marketShort')
  1389. }
  1390. }
  1391. return fmtPrice(entrustPrice)
  1392. }
  1393. function histDirLabel(d: string, entrustType: string) {
  1394. return orderDirLabel(d, entrustType)
  1395. }
  1396. function histDirClass(d: string, entrustType: string) {
  1397. return orderDirClass(d, entrustType)
  1398. }
  1399. // 历史状态标签
  1400. function statusLabel(status: string | number) {
  1401. const s = String(status ?? '').toUpperCase()
  1402. if (s === 'ENTRUST_SUCCESS' || s === 'FILLED' || s === 'DONE' || s === 'PARTLYDONE') {
  1403. return t('futuresPage.orderStatus.filled')
  1404. }
  1405. if (s === 'ENTRUST_ING' || s === 'OPEN' || s === 'STARTED') {
  1406. return t('futuresPage.orderStatus.open')
  1407. }
  1408. if (s === 'ENTRUST_CANCEL' || s === 'CANCELLED') {
  1409. return t('futuresPage.orderStatus.cancelled')
  1410. }
  1411. if (s === 'ENTRUST_FAILURE' || s === 'FAILED') {
  1412. return t('futuresPage.orderStatus.failed')
  1413. }
  1414. return status ? String(status) : t('futuresPage.orderStatus.filled')
  1415. }
  1416. function statusClass(status: string | number) {
  1417. const s = String(status ?? '').toUpperCase()
  1418. if (s === 'ENTRUST_CANCEL' || s === 'CANCELLED' || s === 'ENTRUST_FAILURE' || s === 'FAILED') return 'dn'
  1419. return 'up'
  1420. }
  1421. // 历史仓位平仓类型
  1422. function closeTypeLabel(closeType: number | string) {
  1423. const ty = Number(closeType)
  1424. if (ty === 1) {
  1425. return t('futuresPage.closeTypes.market')
  1426. }
  1427. if (ty === 2) {
  1428. return t('futuresPage.closeTypes.oneClick')
  1429. }
  1430. if (ty === 3) {
  1431. return t('futuresPage.closeTypes.reverseOpen')
  1432. }
  1433. if (ty === 4) {
  1434. return t('futuresPage.closeTypes.tpSl')
  1435. }
  1436. if (ty === 5) {
  1437. return t('futuresPage.closeTypes.liquidation')
  1438. }
  1439. if (ty === 6) {
  1440. return t('futuresPage.closeTypes.admin')
  1441. }
  1442. return ''
  1443. }
  1444. // 历史委托价格显示(区分开仓/平仓单)
  1445. function histOrdOpenPrice(ord: import('@/stores/futures').HistoryOrder) {
  1446. const isClose = ord.entrustType === 'CLOSE' || String(ord.entrustType) === '1'
  1447. return isClose ? fmtPrice(ord.usdtOpenPrice) : fmtPrice(ord.tradedPrice)
  1448. }
  1449. function histOrdEntrustPrice(ord: import('@/stores/futures').HistoryOrder) {
  1450. const s = String(ord.type ?? '')
  1451. const isMarket = s === '0' || s === 'MARKET_PRICE' || s === 'MARKET'
  1452. const isPlan = s === '2' || s === 'SPOT_LIMIT' || s === 'PLAN'
  1453. if (isMarket) {
  1454. return t('futuresPage.orderTypes.marketShort')
  1455. }
  1456. const ep = parseFloat(ord.entrustPrice || '0')
  1457. if (isPlan && (!ep || ep === 0)) {
  1458. return t('futuresPage.orderTypes.marketShort')
  1459. }
  1460. return ep > 0 ? fmtPrice(ord.entrustPrice) : '--'
  1461. }
  1462. function barWidth(items: any[], total: string) {
  1463. const maxTotal = Math.max(...items.map((i: any) => parseFloat(i.total) || 0))
  1464. if (!maxTotal) return '0%'
  1465. return Math.min((parseFloat(total) / maxTotal) * 100, 100) + '%'
  1466. }
  1467. const crosshairMa = computed(() => {
  1468. const kd = crosshairData.value?.kLineData
  1469. if (!kd || !currentBars.length) return null
  1470. const idx = currentBars.findIndex((b: any) => b.timestamp === kd.timestamp)
  1471. if (idx < 0) return null
  1472. const calcMa = (period: number): string | null => {
  1473. if (idx < period - 1) return null
  1474. const sum = currentBars.slice(idx - period + 1, idx + 1).reduce((s: number, b: any) => s + b.close, 0)
  1475. return (sum / period).toFixed(futuresPriceDecimals())
  1476. }
  1477. return { ma5: calcMa(5), ma10: calcMa(10), ma30: calcMa(30) }
  1478. })
  1479. const bsRatio = computed(() => {
  1480. const bidTotal = orderBook.value.bid.items.reduce((s, i) => s + parseFloat(i.amount || '0'), 0)
  1481. const askTotal = orderBook.value.ask.items.reduce((s, i) => s + parseFloat(i.amount || '0'), 0)
  1482. const total = bidTotal + askTotal
  1483. if (!total) return { buy: 50, sell: 50 }
  1484. return { buy: Math.round((bidTotal / total) * 100), sell: Math.round((askTotal / total) * 100) }
  1485. })
  1486. function recalcBookRowCount() {
  1487. const col = bookColRef.value
  1488. if (!col) return
  1489. const rowEl = col.querySelector('.book-row') as HTMLElement | null
  1490. const rowH = Math.max(1, rowEl?.offsetHeight || 22)
  1491. const tabsH = bookTabsRef.value?.offsetHeight || 40
  1492. const ctrlH = bookCtrlRef.value?.offsetHeight || 34
  1493. const headH = bookHeadRef.value?.offsetHeight || 24
  1494. const ratioH = bsRatioRef.value?.offsetHeight || 28
  1495. const midH = bookLayout.value === 'full' ? (bookMidRef.value?.offsetHeight || 32) : 0
  1496. const usable = col.clientHeight - tabsH - ctrlH - headH - ratioH - midH
  1497. if (usable <= 0) return
  1498. const rows = Math.floor((usable + rowH * 0.5) / rowH)
  1499. dynamicBookRows.value = Math.min(60, Math.max(4, rows))
  1500. }
  1501. const bookRowCount = computed(() => {
  1502. const rows = dynamicBookRows.value
  1503. return bookLayout.value === 'full' ? Math.max(1, Math.ceil(rows / 2)) : rows
  1504. })
  1505. function addDepthTotals(rows: [number, number][]) {
  1506. let acc = 0
  1507. return rows.map(([price, qty]) => {
  1508. acc += qty
  1509. return { price: String(price), amount: String(qty), total: String(acc) }
  1510. })
  1511. }
  1512. const displayAskItems = computed(() => {
  1513. const n = bookRowCount.value
  1514. const take = bookLayout.value === 'bids' ? 0 : n
  1515. if (take <= 0) return []
  1516. // 卖盘:从最小价开始取(最接近成交价),展示按价格从大到小
  1517. const sliceAsc = rawBookAsks.value.slice(0, Math.min(take, rawBookAsks.value.length))
  1518. const display = [...sliceAsc].sort((a, b) => b[0] - a[0])
  1519. return addDepthTotals(display)
  1520. })
  1521. const displayBidItems = computed(() => {
  1522. const n = bookRowCount.value
  1523. const take = bookLayout.value === 'asks' ? 0 : n
  1524. if (take <= 0) return []
  1525. const display = rawBookBids.value.slice(0, Math.min(take, rawBookBids.value.length))
  1526. return addDepthTotals(display)
  1527. })
  1528. const bookMidPrice = computed(() => {
  1529. if (klineRealtimeClose.value !== null && Number.isFinite(klineRealtimeClose.value)) {
  1530. return fmtPrice(klineRealtimeClose.value)
  1531. }
  1532. return ticker.value ? fmtPrice(ticker.value.close) : '--'
  1533. })
  1534. // ── Lifecycle ──────────────────────────────────────────
  1535. onMounted(async () => {
  1536. const sym = String(route.params.symbol)
  1537. await store.init(sym)
  1538. if (isLoggedIn.value) {
  1539. store.fetchPositions()
  1540. store.fetchCurrentOrders()
  1541. store.fetchLeverage()
  1542. startPolling()
  1543. }
  1544. await nextTick()
  1545. initChart(); updateChart()
  1546. recalcBookRowCount()
  1547. if (typeof ResizeObserver !== 'undefined' && bookColRef.value) {
  1548. bookRo = new ResizeObserver(() => recalcBookRowCount())
  1549. bookRo.observe(bookColRef.value)
  1550. }
  1551. if (!syncEntrustPriceFromCoinList() && ticker.value && !entrustPrice.value) {
  1552. entrustPrice.value = ticker.value.close
  1553. }
  1554. ws.connect(sym, klineResolution.value)
  1555. countdownTimer = setInterval(updateCountdown, 1000)
  1556. window.addEventListener('resize', onWindowResize)
  1557. })
  1558. watch(() => route.params.symbol, async (sym) => {
  1559. if (!sym) return
  1560. // 切换币对:重置下单输入,避免沿用上一币对的数量与百分比
  1561. entrustPrice.value = ''
  1562. triggerPrice.value = ''
  1563. prevTickClose = 0
  1564. volume.value = ''
  1565. pctSlider.value = 0
  1566. recentTrades.value = []
  1567. rawBookAsks.value = []
  1568. rawBookBids.value = []
  1569. klineRealtimeClose.value = null
  1570. destroyChart()
  1571. await store.init(String(sym))
  1572. if (isLoggedIn.value) {
  1573. store.fetchPositions()
  1574. store.fetchCurrentOrders()
  1575. store.fetchLeverage()
  1576. }
  1577. await nextTick()
  1578. initChart(); updateChart()
  1579. recalcBookRowCount()
  1580. syncEntrustPriceFromCoinList()
  1581. ws.changeSymbol(String(sym))
  1582. })
  1583. watch([bookLayout, bookTab], async () => {
  1584. await nextTick()
  1585. recalcBookRowCount()
  1586. })
  1587. watch(isLoggedIn, (loggedIn) => {
  1588. if (loggedIn) {
  1589. store.fetchPositions()
  1590. store.fetchCurrentOrders()
  1591. store.fetchLeverage()
  1592. startPolling()
  1593. } else {
  1594. stopPolling()
  1595. }
  1596. })
  1597. watch(ticker, (t) => {
  1598. if (t && !entrustPrice.value) entrustPrice.value = t.close
  1599. if (t) document.title = `${symbol.value} ${fmtPrice(t.close)} | iBit`
  1600. })
  1601. function applyPctToVolume(pct: number) {
  1602. const refP = orderRefPrice()
  1603. const avail = parseFloat(swapAvailBalance.value) || 0
  1604. const lev = leverage.value || 1
  1605. const cs = futuresContractSize.value
  1606. const feeRate = futuresOpenFeeRate.value
  1607. if (pct === 0 || avail <= 0) {
  1608. volume.value = ''
  1609. return
  1610. }
  1611. if (refP <= 0) {
  1612. volume.value = ''
  1613. return
  1614. }
  1615. // 与 flutter _PercentSlider 开仓分支:feeMultiplier、effectiveMargin、maxNotional
  1616. const feeMult = 1 + feeRate * lev
  1617. const effectiveMargin = (avail / feeMult) * (pct / 100)
  1618. const maxNotional = effectiveMargin * lev
  1619. const u = amountUnit.value
  1620. if (u === 'usdt') {
  1621. const dp = futuresUsdtDisplayDp.value
  1622. volume.value = floorToDecimals(maxNotional, dp).toFixed(dp)
  1623. } else if (u === 'base') {
  1624. const rawBtc = maxNotional / refP
  1625. volume.value = truncateToCoinPrecision(rawBtc).toFixed(futuresCoinPrecision.value)
  1626. } else {
  1627. const maxL = Math.floor((effectiveMargin * lev) / cs + 1e-12)
  1628. volume.value = maxL > 0 ? String(maxL) : ''
  1629. }
  1630. }
  1631. watch(pctSlider, (pct) => {
  1632. applyPctToVolume(pct)
  1633. })
  1634. // 币对、价格、杠杆、手续费相关变化时,百分比数量按当前参照价重算(对齐 flutter _refreshMaxAmount)
  1635. watch([
  1636. amountUnit, swapAvailBalance, leverage, ticker, entrustPrice, triggerPrice,
  1637. orderType, conditionalSubType, symbolInfo,
  1638. ], () => {
  1639. if (pctSlider.value > 0) applyPctToVolume(pctSlider.value)
  1640. })
  1641. // 与 flutter:切换下单类型时清空数量与百分比
  1642. watch(orderType, () => {
  1643. volume.value = ''
  1644. pctSlider.value = 0
  1645. })
  1646. watch(conditionalSubType, () => {
  1647. volume.value = ''
  1648. pctSlider.value = 0
  1649. })
  1650. // 切到子导航 tab 时加载数据 / 恢复图表
  1651. watch(subNavTab, (tab) => {
  1652. // 图表 canvas 始终保持 position:absolute 渲染,尺寸不会归零
  1653. // 切回图表时只需 resize 更新布局即可
  1654. if (tab === 'chart') {
  1655. nextTick(() => { chart?.resize() })
  1656. }
  1657. if (tab === 'coin-info' && !coinExtInfo.value) {
  1658. store.fetchCoinExtInfo()
  1659. }
  1660. if (tab === 'funding-rate') {
  1661. store.fetchFundingRateCurrent()
  1662. store.fetchFundingRateHistory(true)
  1663. }
  1664. })
  1665. onUnmounted(() => {
  1666. destroyChart()
  1667. ws.disconnect()
  1668. stopPolling()
  1669. if (countdownTimer !== null) clearInterval(countdownTimer)
  1670. window.removeEventListener('resize', onWindowResize)
  1671. frListRo?.disconnect()
  1672. bookRo?.disconnect()
  1673. bookRo = null
  1674. })
  1675. </script>
  1676. <template>
  1677. <div class="ft-page" @click="showPairSelector = false; showLeveragePanel = false; showMarginDrop = false; showPrecDrop = false; showCondDrop = false; showAmountUnitDrop = false">
  1678. <!-- ═══ TICKER BAR ═══ -->
  1679. <div class="ticker-bar">
  1680. <div class="pair-trigger" @click.stop="showPairSelector = !showPairSelector">
  1681. <div class="coin-icon">
  1682. <img v-if="currentCoinIcon" :src="currentCoinIcon" :alt="symbol" />
  1683. <span v-else class="coin-icon-fb">{{ symbol.split('/')[0]?.[0] }}</span>
  1684. </div>
  1685. <div class="pair-name-col">
  1686. <div class="pair-name-row">
  1687. <span class="pair-name">{{ symbol }}</span>
  1688. <svg class="caret" :class="{ open: showPairSelector }" width="10" height="6" viewBox="0 0 10 6">
  1689. <path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/>
  1690. </svg>
  1691. </div>
  1692. <span class="pair-sub">{{ t('futuresPage.perpetual') }}</span>
  1693. </div>
  1694. <Transition name="pop">
  1695. <div v-if="showPairSelector" class="pair-drop" @click.stop>
  1696. <div class="pair-drop-search">
  1697. <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>
  1698. <input v-model="pairSearch" class="pair-search-input" :placeholder="t('futuresPage.pairSearchPlaceholder')" autofocus />
  1699. </div>
  1700. <div class="pair-list">
  1701. <div
  1702. v-for="t in filteredTickers" :key="t.symbol"
  1703. class="pair-item" :class="{ active: t.symbol === symbol }"
  1704. @click="selectPair(t.symbol)"
  1705. >
  1706. <div class="pi-icon">
  1707. <img v-if="coinIcon(t.symbol)" :src="coinIcon(t.symbol)" :alt="t.symbol" />
  1708. <span v-else class="pi-icon-fb">{{ t.symbol[0] }}</span>
  1709. </div>
  1710. <span class="pi-sym">{{ t.symbol }}</span>
  1711. <span class="pi-price">{{ fmtPrice(t.close) }}</span>
  1712. <span class="pi-chg" :class="parseFloat(t.chg) >= 0 ? 'up' : 'dn'">{{ pct(t.chg) }}</span>
  1713. </div>
  1714. <div v-if="!filteredTickers.length" class="pair-empty">{{ t('futuresPage.pairEmpty') }}</div>
  1715. </div>
  1716. </div>
  1717. </Transition>
  1718. </div>
  1719. <div class="ticker-price-wrap">
  1720. <div class="ticker-price" :class="priceUp ? 'up' : 'dn'">{{ ticker ? fmtPrice(ticker.close) : '--' }}</div>
  1721. <div class="ticker-usd">≈ ${{ ticker ? fmtPrice(ticker.close) : '--' }}</div>
  1722. </div>
  1723. <div class="ticker-stats">
  1724. <div class="ts-item">
  1725. <span class="ts-lbl">{{ t('futuresPage.stats.change24h') }}</span>
  1726. <span class="ts-val" :class="priceUp ? 'up' : 'dn'">{{ ticker ? pct(ticker.chg) : '--' }}</span>
  1727. </div>
  1728. <div class="ts-item">
  1729. <span class="ts-lbl">{{ t('futuresPage.stats.markPrice') }}</span>
  1730. <span class="ts-val">{{ wsMarkPrice ? fmtPrice(wsMarkPrice) : (ticker ? fmtPrice(ticker.close) : '--') }}</span>
  1731. </div>
  1732. <div class="ts-item">
  1733. <span class="ts-lbl">{{ t('futuresPage.stats.high24h') }}</span>
  1734. <span class="ts-val">{{ ticker ? fmtPrice(ticker.high) : '--' }}</span>
  1735. </div>
  1736. <div class="ts-item">
  1737. <span class="ts-lbl">{{ t('futuresPage.stats.low24h') }}</span>
  1738. <span class="ts-val">{{ ticker ? fmtPrice(ticker.low) : '--' }}</span>
  1739. </div>
  1740. <div class="ts-item">
  1741. <span class="ts-lbl">{{ t('futuresPage.stats.volume24h') }}</span>
  1742. <span class="ts-val">{{ ticker ? fmtVol(ticker.turnover) : '--' }} USDT</span>
  1743. </div>
  1744. <div class="ts-item">
  1745. <span class="ts-lbl">{{ t('futuresPage.stats.fundingCountdown') }}</span>
  1746. <span class="ts-val">
  1747. <span :class="wsFundingRate && parseFloat(wsFundingRate) < 0 ? 'dn' : 'up'">
  1748. {{ wsFundingRate ? (parseFloat(wsFundingRate) >= 0 ? '+' : '') + (parseFloat(wsFundingRate) * 100).toFixed(4) + '%' : '--' }}
  1749. </span>
  1750. / {{ fundingCountdown }}
  1751. </span>
  1752. </div>
  1753. </div>
  1754. </div>
  1755. <!-- ═══ MOBILE TAB BAR ═══ -->
  1756. <div v-if="isMobile" class="mobile-trade-tabs">
  1757. <button class="mtt-btn" :class="{ act: mobileTab === 'chart' }" @click="mobileTab = 'chart'">{{ t('futuresPage.mobileTabs.chart') }}</button>
  1758. <button class="mtt-btn" :class="{ act: mobileTab === 'trade' }" @click="mobileTab = 'trade'">{{ t('futuresPage.mobileTabs.trade') }}</button>
  1759. </div>
  1760. <!-- ═══ PAGE BODY (CSS Grid) ═══ -->
  1761. <div class="page-body">
  1762. <!-- LEFT + MIDDLE: chart-area -->
  1763. <div class="chart-area" :class="{ 'mobile-hidden': isMobile && mobileTab !== 'chart' }">
  1764. <!-- Loading -->
  1765. <div v-if="loading" class="chart-loading">
  1766. <div class="spinner"></div>
  1767. </div>
  1768. <div v-if="!loading" class="chart-plus-book">
  1769. <!-- COL 1: Chart -->
  1770. <div class="chart-col">
  1771. <!-- Drawing sidebar -->
  1772. <div class="draw-side">
  1773. <button class="db" :class="{ 'db--act': activeDrawTool === 'cursor' }" :title="t('futuresPage.draw.cursor')" @click="activateDrawTool('cursor')">
  1774. <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>
  1775. </button>
  1776. <div class="db-sep"/>
  1777. <button class="db" :class="{ 'db--act': activeDrawTool === 'trend' }" :title="t('futuresPage.draw.trend')" @click="activateDrawTool('trend')">
  1778. <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>
  1779. </button>
  1780. <button class="db" :class="{ 'db--act': activeDrawTool === 'hline' }" :title="t('futuresPage.draw.hline')" @click="activateDrawTool('hline')">
  1781. <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>
  1782. </button>
  1783. <button class="db" :class="{ 'db--act': activeDrawTool === 'ray' }" :title="t('futuresPage.draw.ray')" @click="activateDrawTool('ray')">
  1784. <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>
  1785. </button>
  1786. <button class="db" :class="{ 'db--act': activeDrawTool === 'channel' }" :title="t('futuresPage.draw.channel')" @click="activateDrawTool('channel')">
  1787. <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>
  1788. </button>
  1789. <button class="db" :class="{ 'db--act': activeDrawTool === 'fib' }" :title="t('futuresPage.draw.fib')" @click="activateDrawTool('fib')">
  1790. <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
  1791. <line x1="3" y1="19" x2="21" y2="19"/><line x1="3" y1="14" x2="21" y2="14"/>
  1792. <line x1="3" y1="10" x2="21" y2="10"/><line x1="3" y1="6" x2="21" y2="6"/>
  1793. </svg>
  1794. </button>
  1795. <div class="db-sep"/>
  1796. <button class="db" :class="{ 'db--act': activeDrawTool === 'text' }" :title="t('futuresPage.draw.text')" @click="activateDrawTool('text')">
  1797. <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>
  1798. </button>
  1799. <div class="db-sep"/>
  1800. <button class="db" :title="t('futuresPage.draw.clearAll')" @click="clearOverlays()">
  1801. <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>
  1802. </button>
  1803. <div class="db-spacer"/>
  1804. <button class="db" :title="t('futuresPage.draw.zoomOut')" @click="zoomOut()">
  1805. <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>
  1806. </button>
  1807. </div>
  1808. <!-- Chart main -->
  1809. <div class="chart-main">
  1810. <!-- Sub-nav tabs -->
  1811. <div class="sub-nav">
  1812. <button class="snav-tab" :class="{ act: subNavTab === 'chart' }" @click="subNavTab = 'chart'">{{ t('futuresPage.chartSub.chart') }}</button>
  1813. <button class="snav-tab" :class="{ act: subNavTab === 'coin-info' }" @click="subNavTab = 'coin-info'">{{ t('futuresPage.chartSub.coinInfo') }}</button>
  1814. <button class="snav-tab" :class="{ act: subNavTab === 'funding-rate' }" @click="subNavTab = 'funding-rate'">{{ t('futuresPage.chartSub.fundingHistory') }}</button>
  1815. </div>
  1816. <!-- TF bar -->
  1817. <div v-show="subNavTab === 'chart'" class="tf-bar">
  1818. <button v-for="opt in TF_OPTIONS" :key="opt.value"
  1819. class="tf-btn" :class="{ act: klineResolution === opt.value }"
  1820. @click="klineResolution = opt.value; ws.changeResolution(opt.value)">{{ opt.label }}</button>
  1821. <div class="tf-spacer"/>
  1822. <template v-if="showMA && klines.length">
  1823. <span class="ma-leg ma5">MA5</span>
  1824. <span class="ma-leg ma10">MA10</span>
  1825. <span class="ma-leg ma30">MA30</span>
  1826. </template>
  1827. <button class="tf-ind" :class="{ act: showMA }" @click="showMA = !showMA">MA</button>
  1828. <button class="tf-ind" :class="{ act: showVol }" @click="showVol = !showVol">VOL</button>
  1829. <div class="tf-div"/>
  1830. <button class="tf-icon" :title="t('futuresPage.chartTools.fullscreen')">
  1831. <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>
  1832. </button>
  1833. <button class="tf-icon" :title="t('futuresPage.chartTools.alert')">
  1834. <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>
  1835. </button>
  1836. </div>
  1837. <!-- Canvas + overlays -->
  1838. <div class="chart-area-rel">
  1839. <div class="chart-canvas-wrap" @mouseleave="crosshairData = null">
  1840. <!-- Top strip: symbol/period -->
  1841. <div class="ohlcv-overlay">
  1842. <span class="ohlcv-sym">{{ displaySymbol }} · {{ TF_OPTIONS.find(t => t.value === klineResolution)?.label }} · iBit</span>
  1843. <template v-if="!crosshairData?.kLineData && ticker">
  1844. <span class="ohlcv-item">{{ t('futuresPage.ohlcv.open') }}=<em>{{ fmtPrice(ticker.open) }}</em></span>
  1845. <span class="ohlcv-item">{{ t('futuresPage.ohlcv.high') }}=<em class="up">{{ fmtPrice(ticker.high) }}</em></span>
  1846. <span class="ohlcv-item">{{ t('futuresPage.ohlcv.low') }}=<em class="dn">{{ fmtPrice(ticker.low) }}</em></span>
  1847. <span class="ohlcv-item">{{ t('futuresPage.ohlcv.close') }}=<em>{{ fmtPrice(ticker.close) }}</em></span>
  1848. </template>
  1849. </div>
  1850. <!-- Hover detail panel -->
  1851. <div class="kline-tooltip" v-if="crosshairData?.kLineData" :style="tooltipStyle">
  1852. <div class="kt-time">{{ fmtDateTime(crosshairData.kLineData.timestamp) }}</div>
  1853. <div class="kt-row">
  1854. <span class="kt-lbl">{{ t('futuresPage.ohlcv.open') }}</span>
  1855. <span class="kt-val" :class="crosshairData.kLineData.close >= crosshairData.kLineData.open ? 'up' : 'dn'">{{ fmtPrice(crosshairData.kLineData.open) }}</span>
  1856. </div>
  1857. <div class="kt-row">
  1858. <span class="kt-lbl">{{ t('futuresPage.ohlcv.high') }}</span>
  1859. <span class="kt-val up">{{ fmtPrice(crosshairData.kLineData.high) }}</span>
  1860. </div>
  1861. <div class="kt-row">
  1862. <span class="kt-lbl">{{ t('futuresPage.ohlcv.low') }}</span>
  1863. <span class="kt-val dn">{{ fmtPrice(crosshairData.kLineData.low) }}</span>
  1864. </div>
  1865. <div class="kt-row">
  1866. <span class="kt-lbl">{{ t('futuresPage.ohlcv.close') }}</span>
  1867. <span class="kt-val" :class="crosshairData.kLineData.close >= crosshairData.kLineData.open ? 'up' : 'dn'">{{ fmtPrice(crosshairData.kLineData.close) }}</span>
  1868. </div>
  1869. <div class="kt-row">
  1870. <span class="kt-lbl">{{ t('futuresPage.ohlcv.volume') }}</span>
  1871. <span class="kt-val">{{ crosshairData.kLineData.volume?.toFixed(4) }}</span>
  1872. </div>
  1873. <div class="kt-row">
  1874. <span class="kt-lbl">{{ t('futuresPage.ohlcv.changePct') }}</span>
  1875. <span class="kt-val" :class="crosshairData.kLineData.close >= crosshairData.kLineData.open ? 'up' : 'dn'">
  1876. {{ ((crosshairData.kLineData.close - crosshairData.kLineData.open) / crosshairData.kLineData.open * 100).toFixed(2) }}%
  1877. </span>
  1878. </div>
  1879. <div class="kt-row">
  1880. <span class="kt-lbl">{{ t('futuresPage.ohlcv.amplitude') }}</span>
  1881. <span class="kt-val">{{ ((crosshairData.kLineData.high - crosshairData.kLineData.low) / crosshairData.kLineData.open * 100).toFixed(2) }}%</span>
  1882. </div>
  1883. <template v-if="crosshairMa">
  1884. <div class="kt-row" v-if="crosshairMa.ma5 !== null">
  1885. <span class="kt-lbl kt-c-ma5">MA5</span>
  1886. <span class="kt-val kt-c-ma5">{{ crosshairMa.ma5 }}</span>
  1887. </div>
  1888. <div class="kt-row" v-if="crosshairMa.ma10 !== null">
  1889. <span class="kt-lbl kt-c-ma10">MA10</span>
  1890. <span class="kt-val kt-c-ma10">{{ crosshairMa.ma10 }}</span>
  1891. </div>
  1892. <div class="kt-row" v-if="crosshairMa.ma30 !== null">
  1893. <span class="kt-lbl kt-c-ma30">MA30</span>
  1894. <span class="kt-val kt-c-ma30">{{ crosshairMa.ma30 }}</span>
  1895. </div>
  1896. </template>
  1897. </div>
  1898. <div ref="chartEl" class="kline-canvas"/>
  1899. </div><!-- /chart-canvas-wrap -->
  1900. <!-- 币种资料面板 -->
  1901. <div v-show="subNavTab === 'coin-info'" class="coin-info-panel">
  1902. <div v-if="coinExtLoading" class="ci-loading"><span class="spin-sm"/></div>
  1903. <template v-else-if="coinExtInfo">
  1904. <div class="cip-header">
  1905. <img v-if="coinExtInfo.icon" :src="coinExtInfo.icon" class="cip-icon" alt="icon"/>
  1906. <div class="cip-names">
  1907. <span class="cip-name-cn">{{ coinExtInfo.nameCn || coinExtInfo.name || symbol.split('/')[0] }}</span>
  1908. <span class="cip-name-en">{{ coinExtInfo.nameEn || symbol.split('/')[0] }}</span>
  1909. </div>
  1910. <span v-if="coinExtInfo.rank" class="cip-rank">#{{ coinExtInfo.rank }}</span>
  1911. </div>
  1912. <div class="cip-grid">
  1913. <div class="cip-item">
  1914. <span class="cip-lbl">{{ t('futuresPage.coinInfo.marketCap') }}</span>
  1915. <span class="cip-val">{{ coinExtInfo.marketCap || '--' }}</span>
  1916. </div>
  1917. <div class="cip-item">
  1918. <span class="cip-lbl">{{ t('futuresPage.coinInfo.circulating') }}</span>
  1919. <span class="cip-val">{{ coinExtInfo.circulatingSupply || '--' }}</span>
  1920. </div>
  1921. <div class="cip-item">
  1922. <span class="cip-lbl">{{ t('futuresPage.coinInfo.issuePrice') }}</span>
  1923. <span class="cip-val">{{ coinExtInfo.issuePrice != null ? '$' + Number(coinExtInfo.issuePrice).toFixed(6) : '--' }}</span>
  1924. </div>
  1925. <div class="cip-item">
  1926. <span class="cip-lbl">{{ t('futuresPage.coinInfo.ath') }}</span>
  1927. <span class="cip-val up">{{ coinExtInfo.athPrice != null ? '$' + Number(coinExtInfo.athPrice).toFixed(2) : '--' }}</span>
  1928. </div>
  1929. <div v-if="coinExtInfo.athDate" class="cip-item">
  1930. <span class="cip-lbl">{{ t('futuresPage.coinInfo.athDate') }}</span>
  1931. <span class="cip-val">{{ coinExtInfo.athDate }}</span>
  1932. </div>
  1933. <div v-if="coinExtInfo.whitepaper" class="cip-item cip-item--full">
  1934. <span class="cip-lbl">{{ t('futuresPage.coinInfo.whitepaper') }}</span>
  1935. <a :href="coinExtInfo.whitepaper" target="_blank" class="cip-link">{{ coinExtInfo.whitepaper }}</a>
  1936. </div>
  1937. </div>
  1938. </template>
  1939. <div v-else class="cip-empty">
  1940. <span>{{ t('futuresPage.coinInfo.empty') }}</span>
  1941. <button class="cip-retry" @click="store.fetchCoinExtInfo()">{{ t('futuresPage.coinInfo.reload') }}</button>
  1942. </div>
  1943. </div>
  1944. <!-- 历史资金费率面板 -->
  1945. <div v-show="subNavTab === 'funding-rate'" class="fr-panel">
  1946. <!-- 左右分栏 -->
  1947. <div class="fr-body">
  1948. <!-- 左侧:info bar + 折线图 -->
  1949. <div class="fr-left">
  1950. <div class="fr-info-bar">
  1951. <div class="fr-info-item">
  1952. <span class="fr-info-lbl">{{ t('futuresPage.fundingPanel.rate') }}</span>
  1953. <span class="fr-info-val" :class="(frCurrentRate ?? 0) < 0 ? 'dn' : 'up'">
  1954. {{ frCurrentRateStr }}
  1955. </span>
  1956. </div>
  1957. <div class="fr-info-item">
  1958. <span class="fr-info-lbl">{{ t('futuresPage.fundingPanel.period') }}</span>
  1959. <span class="fr-info-val">{{ frPeriodLabel }}</span>
  1960. </div>
  1961. <div class="fr-info-item">
  1962. <span class="fr-info-lbl">{{ t('futuresPage.fundingPanel.nextCountdown') }}</span>
  1963. <span class="fr-info-val">{{ fundingCountdown }}</span>
  1964. </div>
  1965. </div>
  1966. <div class="fr-chart-wrap">
  1967. <div v-if="!frChart" class="fr-chart-empty">{{ t('futuresPage.fundingPanel.noChart') }}</div>
  1968. <svg v-else
  1969. ref="frChartRef"
  1970. :viewBox="`0 0 ${600} ${200}`"
  1971. preserveAspectRatio="none"
  1972. class="fr-chart-svg"
  1973. @mousemove="onFrMouseMove"
  1974. @mouseleave="onFrMouseLeave"
  1975. >
  1976. <defs>
  1977. <linearGradient id="frAreaGrad" x1="0" y1="0" x2="0" y2="1">
  1978. <stop offset="0%" stop-color="#4a8fff" stop-opacity="0.22"/>
  1979. <stop offset="100%" stop-color="#4a8fff" stop-opacity="0"/>
  1980. </linearGradient>
  1981. </defs>
  1982. <!-- 水平网格线 -->
  1983. <line v-for="(y, i) in frChart.hGrid" :key="`hg${i}`"
  1984. :x1="FR_PL" :y1="y.toFixed(1)" :x2="FR_PL+FR_PW" :y2="y.toFixed(1)"
  1985. stroke="rgba(255,255,255,0.07)" stroke-width="0.5"/>
  1986. <!-- 零轴虚线 -->
  1987. <line v-if="frChart.showZero"
  1988. :x1="FR_PL" :y1="frChart.zeroY.toFixed(1)"
  1989. :x2="FR_PL+FR_PW" :y2="frChart.zeroY.toFixed(1)"
  1990. stroke="rgba(255,255,255,0.3)" stroke-width="0.8" stroke-dasharray="4,4"/>
  1991. <!-- 渐变填充 -->
  1992. <path :d="frChart.areaPath" fill="url(#frAreaGrad)"/>
  1993. <!-- 折线 -->
  1994. <path :d="frChart.linePath" fill="none" stroke="#4a8fff" stroke-width="1.5"/>
  1995. <!-- X轴标签 -->
  1996. <text v-for="(lb, i) in frChart.xLabels" :key="`xl${i}`"
  1997. :x="lb.x" :y="FR_PT+FR_PH+18"
  1998. text-anchor="middle" font-size="9" fill="#6a7182">{{ lb.label }}</text>
  1999. <!-- Y轴标签 -->
  2000. <text v-for="(lb, i) in frChart.yLabels" :key="`yl${i}`"
  2001. :x="FR_PL+FR_PW+4" :y="lb.y+3"
  2002. text-anchor="start" font-size="9" fill="#6a7182">{{ lb.label }}</text>
  2003. <!-- 悬停十字线 + 圆点 + tooltip -->
  2004. <template v-if="frHoverIdx >= 0 && frChart.pts[frHoverIdx]">
  2005. <line
  2006. :x1="frChart.pts[frHoverIdx].x.toFixed(1)" :y1="FR_PT"
  2007. :x2="frChart.pts[frHoverIdx].x.toFixed(1)" :y2="FR_PT+FR_PH"
  2008. stroke="rgba(255,255,255,0.4)" stroke-width="0.5"/>
  2009. <circle
  2010. :cx="frChart.pts[frHoverIdx].x.toFixed(1)"
  2011. :cy="frChart.pts[frHoverIdx].y.toFixed(1)"
  2012. r="3.5" fill="#4a8fff" stroke="white" stroke-width="1.5"/>
  2013. <!-- tooltip box -->
  2014. <g :transform="`translate(${Math.min(frChart.pts[frHoverIdx].x + 8, FR_W - 120)},${Math.max(frChart.pts[frHoverIdx].y - 32, FR_PT)})`">
  2015. <rect x="0" y="0" width="112" height="36" rx="4" fill="#1a2035" opacity="0.9"/>
  2016. <text x="8" y="14" font-size="10" fill="#9ca3b0">{{ frFmtDatetime(frChart.pts[frHoverIdx].time) }}</text>
  2017. <text x="8" y="28" font-size="11" font-weight="600"
  2018. :fill="frChart.pts[frHoverIdx].rate >= 0 ? '#02c076' : '#f6465d'">
  2019. {{ (frChart.pts[frHoverIdx].rate >= 0 ? '+' : '') + (frChart.pts[frHoverIdx].rate * 100).toFixed(4) + '%' }}
  2020. </text>
  2021. </g>
  2022. </template>
  2023. </svg>
  2024. </div><!-- /fr-chart-wrap -->
  2025. </div><!-- /fr-left -->
  2026. <!-- 历史列表 -->
  2027. <div ref="frListRef" class="fr-list">
  2028. <div class="fr-head">
  2029. <span>{{ t('futuresPage.table.settlementTime') }}</span><span class="tr">{{ t('futuresPage.table.fundingRate') }}</span>
  2030. </div>
  2031. <div v-if="!frItems.length" class="fr-empty">{{ t('futuresPage.fundingPanel.empty7d') }}</div>
  2032. <template v-else>
  2033. <div v-for="(item, i) in frVisibleItems" :key="i" class="fr-row">
  2034. <span>{{ frFmtDatetime(item.time) }}</span>
  2035. <span class="tr" :class="item.rate < 0 ? 'dn' : 'up'">
  2036. {{ (item.rate >= 0 ? '+' : '') + (item.rate * 100).toFixed(4) + '%' }}
  2037. </span>
  2038. </div>
  2039. </template>
  2040. </div>
  2041. </div><!-- /fr-body -->
  2042. </div><!-- /fr-panel -->
  2043. </div><!-- /chart-area-rel -->
  2044. </div>
  2045. </div>
  2046. <!-- COL 2: Order book -->
  2047. <div ref="bookColRef" class="book-col">
  2048. <div ref="bookTabsRef" class="book-hd-tabs">
  2049. <button class="book-tab" :class="{ act: bookTab === 'orderbook' }" @click="bookTab = 'orderbook'">{{ t('futuresPage.book.orderBook') }}</button>
  2050. <button class="book-tab" :class="{ act: bookTab === 'trades' }" @click="bookTab = 'trades'">{{ t('futuresPage.book.trades') }}</button>
  2051. </div>
  2052. <div ref="bookCtrlRef" class="book-hd-ctrl">
  2053. <div v-if="bookTab === 'orderbook'" class="book-layouts">
  2054. <button class="bl-btn" :class="{ act: bookLayout === 'full' }" @click="bookLayout = 'full'">
  2055. <svg width="16" height="14" viewBox="0 0 16 14">
  2056. <rect x="1" y="1" width="6" height="2" rx="0.5" fill="#f6465d"/>
  2057. <rect x="1" y="4.5" width="6" height="2" rx="0.5" fill="#f6465d"/>
  2058. <rect x="9" y="1" width="6" height="2" rx="0.5" fill="currentColor" opacity="0.3"/>
  2059. <rect x="9" y="4.5" width="6" height="2" rx="0.5" fill="currentColor" opacity="0.3"/>
  2060. <rect x="1" y="8" width="6" height="2" rx="0.5" fill="#02c076"/>
  2061. <rect x="1" y="11" width="6" height="2" rx="0.5" fill="#02c076"/>
  2062. <rect x="9" y="8" width="6" height="2" rx="0.5" fill="currentColor" opacity="0.3"/>
  2063. <rect x="9" y="11" width="6" height="2" rx="0.5" fill="currentColor" opacity="0.3"/>
  2064. </svg>
  2065. </button>
  2066. <button class="bl-btn" :class="{ act: bookLayout === 'asks' }" @click="bookLayout = 'asks'">
  2067. <svg width="16" height="14" viewBox="0 0 16 14">
  2068. <rect x="1" y="1" width="14" height="2" rx="0.5" fill="#f6465d"/>
  2069. <rect x="1" y="4.5" width="14" height="2" rx="0.5" fill="#f6465d"/>
  2070. <rect x="1" y="8" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.2"/>
  2071. <rect x="1" y="11" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.2"/>
  2072. </svg>
  2073. </button>
  2074. <button class="bl-btn" :class="{ act: bookLayout === 'bids' }" @click="bookLayout = 'bids'">
  2075. <svg width="16" height="14" viewBox="0 0 16 14">
  2076. <rect x="1" y="1" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.2"/>
  2077. <rect x="1" y="4.5" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.2"/>
  2078. <rect x="1" y="8" width="14" height="2" rx="0.5" fill="#02c076"/>
  2079. <rect x="1" y="11" width="14" height="2" rx="0.5" fill="#02c076"/>
  2080. </svg>
  2081. </button>
  2082. </div>
  2083. </div>
  2084. <template v-if="bookTab === 'orderbook'">
  2085. <div ref="bookHeadRef" class="book-col-head">
  2086. <span>{{ t('futuresPage.book.priceQuote') }}</span>
  2087. <span class="tr">{{ t('futuresPage.book.amountBase', { base: baseCurrency }) }}</span>
  2088. <span class="tr">{{ t('futuresPage.book.totalBase', { base: baseCurrency }) }}</span>
  2089. </div>
  2090. <div v-if="bookLayout !== 'bids'" class="book-asks">
  2091. <div v-for="(item, i) in displayAskItems" :key="`a${i}`" class="book-row ask-row" @click="onBookClick(item.price)">
  2092. <div class="br-depth br-depth--ask" :style="{ width: barWidth(displayAskItems, item.total) }"/>
  2093. <span class="br-p dn">{{ fmtPrice(item.price) }}</span>
  2094. <span class="br-a">{{ parseFloat(item.amount).toFixed(4) }}</span>
  2095. <span class="br-t">{{ parseFloat(item.total).toFixed(2) }}</span>
  2096. </div>
  2097. </div>
  2098. <div v-if="bookLayout === 'full'" ref="bookMidRef" class="book-mid" :class="tickPriceUp ? 'up' : 'dn'">
  2099. <span class="bm-price">{{ bookMidPrice }} USDT</span>
  2100. <svg v-if="tickPriceUp" width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><path d="M6 1l5 9H1z"/></svg>
  2101. <svg v-else width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><path d="M6 11L1 2h10z"/></svg>
  2102. </div>
  2103. <div v-if="bookLayout !== 'asks'" class="book-bids">
  2104. <div v-for="(item, i) in displayBidItems" :key="`b${i}`" class="book-row bid-row" @click="onBookClick(item.price)">
  2105. <div class="br-depth br-depth--bid" :style="{ width: barWidth(displayBidItems, item.total) }"/>
  2106. <span class="br-p up">{{ fmtPrice(item.price) }}</span>
  2107. <span class="br-a">{{ parseFloat(item.amount).toFixed(4) }}</span>
  2108. <span class="br-t">{{ parseFloat(item.total).toFixed(2) }}</span>
  2109. </div>
  2110. </div>
  2111. <div ref="bsRatioRef" class="bs-ratio">
  2112. <div class="bs-bar">
  2113. <div class="bs-b" :style="{ width: bsRatio.buy + '%' }"/>
  2114. <div class="bs-s" :style="{ width: bsRatio.sell + '%' }"/>
  2115. </div>
  2116. <div class="bs-lbl">
  2117. <span class="up">{{ t('futuresPage.book.buyRatio', { n: bsRatio.buy }) }}</span>
  2118. <span class="dn">{{ t('futuresPage.book.sellRatio', { n: bsRatio.sell }) }}</span>
  2119. </div>
  2120. </div>
  2121. </template>
  2122. <template v-else>
  2123. <div class="book-col-head">
  2124. <span>{{ t('futuresPage.book.priceQuote') }}</span>
  2125. <span class="tr">{{ t('futuresPage.book.amountBase', { base: baseCurrency }) }}</span>
  2126. <span class="tr">{{ t('futuresPage.book.time') }}</span>
  2127. </div>
  2128. <div v-if="!recentTrades.length" class="empty-tip">{{ t('futuresPage.book.noTrades') }}</div>
  2129. <div v-else class="trades-list">
  2130. <div v-for="(tr, i) in recentTrades.slice(0, 12)" :key="i" class="trade-row">
  2131. <span :class="tr.isSell ? 'dn' : 'up'">{{ fmtPrice(tr.price) }}</span>
  2132. <span class="tr">{{ parseFloat(tr.qty).toFixed(4) }}</span>
  2133. <span class="tr trade-time">{{ fmtTimeShort(tr.time) }}</span>
  2134. </div>
  2135. </div>
  2136. </template>
  2137. </div>
  2138. </div>
  2139. </div>
  2140. <!-- RIGHT: Trading panel -->
  2141. <div class="trade-panel" :class="{ 'mobile-show': isMobile && mobileTab === 'trade' }" @click.stop>
  2142. <!-- Margin mode + leverage -->
  2143. <div class="tp-hd">
  2144. <div class="margin-wrap">
  2145. <button class="margin-btn" :disabled="isSwitchingMode" @click.stop="showMarginDrop = !showMarginDrop">
  2146. {{ marginModeLabel }}
  2147. <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>
  2148. <span v-else class="spin-sm"/>
  2149. </button>
  2150. <Transition name="pop">
  2151. <div v-if="showMarginDrop" class="margin-drop" @click.stop>
  2152. <button v-for="m in MARGIN_MODES" :key="m.value"
  2153. class="mdrop-item" :class="{ act: marginMode === m.value }"
  2154. @click="selectMarginMode(m.value)">{{ m.label }}</button>
  2155. </div>
  2156. </Transition>
  2157. </div>
  2158. <div class="tp-hd-sp"/>
  2159. <div class="lev-wrap">
  2160. <button class="lev-btn" @click.stop="showLeveragePanel = !showLeveragePanel">
  2161. {{ leverage }}x
  2162. <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>
  2163. </button>
  2164. <Transition name="pop">
  2165. <div v-if="showLeveragePanel" class="lev-panel" @click.stop>
  2166. <div class="lp-title">{{ t('futuresPage.leverage.adjustTitle') }}</div>
  2167. <!-- 固定档位模式:只显示 pill 选择 -->
  2168. <template v-if="isDiscreteLeverage">
  2169. <div class="lp-pills lp-pills--full">
  2170. <button v-for="lv in leverageOptions" :key="lv"
  2171. class="lp-pill" :class="{ act: leverageDraft === lv }"
  2172. @click="leverageDraft = lv">{{ lv }}x</button>
  2173. </div>
  2174. </template>
  2175. <!-- 区间任意值模式:步进 + 滑块 -->
  2176. <template v-else>
  2177. <div class="lp-stepper">
  2178. <button class="lp-step-btn" :disabled="leverageDraft <= leverageMin" @click="leverageStep(-1)">-</button>
  2179. <div class="lp-step-val">{{ leverageDraft }}x</div>
  2180. <button class="lp-step-btn" :disabled="leverageDraft >= leverageMax" @click="leverageStep(1)">+</button>
  2181. </div>
  2182. <input type="range" class="lp-range"
  2183. :min="leverageMin" :max="leverageMax" step="1"
  2184. v-model.number="leverageDraft"/>
  2185. <div class="lp-range-ticks">
  2186. <span>{{ leverageMin }}x</span>
  2187. <span>{{ leverageMax }}x</span>
  2188. </div>
  2189. </template>
  2190. <div class="lp-actions">
  2191. <button class="lp-cancel" @click="showLeveragePanel = false">{{ t('common.cancel') }}</button>
  2192. <button class="lp-confirm" @click="confirmLeverage">{{ t('common.confirm') }}</button>
  2193. </div>
  2194. </div>
  2195. </Transition>
  2196. </div>
  2197. </div>
  2198. <!-- ══ OPEN PANEL ══ -->
  2199. <!-- Order type -->
  2200. <div class="ot-row">
  2201. <button class="ot-btn" :class="{ act: orderType === 'limit' }" @click="orderType = 'limit'">{{ t('futuresPage.order.limit') }}</button>
  2202. <button class="ot-btn" :class="{ act: orderType === 'market' }" @click="orderType = 'market'">{{ t('futuresPage.order.market') }}</button>
  2203. <div class="ot-cond-wrap" @click.stop>
  2204. <button class="ot-btn ot-cond" :class="{ act: orderType === 'conditional' }"
  2205. @click="orderType = 'conditional'; showCondDrop = !showCondDrop">
  2206. {{ orderType === 'conditional'
  2207. ? (conditionalSubType === 'market' ? t('futuresPage.order.condMarket') : t('futuresPage.order.condLimit'))
  2208. : t('futuresPage.order.conditional')
  2209. }}
  2210. <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>
  2211. </button>
  2212. <div v-if="showCondDrop" class="cond-drop">
  2213. <div class="cond-drop-item" :class="{ act: conditionalSubType === 'market' }"
  2214. @mousedown.prevent="conditionalSubType = 'market'; showCondDrop = false">{{ t('futuresPage.order.condMarket') }}</div>
  2215. <div class="cond-drop-item" :class="{ act: conditionalSubType === 'limit' }"
  2216. @mousedown.prevent="conditionalSubType = 'limit'; showCondDrop = false">{{ t('futuresPage.order.condLimit') }}</div>
  2217. </div>
  2218. </div>
  2219. <button class="ot-info">
  2220. <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>
  2221. </button>
  2222. </div>
  2223. <div class="tp-body">
  2224. <!-- Available -->
  2225. <div class="avail-row">
  2226. <span class="avail-lbl">{{ t('futuresPage.order.available') }}</span>
  2227. <span class="avail-val">{{ trunc2(swapAvailBalance) }} {{ quoteCurrency }}</span>
  2228. <div class="avail-acts">
  2229. <button class="avail-act" :title="t('futuresPage.order.transferTitle')" @click.stop="showTransfer = true">
  2230. <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>
  2231. </button>
  2232. </div>
  2233. </div>
  2234. <div v-if="isLoggedIn && symbolInfo" class="avail-row max-open-row">
  2235. <span class="avail-lbl">{{ t('futuresPage.order.maxOpen') }}</span>
  2236. <span class="avail-val">{{ maxOpenAmountDisplay }} {{ amountUnit === 'base' ? baseCurrency : amountUnitLabel }}</span>
  2237. </div>
  2238. <!-- Trigger price (条件委托) -->
  2239. <div v-if="orderType === 'conditional'" class="inp-block">
  2240. <div class="inp-wrap">
  2241. <span class="inp-lbl">{{ t('futuresPage.order.triggerPriceLabel', { q: quoteCurrency }) }}</span>
  2242. <div class="inp-row">
  2243. <input v-model="triggerPrice" type="number" class="inp" :placeholder="t('futuresPage.placeholders.triggerPrice')"/>
  2244. <button class="inp-quick" @click="triggerPrice = ticker?.close ?? ''">{{ t('futuresPage.order.latestBtn') }}</button>
  2245. </div>
  2246. </div>
  2247. </div>
  2248. <!-- Price input -->
  2249. <div class="inp-block">
  2250. <div v-if="orderType === 'limit' || (orderType === 'conditional' && conditionalSubType === 'limit')" class="inp-wrap">
  2251. <span class="inp-lbl">{{ t('futuresPage.order.priceLabel', { q: quoteCurrency }) }}</span>
  2252. <div class="inp-row">
  2253. <input v-model="entrustPrice" type="number" class="inp" :placeholder="t('futuresPage.placeholders.price')"/>
  2254. <button class="inp-quick" @click="setLatestPrice">{{ t('futuresPage.order.latestBtn') }}</button>
  2255. </div>
  2256. </div>
  2257. <div v-else-if="orderType === 'market'" class="inp-market">{{ t('futuresPage.order.marketExe') }}</div>
  2258. <div v-else-if="orderType === 'conditional' && conditionalSubType === 'market'" class="inp-market">{{ t('futuresPage.order.condMarketExe') }}</div>
  2259. </div>
  2260. <!-- Volume input -->
  2261. <div class="inp-block">
  2262. <div class="inp-wrap">
  2263. <div class="inp-lbl-row">
  2264. <span class="inp-lbl">{{ t('futuresPage.order.qtyLabel', { u: amountUnitLabel }) }}</span>
  2265. <div class="vol-unit-wrap" @click.stop>
  2266. <button class="vol-unit-btn" @click="showAmountUnitDrop = !showAmountUnitDrop">
  2267. {{ amountUnitLabel }}
  2268. <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>
  2269. </button>
  2270. <Transition name="pop">
  2271. <div v-if="showAmountUnitDrop" class="vol-unit-drop">
  2272. <button v-for="u in AMOUNT_UNITS" :key="u.value"
  2273. class="vud-item" :class="{ act: amountUnit === u.value }"
  2274. @click="selectAmountUnit(u.value)">
  2275. {{ u.value === 'base' ? baseCurrency : u.label }}
  2276. </button>
  2277. </div>
  2278. </Transition>
  2279. </div>
  2280. </div>
  2281. <input v-model="volume" type="number" class="inp" :placeholder="t('futuresPage.placeholders.amount')"/>
  2282. </div>
  2283. </div>
  2284. <!-- PCT buttons (replaces slider) -->
  2285. <div class="pct-btns">
  2286. <button v-for="p in [25,50,75,100]" :key="p" class="pct-btn" @click="setPctSlider(p)">{{ p }}%</button>
  2287. </div>
  2288. <p v-if="error" class="trade-err">{{ error }}</p>
  2289. <!-- Submit buttons -->
  2290. <div v-if="isLoggedIn" class="submit-row">
  2291. <button class="sub-btn sub-buy" :disabled="orderLoading || !volume"
  2292. @click="side = 'buy'; submitOrder()">
  2293. <span v-if="!orderLoading || side !== 'buy'">{{ t('futuresPage.order.openLong') }}</span>
  2294. <span v-else class="spin-sm"/>
  2295. </button>
  2296. <button class="sub-btn sub-sell" :disabled="orderLoading || !volume"
  2297. @click="side = 'sell'; submitOrder()">
  2298. <span v-if="!orderLoading || side !== 'sell'">{{ t('futuresPage.order.openShort') }}</span>
  2299. <span v-else class="spin-sm"/>
  2300. </button>
  2301. </div>
  2302. <div v-else class="auth-btns">
  2303. <router-link to="/register" class="auth-btn auth-register">{{ t('header.register') }}</router-link>
  2304. <router-link to="/login" class="auth-btn auth-login">{{ t('header.login') }}</router-link>
  2305. </div>
  2306. <!-- Contract info -->
  2307. <div v-if="symbolInfo" class="contract-info">
  2308. <div class="ci-title">{{ t('futuresPage.order.contractInfoTitle') }}</div>
  2309. <div class="ci-row"><span>{{ t('futuresPage.order.indexSource') }}</span><span>Huobi,Binance,Okex</span></div>
  2310. <div class="ci-row"><span>{{ t('futuresPage.order.marginCoin') }}</span><span>{{ quoteCurrency }}</span></div>
  2311. <div class="ci-row"><span>{{ t('futuresPage.order.maxLeverage') }}</span><span>{{ symbolInfo.leverage.split(',').pop() }}x</span></div>
  2312. <div class="ci-row"><span>{{ t('futuresPage.order.maintMargin') }}</span><span>{{ (parseFloat(symbolInfo.maintenanceMarginRate) * 100).toFixed(2) }}%</span></div>
  2313. <div class="ci-row"><span>{{ t('futuresPage.order.openFee') }}</span><span>{{ (parseFloat(symbolInfo.openFee) * 100).toFixed(3) }}%</span></div>
  2314. <div class="ci-row"><span>{{ t('futuresPage.order.closeFee') }}</span><span>{{ (parseFloat(symbolInfo.closeFee) * 100).toFixed(3) }}%</span></div>
  2315. <div v-if="symbolInfo.shareNumber" class="ci-row"><span>{{ t('futuresPage.order.contractValue') }}</span><span>{{ symbolInfo.shareNumber }} {{ quoteCurrency }}</span></div>
  2316. <div class="ci-row"><span>{{ t('futuresPage.order.minOrder') }}</span><span>{{ symbolInfo.minShare }} {{ baseCurrency }}</span></div>
  2317. <div class="ci-row"><span>{{ t('futuresPage.order.maxOrder') }}</span><span>{{ symbolInfo.maxShare }} {{ baseCurrency }}</span></div>
  2318. </div>
  2319. </div>
  2320. </div>
  2321. <!-- ═══ BOTTOM AREA (inside page-body grid col 1) ═══ -->
  2322. <div v-if="!loading" class="bottom-area">
  2323. <div class="btab-bar">
  2324. <button class="btab" :class="{ act: activeTab === 'positions' }" @click="activeTab = 'positions'">
  2325. {{ t('futuresPage.bottom.positions') }} <span class="badge">{{ positions.length }}</span>
  2326. </button>
  2327. <button class="btab" :class="{ act: activeTab === 'orders' }" @click="activeTab = 'orders'">
  2328. {{ t('futuresPage.bottom.openOrders') }} <span class="badge">{{ currentOrders.length }}</span>
  2329. </button>
  2330. <button class="btab" :class="{ act: activeTab === 'history-orders' }" @click="activeTab = 'history-orders'; store.fetchHistoryOrders(true)">{{ t('futuresPage.bottom.historyOrders') }}</button>
  2331. <button class="btab" :class="{ act: activeTab === 'history-positions' }" @click="activeTab = 'history-positions'; store.fetchHistoryPositions(true)">{{ t('futuresPage.bottom.historyPositions') }}</button>
  2332. <button class="btab" :class="{ act: activeTab === 'trades' }" @click="activeTab = 'trades'; store.fetchHistoryOrders(true)">{{ t('futuresPage.bottom.tradeDetails') }}</button>
  2333. <button class="btab" :class="{ act: activeTab === 'funding' }" @click="activeTab = 'funding'; store.fetchFundingRecords(true)">{{ t('futuresPage.bottom.fundingFlow') }}</button>
  2334. <button class="btab" :class="{ act: activeTab === 'assets' }" @click="activeTab = 'assets'">{{ t('futuresPage.bottom.assets') }}</button>
  2335. <div class="btab-sp"/>
  2336. <button v-if="activeTab === 'orders'" class="close-all" :disabled="orderLoading || !currentOrders.length" @click="cancelAllOrders">{{ t('futuresPage.bottom.cancelAllOrders') }}</button>
  2337. <button v-else class="close-all" :disabled="orderLoading || !positions.length" @click="confirmCloseAll">{{ t('futuresPage.bottom.closeAllPositions') }}</button>
  2338. </div>
  2339. <div ref="tabScrollRef" class="tab-scroll-area" @scroll.passive="onTabScroll">
  2340. <template v-if="activeTab === 'positions'">
  2341. <div v-if="!positions.length" class="empty-tip">
  2342. <template v-if="!isLoggedIn">
  2343. <router-link to="/login" class="tip-link">{{ t('header.login') }}</router-link>
  2344. {{ t('futuresPage.bottom.loginPromptOr') }}
  2345. <router-link to="/register" class="tip-link">{{ t('futuresPage.bottom.registerNow') }}</router-link>
  2346. {{ t('futuresPage.bottom.startTrading') }}
  2347. </template>
  2348. <template v-else>{{ t('futuresPage.empty.noPositions') }}</template>
  2349. </div>
  2350. <div v-else class="data-tbl">
  2351. <div class="dt-head dt-head--pos">
  2352. <span>{{ t('futuresPage.table.contract') }}</span><span>{{ t('futuresPage.table.dirLeverage') }}</span><span>{{ t('futuresPage.table.openPrice') }}</span><span>{{ t('futuresPage.table.markPrice') }}</span>
  2353. <span>{{ t('futuresPage.table.positionSize') }}</span><span>{{ t('futuresPage.table.margin') }}</span><span>{{ t('futuresPage.table.marginRate') }}</span>
  2354. <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>
  2355. </div>
  2356. <div v-for="pos in positions" :key="pos.id" class="dt-row dt-row--pos dt-row--clickable" @click="openPosDetail(pos)">
  2357. <span class="fw6">{{ pos.coin?.symbol?.replace('/', '-') ?? '--' }}</span>
  2358. <span :class="pos.direction === 'BUY' ? 'up' : 'dn'">{{ dirLabel(pos.direction) }} {{ pos.leverage }}x</span>
  2359. <span>{{ fmtPrice(pos.openPrice) }}</span>
  2360. <span>{{ fmtPrice(pos.currentPrice) }}</span>
  2361. <span>{{ pos.currentPosition }}</span>
  2362. <span>{{ parseFloat(pos.currentMargin || '0').toFixed(4) }}</span>
  2363. <span>{{ parseFloat(pos.marginRate || '0') > 0 ? parseFloat(pos.marginRate).toFixed(2) + '%' : '--' }}</span>
  2364. <span :class="calcLivePnl(pos) >= 0 ? 'up' : 'dn'">
  2365. {{ calcLivePnl(pos) >= 0 ? '+' : '' }}{{ calcLivePnl(pos).toFixed(4) }}
  2366. </span>
  2367. <span :class="calcLivePnlPct(pos) >= 0 ? 'up' : 'dn'">
  2368. {{ calcLivePnlPct(pos) >= 0 ? '+' : '' }}{{ calcLivePnlPct(pos).toFixed(2) }}%
  2369. </span>
  2370. <span :class="parseFloat(pos.estimatedBlastPrice) > 0 ? 'dn' : ''">{{ parseFloat(pos.estimatedBlastPrice) > 0 ? fmtPrice(pos.estimatedBlastPrice) : '--' }}</span>
  2371. <span>{{ fmtTime(pos.openTime) }}</span>
  2372. <span class="op-col">
  2373. <button class="act-btn act-btn--sec" :disabled="orderLoading" @click.stop="openTpSlModal(pos.id)">{{ t('futuresPage.actions.tpSl') }}</button>
  2374. <button class="act-btn act-btn--sec" :disabled="orderLoading" @click.stop="reversePosition(pos)">{{ t('futuresPage.actions.reverse') }}</button>
  2375. <button class="act-btn" :disabled="orderLoading" @click.stop="openCloseSheet(pos)">{{ t('futuresPage.actions.close') }}</button>
  2376. <button class="act-btn act-btn--market" :disabled="orderLoading" @click.stop="store.closePosition(pos.id, pos.availablePosition)">{{ t('futuresPage.actions.marketCloseAll') }}</button>
  2377. </span>
  2378. </div>
  2379. </div>
  2380. </template>
  2381. <template v-if="activeTab === 'orders'">
  2382. <div v-if="!currentOrders.length" class="empty-tip">{{ t('futuresPage.empty.noOrders') }}</div>
  2383. <div v-else class="data-tbl">
  2384. <div class="dt-head dt-head--ord">
  2385. <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>
  2386. <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>
  2387. </div>
  2388. <div v-for="ord in currentOrders" :key="ord.id" class="dt-row dt-row--ord dt-row--clickable" @click="openOrdDetail(ord)">
  2389. <span class="fw6">{{ ord.symbol?.replace('/', '-') ?? '--' }}</span>
  2390. <span :class="orderDirClass(ord.direction, ord.entrustType)">
  2391. {{ orderDirLabel(ord.direction, ord.entrustType) }} {{ ord.leverage }}x
  2392. </span>
  2393. <span>{{ typeLabel(ord.openType ?? ord.type) }}</span>
  2394. <span>{{ orderPriceDisplay(ord.entrustPrice, ord.openType ?? 0) }}</span>
  2395. <span>{{ parseFloat(ord.triggerPrice) > 0 ? fmtPrice(ord.triggerPrice) : '--' }}</span>
  2396. <span>{{ ord.volume }}</span>
  2397. <span>{{ ord.tradedVolume }}</span>
  2398. <span class="up">{{ ord.profitPrice && parseFloat(String(ord.profitPrice)) > 0 ? fmtPrice(String(ord.profitPrice)) : '--' }}</span>
  2399. <span class="dn">{{ ord.lossPrice && parseFloat(String(ord.lossPrice)) > 0 ? fmtPrice(String(ord.lossPrice)) : '--' }}</span>
  2400. <span>{{ fmtTime(ord.time || ord.createTime) }}</span>
  2401. <span class="op-col">
  2402. <button class="act-btn" :disabled="orderLoading" @click.stop="store.cancelOrder(ord.id)">{{ t('futuresPage.actions.cancelOrder') }}</button>
  2403. <template v-if="ord.profitPrice && parseFloat(String(ord.profitPrice)) > 0 || ord.lossPrice && parseFloat(String(ord.lossPrice)) > 0">
  2404. <button class="act-btn act-btn--sec" :disabled="orderLoading"
  2405. @click.stop="openTpSlModal(ord.positionId ?? '', String(ord.profitPrice ?? ''), String(ord.lossPrice ?? ''))">{{ t('futuresPage.actions.tpSl') }}</button>
  2406. </template>
  2407. </span>
  2408. </div>
  2409. </div>
  2410. </template>
  2411. <!-- ── 历史仓位 ── -->
  2412. <template v-if="activeTab === 'history-positions'">
  2413. <div v-if="!isLoggedIn" class="empty-tip">
  2414. <router-link to="/login" class="tip-link">{{ t('header.login') }}</router-link> {{ t('futuresPage.bottom.loginPromptOr') }}
  2415. <router-link to="/register" class="tip-link">{{ t('futuresPage.bottom.registerNow') }}</router-link>
  2416. {{ t('futuresPage.bottom.startTrading') }}
  2417. </div>
  2418. <div v-else-if="!historyPositions.length" class="empty-tip">{{ t('futuresPage.empty.noHistPositions') }}</div>
  2419. <div v-else class="data-tbl">
  2420. <div class="dt-head dt-head--hist">
  2421. <span>{{ t('futuresPage.table.contract') }}</span><span>{{ t('futuresPage.table.dirLeverage') }}</span><span>{{ t('futuresPage.table.status') }}</span>
  2422. <span>{{ t('futuresPage.table.orderAmt') }}</span><span>{{ t('futuresPage.table.avgOpenPrice') }}</span><span>{{ t('futuresPage.table.profitUsdt') }}</span>
  2423. <span>{{ t('futuresPage.table.tradedVol') }}</span><span>{{ t('futuresPage.table.closeAvg') }}</span><span>{{ t('futuresPage.table.profitRate') }}</span>
  2424. <span>{{ t('futuresPage.table.openTime') }}</span><span>{{ t('futuresPage.table.closeTime') }}</span>
  2425. </div>
  2426. <div v-for="pos in historyPositions" :key="pos.id" class="dt-row dt-row--hist dt-row--clickable" @click="openHistPosDetail(pos)">
  2427. <span class="fw6">{{ histPosSymbol(pos) }}</span>
  2428. <span :class="pos.direction === 'BUY' ? 'up' : 'dn'">{{ dirLabel(pos.direction) }} {{ pos.leverage }}x</span>
  2429. <span :class="statusClass(pos.status)">
  2430. {{ statusLabel(pos.status) }}
  2431. <template v-if="pos.closeType > 0"><br><small style="color:#6a7182">{{ closeTypeLabel(pos.closeType) }}</small></template>
  2432. </span>
  2433. <span>{{ histPosTotalVol(pos) }}</span>
  2434. <span>{{ histPosOpenPrice(pos) }}</span>
  2435. <span :class="profitClass(String(histPosProfit(pos)))">
  2436. {{ histPosProfit(pos) >= 0 ? '+' : '' }}{{ histPosProfit(pos).toFixed(4) }}
  2437. </span>
  2438. <span>{{ histPosDealVol(pos) }}</span>
  2439. <span>{{ histPosClosePrice(pos) }}</span>
  2440. <span :class="profitClass(String(histPosProfit(pos)))">{{ histPosProfitRate(pos) }}</span>
  2441. <span>{{ histPosOpenTime(pos) }}</span>
  2442. <span>{{ histPosCloseTime(pos) }}</span>
  2443. </div>
  2444. </div>
  2445. </template>
  2446. <!-- ── 历史委托 ── -->
  2447. <template v-if="activeTab === 'history-orders'">
  2448. <div v-if="!historyOrders.length" class="empty-tip">{{ t('futuresPage.empty.noHistOrders') }}</div>
  2449. <div v-else class="data-tbl">
  2450. <div class="dt-head dt-head--hist-ord">
  2451. <span>{{ t('futuresPage.table.contract') }}</span><span>{{ t('futuresPage.table.dirLeverage') }}</span><span>{{ t('futuresPage.table.type') }}</span><span>{{ t('futuresPage.table.status') }}</span>
  2452. <span>{{ t('futuresPage.table.orderAmt') }}</span><span>{{ t('futuresPage.table.tradedVol') }}</span><span>{{ t('futuresPage.table.avgOpenPrice') }}</span><span>{{ t('futuresPage.table.entrustPriceHist') }}</span>
  2453. <span>{{ t('futuresPage.table.orderTime') }}</span><span>{{ t('futuresPage.table.fillTime') }}</span>
  2454. </div>
  2455. <div v-for="ord in historyOrders" :key="ord.id" class="dt-row dt-row--hist-ord dt-row--clickable" @click="openHistOrdDetail(ord)">
  2456. <span class="fw6">{{ histOrdSymbol(ord) }}</span>
  2457. <span :class="orderDirClass(ord.direction, ord.entrustType)">{{ orderDirLabel(ord.direction, ord.entrustType) }} {{ ord.leverage }}x</span>
  2458. <span>{{ typeLabel(ord.type) }}</span>
  2459. <span :class="statusClass(ord.status)">{{ statusLabel(ord.status) }}</span>
  2460. <span>{{ ord.volume ?? ord.tradedVolume }}</span>
  2461. <span>{{ ord.tradedVolume }}</span>
  2462. <span>{{ histOrdOpenPrice(ord) }}</span>
  2463. <span>{{ histOrdEntrustPrice(ord) }}</span>
  2464. <span>{{ ord.createTime ? fmtTime(ord.createTime) : '--' }}</span>
  2465. <span>{{ ord.dealTime ? fmtTime(ord.dealTime) : '--' }}</span>
  2466. </div>
  2467. </div>
  2468. </template>
  2469. <!-- ── 成交明细 ── -->
  2470. <template v-if="activeTab === 'trades'">
  2471. <div v-if="!historyOrders.length" class="empty-tip">{{ t('futuresPage.empty.noTradeRecords') }}</div>
  2472. <div v-else class="data-tbl">
  2473. <div class="dt-head dt-head--trade">
  2474. <span>{{ t('futuresPage.table.contract') }}</span><span>{{ t('futuresPage.table.directionOpenClose') }}</span><span>{{ t('futuresPage.table.dealPrice') }}</span><span>{{ t('futuresPage.table.tradedVol') }}</span>
  2475. <span>{{ t('futuresPage.table.fee') }}</span><span>{{ t('futuresPage.table.fillTime') }}</span>
  2476. </div>
  2477. <div v-for="ord in historyOrders" :key="ord.id" class="dt-row dt-row--trade">
  2478. <span class="fw6">{{ ord.symbol?.replace('/', '-') ?? '--' }}</span>
  2479. <span :class="histDirClass(ord.direction, ord.entrustType)">
  2480. {{ histDirLabel(ord.direction, ord.entrustType) }}
  2481. <span class="entrust-type-tag">{{ ord.entrustType === 'OPEN' ? t('futuresPage.directions.openTag') : t('futuresPage.directions.closeTag') }}</span>
  2482. </span>
  2483. <span>{{ ord.tradedPrice ? fmtPrice(ord.tradedPrice) : '--' }}</span>
  2484. <span>{{ ord.tradedVolume || '--' }}</span>
  2485. <span class="dn">{{ ord.entrustType === 'OPEN' ? (ord.openFee ? '-' + parseFloat(ord.openFee).toFixed(4) : '--') : (ord.closeFee ? '-' + parseFloat(ord.closeFee).toFixed(4) : '--') }}</span>
  2486. <span>{{ ord.dealTime ? fmtTime(ord.dealTime) : (ord.createTime ? fmtTime(ord.createTime) : '--') }}</span>
  2487. </div>
  2488. </div>
  2489. </template>
  2490. <!-- ── 资金流水 ── -->
  2491. <template v-if="activeTab === 'funding'">
  2492. <div v-if="!contractFundingRecords.length" class="empty-tip">{{ t('futuresPage.empty.noFunding') }}</div>
  2493. <div v-else class="data-tbl">
  2494. <div class="dt-head dt-head--funding">
  2495. <span>{{ t('futuresPage.table.fundType') }}</span><span>{{ t('futuresPage.table.amount') }}</span><span>{{ t('futuresPage.table.coin') }}</span><span>{{ t('futuresPage.table.time') }}</span>
  2496. </div>
  2497. <div v-for="rec in contractFundingRecords" :key="rec.id" class="dt-row dt-row--funding">
  2498. <span>{{ txTypeName(rec.type) }}</span>
  2499. <span :class="parseFloat(String(rec.amount)) >= 0 ? 'up' : 'dn'">
  2500. {{ parseFloat(String(rec.amount)) >= 0 ? '+' : '' }}{{ parseFloat(String(rec.amount)).toFixed(4) }}
  2501. </span>
  2502. <span>{{ formatFundingSymbol(rec.symbol) }}</span>
  2503. <span>{{ rec.createTime ? fmtTime(new Date(String(rec.createTime).replace(' ', 'T')).getTime()) : '--' }}</span>
  2504. </div>
  2505. </div>
  2506. </template>
  2507. <!-- ── 资产 ── -->
  2508. <template v-if="activeTab === 'assets'">
  2509. <div v-if="!isLoggedIn" class="empty-tip">
  2510. <router-link to="/login" class="tip-link">{{ t('header.login') }}</router-link> {{ t('futuresPage.bottom.loginPromptOr') }}
  2511. <router-link to="/register" class="tip-link">{{ t('futuresPage.bottom.registerNow') }}</router-link>
  2512. {{ t('futuresPage.bottom.startTrading') }}
  2513. </div>
  2514. <div v-else class="assets-grid">
  2515. <div class="asset-card">
  2516. <div class="asset-lbl">{{ t('futuresPage.table.totalBal') }}</div>
  2517. <div class="asset-val">{{ accountInfo.totalBalance.toFixed(4) }} <span class="asset-cur">USDT</span></div>
  2518. </div>
  2519. <div class="asset-card">
  2520. <div class="asset-lbl">{{ t('futuresPage.table.availMargin') }}</div>
  2521. <div class="asset-val">{{ accountInfo.availableMargin.toFixed(4) }} <span class="asset-cur">USDT</span></div>
  2522. </div>
  2523. <div class="asset-card">
  2524. <div class="asset-lbl">{{ t('futuresPage.table.usedMargin') }}</div>
  2525. <div class="asset-val">{{ accountInfo.usedMargin.toFixed(4) }} <span class="asset-cur">USDT</span></div>
  2526. </div>
  2527. <div class="asset-card">
  2528. <div class="asset-lbl">{{ t('futuresPage.table.unrealized') }}</div>
  2529. <div class="asset-val" :class="accountInfo.unrealizedPnl >= 0 ? 'up' : 'dn'">
  2530. {{ accountInfo.unrealizedPnl >= 0 ? '+' : '' }}{{ accountInfo.unrealizedPnl.toFixed(4) }} <span class="asset-cur">USDT</span>
  2531. </div>
  2532. </div>
  2533. <div class="asset-card">
  2534. <div class="asset-lbl">{{ t('futuresPage.table.spotWalletBal') }}</div>
  2535. <div class="asset-val">{{ parseFloat(spotAvailBalance).toFixed(4) }} <span class="asset-cur">USDT</span></div>
  2536. </div>
  2537. <div class="asset-card asset-card--actions">
  2538. <button class="asset-btn" @click="showTransfer = true">{{ t('futuresPage.table.fundTransfer') }}</button>
  2539. </div>
  2540. </div>
  2541. </template>
  2542. </div><!-- /tab-scroll-area -->
  2543. </div><!-- /bottom-area -->
  2544. </div><!-- /page-body -->
  2545. <!-- ═══ 仓位详情弹窗 ═══ -->
  2546. <Transition name="modal">
  2547. <div v-if="showPosDetail && detailPos" class="modal-mask" @click.self="showPosDetail = false">
  2548. <div class="modal-box detail-modal" @click.stop>
  2549. <div class="modal-hd">
  2550. <span class="modal-title">{{ t('futuresPage.modal.positionDetailTitle') }}</span>
  2551. <button class="modal-close" @click="showPosDetail = false">✕</button>
  2552. </div>
  2553. <div class="detail-modal-body">
  2554. <!-- 标的行 -->
  2555. <div class="detail-header">
  2556. <span class="detail-dir-chip" :class="detailPos.direction === 'BUY' ? 'chip--up' : 'chip--dn'">
  2557. {{ detailPos.direction === 'BUY' ? t('futuresPage.directions.long') : t('futuresPage.directions.short') }}
  2558. </span>
  2559. <span class="detail-symbol">{{ detailPos.coin?.symbol?.replace('/', '-') ?? '--' }}</span>
  2560. <span class="detail-tag">{{ posTypeLabel(detailPos.positionType, detailPos.patterns, detailPos.experienceRecordId) }}</span>
  2561. <span class="detail-tag detail-tag--lev">{{ detailPos.leverage }}x</span>
  2562. </div>
  2563. <!-- 价格 -->
  2564. <div class="detail-section">
  2565. <div class="detail-row"><span class="detail-lbl">{{ t('futuresPage.table.openPrice') }}</span><span class="detail-val">{{ fmtPrice(detailPos.openPrice) }}</span></div>
  2566. <div class="detail-row"><span class="detail-lbl">{{ t('futuresPage.table.markPrice') }}</span><span class="detail-val">{{ fmtPrice(detailPos.currentPrice) }}</span></div>
  2567. </div>
  2568. <div class="detail-section-gap"/>
  2569. <!-- 持仓数据 -->
  2570. <div class="detail-section">
  2571. <div class="detail-row">
  2572. <span class="detail-lbl">{{ t('futuresPage.table.entrustVolCoin', { base: baseCoinFromSym(detailPos.coin?.symbol ?? '') }) }}</span>
  2573. <span class="detail-val">{{ detailPos.currentPosition }}</span>
  2574. </div>
  2575. <div class="detail-row">
  2576. <span class="detail-lbl">{{ t('futuresPage.table.notionalUsdt') }}</span>
  2577. <span class="detail-val">{{ (parseFloat(detailPos.currentPosition||'0') * parseFloat(detailPos.currentPrice||'0')).toFixed(4) }}</span>
  2578. </div>
  2579. <div class="detail-row">
  2580. <span class="detail-lbl">{{ t('futuresPage.table.estLiqPx') }}</span>
  2581. <span class="detail-val" :class="parseFloat(detailPos.estimatedBlastPrice) > 0 ? 'dn' : ''">
  2582. {{ parseFloat(detailPos.estimatedBlastPrice) > 0 ? fmtPrice(detailPos.estimatedBlastPrice) : '--' }}
  2583. </span>
  2584. </div>
  2585. <div class="detail-row">
  2586. <span class="detail-lbl">{{ t('futuresPage.table.marginUsdtBracket') }}</span>
  2587. <span class="detail-val">{{ parseFloat(detailPos.currentMargin||'0').toFixed(4) }}</span>
  2588. </div>
  2589. </div>
  2590. <div class="detail-section-gap"/>
  2591. <!-- 盈亏 -->
  2592. <div class="detail-section">
  2593. <div class="detail-row">
  2594. <span class="detail-lbl">{{ t('futuresPage.table.profitRate') }}</span>
  2595. <span class="detail-val" :class="calcLivePnlPct(detailPos) >= 0 ? 'up' : 'dn'">
  2596. {{ calcLivePnlPct(detailPos) >= 0 ? '+' : '' }}{{ calcLivePnlPct(detailPos).toFixed(2) }}%
  2597. </span>
  2598. </div>
  2599. <div class="detail-row">
  2600. <span class="detail-lbl">{{ t('futuresPage.table.unrealized') }}</span>
  2601. <span class="detail-val" :class="calcLivePnl(detailPos) >= 0 ? 'up' : 'dn'">
  2602. {{ calcLivePnl(detailPos) >= 0 ? '+' : '' }}{{ calcLivePnl(detailPos).toFixed(4) }}
  2603. </span>
  2604. </div>
  2605. <div class="detail-row">
  2606. <span class="detail-lbl">{{ t('futuresPage.table.marginRate') }}</span>
  2607. <span class="detail-val">{{ parseFloat(detailPos.marginRate||'0') > 0 ? parseFloat(detailPos.marginRate).toFixed(2) + '%' : '--' }}</span>
  2608. </div>
  2609. <div class="detail-row">
  2610. <span class="detail-lbl">{{ t('futuresPage.table.tpPrice') }}</span>
  2611. <span class="detail-val up">{{ detailPos.profitPrice && parseFloat(detailPos.profitPrice) > 0 ? fmtPrice(detailPos.profitPrice) : '--' }}</span>
  2612. </div>
  2613. <div class="detail-row">
  2614. <span class="detail-lbl">{{ t('futuresPage.table.slPrice') }}</span>
  2615. <span class="detail-val dn">{{ detailPos.lossPrice && parseFloat(detailPos.lossPrice) > 0 ? fmtPrice(detailPos.lossPrice) : '--' }}</span>
  2616. </div>
  2617. <div class="detail-row">
  2618. <span class="detail-lbl">{{ t('futuresPage.table.openTime') }}</span>
  2619. <span class="detail-val">{{ fmtTime(detailPos.openTime) }}</span>
  2620. </div>
  2621. </div>
  2622. </div>
  2623. </div>
  2624. </div>
  2625. </Transition>
  2626. <!-- ═══ 当前委托详情弹窗 ═══ -->
  2627. <Transition name="modal">
  2628. <div v-if="showOrdDetail && detailOrd" class="modal-mask" @click.self="showOrdDetail = false">
  2629. <div class="modal-box detail-modal" @click.stop>
  2630. <div class="modal-hd">
  2631. <span class="modal-title">{{ t('futuresPage.modal.orderDetailTitle') }}</span>
  2632. <button class="modal-close" @click="showOrdDetail = false">✕</button>
  2633. </div>
  2634. <div class="detail-modal-body">
  2635. <!-- 标的行 -->
  2636. <div class="detail-header">
  2637. <span class="detail-dir-chip" :class="orderDirClass(detailOrd.direction, detailOrd.entrustType)">
  2638. {{ orderDirLabel(detailOrd.direction, detailOrd.entrustType) }}
  2639. </span>
  2640. <span class="detail-symbol">{{ detailOrd.symbol?.replace('/', '-') ?? '--' }}</span>
  2641. <span class="detail-tag">{{ typeLabel(detailOrd.openType ?? detailOrd.type) }}</span>
  2642. <span class="detail-tag detail-tag--lev">{{ detailOrd.leverage }}x</span>
  2643. </div>
  2644. <!-- 委托信息 -->
  2645. <div class="detail-section">
  2646. <div class="detail-row">
  2647. <span class="detail-lbl">{{ t('futuresPage.table.orderPrice') }}</span>
  2648. <span class="detail-val">{{ orderPriceDisplay(detailOrd.entrustPrice, detailOrd.openType ?? 0) }}</span>
  2649. </div>
  2650. <div class="detail-row">
  2651. <span class="detail-lbl">{{ t('futuresPage.table.triggerPrice') }}</span>
  2652. <span class="detail-val">{{ parseFloat(detailOrd.triggerPrice) > 0 ? fmtPrice(detailOrd.triggerPrice) : '--' }}</span>
  2653. </div>
  2654. <div class="detail-row">
  2655. <span class="detail-lbl">{{ t('futuresPage.table.entrustVolCoin', { base: baseCoinFromSym(detailOrd.symbol ?? '') }) }}</span>
  2656. <span class="detail-val">{{ detailOrd.volume }}</span>
  2657. </div>
  2658. <div class="detail-row">
  2659. <span class="detail-lbl">{{ t('futuresPage.table.filledVolCoin', { base: baseCoinFromSym(detailOrd.symbol ?? '') }) }}</span>
  2660. <span class="detail-val">{{ detailOrd.tradedVolume }}</span>
  2661. </div>
  2662. </div>
  2663. <div class="detail-section-gap"/>
  2664. <!-- 止盈止损 -->
  2665. <div class="detail-section">
  2666. <div class="detail-row">
  2667. <span class="detail-lbl">{{ t('futuresPage.table.tpPrice') }}</span>
  2668. <span class="detail-val up">{{ detailOrd.profitPrice && parseFloat(String(detailOrd.profitPrice)) > 0 ? fmtPrice(String(detailOrd.profitPrice)) : '--' }}</span>
  2669. </div>
  2670. <div class="detail-row">
  2671. <span class="detail-lbl">{{ t('futuresPage.table.slPrice') }}</span>
  2672. <span class="detail-val dn">{{ detailOrd.lossPrice && parseFloat(String(detailOrd.lossPrice)) > 0 ? fmtPrice(String(detailOrd.lossPrice)) : '--' }}</span>
  2673. </div>
  2674. </div>
  2675. <div class="detail-section-gap"/>
  2676. <!-- 时间与状态 -->
  2677. <div class="detail-section">
  2678. <div class="detail-row">
  2679. <span class="detail-lbl">{{ t('futuresPage.table.orderTime') }}</span>
  2680. <span class="detail-val">{{ fmtTime(detailOrd.time || detailOrd.createTime) }}</span>
  2681. </div>
  2682. <div class="detail-row">
  2683. <span class="detail-lbl">{{ t('futuresPage.table.status') }}</span>
  2684. <span class="detail-val" :class="statusClass(String(detailOrd.status))">{{ statusLabel(String(detailOrd.status)) }}</span>
  2685. </div>
  2686. </div>
  2687. </div>
  2688. </div>
  2689. </div>
  2690. </Transition>
  2691. <!-- ═══ 历史仓位详情弹窗 ═══ -->
  2692. <Transition name="modal">
  2693. <div v-if="showHistPosDetail && detailHistPos" class="modal-mask" @click.self="showHistPosDetail = false">
  2694. <div class="modal-box detail-modal" @click.stop>
  2695. <div class="modal-hd">
  2696. <span class="modal-title">{{ t('futuresPage.modal.histPositionDetailTitle') }}</span>
  2697. <button class="modal-close" @click="showHistPosDetail = false">✕</button>
  2698. </div>
  2699. <div class="detail-modal-body">
  2700. <!-- 状态图标 -->
  2701. <div class="detail-status-block">
  2702. <div class="detail-status-icon">
  2703. <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>
  2704. </div>
  2705. <div class="detail-status-label" :class="statusClass(detailHistPos.status)">{{ statusLabel(detailHistPos.status) }}</div>
  2706. <div v-if="detailHistPos.closeType > 0" class="detail-close-type">{{ closeTypeLabel(detailHistPos.closeType) }}</div>
  2707. </div>
  2708. <!-- 基本信息 -->
  2709. <div class="detail-section">
  2710. <div class="detail-row">
  2711. <span class="detail-lbl">{{ t('futuresPage.modal.marginSplitMode') }}</span>
  2712. <span class="detail-val">{{ posTypeLabel(detailHistPos.positionType ?? detailHistPos.type, detailHistPos.patterns, detailHistPos.experienceRecordId) }}</span>
  2713. </div>
  2714. <div class="detail-row">
  2715. <span class="detail-lbl">{{ t('futuresPage.modal.directionLabel') }}</span>
  2716. <span class="detail-val" :class="detailHistPos.direction === 'BUY' ? 'up' : 'dn'">{{ dirLabel(detailHistPos.direction) }}</span>
  2717. </div>
  2718. <div class="detail-row">
  2719. <span class="detail-lbl">{{ t('futuresPage.modal.entrustTypeLabel') }}</span>
  2720. <span class="detail-val">{{ typeLabel(detailHistPos.openType ?? detailHistPos.type) }}</span>
  2721. </div>
  2722. <div class="detail-row">
  2723. <span class="detail-lbl">{{ t('futuresPage.modal.leverageLabel') }}</span>
  2724. <span class="detail-val">{{ detailHistPos.leverage }}x</span>
  2725. </div>
  2726. <div class="detail-row">
  2727. <span class="detail-lbl">{{ t('futuresPage.table.openTime') }}</span>
  2728. <span class="detail-val">{{ histPosOpenTime(detailHistPos) }}</span>
  2729. </div>
  2730. <div class="detail-row">
  2731. <span class="detail-lbl">{{ t('futuresPage.table.closeTime') }}</span>
  2732. <span class="detail-val">{{ histPosCloseTime(detailHistPos) }}</span>
  2733. </div>
  2734. </div>
  2735. <div class="detail-section-gap"/>
  2736. <!-- 价格信息 -->
  2737. <div class="detail-section">
  2738. <div class="detail-row">
  2739. <span class="detail-lbl">{{ t('futuresPage.table.triggerUsdt') }}</span>
  2740. <span class="detail-val">{{ detailHistPos.triggerPrice && parseFloat(detailHistPos.triggerPrice) > 0 ? fmtPrice(detailHistPos.triggerPrice) : '--' }}</span>
  2741. </div>
  2742. <div class="detail-row">
  2743. <span class="detail-lbl">{{ t('futuresPage.table.orderPriceUsdt') }}</span>
  2744. <span class="detail-val">{{ detailHistPos.entrustPrice && parseFloat(detailHistPos.entrustPrice) > 0 ? fmtPrice(detailHistPos.entrustPrice) : t('futuresPage.orderTypes.marketShort') }}</span>
  2745. </div>
  2746. <div class="detail-row">
  2747. <span class="detail-lbl">{{ t('futuresPage.table.openAvgUsdt') }}</span>
  2748. <span class="detail-val">{{ histPosOpenPrice(detailHistPos) }}</span>
  2749. </div>
  2750. <div class="detail-row">
  2751. <span class="detail-lbl">{{ t('futuresPage.table.closeAvgUsdt') }}</span>
  2752. <span class="detail-val">{{ histPosClosePrice(detailHistPos) }}</span>
  2753. </div>
  2754. <div class="detail-row">
  2755. <span class="detail-lbl">{{ t('futuresPage.table.tpUsdt') }}</span>
  2756. <span class="detail-val up">{{ detailHistPos.profitPrice && parseFloat(detailHistPos.profitPrice) > 0 ? fmtPrice(detailHistPos.profitPrice) : '--' }}</span>
  2757. </div>
  2758. <div class="detail-row">
  2759. <span class="detail-lbl">{{ t('futuresPage.table.slUsdt') }}</span>
  2760. <span class="detail-val dn">{{ detailHistPos.lossPrice && parseFloat(detailHistPos.lossPrice) > 0 ? fmtPrice(detailHistPos.lossPrice) : '--' }}</span>
  2761. </div>
  2762. <div class="detail-row">
  2763. <span class="detail-lbl">{{ t('futuresPage.table.marginUsdtBracket') }}</span>
  2764. <span class="detail-val">{{ histPosPrincipal(detailHistPos) > 0 ? histPosPrincipal(detailHistPos).toFixed(2) : '--' }}</span>
  2765. </div>
  2766. </div>
  2767. <div class="detail-section-gap"/>
  2768. <!-- 盈亏信息 -->
  2769. <div class="detail-section">
  2770. <div class="detail-row">
  2771. <span class="detail-lbl">{{ t('futuresPage.table.entrustVolCoin', { base: baseCoinFromSym(histPosSymbol(detailHistPos)) }) }}</span>
  2772. <span class="detail-val">{{ histPosTotalVol(detailHistPos) }}</span>
  2773. </div>
  2774. <div class="detail-row">
  2775. <span class="detail-lbl">{{ t('futuresPage.table.profitBracketUsdt') }}</span>
  2776. <span class="detail-val" :class="profitClass(String(histPosProfit(detailHistPos)))">
  2777. {{ histPosProfit(detailHistPos) !== 0 ? (histPosProfit(detailHistPos) >= 0 ? '+' : '') + histPosProfit(detailHistPos).toFixed(4) : '--' }}
  2778. </span>
  2779. </div>
  2780. <div class="detail-row">
  2781. <span class="detail-lbl">{{ t('futuresPage.table.profitRate') }}</span>
  2782. <span class="detail-val" :class="profitClass(String(histPosProfit(detailHistPos)))">{{ histPosProfitRate(detailHistPos) }}</span>
  2783. </div>
  2784. <div class="detail-row">
  2785. <span class="detail-lbl">{{ t('futuresPage.table.feeHint') }}</span>
  2786. <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>
  2787. </div>
  2788. </div>
  2789. </div>
  2790. </div>
  2791. </div>
  2792. </Transition>
  2793. <!-- ═══ 历史委托详情弹窗 ═══ -->
  2794. <Transition name="modal">
  2795. <div v-if="showHistOrdDetail && detailHistOrd" class="modal-mask" @click.self="showHistOrdDetail = false">
  2796. <div class="modal-box detail-modal" @click.stop>
  2797. <div class="modal-hd">
  2798. <span class="modal-title">{{ t('futuresPage.modal.histOrderDetailTitle') }}</span>
  2799. <button class="modal-close" @click="showHistOrdDetail = false">✕</button>
  2800. </div>
  2801. <div class="detail-modal-body">
  2802. <!-- 状态图标 -->
  2803. <div class="detail-status-block">
  2804. <div class="detail-status-icon">
  2805. <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>
  2806. </div>
  2807. <div class="detail-status-label" :class="statusClass(detailHistOrd.status)">{{ statusLabel(detailHistOrd.status) }}</div>
  2808. </div>
  2809. <!-- 基本信息 -->
  2810. <div class="detail-section">
  2811. <div class="detail-row">
  2812. <span class="detail-lbl">{{ t('futuresPage.modal.marginSplitMode') }}</span>
  2813. <span class="detail-val">{{ posTypeLabel(detailHistOrd.positionType, detailHistOrd.patterns, detailHistOrd.experienceRecordId) }}</span>
  2814. </div>
  2815. <div class="detail-row">
  2816. <span class="detail-lbl">{{ t('futuresPage.modal.directionLabel') }}</span>
  2817. <span class="detail-val" :class="orderDirClass(detailHistOrd.direction, detailHistOrd.entrustType)">{{ orderDirLabel(detailHistOrd.direction, detailHistOrd.entrustType) }}</span>
  2818. </div>
  2819. <div class="detail-row">
  2820. <span class="detail-lbl">{{ t('futuresPage.modal.entrustTypeLabel') }}</span>
  2821. <span class="detail-val">{{ typeLabel(detailHistOrd.type) }}</span>
  2822. </div>
  2823. <div class="detail-row">
  2824. <span class="detail-lbl">{{ t('futuresPage.modal.leverageLabel') }}</span>
  2825. <span class="detail-val">{{ detailHistOrd.leverage }}x</span>
  2826. </div>
  2827. <div class="detail-row">
  2828. <span class="detail-lbl">{{ t('futuresPage.table.orderTime') }}</span>
  2829. <span class="detail-val">{{ detailHistOrd.createTime ? fmtTime(detailHistOrd.createTime) : '--' }}</span>
  2830. </div>
  2831. <div class="detail-row">
  2832. <span class="detail-lbl">{{ t('futuresPage.table.fillTime') }}</span>
  2833. <span class="detail-val">{{ detailHistOrd.dealTime ? fmtTime(detailHistOrd.dealTime) : '--' }}</span>
  2834. </div>
  2835. </div>
  2836. <div class="detail-section-gap"/>
  2837. <!-- 价格与数量 -->
  2838. <div class="detail-section">
  2839. <div class="detail-row">
  2840. <span class="detail-lbl">{{ t('futuresPage.table.triggerUsdt') }}</span>
  2841. <span class="detail-val">{{ detailHistOrd.triggerPrice && parseFloat(String(detailHistOrd.triggerPrice)) > 0 ? fmtPrice(String(detailHistOrd.triggerPrice)) : '--' }}</span>
  2842. </div>
  2843. <div class="detail-row">
  2844. <span class="detail-lbl">{{ t('futuresPage.table.orderPriceUsdt') }}</span>
  2845. <span class="detail-val">{{ histOrdEntrustPriceDisplay(detailHistOrd) }}</span>
  2846. </div>
  2847. <div class="detail-row">
  2848. <span class="detail-lbl">{{ t('futuresPage.table.openAvgUsdt') }}</span>
  2849. <span class="detail-val">{{ histOrdAvgOpenPrice(detailHistOrd) }}</span>
  2850. </div>
  2851. <div class="detail-row">
  2852. <span class="detail-lbl">{{ t('futuresPage.table.closeAvgUsdt') }}</span>
  2853. <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>
  2854. </div>
  2855. <div class="detail-row">
  2856. <span class="detail-lbl">{{ t('futuresPage.table.tpUsdt') }}</span>
  2857. <span class="detail-val up">{{ (detailHistOrd.profitPrice ?? detailHistOrd.stopProfitPrice) && parseFloat(String(detailHistOrd.profitPrice ?? detailHistOrd.stopProfitPrice ?? '0')) > 0 ? fmtPrice(String(detailHistOrd.profitPrice ?? detailHistOrd.stopProfitPrice ?? '')) : '--' }}</span>
  2858. </div>
  2859. <div class="detail-row">
  2860. <span class="detail-lbl">{{ t('futuresPage.table.slUsdt') }}</span>
  2861. <span class="detail-val dn">{{ (detailHistOrd.lossPrice ?? detailHistOrd.stopLossPrice) && parseFloat(String(detailHistOrd.lossPrice ?? detailHistOrd.stopLossPrice ?? '0')) > 0 ? fmtPrice(String(detailHistOrd.lossPrice ?? detailHistOrd.stopLossPrice ?? '')) : '--' }}</span>
  2862. </div>
  2863. <div class="detail-row">
  2864. <span class="detail-lbl">{{ t('futuresPage.table.marginUsdtBracket') }}</span>
  2865. <span class="detail-val">{{ detailHistOrd.principalAmount && parseFloat(detailHistOrd.principalAmount) > 0 ? parseFloat(detailHistOrd.principalAmount).toFixed(2) : '--' }}</span>
  2866. </div>
  2867. <div class="detail-row">
  2868. <span class="detail-lbl">{{ t('futuresPage.table.entrustVolCoin', { base: baseCoinFromSym(histOrdSymbol(detailHistOrd)) }) }}</span>
  2869. <span class="detail-val">{{ detailHistOrd.volume ?? detailHistOrd.size ?? '--' }}</span>
  2870. </div>
  2871. <div class="detail-row">
  2872. <span class="detail-lbl">{{ t('futuresPage.table.filledVolCoin', { base: baseCoinFromSym(histOrdSymbol(detailHistOrd)) }) }}</span>
  2873. <span class="detail-val">{{ detailHistOrd.tradedVolume ?? '--' }}</span>
  2874. </div>
  2875. <div class="detail-row">
  2876. <span class="detail-lbl">{{ t('futuresPage.table.feeHint') }}</span>
  2877. <span class="detail-val">{{ (parseFloat(detailHistOrd.openFee||'0') + parseFloat(detailHistOrd.closeFee||'0')) > 0 ? (parseFloat(detailHistOrd.openFee||'0') + parseFloat(detailHistOrd.closeFee||'0')).toFixed(4) : '--' }}</span>
  2878. </div>
  2879. </div>
  2880. </div>
  2881. </div>
  2882. </div>
  2883. </Transition>
  2884. <!-- ═══ 划转弹窗 ═══ -->
  2885. <Transition name="modal">
  2886. <div v-if="showTransfer" class="modal-mask" @click.self="showTransfer = false; showFromDrop = false; showToDrop = false">
  2887. <div class="modal-box" @click.stop>
  2888. <div class="modal-hd">
  2889. <span class="modal-title">{{ t('spot.transferModal.title') }}</span>
  2890. <button class="modal-close" @click="showTransfer = false">
  2891. <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>
  2892. </button>
  2893. </div>
  2894. <div class="modal-body">
  2895. <!-- 币种 -->
  2896. <div class="tf-field">
  2897. <div class="tf-label">{{ t('common.coin') }}</div>
  2898. <div class="tf-select tf-select--static">
  2899. <span>USDT</span>
  2900. </div>
  2901. </div>
  2902. <!-- 从 / 到:一侧固定资金,另一侧仅合约 / 跟单可选 -->
  2903. <div class="tf-direction">
  2904. <!-- 从 -->
  2905. <div class="tf-dir-col">
  2906. <div class="tf-dir-lbl">{{ t('common.from') }}</div>
  2907. <div v-if="transferFrom === 'SPOT'" class="tf-select tf-select--static">
  2908. <span>{{ transferWalletLabel('SPOT') }}</span>
  2909. </div>
  2910. <div v-else class="tf-select tf-select--btn" @click="showFromDrop = !showFromDrop; showToDrop = false">
  2911. <span>{{ transferWalletLabel(transferFrom) }}</span>
  2912. <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>
  2913. <div v-if="showFromDrop" class="tf-dropdown">
  2914. <div
  2915. v-for="opt in FUTURES_OTHER_WALLET_TYPES"
  2916. :key="opt"
  2917. class="tf-dropdown-item"
  2918. :class="{ active: transferFrom === opt }"
  2919. @click.stop="selectFromOther(opt)"
  2920. >{{ transferWalletLabel(opt) }}</div>
  2921. </div>
  2922. </div>
  2923. </div>
  2924. <!-- 交换按钮 -->
  2925. <button class="tf-swap" @click="swapTransferDirection">
  2926. <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>
  2927. </button>
  2928. <!-- 到 -->
  2929. <div class="tf-dir-col">
  2930. <div class="tf-dir-lbl">{{ t('common.to') }}</div>
  2931. <div
  2932. v-if="transferFrom === 'SWAP' || transferFrom === 'FOLLOW' || transferFrom === 'SPOT_TRADING'"
  2933. class="tf-select tf-select--static"
  2934. >
  2935. <span>{{ transferWalletLabel('SPOT') }}</span>
  2936. </div>
  2937. <div v-else class="tf-select tf-select--btn" @click="showToDrop = !showToDrop; showFromDrop = false">
  2938. <span>{{ transferWalletLabel(transferTo) }}</span>
  2939. <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>
  2940. <div v-if="showToDrop" class="tf-dropdown">
  2941. <div
  2942. v-for="opt in FUTURES_OTHER_WALLET_TYPES"
  2943. :key="opt"
  2944. class="tf-dropdown-item"
  2945. :class="{ active: transferTo === opt }"
  2946. @click.stop="selectToOther(opt)"
  2947. >{{ transferWalletLabel(opt) }}</div>
  2948. </div>
  2949. </div>
  2950. </div>
  2951. </div>
  2952. <!-- 划转数量 -->
  2953. <div class="tf-field">
  2954. <div class="tf-label">{{ t('spot.transferModal.amount') }}</div>
  2955. <div class="tf-amount-wrap">
  2956. <input
  2957. v-model="transferAmount"
  2958. type="number"
  2959. class="tf-amount-inp"
  2960. :placeholder="t('spot.transferModal.amountPlaceholder')"
  2961. :disabled="transferSubmitting"
  2962. @input="transferError = ''"
  2963. />
  2964. <div class="tf-amount-right">
  2965. <span class="tf-currency">USDT</span>
  2966. <span class="tf-sep">|</span>
  2967. <button class="tf-all" @click="transferAmount = transferAvail">{{ t('common.all') }}</button>
  2968. </div>
  2969. </div>
  2970. <div class="tf-avail">
  2971. {{ t('spot.transferModal.availableUsdt') }}
  2972. <span v-if="transferLoading" class="tf-avail-loading">{{ t('common.loading') }}</span>
  2973. <span v-else>{{ transferAvail }}</span>
  2974. </div>
  2975. <div v-if="transferError" class="tf-error">{{ transferError }}</div>
  2976. </div>
  2977. </div>
  2978. <div class="modal-ft">
  2979. <button class="tf-cancel" :disabled="transferSubmitting" @click="showTransfer = false">{{ t('common.cancel') }}</button>
  2980. <button class="tf-confirm" :disabled="transferSubmitting || transferLoading" @click="confirmTransfer">
  2981. {{ transferSubmitting ? t('spot.transferModal.transferring') : t('common.confirm') }}
  2982. </button>
  2983. </div>
  2984. </div>
  2985. </div>
  2986. </Transition>
  2987. <!-- ── TP/SL Modify Modal ── -->
  2988. <Transition name="modal">
  2989. <div v-if="showTpSlModal" class="modal-mask" @click.self="showTpSlModal = false">
  2990. <div class="modal-box" @click.stop>
  2991. <div class="modal-hd">
  2992. <span class="modal-title">{{ tpSlPosition?.cutList?.length ? t('futuresPage.modal.modifyTpSl') : t('futuresPage.modal.setTpSl') }}</span>
  2993. <button class="modal-close" @click="showTpSlModal = false">✕</button>
  2994. </div>
  2995. <div class="modal-body">
  2996. <!-- 仓位信息摘要 -->
  2997. <div class="tpsl-pos-summary">
  2998. <span class="fw6">{{ tpSlPosition?.coin?.symbol?.replace('/', '-') }}</span>
  2999. <span :class="tpSlPosition?.direction === 'BUY' ? 'up' : 'dn'" style="margin-left:8px">
  3000. {{ tpSlPosition?.direction === 'BUY' ? t('futuresPage.directions.long') : t('futuresPage.directions.short') }} {{ tpSlPosition?.leverage }}x
  3001. </span>
  3002. <span style="margin-left:auto;color:#8a92a8;font-size:11px">{{ t('futuresPage.modal.posQtyLabel') }} {{ tpSlPosition?.currentPosition }}</span>
  3003. </div>
  3004. <!-- 关键价格信息 -->
  3005. <div v-if="tpSlPosition" class="tpsl-info-rows">
  3006. <div class="tpsl-info-row"><span>{{ t('futuresPage.table.openPrice') }}</span><span>{{ fmtPrice(tpSlPosition.openPrice) }}</span></div>
  3007. <div class="tpsl-info-row"><span>{{ t('futuresPage.table.markPrice') }}</span><span>{{ fmtPrice(tpSlMarkPriceStr || tpSlPosition.currentPrice) }}</span></div>
  3008. <div class="tpsl-info-row"><span>{{ t('futuresPage.table.liqPrice') }}</span><span>{{ parseFloat(tpSlPosition.estimatedBlastPrice) > 0 ? fmtPrice(tpSlPosition.estimatedBlastPrice) : '--' }}</span></div>
  3009. </div>
  3010. <!-- 当前止盈止损委托列表 -->
  3011. <template v-if="tpSlPosition?.cutList?.length">
  3012. <div class="tpsl-section-title">{{ t('futuresPage.modal.currentTpSlSection') }}</div>
  3013. <div class="tpsl-current-list">
  3014. <div v-for="cut in tpSlPosition.cutList" :key="cut.id" class="tpsl-current-item">
  3015. <div class="tpsl-current-row">
  3016. <span v-if="cut.profitPrice && parseFloat(String(cut.profitPrice)) > 0" class="tpsl-badge tpsl-badge--profit">{{ t('futuresPage.modal.tpBadge') }}</span>
  3017. <span v-if="cut.lossPrice && parseFloat(String(cut.lossPrice)) > 0" class="tpsl-badge tpsl-badge--loss">{{ t('futuresPage.modal.slBadge') }}</span>
  3018. <span class="tpsl-current-detail">
  3019. <template v-if="cut.profitPrice && parseFloat(String(cut.profitPrice)) > 0">
  3020. {{ t('futuresPage.modal.tpPriceWord') }}<strong>{{ fmtPrice(String(cut.profitPrice)) }}</strong>
  3021. </template>
  3022. <template v-if="cut.lossPrice && parseFloat(String(cut.lossPrice)) > 0">
  3023. <span style="margin:0 6px;color:#3a3e4a">|</span>
  3024. {{ t('futuresPage.modal.slPriceWord') }}<strong>{{ fmtPrice(String(cut.lossPrice)) }}</strong>
  3025. </template>
  3026. </span>
  3027. <span v-if="cut.volume" class="tpsl-current-vol">{{ t('futuresPage.modal.volLabel') }} {{ cut.volume }}</span>
  3028. </div>
  3029. </div>
  3030. </div>
  3031. </template>
  3032. <!-- 修改输入区 -->
  3033. <div class="tpsl-section-title">{{ tpSlPosition?.cutList?.length ? t('futuresPage.modal.modifyToSection') : t('futuresPage.modal.setPricesSection') }}</div>
  3034. <div class="tf-row">
  3035. <label class="tf-label">{{ t('futuresPage.modal.tpTriggerLabel') }}<span style="color:#3a3e4a;font-weight:400">{{ t('futuresPage.modal.tpHintOptionalClear') }}</span></label>
  3036. <input v-model="tpSlProfitPrice" type="number" class="tf-inp tf-inp--profit" :placeholder="t('futuresPage.modal.placeholderTpTrigger')" />
  3037. </div>
  3038. <div class="tf-row">
  3039. <label class="tf-label">{{ t('futuresPage.modal.slTriggerLabel') }}<span style="color:#3a3e4a;font-weight:400">{{ t('futuresPage.modal.slHintOptionalClear') }}</span></label>
  3040. <input v-model="tpSlLossPrice" type="number" class="tf-inp tf-inp--loss" :placeholder="t('futuresPage.modal.placeholderSlTrigger')" />
  3041. </div>
  3042. <div v-if="tpSlError" class="tf-err">{{ tpSlError }}</div>
  3043. </div>
  3044. <div class="modal-ft">
  3045. <button class="modal-cancel" @click="showTpSlModal = false">{{ t('common.cancel') }}</button>
  3046. <button class="modal-confirm" :disabled="orderLoading" @click="confirmTpSl">{{ t('common.confirm') }}</button>
  3047. </div>
  3048. </div>
  3049. </div>
  3050. </Transition>
  3051. <!-- ── 平仓 Sheet ── -->
  3052. <Transition name="modal">
  3053. <div v-if="showCloseSheet && closeTargetPos" class="modal-mask" @click.self="showCloseSheet = false">
  3054. <div class="modal-box" @click.stop>
  3055. <div class="modal-hd">
  3056. <span class="modal-title">{{ t('futuresPage.modal.closePositionTitlePrefix') }} · {{ closeTargetPos.coin?.symbol?.replace('/', '-') }}</span>
  3057. <button class="modal-close" @click="showCloseSheet = false">✕</button>
  3058. </div>
  3059. <div class="modal-body close-sheet-stack">
  3060. <div class="tpsl-pos-summary">
  3061. <span :class="closeTargetPos.direction === 'BUY' ? 'up' : 'dn'">
  3062. {{ closeTargetPos.direction === 'BUY' ? t('futuresPage.directions.long') : t('futuresPage.directions.short') }} {{ closeTargetPos.leverage }}x
  3063. </span>
  3064. <span style="margin-left:auto;color:#8a92a8;font-size:11px">{{ t('futuresPage.table.closeAvail') }} {{ closeTargetPos.availablePosition }} {{ closeSheetBaseCurrency }}</span>
  3065. </div>
  3066. <div class="tpsl-info-rows">
  3067. <div class="tpsl-info-row"><span>{{ t('futuresPage.table.openPrice') }}</span><span>{{ fmtPrice(closeTargetPos.openPrice) }}</span></div>
  3068. <div class="tpsl-info-row"><span>{{ t('futuresPage.table.markPrice') }}</span><span>{{ fmtPrice(closeSheetMarkPriceStr || closeTargetPos.currentPrice) }}</span></div>
  3069. </div>
  3070. <!-- 市价/限价切换 -->
  3071. <div class="cs-tabs">
  3072. <button class="cs-tab" :class="{ act: closeIsMarket }" @click="closeIsMarket = true">{{ t('futuresPage.order.market') }}</button>
  3073. <button class="cs-tab" :class="{ act: !closeIsMarket }" @click="closeIsMarket = false">{{ t('futuresPage.order.limit') }}</button>
  3074. </div>
  3075. <!-- 价格 -->
  3076. <div v-if="!closeIsMarket" class="inp-block">
  3077. <div class="inp-wrap">
  3078. <span class="inp-lbl">{{ t('futuresPage.table.orderPrice') }} ({{ closeSheetQuoteCurrency }})</span>
  3079. <div class="inp-row">
  3080. <input v-model="closeSheetPrice" type="number" class="inp" :placeholder="t('futuresPage.placeholders.price')"/>
  3081. <button class="inp-quick" @click="closeSheetPrice = closeSheetMarkPriceStr || closeTargetPos.currentPrice">{{ t('futuresPage.table.markPriceBtn') }}</button>
  3082. </div>
  3083. </div>
  3084. </div>
  3085. <div v-else class="inp-block">
  3086. <div class="inp-market">{{ t('futuresPage.order.marketExe') }}</div>
  3087. </div>
  3088. <!-- 数量 -->
  3089. <div class="inp-block">
  3090. <div class="inp-wrap">
  3091. <span class="inp-lbl">{{ t('futuresPage.order.qtyLabel', { u: closeSheetBaseCurrency }) }}</span>
  3092. <div class="inp-row">
  3093. <input v-model="closeSheetVolume" type="number" class="inp" :placeholder="t('futuresPage.placeholders.amount')"
  3094. @input="() => { const a = parseFloat(closeTargetPos!.availablePosition); if (a>0) closeSheetPct = Math.min(100, Math.round(parseFloat(closeSheetVolume)/a*100)) || 0 }"/>
  3095. <button class="inp-quick" @click="closeSheetVolume = closeTargetPos.availablePosition; closeSheetPct = 100">{{ t('common.all') }}</button>
  3096. </div>
  3097. </div>
  3098. </div>
  3099. <!-- PCT 滑块 -->
  3100. <div class="pct-wrap">
  3101. <div class="pct-track-wrap">
  3102. <input type="range" v-model="closeSheetPct" min="0" max="100" step="1" class="pct-slider"/>
  3103. <div class="pct-dots">
  3104. <span v-for="d in [0,25,50,75,100]" :key="d" class="pct-dot" :class="{ fill: closeSheetPct >= d }"/>
  3105. </div>
  3106. </div>
  3107. <div class="pct-ticks">
  3108. <span>0%</span><span>25%</span><span>50%</span><span>75%</span><span>100%</span>
  3109. </div>
  3110. </div>
  3111. <!-- 预计盈亏 -->
  3112. <div class="tpsl-info-rows">
  3113. <div class="tpsl-info-row">
  3114. <span>{{ t('futuresPage.table.estimatedPnlRow') }}</span>
  3115. <span :class="closeSheetEstPnl >= 0 ? 'up' : 'dn'">
  3116. {{ closeSheetEstPnl >= 0 ? '+' : '' }}{{ closeSheetEstPnl.toFixed(4) }} {{ closeSheetQuoteCurrency }}
  3117. </span>
  3118. </div>
  3119. </div>
  3120. <div v-if="closeSheetError" class="tf-err">{{ closeSheetError }}</div>
  3121. </div>
  3122. <div class="modal-ft">
  3123. <button class="modal-cancel" @click="showCloseSheet = false">{{ t('common.cancel') }}</button>
  3124. <button class="modal-confirm" :disabled="orderLoading" @click="confirmClose">{{ t('futuresPage.modal.confirmCloseBtn') }}</button>
  3125. </div>
  3126. </div>
  3127. </div>
  3128. </Transition>
  3129. <AppModal
  3130. v-if="showActionConfirm"
  3131. :title="actionConfirmTitle"
  3132. :confirm-text="t('futuresPage.modal.ok')"
  3133. :cancel-text="t('common.cancel')"
  3134. :loading="actionConfirmLoading"
  3135. @confirm="onActionConfirmOk"
  3136. @cancel="onActionConfirmCancel"
  3137. >
  3138. <p class="action-confirm-msg">{{ actionConfirmMessage }}</p>
  3139. </AppModal>
  3140. </div><!-- /ft-page -->
  3141. </template>
  3142. <style scoped>
  3143. /* 二次确认弹窗文案 */
  3144. .action-confirm-msg {
  3145. margin: 0;
  3146. font-size: 14px;
  3147. line-height: 1.55;
  3148. color: var(--color-text-secondary, #9ca3b0);
  3149. }
  3150. /* ─── Reset / Base ─── */
  3151. * { box-sizing: border-box; }
  3152. .ft-page {
  3153. min-height: 100vh;
  3154. padding-top: var(--header-height, 60px);
  3155. box-sizing: border-box;
  3156. background: #141414;
  3157. color: #d1d1d2;
  3158. display: grid;
  3159. grid-template-columns: 1fr 283px;
  3160. grid-template-rows: 58px 420px 260px;
  3161. column-gap: 1px;
  3162. font-size: 13px;
  3163. font-family: var(--font-family);
  3164. }
  3165. /* ─── Ticker bar ─── */
  3166. .ticker-bar {
  3167. grid-column: 1;
  3168. grid-row: 1;
  3169. display: flex;
  3170. align-items: center;
  3171. height: 58px;
  3172. padding: 0 12px 0 8px;
  3173. background: #000;
  3174. border-bottom: 1px solid #212121;
  3175. z-index: 30;
  3176. min-width: 0;
  3177. }
  3178. .pair-trigger {
  3179. display: flex;
  3180. align-items: center;
  3181. gap: 8px;
  3182. cursor: pointer;
  3183. padding: 5px 10px 5px 6px;
  3184. border-radius: 6px;
  3185. position: relative;
  3186. flex-shrink: 0;
  3187. margin-right: 4px;
  3188. transition: background .15s;
  3189. }
  3190. .pair-trigger:hover { background: rgba(255,255,255,0.04); }
  3191. .coin-icon {
  3192. width: 32px; height: 32px;
  3193. border-radius: 50%;
  3194. display: flex; align-items: center; justify-content: center;
  3195. flex-shrink: 0; overflow: hidden; background: #2a2a2a;
  3196. }
  3197. .coin-icon img { width: 100%; height: 100%; object-fit: cover; }
  3198. .coin-icon-fb {
  3199. font-size: 13px; font-weight: 700; color: #f0b90b;
  3200. }
  3201. .pair-name-col { display: flex; flex-direction: column; gap: 1px; }
  3202. .pair-name-row { display: flex; align-items: center; gap: 4px; }
  3203. .pair-name { font-size: 18px; font-weight: 700; color: #e8ecf4; letter-spacing: 0.3px; }
  3204. .pair-sub { font-size: 10px; color: #5a6278; }
  3205. .caret { color: #6a7182; transition: transform .2s; flex-shrink: 0; }
  3206. .caret.open { transform: rotate(180deg); }
  3207. /* Pair dropdown */
  3208. .pair-drop {
  3209. position: absolute; top: calc(100% + 6px); left: 0;
  3210. width: 340px; background: #1d1d1d;
  3211. border: 1px solid rgba(255,255,255,0.1); border-radius: 10px;
  3212. box-shadow: 0 20px 60px rgba(0,0,0,.9); overflow: hidden; z-index: 200;
  3213. }
  3214. .pair-drop-search {
  3215. display: flex; align-items: center; gap: 8px;
  3216. padding: 10px 14px; border-bottom: 1px solid #212121;
  3217. }
  3218. .pair-search-input { flex: 1; background: none; color: #c7cce4; font-size: 13px; }
  3219. .pair-search-input::placeholder { color: #5a6278; }
  3220. .pair-list { max-height: 360px; overflow-y: auto; }
  3221. .pair-list::-webkit-scrollbar { width: 4px; }
  3222. .pair-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 2px; }
  3223. .pair-item {
  3224. display: grid; grid-template-columns: 22px 1fr 1fr 80px;
  3225. align-items: center; gap: 6px; padding: 9px 14px;
  3226. cursor: pointer; transition: background .12s;
  3227. }
  3228. .pair-item:hover, .pair-item.active { background: rgba(255,255,255,0.04); }
  3229. .pi-icon {
  3230. width: 20px; height: 20px; border-radius: 50%; overflow: hidden;
  3231. flex-shrink: 0; background: #2a2a2a;
  3232. display: flex; align-items: center; justify-content: center;
  3233. }
  3234. .pi-icon img { width: 100%; height: 100%; object-fit: cover; }
  3235. .pi-icon-fb { font-size: 10px; font-weight: 700; color: #f0b90b; }
  3236. .pi-sym { font-weight: 600; font-size: 12px; }
  3237. .pi-price { font-size: 12px; color: #9298ab; text-align: right; }
  3238. .pi-chg { font-size: 12px; text-align: right; }
  3239. .pair-empty { padding: 24px; text-align: center; color: #5a6278; font-size: 13px; }
  3240. /* Ticker price */
  3241. .ticker-price-wrap {
  3242. flex-shrink: 0; padding: 0 20px;
  3243. border-right: 1px solid #212121;
  3244. }
  3245. .ticker-price { font-size: 24px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
  3246. .ticker-usd { font-size: 11px; color: #5a6278; margin-top: 2px; }
  3247. /* Ticker stats */
  3248. .ticker-stats { display: flex; align-items: center; flex: 1; overflow: hidden; padding-left: 8px; }
  3249. .ts-item { display: flex; flex-direction: column; gap: 3px; flex-shrink: 0; padding: 0 16px; }
  3250. .ts-lbl { font-size: 11px; color: #5a6278; white-space: nowrap; }
  3251. .ts-val { font-size: 12px; font-weight: 500; white-space: nowrap; color: #c7cce4; }
  3252. /* ─── Page body (display:contents so children participate in ft-page grid) ─── */
  3253. .page-body {
  3254. display: contents;
  3255. }
  3256. /* ─── Chart area ─── */
  3257. .chart-area {
  3258. grid-column: 1;
  3259. grid-row: 2;
  3260. min-width: 0;
  3261. min-height: 0;
  3262. display: flex;
  3263. flex-direction: column;
  3264. background: #000;
  3265. }
  3266. /* Sub-nav */
  3267. .sub-nav {
  3268. display: flex;
  3269. align-items: center;
  3270. height: 38px;
  3271. padding: 0 8px;
  3272. border-bottom: 1px solid #212121;
  3273. flex-shrink: 0;
  3274. }
  3275. .snav-tab {
  3276. padding: 0 14px; height: 100%;
  3277. font-size: 13px; color: #6a7182;
  3278. border-bottom: 2px solid transparent; margin-bottom: -1px;
  3279. transition: all .15s; white-space: nowrap;
  3280. }
  3281. .snav-tab:hover { color: #c7cce4; }
  3282. .snav-tab.act { color: #e8ecf4; border-bottom-color: #f0b90b; }
  3283. /* Loading */
  3284. .chart-loading {
  3285. flex: 1; display: flex; align-items: center; justify-content: center;
  3286. }
  3287. .spinner {
  3288. width: 28px; height: 28px;
  3289. border: 2px solid rgba(255,255,255,0.07);
  3290. border-top-color: #f0b90b; border-radius: 50%;
  3291. animation: spin .8s linear infinite;
  3292. }
  3293. @keyframes spin { to { transform: rotate(360deg); } }
  3294. /* Chart + book side by side */
  3295. .chart-plus-book {
  3296. flex: 1; min-height: 0;
  3297. display: flex;
  3298. }
  3299. /* ─── Chart column ─── */
  3300. .chart-col {
  3301. flex: 1; min-width: 0;
  3302. display: flex;
  3303. border-right: 1px solid #212121;
  3304. }
  3305. /* Drawing sidebar */
  3306. .draw-side {
  3307. width: 44px; flex-shrink: 0;
  3308. display: flex; flex-direction: column; align-items: center;
  3309. padding: 44px 0 8px; gap: 2px;
  3310. border-right: 1px solid #212121;
  3311. }
  3312. .db {
  3313. width: 34px; height: 32px;
  3314. display: flex; align-items: center; justify-content: center;
  3315. color: #6a7182; border-radius: 4px; transition: all .12s; flex-shrink: 0;
  3316. }
  3317. .db:hover, .db--act { color: #e8ecf4; background: rgba(255,255,255,0.08); }
  3318. .db-sep { width: 20px; height: 1px; background: rgba(255,255,255,0.07); margin: 3px 0; }
  3319. .db-spacer { flex: 1; }
  3320. /* Chart main */
  3321. .chart-main {
  3322. flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0;
  3323. }
  3324. /* TF bar */
  3325. .tf-bar {
  3326. display: flex; align-items: center; gap: 1px;
  3327. padding: 0 8px; height: 36px;
  3328. border-bottom: 1px solid #212121;
  3329. flex-shrink: 0;
  3330. }
  3331. .tf-btn {
  3332. padding: 3px 7px; font-size: 12px; color: #6a7182;
  3333. border-radius: 3px; transition: all .12s; white-space: nowrap;
  3334. }
  3335. .tf-btn:hover { color: #c7cce4; background: rgba(255,255,255,0.05); }
  3336. .tf-btn.act { color: #f0b90b; }
  3337. .tf-more {
  3338. padding: 4px 6px; color: #6a7182; border-radius: 3px;
  3339. display: flex; align-items: center; justify-content: center;
  3340. transition: all .12s;
  3341. }
  3342. .tf-more:hover { color: #c7cce4; }
  3343. .tf-div { width: 1px; height: 14px; background: rgba(255,255,255,0.08); margin: 0 4px; flex-shrink: 0; }
  3344. .tf-spacer { flex: 1; }
  3345. .tf-icon {
  3346. width: 28px; height: 28px;
  3347. display: flex; align-items: center; justify-content: center;
  3348. color: #6a7182; border-radius: 3px; transition: all .12s; flex-shrink: 0;
  3349. }
  3350. .tf-icon:hover { color: #c7cce4; background: rgba(255,255,255,0.06); }
  3351. .tf-price-mode {
  3352. display: flex; align-items: center; gap: 3px;
  3353. padding: 3px 7px; font-size: 12px; color: #6a7182;
  3354. border-radius: 3px; transition: all .12s; white-space: nowrap;
  3355. }
  3356. .tf-price-mode:hover { color: #c7cce4; }
  3357. .ma-leg { font-size: 11px; font-weight: 600; margin-right: 2px; }
  3358. .ma5 { color: #f0b90b; }
  3359. .ma10 { color: #5d9cf5; }
  3360. .ma30 { color: #e84d5b; }
  3361. .tf-ind {
  3362. padding: 2px 6px; font-size: 11px; font-weight: 600;
  3363. border: 1px solid rgba(255,255,255,0.1); border-radius: 3px;
  3364. color: #6a7182; transition: all .12s; flex-shrink: 0; margin-left: 1px;
  3365. }
  3366. .tf-ind.act { color: #f0b90b; border-color: rgba(240,185,11,0.4); background: rgba(240,185,11,0.07); }
  3367. /* Chart canvas */
  3368. .chart-area-rel { flex: 1; position: relative; min-height: 0; overflow: hidden; }
  3369. /* z-index:1 使 chart-canvas-wrap 形成独立层叠上下文,内部 ohlcv-overlay(z:10)/tooltip(z:20) 被隔离,
  3370. 不会穿透到 coin-info-panel(z:2) / fr-panel(z:2) 之上 */
  3371. .chart-canvas-wrap { position: absolute; inset: 0; overflow: hidden; z-index: 1; }
  3372. .kline-canvas { width: 100%; height: 100%; }
  3373. .ohlcv-overlay {
  3374. position: absolute; top: 4px; left: 4px; right: 0; z-index: 10;
  3375. display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
  3376. font-size: 11px; color: #6a7182; pointer-events: none; padding: 2px 6px;
  3377. }
  3378. .ohlcv-sym { color: #9298ab; font-weight: 500; }
  3379. .ohlcv-item em { font-style: normal; color: #9298ab; }
  3380. /* Hover detail tooltip */
  3381. .kline-tooltip {
  3382. position: absolute; top: 24px; z-index: 20;
  3383. background: rgba(16, 17, 22, 0.88);
  3384. border: 1px solid rgba(255,255,255,0.07);
  3385. border-radius: 4px; padding: 6px 10px;
  3386. font-size: 11px; pointer-events: none; min-width: 160px;
  3387. }
  3388. .kt-time { color: #6a7182; margin-bottom: 3px; font-size: 10.5px; }
  3389. .kt-row { display: flex; justify-content: space-between; gap: 16px; line-height: 1.75; }
  3390. .kt-lbl { color: #6a7182; }
  3391. .kt-val { color: #d1d4dc; }
  3392. .kt-c-ma5 { color: #f0b90b; }
  3393. .kt-c-ma10 { color: #5d9cf5; }
  3394. .kt-c-ma30 { color: #e84d5b; }
  3395. /* ─── Order book ─── */
  3396. .book-col {
  3397. width: 214px; flex-shrink: 0;
  3398. background: #000;
  3399. display: flex; flex-direction: column; overflow: hidden;
  3400. }
  3401. .book-hd-tabs {
  3402. display: flex; align-items: center;
  3403. padding: 0 12px; height: 40px;
  3404. border-bottom: 1px solid #212121;
  3405. flex-shrink: 0;
  3406. }
  3407. .book-hd-ctrl {
  3408. display: flex; align-items: center;
  3409. padding: 0 12px; height: 34px;
  3410. border-bottom: 1px solid #212121;
  3411. flex-shrink: 0; gap: 6px;
  3412. }
  3413. .book-tab {
  3414. padding: 0 6px; height: 100%;
  3415. font-size: 13px; font-weight: 500; color: #45454a;
  3416. position: relative; transition: color .12s; white-space: nowrap;
  3417. }
  3418. .book-tab:hover { color: #d1d1d2; }
  3419. .book-tab.act { color: #fff; }
  3420. .book-tab.act::after {
  3421. content: ''; position: absolute; bottom: 0; left: 50%;
  3422. transform: translateX(-50%); width: 16px; height: 3px;
  3423. background: #fff; border-radius: 2px 2px 0 0;
  3424. }
  3425. .book-hd-sp { flex: 1; }
  3426. .book-layouts { display: flex; align-items: center; gap: 4px; }
  3427. .bl-btn {
  3428. width: 24px; height: 24px;
  3429. display: flex; align-items: center; justify-content: center;
  3430. color: #5a6278; border-radius: 3px; transition: all .12s;
  3431. }
  3432. .bl-btn:hover { background: rgba(255,255,255,0.06); color: #c7cce4; }
  3433. .bl-btn.act { background: rgba(255,255,255,0.08); color: #c7cce4; }
  3434. .prec-wrap { position: relative; margin-left: auto; }
  3435. .book-precision {
  3436. font-size: 11px; color: #6a7182;
  3437. padding: 3px 6px; border: 1px solid rgba(255,255,255,0.1);
  3438. border-radius: 3px; display: flex; align-items: center; gap: 3px; transition: all .12s;
  3439. }
  3440. .book-precision:hover { color: #c7cce4; border-color: rgba(255,255,255,0.2); }
  3441. .prec-drop {
  3442. position: absolute; top: calc(100% + 4px); right: 0;
  3443. min-width: 80px; background: #1d1d1d;
  3444. border: 1px solid rgba(255,255,255,0.1); border-radius: 8px;
  3445. box-shadow: 0 8px 24px rgba(0,0,0,.7); z-index: 100; overflow: hidden;
  3446. }
  3447. .pdrop-item {
  3448. display: block; width: 100%; padding: 8px 14px;
  3449. font-size: 12px; color: #9298ab; text-align: left; transition: all .12s;
  3450. }
  3451. .pdrop-item:hover { background: rgba(255,255,255,0.06); color: #e8ecf4; }
  3452. .pdrop-item.act { color: #fff; }
  3453. .book-col-head {
  3454. display: grid; grid-template-columns: 1fr 1fr 1fr;
  3455. padding: 4px 8px; font-size: 10px; color: #5a6278;
  3456. flex-shrink: 0;
  3457. }
  3458. .book-asks, .book-bids { overflow: hidden; }
  3459. .book-row {
  3460. position: relative; display: grid; grid-template-columns: 1fr 1fr 1fr;
  3461. padding: 2px 8px; font-size: 11px; align-items: center; cursor: pointer;
  3462. }
  3463. .book-row:hover { background: rgba(255,255,255,0.02); }
  3464. .br-depth {
  3465. position: absolute; top: 0; right: 0; bottom: 0;
  3466. opacity: 0.12; pointer-events: none; transition: width .3s;
  3467. }
  3468. .br-depth--ask { background: #f6465d; }
  3469. .br-depth--bid { background: #02c076; }
  3470. .br-p { font-weight: 500; z-index: 1; font-variant-numeric: tabular-nums; }
  3471. .br-a { text-align: right; color: #7a8096; z-index: 1; font-size: 10.5px; }
  3472. .br-t { text-align: right; color: #5a6278; z-index: 1; font-size: 10.5px; }
  3473. .tr { text-align: right; }
  3474. .book-mid {
  3475. display: flex; align-items: center; gap: 6px;
  3476. padding: 6px 8px; flex-shrink: 0;
  3477. }
  3478. .book-mid.up { background: rgba(2,192,118,0.06); }
  3479. .book-mid.dn { background: rgba(246,70,93,0.06); }
  3480. .bm-price {
  3481. font-size: 16px; font-weight: 700;
  3482. font-variant-numeric: tabular-nums; letter-spacing: 0.02em;
  3483. }
  3484. .bm-mark-price {
  3485. margin-left: auto; font-size: 11px; color: #6a7182;
  3486. font-variant-numeric: tabular-nums;
  3487. }
  3488. .bs-ratio { margin-top: auto; padding: 6px 8px; border-top: 1px solid #212121; flex-shrink: 0; }
  3489. .bs-bar { display: flex; height: 3px; border-radius: 2px; overflow: hidden; margin-bottom: 4px; }
  3490. .bs-b { background: #02c076; transition: width .5s; }
  3491. .bs-s { background: #f6465d; transition: width .5s; }
  3492. .bs-lbl { display: flex; justify-content: space-between; font-size: 11px; }
  3493. /* ─── Trade panel ─── */
  3494. .trade-panel {
  3495. grid-column: 2;
  3496. grid-row: 1 / span 3;
  3497. background: #000;
  3498. display: flex; flex-direction: column;
  3499. overflow-y: auto;
  3500. min-height: 0;
  3501. }
  3502. .trade-panel::-webkit-scrollbar { width: 3px; }
  3503. .trade-panel::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
  3504. /* 上线初期:交易禁用提示 */
  3505. /* TP header */
  3506. .tp-hd {
  3507. display: flex; align-items: center;
  3508. padding: 8px 12px 8px; gap: 6px;
  3509. border-bottom: 1px solid #212121;
  3510. position: relative;
  3511. }
  3512. .margin-wrap { position: relative; }
  3513. .margin-btn {
  3514. display: flex; align-items: center; gap: 3px;
  3515. font-size: 12px; color: #9298ab;
  3516. padding: 4px 10px; border-radius: 4px;
  3517. border: 1px solid rgba(255,255,255,0.12);
  3518. transition: all .12s;
  3519. }
  3520. .margin-btn:hover { border-color: rgba(255,255,255,0.25); color: #c7cce4; }
  3521. .margin-drop {
  3522. position: absolute; top: calc(100% + 4px); left: 0;
  3523. min-width: 100px; background: #1d1d1d;
  3524. border: 1px solid rgba(255,255,255,0.1); border-radius: 8px;
  3525. box-shadow: 0 8px 24px rgba(0,0,0,.7); z-index: 100; overflow: hidden;
  3526. }
  3527. .mdrop-item {
  3528. display: block; width: 100%; padding: 10px 14px;
  3529. font-size: 13px; color: #9298ab; text-align: left; transition: all .12s;
  3530. }
  3531. .mdrop-item:hover { background: rgba(255,255,255,0.06); color: #e8ecf4; }
  3532. .mdrop-item.act { color: #fff; }
  3533. .tp-hd-sp { flex: 1; }
  3534. .lev-wrap { position: relative; }
  3535. .lev-btn {
  3536. display: flex; align-items: center; gap: 3px;
  3537. font-size: 13px; font-weight: 700; color: #f0b90b;
  3538. padding: 4px 10px; border-radius: 4px;
  3539. border: 1px solid rgba(240,185,11,0.4);
  3540. background: rgba(240,185,11,0.06);
  3541. transition: all .12s;
  3542. }
  3543. .lev-btn:hover { background: rgba(240,185,11,0.12); }
  3544. .lev-panel {
  3545. position: absolute; top: calc(100% + 4px); right: 0;
  3546. width: 200px; background: #1d1d1d;
  3547. border: 1px solid rgba(255,255,255,0.1); border-radius: 8px;
  3548. box-shadow: 0 8px 24px rgba(0,0,0,.7); z-index: 100; padding: 14px;
  3549. }
  3550. .lp-title { font-size: 12px; color: #5a6278; margin-bottom: 10px; }
  3551. .lp-pills { display: flex; flex-wrap: wrap; gap: 6px; }
  3552. .lp-pills--full { width: 100%; }
  3553. .lp-pill {
  3554. padding: 5px 12px; font-size: 12px;
  3555. border-radius: 4px; border: 1px solid rgba(255,255,255,0.1);
  3556. color: #6a7182; transition: all .12s;
  3557. }
  3558. .lp-pill:hover { border-color: #f0b90b; color: #f0b90b; }
  3559. .lp-pill.act { background: rgba(240,185,11,0.1); border-color: #f0b90b; color: #f0b90b; }
  3560. .lp-stepper {
  3561. display: flex; align-items: center; gap: 8px;
  3562. margin-bottom: 10px;
  3563. }
  3564. .lp-step-btn {
  3565. width: 28px; height: 28px;
  3566. font-size: 16px; font-weight: 700; color: #c7cce4;
  3567. background: rgba(255,255,255,0.06);
  3568. border-radius: 4px;
  3569. transition: all .12s;
  3570. }
  3571. .lp-step-btn:hover:not(:disabled) { background: rgba(255,255,255,0.12); }
  3572. .lp-step-btn:disabled { opacity: 0.4; cursor: not-allowed; }
  3573. .lp-step-val {
  3574. flex: 1; text-align: center;
  3575. font-size: 16px; font-weight: 700; color: #f0b90b;
  3576. }
  3577. .lp-range {
  3578. width: 100%; height: 4px; cursor: pointer;
  3579. accent-color: #f0b90b; margin-bottom: 4px;
  3580. }
  3581. .lp-range-ticks {
  3582. display: flex; justify-content: space-between;
  3583. font-size: 10px; color: #5a6278; margin-bottom: 10px;
  3584. }
  3585. .lp-actions {
  3586. display: grid; grid-template-columns: 1fr 1fr;
  3587. gap: 8px; margin-top: 10px;
  3588. }
  3589. .lp-cancel, .lp-confirm {
  3590. height: 32px; border-radius: 4px;
  3591. font-size: 13px; font-weight: 600;
  3592. transition: all .12s;
  3593. }
  3594. .lp-cancel { background: rgba(255,255,255,0.06); color: #c7cce4; }
  3595. .lp-cancel:hover { background: rgba(255,255,255,0.12); }
  3596. .lp-confirm { background: #f0b90b; color: #1a1a1a; }
  3597. .lp-confirm:hover { opacity: 0.9; }
  3598. /* Vol unit dropdown */
  3599. .vol-unit-wrap { position: relative; }
  3600. .vol-unit-drop {
  3601. position: absolute; top: calc(100% + 4px); right: 0;
  3602. min-width: 80px; background: #1d1d1d;
  3603. border: 1px solid rgba(255,255,255,0.1); border-radius: 6px;
  3604. box-shadow: 0 8px 24px rgba(0,0,0,.7); z-index: 100; overflow: hidden;
  3605. }
  3606. .vud-item {
  3607. display: block; width: 100%; padding: 6px 12px;
  3608. font-size: 12px; color: #9298ab; text-align: left;
  3609. transition: all .12s;
  3610. }
  3611. .vud-item:hover { background: rgba(255,255,255,0.06); color: #e8ecf4; }
  3612. .vud-item.act { color: #f0b90b; }
  3613. /* Close sheet tabs */
  3614. .cs-tabs {
  3615. display: flex; gap: 6px; padding: 8px 0 4px;
  3616. }
  3617. .cs-tab {
  3618. flex: 1; height: 30px; font-size: 13px; font-weight: 500;
  3619. color: #8b8b8e; background: rgba(255,255,255,0.06);
  3620. border-radius: 6px; transition: all .12s;
  3621. }
  3622. .cs-tab:hover { color: #c7cce4; }
  3623. .cs-tab.act { color: #1a1a1a; background: #f0b90b; }
  3624. /* 平仓弹窗:区块左右对齐 + 比例滑块/刻度(原 pct-* 未定义样式会导致刻度粘连) */
  3625. .close-sheet-stack .tpsl-pos-summary {
  3626. padding: 10px 12px;
  3627. }
  3628. .close-sheet-stack .tpsl-info-rows {
  3629. padding: 10px 12px;
  3630. margin: 0;
  3631. }
  3632. .close-sheet-stack .inp-block {
  3633. padding-left: 0;
  3634. padding-right: 0;
  3635. }
  3636. .close-sheet-stack .cs-tabs {
  3637. padding-left: 0;
  3638. padding-right: 0;
  3639. }
  3640. .close-sheet-stack .pct-wrap {
  3641. display: flex;
  3642. flex-direction: column;
  3643. gap: 6px;
  3644. padding: 2px 0 0;
  3645. width: 100%;
  3646. }
  3647. .close-sheet-stack .pct-track-wrap {
  3648. position: relative;
  3649. padding: 6px 10px 20px;
  3650. box-sizing: border-box;
  3651. }
  3652. .close-sheet-stack .pct-slider {
  3653. display: block;
  3654. width: 100%;
  3655. margin: 0;
  3656. height: 20px;
  3657. cursor: pointer;
  3658. -webkit-appearance: none;
  3659. appearance: none;
  3660. background: transparent;
  3661. }
  3662. .close-sheet-stack .pct-slider:focus {
  3663. outline: none;
  3664. }
  3665. .close-sheet-stack .pct-slider::-webkit-slider-runnable-track {
  3666. height: 4px;
  3667. border-radius: 2px;
  3668. background: rgba(255, 255, 255, 0.12);
  3669. }
  3670. .close-sheet-stack .pct-slider::-webkit-slider-thumb {
  3671. -webkit-appearance: none;
  3672. width: 14px;
  3673. height: 14px;
  3674. margin-top: -5px;
  3675. border-radius: 50%;
  3676. background: #fff;
  3677. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
  3678. }
  3679. .close-sheet-stack .pct-slider::-moz-range-track {
  3680. height: 4px;
  3681. border-radius: 2px;
  3682. background: rgba(255, 255, 255, 0.12);
  3683. }
  3684. .close-sheet-stack .pct-slider::-moz-range-thumb {
  3685. width: 14px;
  3686. height: 14px;
  3687. border: none;
  3688. border-radius: 50%;
  3689. background: #fff;
  3690. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
  3691. }
  3692. .close-sheet-stack .pct-dots {
  3693. display: flex;
  3694. justify-content: space-between;
  3695. position: absolute;
  3696. left: 10px;
  3697. right: 10px;
  3698. bottom: 8px;
  3699. pointer-events: none;
  3700. }
  3701. .close-sheet-stack .pct-dot {
  3702. width: 5px;
  3703. height: 5px;
  3704. border-radius: 50%;
  3705. background: rgba(255, 255, 255, 0.18);
  3706. }
  3707. .close-sheet-stack .pct-dot.fill {
  3708. background: #f0b90b;
  3709. }
  3710. .close-sheet-stack .pct-ticks {
  3711. display: grid;
  3712. grid-template-columns: repeat(5, minmax(0, 1fr));
  3713. gap: 4px;
  3714. padding: 0 10px;
  3715. font-size: 11px;
  3716. color: #6a7182;
  3717. line-height: 1.3;
  3718. }
  3719. .close-sheet-stack .pct-ticks span:nth-child(1) {
  3720. text-align: left;
  3721. }
  3722. .close-sheet-stack .pct-ticks span:nth-child(2),
  3723. .close-sheet-stack .pct-ticks span:nth-child(3),
  3724. .close-sheet-stack .pct-ticks span:nth-child(4) {
  3725. text-align: center;
  3726. }
  3727. .close-sheet-stack .pct-ticks span:nth-child(5) {
  3728. text-align: right;
  3729. }
  3730. /* TP/SL info rows */
  3731. .tpsl-info-rows {
  3732. display: flex; flex-direction: column; gap: 4px;
  3733. margin: 8px 0;
  3734. padding: 8px 10px;
  3735. background: rgba(255,255,255,0.04);
  3736. border-radius: 6px;
  3737. }
  3738. .tpsl-info-row {
  3739. display: flex; justify-content: space-between;
  3740. font-size: 12px;
  3741. }
  3742. .tpsl-info-row span:first-child { color: #6a7182; }
  3743. .tpsl-info-row span:last-child { color: #c7cce4; font-weight: 500; }
  3744. /* Assets tab */
  3745. .assets-grid {
  3746. display: grid; grid-template-columns: repeat(3, 1fr);
  3747. gap: 12px; padding: 16px;
  3748. }
  3749. .asset-card {
  3750. padding: 14px 16px;
  3751. background: rgba(255,255,255,0.04);
  3752. border: 1px solid rgba(255,255,255,0.06);
  3753. border-radius: 8px;
  3754. }
  3755. .asset-lbl { font-size: 12px; color: #6a7182; margin-bottom: 6px; }
  3756. .asset-val {
  3757. font-size: 18px; font-weight: 700; color: #e8ecf4;
  3758. font-variant-numeric: tabular-nums;
  3759. }
  3760. .asset-val.up { color: #02c076; }
  3761. .asset-val.dn { color: #f6465d; }
  3762. .asset-cur { font-size: 11px; color: #8b8b8e; font-weight: 500; margin-left: 4px; }
  3763. .asset-card--actions {
  3764. display: flex; align-items: center; justify-content: center;
  3765. }
  3766. .asset-btn {
  3767. padding: 8px 18px; border-radius: 6px;
  3768. background: #f0b90b; color: #1a1a1a;
  3769. font-size: 13px; font-weight: 600;
  3770. transition: opacity .12s;
  3771. }
  3772. .asset-btn:hover { opacity: 0.9; }
  3773. /* Order type */
  3774. .ot-row {
  3775. display: flex; align-items: center;
  3776. padding: 2px 10px 8px; gap: 2px;
  3777. border-bottom: 1px solid #212121;
  3778. }
  3779. .ot-btn {
  3780. padding: 5px 9px; font-size: 12px; font-weight: 500;
  3781. border-radius: 4px; color: #6a7182; transition: all .12s;
  3782. }
  3783. .ot-btn.act { background: rgba(255,255,255,0.08); color: #e8ecf4; }
  3784. .ot-cond { display: flex; align-items: center; gap: 3px; }
  3785. .ot-cond-wrap { position: relative; }
  3786. .cond-drop {
  3787. position: absolute; top: calc(100% + 4px); left: 0; right: 0;
  3788. background: #1e2028; border: 1px solid #2a2a2a; border-radius: 4px;
  3789. overflow: hidden; z-index: 200;
  3790. }
  3791. .cond-drop-item {
  3792. padding: 8px 10px; font-size: 12px; color: #8a91a0; cursor: pointer;
  3793. transition: background .12s, color .12s;
  3794. }
  3795. .cond-drop-item:hover { background: rgba(255,255,255,0.06); color: #e8ecf4; }
  3796. .cond-drop-item.act { color: var(--color-primary); }
  3797. .ot-info {
  3798. margin-left: auto; color: #6a7182;
  3799. width: 24px; height: 24px;
  3800. display: flex; align-items: center; justify-content: center;
  3801. border-radius: 50%; transition: all .12s;
  3802. }
  3803. .ot-info:hover { color: #c7cce4; }
  3804. /* Panel body */
  3805. .tp-body { display: flex; flex-direction: column; padding-bottom: 10px; }
  3806. .avail-row {
  3807. display: flex; align-items: center;
  3808. font-size: 12px; padding: 8px 12px;
  3809. }
  3810. .avail-lbl { color: #6a7182; margin-right: 6px; flex-shrink: 0; }
  3811. .avail-val { color: #c7cce4; font-weight: 500; flex: 1; }
  3812. .avail-acts { display: flex; gap: 4px; }
  3813. .avail-act {
  3814. width: 22px; height: 22px;
  3815. display: flex; align-items: center; justify-content: center;
  3816. color: #6a7182; border-radius: 3px; border: 1px solid rgba(255,255,255,0.1);
  3817. transition: all .12s;
  3818. }
  3819. .avail-act:hover { color: #c7cce4; border-color: rgba(255,255,255,0.25); }
  3820. /* Input blocks */
  3821. .inp-block { padding: 6px 12px 0; }
  3822. .inp-wrap {
  3823. background: rgba(255,255,255,0.12);
  3824. border-radius: 8px;
  3825. padding: 8px 10px 6px;
  3826. transition: background .15s;
  3827. }
  3828. .inp-wrap:focus-within { background: rgba(255,255,255,0.15); }
  3829. .inp-lbl { display: block; font-size: 12px; color: #8b8b8e; margin-bottom: 2px; }
  3830. .inp-lbl-row {
  3831. display: flex; align-items: center; justify-content: space-between;
  3832. margin-bottom: 2px;
  3833. }
  3834. .inp-lbl-row .inp-lbl { margin-bottom: 0; }
  3835. .inp-row { display: flex; align-items: center; gap: 4px; }
  3836. .inp {
  3837. flex: 1; background: none; color: #fff;
  3838. font-size: 18px; min-width: 0; padding: 0; font-weight: 700;
  3839. border: none; outline: none;
  3840. -webkit-appearance: none; appearance: none;
  3841. }
  3842. .inp::placeholder { color: #5a6278; font-size: 12px; font-weight: 400; }
  3843. .inp::-webkit-inner-spin-button, .inp::-webkit-outer-spin-button { display: none; }
  3844. .inp-quick {
  3845. padding: 2px 8px; font-size: 12px; font-weight: 700; color: #f0b90b;
  3846. border-radius: 4px; flex-shrink: 0; white-space: nowrap;
  3847. transition: all .12s;
  3848. }
  3849. .inp-quick:hover { background: rgba(240,185,11,0.1); }
  3850. .inp-bbo {
  3851. background: rgba(255,255,255,0.12); color: #fff; font-weight: 400;
  3852. padding: 3px 8px; border-radius: 6px;
  3853. }
  3854. .inp-bbo:hover { background: rgba(255,255,255,0.18); }
  3855. .inp-unit {
  3856. font-size: 12px; color: #8b8b8e; flex-shrink: 0;
  3857. }
  3858. .inp-market {
  3859. height: 42px; display: flex; align-items: center;
  3860. padding: 0 10px; background: rgba(255,255,255,0.12);
  3861. border-radius: 8px; color: #6a7182; font-size: 13px;
  3862. }
  3863. .vol-unit-btn {
  3864. display: flex; align-items: center; gap: 3px;
  3865. font-size: 11px; color: #9298ab; transition: all .12s; flex-shrink: 0;
  3866. }
  3867. .vol-unit-btn:hover { color: #c7cce4; }
  3868. /* PCT buttons */
  3869. .pct-btns { display: flex; gap: 4px; padding: 10px 12px 4px; }
  3870. .pct-btn {
  3871. flex: 1; font-size: 12px; padding: 4px 0; border-radius: 4px;
  3872. border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
  3873. color: #8b8b8e; cursor: pointer; transition: all .15s;
  3874. }
  3875. .pct-btn:hover { background: rgba(255,255,255,0.1); color: #e8ecf4; }
  3876. .trade-err { font-size: 12px; color: #f6465d; padding: 0 12px 4px; }
  3877. /* Submit buttons (logged in) */
  3878. .submit-row {
  3879. display: grid; grid-template-columns: 1fr 1fr;
  3880. gap: 8px; padding: 4px 12px;
  3881. }
  3882. .sub-btn {
  3883. height: 44px; border-radius: 6px; font-size: 14px; font-weight: 700;
  3884. display: flex; align-items: center; justify-content: center;
  3885. transition: opacity .15s;
  3886. }
  3887. .sub-btn:disabled { opacity: 0.35; cursor: not-allowed; }
  3888. .sub-buy { background: #02c076; color: #0b0e16; }
  3889. .sub-sell { background: #f6465d; color: #fff; }
  3890. .sub-buy:not(:disabled):hover, .sub-sell:not(:disabled):hover { opacity: 0.9; }
  3891. .spin-sm {
  3892. display: inline-block; width: 16px; height: 16px;
  3893. border: 2px solid rgba(255,255,255,0.3); border-top-color: currentColor;
  3894. border-radius: 50%; animation: spin .7s linear infinite;
  3895. }
  3896. /* Auth buttons (not logged in) */
  3897. .auth-btns {
  3898. display: flex; flex-direction: column; gap: 8px;
  3899. padding: 4px 12px;
  3900. }
  3901. .auth-btn {
  3902. width: 100%; height: 40px; border-radius: 9999px;
  3903. font-size: 14px; font-weight: 500;
  3904. display: flex; align-items: center; justify-content: center;
  3905. text-decoration: none; transition: all .15s;
  3906. }
  3907. .auth-register {
  3908. background: #fff; color: #000;
  3909. }
  3910. .auth-register:hover { background: rgba(255,255,255,0.9); }
  3911. .auth-login {
  3912. background: rgba(255,255,255,0.12); color: #fff;
  3913. }
  3914. .auth-login:hover { background: rgba(255,255,255,0.18); }
  3915. /* Contract info */
  3916. .contract-info {
  3917. border-top: 1px solid #212121;
  3918. margin: 6px 12px 0; padding-top: 10px;
  3919. }
  3920. .ci-title { font-size: 12px; font-weight: 600; color: #9298ab; margin-bottom: 8px; }
  3921. .ci-row { display: flex; justify-content: space-between; font-size: 11px; color: #5a6278; margin-bottom: 6px; }
  3922. .ci-row span:last-child { color: #9298ab; text-align: right; }
  3923. /* ─── Bottom area ─── */
  3924. .bottom-area {
  3925. grid-column: 1;
  3926. grid-row: 3;
  3927. background: #000;
  3928. border-top: 1px solid #212121;
  3929. display: flex;
  3930. flex-direction: column;
  3931. min-height: 260px;
  3932. min-width: 0;
  3933. }
  3934. .tab-scroll-area {
  3935. flex: 1;
  3936. overflow-y: auto;
  3937. min-height: 0;
  3938. min-width: 0;
  3939. }
  3940. .tab-scroll-area::-webkit-scrollbar { width: 3px; }
  3941. .tab-scroll-area::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
  3942. .btab-bar {
  3943. display: flex; align-items: center;
  3944. padding: 0 12px; height: 42px;
  3945. border-bottom: 1px solid #212121;
  3946. }
  3947. .btab-sp { flex: 1; }
  3948. .btab {
  3949. padding: 0 12px; height: 100%;
  3950. font-size: 12.5px; color: #6a7182;
  3951. border-bottom: 2px solid transparent; margin-bottom: -1px;
  3952. transition: all .15s; display: flex; align-items: center; gap: 4px;
  3953. white-space: nowrap;
  3954. }
  3955. .btab:hover { color: #c7cce4; }
  3956. .btab.act { color: #f0b90b; border-bottom-color: #f0b90b; }
  3957. .badge {
  3958. font-size: 10px; background: rgba(240,185,11,0.15); color: #f0b90b;
  3959. border-radius: 10px; padding: 0 5px; min-width: 16px; text-align: center;
  3960. }
  3961. .only-cur {
  3962. display: flex; align-items: center; gap: 5px;
  3963. font-size: 12px; color: #6a7182; cursor: pointer; padding: 0 8px;
  3964. white-space: nowrap;
  3965. }
  3966. .only-cur input { accent-color: #f0b90b; }
  3967. .close-all {
  3968. padding: 5px 12px; font-size: 12px; font-weight: 500;
  3969. background: transparent; color: #f0b90b;
  3970. border: 1px solid rgba(240,185,11,0.45); border-radius: 4px;
  3971. transition: all .12s; white-space: nowrap; margin-left: 6px;
  3972. }
  3973. .close-all:hover { background: rgba(240,185,11,0.1); }
  3974. /* Data tables */
  3975. .empty-tip {
  3976. padding: 28px 16px; text-align: center; color: #6a7182; font-size: 13px;
  3977. display: flex; align-items: center; justify-content: center; gap: 6px;
  3978. }
  3979. .trades-list { overflow-y: auto; flex: 1; }
  3980. .trades-list::-webkit-scrollbar { width: 3px; height: 3px; }
  3981. .trades-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
  3982. .trade-row {
  3983. display: grid; grid-template-columns: 1fr 1fr 1fr;
  3984. padding: 3px 10px; font-size: 12px; line-height: 1.6;
  3985. }
  3986. .trade-time { color: #6a7182; }
  3987. .tip-link { color: #f0b90b; text-decoration: none; }
  3988. .tip-link:hover { text-decoration: underline; }
  3989. /* 表格容器:列少时右侧留白,内容超宽才出滚动条 */
  3990. .data-tbl { overflow-x: auto; position: relative; }
  3991. .data-tbl::-webkit-scrollbar { width: 3px; height: 3px; }
  3992. .data-tbl::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
  3993. /* 行通用:宽度自适应但不小于容器 */
  3994. .dt-head, .dt-row {
  3995. display: grid;
  3996. padding: 6px 10px;
  3997. align-items: center; gap: 0; font-size: 12px;
  3998. min-width: 100%; width: max-content;
  3999. box-sizing: border-box;
  4000. }
  4001. /* 直接子 span 居中(> 只选直接子元素,不影响嵌套 span 如 entrust-type-tag) */
  4002. .dt-head > span, .dt-row > span {
  4003. display: block;
  4004. overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  4005. text-align: center; padding: 0 5px;
  4006. }
  4007. .dt-head > span { color: #6a7182; }
  4008. /* 首列左对齐 */
  4009. .dt-head > span:first-child, .dt-row > span:first-child { text-align: left; }
  4010. /* 操作列:flex 布局,不截断 */
  4011. .dt-row > span.op-col {
  4012. display: flex; overflow: visible; white-space: nowrap;
  4013. text-align: left; padding: 0;
  4014. }
  4015. /* ── 统一基准列宽 ──
  4016. 普通列: 84px 宽列(大数字/盈亏): 92px 时间列: 104px
  4017. 细列(状态/类型): 70px 合约列: 84px 操作(仓位): 160px 操作(委托): 82px */
  4018. /* 仓位:合约|方向杠杆|开仓价|标记价|持仓量|保证金|保证金率|浮动盈亏|收益率|强平价|开仓时间|操作 */
  4019. .dt-head--pos, .dt-row--pos {
  4020. grid-template-columns: 84px 84px 84px 84px 70px 92px 70px 92px 70px 84px 104px 160px;
  4021. }
  4022. /* 当前委托:合约|方向杠杆|类型|委托价|触发价|委托量|已成交|止盈价|止损价|时间|操作 */
  4023. .dt-head--ord, .dt-row--ord {
  4024. grid-template-columns: 84px 84px 70px 84px 84px 70px 70px 84px 84px 104px 82px;
  4025. }
  4026. /* 固定操作列吸附右侧 */
  4027. .dt-head--pos > span:last-child,
  4028. .dt-head--ord > span:last-child {
  4029. position: sticky; right: 0; z-index: 2; background: #000;
  4030. }
  4031. .dt-row--pos, .dt-row--ord { --row-bg: #000; }
  4032. .dt-row--pos:hover, .dt-row--ord:hover { --row-bg: #111; }
  4033. .dt-row--pos > span:last-child,
  4034. .dt-row--ord > span:last-child {
  4035. position: sticky; right: 0; z-index: 1; background: var(--row-bg, #000);
  4036. }
  4037. /* 历史仓位:合约|方向杠杆|状态|委托量|开仓均价|收益|已成交量|平仓均价|收益率|开仓时间|平仓时间 */
  4038. .dt-head--hist, .dt-row--hist {
  4039. grid-template-columns: 84px 84px 70px 70px 84px 92px 70px 84px 70px 104px 104px;
  4040. }
  4041. /* 历史委托:合约|方向杠杆|类型|状态|委托量|已成交量|开仓均价|委托价格|委托时间|成交时间 */
  4042. .dt-head--hist-ord, .dt-row--hist-ord {
  4043. grid-template-columns: 84px 84px 70px 70px 70px 70px 84px 84px 104px 104px;
  4044. }
  4045. /* 成交明细:合约|方向|成交价|成交量|手续费|成交时间 */
  4046. .dt-head--trade, .dt-row--trade {
  4047. grid-template-columns: 84px 84px 84px 70px 84px 104px;
  4048. }
  4049. /* 资金流水:类型|数量|币种|时间 */
  4050. .dt-head--funding, .dt-row--funding {
  4051. grid-template-columns: 120px 100px 100px 104px;
  4052. }
  4053. .entrust-type-tag {
  4054. display: inline-block; font-size: 10px; padding: 1px 4px; border-radius: 2px;
  4055. background: rgba(255,255,255,0.08); color: #8a92a8; margin-left: 3px;
  4056. }
  4057. .load-more-row { display: flex; justify-content: center; padding: 10px 0; }
  4058. .load-more-btn {
  4059. font-size: 12px; padding: 4px 20px;
  4060. border: 1px solid #333; border-radius: 3px; color: #9298ab;
  4061. transition: all .12s;
  4062. }
  4063. .load-more-btn:hover { border-color: #555; color: #d1d4dc; }
  4064. .dt-head { border-bottom: 1px solid #212121; }
  4065. .dt-row { color: #9298ab; border-bottom: 1px solid #1a1a1a; transition: background .12s; }
  4066. .dt-row:hover { background: #111; }
  4067. .fw6 { font-weight: 600; color: #e8ecf4 !important; }
  4068. .act-btn {
  4069. font-size: 10px; padding: 2px 6px;
  4070. border: 1px solid rgba(240,185,11,0.35); border-radius: 3px;
  4071. color: #f0b90b; transition: all .12s; white-space: nowrap;
  4072. }
  4073. .act-btn:hover:not(:disabled) { background: rgba(240,185,11,0.08); }
  4074. .act-btn:disabled { opacity: 0.4; cursor: not-allowed; }
  4075. .act-btn--sec {
  4076. border-color: rgba(100,180,255,0.35); color: #64b4ff;
  4077. }
  4078. .act-btn--sec:hover:not(:disabled) { background: rgba(100,180,255,0.08); }
  4079. .act-btn--market {
  4080. border-color: rgba(246,70,93,0.35); color: #f6465d;
  4081. }
  4082. .act-btn--market:hover:not(:disabled) { background: rgba(246,70,93,0.08); }
  4083. /* 操作列:按钮横向排列,不换行 */
  4084. .op-col {
  4085. display: flex; flex-direction: row; flex-wrap: nowrap; gap: 3px; align-items: center;
  4086. }
  4087. /* ─── Colors ─── */
  4088. .up { color: #02c076 !important; }
  4089. .dn { color: #f6465d !important; }
  4090. /* ─── Transitions ─── */
  4091. .pop-enter-active, .pop-leave-active { transition: opacity .15s, transform .15s; }
  4092. .pop-enter-from, .pop-leave-to { opacity: 0; transform: translateY(-6px); }
  4093. /* ─── Transfer Modal ─── */
  4094. .modal-mask {
  4095. position: fixed; inset: 0; z-index: 1000;
  4096. background: rgba(0,0,0,0.6);
  4097. display: flex; align-items: center; justify-content: center;
  4098. }
  4099. .modal-box {
  4100. width: 480px; background: #1e2026; border-radius: 12px;
  4101. display: flex; flex-direction: column; overflow: hidden;
  4102. box-shadow: 0 20px 60px rgba(0,0,0,0.5);
  4103. }
  4104. .modal-hd {
  4105. display: flex; align-items: center; justify-content: space-between;
  4106. padding: 20px 24px 16px; border-bottom: 1px solid #2a2d35;
  4107. }
  4108. .modal-title { font-size: 16px; font-weight: 600; color: #e8ecf4; }
  4109. .modal-close {
  4110. color: #6a7182; padding: 4px; border-radius: 4px; transition: color .15s;
  4111. }
  4112. .modal-close:hover { color: #c7cce4; }
  4113. .modal-body { padding: 20px 24px; display: flex; flex-direction: column; gap: 18px; }
  4114. .modal-ft {
  4115. display: flex; gap: 12px; padding: 16px 24px 24px;
  4116. }
  4117. .modal-cancel {
  4118. flex: 1; height: 40px; border-radius: 6px;
  4119. background: #2a2d35; color: #c7cce4; font-size: 14px;
  4120. }
  4121. .modal-cancel:hover { background: #363a45; }
  4122. .modal-confirm {
  4123. flex: 1; height: 40px; border-radius: 6px;
  4124. background: #f0b90b; color: #111; font-weight: 600; font-size: 14px;
  4125. }
  4126. .modal-confirm:hover:not(:disabled) { background: #f5c842; }
  4127. .modal-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
  4128. .tf-label { font-size: 12px; color: #6a7182; margin-bottom: 8px; }
  4129. .tf-row { display: flex; flex-direction: column; gap: 6px; }
  4130. .tf-inp {
  4131. background: #2a2d35; border: 1px solid #3a3e4a; border-radius: 6px;
  4132. padding: 10px 12px; color: #e8ecf4; font-size: 14px; width: 100%;
  4133. }
  4134. .tf-inp:focus { outline: none; border-color: #f0b90b; }
  4135. .tf-inp--profit:focus { border-color: #02c076; }
  4136. .tf-inp--loss:focus { border-color: #f6465d; }
  4137. .tf-err { font-size: 12px; color: #f6465d; margin-top: 4px; }
  4138. /* TP/SL modal 内部元素 */
  4139. .tpsl-pos-summary {
  4140. display: flex; align-items: center; padding: 10px 14px;
  4141. background: #1e2026; border-radius: 6px; font-size: 13px;
  4142. }
  4143. .tpsl-section-title {
  4144. font-size: 11px; color: #6a7182; font-weight: 500;
  4145. margin: 14px 0 8px; text-transform: uppercase; letter-spacing: .5px;
  4146. }
  4147. .tpsl-current-list { display: flex; flex-direction: column; gap: 6px; }
  4148. .tpsl-current-item {
  4149. background: #1e2026; border-radius: 6px; padding: 10px 12px;
  4150. }
  4151. .tpsl-current-row {
  4152. display: flex; align-items: center; gap: 8px; font-size: 12px;
  4153. }
  4154. .tpsl-badge {
  4155. padding: 2px 7px; border-radius: 3px; font-size: 11px; font-weight: 600;
  4156. flex-shrink: 0;
  4157. }
  4158. .tpsl-badge--profit { background: rgba(2,192,118,0.15); color: #02c076; }
  4159. .tpsl-badge--loss { background: rgba(246,70,93,0.15); color: #f6465d; }
  4160. .tpsl-current-detail { flex: 1; color: #c7cce4; }
  4161. .tpsl-current-detail strong { color: #e8ecf4; }
  4162. .tpsl-current-vol { color: #6a7182; font-size: 11px; flex-shrink: 0; }
  4163. .tf-select {
  4164. display: flex; align-items: center; justify-content: space-between;
  4165. background: #2a2d35; border-radius: 8px; padding: 12px 14px;
  4166. color: #e8ecf4; font-size: 14px; cursor: pointer;
  4167. }
  4168. .tf-direction { display: flex; align-items: flex-end; gap: 10px; }
  4169. .tf-dir-col { flex: 1; }
  4170. .tf-dir-lbl { font-size: 12px; color: #6a7182; margin-bottom: 8px; }
  4171. .tf-swap {
  4172. flex-shrink: 0; width: 36px; height: 44px; margin-bottom: 0;
  4173. background: #2a2d35; border-radius: 8px; color: #c7cce4;
  4174. display: flex; align-items: center; justify-content: center;
  4175. transition: background .15s;
  4176. }
  4177. .tf-swap:hover { background: #363a45; }
  4178. .tf-amount-wrap {
  4179. display: flex; align-items: center;
  4180. background: #2a2d35; border-radius: 8px; padding: 0 14px;
  4181. height: 46px;
  4182. }
  4183. .tf-amount-inp {
  4184. flex: 1; background: none; color: #e8ecf4; font-size: 14px;
  4185. outline: none; border: none;
  4186. }
  4187. .tf-amount-inp::placeholder { color: #3a3d45; }
  4188. .tf-amount-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
  4189. .tf-currency { font-size: 13px; color: #6a7182; }
  4190. .tf-sep { color: #3a3d45; }
  4191. .tf-all { font-size: 13px; color: #f0b90b; }
  4192. .tf-all:hover { opacity: 0.8; }
  4193. .tf-select--static { cursor: default; }
  4194. .tf-select--btn { position: relative; cursor: pointer; user-select: none; }
  4195. .tf-select--btn:hover { background: #363a45; }
  4196. .tf-dropdown {
  4197. position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 100;
  4198. background: #2a2d35; border: 1px solid #363a45; border-radius: 8px;
  4199. overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.4);
  4200. }
  4201. .tf-dropdown-item {
  4202. padding: 10px 14px; font-size: 14px; color: #c7cce4; cursor: pointer;
  4203. transition: background .12s;
  4204. }
  4205. .tf-dropdown-item:hover { background: #363a45; }
  4206. .tf-dropdown-item.active { color: #f0b90b; }
  4207. .tf-avail { font-size: 12px; color: #6a7182; margin-top: 8px; }
  4208. .tf-avail span { color: #c7cce4; }
  4209. .tf-avail-loading { color: #6a7182; font-style: italic; }
  4210. .tf-error { font-size: 12px; color: #f6465d; margin-top: 6px; }
  4211. .tf-cancel {
  4212. flex: 1; height: 44px; border-radius: 8px;
  4213. background: #2a2d35; color: #c7cce4; font-size: 14px; font-weight: 500;
  4214. transition: background .15s;
  4215. }
  4216. .tf-cancel:hover { background: #363a45; }
  4217. .tf-confirm {
  4218. flex: 1; height: 44px; border-radius: 8px;
  4219. background: #f0b90b; color: #000; font-size: 14px; font-weight: 600;
  4220. transition: opacity .15s;
  4221. }
  4222. .tf-confirm:hover { opacity: 0.9; }
  4223. .tf-cancel:disabled, .tf-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
  4224. .tf-amount-inp:disabled { opacity: 0.6; }
  4225. .modal-enter-active, .modal-leave-active { transition: opacity .2s; }
  4226. .modal-enter-from, .modal-leave-to { opacity: 0; }
  4227. .modal-enter-active .modal-box, .modal-leave-active .modal-box { transition: transform .2s; }
  4228. .modal-enter-from .modal-box, .modal-leave-to .modal-box { transform: scale(0.95); }
  4229. /* ─── Responsive (desktop) ─── */
  4230. @media (max-width: 1200px) {
  4231. .book-col { width: 200px; }
  4232. }
  4233. @media (max-width: 960px) {
  4234. .book-col { display: none; }
  4235. }
  4236. @media (max-width: 860px) {
  4237. .trade-panel:not(.mobile-show) { display: none; }
  4238. }
  4239. /* ─── Mobile H5 (≤ 768px) ─── */
  4240. @media (max-width: 768px) {
  4241. /* Page: single-column flex */
  4242. .ft-page {
  4243. display: flex;
  4244. flex-direction: column;
  4245. min-height: 100vh;
  4246. grid-template-columns: unset;
  4247. grid-template-rows: unset;
  4248. }
  4249. /* page-body keeps display:contents so children are ft-page flex items */
  4250. /* Ticker bar: compact */
  4251. .ticker-bar {
  4252. grid-column: unset; grid-row: unset;
  4253. height: auto; min-height: 54px;
  4254. padding: 8px 10px;
  4255. flex-shrink: 0;
  4256. position: relative;
  4257. }
  4258. .ticker-stats { display: none; }
  4259. .ticker-price-wrap { border-right: none; padding: 0 12px; }
  4260. .ticker-price { font-size: 20px; }
  4261. .ticker-usd { display: none; }
  4262. .pair-name { font-size: 16px; }
  4263. .coin-icon { width: 26px; height: 26px; }
  4264. .pair-drop { width: calc(100vw - 20px); }
  4265. /* Mobile tab bar */
  4266. .mobile-trade-tabs {
  4267. display: flex;
  4268. height: 40px;
  4269. flex-shrink: 0;
  4270. background: #000;
  4271. border-bottom: 1px solid #212121;
  4272. }
  4273. .mtt-btn {
  4274. flex: 1;
  4275. font-size: 14px;
  4276. font-weight: 500;
  4277. color: #6a7182;
  4278. border-bottom: 2px solid transparent;
  4279. margin-bottom: -1px;
  4280. transition: all .15s;
  4281. }
  4282. .mtt-btn.act { color: #f0b90b; border-bottom-color: #f0b90b; }
  4283. /* Chart area */
  4284. .chart-area {
  4285. grid-column: unset; grid-row: unset;
  4286. height: 280px;
  4287. flex-shrink: 0;
  4288. }
  4289. .chart-area.mobile-hidden { display: none; }
  4290. /* Drawing sidebar: hidden on mobile */
  4291. .draw-side { display: none; }
  4292. /* Sub-nav: horizontal scroll */
  4293. .sub-nav { overflow-x: auto; -webkit-overflow-scrolling: touch; }
  4294. .sub-nav::-webkit-scrollbar { display: none; }
  4295. /* TF bar: horizontal scroll */
  4296. .tf-bar { overflow-x: auto; -webkit-overflow-scrolling: touch; padding-right: 8px; }
  4297. .tf-bar::-webkit-scrollbar { display: none; }
  4298. .tf-spacer { min-width: 8px; flex: unset; }
  4299. /* OHLCV overlay: smaller */
  4300. .ohlcv-overlay { font-size: 10px; gap: 4px; padding: 2px 4px; }
  4301. /* Kline tooltip: smaller */
  4302. .kline-tooltip { font-size: 10px; padding: 4px 8px; min-width: 120px; }
  4303. /* Trade panel: hidden by default, full-width when active */
  4304. .trade-panel {
  4305. grid-column: unset; grid-row: unset;
  4306. display: none;
  4307. flex-shrink: 0;
  4308. width: 100%;
  4309. max-height: none;
  4310. overflow-y: auto;
  4311. }
  4312. .trade-panel.mobile-show { display: flex; }
  4313. /* Bottom area */
  4314. .bottom-area {
  4315. grid-column: unset; grid-row: unset;
  4316. min-height: 220px;
  4317. flex-shrink: 0;
  4318. flex: 1;
  4319. }
  4320. /* Bottom tab bar: horizontal scroll */
  4321. .btab-bar { overflow-x: auto; -webkit-overflow-scrolling: touch; padding: 0 6px; }
  4322. .btab-bar::-webkit-scrollbar { display: none; }
  4323. .btab { padding: 0 8px; font-size: 12px; }
  4324. /* Data tables: touch scroll */
  4325. .data-tbl { -webkit-overflow-scrolling: touch; }
  4326. /* Transfer modal: narrower */
  4327. .modal-box { width: calc(100vw - 24px); }
  4328. .modal-body { padding: 16px; gap: 14px; }
  4329. .modal-ft { padding: 12px 16px 20px; }
  4330. /* Detail modal: full width on mobile */
  4331. .detail-modal { width: calc(100vw - 24px); max-height: 92vh; }
  4332. }
  4333. /* ─── Detail Modal ─── */
  4334. .detail-modal {
  4335. width: 500px;
  4336. max-height: 88vh;
  4337. display: flex;
  4338. flex-direction: column;
  4339. }
  4340. .detail-modal-body {
  4341. overflow-y: auto;
  4342. flex: 1;
  4343. padding-bottom: 16px;
  4344. }
  4345. .dt-row--clickable { cursor: pointer; }
  4346. .dt-row--clickable:hover { background: #111 !important; }
  4347. .detail-header {
  4348. display: flex; align-items: center; gap: 8px;
  4349. padding: 14px 20px 14px;
  4350. border-bottom: 1px solid #2a2d35;
  4351. }
  4352. .detail-dir-chip {
  4353. padding: 2px 8px; border-radius: 4px;
  4354. font-size: 12px; font-weight: 700;
  4355. flex-shrink: 0;
  4356. }
  4357. .chip--up { background: rgba(2,192,118,0.15); color: #02c076; }
  4358. .chip--dn { background: rgba(246,70,93,0.15); color: #f6465d; }
  4359. .detail-symbol {
  4360. font-size: 15px; font-weight: 700; color: #e8ecf4; flex: 1;
  4361. }
  4362. .detail-tag {
  4363. padding: 2px 7px; border-radius: 4px;
  4364. font-size: 11px; font-weight: 500;
  4365. background: rgba(100,180,255,0.12); color: #64b4ff;
  4366. flex-shrink: 0;
  4367. }
  4368. .detail-tag--lev {
  4369. background: rgba(255,255,255,0.08); color: #9298ab;
  4370. }
  4371. .detail-status-block {
  4372. display: flex; flex-direction: column; align-items: center;
  4373. padding: 28px 20px 20px;
  4374. }
  4375. .detail-status-icon {
  4376. width: 52px; height: 52px; border-radius: 14px;
  4377. background: rgba(100,180,255,0.1);
  4378. display: flex; align-items: center; justify-content: center;
  4379. color: #64b4ff; margin-bottom: 12px;
  4380. }
  4381. .detail-status-label {
  4382. font-size: 16px; font-weight: 600;
  4383. }
  4384. .detail-close-type {
  4385. font-size: 12px; color: #6a7182; margin-top: 4px;
  4386. }
  4387. .detail-section {
  4388. background: #161a23;
  4389. border-top: 1px solid #2a2d35;
  4390. border-bottom: 1px solid #2a2d35;
  4391. }
  4392. .detail-row {
  4393. display: flex; align-items: center; justify-content: space-between;
  4394. padding: 11px 20px;
  4395. border-bottom: 1px solid rgba(255,255,255,0.04);
  4396. font-size: 13px;
  4397. }
  4398. .detail-row:last-child { border-bottom: none; }
  4399. .detail-lbl { color: #6a7182; }
  4400. .detail-val { color: #e8ecf4; font-weight: 500; text-align: right; }
  4401. .detail-section-gap { height: 8px; background: #0d0f14; }
  4402. /* ── 币种资料面板 ─────────────────────────────────── */
  4403. .coin-info-panel {
  4404. position: absolute; inset: 0; overflow-y: auto; padding: 16px;
  4405. background: #0d1117; z-index: 2;
  4406. }
  4407. .ci-loading { display: flex; align-items: center; justify-content: center; height: 100px; }
  4408. .cip-header {
  4409. display: flex; align-items: center; gap: 10px;
  4410. padding-bottom: 14px; border-bottom: 1px solid #1e2330; margin-bottom: 14px;
  4411. }
  4412. .cip-icon { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; }
  4413. .cip-names { flex: 1; display: flex; flex-direction: column; gap: 2px; }
  4414. .cip-name-cn { font-size: 15px; font-weight: 600; color: #e8ecf4; }
  4415. .cip-name-en { font-size: 12px; color: #6a7182; }
  4416. .cip-rank {
  4417. font-size: 11px; font-weight: 600; color: #f0b90b;
  4418. background: rgba(240,185,11,0.12); border-radius: 4px; padding: 2px 7px;
  4419. }
  4420. .cip-grid {
  4421. display: grid; grid-template-columns: 1fr 1fr; gap: 1px;
  4422. background: #1e2330; border-radius: 6px; overflow: hidden;
  4423. }
  4424. .cip-item {
  4425. display: flex; flex-direction: column; gap: 4px;
  4426. background: #131720; padding: 10px 12px;
  4427. }
  4428. .cip-item--full { grid-column: 1 / -1; }
  4429. .cip-lbl { font-size: 11px; color: #6a7182; }
  4430. .cip-val { font-size: 13px; color: #e8ecf4; font-weight: 500; word-break: break-all; }
  4431. .cip-link { font-size: 12px; color: #4a8fff; text-decoration: none; word-break: break-all; }
  4432. .cip-link:hover { text-decoration: underline; }
  4433. .cip-empty {
  4434. display: flex; flex-direction: column; align-items: center; justify-content: center;
  4435. gap: 10px; height: 100px; color: #6a7182; font-size: 13px;
  4436. }
  4437. .cip-retry {
  4438. background: transparent; border: 1px solid #2a2d35; color: #9ca3b0;
  4439. border-radius: 4px; padding: 4px 14px; cursor: pointer; font-size: 12px;
  4440. }
  4441. .cip-retry:hover { border-color: #555; color: #d1d4dc; }
  4442. /* ── 历史资金费率面板 ───────────────────────────────── */
  4443. .fr-panel {
  4444. position: absolute; inset: 0; display: flex; flex-direction: column; overflow-y: auto;
  4445. background: #0d1117; z-index: 2;
  4446. }
  4447. .fr-info-bar {
  4448. display: flex; gap: 0; border-bottom: 1px solid #1e2330; flex-shrink: 0;
  4449. background: #0d1117;
  4450. }
  4451. .fr-info-item {
  4452. flex: 1; display: flex; flex-direction: column; gap: 3px;
  4453. padding: 10px 14px; border-right: 1px solid #1e2330;
  4454. }
  4455. .fr-info-item:last-child { border-right: none; }
  4456. .fr-info-lbl { font-size: 11px; color: #6a7182; }
  4457. .fr-info-val { font-size: 13px; font-weight: 600; color: #e8ecf4; }
  4458. .fr-body {
  4459. flex: 1; display: flex; min-height: 0; overflow: hidden;
  4460. }
  4461. .fr-left {
  4462. flex: 1; min-width: 0; display: flex; flex-direction: column;
  4463. border-right: 1px solid #1e2330;
  4464. }
  4465. .fr-chart-wrap {
  4466. flex: 1; min-height: 0; background: #0d1117; overflow: hidden;
  4467. }
  4468. .fr-chart-svg { width: 100%; height: 100%; display: block; }
  4469. .fr-chart-empty {
  4470. display: flex; align-items: center; justify-content: center;
  4471. height: 100%; color: #6a7182; font-size: 13px;
  4472. }
  4473. .fr-list { width: 240px; flex-shrink: 0; overflow: hidden; }
  4474. .fr-head {
  4475. display: grid; grid-template-columns: 150px 1fr;
  4476. padding: 7px 10px; font-size: 11px; color: #6a7182;
  4477. border-bottom: 1px solid #1e2330; position: sticky; top: 0; background: #0d1117;
  4478. }
  4479. .fr-row {
  4480. display: grid; grid-template-columns: 150px 1fr;
  4481. padding: 8px 10px; font-size: 12px; color: #c9d1dc;
  4482. border-bottom: 1px solid rgba(255,255,255,0.04);
  4483. white-space: nowrap;
  4484. }
  4485. .fr-row:last-child { border-bottom: none; }
  4486. .fr-empty { padding: 20px 12px; color: #6a7182; font-size: 13px; text-align: center; }
  4487. </style>