css 生成 footer 固定,上面可滚动的布局

 
并可以自动适配,如果 footer没有则上面的东西会铺满
notion image
<div class="panel">
  <div class="container">
    <!-- 容器内容 -->
  </div>
  <div class="footer">
    <!-- 页脚内容 -->
  </div>
</div>


.panel {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

.container {
  flex-grow: 1; /* 占满剩余空间 */
  overflow-y: auto; /* 当内容溢出时滚动 */
}

.footer {
  flex-shrink: 0; /* 不允许压缩,始终保持最小高度 */
  min-height: 40px; /* 固定最小高度 */
}
 

源代码

import React, { useState } from 'react'
import NextImage from 'next/image'
import NoData from '@futures-simulation-trade/components/Nodata'
import AnswerIcon from '@futures-simulation-trade/assets/images/answer.svg'
import { useTranslation } from 'next-i18next'
import { observer } from 'mobx-react-lite'
import { Tooltip } from '@mantine/core'
import { imgLoader } from 'utils/index'
import { handleGoKYC, handleGotoCouponCenter } from '@/utils/goOtherPage'
import { RedEnvelopeMyPrizeInfo } from '@futures-simulation-trade/types/redPackets'
import { usePrizeMap } from '@futures-simulation-trade/hooks/usePrizeMap'
import { ActiveStatus, StackPopupTypeEnum } from '@futures-simulation-trade/types'
import { useStore } from '@futures-simulation-trade/store'
import { ActivityErrorCodeMap } from '@futures-simulation-trade/utils/errorCode'
import { requestExtract, requestGetRedEnvelopeMyPrize } from '@futures-simulation-trade/apis/redEnvelope'
import { Pagination } from '@gate/gui'
import Button from '@futures-simulation-trade/components/Button'
import useSWR from 'swr'
import styles from './index.module.css'
import { Loading } from '@gate/gui-business'
import { logger } from '@gateio/core-lib'
// 合约体验券id
const contractExperienceId = 'G10377'

const PER_PAGE_NUMBER = 20

const ONE_PAGE = 1

enum ContractExperienceType {
  CLAIM_NOW = 0,
  COLLECTING = 1,
  USE_NOW = 2,
}

function RedEnvelopeWiningRecord() {
  const { t } = useTranslation('simulation')
  const { prizeMap } = usePrizeMap()
  const { addStackPopup, getBuryingPointProps, activeStatus, physicalPrizes, closeTopStackPopup } = useStore()

  const [pagination, setPagination] = useState({ page: 1, pageSize: PER_PAGE_NUMBER, totalPage: 0 })
  const [loading, setLoading] = useState(false)
  const [isShowFooter, setIsShowFooter] = useState(false)

  const { data, mutate, isLoading } = useSWR(`GET_RED_PACKETS_MY_RECORD-${pagination.page}`, () =>
    requestGetRedEnvelopeMyPrize({ page: pagination.page, pageSize: pagination.pageSize }).then(res => {
      if (res?.code === ActivityErrorCodeMap.SUCCESS) {
        setPagination({
          page: res?.data?.prize?.current_page || 0,
          pageSize: res?.data?.prize?.per_page || PER_PAGE_NUMBER,
          totalPage: res?.data?.prize?.last_page || 0,
        })
        return res?.data
      }
      throw res?.message
    })
  )

  const prizes: RedEnvelopeMyPrizeInfo[] = data?.prize?.data || []

  const ActivityErrorMap = {
    //400 不存在提取 406未完成kyc 402同一实名 403风控 404未完成7天连续入金 405领取合约体验券已过期
    [ActivityErrorCodeMap.TWENTY_ONE_DAYS_DEPOSIT_UNFINISHED]: t('simulation.Game.FuturesVoucherRule', { unit: '$' }),
    [ActivityErrorCodeMap.EXTRACT_NOT_EXIST]: t('simulation.Game.FuturesVoucherOnce'),
    [ActivityErrorCodeMap.RISK_CONTROL]: t('simulation.Game.AccountError'),
    [ActivityErrorCodeMap.SAME_REAL_NAME]: t('simulation.Game.SameAccountClaimOnce'),
    [ActivityErrorCodeMap.KYC_UNFINISHED]: t('simulation.Game.NeedKYCWarning'),
    [ActivityErrorCodeMap.CONTRACT_EXPERIENCE_COUPON_EXPIRED]: t('simulation.Game.Expired'),
  }

  const buttonMap = {
    [ContractExperienceType.CLAIM_NOW]: t('simulation.Game.ClaimNow'),
    [ContractExperienceType.COLLECTING]: t('simulation.Game.Collecting'),
    [ContractExperienceType.USE_NOW]: t('simulation.Game.UseNow'),
  }

  const burialPointMap = {
    [ContractExperienceType.CLAIM_NOW]: 'coupon_extract',
    [ContractExperienceType.COLLECTING]: '',
    [ContractExperienceType.USE_NOW]: 'coupon_view',
  }

  /* 领取合约体验券 */
  const receiveFuturesVoucher = async () => {
    setLoading(true)
    try {
      const res = await requestExtract()
      if (res?.code === ActivityErrorCodeMap.SUCCESS) {
        mutate()
        return
      }

      if (res?.code === ActivityErrorCodeMap.KYC_UNFINISHED) {
        addStackPopup({
          type: StackPopupTypeEnum.TIP,
          content: t('simulation.Game.NeedKYCWarning'),
          data: {
            confirmText: t('simulation.Game.GoKYC'),
            onClick: handleGoKYC,
          },
        })
        return
      }

      addStackPopup({
        type: StackPopupTypeEnum.TIP,
        content: ActivityErrorMap[res?.code] || res?.message || '',
      })
    } catch (error) {
      logger.error('Simulation RedEnvelopeWiningRecord ReceiveFuturesVoucher Error', error)
    } finally {
      setLoading(false)
    }
  }

  const handleFuturesVoucher = (item: RedEnvelopeMyPrizeInfo) => {
    if (item.get_status === ContractExperienceType.COLLECTING) {
      return
    } else if (item.get_status === ContractExperienceType.CLAIM_NOW) {
      receiveFuturesVoucher()
    } else if (item.get_status === ContractExperienceType.USE_NOW) {
      handleGotoCouponCenter()
    }
  }

  const changePage = (page: number) => {
    setPagination(pre => ({ ...pre, page }))
  }

  /* 体验金提示 */
  const renderExperienceTips = (item: RedEnvelopeMyPrizeInfo) => {
    if (item.gift_id !== contractExperienceId) return

    return (
      <Tooltip
        label={t('simulation.Game.FuturesVoucher', { unit: '$' })}
        w={200}
        multiline
        withArrow
        arrowSize={4}
        position="bottom"
        events={{ hover: true, focus: true, touch: true }}
      >
        <NextImage loader={imgLoader} className={styles.icon} src={AnswerIcon} alt="answer icon" />
      </Tooltip>
    )
  }

  const receivePrizeInKind = () => {
    // TODO 调用接口
    // 已过期提示
    // addStackPopup({
    //   type: StackPopupTypeEnum.TIP,
    //   title: 'Sorry',
    //   content: 'The event has ended for more than 15 days and the physical reward has expired',
    // })

    closeTopStackPopup()
    addStackPopup({
      type: StackPopupTypeEnum.RED_ENVELOPE_RECEIVE_REWARD,
    })
  }

  const renderMainContent = () => {
    if (isLoading) {
      return (
        <div className={styles.loadingContainer}>
          <Loading />
        </div>
      )
    }
    if (!prizes?.length)
      return (
        <div className={styles.noDataContainer}>
          <NoData className={styles.noData} />
        </div>
      )

    return (
      <>
        <ul className={styles.wrap}>
          {prizes?.map((item, index) => (
            <li key={index} className={styles.wining}>
              <div className={styles.winingWrap}>
                <div className={styles.winingLeft}>
                  <NextImage
                    className={styles.prizeImage}
                    loader={imgLoader}
                    width={32}
                    height={32}
                    src={prizeMap[item.gift_id]?.img}
                    alt="ActionWinningRecord"
                  />

                  <div className={styles.details}>
                    <div className={styles.prizeInfo}>
                      <span className={styles.name}>{prizeMap[item.gift_id]?.name}</span>
                      <span className={styles.icon}>{renderExperienceTips(item)}</span>
                    </div>
                    <div className={styles.time}>{item.inst_time}</div>
                  </div>
                </div>

                <div className={styles.winingRight}>
                  {item.gift_id === contractExperienceId && (
                    <Button
                      shape="normal"
                      isLoading={loading}
                      className={styles.claimButton}
                      onClick={() => handleFuturesVoucher(item)}
                      {...(burialPointMap[item.get_status] && getBuryingPointProps(burialPointMap[item.get_status]))}
                    >
                      <span className={styles.actionButtonText}>{buttonMap[item.get_status]}</span>
                    </Button>
                  )}
                </div>
              </div>
            </li>
          ))}
        </ul>
        {pagination?.totalPage > ONE_PAGE && (
          <Pagination
            className={styles.pagination}
            total={pagination?.totalPage || 0}
            page={pagination?.page || 1}
            size={pagination?.pageSize || PER_PAGE_NUMBER}
            onChange={changePage}
          />
        )}
      </>
    )
  }

  const renderFooter = () => {
    // if (activeStatus !== ActiveStatus.End || !physicalPrizes.length) return
    if (!physicalPrizes.length) return
    return (
      <div className={styles.footer}>
        <Button className={styles.confirmButton} shape="secondary" onClick={receivePrizeInKind}>
          领取实物奖励
        </Button>
        <div className={styles.confirmTips}>
          You can claim the prize within 15 days after the game, and the physical reward will expire if it is not
          claimed after 15 days
        </div>
      </div>
    )
  }
  return (
    <div className={styles.panelContainer}>
      <div className={styles.container}>{renderMainContent()}</div>
      {renderFooter()}
    </div>
  )
}

export default observer(RedEnvelopeWiningRecord)
.panelContainer {
  width: 420px;
  min-height: 500px;
}

.container {
  width: 420px;
  height: 500px;
  overflow-y: auto;
  margin-right: -12px;
  padding-right: 12px;
}

.footer {
  padding-top: 32px;
  width: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.confirmButton {
  width: 100%;
}

.confirmTips {
  padding-top: 12px;
  font-size: 12px;
  font-weight: 400;
  line-height: 14.4px;
  text-align: center;
  color: #bfbfbf;
}

.wrap {
  box-sizing: border-box;
  min-height: 400px;
  overflow-y: auto;
  width: 100%;
}

.tip {
  font-size: 12px;
  font-weight: 400;
  line-height: 18px;
  text-align: center;
  color: #cef4f0;

  > span {
    font-weight: 500;
    margin-inline-start: 8px;
    color: #f0fb4e;
    cursor: pointer;
  }
}

.wining {
  margin-top: 24px;
  font-size: 16px;
  font-weight: 400;
  line-height: 16px;
  color: #fff;
  width: 100%;
}

.winingWrap {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.wining:first-child {
  margin-top: 0;
}

.winingLeft {
  display: flex;
  align-items: center;
  margin-inline-end: 24px;
  /* width: 270px; */
  flex: 1;

  .prizeImage {
    width: 32px;
    height: 32px;
    margin-inline-end: 8px;
  }

  .details {
    display: flex;
    flex-direction: column;
    max-width: 252px;

    .prizeInfo {
      font-size: 14px;
      line-height: 16px;
      color: #fafafa;
      margin-bottom: 4px;

      .name {
        max-width: 232px;
        overflow: hidden;
        text-overflow: ellipsis;
      }

      .icon {
        display: inline-flex;
        align-items: center;
        margin-inline-start: 4px;
        width: 16px;
        height: 16px;
        cursor: pointer;
        vertical-align: text-bottom;
      }
    }

    .time {
      font-size: 12px;
      color: #8c8c8c;
      line-height: 14.4px;
    }
  }
}

.winingRight {
  display: flex;
  align-items: center;
  overflow: hidden;
}

.claimButton {
  white-space: nowrap;
}

.actionButtonText {
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
}

.loadingContainer {
  width: 100%;
  height: 400px;
  overflow: hidden;
  display: flex;
  align-items: center;
}

.noDataContainer {
  width: 100%;
  height: 400px;
  display: flex;
  align-items: center;
}

.noData {
  min-height: unset;
}

.pagination {
  display: flex;
  justify-content: center;
  margin: 32px 0 0 0;
}

@media screen and (max-width: 768px) {
  .panelContainer {
    width: 100%;
    min-height: auto;
    max-height: 64vh;
    display: flex;
    flex-direction: column;
  }

  .container {
    width: 100%;
    flex-grow: 1;
    overflow-y: auto;
  }

  .wrap {
    min-height: auto;
  }

  .actionButton {
    margin-top: 8px;
    margin-inline-start: 0;
  }

  .wining {
    margin-top: 18px;
    font-size: 14px;
    line-height: 14px;
  }

  .pagination {
    margin: 32px 0 20px 0;
  }

  .footer {
    width: 100%;
    min-height: 40px;
    margin-bottom: 16px;
    flex-shrink: 0;
  }
}