DepositView.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728
  1. <script setup lang="ts">
  2. import { ref, onMounted, computed, watch, onBeforeUnmount } from 'vue'
  3. import { storeToRefs } from 'pinia'
  4. import { useI18n } from 'vue-i18n'
  5. import { useDepositStore } from '@/stores/deposit'
  6. import { rechargeStatusTranslate } from '@/utils/rechargeI18n'
  7. import { useToast } from '@/composables/useToast'
  8. import { useWalletConnectDeposit } from '@/composables/useWalletConnectDeposit'
  9. import { useWalletConnectTronDeposit } from '@/composables/useWalletConnectTronDeposit'
  10. import { resolveEvmChainId } from '@/utils/evmRecharge'
  11. import { qrCodeImageUrl } from '@/utils/qr'
  12. import { WalletFlowCancelledError } from '@/utils/reownConnectWait'
  13. const DEPOSIT_MODE_KEY = 'ibit-deposit-mode'
  14. function loadDepositMode(): 'manual' | 'wallet' {
  15. try {
  16. const stored = sessionStorage.getItem(DEPOSIT_MODE_KEY)
  17. if (stored === 'manual' || stored === 'wallet') {
  18. return stored
  19. }
  20. } catch {
  21. // ignore
  22. }
  23. return 'manual'
  24. }
  25. function saveDepositMode(mode: 'manual' | 'wallet') {
  26. try {
  27. sessionStorage.setItem(DEPOSIT_MODE_KEY, mode)
  28. } catch {
  29. // ignore
  30. }
  31. }
  32. const { t } = useI18n()
  33. const store = useDepositStore()
  34. const {
  35. parentCoins,
  36. selectedParentIndex,
  37. selectedNetworkId,
  38. selectedSubCoinIndex,
  39. networkOptions,
  40. subCoinOptions,
  41. loadingParents,
  42. childrenLoading,
  43. subCoinsLoading,
  44. error,
  45. currentOrder,
  46. orderSubmitting,
  47. hashSubmitting,
  48. selectedParent,
  49. selectedNetwork,
  50. selectedConfig,
  51. } = storeToRefs(store)
  52. const toast = useToast()
  53. const { wcBusy, wcConfigured, resetWalletBusy, canPayWithWallet, connectAndPay } = useWalletConnectDeposit()
  54. const { wcTronBusy, wcTronConfigured, resetTronWalletBusy, canPayTronWithWallet, connectAndPayTron } =
  55. useWalletConnectTronDeposit()
  56. const canUseWalletConnect = computed(() =>
  57. canPayWithWallet(selectedNetwork.value, currentOrder.value),
  58. )
  59. const canUseTronWalletConnect = computed(() =>
  60. canPayTronWithWallet(selectedNetwork.value, currentOrder.value),
  61. )
  62. const anyWcBusy = computed(() => wcBusy.value || wcTronBusy.value)
  63. const isTronLikeNetwork = computed(() => {
  64. const o = selectedNetwork.value
  65. if (!o) {
  66. return false
  67. }
  68. if (resolveEvmChainId(o.protocol, o.networkName) !== null) {
  69. return false
  70. }
  71. const p = o.protocol.toLowerCase()
  72. const n = o.networkName.toLowerCase()
  73. return p.includes('tron') || n === 'trc20'
  74. })
  75. const amount = ref('')
  76. const amountError = ref('')
  77. const txHashInput = ref('')
  78. const txHashError = ref('')
  79. const rechargeQrUrl = ref<string | null>(null)
  80. const depositMode = ref<'manual' | 'wallet'>(loadDepositMode())
  81. /** 创建订单后锁定充值方式,链上/手工不可再切换 */
  82. const depositModeLocked = ref(false)
  83. const walletAttemptSeq = ref(0)
  84. watch(depositMode, (mode) => {
  85. if (!depositModeLocked.value) {
  86. saveDepositMode(mode)
  87. }
  88. })
  89. watch(
  90. () => currentOrder.value?.rechargeAddress?.trim() ?? '',
  91. async (addr) => {
  92. if (!addr) {
  93. rechargeQrUrl.value = null
  94. return
  95. }
  96. try {
  97. rechargeQrUrl.value = await qrCodeImageUrl(addr, 200)
  98. } catch {
  99. rechargeQrUrl.value = null
  100. }
  101. },
  102. { immediate: true },
  103. )
  104. onMounted(() => {
  105. if (currentOrder.value) {
  106. depositModeLocked.value = true
  107. }
  108. store.loadParentCoins().catch(() => {
  109. toast.error(store.error ?? t('common.loadFailed'))
  110. })
  111. })
  112. async function onSelectParent(i: number) {
  113. try {
  114. await store.selectParent(i)
  115. } catch {
  116. toast.error(store.error ?? t('deposit.loadNetworksFailed'))
  117. }
  118. }
  119. async function onSelectNetwork(networkId: number) {
  120. try {
  121. await store.selectNetwork(networkId)
  122. } catch {
  123. toast.error(store.error ?? t('deposit.loadNetworksFailed'))
  124. }
  125. }
  126. function onSelectSubCoin(i: number) {
  127. store.selectSubCoin(i)
  128. }
  129. function subCoinTabLabel(opt: { coinName: string; coinNameCn: string }) {
  130. const cn = opt.coinNameCn?.trim()
  131. if (cn && cn !== opt.coinName) {
  132. return `${cn} (${opt.coinName})`
  133. }
  134. return opt.coinName
  135. }
  136. function validateAmount(): boolean {
  137. amountError.value = ''
  138. const s = amount.value.trim()
  139. if (!s) {
  140. amountError.value = t('deposit.amountRequired')
  141. return false
  142. }
  143. const n = Number(s)
  144. if (!Number.isFinite(n) || n <= 0) {
  145. amountError.value = t('deposit.amountPositive')
  146. return false
  147. }
  148. return true
  149. }
  150. async function onCreateOrder() {
  151. if (!validateAmount()) {
  152. return
  153. }
  154. resetPendingWalletUi()
  155. try {
  156. await store.createRechargeOrder(amount.value)
  157. depositModeLocked.value = true
  158. saveDepositMode(depositMode.value)
  159. txHashInput.value = ''
  160. txHashError.value = ''
  161. toast.success(t('deposit.orderCreated'))
  162. } catch {
  163. toast.error(store.error ?? t('deposit.orderFailed'))
  164. }
  165. }
  166. function copyAddress() {
  167. const addr = currentOrder.value?.rechargeAddress
  168. if (!addr) {
  169. return
  170. }
  171. navigator.clipboard.writeText(addr).then(() => {
  172. toast.success(t('deposit.addressCopy'))
  173. })
  174. }
  175. function copyOrderNo() {
  176. const no = currentOrder.value?.orderNo
  177. if (!no) {
  178. return
  179. }
  180. navigator.clipboard.writeText(no).then(() => {
  181. toast.success(t('deposit.orderNoCopy'))
  182. })
  183. }
  184. // function validateTxHash(): boolean {
  185. // txHashError.value = ''
  186. // const s = txHashInput.value.trim()
  187. // if (!s) {
  188. // txHashError.value = t('deposit.hashRequired')
  189. // return false
  190. // }
  191. // return true
  192. // }
  193. onBeforeUnmount(() => {
  194. resetPendingWalletUi()
  195. })
  196. function isWalletConnectCancelError(e: unknown): boolean {
  197. if (e instanceof WalletFlowCancelledError) {
  198. return true
  199. }
  200. const msg = e instanceof Error ? e.message : typeof e === 'string' ? e : ''
  201. const normalized = msg.toLowerCase()
  202. const code = e && typeof e === 'object' && 'code' in e ? Number((e as { code?: unknown }).code) : NaN
  203. return (
  204. code === 4001 ||
  205. normalized.includes('connection request reset') ||
  206. normalized.includes('user rejected') ||
  207. normalized.includes('user denied') ||
  208. normalized.includes('user closed') ||
  209. normalized.includes('user cancelled') ||
  210. normalized.includes('user canceled') ||
  211. normalized.includes('rejected the request') ||
  212. normalized.includes('rejected transaction') ||
  213. normalized.includes('modal closed') ||
  214. normalized.includes('proposal expired') ||
  215. normalized.includes('action_rejected')
  216. )
  217. }
  218. // async function onSubmitHash() {
  219. // const o = currentOrder.value
  220. // if (!o?.orderNo) {
  221. // return
  222. // }
  223. // if (Number(o.status) !== 0) {
  224. // toast.error(t('deposit.statusNotSubmittable'))
  225. // return
  226. // }
  227. // if (!validateTxHash()) {
  228. // return
  229. // }
  230. // try {
  231. // await store.submitTxHash(o.orderNo, txHashInput.value)
  232. // toast.success(t('deposit.hashSubmitted'))
  233. // } catch {
  234. // toast.error(store.error ?? t('deposit.hashFailed'))
  235. // }
  236. // }
  237. async function onWalletPay() {
  238. const o = currentOrder.value
  239. const net = selectedNetwork.value
  240. if (!o?.orderNo || !net || Number(o.status) !== 0) {
  241. return
  242. }
  243. const seq = ++walletAttemptSeq.value
  244. try {
  245. const hash = await connectAndPay({ network: net, order: o })
  246. if (seq !== walletAttemptSeq.value || currentOrder.value?.orderNo !== o.orderNo) {
  247. return
  248. }
  249. txHashInput.value = hash
  250. await store.submitTxHash(o.orderNo, hash)
  251. toast.success(t('deposit.broadcastSuccess'))
  252. } catch (e) {
  253. if (isWalletConnectCancelError(e)) {
  254. resetWalletBusy()
  255. return
  256. }
  257. const msg = e instanceof Error ? e.message : t('deposit.walletPayFallback')
  258. toast.error(msg)
  259. }
  260. }
  261. async function onTronWalletPay() {
  262. const o = currentOrder.value
  263. const net = selectedNetwork.value
  264. if (!o?.orderNo || !net || Number(o.status) !== 0) {
  265. return
  266. }
  267. const seq = ++walletAttemptSeq.value
  268. try {
  269. const txid = await connectAndPayTron({ network: net, order: o })
  270. if (seq !== walletAttemptSeq.value || currentOrder.value?.orderNo !== o.orderNo) {
  271. return
  272. }
  273. txHashInput.value = txid
  274. await store.submitTxHash(o.orderNo, txid)
  275. toast.success(t('deposit.tronBroadcastSuccess'))
  276. } catch (e) {
  277. if (isWalletConnectCancelError(e)) {
  278. resetTronWalletBusy()
  279. return
  280. }
  281. const msg = e instanceof Error ? e.message : t('deposit.tronWalletPayFallback')
  282. toast.error(msg)
  283. }
  284. }
  285. function resetPendingWalletUi() {
  286. walletAttemptSeq.value += 1
  287. resetWalletBusy()
  288. resetTronWalletBusy()
  289. }
  290. function onNewDeposit() {
  291. resetPendingWalletUi()
  292. store.clearCurrentOrder()
  293. amount.value = ''
  294. amountError.value = ''
  295. txHashInput.value = ''
  296. txHashError.value = ''
  297. depositModeLocked.value = false
  298. depositMode.value = 'wallet'
  299. }
  300. function statusLabel(status: number) {
  301. return rechargeStatusTranslate(t, status)
  302. }
  303. </script>
  304. <template>
  305. <main class="page-main">
  306. <div class="page-container content">
  307. <div class="page-header">
  308. <RouterLink to="/assets" class="back-link">{{ t('deposit.backWithArrow') }}</RouterLink>
  309. <div class="title-row">
  310. <h1 class="page-title">{{ t('deposit.title') }}</h1>
  311. <RouterLink to="/assets/deposit/history" class="hist-link">{{ t('deposit.histLink') }}</RouterLink>
  312. </div>
  313. </div>
  314. <div v-if="loadingParents" class="loading-state">
  315. <div class="spinner" />
  316. </div>
  317. <div v-else-if="error && !parentCoins.length" class="error-state dark-card">
  318. <p class="error-msg">{{ error }}</p>
  319. <button class="retry-btn" type="button" @click="store.loadParentCoins()">{{ t('deposit.retryBtn') }}</button>
  320. </div>
  321. <template v-else-if="parentCoins.length">
  322. <!-- 已创建订单:展示付款与回写 hash -->
  323. <template v-if="currentOrder">
  324. <div class="toolbar-row">
  325. <button type="button" class="link-btn" @click="onNewDeposit">{{ t('deposit.newDeposit') }}</button>
  326. </div>
  327. <div class="form-card dark-card order-detail-card">
  328. <h2 class="section-title order-title">{{ t('deposit.orderInfo') }}</h2>
  329. <div class="order-meta">
  330. <div class="meta-row">
  331. <span class="meta-k">{{ t('deposit.orderNo') }}</span>
  332. <span class="meta-v">{{ currentOrder.orderNo }}</span>
  333. <button type="button" class="mini-copy" @click="copyOrderNo">{{ t('deposit.copyAddress') }}</button>
  334. </div>
  335. <div class="meta-row">
  336. <span class="meta-k">{{ t('deposit.status') }}</span>
  337. <span class="meta-v status" :class="'st-' + currentOrder.status">{{ statusLabel(Number(currentOrder.status)) }}</span>
  338. </div>
  339. <div class="meta-row">
  340. <span class="meta-k">{{ t('deposit.coinLabel') }}</span>
  341. <span class="meta-v">{{ currentOrder.coinName }}</span>
  342. </div>
  343. <div class="meta-row">
  344. <span class="meta-k">{{ t('deposit.networkLabel') }}</span>
  345. <span class="meta-v">{{ currentOrder.networkName }}</span>
  346. </div>
  347. <div class="meta-row">
  348. <span class="meta-k">{{ t('deposit.amount') }}</span>
  349. <span class="meta-v amt">{{ currentOrder.amount }}</span>
  350. </div>
  351. </div>
  352. <div class="pay-destination">
  353. <h3 class="sub-section-title">{{ t('deposit.payAddress') }}</h3>
  354. <p class="addr-hint">{{ t('deposit.payAddressHint') }}</p>
  355. <div class="addr-row">
  356. <p class="addr-text">{{ currentOrder.rechargeAddress || '-' }}</p>
  357. <button type="button" class="copy-btn" @click="copyAddress">{{ t('deposit.copyAddress') }}</button>
  358. </div>
  359. <div v-if="depositMode === 'manual' && rechargeQrUrl" class="qr-block">
  360. <h3 class="sub-section-title qr-block-title">{{ t('deposit.qrTitle') }}</h3>
  361. <p class="addr-hint">{{ t('deposit.qrHint') }}</p>
  362. <div class="qr-frame">
  363. <img class="qr-img" :src="rechargeQrUrl" :alt="t('deposit.qrAlt')" width="200" height="200">
  364. </div>
  365. </div>
  366. <div v-if="depositMode === 'manual' && selectedNetwork?.contractAddress" class="contract-box">
  367. <span class="meta-k">{{ t('deposit.contractAddress') }}</span>
  368. <p class="mono small">{{ selectedNetwork.contractAddress }}</p>
  369. </div>
  370. <template v-if="depositMode === 'wallet' && Number(currentOrder.status) === 0">
  371. <p class="wc-hint" v-if="canUseWalletConnect && wcConfigured">
  372. {{ t('deposit.wcHintEvmConfigured') }}
  373. </p>
  374. <p class="wc-hint muted" v-if="canUseWalletConnect && !wcConfigured">
  375. {{ t('deposit.wcHintEvmUnconfigured') }}
  376. </p>
  377. <p class="wc-hint" v-if="isTronLikeNetwork && canUseTronWalletConnect && wcTronConfigured">
  378. {{ t('deposit.wcHintTronConfigured') }}
  379. </p>
  380. <p class="wc-hint muted" v-if="isTronLikeNetwork && canUseTronWalletConnect && !wcTronConfigured">
  381. {{ t('deposit.wcHintTronUnconfigured') }}
  382. </p>
  383. <p class="wc-hint muted" v-if="isTronLikeNetwork && !canUseTronWalletConnect">
  384. {{ t('deposit.wcHintTronManualOnly') }}
  385. </p>
  386. <button
  387. v-if="canUseWalletConnect && wcConfigured"
  388. type="button"
  389. class="wc-btn"
  390. :disabled="anyWcBusy || hashSubmitting"
  391. @click="onWalletPay"
  392. >
  393. {{ wcBusy ? t('deposit.walletPayingEvm') : t('deposit.walletPayEvm') }}
  394. </button>
  395. <button
  396. v-if="isTronLikeNetwork && canUseTronWalletConnect && wcTronConfigured"
  397. type="button"
  398. class="wc-btn"
  399. :disabled="anyWcBusy || hashSubmitting"
  400. @click="onTronWalletPay"
  401. >
  402. {{ wcTronBusy ? t('deposit.walletPayingTron') : t('deposit.walletPayTron') }}
  403. </button>
  404. </template>
  405. </div>
  406. <!-- <div v-if="Number(currentOrder.status) === 0" class="hash-block">
  407. <p class="form-label hash-label">{{ t('deposit.orSubmitHash') }}</p>
  408. <input
  409. v-model="txHashInput"
  410. type="text"
  411. class="text-input"
  412. :placeholder="t('deposit.hashPlaceholder')"
  413. autocomplete="off"
  414. >
  415. <p v-if="txHashError" class="field-err">{{ txHashError }}</p>
  416. <button
  417. type="button"
  418. class="primary-btn"
  419. :disabled="hashSubmitting || anyWcBusy"
  420. @click="onSubmitHash"
  421. >
  422. {{ hashSubmitting ? t('deposit.submittingHash') : t('deposit.submitHash') }}
  423. </button>
  424. </div>
  425. <p v-else-if="currentOrder.txHash" class="hash-done">
  426. {{ t('deposit.submittedHash') }}<span class="mono">{{ currentOrder.txHash }}</span>
  427. </p> -->
  428. </div>
  429. </template>
  430. <!-- 未创建订单:选币种与网络、填金额 -->
  431. <template v-else>
  432. <div class="two-col">
  433. <div class="form-column">
  434. <div class="mode-tabs">
  435. <button
  436. type="button"
  437. class="mode-tab"
  438. :class="{ active: depositMode === 'wallet' }"
  439. @click="depositMode = 'wallet'"
  440. >
  441. {{ t('deposit.walletDepositTab') }}
  442. </button>
  443. <button
  444. type="button"
  445. class="mode-tab"
  446. :class="{ active: depositMode === 'manual' }"
  447. @click="depositMode = 'manual'"
  448. >
  449. {{ t('deposit.manualDepositTab') }}
  450. </button>
  451. </div>
  452. <div class="form-card dark-card">
  453. <div v-if="parentCoins.length > 1" class="form-group">
  454. <p class="form-label">{{ t('deposit.coin') }}</p>
  455. <div class="network-tabs">
  456. <button
  457. v-for="(p, i) in parentCoins"
  458. :key="p.name"
  459. type="button"
  460. class="net-tab"
  461. :class="{ active: selectedParentIndex === i }"
  462. @click="onSelectParent(i)"
  463. >
  464. {{ p.nameCn || p.unit || p.name }}
  465. </button>
  466. </div>
  467. </div>
  468. <div class="form-group">
  469. <p class="form-label">{{ t('deposit.protocol') }}</p>
  470. <div v-if="childrenLoading" class="sub-loading">
  471. <div class="spinner sm" />
  472. </div>
  473. <div v-else-if="!networkOptions.length" class="empty-net">{{ t('deposit.noNetwork') }}</div>
  474. <div v-else class="network-tabs">
  475. <button
  476. v-for="net in networkOptions"
  477. :key="net.id"
  478. type="button"
  479. class="net-tab"
  480. :class="{ active: selectedNetworkId === net.id }"
  481. @click="onSelectNetwork(net.id)"
  482. >
  483. {{ net.name }}{{ net.protocol ? ` · ${net.protocol}` : '' }}
  484. </button>
  485. </div>
  486. </div>
  487. <div v-if="selectedNetworkId" class="form-group">
  488. <p class="form-label">{{ t('deposit.subCoin') }}</p>
  489. <div v-if="subCoinsLoading" class="sub-loading">
  490. <div class="spinner sm" />
  491. </div>
  492. <div v-else-if="!subCoinOptions.length" class="empty-net">{{ t('deposit.noSubCoin') }}</div>
  493. <div v-else class="network-tabs">
  494. <button
  495. v-for="(opt, i) in subCoinOptions"
  496. :key="opt.coinName + '-' + opt.networkConfigId"
  497. type="button"
  498. class="net-tab"
  499. :class="{ active: selectedSubCoinIndex === i }"
  500. @click="onSelectSubCoin(i)"
  501. >
  502. {{ subCoinTabLabel(opt) }}
  503. </button>
  504. </div>
  505. </div>
  506. <div v-if="selectedNetwork?.contractAddress" class="net-detail">
  507. <div class="info-row">
  508. <span class="info-label">{{ t('deposit.contract') }}</span>
  509. <span class="info-val mono break">{{ selectedNetwork.contractAddress }}</span>
  510. </div>
  511. </div>
  512. <div class="form-group">
  513. <p class="form-label">{{ t('deposit.depositAmount') }}</p>
  514. <input
  515. v-model="amount"
  516. type="text"
  517. inputmode="decimal"
  518. class="text-input"
  519. :placeholder="t('deposit.amountPlaceholder')"
  520. autocomplete="off"
  521. >
  522. <p v-if="amountError" class="field-err">{{ amountError }}</p>
  523. </div>
  524. <button
  525. type="button"
  526. class="primary-btn full"
  527. :disabled="orderSubmitting || !selectedNetwork"
  528. @click="onCreateOrder"
  529. >
  530. {{ orderSubmitting ? t('deposit.generating') : t('deposit.generateOrder') }}
  531. </button>
  532. </div>
  533. </div>
  534. <div class="address-card dark-card muted-card">
  535. <h2 class="addr-title">{{ t('deposit.intro') }}</h2>
  536. <p class="hint-p">{{ t('deposit.introText') }}</p>
  537. <p v-if="selectedParent" class="hint-p">
  538. {{ t('deposit.currentPrefix') }}<strong>{{ selectedParent.nameCn || selectedParent.name }}</strong>
  539. <template v-if="selectedNetwork"> - {{ selectedNetwork.networkName }}{{ selectedConfig ? ` / ${selectedConfig.coinName}` : '' }}</template>
  540. </p>
  541. </div>
  542. </div>
  543. </template>
  544. </template>
  545. </div>
  546. </main>
  547. </template>
  548. <style scoped>
  549. .content { padding-top: var(--space-8); padding-bottom: var(--space-16); max-width: 780px; }
  550. .page-header { margin-bottom: var(--space-5); }
  551. .back-link { font-size: var(--text-small); color: var(--color-text-secondary); display: inline-block; margin-bottom: var(--space-3); text-decoration: none; }
  552. .back-link:hover { color: var(--color-primary); }
  553. .title-row { display: flex; align-items: center; justify-content: space-between; }
  554. .page-title { font-size: var(--text-h1); font-weight: var(--fw-bold); color: var(--color-text-primary); line-height: 1.15; }
  555. .hist-link { font-size: var(--text-small); color: var(--color-primary); text-decoration: none; }
  556. .loading-state { display: flex; justify-content: center; padding: var(--space-16); }
  557. .spinner { width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--color-border); border-top-color: var(--color-primary); animation: spin .8s linear infinite; }
  558. .spinner.sm { width: 22px; height: 22px; border-width: 2px; }
  559. @keyframes spin { to { transform: rotate(360deg); } }
  560. .sub-loading { display: flex; padding: var(--space-4); }
  561. .error-state { padding: var(--space-8); text-align: center; }
  562. .error-msg { color: var(--color-text-secondary); margin-bottom: var(--space-4); }
  563. .retry-btn { background: transparent; border: 1px solid var(--color-primary); border-radius: var(--radius-md); color: var(--color-primary); padding: var(--space-2) var(--space-5); cursor: pointer; }
  564. .toolbar-row { margin-bottom: var(--space-3); }
  565. .link-btn { background: none; border: none; color: var(--color-primary); font-size: var(--text-small); cursor: pointer; padding: 0; }
  566. .link-btn:hover { opacity: .85; }
  567. .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); align-items: start; }
  568. .form-column { min-width: 0; }
  569. .form-card, .address-card { background: #151515; padding: var(--space-5); border-color: rgba(255,255,255,.06); }
  570. .address-card { min-height: 330px; }
  571. .muted-card { opacity: .96; }
  572. .mode-tabs { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); margin-bottom: var(--space-4); }
  573. .mode-tab {
  574. height: 40px;
  575. border: 1px solid transparent;
  576. border-radius: var(--radius-md);
  577. background: #101010;
  578. color: var(--color-text-primary);
  579. font-size: var(--text-small);
  580. font-weight: var(--fw-semibold);
  581. cursor: pointer;
  582. transition: all var(--transition);
  583. }
  584. .mode-tab.active { background: var(--color-primary); color: #0a0a0a; box-shadow: 0 6px 18px rgba(240,185,11,.18); }
  585. .section-title { font-size: var(--text-body); font-weight: var(--fw-semibold); color: var(--color-text-primary); margin-bottom: var(--space-5); }
  586. .order-detail-card { max-width: 720px; margin: 0 auto; padding: var(--space-6); }
  587. .order-title { text-align: center; margin-bottom: var(--space-6); }
  588. .form-group { margin-bottom: var(--space-5); }
  589. .form-label { font-size: var(--text-small); color: var(--color-text-secondary); margin-bottom: var(--space-2); display: block; }
  590. .network-tabs { display: flex; gap: var(--space-2); flex-wrap: wrap; }
  591. .net-tab {
  592. padding: 7px var(--space-4);
  593. border: 1px solid var(--color-border);
  594. border-radius: var(--radius-md);
  595. background: transparent;
  596. color: var(--color-text-secondary);
  597. font-size: var(--text-caption);
  598. cursor: pointer;
  599. transition: all var(--transition);
  600. }
  601. .net-tab:hover { border-color: var(--color-primary); color: var(--color-primary); }
  602. .net-tab.active { border-color: rgba(240,185,11,.7); background: rgba(240,185,11,.1); color: var(--color-primary); }
  603. .empty-net { font-size: var(--text-small); color: var(--color-text-muted); padding: var(--space-3); }
  604. .net-detail { background: rgba(255,255,255,.03); border-radius: var(--radius-md); padding: var(--space-4); margin-bottom: var(--space-5); }
  605. .info-row { display: flex; justify-content: space-between; gap: var(--space-3); padding: var(--space-2) 0; align-items: flex-start; }
  606. .info-label { font-size: var(--text-small); color: var(--color-text-secondary); flex-shrink: 0; }
  607. .info-val { font-size: var(--text-small); color: var(--color-text-primary); font-weight: var(--fw-medium); text-align: right; }
  608. .text-input {
  609. width: 100%;
  610. box-sizing: border-box;
  611. padding: var(--space-3) var(--space-4);
  612. border: 1px solid transparent;
  613. border-radius: var(--radius-md);
  614. outline: none;
  615. background: #202020;
  616. color: var(--color-text-primary);
  617. font-size: var(--text-small);
  618. }
  619. .text-input:focus { border-color: rgba(240,185,11,.35); }
  620. .text-input::placeholder { color: var(--color-text-muted); }
  621. .field-err { font-size: 12px; color: var(--color-down); margin-top: var(--space-2); }
  622. .primary-btn {
  623. margin-top: var(--space-3);
  624. min-width: 150px;
  625. padding: 13px var(--space-6);
  626. border: none;
  627. border-radius: var(--radius-md);
  628. background: var(--color-primary);
  629. color: #0a0a0a;
  630. font-weight: var(--fw-semibold);
  631. cursor: pointer;
  632. }
  633. .primary-btn:disabled { opacity: .5; cursor: not-allowed; }
  634. .primary-btn.full { width: 100%; margin-top: var(--space-5); }
  635. .addr-title { font-size: var(--text-body); font-weight: var(--fw-semibold); color: var(--color-text-primary); margin-bottom: var(--space-4); }
  636. .addr-hint { font-size: var(--text-caption); color: var(--color-text-muted); line-height: 1.6; margin-bottom: var(--space-4); }
  637. .addr-row { display: flex; align-items: flex-start; gap: var(--space-3); }
  638. .addr-text { flex: 1; font-size: var(--text-caption); color: var(--color-text-secondary); font-family: var(--font-family-num); word-break: break-all; line-height: 1.5; }
  639. .copy-btn { white-space: nowrap; background: none; border: none; color: var(--color-text-secondary); font-size: var(--text-caption); cursor: pointer; flex-shrink: 0; }
  640. .copy-btn:hover { color: var(--color-primary); }
  641. .qr-block { margin-top: var(--space-5); }
  642. .qr-block-title { margin-bottom: var(--space-2); }
  643. .qr-frame { display: inline-flex; padding: var(--space-3); background: #fff; border-radius: var(--radius-md); }
  644. .qr-img { display: block; width: 152px; height: 152px; }
  645. .order-meta { display: flex; flex-direction: column; gap: var(--space-3); }
  646. .meta-row { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; font-size: var(--text-small); }
  647. .meta-k { color: var(--color-text-secondary); min-width: 64px; }
  648. .meta-v { color: var(--color-text-primary); font-weight: var(--fw-medium); }
  649. .meta-v.amt { font-family: var(--font-family-num); }
  650. .meta-v.status.st-0, .meta-v.status.st-1 { color: var(--color-primary); }
  651. .meta-v.status.st-2 { color: var(--color-up); }
  652. .meta-v.status.st-3, .meta-v.status.st-4 { color: var(--color-text-muted); }
  653. .mini-copy { font-size: 12px; padding: 2px 8px; border: none; border-radius: var(--radius-sm); background: rgba(255,255,255,.04); color: var(--color-text-secondary); cursor: pointer; }
  654. .mini-copy:hover { color: var(--color-primary); }
  655. .pay-destination { margin-top: var(--space-6); padding-top: var(--space-5); border-top: 1px solid var(--color-border); }
  656. .hash-block { margin-top: var(--space-5); }
  657. .sub-section-title { font-size: var(--text-small); font-weight: var(--fw-semibold); color: var(--color-text-primary); margin-bottom: var(--space-2); }
  658. .wc-hint { font-size: var(--text-caption); color: var(--color-text-secondary); line-height: 1.6; margin: var(--space-4) 0 var(--space-3); }
  659. .wc-hint.muted { color: var(--color-text-muted); }
  660. .wc-btn {
  661. width: 100%;
  662. margin-bottom: var(--space-5);
  663. padding: var(--space-3) var(--space-6);
  664. border: 1px solid rgba(240,185,11,.5);
  665. border-radius: var(--radius-md);
  666. background: rgba(240,185,11,.14);
  667. color: var(--color-primary);
  668. font-weight: var(--fw-semibold);
  669. cursor: pointer;
  670. }
  671. .wc-btn:disabled { opacity: .5; cursor: not-allowed; }
  672. .hash-label { margin-top: var(--space-4); }
  673. .hash-done { margin-top: var(--space-5); font-size: var(--text-small); color: var(--color-text-secondary); word-break: break-all; }
  674. .contract-box { margin-top: var(--space-5); padding: var(--space-4); background: rgba(255,255,255,.04); border-radius: var(--radius-md); }
  675. .mono { font-family: var(--font-family-num); }
  676. .mono.small { font-size: 12px; line-height: 1.5; word-break: break-all; }
  677. .break { word-break: break-all; }
  678. .hint-p { font-size: var(--text-small); color: var(--color-text-secondary); line-height: 1.8; margin-bottom: var(--space-4); }
  679. .hint-p strong { color: var(--color-text-primary); font-weight: var(--fw-medium); }
  680. @media (max-width: 768px) {
  681. .content { max-width: 100%; }
  682. .two-col { grid-template-columns: 1fr; }
  683. .address-card { min-height: 0; }
  684. .order-detail-card { padding: var(--space-5); }
  685. }
  686. </style>