【山竹记账前端-vue】16.制作 echarts 图表


大纲链接 §

[toc]


1. 创建统计图表页面,封装 DateTabsLayout ⇧

由于记账页和统计图表页的 tab 分类都为日期,并且切换和将日期传参到插槽组件中的逻辑相同,即可以统一封装为 DateTabsLayout.tsx

  • 仅仅是插槽 ItemSummary 和 ChartsSummary 展示的内容不同,入参都为开始日期和结束日期

@/components/layout/DateTabsLayout.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
import {
  type ComputedRef,
  type PropType,
  defineComponent,
  reactive,
  computed,
  ref,
} from 'vue'
import { MainLayout } from '@/components/layout/MainLayout.tsx'
import { SwitchTabs } from '@/components/tab/SwitchTabs.tsx'
import { ItemTab } from '@/components/tab/ItemTab.tsx'
import { CustomDateRangeOverlay } from '@/modules/items/CustomDateRangeOverlay.tsx'
import { DateTime } from '@/shared/dateTime.ts'

interface DateTab {
  tabName: string
  tabId: 'thisMonth' | 'lastMonth' | 'thisYear' | 'customDateRange'
  start: string
  end: string
}

export const DateTabsLayout = defineComponent({
  name: 'DateTabsLayout',
  props: {
    title: { type: String as PropType<string>, default: '', },
  },
  setup( _props, _ctx ) {
    const currentDate = new DateTime()
    const customDate = reactive({
      start: currentDate.getOriginDate()?.format() || '',
      end: currentDate.getOriginDate()?.format() || '',
    })
    const itemTabList: ComputedRef<DateTab[]> = computed(() => [
      {
        tabName: '本月',
        tabId: 'thisMonth',
        start: currentDate.firstDayOfMonth().format(),
        end: currentDate.lastDayOfMonth().format(),
      },
      {
        tabName: '上月',
        tabId: 'lastMonth',
        start: currentDate.subtract(1, 'month').firstDayOfMonth().format(),
        end: currentDate.subtract(1, 'month').lastDayOfMonth().format(),
      },
      {
        tabName: '今年',
        tabId: 'thisYear',
        start: currentDate.firstDayOfYear().format(),
        end: currentDate.lastDayOfYear().format(),
      },
      {
        tabName: '自定义日期范围',
        tabId: 'customDateRange',
        start: customDate.start,
        end: customDate.end,
      },
    ])
    const selectedTabIdRef = ref<DateTab['tabId']>('thisMonth')
    const overlayVisibleRef = ref(false)
    const openCustomDateRange = () => {
      overlayVisibleRef.value = selectedTabIdRef.value === 'customDateRange'
    }
    return () => (
      <MainLayout>
        {{
          title: () => `${_props.title}`,
          icon: () => _ctx.slots?.icon?.(),
          default: () => (
            <div class="h-full flex flex-col">
              <SwitchTabs
                v-model:selectedTabId={selectedTabIdRef.value}
                onUpdate:selectedTabId={openCustomDateRange}
                class="[&>nav>menu>li]:(text-start grow-0)">
                {
                  itemTabList.value
                    .map(tabItem => (
                      <ItemTab key={tabItem.tabId} tabName={tabItem.tabName} tabId={tabItem.tabId}>
                        {_ctx.slots?.default?.({ start: tabItem.start, end: tabItem.end })}
                      </ItemTab>
                    ))
                }
              </SwitchTabs>

              {/* 自定义日期范围弹出层 */}
              <CustomDateRangeOverlay
                v-model:customStartDate={customDate.start}
                v-model:customEndDate={customDate.end}
                v-model:visibleValue={overlayVisibleRef.value} />
            </div>
          ),
        }}
      </MainLayout>
    )
  },
})
  • 保证渲染动态节点的数据必须是响应式的,比如 ref、reactive、computed 即代码中遍历渲染的 itemTabList 数据
  • v-model 绑定的数据也必须是响应式的,比如 ref、reactive的整体或其中某个属性、可读写的 computed 值

@/modules/items/CustomDateRangeOverlay.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
import { computed, defineComponent, type PropType, ref } from 'vue'
import { Overlay } from 'vant'
import { FormContainer } from '@/components/form/FormContainer.tsx'
import { FormItem } from '@/components/form/FormItem.tsx'
import cssVars from '@/assets/styles/vars.module.scss'

export const CustomDateRangeOverlay = defineComponent({
  name: 'CustomDateRangeOverlay',
  props: {
    visibleValue: { type: Boolean as PropType<boolean>, default: false, },
    customStartDate: { type: String as PropType<string>, default: '', },
    customEndDate: { type: String as PropType<string>, default: '', },
  },
  emits: ['update:visibleValue', 'update:customStartDate', 'update:customEndDate',],
  setup( _props, _ctx ) {
    const {zIndexFloatOverlay,} = cssVars
    const getVisibleValue = computed({
      get() {return _props.visibleValue},
      set(value) {_ctx.emit('update:visibleValue', value)}
    })
    const lastStarDate = ref(_props.customStartDate)
    const lastEndDate = ref(_props.customEndDate)
    const getCustomStartDate = computed({
      get() {return _props.customStartDate},
      set(value) {_ctx.emit('update:customStartDate', value)}
    })
    const getCustomEndDate = computed({
      get() {return _props.customEndDate},
      set(value) {_ctx.emit('update:customEndDate', value)}
    })
    const onSubmitCustomDateRange = (e: SubmitEvent) => {
        e.preventDefault()
        _ctx.emit('update:visibleValue', false)
        _ctx.emit('update:customStartDate', getCustomStartDate.value)
        _ctx.emit('update:customEndDate', getCustomEndDate.value)
        lastStarDate.value = getCustomStartDate.value
        lastEndDate.value = getCustomEndDate.value
      }
    const onCancelCustomDateRange = () => {
      _ctx.emit('update:visibleValue', false)
      getCustomStartDate.value = lastStarDate.value
      getCustomEndDate.value = lastEndDate.value
    }
    const startDateVisibleRef = ref(false)
    const endDateVisibleRef = ref(false)
    return () => (
      <Overlay
        z-index={zIndexFloatOverlay}
        show={getVisibleValue.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-[clamp(340px,35vh,400px)]">
          <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={getCustomStartDate.value}
                v-model:visibleValue={startDateVisibleRef.value} />
              <FormItem
                type="date"
                label="结束时间"
                v-model={getCustomEndDate.value}
                v-model:visibleValue={endDateVisibleRef.value} />
              <footer class="flex justify-between mt-1em children:(w-[calc(50%-.25em)])">
                <FormItem
                  type="button"
                  onClick={onCancelCustomDateRange}
                  buttonConfig={{level: 'default', text: '取 消' }}/>
                <FormItem
                  type="submit"
                  buttonConfig={{level: 'primary', text: '确 认' }}/>
              </footer>
            </FormContainer>
          </main>
        </div>
      </Overlay>
    )
  },
})
  • 取消时恢复上一次记录的值,就必须另外声明响应式变量 lastStarDate 和 lastEndDate
  • 限制弹出框高度大小适应范围 h-[clamp(340px,35vh,400px)] 的样式
  • 暂时缺少日期验证逻辑:结束时间必须大于开始时间

@/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
66
67
68
69
70
71
72
73
74
75
76
77
import { defineComponent, type PropType, } from 'vue'
import { ItemTab } from '@/components/tab/ItemTab.tsx'
import { cn } from 'cn'
import { genDefaultSlotTabs } from '@/components/tab/genDefaultSlotTabs.ts'

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

    return () => {
      // 支持插槽使用map遍历生成的文档片段
      const defaultSlotTabs =
        genDefaultSlotTabs(_ctx.slots?.default?.() ?? [])
      const ifAllItemTab = defaultSlotTabs
        .every((slot) =>
          slot.type === ItemTab &&
          slot.props &&
          Object.hasOwn(slot.props,'tabId') &&
          Object.hasOwn(slot.props,'tabName')
        )
      if (!ifAllItemTab) {
        throw new Error(`SwitchTabs: 子组件必须是 ItemTab 组件,且必须有 tabId 和 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?.tabId}`}
                      class={cn(
                        'where:(text-center grow-1 shrink-0 px-1em py-12px)',
                        selectedClass(slot?.props?.tabId),
                      )}
                      onClick={() => onSwitchTab(slot?.props?.tabId)}>
                      {slot?.props?.tabName}
                    </li>
                  ))
              }
            </menu>
          </nav>

          {/* 标签内容-插槽-数组 */}
          <section class="max-h-[calc(100vh-136px)] overflow-auto scrollbar-hide">
            {
              defaultSlotTabs
                .find((slot) =>
                  slot.props?.tabId === _props.selectedTabId)
            }
          </section>
        </div>
      )
    }
  },
})
  • 将标签选择机制从 tabName 改为 tabId
  • 将 selected 的 prop 重命名为 selectedTabId
  • 将 update:selected 事件改为 update:selectedTabId
  • 内部比较逻辑统一使用 tabId 进行匹配,tabName 仅用来展示tab文本
  • 其他逻辑不变
    • 内部必须使用默认插槽接收 ItemTab 组件,ItemTab 组件必须有 tabId 和 tabName 属性
    • 拿到默认插槽的子组件数组,遍历判断是否为 ItemTab 或是 v-fgt,递归拍平返回子组件数组并渲染 defaultSlotTabs
  • 需要重构引用它的相关组件 @src/views/ItemCreate.tsx

重构 @/views/ItemListView.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { defineComponent } from 'vue'
import { ItemSummary } from '@/modules/items/ItemSummary.tsx'
import { DateTabsLayout } from '@/components/layout/DateTabsLayout.tsx'
import { OverlayIcon } from '@/components/overlay/OverlayIcon.tsx'

export const ItemListView = defineComponent({
  name: 'ItemListView',
  setup(/* _props, _ctx */) {
    return () => (
      <DateTabsLayout title="山竹记账">
        {{
          icon: () => <OverlayIcon iconName="menu" />,
          default: ({ start, end }: { start: string; end: string }) => (
            <ItemSummary startDate={start} endDate={end} />
          ),
        }}
      </DateTabsLayout>
    )
  },
})

创建统计图表页面 @/views/StatisticsView.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { defineComponent, } from 'vue'
import { ChartsSummary } from '@/modules/statistics/ChartsSummary.tsx'
import { DateTabsLayout } from '@/components/layout/DateTabsLayout.tsx'
import { SvgIcon } from '@/components/icons/SvgIcon.tsx'

export const StatisticsView = defineComponent({
  name: 'StatisticsView',
  setup(/* _props, _ctx */) {
    return () => (
      <DateTabsLayout title="统计图表">
        {{
          icon: () => <SvgIcon name="back-arrow" class="size-1.5em" />,
          default: ({ start, end }: { start: string; end: string }) => (
            <ChartsSummary startDate={start} endDate={end} />
          ),
        }}
      </DateTabsLayout>
    )
  },
})
  • 如果需要将一个组件传参到子组件内部的话,直接使用插槽,代替通过使用外部参数 props 来传一个组件,后者需要声明组件类型
    • 还可以定义好插槽执行函数的参数,即作用域插槽
    • default: ({ start, end }: { start: string; end: string }) => (<Xxx startDate={start} endDate={end} />)
  • 子组件消费插槽时,传递实参 {_ctx.slots?.default?.({ start: tabItem.start, end: tabItem.end })}

2. 实现 select 控件 ⇧


3. 用 echarts 实现折线图 ⇧


4. 用 echarts 实现饼图 ⇧


5. 用 echarts 实现柱状图 ⇧


6. 重构:封装三个图表组件 ⇧


·未完待续·

参考文章

相关文章

  • 无

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