【山竹记账前端-react】11.完成制作列表页布局


大纲链接 §

[toc]


1. 创建ItemsPage页面 ⇧

制作列表页,使用渐变色生成器做背景 CSS Gradient generator

  • 'bg-linear-to-b from-[var(--welcome-card-start)] to-[var(--welcome-card-end)]'

@/pages/ItemsPage.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { AddItemFloatButton } from "@/components/AddItemFloatButton"
import { DateRangePicker } from "@/components/DateRangePicker"
import { TopNav } from "@/components/TopNav"
import { ItemsList } from "@/modules/items/ItemsList"
import { ItemsSummary } from "@/modules/items/ItemsSummary"
import { cn } from "cn"

export const ItemsPage: React.FC = () => {

  return (
    <div>
      <header
        className={cn(
          'bg-linear-to-b from-[var(--welcome-card-start)] to-[var(--welcome-card-end)]',
        )}>
        <TopNav />
        <DateRangePicker />
      </header>

      <ItemsSummary />
      <ItemsList />

      <AddItemFloatButton />
    </div>
  )
}
  • 新建若干组件占位

2. 完成TopNav组件 ⇧

@/components/TopNav.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { SvgIcon } from "@/components/SvgIcon"
import { cn } from "cn"

interface Props {
  title?: string
  className?: string
}

export const TopNav: React.FC<Props> = ({ title = '山竹记账', className }) => {
  return (
    <div
      className={cn(
        'h-64px pl-1em text-white flex items-center gap-x-1em',
        className,
      )}>
      <SvgIcon name="menu" className="size-[2em]" />
      <h1 className="text-[2em]">{title}</h1>
    </div>
  )
}

3. 完成TimeRangePicker组件 ⇧

@/components/DateRangePicker.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { cn } from "cn"

export type DateTabRangeStr = 'thisMonth' | 'lastMonth' | 'thisYear' | 'customDate'

interface Props {
  selectedId: DateTabRangeStr
  onSelected: (selectedId: DateTabRangeStr) => void
}

type DateRange = {
  key: DateTabRangeStr
  text: string
}

const dateRanges: Readonly<DateRange[]> = [
  { key: 'thisMonth', text: '本月', },
  { key: 'lastMonth', text: '上月', },
  { key: 'thisYear', text: '今年', },
  { key: 'customDate', text: '去自定义日期', },
]

export const DateRangePicker: React.FC<Props> = ({
   selectedId = 'thisMonth',
   onSelected,
 }) => {
  const selectedClass = (tabId: string) =>
    selectedId === tabId
      ? cn(
        'relative',
        `after:(content-[""] block absolute bottom-0 left-0 w-full h-4px bg-[var(--tabs-indicator-bg)])`,
      )
      : ''
  return (
    <nav className="">
      <menu className={cn(
        'flex text-white',
        'children:(px-24px py-12px cursor-pointer)',
      )}>
        {
          dateRanges.map(item => (
            <li
              key={item.key}
              onClick={() => onSelected(item.key)}
              className={selectedClass(item.key)}>
              {item.text}
            </li>
          ))
        }
      </menu>
    </nav>
  )
}
  • 接受外部数据,使用父组件传来的方法改变数据
  • 使用伪类 after:(content-[""] block absolute bottom-0 left-0 w-full h-4px bg-[var(--tabs-indicator-bg)]) 表示选中样式

@/pages/ItemsPage.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { AddItemFloatButton } from "@/components/AddItemFloatButton"
import { DateRangePicker, DateTabRangeStr } from "@/components/DateRangePicker"
import { TopNav } from "@/components/TopNav"
import { ItemsList } from "@/modules/items/ItemsList"
import { ItemsSummary } from "@/modules/items/ItemsSummary"
import { cn } from "cn"
import { useState } from "react"

export const ItemsPage: React.FC = () => {
  const [selectedDate, setSelectedDate] = useState<DateTabRangeStr>('thisMonth')

  return (
    <div>
      <header
        className={cn(
          'bg-linear-to-b from-[var(--welcome-card-start)] to-[var(--welcome-card-end)]',
        )}>
        <TopNav />
        <DateRangePicker selectedId={selectedDate} onSelected={setSelectedDate} />
      </header>

      <ItemsSummary />
      <ItemsList />

      <AddItemFloatButton />
    </div>
  )
}
  • 组件化可以明确各个组件的输入输出,独立样式和逻辑和其他组件无关

4. 完成ItemsSummary组件 ⇧

@/modules/items/ItemsSummary.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { ItemsList } from "@/modules/items/ItemsList"
import { cn } from "cn"

interface Props {
  startDate: string
  endDate: string
}

export const ItemsSummary: React.FC<Props> = ({
  startDate,
  endDate
}) => {

  return (
    <div className="flex flex-col h-[calc(100vh-112px)] scrollbar-hide relative">
      {startDate}-{endDate}

      <ul className={cn(
        'bg-[var(--total-bg)] text-[var(--total-text)]',
        'grow-0 shrink-0 flex justify-between items-center',
        'm-1em py-0 px-1em rounded-[var(--button-radius)]',
        'children:(p-[1em] flex flex-col text-center)',
      )}>
        <li className="color-[var(--total-income)]">
          <span>收入</span>
          <span>¥ 128.00</span>
        </li>
        <li className="color-[var(--total-expenses)]">
          <span>支出</span>
          <span>¥ 99.00</span>
        </li>
        <li className="color-[var(--total-text)]">
          <span>净收入</span>
          <span>¥ 39.00</span>
        </li>
      </ul>

      <ItemsList />
    </div>
  )
}
  • 使用 'children:(p-[1em] flex flex-col text-center)' 统一写子组件样式
  • 需要在 uno.config.ts 中配置 transformers: [transformerVariantGroup(),]

参考

  • Variant group transformer
  • 允许写 hover:(bg-gray-400 font-medium) font-(light mono)
  • 被转译成 hover:bg-gray-400 hover:font-medium font-light font-mono

5. 完成ItemsList组件 ⇧

ItemsList组件基本结构样式 ⇧

@/modules/items/ItemsList.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import { Item } from "@/global"
import { cn } from "cn"

interface Props {
  items: Item[]
}

export const ItemsList: React.FC<Props> = ({ items }) => {

  return (
    <div className="item-list-wrapper max-h-[calc(100%-112px)] grow-1 shrink-1 overflow-auto scrollbar-hide">
      <ol className={cn(
        'accounts',
        '[&>li]:[--sibling-idx:calc(1*sibling-index())]',
        '[&>li>div:first-child]:bg-[oklch(70%_0.15_calc(var(--sibling-idx)*70))]',
      )}>
        {
          items
            .map(item => (
              <li
                key={item.id}
                className={cn(
                  'account-item',
                  'flex justify-between items-start',
                  'py-[12px] px-[1em] b-b-[1px] b-b-[var(--button-border-color)]',
                )}>
                <div className="sign grow-0 shrink-0 size-[48px] rounded-[50%] flex justify-center items-center my-0 mx-.5em">
                  <span>😀</span>
                </div>

                <div className="mx-.25em grow-1 shrink-1">
                  <div className="flex justify-between">
                    <span className="text-[var(--list-tag-text)]">旅行</span>
                    <span className="text-[var(--list-amount-text)]">¥1234.00</span>
                  </div>

                  <div className="text-[var(--list-time-text)] mt-.25em">
                    2027-01-01 12:00:00
                  </div>
                </div>
              </li>
            ))
        }
      </ol>

      <div className="pt-[2em] mb-[2em] text-center">向下滑动加载更多</div>
    </div>
  )
}

@/modules/items/ItemsSummary.tsx 传参

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { Item } from "@/global"
import { accountItems } from "@/mock/items"
import { ItemsList } from "@/modules/items/ItemsList"
import { cn } from "cn"
import { useState } from "react"

interface Props {
  startDate: string
  endDate: string
}

export const ItemsSummary: React.FC<Props> = ({
  startDate,
  endDate
}) => {
  const [items,] = useState<Item[]>(accountItems)

  return (
    <div className="flex flex-col h-[calc(100vh-112px)] scrollbar-hide relative">
      {startDate}-{endDate}

      <ul className={cn(
        'bg-[var(--total-bg)] text-[var(--total-text)]',
        'grow-0 shrink-0 flex justify-between items-center',
        'm-1em py-0 px-1em rounded-[var(--button-radius)]',
        'children:(p-[1em] flex flex-col text-center)',
      )}>
        <li className="color-[var(--total-income)]">
          <span>收入</span>
          <span>¥ 128.00</span>
        </li>
        <li className="color-[var(--total-expenses)]">
          <span>支出</span>
          <span>¥ 99.00</span>
        </li>
        <li className="color-[var(--total-text)]">
          <span>净收入</span>
          <span>¥ 39.00</span>
        </li>
      </ul>

      <ItemsList items={items}/>
    </div>
  )
}

mock 数据 @/mock/items.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { Item } from "@/global";

export const accountItems: Item[] = [
  {
    id: 1,
    user_id: '1',
    kind: 'incomes',
    amount: 100,
    tag_ids: [1],
    happened_at: '2026-11-01T00:00:00.000Z',
    created_at: '2026-11-01T00:00:00.000Z',
    updated_at: '2026-11-01T00:00:00.000Z',
  },
  // ...
]

使用 unocss shortcuts ⇧

uno.config.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { defineConfig, presetAttributify, presetIcons, presetTypography, transformerAttributifyJsx, transformerVariantGroup } from 'unocss'
import { presetWind4 } from '@unocss/preset-wind4'

export default defineConfig({
  presets: [
    // ...
  ],
  preflights: [
    {
      getCSS: () => `
        @property --sibling-idx {
          syntax: '<integer>'; /* 核心:限制为整数类型,迫使浏览器立即求值 */
          inherits: true;      /* 允许向下继承计算后的结果 */
          initial-value: 1;
        }
      `
    }
  ],
  shortcuts: {
    'primary-btn': 'w-100% h-48px bg-[var(--button-primary-color)] text-[var(--button-primary-text-color)] b-none rounded-8px',
  },
})

@/pages/Home.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// ...

interface Props {
  title?: string
}

export const Home: React.FC<Props> = ({ title }) => {
  useTitle(title ?? '记账首页')

  const { data: meData, /* error: meError, */ isLoading: isLoadingMe } = useSWR('/api/v1/me', async path =>
    (await request.get<ResponseData<Resource<User>>>(path))?.data?.data)
  const { data: itemsData, /* error: itemsError, */ isLoading: isLoadingItems } = useSWR(meData ? '/api/v1/items' : null, async path =>
    (await request.get<ResponseData<Resources<Item>>>(path))?.data?.data)

  if (isLoadingMe || isLoadingItems) {
    return (
      <main h-screen flex flex-col justify-center items-center>
        <InLoading className="h-100vh" message="正在加载,请稍等..." />
    </main>
  ) }

  if (itemsData?.resources?.[0]) { return <Navigate to="/items" /> }

  return (
    <main flex flex-col justify-center items-center>
      <div flex justify-center items-center>
        <img src={pig} alt="pig" w-128px h-130px mt-20vh mb-20vh block />
      </div>

      <div px-16px self-stretch>
        <button
          className={cn('primary-btn')}>
          开始记账
        </button>
      </div>

      <AddItemFloatButton />
    </main>
  )
}

复用到 @/modules/items/ItemsList.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { Item } from "@/global"
import { cn } from "cn"

interface Props {
  items: Item[]
}

export const ItemsList: React.FC<Props> = ({ items }) => {

  return (
    <div className="item-list-wrapper max-h-[calc(100%-112px)] grow-1 shrink-1 overflow-auto scrollbar-hide">
      <ol className={cn(
        'accounts',
        '[&>li]:[--sibling-idx:calc(1*sibling-index())]',
        '[&>li>div:first-child]:bg-[oklch(70%_0.15_calc(var(--sibling-idx)*70))]',
      )}>
        {
          items
            .map(item => (/* ... */))
        }
      </ol>

      <div className="px-[1em] pt-[2em] mb-[2em] text-center">
        <button
          className={cn('primary-btn')}>
          加载更多
        </button>
      </div>
    </div>
  )
}

·未完待续·

参考文章

相关文章

  • 无

  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名