1 Button组件

1.1 Icon

Icon组件接受svg的id作为参数,所以需要让definedComponent方法与TypeScript都知道此组件的参数及类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
export const Icon = defineComponent({
  props: {
    name: {
      // 前面的类型是为Vue准备的,as后面的是为TypeScript准备的
      type: String as 
      	PropType<"add" | "chart" | "clock" | "cloud" | "pig" | "mangosteen">,
      required: true,
    },
  },
  setup(props, context) {
    return () => (
      <svg class={s.icon}>
        <use xlinkHref={`#${props.name}`}></use>
      </svg>
    );
  },
});

小细节:如果在使用Icon组件时添加了class属性,那么会将这个class赋值给内部的svg标签,而name属性则自动赋值给了svg标签内部的use标签,这些都是vue自动区分的,如果使用react则需我们自行区分

1.2 Button

  • 若要自定义样式,可以在Button组件添加;若要调整布局相关样式,可以在Button外包裹一层div.button_wrapper
1
2
3
4
5
<div class={s.button_wrapper}>
	<Button class={s.button}>
		测试
	</Button>
</div>
  • 总之为了方便Button应用于不同场景,所以只在Button组件中规定基本样式即可

  • 在Button组件中并未定义onClick事件,但在使用时可以直接传入onClick事件,且能够成功触发

    • 这是因为在Vue中,如果给组件绑定了onClick事件,那么就会默认透传给组件内部的第一层元素

    • 此时的报错是TypeScript报错,只需在defineComponent传入props类型即可解决:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import { defineComponent } from "vue";
import s from "./Button.module.scss";
interface Props {
  onClick: (e: MouseEvent) => void;
}
export const Button = defineComponent<Props>({
  setup(props, context) {
    // button的内容应该是从外部插槽定义的
    return () => (
      <button class={s.button}>
      	{context.slots.default?.()}
    	</button>
  )},
});

1.3 FloatButton

直接在Icon组件的基础上包装一下即可

实现自定义图标,因此需要接受iconName参数,其类型与Icon的name属性一致,为避免重复,我们在Icon中将类型导出以复用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Icon.tsx
export type IconName = "add" | "chart"| "clock" | "cloud" | "pig" | "mangosteen";

// FloatButton.tsx
import { defineComponent, PropType } from "vue";
import { Icon, IconName } from "./Icon";
import s from "./FloatButton.module.scss";
export const FloatButton = defineComponent({
  props: {
    iconName: {
      type: String as PropType<IconName>,
      required: true,
    },
  },
  setup(props, context) {
    return () => (
      <div class={s.floatButton}>
        <Icon name={props.iconName} class={s.icon} />
      </div>
    );
  },
});

2 Center组件

  • Center组件实质上就是让父组件传入的元素水平垂直居中(flex)

  • 支持多元素居中方向的配置(flex的方向)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { defineComponent, PropType } from "vue";
import s from "./Center.module.scss";
export const Center = defineComponent({
  props: {
    direction: {
      type: String as PropType<"row" | "column">,
      default: "row",
    },
  },
  setup(props, context) {
    return () => (
      // react中需要自己去join className,而vue的话一个数组就搞定了
      <div class={[s.center, props.direction]}>
        {context.slots.default?.()}
      </div>
    );
  },
});
  • Center.module.scss
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
.center {
    display: flex;
    justify-content: center;
    align-items: center;
    &.row {
        flex-direction: row;
    }
    &.column {
        flex-direction: column;
    }
}

3 Navbar组件

  • Navbar设置两个插槽,分别表示图标与标题
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { defineComponent } from "vue";
import s from "./Navbar.module.scss";
export const Navbar = defineComponent({
  setup(props, context) {
    const { slots } = context;
    return () => (
      <div class={s.navbar}>
        <span class={s.icon_wrapper}>{slots.icon?.()}</span>
        <span class={s.title_wrapper}>{slots.default?.()}</span>
      </div>
    );
  },
});
  • 样式方面,由于不方便给插槽内部的图标写样式,索性就规定icon_wrapper的大小,具体样式在父组件使用插槽传递具体图标时再定义

  • 使用

1
2
3
4
5
6
<Navbar>
{{
	default: () => "GS记账",
	icon: () => <Icon name="menu" class={s.navIcon} />,
}}
</Navbar>

4 Overlay组件

  • Overlay组件主要由主体和遮罩两部分构成
1
2
3
4
5
<div class={s.mask}></div>
<div class={s.overlay}>
	<section>顶部</section>
  <nav><ul>下部列表</ul></nav>
</div>
  • 给Icon绑定onClick事件,如果在props中声明了就只能手动绑定了(与按钮不同)
1
2
3
4
5
6
7
8
9
// Icon.tsx
props: {
	...
	onClick: Function as PropType<(e: MouseEvent) => void>,
},
setup(props, context) {
	return () => (
		<svg onClick={props.onClick}>
...
  • 单击菜单按钮触发Overlay的显示,单击遮罩层隐藏,利用props实现子父组件通信(too React)

  • 基本样式

    • mask与overlay定位均是absolute,left为0,距离top保留一段安全高度

    • 宽度100%,高度100%-顶部安全距离(摄像头)

    • overlay的z-index要大于mask

5 Tabs组件

5.1 创建items相关路由

  • /items/create:/components/item/ItemCreate.tsx

  • /item:/components/item/ItemList.tsx

  • views/ItemPage.tsx

1
2
3
4
5
6
7
8
import { defineComponent } from "vue";
import { RouterView } from "vue-router";
import s from "./ItemPage.module.scss";
export const ItemPage = defineComponent({
  setup(props, context) {
    return () => <RouterView />;
  },
});

5.2 封装MainLayout组件

绝大部分页面的结构都是上下结构,上部Navbar,下部是页面内容,因此可以将其抽离为MainLayout组件,以StartPage为例,将title、icon和主体设置为三个插槽

 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
// layouts/MainLayout.tsx 留好三个插槽
...
<>
	<Navbar>
  	{{
    	default: () => context.slots.default?.(),
      icon: () => context.slots.icon?.(),
  	}}
	</Navbar>
	{context.slots.main?.()}
</>
// StartPage.tsx 直接向插槽传组件即可
...
<MainLayout>
  {{
    default: () => "GS记账",
      icon: () => (
        <Icon name="menu" class={s.navIcon} onClick={onClickMenu} />
      ),
        main: () => (
          <>
          <Center class={s.pig_wrapper}>
            <Icon class={s.pig} name="pig"></Icon>
          </Center>
          <div class={s.button_wrapper}>
            <RouterLink to="/items/create">
              <Button class={s.button}>开始记账</Button>
            </RouterLink>
          </div>
          <RouterLink to="/items/create">
            <FloatButton iconName="add"></FloatButton>
          </RouterLink>
          {showOverlay.value && <Overlay close={close} />}
   </>
   ),
  }}
</MainLayout>

5.3 设计Tabs Api

分为外部的Tabs组件和其内部的Tab组件

Tabs组件接收selected与onChange属性,且内部元素只允许为Tab组件

Tab组件接收name属性

1
2
3
4
5
6
7
8
// 基本使用
const refSelected = ref("支出");
...
<Tabs selected={refSelected.value} onChange={(tab: string) => refSelected.value = tab}>
	<Tab name="支出">icon列表</Tab>
  <Tab name="收入">icon列表2</Tab>
</Tabs>
...

5.4 完成Tabs

  • Tab比较简单,将传入的元素作为插槽返回即可。记得声明name属性
  • Tabs通过context.slots.default()拿到内部元素(数组),遍历并判断其type属性是否为Tab,若不是,throw 一个运行时错误
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// slots相当于React中的props.children
const nodeArr = context.slots.default?.();
// 如果没有传子组件,则返回null
if (!nodeArr) return null;
// 遍历nodeArr
for (let item of nodeArr) {
	if (item.type !== Tab) {
		// 运行时报错
		throw new Error("Tabs组件的子组件必须是Tab组件");
	}
}
  • 实现点击切换tab名称

    • 给li元素绑定onClick和selected样式,onClick事件即调用父组件传来的props.onChange方法,将新的TabName传给父组件
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    return ( // 这里没有返回函数是因为已经将tsx与逻辑放入setup return的函数中了
    	<div class={s.tabs}>
    		<ol class={s.tabs_nav}>
    			{nodeArr.map((item) => (
    				<li
    					class={item.props?.name === props.selected ? s.selected : ""}
    					onClick={() => props.onChange?.(item.props?.name)}
    				>
    					{item.props?.name}
    				</li>
    			))}
    		</ol>
    	<div></div>
    </div>
    );
    
    • v-model实现,区别在于使用时无需定义onChange的回调函数,以及tab的单击事件所调用的方法(props => context.emit)
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    // Tabs.tsx触发v-model的update
    <ol class={s.tabs_nav}>
      {nodeArr.map((item) => (
        <li
          class={item.props?.name === props.selected ? s.selected : ""}
          onClick={() => context.emit("update:selected", item.props?.name)}
        >
        	{item.props?.name}
        </li>
      ))}
    </ol>
    // 使用v-model
    const refSelected = ref("支出");
    ...
    <Tabs v-model:selected={refSelected.value}>
    	<Tab name="支出">icon列表</Tab>
      <Tab name="收入">icon列表2</Tab>
    </Tabs>
    ...
    
  • 展示Tab内容

    • 重点在于获取到子组件(Tab)的子组件(插槽)
    • 通过log大法,根据item.props.name找到并直接渲染子组件即可
    1
    
    <div>{nodeArr.find((item) => item.props?.name === props.selected)}</div>
    
  • 完善样式

    • tabs_nav左右布局并居中
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    &_nav {
      // 左右并居中
      display: flex;
      justify-content: space-between;
      align-items: center;
      text-align: center;
      color: var(--nav-text-color);
      li {
        flex-shrink: 0;
        flex-grow: 1;
    ...
    
    • 选中状态,下部有白色横线标识
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    &.selected {
      position: relative;
      // 选中 下边框变白
      &::after {
        content: '';
        position: absolute;
        left: 0;
        bottom: 0;
        width: 100%;
        height: 4px;
        background-color: var(--tabs-indicator-bg);
      }
    }
    

6 数字按键

  • 下方数字按键包括两部分,上半部分是日期选择器及数字显示,下半部分是数字按键

6.1 grid布局制作数字按键

  • 数字按键采用grid布局,通过子类选择器&:nth-child(n)进行area的指定
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.buttons {
    display: grid;
    // grid布局 area不要以数字开头
    grid-template-areas:
      "n1 n2 n3 d"
      "n4 n5 n6 d"
      "n7 n8 n9 s"
      "n0 n0 nd s";
    // 单元格高度
    grid-auto-rows: 48px;
    // 单元格等宽
    grid-auto-columns: 1fr;
    gap: 1px;
    background: var(--button-border-color);
    border-top: 1px solid var(--button-border-color);
    > button {
      border: none;
      background: var(--button-bg-normal);
      // 子元素选择器
      &:nth-child(1) {
        grid-area: n1;
      }
...
  • tsx中,利用map遍历buttons
 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
const buttons = [
      { text: "1", onClick: () => {} },
      { text: "2", onClick: () => {} },
      { text: "3", onClick: () => {} },
      { text: "4", onClick: () => {} },
      { text: "5", onClick: () => {} },
      { text: "6", onClick: () => {} },
      { text: "7", onClick: () => {} },
      { text: "8", onClick: () => {} },
      { text: "9", onClick: () => {} },
      { text: ".", onClick: () => {} },
      { text: "0", onClick: () => {} },
      { text: "清空", onClick: () => {} },
      { text: "提交", onClick: () => {} },
];
return () => (
	<>
    <div class={s.dateAndAmount}>
    	...
    </div>
    <div class={s.buttons}>
      {buttons.map((button) => (
      	<button onClick={button.onClick}>{button.text}</button>
      ))}
    </div>
  </>
);

6.2 利用Vant UI制作日期选择器

DatetimePicker 时间选择 - Vant 3 (gitee.io)

  • 安装Vant与Vite按需引入配置
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 安装
pnpm add vant
pnpm add vite-plugin-style-import@1.4.1 -D

// vite.config.ts
import styleImport, { VantResolve } from 'vite-plugin-style-import';
export default {
  plugins: [
    styleImport({
      resolves: [VantResolve()],
    }),
  ],
};

// 页面引入
import { DatetimePicker, Popup } from 'vant';
<DatetimePicker 
  type="date" 
  title="选择年月日"               
  onConfirm={setDate} 
  onCancel={hideDatePicker}
/>
  • 完善日期选择器功能
 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
setup(props, context) {
    // 日期选择器功能
    const now = new Date();
    const refDate = ref<Date>(now);
    const refIsDatePickerShow = ref(false);
    const showDatePicker = () => (refIsDatePickerShow.value = true);
    const onCancel = () => (refIsDatePickerShow.value = false);
    const onConfirm = (date: Date) => {
      refDate.value = date;
      onCancel();
    };
    return () => (
      <>
        <div class={s.dateAndAmount}>
          <span class={s.date}>
            <Icon name="date" class={s.icon}></Icon>
            <span>
              <span onClick={showDatePicker}>
                {time(refDate.value).format()}
              </span>
              <Popup position="bottom" v-model:show={refIsDatePickerShow.value}>
                <DatetimePicker
                  value={refDate.value}
                  type="date"
                  title="选择年月日"
                  onConfirm={onConfirm}
                  onCancel={onCancel}
                />
              </Popup>
            </span>
          </span>
          <span class={s.amount}>199.00</span>
        </div>
        <div class={s.buttons}>
          ...
        </div>
      </>
    );
  },
  • 样式
 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
.dateAndAmount {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 16px;
    // monospace是等宽字体的族
    font-family: monospace;
    border-top: 1px solid var(--button-border-color);
    > .date {
      font-size: 12px;
      display: flex;
      align-items: center;
      justify-content: start;
      color: var(--date-text);
      .icon {
        width: 24px;
        height: 24px;
        margin-right: 8px;
        fill: var(--amount-text);
      }
    }
    > .amount {
      font-size: 20px;
      color: var(--amount-text);
    }
}

6.3 完善数字按键功能

  • 数字输入的逻辑主要是围绕0.这两个字符
    1. 先判断位数(能提前return的逻辑就放到前面)
    2. 如果输入的是.,则去看前面存不存在.,如果存在则不生效
    3. 如果输入的是0,则去看值是不是0(初始状态),如果值为0则不生效
    4. 如果输入的是0-9,则将初始值置为空(去掉0)
    5. 最后完成赋值操作即可
 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
// 数字输入功能
const refAmount = ref("0");
const appendText = (n: number | string) => {
  const nString = n.toString();
  const dotIndex = refAmount.value.indexOf(".");
  // 位数限制,最大13位
  if (refAmount.value.length >= 13) {
    return;
  }
  // 位数限制,小数点后最多两位
  if (dotIndex >= 0 && refAmount.value.length - dotIndex > 2) {
    return;
  }
  // 如果输入的是小数点
  if (nString === ".") {
    // 如果已经存在小数点,则不能再次输入
    if (dotIndex >= 0) {
      return;
    }
    // 如果输入的是0
  } else if (nString === "0") {
    // 当第一次输入时(一开始就输入0),则不生效
    if (refAmount.value === "0") {
      return;
    }
    // 如果输入的是其他(0-9)
  } else {
    // 把默认的0删掉
    if (refAmount.value === "0") {
      refAmount.value = "";
    }
  }
  // 赋值
  refAmount.value += n.toString();
};
const buttons = [
  { text: "1", onClick: () => { appendText("1") } },
  ...
];

7 表单组件

7.1 创建Tag页面

  • 快速创建100个li,内容为1~100
    • li{$}*100
  • routes
1
2
3
4
5
6
7
8
{
  path: "/tags",
  component: TagPage,
  children: [
    { path: "create", component: TagCreate },
    { path: ":id", component: TagEdit },
  ],
},
  • views/TagPage.tsx
1
2
3
4
...
setup(props,context) {
  return () => <RouterView />;
},
  • components/tag/TagCreate.tsx
  • 使用MainLayout.tsx,主体分为四部分,利用四个div完成布局
 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
<MainLayout>
  {{
    default: () => "新建标签",
    icon: () => <Icon name="left" />,
    main: () => (
      <form>
        {/* 标签名称表单项 */}
        <div>
          <label>
            <span>标签名</span>
            <input />
          </label>
        </div>
        {/* 表情选择框 */}
        <div>
          <label>
            <span>符号</span>
            <div>
              {/* 表情类型导航 */}
              <nav>
                <span>表情</span>
                ...
                <span>运动</span>
              </nav>
              {/* 表情列表 */}
              <ol>
                <li>1</li>
                ...
                <li>100</li>
              </ol>
            </div>
          </label>
        </div>
        {/* 提示信息行  */}
        <div>
          <p>记账时长按标签即可进行编辑</p>
        </div>
        {/* 提交按钮行 */}
        <div>
          <button>确定</button>
        </div>
      </form>
		),
	}}
</MainLayout>

7.2 TagCreate样式

  • 控制emoji显示行数为12,只需规定外面ol的行高(line-height)与高度(height)的关系即可
1
2
3
4
ol {
	line-height: 32px;
	height: calc(32px * 12);
}
  • form表单项元素在命名class时要拼接上formItem关键字,方便样式定义
    • 例如输入框及错误提示
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// tsx
<span class={s.formItem_name}>标签名</span>
<div class={s.formItem_value}>
	<input class={[s.formItem, s.input, s.error]} />
</div>
<div class={s.formItem_errorHint}>
	<span>必填</span>
</div>
<Button class={[s.formItem, s.button]}>确定</Button>
// scss
.formItem {
    &.button {}
    &.input {} => class="formItem input"
    &_name {} => class="formItem_name"
    &_value {}
    &_errorHint {}
}

  • CSS变量排序

    • 对CSS变量进行排序,以防止变量重名等bug

    • 选中全部变量,cmd+shift+p 输入sort,选择按升序/降序排列

7.3 利用js爬取页面数据

获取全部emojiList数据

Full Emoji List, v14.0 (unicode.org)

主要是利用DOM Api以及数组的方法来分析及处理emoji表格数据,主要用到的API如下

1
2
3
4
DOM.classList.contains('rchars') // 元素的class有没有rchars的类名
DOM.tagName.toLowerCase() // 获取元素的tag名称
document.querySelectorAll('table') // 获取页面中全部table元素
document.querySelectorAll('table')[0].querySelectorAll('tr') // 获取第一个表格元素内部的全部tr元素

7.4 封装EmojiList组件

  • 利用reactivev-model关联input与表单数据
1
2
3
4
5
const formData = reactive({
	name: "",
	sign: "",
});
<input v-model={formData.name}/>
  • 创建EmojiList组件,原来父元素的样式保留,将内部元素抽取为tsx组件

  • 获取到emojiList数据后,遍历渲染在页面中即可

  • 这里有个细节,如果将emoji的遍历过程抽离出来,那么遍历渲染的方法所处的位置很关键

    • 如果放在return函数前,由于setup中除return函数之外,其余代码只运行一次,所以emoji只会计算一次,不会随着系列的改变而改变

    • 可以将方法放在return函数中,这样每次渲染都会重新计算渲染

    1
    2
    3
    4
    5
    6
    
    setup(){
    	return () => {
    	 const emojis = {...}
    	 return <div>{emojis}</div>
    	} 
    }
    
    • 可以将方法用computed包裹,vue会自动跟踪其依赖(性能更好)
    1
    2
    3
    4
    
    setup(){
    	const emojis = computed({...})
    	return () => <div>{emojis.value}</div>
    }
    
  • 绑定emoji的导航栏选中事件及选中样式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
setup(props, context) {
    // 选中emoji系列的索引
    const refSelected = ref(1);
    const onSeriesClick = (index: number) => {
      refSelected.value = index;
    };
    return () => (
      ...
        <nav>
          {table.map((item, index) => (
            <span
              onClick={() => {
                onSeriesClick(index);
              }}
              class={index === refSelected.value ? s.selected : ""}
            >
              {item[0]}
            </span>
          ))}
        </nav>
      ...
    );
  },
  • 绑定emoji选中事件及选中样式
  • 选中后需要与父组件的表单数据进行交互,因此需要进行双向数据绑定
 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
// v-model数据绑定
<EmojiList v-model={formData.sign} />
// 给每个li绑定单击事件,触发v-model
export const EmojiList = defineComponent({
  // 在props中定义一个v-model属性,用于接收父组件绑定的值
  props: {
    modelValue: {
      type: String,
    },
  },
  setup(props, context) {
    ...
    const onEmojiClick = (emoji: string) => {
      // 触发modelValue的更新事件
      context.emit("update:modelValue", emoji);
    };
    return () => (
      ...
        <ol>
          {taåble[refSelected.value][1].map((category) =>
            emojiList
              .find((item) => item[0] === category)?.[1]
              .map((item) => (
                <li
                  onClick={() => {
                    onEmojiClick(item);
                  }}
                  class={item === props.modelValue ? s.selectedEmoji : ""}
                >
                  {item}
                </li>
              ))
          )}
        </ol>
     ...

7.5 设计表单验证器

定义规则与校验函数,在onSubmit回调中校验

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 使用
const onSubmit = (e: Event) => {
  // 取消默认的刷新行为
	e.preventDefault()
  // 拿到formData原始对象(原来是proxy对象)
  const data = toRaw(formData);
	const rules = [
    // 每条规则只能描述一种信息
		{ key: 'name', required: true, message: '必填' },
		{ key: 'name', pattern: /^.{1,4}$/, message: '只能填1~4个字符' },
		{ key: 'sign', required: true }
	]
	const errors = validate(formData, rules)
  // errors = {
  //   name: ['错误1', '错误2'],
  //   sign: ['错误3']
  // }
}

// 错误提示中展示
<span>{errors['name'].join(',')}</span>

7.6 实现表单验证器

  • 初步定义类型

  • 定义表单数据的类型

1
2
3
4
5
// 对于表单数据,key和value都不是提前定义的,所以只能暂时定义为一个普通对象的形式
// 要注意其值也有可能是一个对象,可以循环使用FData类型
interface FData {
  [key: string]: string | number | null | undefined | FData;
}
  • 定义规则数据的类型
1
2
3
4
5
6
7
type Rule = {
  key: string;
  message: string;
} & (
  | { type: "regExp"; regExp: RegExp }
  | { type: "required"; required: boolean }
);
  • 实际上FData中的key和Rule与Errors中的key都是有关联的
  • 通过泛型添加FDataRuleErrors的关联
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 对于表格数据,key和value都不是提前定义的,所以只能暂时定义为一个普通对象的形式
// 要注意其值也有可能是一个对象,可以循环使用FData类型
interface FData {
  [key: string]: string | number | null | undefined | FData;
}
type Rule<T> = {
  // 当Rule类型接收到表单数据的泛型T时,此时的key就是T中的key
  // key: string;
  key: keyof T;
  message: string;
} & (
  | { type: "regExp"; regExp: RegExp }
  | { type: "required"; required: boolean }
);
type Errors<T> = {
  // 将FData中的key与Error中的key对应起来
  [key in keyof T]?: string[];
};
// 在箭头函数前添加泛型<T>,继承自FData,并将T作为参数传给Rule类型
export const validate = <T extends FData>(data: T, rules: Rule<T>[]) => {};
  • 实现校验逻辑
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
export const validate = <T extends FData>(data: T, rules: Rule<T>[]) => {
  const errors: Errors<T> = {};
  rules.forEach((rule) => {
    const { key, message, type } = rule;
    const value = data[key];
    if (type === "required") {
      if (rule.required && !value && value === "") {
        // 初始化为一个数组
        errors[key] = errors[key] ?? [];
        errors[key]?.push(message);
      }
    } else if (type === "regExp") {
      if (value && !rule.regExp.test(value.toString())) {
        // 初始化为一个数组
        errors[key] = errors[key] ?? [];
        errors[key]?.push(message);
      }
    }
  });
  return errors;
};
  • 使用校验方法,注意类型定义与占位的细节
 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
// components/tag/TagCreate.tsx
setup(props, context) {
  const formData = reactive({
    name: "",
    sign: "",
  });
  // errors类型与formData类型建立关联
  const errors = reactive<{ [k in keyof typeof formData]?: string[] }>({});
  const onSubmit = (e: Event) => {
    e.preventDefault();
    const data = toRaw(formData);
    // 将formData的类型提前传入Rule中,防止ts自动进行类型定义
    const rules: Rule<typeof formData>[] = [
      {
        key: "name",
        type: "required",
        required: true,
        message: "请输入标签名称",
      },
      {
        key: "name",
        type: "regExp",
        regExp: /^.{2,6}$/,
        message: "标签名称长度为2-6",
      },
      {
        key: "sign",
        type: "required",
        required: true,
        message: "请输入标签签名",
      },
    ];
		// 每次校验前先清空错误信息
		Object.assign(errors, { name: undefined, sign: undefined });
  	// 将validate返回的数据放入errors中
  	Object.assign(errors, validate(data, rules));
	};
	return () => (
    ...
		{/* 添加空格提前占位,防止出现报错页面整体下移 */}
		{/* 也可以通过CSS设置span的min-height */}
    <span>{errors["name"] ? errors["name"][0] : " "}</span>
		...
    <span>
      {/* 注意空格是中文全角空格,否则报错时会出现1px的抖动 */}
      {errors["sign"] ? errors["sign"][0] : " "}
    </span>
		...
	)
}

8 页面开发

8.1 制作ItemCreate页面

  • 将布局改为flex(原来的inputPad是fixed布局),nav与inputPad固定高度,中间Tab页自适应,Tab组件的nav的布局使用sticky,防止其跟随内容滑动
 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
.wrapper {
    ...
    // 减去header
    height: calc(100vh - 88px);
  }
  .tabs {
    // tab页伸缩自如
    flex-grow: 1;
    flex-shrink: 1;
    overflow: auto;
  }
  .inputPad_wrapper {
    // inputPad不允许伸缩
    flex-grow: 0;
    flex-shrink: 0;
  }
  .tags_wrapper {
    display: flex;
    // 允许换行
    flex-wrap: wrap;
    padding-top: 16px;
  }
  .tag {
    // 一行永远只有五个tag
    width: 20vw;
    height: calc(20vw * 1.14);
    ...
  }

8.2 制作TagEdit页面

  • 由于TagEdit页面与TagCreate页面都有form表单,区别仅在于页面标题与下部按钮

  • 两个页面的公共样式抽离为Tag.module.scss

  • 将form抽离为TagForm组件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// TagForm.tsx
export const TagForm = defineComponent({
  setup(props, context) {
    const formData ...;
    const errors ...;
    const onSubmit ...;
    return () => <form .../>
  },
});
// TagCreate.tsx
export const TagCreate = defineComponent({
  setup(props, context) {
    return () => (
      <MainLayout>
        {{
          default: () => "新建标签",
          icon: () => <Icon name="left" />,
          main: () => <TagForm />,
        }}
      </MainLayout>
    );
  },
});
  • 扩展Button组件,支持按钮类型指定
 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
// Button.tsx
export const Button = defineComponent({
  props: {
    onClick: {
      type: Function as PropType<(e: MouseEvent) => void>,
    },
    level: {
      type: String as PropType<"important" | "normal" | "danger">,
      default: "important",
    },
  },
  setup(props, context) {
    // button的内容应该是从外部插槽定义的
    return () => <button class={[s.button, s[props.level]]}>{context.slots.default?.()}</button>;
  },
});

// Button.module.scss
.button {
    ...
    &.normal {
    }
    &.important {
    }
    &.danger {
      background: var(--button-bg-danger);
      border-color: var(--button-bg-danger);
    }
}

// TagEdit.tsx
// 在TagForm组件下面添加按钮即可
  • 扩展MainLayout组件,保证nav不随页面滚动(position: sticky)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// MainLayout.tsx
<div class={s.wrapper}>
	<Navbar class={s.navbar}>
		{{
			default: () => context.slots.default?.(),
			icon: () => context.slots.icon?.(),
		}}
	</Navbar>
	{context.slots.main?.()}
</div>

// MainLayout.module.scss
.wrapper {
    position: relative;
    .navbar {
      position: sticky;
      top: 0;
    }
}

8.3 制作ItemList页面

  • 使用MainLayout制作列表页

8.3.1 Tabs组件支持classPrefix

  • 设计稿中的Tabs是靠左对齐的,而Tabs组件默认是居中分布的

  • 索性让Tabs组件支持类名前缀(classPrefix),开发者可以自定义样式来覆盖原始样式

 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
setup(props, context) {
    return () => {
      ...
      const cp = props.classPrefix;
      return (
        <div class={[s.tabs, cp + "_tabs"]}>
          <ol class={[s.tabs_nav, cp + "_tabs_nav"]}>
            {nodeArr.map((item) => (
              <li
                class={[
                  item.props?.name === props.selected
                    ? [s.selected, cp + "_selected"]
                    : "",
                  cp + "_tabs_nav_item",
                ]}
              >
                {item.props?.name}
              </li>
            ))}
          </ol>
        </div>
      );
    };
  },
});

由于使用的是CSS Modules,会导致在拼接类名时会存在随机字符串,使得自定义样式无法准确匹配到类名

1
2
3
4
5
6
7
// ItemList.tsx
<Tabs
	classPrefix={s.custom}
	v-model:selected={refSelected.value}
>...</Tabs>
// 最终结果
// custom_x123fx_tabs 而非 custom_tabs
  • 所以可以使传入的classPrefix为固定的字符串,而不是s.xxx(放弃使用CSS Module)

  • 此时自定义样式就必须写在全局样式中(App.scss)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 自定义Tabs组件样式
// 存在选择器优先级的问题
.customTabs_tabs {
    & > &_nav {
        justify-content: flex-start;
    }
}
// 同样存在选择器优先级的问题
// 原始样式是 tabs_nav > li,即两层选择器(较具体)
// 那么覆盖样式时也需要两层选择器
.customTabs_tabs_nav {
    & > &_item {
        flex-grow: 0;
        padding-left: 16px;
        padding-right: 16px;
      }
}

8.3.2 重构Time类

值得一提的就是js对于月份加1,也就是所谓下个月的概念与我们正常生活中提到的下个月不一致,解决思路如下:

  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
101
102
103
104
105
export class Time {
  date: Date;
  constructor(date = new Date()) {
    this.date = date;
  }
  format(pattern = "YYYY-MM-DD") {
    // 目前支持的格式有 YYYY MM DD HH mm ss SSS
    const year = this.date.getFullYear();
    const month = this.date.getMonth() + 1;
    const day = this.date.getDate();
    const hour = this.date.getHours();
    const minute = this.date.getMinutes();
    const second = this.date.getSeconds();
    const msecond = this.date.getMilliseconds();
    // 就是用正则替换
    return pattern
      .replace(/YYYY/g, year.toString())
      // padStart和padEnd方法用于字符串长度补全,即用0来补全字符串,保证其有两位字符
      .replace(/MM/, month.toString().padStart(2, "0"))
      .replace(/DD/, day.toString().padStart(2, "0"))
      .replace(/HH/, hour.toString().padStart(2, "0"))
      .replace(/mm/, minute.toString().padStart(2, "0"))
      .replace(/ss/, second.toString().padStart(2, "0"))
      .replace(/SSS/, msecond.toString().padStart(3, "0"));
  }
  firstDayOfMonth() {
    return new Time(
      new Date(this.date.getFullYear(), this.date.getMonth(), 1, 0, 0, 0)
    );
  }
  firstDayOfYear() {
    return new Time(new Date(this.date.getFullYear(), 0, 1, 0, 0, 0));
  }
  lastDayOfMonth() {
    return new Time(
      new Date(this.date.getFullYear(), this.date.getMonth() + 1, 0, 0, 0, 0)
    );
  }
  lastDayOfYear() {
    return new Time(new Date(this.date.getFullYear() + 1, 0, 0, 0, 0, 0));
  }
  getRaw() {
    return this.date;
  }
  add(
    amount: number,
    unit:
      | "year"
      | "month"
      | "day"
      | "hour"
      | "minute"
      | "second"
      | "millisecond"
  ) {
    // return new Time but not change this.date
    let date = new Date(this.date.getTime());
    switch (unit) {
      case "year":
        date.setFullYear(date.getFullYear() + amount);
        break;
      case "month":
        // 由于js中理解的下一个月与现实中下一个月不同
        // 例如1.31加一个月之后是3.2而非2.28或2.29
        // 例如10.31加一个月之后是12.1而非11.30
        // 同样的情况均出现在本月(比下个月的天数多)最后一天
        // 思路1:所以如果发现加了amount个月导致月份差值超过amount,那么就返回上个月最后一天
        // 思路2:先处理月份,再对比日期,用较小的那个日期
        const d = date.getDate(); // d = 31; date = 2000.1.31
        // 先把月份置为1,再加amount个月(目的是先使月份按要求正确新增之后,再单独处理日期)
        date.setDate(1); // date = 2000.1.1
        date.setMonth(date.getMonth() + amount); // date = 2000.2.1
        // 找到date加amount个月后的月份最后一天的日期
        const d2 = new Date(
          date.getFullYear(), // 2000
          date.getMonth() + 1, // 3
          0, // 日期为0表示上月最后一天
          0,
          0,
          0
        ).getDate(); // d2 = 29
        // 对比这个日期(d2)以及当前日期(d),返回小的那个
        date.setDate(Math.min(d, d2)); // date = 2000.2.29
        break;
      case "day":
        date.setDate(date.getDate() + amount);
        break;
      case "hour":
        date.setHours(date.getHours() + amount);
        break;
      case "minute":
        date.setMinutes(date.getMinutes() + amount);
        break;
      case "second":
        date.setSeconds(date.getSeconds() + amount);
        break;
      case "millisecond":
        date.setMilliseconds(date.getMilliseconds() + amount);
        break;
      default:
        throw new Error("Time.add: unknown unit");
    }
    return new Time(date);
  }
}

8.3.3 itemSummary组件

  • 组件设计

    • 接受startDate与endDate字段,展示页面内容
  • 组件实现 略

8.3.4 封装Form和FormItem组件

  • 自定义时间Tab页内部弹窗表单开发

  • 设计Form Api

1
2
3
4
<Form onSubmit={()=>{}}>
	<FormItem label='' v-model={x.value} type='text|emojiList|date' errors={errors[x]}>
  </FormItem>
</Form>
  • 实现Form与FormItem组件
 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
export const Form = defineComponent({
  props: {
    onSubmit: {
      type: Function as PropType<(e: Event) => void>,
    },
  },
  setup: (props, context) => {
    return () => (
      <form class={s.form} onSubmit={props.onSubmit}>
        {context.slots.default?.()}
      </form>
    );
  },
});

export const FormItem = defineComponent({
  props: {
    label: {
      type: String,
    },
    modelValue: {
      type: [String, Number],
    },
    type: {
      type: String as PropType<"text" | "emojiList" | "date">,
    },
    error: {
      type: String,
    },
  },
  setup: (props, context) => {
    const refDateVisible = ref(false);
    // 表单项通过type判断得出
    const content = computed(() => {
      switch (props.type) {
        case "text":
          ...
        case "emojiList":
          ...
        case "date":
          ...
        // 如果没有type,则默认为插槽,传什么都可以
        case undefined:
          return context.slots.default?.();
      }
    });
    return () => (
      <div class={s.formRow}>
        <label class={s.formLabel}>
          {/* 标签 */}
          {props.label && <span class={s.formItem_name}>{props.label}</span>}
          {/* 表单项 */}
          <div class={s.formItem_value}>{content.value}</div>
          {/* 错误提示 */}
          {props.error && (
            <div class={s.formItem_errorHint}>
              <span>{props.error}</span>
            </div>
          )}
        </label>
      </div>
    );
  },
});
  • 应用Form
 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
// TagForm.tsx
<Form onSubmit={onSubmit}>
  <FormItem
    label="标签名"
    type="text"
    v-model={formData.name}
    error={errors["name"] ? errors["name"][0] : " "}
    />
  <FormItem
    label={"符号 " + formData.sign}
    type="emojiList"
    v-model={formData.sign}
    error={errors["sign"] ? errors["sign"][0] : " "}
    />
  <FormItem>
    <p class={s.tips}>记账时长按标签即可进行编辑</p>
  </FormItem>
  <FormItem>
    <Button class={[s.button]}>确定</Button>
  </FormItem>
</Form>
// ItemList.tsx
<Form onSubmit={onSubmitCustomTime}>
  <FormItem
    label="开始时间"
    v-model={customTime.start}
    type="date"
    />
  <FormItem
    label="结束时间"
    v-model={customTime.end}
    type="date"
    />
  <FormItem>
    <div class={s.actions}>
      <button type="button">取消</button>
      {/* type必须为submit才能触发form的onsubmit事件 */}
      <button type="submit">确认</button>
    </div>
  </FormItem>
</Form>

8.4 封装弹出菜单

8.4.1 更新v-model的使用

  • 详情见Vue3组件通信一文

  • 改造Tabs组件、等对于v-model的使用方案

8.4.2 封装OverlayIcon组件

  • 单击菜单图标会弹出浮窗组件,我们将图标、浮窗及相关逻辑抽离为OverlayIcon组件
  • 先在StartPage页面下手,将浮窗与图标放在一起,再进行相关视图与逻辑的抽离
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Overlay.tsx
export const Overlay = defineComponent({...})
export const OverlayIcon = defineComponent({
  setup(props, context) {
    const showOverlay = ref(false);
    const onClickMenu = () => {
      showOverlay.value = !showOverlay.value;
    };
    const close = () => {
      showOverlay.value = false;
    };
    return () => (
      <>
        <Icon name="menu" class={s.icon} onClick={onClickMenu} />
        {showOverlay.value && <Overlay close={close} />}
      </>
    );
  },
});
  • 在StartPage中Mainlayout的icon插槽中引入并使用即可
  • 当在ItemList中使用时,由于position: sticky属性会影响z-index,因此我们需要重新设计Overlay组件的z-index
1
2
3
4
5
6
// var.scss
--z-index-bottom-nav: 8;
--z-index-navbar: 32;
// 修改vantUI的z-index变量
--van-overlay-z-index: 64;
--z-index-overlay: 16;

8.5 制作登录页面

写路由,MainLayout写页面,过程略

封装validationCode表单项,在FormItem组件中添加此分支

8.6 制作echarts图表

8.6.1 封装TimeTabsLayout组件

统计图表页面与ItemList页面唯一的区别就在于每个Tab中的组件内容不同,其他均相同

  • 在定义传入组件的类型时,可以在Tabs中声明一个子组件的类型(TImeTabsProps)供父组件完善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
// Tabs.tsx
export const TimeTabsProps = defineComponent({
  props: {
    startDate: {
      type: String as PropType<String>,
      required: true,
    },
    endDate: {
      type: String as PropType<String>,
      required: true,
    },
  },
});

// TimeTabsLayout.tsx
export const TimeTabsLayout = defineComponent({
  props: {
    component: {
      type: Object as PropType<typeof TimeTabsProps>,
      required: true,
    },
  },
  setup: (props, context) => {
    ...
    return () => (
      <MainLayout>
        {{
          ...
          main: () => (
            <>
              <Tabs
                classPrefix="customTabs"
                v-model:selected={refSelected.value}
                onUpdate:selected={onSelect}
              >
                <Tab name="本月">
                  <props.component
                    startDate={timeList[0].start.format()}
                    endDate={timeList[0].end.format()}
                  />
                </Tab>
                ....
              </Tabs>
              ...
  • 此时ItemList页面就可以重构为
1
2
3
4
5
6
7
8
import { defineComponent } from "vue";
import { TimeTabsLayout } from "../../layouts/TimeTabsLayout";
import { ItemSummary } from "./ItemSummary";
export const ItemList = defineComponent({
  setup: (props, context) => {
    return () => <TimeTabsLayout component={ItemSummary} />;
  },
});

8.6.2 实现select控件

设计Select Api(作为FormItem的类型之一)

1
2
3
4
5
6
7
8
9
<FormItem
  label="类型"
  type="select"
  options={[
    { text: "收入", value: "income" },
    { text: "支出", value: "expense" },
  ]}
  v-model={refSelect.value}
/>

实现

 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
// Form.tsx
export const FormItem = defineComponent({
  props: {
    ...
    options: {
      type: Array as PropType<{ text: string; value: string }[]>,
    },
  },
  setup: (props, context) => {
    const content = computed(() => {
      switch (props.type) {
        case "select":
          if (!props.options) return;
          return (
            <>
              <select
                class={[s.formItem, s.select]}
                value={props.modelValue}
                onChange={(e: any) => {
                  context.emit("update:modelValue", e.target.value);
                }}
              >
                {props.options.map((opt) => {
                  return <option value={opt.value}>{opt.text}</option>;
                })}
              </select>
            </>
          );
...

8.6.3 引入Echarts

  • 安装
    • pnpm i echarts@5.3.2
  • 使用
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { defineComponent, onMounted, PropType, ref } from "vue";
import s from "./PieChart.module.scss";
import * as echarts from "echarts";
export const PieChart = defineComponent({
  setup: (props, context) => {
    const refDiv2 = ref<HTMLDivElement>();
    onMounted(() => {
      if (refDiv2.value === undefined) {
        return;
      }
      // 基于准备好的dom,初始化echarts实例
      var myChart = echarts.init(refDiv2.value);
      // 绘制图表
      const option = {...};
      myChart.setOption(option);
    });
    return () => <div ref={refDiv2} class={s.wrapper}></div>;
  },
});