【山竹记账前端-vue】15.【山竹记账前端】封装弹出菜单,完成登录页


大纲链接 §

[toc]


1. v-model 的正确用法 ⇧

  • 双向绑定数据
    • 父组件中在子组件上 v-model={} 即绑定默认的值 v-model:modelValue={}
    • 相当于写了 modelValue={} onUpdate:modelValue={() => {}} 的语法糖
      • 写了 onUpdate:modelValue 子组件内必须写 emits: ['update:modelValue',]
    • 在 input 上使用 v-model={} onInput={() => {}}
    • 绑自定义数据 v-model:customValue={} 注意只用小驼峰命名绑定值
    • 不可用下划线等其他形式,特别的下划线用来指定原来 SFC 中修饰符
    • 子组件 defineComponent 选项中写
    • props: { modelValue: {}, customValue: {} },
    • emits: ['update:modelValue', 'update:customValue']

参考


ItemList.tsx 关闭日期选择框,再次打开 ⇧

@/components/tab/SwitchTabs.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
53
54
55
56
57
58
59
60
61
62
63
64
65
import { defineComponent, type PropType, } from 'vue'
import { ItemTab } from '@/components/tab/ItemTab.tsx'
import { cn } from 'cn'
import { genDefaultSlotTabs } from '@/views/genDefaultSlotTabs.ts'

export const SwitchTabs = defineComponent({
  name: 'SwitchTabs',
  props: {
    selected: {
      type: String as PropType<string>,
    },
  },
  emits: [
    // 由点击事件触发
    'update:selected',
  ],
  setup(_props, _ctx) {
    const selectedClass = (tabName: string) =>
      _props.selected === tabName
        ? cn(
            'relative',
            `after:(content-[""] block absolute bottom-0 left-0 w-full h-4px bg-[var(--tabs-indicator-bg)])`,
          )
        : ''
    
    const onSwitchTab = (tabName: string) => {
      _ctx.emit('update:selected', tabName)
    }

    return () => {
      const defaultSlotTabs = genDefaultSlotTabs(_ctx.slots?.default?.() ?? [])
      const ifAllItemTab = defaultSlotTabs
        .every((slot) => slot.type === ItemTab && !!slot.props?.tabName)
      if (!ifAllItemTab) {
        throw new Error(`SwitchTabs: 子组件必须是 ItemTab,且必须有 tabName 属性`)
      }

      return (
        <div class="grow-1 shrink-1 overflow-auto scrollbar-hide relative">
          {/* 切换标签 */}
          <nav class="sticky left-0 top-0 z-1 bg-[var(--navbar-bg-end)]">
            <menu class="flex justify-start items-center text-color-[var(--navbar-text-color)]">
              {defaultSlotTabs.map((slot, index) => (
                <li
                  key={index + slot?.props?.tabName}
                  class={cn(
                    'where:(text-center grow-1 shrink-0 px-1em py-12px)',
                    selectedClass(slot?.props?.tabName),
                  )}
                  onClick={() => onSwitchTab(slot?.props?.tabName)}>
                  {slot?.props?.tabName}
                </li>
              ))}
            </menu>
          </nav>

          {/* 标签内容-插槽-数组 */}
          <section class="max-h-[calc(100vh-136px)] overflow-auto scrollbar-hide">
            {defaultSlotTabs.find((slot) => slot.props?.tabName === _props.selected)}
          </section>
        </div>
      )
    }
  },
})
  • 必须添加
    • emits: ['update:selected',]
    • Props: { selected: {}, }

@/views/ItemList.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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// import ...

export const ItemList = defineComponent({
  name: 'ItemList',
  setup(/* _props, _ctx */) {
    const selectedDateRef = ref('本月')
    const currentDate = new DateTime()
    const customDate = reactive<{ start: string; end: string }>({
      start: currentDate.getOriginDate()?.format(),
      end: currentDate.getOriginDate()?.format()
    })

    const dateList = [
      {start: currentDate.firstDayOfMonth().format(), end: currentDate.lastDayOfMonth().format(),},
      {
        start: currentDate.subtract(1, 'month').firstDayOfMonth().format(),
        end: currentDate.subtract(1, 'month').lastDayOfMonth().format(),
      },
      {start: currentDate.firstDayOfYear().format(), end: currentDate.lastDayOfYear().format(),},
    ] as const

    const customDateRangeOverlayVisibleRef = ref(false)
    const onSubmitCustomDateRange = (e: SubmitEvent) => {
      e.preventDefault()
      customDateRangeOverlayVisibleRef.value = false
    }
    const onCancelCustomDateRange = () => {
      customDateRangeOverlayVisibleRef.value = false
    }
    const openCustomDateRange = () => {
      customDateRangeOverlayVisibleRef.value = selectedDateRef.value === '自定义日期范围'
    }

    return () => (
      <MainLayout>
        {{
          title: () => '山竹记账',
          icon: () => <SvgIcon name="menu" class="w-2em h-2em" />,
          default: () => (
            <div class="h-full flex flex-col">
              <SwitchTabs
                selected={selectedDateRef.value}
                v-model:selected={selectedDateRef.value}
                onUpdate:selected={openCustomDateRange}
                class="[&>nav>menu>li]:(text-start grow-0)">
                <ItemTab tabName="本月" tabId="thisMonth">
                  <ItemSummary
                    startDate={dateList[0].start}
                    endDate={dateList[0].end} />
                </ItemTab>
                <ItemTab tabName="上月" tabId="lastMonth">
                  <ItemSummary
                    startDate={dateList[1].start}
                    endDate={dateList[1].end} />
                </ItemTab>
                <ItemTab tabName="今年" tabId="thisYear">
                  <ItemSummary
                    startDate={dateList[2].start}
                    endDate={dateList[2].end} />
                </ItemTab>
                <ItemTab tabName="自定义日期范围" tabId="customDateRange">
                  <ItemSummary
                    startDate={customDate.start}
                    endDate={customDate.end} />
                </ItemTab>
              </SwitchTabs>

              {/* 自定义日期范围弹出层 */}
              <Overlay
                show={customDateRangeOverlayVisibleRef.value}
                onClick={onCancelCustomDateRange}
                class="flex items-center justify-center bg-[var(--overlay-mask-bg)]" >
                <div
                  onClick={(e: MouseEvent) => e.stopPropagation()}
                  class="bg-[var(--overlay-bg)] w-80vw h-35vh">
                  <header class="bg-[var(--main)] text-[var(--dialog-header-text)] py-12px px-1em">
                    请选择时间
                  </header>
                  <main>
                    <FormContainer onSubmit={onSubmitCustomDateRange}>
                      <FormItem type="date" label="开始时间" v-model={customDate.start} />
                      <FormItem type="date" label="结束时间" v-model={customDate.end} />
                      <footer class="flex justify-between mt-1em children:(w-[calc(50%-.25em)])">
                        <FormItem type="button" onClick={onCancelCustomDateRange} buttonConfig={{level: 'default', text: '取 消' }}/>
                        <FormItem type="submit-button" buttonConfig={{level: 'primary', text: '确 认' }}/>
                      </footer>
                    </FormContainer>
                  </main>
                </div>
              </Overlay>

              <FloatButton class="fixed bottom-1em right-1em" iconName="add" />
            </div>
          ),
        }}
      </MainLayout>
    )
  },
})

2. 封装 OverlayIcon 组件 ⇧

移动 FloatOverlay 到 icon 插槽中,使用图标和浮动层组件逻辑内聚 ⇧

@/views/StartAccountsView.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
// import ...

export const StartAccountsView = defineComponent({
  name: 'StartAccountsView',
  setup(/*props, ctx*/) {
    const { overlayVisible } = storeToRefs(useOverlayStore())
    const { toggleOverlay } = useOverlayStore()
    const onClickMenu = () => {
      toggleOverlay()
    }
    const router = useRouter()
    const routerToCreateItemView = async () => {
      await router.push('/items/create')
    }
    return () => (
      <MainLayout>
        {{
          title: () => '山竹记账',
          icon: () => (
            <>
              <SvgIcon name="menu" class="w-2em h-2em" onClick={onClickMenu} />
              {overlayVisible.value && <FloatOverlay direct-scoped />}
            </>
          ),
          default: () => (
            <>
              <PlaceCenter class="p-y-10em">
                <SvgIcon class="w-8em h-8em" name="pig" />
              </PlaceCenter>

              <div class={cn('p-x-1em')}>
                <BarButton onClick={routerToCreateItemView}>开始记账</BarButton>
              </div>

              <FloatButton
                onClick={routerToCreateItemView}
                iconName="add"
                class="fixed bottom-1em right-1em"
              />
            </>
          ),
        }}
      </MainLayout>
    )
  },
})

封装 OverlayIcon 组件 ⇧

@/components/overlay/OverlayIcon.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { defineComponent /*,  type PropType */ } from 'vue'
import { storeToRefs } from 'pinia'
import { SvgIcon } from '@/components/icons/SvgIcon.tsx'
import { FloatOverlay } from '@/components/overlay/FloatOverlay.tsx'
import { useOverlayStore } from '@/stores/useOverlayStore.ts'

export const OverlayIcon = defineComponent({
  name: 'OverlayIcon',
  setup(/* _props, _ctx */) {
    const { overlayVisible } = storeToRefs(useOverlayStore())
    const { toggleOverlay } = useOverlayStore()
    const onClickMenu = () => {
      toggleOverlay()
    }
    return () => (
      <>
        <SvgIcon name="menu" class="w-2em h-2em" onClick={onClickMenu} />
        {overlayVisible.value && <FloatOverlay />}
      </>
    )
  },
})

@/views/StartAccountsView.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
import { defineComponent } from 'vue'
import { cn } from 'cn'
import { useRouter } from 'vue-router'
import { BarButton } from '@/components/BarButton.tsx'
import { FloatButton } from '@/components/FloatButton.tsx'
import { PlaceCenter } from '@/components/PlaceCenter.tsx'
import { SvgIcon } from '@/components/icons/SvgIcon.tsx'
import { MainLayout } from '@/components/layout/MainLayout.tsx'
import { OverlayIcon } from '@/components/overlay/OverlayIcon.tsx'

export const StartAccountsView = defineComponent({
  name: 'StartAccountsView',
  setup(/*props, ctx*/) {
    const router = useRouter()
    const routerToCreateItemView = async () => {
      await router.push('/items/create')
    }
    return () => (
      <MainLayout>
        {{
          title: () => '山竹记账',
          icon: () => <OverlayIcon/>,
          default: () => (
            <>
              <PlaceCenter class="p-y-10em">
                <SvgIcon class="w-8em h-8em" name="pig" />
              </PlaceCenter>

              <div class={cn('p-x-1em')}>
                <BarButton onClick={routerToCreateItemView}>开始记账</BarButton>
              </div>

              <FloatButton
                onClick={routerToCreateItemView}
                iconName="add"
                class="fixed bottom-1em right-1em"
              />
            </>
          ),
        }}
      </MainLayout>
    )
  },
})

实现 ItemList 页面组件集成 OverlayIcon ⇧

@/views/ItemList.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
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
// import ...
import { OverlayIcon } from '@/components/overlay/OverlayIcon.tsx'

export const ItemList = defineComponent({
  name: 'ItemList',
  setup(/* _props, _ctx */) {
    const selectedDateRef = ref('本月')
    const currentDate = new DateTime()
    const customDate = reactive<{ start: string; end: string }>({
      start: currentDate.getOriginDate()?.format(),
      end: currentDate.getOriginDate()?.format()
    })

    const dateList = [
      {start: currentDate.firstDayOfMonth().format(), end: currentDate.lastDayOfMonth().format(),},
      {
        start: currentDate.subtract(1, 'month').firstDayOfMonth().format(),
        end: currentDate.subtract(1, 'month').lastDayOfMonth().format(),
      },
      {start: currentDate.firstDayOfYear().format(), end: currentDate.lastDayOfYear().format(),},
    ] as const

    const customDateRangeOverlayVisibleRef = ref(false)
    const onSubmitCustomDateRange = (e: SubmitEvent) => {
      e.preventDefault()
      customDateRangeOverlayVisibleRef.value = false
    }
    const onCancelCustomDateRange = () => {
      customDateRangeOverlayVisibleRef.value = false
    }
    const openCustomDateRange = () => {
      customDateRangeOverlayVisibleRef.value = selectedDateRef.value === '自定义日期范围'
    }

    return () => (
      <MainLayout>
        {{
          title: () => '山竹记账',
          icon: () => <OverlayIcon/>,
          default: () => (
            <div class="h-full flex flex-col">
              <SwitchTabs
                selected={selectedDateRef.value}
                v-model:selected={selectedDateRef.value}
                onUpdate:selected={openCustomDateRange}
                class="[&>nav>menu>li]:(text-start grow-0)">
                <ItemTab tabName="本月" tabId="thisMonth">
                  <ItemSummary
                    startDate={dateList[0].start}
                    endDate={dateList[0].end} />
                </ItemTab>
                <ItemTab tabName="上月" tabId="lastMonth">
                  <ItemSummary
                    startDate={dateList[1].start}
                    endDate={dateList[1].end} />
                </ItemTab>
                <ItemTab tabName="今年" tabId="thisYear">
                  <ItemSummary
                    startDate={dateList[2].start}
                    endDate={dateList[2].end} />
                </ItemTab>
                <ItemTab tabName="自定义日期范围" tabId="customDateRange">
                  <ItemSummary
                    startDate={customDate.start}
                    endDate={customDate.end} />
                </ItemTab>
              </SwitchTabs>

              {/* 自定义日期范围弹出层 */}
              <Overlay
                show={customDateRangeOverlayVisibleRef.value}
                onClick={onCancelCustomDateRange}
                class="flex items-center justify-center bg-[var(--overlay-mask-bg)]" >
                <div
                  onClick={(e: MouseEvent) => e.stopPropagation()}
                  class="bg-[var(--overlay-bg)] w-80vw h-35vh">
                  <header class="bg-[var(--main)] text-[var(--dialog-header-text)] py-12px px-1em">
                    请选择时间
                  </header>
                  <main>
                    <FormContainer onSubmit={onSubmitCustomDateRange}>
                      <FormItem type="date" label="开始时间" v-model={customDate.start} />
                      <FormItem type="date" label="结束时间" v-model={customDate.end} />
                      <footer class="flex justify-between mt-1em children:(w-[calc(50%-.25em)])">
                        <FormItem type="button" onClick={onCancelCustomDateRange} buttonConfig={{level: 'default', text: '取 消' }}/>
                        <FormItem type="submit-button" buttonConfig={{level: 'primary', text: '确 认' }}/>
                      </footer>
                    </FormContainer>
                  </main>
                </div>
              </Overlay>

              <FloatButton class="fixed bottom-1em right-1em" iconName="add" />
            </div>
          ),
        }}
      </MainLayout>
    )
  },
})

3. 解决 z-index 的 bug ⇧

@/assets/styles/vars.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* color palette from <https://github.com/vuejs/theme> */
:root {
  // ...
  --navbar-bg-start: var(--welcome-card-start);
  --navbar-bg-end: var(--welcome-card-end);
  --navbar-text-color: var(--button-text-color);
  --navbar-min-height: 88px;
  --z-index-button-nav: 10;
  --z-index-navbar: 30;
  // 划出浮层 遮罩层
  --z-index-float-overlay: 20;
}

@/components/layout/MainLayout.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 { defineComponent /* type PropType */ } from 'vue'
import { NavBar } from '@/components/NavBar.tsx'
import { cn } from 'cn'

export const MainLayout = defineComponent({
  name: 'MainLayout',
  setup(_props, _ctx) {
    return () => (
      <div class={cn('w-full h-full relative')}>
        <NavBar
          direct-scoped
          class={cn(
            'sticky top-0 z-[var(--z-index-navbar)]',
            'bg-gradient-to-b from-[var(--navbar-bg-start)] to-[var(--navbar-bg-end)]',
          )}>
          {{
            default: () => _ctx.slots?.title?.(),
            icon: () => _ctx.slots?.icon?.(),
          }}
        </NavBar>
        <main
          class="h-[calc(100vh-var(--navbar-min-height))]">
          {_ctx.slots?.default?.()}
        </main>
      </div>
    )
  },
})

4. 重构 FormItem.tsx 和 ItemList.tsx,可见性改为 getVisibleValue,并拆分 Overlay ⇧

@src/components/form/FormItem.tsx


5. $3 ⇧


6. $3 ⇧


·未完待续·

参考文章

相关文章

  • 无

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