فهرست منبع

新增 IDO 我的团队页并对接质押团队接口。

IDO 页团队/锁仓总览改用 /uc/staking/team,点击我的团队跳转团队详情与直推列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
jean 1 ماه پیش
والد
کامیت
11160067c2

+ 29 - 34
code/src/api/staking.ts

@@ -71,40 +71,35 @@ export interface StakingOrderItem {
   createTime?: string
 }
 
-/** IDO 页「我的团队」人数:优先经纪商 info,其次邀请列表长度 */
-export async function fetchIdoTeamCount(): Promise<number> {
-  const pickCount = (raw: unknown): number | null => {
-    if (!raw || typeof raw !== 'object') return null
-    const r = raw as Record<string, unknown>
-    const keys = [
-      'teamCount',
-      'teamMemberCount',
-      'memberCount',
-      'childCount',
-      'totalMember',
-      'inviteCount',
-      'promotionCount',
-      'totalInvited',
-    ]
-    for (const k of keys) {
-      const n = Number(r[k])
-      if (Number.isFinite(n) && n >= 0) return Math.floor(n)
-    }
-    return null
-  }
-  try {
-    const fromInfo = pickCount(await post<unknown>('/uc/agent/info', {}))
-    if (fromInfo != null) return fromInfo
-  } catch {
-    // 非经纪商或接口不可用
-  }
-  try {
-    const list = await post<unknown>('/uc/agent/reward/set/list', {})
-    if (Array.isArray(list)) return list.length
-  } catch {
-    // 静默
-  }
-  return 0
+/** POST /uc/staking/team — IDO 团队总览 */
+export interface StakingTeamOverview {
+  directNum: number
+  teamNum: number
+  teamPerformance: number
+  totalRevenue: number
+  lockTotal: number
+  nodeLevel: string
+}
+
+/** POST /uc/staking/direct/list — 直推列表项 */
+export interface StakingDirectMember {
+  directAddress: string
+  level: string
+  buyAmount: number
+  reward: number
+}
+
+/** 查询质押团队总览(IDO 页卡片、我的团队页头部) */
+export function fetchStakingTeamOverview() {
+  return postForm<StakingTeamOverview>('/uc/staking/team', {})
+}
+
+/** 分页查询直推列表 */
+export function fetchStakingDirectList(pageNo = 1, pageSize = 20) {
+  return postForm<SpringPage<StakingDirectMember>>('/uc/staking/direct/list', {
+    pageNo,
+    pageSize,
+  })
 }
 
 /** 分页查询我的质押订单,status 支持单个状态或逗号分隔状态,如 "0,1" */

+ 112 - 0
code/src/locales/financePage.i18n.ts

@@ -8,6 +8,22 @@ export const financePageByLocale: Record<SupportedLocale, Record<string, string>
     idoRuleOnce: '会员购买一定量平台自己的主链币,自动进入锁仓账户。锁仓{lock}个月,到期一次性释放。',
     idoRuleBatch: '会员购买一定量平台自己的主链币,自动进入锁仓账户。锁仓{lock}个月,按{release}个月释放。',
     myTeam: '我的团队',
+    currentNodeLevel: '当前节点等级',
+    directNumLabel: '直推人数',
+    teamNumLabel: '团队人数',
+    teamPerformanceLabel: '团队业绩(iBit)',
+    totalRevenueLabel: '总收益(USDT)',
+    directListTitle: '直推列表',
+    directAddress: '直推地址',
+    levelCol: '等级',
+    buyAmountCol: '购买量(iBit)',
+    myRewardCol: '我的奖励(U)',
+    nodeLevelNormal: '无节点',
+    nodeLevelCommunity: '社区节点',
+    nodeLevelCity: '城市节点',
+    nodeLevelApac: '亚太节点',
+    nodeLevelGlobal: '全球节点',
+    teamLoadMore: '加载更多',
     joinPresale: '参与预售',
     subscribeQty: '认购数量',
     availableIbit: '可提现可用 iBit',
@@ -73,6 +89,22 @@ export const financePageByLocale: Record<SupportedLocale, Record<string, string>
     idoRuleOnce: '會員購買一定量平台自己的主鏈幣,自動進入鎖倉帳戶。鎖倉{lock}個月,到期一次性釋放。',
     idoRuleBatch: '會員購買一定量平台自己的主鏈幣,自動進入鎖倉帳戶。鎖倉{lock}個月,按{release}個月釋放。',
     myTeam: '我的團隊',
+    currentNodeLevel: '當前節點等級',
+    directNumLabel: '直推人數',
+    teamNumLabel: '團隊人數',
+    teamPerformanceLabel: '團隊業績(iBit)',
+    totalRevenueLabel: '總收益(USDT)',
+    directListTitle: '直推列表',
+    directAddress: '直推地址',
+    levelCol: '等級',
+    buyAmountCol: '購買量(iBit)',
+    myRewardCol: '我的獎勵(U)',
+    nodeLevelNormal: '無節點',
+    nodeLevelCommunity: '社區節點',
+    nodeLevelCity: '城市節點',
+    nodeLevelApac: '亞太節點',
+    nodeLevelGlobal: '全球節點',
+    teamLoadMore: '載入更多',
     joinPresale: '參與預售',
     subscribeQty: '認購數量',
     availableIbit: '可提现可用 iBit',
@@ -137,6 +169,22 @@ export const financePageByLocale: Record<SupportedLocale, Record<string, string>
     idoRuleOnce: 'Members purchase a set amount of the platform main-chain token; funds are locked automatically. Locked for {lock} month(s), then released in full.',
     idoRuleBatch: 'Members purchase a set amount of the platform main-chain token; funds are locked automatically. Locked for {lock} month(s), released over {release} month(s).',
     myTeam: 'My Team',
+    currentNodeLevel: 'Current Node Level',
+    directNumLabel: 'Direct Referrals',
+    teamNumLabel: 'Team Size',
+    teamPerformanceLabel: 'Team Volume (iBit)',
+    totalRevenueLabel: 'Total Revenue (USDT)',
+    directListTitle: 'Direct Referrals',
+    directAddress: 'Address',
+    levelCol: 'Level',
+    buyAmountCol: 'Purchase (iBit)',
+    myRewardCol: 'My Reward (U)',
+    nodeLevelNormal: 'No Node',
+    nodeLevelCommunity: 'Community Node',
+    nodeLevelCity: 'City Node',
+    nodeLevelApac: 'APAC Node',
+    nodeLevelGlobal: 'Global Node',
+    teamLoadMore: 'Load more',
     joinPresale: 'Join Presale',
     subscribeQty: 'Subscription Amount',
     availableIbit: 'Withdrawable iBit',
@@ -202,6 +250,22 @@ export const financePageByLocale: Record<SupportedLocale, Record<string, string>
     idoRuleOnce: '회원이 일정량의 플랫폼 메인체인 코인을 구매하면 자동으로 락업 계정에 들어갑니다. {lock}개월 락업 후 일괄 해제됩니다.',
     idoRuleBatch: '회원이 일정량의 플랫폼 메인체인 코인을 구매하면 자동으로 락업 계정에 들어갑니다. {lock}개월 락업 후 {release}개월에 걸쳐 해제됩니다.',
     myTeam: '내 팀',
+    currentNodeLevel: '현재 노드 등급',
+    directNumLabel: '직추천 인원',
+    teamNumLabel: '팀 인원',
+    teamPerformanceLabel: '팀 실적(iBit)',
+    totalRevenueLabel: '총 수익(USDT)',
+    directListTitle: '직추천 목록',
+    directAddress: '주소',
+    levelCol: '등급',
+    buyAmountCol: '구매량(iBit)',
+    myRewardCol: '내 보상(U)',
+    nodeLevelNormal: '노드 없음',
+    nodeLevelCommunity: '커뮤니티 노드',
+    nodeLevelCity: '시티 노드',
+    nodeLevelApac: 'APAC 노드',
+    nodeLevelGlobal: '글로벌 노드',
+    teamLoadMore: '더 보기',
     joinPresale: '프리세일 참여',
     subscribeQty: '청약 수량',
     availableIbit: '출금 가능 iBit',
@@ -266,6 +330,22 @@ export const financePageByLocale: Record<SupportedLocale, Record<string, string>
     idoRuleOnce: '会員が一定量のプラットフォーム主チェーンコインを購入すると、自動的にロック口座に入ります。{lock}ヶ月ロック後、一括解放されます。',
     idoRuleBatch: '会員が一定量のプラットフォーム主チェーンコインを購入すると、自動的にロック口座に入ります。{lock}ヶ月ロック後、{release}ヶ月かけて解放されます。',
     myTeam: 'マイチーム',
+    currentNodeLevel: '現在のノード等級',
+    directNumLabel: '直接紹介人数',
+    teamNumLabel: 'チーム人数',
+    teamPerformanceLabel: 'チーム実績(iBit)',
+    totalRevenueLabel: '総収益(USDT)',
+    directListTitle: '直接紹介リスト',
+    directAddress: 'アドレス',
+    levelCol: '等級',
+    buyAmountCol: '購入量(iBit)',
+    myRewardCol: '報酬(U)',
+    nodeLevelNormal: 'ノードなし',
+    nodeLevelCommunity: 'コミュニティノード',
+    nodeLevelCity: 'シティノード',
+    nodeLevelApac: 'APACノード',
+    nodeLevelGlobal: 'グローバルノード',
+    teamLoadMore: 'もっと見る',
     joinPresale: 'プレセール参加',
     subscribeQty: '申込数量',
     availableIbit: '出金可能 iBit',
@@ -330,6 +410,22 @@ export const financePageByLocale: Record<SupportedLocale, Record<string, string>
     idoRuleOnce: 'सदस्य प्लेटफ़ॉर्म के मुख्य चेन सिक्के की निश्चित मात्रा खरीदते हैं, राशि स्वतः लॉक खाते में जाती है। {lock} महीने लॉक, फिर एकमुश्त रिलीज़।',
     idoRuleBatch: 'सदस्य प्लेटफ़ॉर्म के मुख्य चेन सिक्के की निश्चित मात्रा खरीदते हैं, राशि स्वतः लॉक खाते में जाती है। {lock} महीने लॉक, {release} महीनों में रिलीज़।',
     myTeam: 'मेरी टीम',
+    currentNodeLevel: 'वर्तमान नोड स्तर',
+    directNumLabel: 'प्रत्यक्ष रेफरल',
+    teamNumLabel: 'टीम आकार',
+    teamPerformanceLabel: 'टीम प्रदर्शन (iBit)',
+    totalRevenueLabel: 'कुल राजस्व (USDT)',
+    directListTitle: 'प्रत्यक्ष सूची',
+    directAddress: 'पता',
+    levelCol: 'स्तर',
+    buyAmountCol: 'खरीद (iBit)',
+    myRewardCol: 'मेरा इनाम (U)',
+    nodeLevelNormal: 'कोई नोड नहीं',
+    nodeLevelCommunity: 'सामुदायिक नोड',
+    nodeLevelCity: 'शहर नोड',
+    nodeLevelApac: 'APAC नोड',
+    nodeLevelGlobal: 'वैश्विक नोड',
+    teamLoadMore: 'और लोड करें',
     joinPresale: 'प्रीसेल में शामिल हों',
     subscribeQty: 'सदस्यता राशि',
     availableIbit: 'निकासी योग्य iBit',
@@ -394,6 +490,22 @@ export const financePageByLocale: Record<SupportedLocale, Record<string, string>
     idoRuleOnce: 'Anggota membeli token main-chain platform dalam jumlah tertentu dan otomatis masuk akun lock. Terkunci {lock} bulan, lalu dilepas sekaligus.',
     idoRuleBatch: 'Anggota membeli token main-chain platform dalam jumlah tertentu dan otomatis masuk akun lock. Terkunci {lock} bulan, dilepas selama {release} bulan.',
     myTeam: 'Tim Saya',
+    currentNodeLevel: 'Level Node Saat Ini',
+    directNumLabel: 'Referral Langsung',
+    teamNumLabel: 'Ukuran Tim',
+    teamPerformanceLabel: 'Volume Tim (iBit)',
+    totalRevenueLabel: 'Total Pendapatan (USDT)',
+    directListTitle: 'Daftar Langsung',
+    directAddress: 'Alamat',
+    levelCol: 'Level',
+    buyAmountCol: 'Beli (iBit)',
+    myRewardCol: 'Hadiah Saya (U)',
+    nodeLevelNormal: 'Tanpa Node',
+    nodeLevelCommunity: 'Node Komunitas',
+    nodeLevelCity: 'Node Kota',
+    nodeLevelApac: 'Node APAC',
+    nodeLevelGlobal: 'Node Global',
+    teamLoadMore: 'Muat lebih banyak',
     joinPresale: 'Ikut Presale',
     subscribeQty: 'Jumlah Langganan',
     availableIbit: 'iBit dapat ditarik',

+ 6 - 0
code/src/router/index.ts

@@ -85,6 +85,12 @@ const router = createRouter({
       component: () => import('@/views/finance/AirdropView.vue'),
       meta: { requiresAuth: true, title: '领取空投' },
     },
+    {
+      path: '/finance/team',
+      name: 'finance-team',
+      component: () => import('@/views/finance/StakingTeamView.vue'),
+      meta: { requiresAuth: true, title: '我的团队' },
+    },
     // 费率页已下架,统一跳转首页
     { path: '/fees', redirect: '/' },
     {

+ 22 - 0
code/src/utils/stakingNodeLevel.ts

@@ -0,0 +1,22 @@
+/** 质押团队节点等级(与后端 /uc/staking/team、direct/list 一致) */
+export type StakingNodeLevelCode = 'NORMAL' | 'COMMUNITY' | 'CITY' | 'APAC' | 'GLOBAL'
+
+const LEVEL_I18N_KEY: Record<string, string> = {
+  NORMAL: 'finance.nodeLevelNormal',
+  COMMUNITY: 'finance.nodeLevelCommunity',
+  CITY: 'finance.nodeLevelCity',
+  APAC: 'finance.nodeLevelApac',
+  GLOBAL: 'finance.nodeLevelGlobal',
+}
+
+export function normalizeStakingNodeLevel(level: string | undefined | null): StakingNodeLevelCode {
+  const u = String(level ?? 'NORMAL').trim().toUpperCase()
+  if (u in LEVEL_I18N_KEY) {
+    return u as StakingNodeLevelCode
+  }
+  return 'NORMAL'
+}
+
+export function stakingNodeLevelI18nKey(level: string | undefined | null): string {
+  return LEVEL_I18N_KEY[normalizeStakingNodeLevel(level)] ?? LEVEL_I18N_KEY.NORMAL
+}

+ 22 - 17
code/src/views/finance/StakeView.vue

@@ -3,9 +3,9 @@ import { computed, onMounted, ref, watch } from 'vue'
 import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 import {
-  fetchIdoTeamCount,
   fetchMinOpenStakingConfig,
   fetchStakingConfigList,
+  fetchStakingTeamOverview,
   submitStake,
   type StakingConfig,
 } from '@/api/staking'
@@ -145,23 +145,28 @@ async function refreshWithdrawableIbit() {
 
 async function refreshOverviewStats() {
   const unit = stakingCoinUnit()
-  const [stakingSnap, team] = await Promise.all([
-    loadStakingWalletSnapshot(unit).catch(() => ({
-      available: '0',
-      locked: '0',
-      availableUsdt: '0',
-      lockedUsdt: '0',
-    })),
-    fetchIdoTeamCount().catch(() => 0),
-  ])
-  const total =
-    Number(stakingSnap.available) + Number(stakingSnap.locked)
-  lockedTotal.value = Number.isFinite(total) ? String(total) : '0'
-  teamCount.value = team
+  try {
+    const team = await fetchStakingTeamOverview()
+    teamCount.value = Number(team.teamNum) || 0
+    const lock = Number(team.lockTotal)
+    if (Number.isFinite(lock)) {
+      lockedTotal.value = String(lock)
+      return
+    }
+  } catch {
+    // 回退质押钱包余额
+  }
+  try {
+    const stakingSnap = await loadStakingWalletSnapshot(unit)
+    const total = Number(stakingSnap.available) + Number(stakingSnap.locked)
+    lockedTotal.value = Number.isFinite(total) ? String(total) : '0'
+  } catch {
+    lockedTotal.value = '0'
+  }
 }
 
-function goToBroker() {
-  router.push({ name: 'broker' })
+function goToMyTeam() {
+  router.push({ name: 'finance-team' })
 }
 
 function goToStakingAccount() {
@@ -284,7 +289,7 @@ watch(() => route.params.configId, () => load())
       <!-- 团队 / 锁仓总览 -->
       <section class="ido-overview-section page-container">
         <div class="ido-overview-grid">
-          <button type="button" class="ido-overview-card" @click="goToBroker">
+          <button type="button" class="ido-overview-card" @click="goToMyTeam">
             <span class="ido-overview-label">
               {{ t('finance.myTeam') }}
               <span class="ido-overview-chevron" aria-hidden="true">&gt;</span>

+ 394 - 0
code/src/views/finance/StakingTeamView.vue

@@ -0,0 +1,394 @@
+<script setup lang="ts">
+import { computed, onMounted, ref } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { useRouter } from 'vue-router'
+import {
+  fetchStakingDirectList,
+  fetchStakingTeamOverview,
+  type StakingDirectMember,
+  type StakingTeamOverview,
+} from '@/api/staking'
+import { stakingNodeLevelI18nKey } from '@/utils/stakingNodeLevel'
+
+const { t, te } = useI18n()
+const router = useRouter()
+
+const PAGE_SIZE = 20
+
+const loading = ref(true)
+const error = ref<string | null>(null)
+const overview = ref<StakingTeamOverview | null>(null)
+
+const directList = ref<StakingDirectMember[]>([])
+const listLoading = ref(false)
+const listPage = ref(1)
+const listHasMore = ref(false)
+
+function fmtNum(n: string | number | undefined, maxFrac = 2) {
+  const v = Number(n)
+  if (!Number.isFinite(v)) return '0'
+  return v.toLocaleString('en-US', {
+    minimumFractionDigits: 0,
+    maximumFractionDigits: maxFrac,
+  })
+}
+
+function levelLabel(level: string | undefined) {
+  const key = stakingNodeLevelI18nKey(level)
+  return te(key) ? t(key) : t('finance.nodeLevelNormal')
+}
+
+const nodeLevelText = computed(() => levelLabel(overview.value?.nodeLevel))
+
+const statCards = computed(() => {
+  const o = overview.value
+  return [
+    { label: t('finance.directNumLabel'), value: fmtNum(o?.directNum ?? 0, 0) },
+    { label: t('finance.teamNumLabel'), value: fmtNum(o?.teamNum ?? 0, 0) },
+    { label: t('finance.teamPerformanceLabel'), value: fmtNum(o?.teamPerformance ?? 0) },
+    { label: t('finance.totalRevenueLabel'), value: fmtNum(o?.totalRevenue ?? 0) },
+  ]
+})
+
+function goBack() {
+  if (window.history.length > 1) {
+    router.back()
+    return
+  }
+  router.push({ name: 'finance-ido' })
+}
+
+async function loadOverview() {
+  overview.value = await fetchStakingTeamOverview()
+}
+
+async function loadDirectList(page = 1, append = false) {
+  listLoading.value = true
+  try {
+    const pageData = await fetchStakingDirectList(page, PAGE_SIZE)
+    const rows = Array.isArray(pageData?.content) ? pageData.content : []
+    directList.value = append ? [...directList.value, ...rows] : rows
+    listPage.value = page
+    listHasMore.value = pageData?.last === false
+  } catch {
+    if (!append) {
+      directList.value = []
+    }
+    listHasMore.value = false
+  } finally {
+    listLoading.value = false
+  }
+}
+
+async function loadAll() {
+  loading.value = true
+  error.value = null
+  try {
+    await Promise.all([loadOverview(), loadDirectList(1, false)])
+  } catch (e) {
+    error.value = e instanceof Error ? e.message : t('common.loadFailed')
+    overview.value = null
+    directList.value = []
+  } finally {
+    loading.value = false
+  }
+}
+
+function loadMoreDirect() {
+  if (listLoading.value || !listHasMore.value) return
+  void loadDirectList(listPage.value + 1, true)
+}
+
+onMounted(() => {
+  void loadAll()
+})
+</script>
+
+<template>
+  <main class="team-page page-main">
+    <header class="team-topbar page-container">
+      <button type="button" class="team-back" aria-label="back" @click="goBack">‹</button>
+      <h1 class="team-title">{{ t('finance.myTeam') }}</h1>
+      <span class="team-topbar-spacer" aria-hidden="true" />
+    </header>
+
+    <div v-if="loading" class="team-loading page-container">
+      <div class="spinner" />
+    </div>
+
+    <div v-else-if="error" class="team-error page-container">
+      <p>{{ error }}</p>
+      <button type="button" class="retry-btn" @click="loadAll">{{ t('common.retry') }}</button>
+    </div>
+
+    <template v-else>
+      <div class="page-container team-body">
+        <div class="node-banner">
+          <span class="node-banner-label">{{ t('finance.currentNodeLevel') }}</span>
+          <span class="node-banner-value">{{ nodeLevelText }}</span>
+        </div>
+
+        <div class="stats-grid">
+          <div v-for="(card, i) in statCards" :key="i" class="stat-card dark-card">
+            <p class="stat-value">{{ card.value }}</p>
+            <p class="stat-label">{{ card.label }}</p>
+          </div>
+        </div>
+
+        <h2 class="section-title">{{ t('finance.directListTitle') }}</h2>
+
+        <div v-if="listLoading && !directList.length" class="list-loading">
+          <div class="spinner sm" />
+        </div>
+        <div v-else-if="!directList.length" class="list-empty">{{ t('common.noData') }}</div>
+        <div v-else class="direct-table-wrap">
+          <table class="direct-table">
+            <thead>
+              <tr>
+                <th>{{ t('finance.directAddress') }}</th>
+                <th>{{ t('finance.levelCol') }}</th>
+                <th>{{ t('finance.buyAmountCol') }}</th>
+                <th>{{ t('finance.myRewardCol') }}</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="(row, idx) in directList" :key="`${row.directAddress}-${idx}`">
+                <td class="direct-addr">{{ row.directAddress || '—' }}</td>
+                <td>{{ levelLabel(row.level) }}</td>
+                <td class="num">{{ fmtNum(row.buyAmount) }}</td>
+                <td class="num">{{ fmtNum(row.reward) }}</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+
+        <div v-if="listHasMore" class="load-more-row">
+          <button
+            type="button"
+            class="load-more-btn"
+            :disabled="listLoading"
+            @click="loadMoreDirect"
+          >
+            {{ listLoading ? t('common.loading') : t('finance.teamLoadMore') }}
+          </button>
+        </div>
+      </div>
+    </template>
+  </main>
+</template>
+
+<style scoped>
+.team-page {
+  padding-bottom: var(--space-16);
+  min-height: 60vh;
+}
+
+.team-topbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding-top: calc(var(--header-height) + var(--space-4));
+  padding-bottom: var(--space-4);
+}
+
+.team-back {
+  width: 40px;
+  height: 40px;
+  border: none;
+  background: transparent;
+  color: var(--color-text-primary);
+  font-size: 28px;
+  line-height: 1;
+  cursor: pointer;
+  padding: 0;
+}
+
+.team-title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: var(--fw-semibold);
+  color: var(--color-text-primary);
+}
+
+.team-topbar-spacer {
+  width: 40px;
+}
+
+.team-body {
+  max-width: 960px;
+}
+
+.team-loading,
+.team-error {
+  padding-top: var(--space-16);
+  text-align: center;
+  color: var(--color-text-secondary);
+}
+
+.spinner {
+  width: 36px;
+  height: 36px;
+  margin: 0 auto;
+  border-radius: 50%;
+  border: 2px solid var(--color-border);
+  border-top-color: var(--color-primary);
+  animation: spin 0.8s linear infinite;
+}
+
+.spinner.sm {
+  width: 24px;
+  height: 24px;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.retry-btn {
+  margin-top: var(--space-4);
+  padding: var(--space-2) var(--space-6);
+  border-radius: var(--radius-md);
+  border: 1px solid var(--color-primary);
+  background: transparent;
+  color: var(--color-primary);
+  cursor: pointer;
+}
+
+.node-banner {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-4);
+  padding: var(--space-5) var(--space-6);
+  margin-bottom: var(--space-4);
+  border-radius: 12px;
+  background: linear-gradient(90deg, #c9a227 0%, #e8c547 50%, #c9a227 100%);
+  color: #1a1408;
+}
+
+.node-banner-label {
+  font-size: 15px;
+  font-weight: var(--fw-medium);
+}
+
+.node-banner-value {
+  font-size: 16px;
+  font-weight: var(--fw-bold);
+}
+
+.stats-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: var(--space-3);
+  margin-bottom: var(--space-8);
+}
+
+.stat-card {
+  padding: var(--space-5) var(--space-4);
+  text-align: center;
+}
+
+.stat-value {
+  margin: 0 0 var(--space-2);
+  font-size: 22px;
+  font-weight: var(--fw-bold);
+  color: var(--color-primary);
+  font-family: var(--font-family-num);
+  line-height: 1.2;
+}
+
+.stat-label {
+  margin: 0;
+  font-size: 13px;
+  color: var(--color-text-secondary);
+  line-height: 1.4;
+}
+
+.section-title {
+  margin: 0 0 var(--space-4);
+  font-size: 18px;
+  font-weight: var(--fw-semibold);
+  color: var(--color-text-primary);
+}
+
+.list-loading,
+.list-empty {
+  text-align: center;
+  padding: var(--space-8) 0;
+  color: var(--color-text-secondary);
+  font-size: var(--text-small);
+}
+
+.direct-table-wrap {
+  overflow-x: auto;
+  -webkit-overflow-scrolling: touch;
+}
+
+.direct-table {
+  width: 100%;
+  min-width: 560px;
+  border-collapse: collapse;
+  font-size: 13px;
+}
+
+.direct-table th {
+  padding: var(--space-3) var(--space-2);
+  font-weight: var(--fw-normal);
+  font-size: 12px;
+  color: var(--color-text-muted);
+  text-align: left;
+  white-space: nowrap;
+  border-bottom: 1px solid var(--color-border);
+}
+
+.direct-table td {
+  padding: var(--space-4) var(--space-2);
+  color: var(--color-text-primary);
+  vertical-align: middle;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.direct-table td.num {
+  font-family: var(--font-family-num);
+  white-space: nowrap;
+}
+
+.direct-addr {
+  max-width: 140px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.load-more-row {
+  display: flex;
+  justify-content: center;
+  padding: var(--space-6) 0;
+}
+
+.load-more-btn {
+  padding: var(--space-2) var(--space-8);
+  border-radius: 999px;
+  border: 1px solid var(--color-border);
+  background: transparent;
+  color: var(--color-text-secondary);
+  font-size: var(--text-small);
+  cursor: pointer;
+}
+
+.load-more-btn:hover:not(:disabled) {
+  border-color: var(--color-primary);
+  color: var(--color-primary);
+}
+
+.load-more-btn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+@media (max-width: 640px) {
+  .stat-value {
+    font-size: 20px;
+  }
+}
+</style>