【山竹记账前端-vue】10.使用 Vant UI 制作数字按键


大纲链接 §

[toc]


1. 制作数字按键 ⇧

  • 声明按钮列表数据,遍历渲染;声明按钮类型
    • 按钮支持文字或图标
  • 使用二维布局 grid 做不规则分布的按钮
  • 四列四行,其中 清空(归零)、删除(图标)、提交、0 位数字占据跨多行/列
  • 父容器布局样式 grid grid-cols-[repeat(4,1fr)] grid-rows-[repeat(4,48px)]
  • 父容器设置背景色 #ddd,留出子节点空隙 gap-1px,设置子节点背景为白色,形成类似边框线条
  • 遍历的按钮对象需要设置具体不同的 grid-area-[],具体网格样式配置可参考:css grid generator

CSS Grid Generator Your Grid Code

@/modules/items/InputPad.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
import { defineComponent } from 'vue'
import { SvgIcon } from '@/components/icons/SvgIcon.tsx'
import { NumberOutput } from '@/modules/items/NumberOutput.tsx'
import type { SvgIconName } from '@svgoStore'
import { cn } from 'cn'

type ButtonItem<T extends boolean = boolean> = {
  text: string,
  onClick: () => void,
  gridArea: string,
} & (T extends true
  ? {
      hasIcon: T,
      iconName: SvgIconName,
    }
  : {
      hasIcon?: false,
      iconName?: never,
    })

export const InputPad = defineComponent({
  name: 'InputPad',
  setup(/* _props, _ctx */) {
    const buttons: ButtonItem[] = [
      {text: '1', onClick: () => {}, gridArea: 'grid-area-[1/1/2/2]',},
      {text: '2', onClick: () => {}, gridArea: 'grid-area-[1/2/2/3]',},
      {text: '3', onClick: () => {}, gridArea: 'grid-area-[1/3/2/4]',},
      {text: '清空', onClick: () => {}, gridArea: 'grid-area-[1/4/2/5]',},
      {text: '4', onClick: () => {}, gridArea: 'grid-area-[2/1/3/2]',},
      {text: '5', onClick: () => {}, gridArea: 'grid-area-[2/2/3/3]',},
      {text: '6', onClick: () => {}, gridArea: 'grid-area-[2/3/3/4]',},
      {text: '删除', onClick: () => {}, hasIcon: true, iconName: 'backspace', gridArea: 'grid-area-[2/4/3/5]',},
      {text: '7', onClick: () => {}, gridArea: 'grid-area-[3/1/4/2]',},
      {text: '8', onClick: () => {}, gridArea: 'grid-area-[3/2/4/3]',},
      {text: '9', onClick: () => {}, gridArea: 'grid-area-[3/3/4/4]',},
      {text: '提交', onClick: () => {}, gridArea: 'grid-area-[3/4/5/5]',},
      {text: '0', onClick: () => {}, gridArea: 'grid-area-[4/1/5/3]',},
      {text: '.', onClick: () => {}, gridArea: 'grid-area-[4/3/5/4]',},
    ]
    return () => (
      <>
        <div class="">
          <span class="notes">
            <SvgIcon name="date" />
            <span>2027-01-01</span>
          </span>
          <span class="amount">
            <NumberOutput />
          </span>
        </div>

        {/* 数字键盘 */}
        <div class={cn(
          'grid grid-cols-[repeat(4,1fr)] grid-rows-[repeat(4,48px)]',
          'bg-[#ddd] gap-1px p-1px',
          'children-bg-white children-b-none'
        )}>
          {buttons.map((buttonItem) => (
            <button
              type="button"
              key={buttonItem.text}
              onClick={buttonItem.onClick}
              class={cn(
                'flex justify-center items-center',
                buttonItem.gridArea,
              )}>
              {buttonItem.hasIcon ? <SvgIcon name={buttonItem.iconName} /> : buttonItem.text}
            </button>
          ))}
        </div>
      </>
    )
  },
})

2. 添加日期、金额结构样式 ⇧

@/modules/items/NumberOutput.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { defineComponent /* type PropType */ } from 'vue'

export const NumberOutput = defineComponent({
  name: 'NumberOutput',
  setup(/* _props, _ctx */) {
    return () => (
      <span class="text-24px font-mono color-[var(--amount-text-color)]">
        199.12
      </span>
    )
  },
})
  • 保证金额输出文字字体为等宽字体,添加样式 font-mono

@/modules/items/InputPad.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
import { defineComponent } from 'vue'
import { SvgIcon } from '@/components/icons/SvgIcon.tsx'
import { NumberOutput } from '@/modules/items/NumberOutput.tsx'
import type { SvgIconName } from '@svgoStore'
import { cn } from 'cn'

type ButtonItem<T extends boolean = boolean> = {
  text: string,
  onClick: () => void,
  gridArea: string,
  extraClass?: string,
} & (T extends true
  ? {
      hasIcon: T,
      iconName: SvgIconName,
    }
  : {
      hasIcon?: false,
      iconName?: never,
    })

export const InputPad = defineComponent({
  name: 'InputPad',
  setup(/* _props, _ctx */) {
    const buttons: ButtonItem[] = [
      {text: '1', onClick: () => {}, gridArea: 'grid-area-[1/1/2/2]',},
      {text: '2', onClick: () => {}, gridArea: 'grid-area-[1/2/2/3]',},
      {text: '3', onClick: () => {}, gridArea: 'grid-area-[1/3/2/4]',},
      {text: '清空', onClick: () => {}, gridArea: 'grid-area-[1/4/2/5]',},
      {text: '4', onClick: () => {}, gridArea: 'grid-area-[2/1/3/2]',},
      {text: '5', onClick: () => {}, gridArea: 'grid-area-[2/2/3/3]',},
      {text: '6', onClick: () => {}, gridArea: 'grid-area-[2/3/3/4]',},
      {text: '删除', onClick: () => {}, hasIcon: true, iconName: 'backspace',
        gridArea: 'grid-area-[2/4/3/5]', extraClass: cn('children:w-1.5em children:h-1.5em'),},
      {text: '7', onClick: () => {}, gridArea: 'grid-area-[3/1/4/2]',},
      {text: '8', onClick: () => {}, gridArea: 'grid-area-[3/2/4/3]',},
      {text: '9', onClick: () => {}, gridArea: 'grid-area-[3/3/4/4]',},
      {text: '提交', onClick: () => {}, gridArea: 'grid-area-[3/4/5/5]',},
      {text: '0', onClick: () => {}, gridArea: 'grid-area-[4/1/5/3]',},
      {text: '.', onClick: () => {}, gridArea: 'grid-area-[4/3/5/4]',},
    ]
    const numberSizeClass = (text: string) => (/[0-9.]/g).test(text)
      ? cn('text-18px')
      : ''
    return () => (
      <>
        {/* 日期、金额输入 */}
        <div class="flex justify-between items-center py-1em b-t-[var(--button-border-color)] b-t-1">
          <span class="flex justify-start items-center">
            <SvgIcon name="date" class="size-2em mx-1 fill-[var(--button-bg)]" />
            <span class="text-14px font-mono animation-rainbow color-[var(--vp-c-brand-next)]">2027-01-01</span>
          </span>

          <NumberOutput class="mr-4" />
        </div>
        {/* 数字键盘 */}
        <div class={cn(
          'grid grid-cols-[repeat(4,1fr)] grid-rows-[repeat(4,56px)]',
          'bg-[var(--button-border-color)] gap-1px p-y-1px',
          'children-bg-white children-b-none'
        )}>
          {buttons.map((buttonItem) => (
            <button
              type="button"
              onClick={buttonItem.onClick}
              class={cn(
                'flex justify-center items-center font-bold',
                buttonItem.gridArea,
                buttonItem?.extraClass,
                numberSizeClass(buttonItem.text),
              )}>
              {buttonItem.hasIcon ? <SvgIcon name={buttonItem.iconName} /> : buttonItem.text}
            </button>
          ))}
        </div>
      </>
    )
  },
})

拆分 @/modules/items/Numpad.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
import { defineComponent /* type PropType */ } from 'vue'
import { cn } from 'cn'
import { SvgIcon } from '@/components/icons/SvgIcon.tsx'
import type { SvgIconName } from '@svgoStore'

type ButtonItem<T extends boolean = boolean> = {
  text: string,
  onClick: () => void,
  gridArea: string,
  extraClass?: string,
} & (T extends true
  ? {
    hasIcon: T,
    iconName: SvgIconName,
  }
  : {
    hasIcon?: false,
    iconName?: never,
  })

export const Numpad = defineComponent({
  // eslint-disable-next-line vue/multi-word-component-names
  name: 'Numpad',
  setup(/* _props, _ctx */) {
    const buttons: ButtonItem[] = [
      {text: '1', onClick: () => {}, gridArea: 'grid-area-[1/1/2/2]',},
      {text: '2', onClick: () => {}, gridArea: 'grid-area-[1/2/2/3]',},
      {text: '3', onClick: () => {}, gridArea: 'grid-area-[1/3/2/4]',},
      {text: '清空', onClick: () => {}, gridArea: 'grid-area-[1/4/2/5]',},
      {text: '4', onClick: () => {}, gridArea: 'grid-area-[2/1/3/2]',},
      {text: '5', onClick: () => {}, gridArea: 'grid-area-[2/2/3/3]',},
      {text: '6', onClick: () => {}, gridArea: 'grid-area-[2/3/3/4]',},
      {text: '删除', onClick: () => {}, hasIcon: true, iconName: 'backspace',
        gridArea: 'grid-area-[2/4/3/5]', extraClass: cn('children:w-1.5em children:h-1.5em'),},
      {text: '7', onClick: () => {}, gridArea: 'grid-area-[3/1/4/2]',},
      {text: '8', onClick: () => {}, gridArea: 'grid-area-[3/2/4/3]',},
      {text: '9', onClick: () => {}, gridArea: 'grid-area-[3/3/4/4]',},
      {text: '提交', onClick: () => {}, gridArea: 'grid-area-[3/4/5/5]',},
      {text: '0', onClick: () => {}, gridArea: 'grid-area-[4/1/5/3]',},
      {text: '.', onClick: () => {}, gridArea: 'grid-area-[4/3/5/4]',},
    ]
    const numberSizeClass = (text: string) => (/[0-9.]/g).test(text)
      ? cn('text-18px')
      : ''
    return () => (
      <div class={cn(
        'grid grid-cols-[repeat(4,1fr)] grid-rows-[repeat(4,56px)]',
        'bg-[var(--button-border-color)] gap-1px p-y-1px',
        'children-bg-white children-b-none'
      )}>
        {buttons.map((buttonItem) => (
          <button
            type="button"
            onClick={buttonItem.onClick}
            class={cn(
              'flex justify-center items-center font-bold',
              buttonItem.gridArea,
              buttonItem?.extraClass,
              numberSizeClass(buttonItem.text),
            )}>
            {buttonItem.hasIcon ? <SvgIcon name={buttonItem.iconName} /> : buttonItem.text}
          </button>
        ))}
      </div>
    )
  },
})

3. 封装 time.tsx ⇧

@src/shared/time.ts

 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
export const time = (date?: Date) => {
  if(!date) {date = new Date()}
  // 目前支持的格式有 'YYYY-MM-DD HH:mm:ss.sss'
  const api = {
    format: (pattern: string = 'YYYY-MM-DD') => {
      // YYYY
      const year = date.getFullYear()
      // MM
      const month = date.getMonth() + 1
      // DD
      const day = date.getDate()
      // HH
      const hour = date.getHours()
      // mm
      const minute = date.getMinutes()
      // ss
      const second = date.getSeconds()
      // sss
      const millisecond = date.getMilliseconds()

      return pattern
        .replace(/YYYY/g, year.toString())
        // 使用 padStart 长度不足则补零
        .replace(/MM/g, month.toString().padStart(2, '0'))
        .replace(/DD/g, day.toString().padStart(2, '0'))
        .replace(/HH/g, hour.toString().padStart(2, '0'))
        .replace(/mm/g, minute.toString().padStart(2, '0'))
        .replace(/ss/g, second.toString().padStart(2, '0'))
        .replace(/sss/g, millisecond.toString().padStart(3, '0'))
    }
  }

  return {
    api,
  }
}

使用 @/modules/items/InputPad.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
import { defineComponent } from 'vue'
import { SvgIcon } from '@/components/icons/SvgIcon.tsx'
import { NumberOutput } from '@/modules/items/NumberOutput.tsx'
import { Numpad } from '@/modules/items/Numpad.tsx'
import { time } from '@/shared/time.ts'

export const InputPad = defineComponent({
  name: 'InputPad',
  setup(/* _props, _ctx */) {
    const now = new Date()
    const {api: dateApi} = time(now)

    return () => (
      <>
        {/* 日期、金额输入 */}
        <div class="flex justify-between items-center py-1em b-t-[var(--button-border-color)] b-t-1">
          <span class="flex justify-start items-center">
            <SvgIcon name="date" class="size-2em mx-1 fill-[var(--button-bg)]" />
            <span class="animation-rainbow text-14px font-mono color-[var(--vp-c-brand-next)]">
              <input type="date" value={dateApi.format()} />
            </span>
          </span>

          <NumberOutput class="mr-4" />
        </div>

        {/* 数字键盘 */}
        <Numpad />
      </>
    )
  },
})

4. 使用 Vant UI 制作日期组件 ⇧

安装、配置、引入样式 ⇧

安装 Vant 4,按需引入组件样式,并安装 unplugin-vue-components

1
2
pnpm add vant@4.10.2
pnpm add -D unplugin-vue-components@32.1.0 @vant/auto-import-resolver@1.3.0 unplugin-auto-import@21.1.0

配置 vite.config.ts,并配置自动组件导入插件(类型)

 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
import { fileURLToPath, pathToFileURL, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import unoCSS from 'unocss/vite'
import jsxScoped from '@10coding/vite-plugin-jsx-scoped'
import { svgoStore } from './src/plugins/svgoStore.ts'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'

// https://vite.dev/config/
export default defineConfig({
  // base: '/mangosteen-fe-0-publish/',
  plugins: [
    unoCSS(),
    jsxScoped({ warnMultiScopedImport: true }),
    vue(),
    vueJsx({
      transformOn: true,
      mergeProps: true,
    }),
    AutoImport({
      resolvers: [
        VantResolver(),
      ],
    }),
    Components({
      resolvers: [
        VantResolver(),
      ],
    }),
    vueDevTools(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  css: {
    preprocessorOptions: {
      scss: {
        // 自定义 importers 解析 @/ 别名
        importers: [
          {
            findFileUrl(url) {
              if (!url.startsWith('@/')) return null;
              return new URL(url.substring(2), pathToFileURL('./src'));
            }
          },
        ],
      },
    },
  },
})

引入样式 @/main.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from '@/router'
import { App } from '@/App.tsx'
import '@/assets/styles/main.scss'
import '@svgoStore'
// vendors
import 'uno.css'
// import 'virtual:uno.css'
import 'vant/lib/index.css'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')

弹出层组件配合实现日期组件 ⇧

替换掉原生日期输入框,@/modules/items/InputPad.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
import { computed, defineComponent, ref } from 'vue'
import { SvgIcon } from '@/components/icons/SvgIcon.tsx'
import { NumberOutput } from '@/modules/items/NumberOutput.tsx'
import { Numpad } from '@/modules/items/Numpad.tsx'
import { time } from '@/shared/time.ts'
import { DatePicker, Popup } from 'vant'

export const InputPad = defineComponent({
  name: 'InputPad',
  setup(/* _props, _ctx */) {
    const datePickerVisibleRef = ref(false)
    const lastDateRef = ref(['2026', '11', '01'])
    const currentDateRef = ref(['2026', '11', '01'])
    const minDate = new Date(2025, 1, 1)
    const maxDate = new Date(2028, 0, 1)
    const formattedDateStr = computed(() =>
      time(new Date(currentDateRef.value.join('-'))).api.format(),
    )
    const toggleDatePicker = () => {
      datePickerVisibleRef.value = !datePickerVisibleRef.value
    }
    const confirmDate = ({ selectedValues }: {selectedValues: string[]}) => {
      lastDateRef.value = selectedValues
      currentDateRef.value = selectedValues
      datePickerVisibleRef.value = false
    }
    const cancelDate = () => {
      currentDateRef.value = lastDateRef.value
      datePickerVisibleRef.value = false
    }

    return () => (
      <>
        {/* 日期、金额输入 */}
        <div class="flex justify-between items-center py-1em b-t-[var(--button-border-color)] b-t-1">
          <span class="flex justify-start items-center">
            <SvgIcon name="date" class="size-2em mx-1 fill-[var(--button-bg)]" />
            <span class="animation-rainbow text-14px font-mono color-[var(--vp-c-brand-next)]">
              <span onClick={toggleDatePicker}>{formattedDateStr.value}</span>
              <Popup
                position="bottom"
                v-model:show={datePickerVisibleRef.value}
                onClose={cancelDate}>
                <DatePicker
                  title="选择年月日"
                  v-model={currentDateRef.value}
                  minDate={minDate}
                  maxDate={maxDate}
                  onConfirm={confirmDate}
                  onCancel={cancelDate}/>
              </Popup>
            </span>
          </span>

          <NumberOutput class="mr-4" />
        </div>

        {/* 数字键盘 */}
        <Numpad />
      </>
    )
  },
})
  • datePickerVisibleRef 控制 Popup 显示状态
  • lastDateRef 为 DatePicker 暂存上一次选择数据,用来取消时,恢复上一次数据
  • currentDateRef 为 DatePicker 当前选择数据
  • DatePicker 设置范围 minDate、maxDate,格式为 new Date(2028, 0, 1)
  • formattedDateStr 配合自己封装的 time 函数,计算格式化后的字符串
  • toggleDatePicker 切换开/关 Popup
  • confirmDate 确认选择日期,同时更新 lastDateRef 和 currentDateRef
  • cancelDate 取消选择日期,由于切换日期时组件立即就会同时更新 currentDateRef 数据,取消时需要恢复上一次选择的数据 lastDateRef

5. 砍需求啦! ⇧


6. 数字输入检查 ⇧


·未完待续·

参考文章

相关文章


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