五、可复用组件

我们已经在前四篇博客里讨论了 FormWrapperFieldTxtBtnTaskBtnSearchDeletor 等组件。接下来我们会改进旧组件、开发新组件,用这些组件组成抽象层。未来我们会利用此抽象层快速开发业务功能。

5-banner-low

FormWrapperFieldBtn 及其变种

严重问题

图-5.1_三件套核心思想
我后知后觉地发现 Btn 有严重漏洞——如果我在响应到来前就路由走了,Btn 会被卸载,响应到来后就不会调用 whenReadyStdResponse.codeStdResponse.userVisibiltyStdResponse.msg 已经在 axios 响应拦截器里被利用并过滤掉了,whenReady 只能在 StdResponse.data 非空时被调用,拿到 StdResponse.data。它无非是想用这个数据更新:

  • 组件渲染时依赖的变量,以此实现组件的局部刷新。
  • 某个 store 中的某个状态,以备日后其他功能使用。比如更新 user store 中的 me.userInfo.username

组件渲染时依赖的变量可以定义在组件文件的 <script> 块里,也可以定义在某个 store 里。如果我们限制这些组件把所依赖的变量都定义成 store 中的状态,我们就把 whenReady 的行为归约成了“更新某个 store 中的某个状态”一种。说到“更新 store 中状态”,我们自然想到 actions。所以——whenReady 淡出舞台,修改 Btn 中的 send 函数:

async function send() {
  isTouched.value = true;
  // 收集数据
  if (!props.sender || !isAllValid.value || !isLinkValid.value) {
    playSound("btn", "forbidden");
    return shakeFields();
  }
  if (isUnderProcessing.value) {
    toast.waiting("参谋正在处理你的请求,请稍候");
    playSound("btn", "forbidden");
    return; // 防止重复点击
  }
  isUnderProcessing.value = true;
  const formData = {};
  relatedFields.value.forEach((f) => {
    formData[f.fkey] = f.fvalue.value;
  });

  const data = await props.sender(formData);
  isUnderProcessing.value = false;
  if (data === null) return;
  props.whenReady(data);
}
  • 如果只想得到弹窗反馈,向 sender 传入一个仅发送请求的异步函数即可。
  • 如果还想根据 StdResponse.data 更新特定状态,向 sender 传入该状态所属的 store 中的 action 异步函数即可,这个 action 既负责发送请求,也负责处理响应、更新 store 中的状态
  • 如果想在响应到达后做一些特殊操作(不是根据 StdResponse.data 更新特定状态),在 axios 响应拦截器里根据 StdResponse.code 编写特定分支,执行特殊操作即可。
  • 如果重点是让与 Btn 同处一个文件的其他组件在 Btn 获得响应后立刻执行某种操作,使用 whenReady。比如 Search 由一个 Field 和一个 Btn 构成,当响应到达浏览器时,Field 下会跳出“找到 × 个结果”提示(注意,不是渲染响应中的详细数据)。whenReady 主要用于控制时序

现在的代码里有一处受到了这个修改的影响:

<Btn
  prompt="保存"
  :sender="getBtnSender('PATCH', '/user/reset-username')"
  :whenReady="afterResetUsername"
  width="14rem"
  height="4.4rem"
  class="edit__btn--reset-username"
/>

<script setup>
const user = useUserStore();
function afterResetUsername(data) {
  user.resetMyUsername(data);
}
</script>

我们在 user store 中修改 resetMyUsername action

async resetMyUsername(formData) {
  const newUsername = await http.patch('/user/reset-username', formData);
  if (newUsername !== null) this.me.userInfo.username = newUsername;
}

Btn 的参数修改为:

<Btn
  prompt="保存"
  :sender="user.resetMyUsername"
  width="14rem"
  height="4.4rem"
  class="edit__btn--reset-username"
/>

还有一个问题——大部分 POSTPATCHDELETE 请求都不要求 StdResponse.data 携带数据,可是发出这些请求的 action 依旧需要一条得知这些请求成功了的途径,这样它才能去更新状态。刚才的 resetUsername 中,后端 API 返回了新的用户名字符串,响应拦截器返回了 StdResponse.data = 新用户名字符串,所以可以用 newUsername !== null 判断请求成功。但是这显然不合理——这些操作没必要在 StdResponse.data 里携带数据,StdResponse.data 就应该是 null。这样的“合理”会导致 action 无法利用 ×× !== null 判断这类请求是否成功。所以归约——修改 axios 响应拦截器,当 code 落入成功区间时返回 StdResponse.data ?? true。我能保证诤略参谋里的 StdResponse.data 永远不会携带空串、0 或其他 falsy value,所以 resetMyUsername 可以改成:

async resetMyUsername(formData) {
  const result = await http.patch("user/reset-username", formData);
  if (result) this.me.userInfo.username = formData.username;
}

搞定。


Txt 加入“追加文本”功能

部分 Txt 组件支持 OCR 输入。OCR 大概会对应一个与 Txt 同级的按钮,这个按钮会把识别出的文本追加到 Txt 已有的输入文本的末尾。所以我们要为 Txt 添加 append(textToAppend) 函数,并用 defineExpose 把它暴露给 OCR 组件使用。

function append(textToAppend) {
  if (!textToAppend) return;
  val.value = val.value + textToAppend;
  currLen.value = val.value.length;
  isInputed.value = true;
  isSaved.value = false;
  isValid.value = valid(val.value);
}
defineExpose({ append });

在父组件中,我们可以利用 ref 调用 append 方法:

<Txt ref="testTxt" />
<OCR @success="updateTest" />

<script setup>
const testTxt = ref(null);
function updateTest(text) {
  if (testTxt.value) testTxt.value.append(text);
}
</script>

允许用回车键代替点击 Btn

FormWrapper 可以分为两种情况:

  1. 下有一个 Btn。这种情况下我们对着任意一个 Field 按回车时都模拟点击这个 Btn 即可。
  2. 下有多个 Btn。这种情况下无法自动确认对着某个 Field 按回车键时该模拟点击哪个 Btn

先解决第二种情况。

  1. Field<input /> 上监听回车键抬起的事件,触发 'enter-pressed' 事件。
<input @keyup.enter="simulateClickBtn" />

const emit = defineEmits(['enter-pressed']);
function simulateClickBtn() { emit("enter-pressed"); }
  1. 暴露 Btnsend 方法(defineExpose({ send }))。
  2. 在表单中监听 Field'enter-pressed' 事件,事件发生时调用对应 Btnsend 方法。以 Search 组件为例,给 Btn 设置 ref,再给 Field 设置 @enter-pressed="$refs.Btn 的 ref 值.send()" 即可。
<Field
  :prompt="prompt"
  :fkey="searchBy"
  :width="fieldWidth"
  @enter-pressed="$refs.searchBtn.send()"
></Field>
<Btn
  ref="searchBtn"
  class="search__btn"
  :sender="getBtnSender('SEARCH', url)"
  :whenReady="searcher"
  :width="btnWidth"
  type="search"
  height="4.4rem"
>

再解决第一种情况。

  1. FormWrapper 添加 autoBindEnter props,默认为 true,当同一个 FormWrapper 下有多个 Btn 时,必须将 autoBindEnter 设为 false
  2. FormWrapperBtn 提供 registerBtnSend 函数,Btn 调用此函数把自己的 send 函数交给 FormWrapper 中的 simulateClickBtn 调用。向 Field 提供 simulateClickBtn 函数,Field 中的 <input /> 监听回车事件,在事件发生时调用提供的 SimulateClickBtn
// FormWrapper.vue
/* Btn 与回车绑定 */
// 1. Btn 通过提供的 registerBtnSend 注册 send 函数
let clickBtn;
const registerBtnSend = props.autoBindEnter
  ? (send) => (clickBtn = send)
  : (_) => {};
// 2. Field <input /> 监听到 enter 事件,调用 FormWrapper 提供的 simulateClickBtn
const simulateClickBtn = props.autoBindEnter ? () => clickBtn() : () => {};

Search 又变了回去:

<FormWrapper class="search__box">
  <Field :prompt="prompt" :fkey="searchBy" :width="fieldWidth"></Field>
  <Btn
    class="search__btn"
    :sender="getBtnSender('SEARCH', url)"
    :whenReady="searcher"
    :width="btnWidth"
    type="search"
    height="4.4rem"
  >
  <PhMagnifyingGlass :size="24" weight="bold" />
  </Btn>
</FormWrapper>

总之:

  • 如果 FormWrapper 下只有一个 Btn,什么都不用管,回车和 Btn 自动绑定了。
  • 如果 FormWrapper 下有多个 Btn,必须把 autoBindEnter 设为 false,然后手动编写 ref@enter-pressed="$refs.Btn 的 ref 值.send()"

多页列表 PaginatedList

UI 草稿对多页列表提出了以下需求:

  1. 列表中的条目是其他 .vue 里定义好的组件,这些组件通过 props 获取要渲染的数据。
  2. 可以自定义列表的一页至多包含几个条目。
  3. 其中一种组件可以展开/收起。列表里在同一页上显示的所有这种组件在同一时刻最多只有一个展开,这个展开了其他的就收起了。展开/收起不应该影响列表的高度。
  4. 底部有页码栏,一般状态下显示“首页”“上一页”“下一页”“尾页”按钮,四个按钮中间是当前页码和前后几个临近的页码, 你也可以直接点那几个页码。
  5. 只有一页时不显示页码栏,只有两页时不显示“首页”和“尾页”按钮。
  6. 有多页时可以按 键翻动页面,如果到了尽头还在继续翻,会循环回来,像是模运算一样。
  7. 可以自定义条目的排序方式。
  8. 列表为空时显示 Empty 提示。

图-5.2_草稿中的分页列表
先解决需求一和八。GlobalModalWrapper 已经玩过 <component :is> 的把戏了。

  • 假设 PaginatedList 内的条目是 X 组件的样式。为 PaginatedList 设置 item 属性,将 X 组件传给 item。我们在 PaginatedListv-for <li><li> 包裹一个 <component :is="item" v-bind="d">。注意,后端实体类的主键名是 ××Id,为了统一列表的行为,我们要在前端 store 的概览数组里把各对象的 ××Id 的值复制到 id
<div class="list" >
  <transition-group>
    <li v-for="d in data" :key="d.id" >   
      <component :is="item" v-bind="d"/>
    </li>
  </transition-group>
  <Empty v-if="!data.length" v-bind="empty" />
</div>

<script setup>
const props = defineProps({
  data: {
    type: Array,
    required: true,
  },
  item: {
    type: [Object, Function, String], // 组件对象、setup 函数或异步组件加载器
    required: true,
  },
  empty: { type: Object }, // 配置 Empty 的 title、cta 和 action 用
});
</script>
  • PaginatedList 设置 data 数组属性,这个属性在未来应该会与 store 中的数组绑定,data 中各个对象 d 的键值对对应 X 组件的 props
  • 需求八直接利用已有的 Empty 组件,外部向列表传递用于配置 Empty 的对象 empty 即可。

接下来解决排序问题。

  • 为列表添加 orderBy(a, b) 函数属性。
    • 如果不使用列表排序,传 null
    • 如果使用列表排序,orderBy 必须符合 Array.prototype.sort() 对比较函数的要求——如果返回负数表示 a 应该排在 b 前,返回正数则表示 b 应该排在 a 前,返回 0 表示二者相对顺序不变。
  • 设置数组 sortedData,替换掉 data 的职责。
const sortedData = computed(() => {
  if (props.orderBy) return [...props.data].sort(props.orderBy);
  return props.data;
});

再解决需求二、四、五和六。先构思如何分页。

  • 为列表添加 pageSize 属性,表示一页最多有多少条目,用 totalPages = Math.ceil(data.length / pageSize) 计算共多少页。
  • const lookAt = ref(1) 存储用户正在查看的页码。
  • <li v-for="d in sortedData" :key="d.id" > 中的 sortedData 数组改成由 lookAt 控制的子集数组 currentPage
<div class="list"
  @keydown.left.prevent="toPrevPage"       
  @keydown.right.prevent="toNextPage"
>

  <div class="list__btns" v-if="showPagination">
    <button v-if="showFirstLastButtons"
      @click="toFirstPage">首页</button>
    <button @click="toPrevPage">上一页</button>
    <span>
      <button 
        v-for="pageNumber in pageNumbers"
        :key="pageNumber"
        @click="toPage(pageNumber)"
      >{{ pageNumber }}</button>
    </span>
    <button @click="toNextPage">下一页</button>
    <button v-if="showFirstLastButtons"
      @click="toLastPage"
    >尾页</button>
    <span>{{ totalPages }}</span>
  </div>
</div>

<script setup>
const lookAt = ref(1);
const totalPages = computed(() => {
  if (!props.data || props.data.length === 0) return 1;
  return Math.ceil(props.data.length / props.pageSize);
});

const currentPage = computed(() => {
  const start = (lookAt.value - 1) * props.pageSize;
  const end = start + props.pageSize;
  return sortedData.value.slice(start, end);
});

/* 页码栏 */
const showPagination = computed(() => totalPages.value > 1);
const showFirstLastButtons = computed(() => totalPages.value > 2);

// 页码栏中部显示的页码按钮
const pageNumbers = computed(() => {
  if (!showPagination.value) return [];
  const currentPage = lookAt.value;
  const totalPage = totalPages.value;
  
  // 确定起止页码(假设 props.maxBtnNum = 5,startPage _ currentPage _ endPage)
  let startPage = Math.max(1, currentPage - Math.floor(props.maxBtnNum / 2));
  let endPage = Math.min(totalPage, startPage + props.maxBtnNum - 1);
  // startPage _ currentPage endPage → startPage _ _ currentPage endPage
  if (endPage === totalPage && ((endPage - startPage + 1) < props.maxBtnNum))
    startPage = Math.max(1, endPage - props.maxBtnNum + 1);
  // startPage currentPage _ endPage → startPage currentPage _ _ endPage
  if (startPage === 1 && ((endPage - startPage + 1) < props.maxBtnNum)) 
    endPage = Math.min(totalPage, startPage + props.maxBtnNum - 1);
  
  const pages = [];
  for (let i = startPage; i <= endPage; i++) pages.push(i);
  return pages;
});

function toPage(pageNumber) { 
  // 一共就一页,不翻
  if (totalPages.value <= 1) return;
  
  // 不直接用模是因为页码不是 [0 ... totalPages - 1] 而是 [1 ... totalPages]
  if (pageNumber < 1) lookAt.value = totalPages.value;
  else if (pageNumber > totalPages.value) lookAt.value = 1;
  else lookAt.value = pageNumber;
};
function toPrevPage() { toPage(lookAt.value - 1); };
function ToNextPage() { toPage(lookAt.value + 1); };
function toFirstPage() { toPage(1); };
function toLastPage() { toPage(totalPages.value); };

watch(() => props.data, (newData, oldData) => {
  if (lookAt.value > totalPages.value) lookAt.value = totalPages.value;
}, { deep: true });

</script>
  • 页码栏中部的页码按钮通过 v-for 页码值数组 pageNumbers 实现。pageNumbers 是一个以 lookAt 为中心向两侧扩散的数组。
  • maxBtnNum 是一个默认值为 7props,用户通过它控制“在四个特殊按钮的中间最多显示多少个页码”。

最后处理互斥展开需求。PaginatedList 维护 expandedItemId,记录正处于展开状态的条目的 d.id。列表会向所有条目组件提供 requestExpand 函数、展示当前展开的条目 expandedItemId。当组件看到列表公示的 expandedItemId 与自己的 d.id 相同时,组件展开。

  1. 组件上的“展开”按钮被点击时,组件用自己的 d.id 调用 requestExpand 请求展开。
  2. requestExpand 先检查组件传的实参 d.id 是否处于当前页,如果不处于,不予处理。
  3. 否则将 expandedItemId 设为实参。组件发现公示的 expandedItemId 是自己的,于是展开。
const expandedItemId = ref(null); 
provide('currentlyExpandedItemId', expandedItemId);

function requestExpand(itemId) {
  // 检查请求的 itemId 是否存在于当前页
  const existsInCurrentPage = currentPage.value.some(item => item.id === itemId);
  if (existsInCurrentPage) expandedItemId.value = itemId;
};
provide('requestExpand', requestExpand);

// 翻页时自动收起所有项
watch(lookAt, () => { expandedItemId.value = null; });

因为控制组件展开/收起的状态由列表维护,所以组件也要向列表请求收起:

function requestClose(itemId) {
  if (itemId === expandedItemId.value) expandedItemId.value = null;
};
provide('requestClose', requestClose);

至于“保证展开/收起”不影响列表的高度,我的做法是给列表增加 height 属性,控制表格部分的高度。属性值 = ( = ( =(pageSize − 1 ) - 1) 1) × \times × ( ( (未展开时组件高度 + + + 条目间距 0.8rem ) ) ) + + + 展开时组件高度……


效果展示

我们写一个测试用的 plan store,其中有数组 projects,里面都是 project 对象。各 project 对象暂时被硬编码,键值对对应测试用的 ProjectItem 组件。ProjectItem 组件具有展开/收起功能。

// plan store
export const usePlanStore = defineStore("plan", {
  state: () => ({
    // 假设这是初始化/Search 搜到的项目概览数据
    projects: [
      {
        id: 1,
        projectId: 1,
        name: "项目1",
        description: "项目1的描述",
        lastEditedAt: "2023-10-01",
        plans: [
          {
            planId: 1,
            name: "计划1",
            description: "计划1的描述",
            lastEditedAt: "2023-10-01",
          },
          // ...
        ],
      },
      // ...
      {
        id: 10,
        projectId: 10,
        name: "项目10",
        description: "项目10的描述",
        lastEditedAt: "2023-10-10",
        plans: [],
      },
    ],
  }),
  getters: {
    projectsOverview: (state) => {
      return state.projects;
    },
  },
});

ProjectItem 组件的 propsprojects 中任务概览对象的结构完全对应,它使用列表提供的 requestExpandrequestClose 申请展开/收起,根据列表公示的 expandedItemId 与自己的 id 是否一致决定是否展开计划(<div class="project-item__plans" v-if="id === expandedItemId">)。

<template>
  <div class="project-item">
    <div class="project-item__project">
      <div class="project__name">{{ name }}</div>
      <div class="project__description">{{ description }}</div>
      <div class="project__lastEditedAt">{{ lastEditedAt }}</div>
      <div
        class="project__btn"
        v-if="plans.length > 0"
        :class="{ 'project__btn--expanded': id === expandedItemId }"
        @click="toggle"
      >
        <PhCaretDown />
      </div>
    </div>
    <div class="project-item__plans" v-if="id === expandedItemId">
      <div
        class="project-item__plan"
        v-for="plan in plans.slice(0, 2)"
        :key="plan.planId"
      >
        <div class="plan__name">{{ plan.name }}</div>
        <div class="plan__content">{{ plan.description }}</div>
        <div class="plan__lastEditedAt">
          {{ plan.lastEditedAt }}
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { inject } from "vue";
import { PhCaretDown } from "@phosphor-icons/vue";
const props = defineProps({
  name: {
    type: String,
    required: true,
  },
  description: {
    type: String,
    required: true,
  },
  lastEditedAt: {
    type: String,
    required: true,
  },
  id: {
    type: Number,
    required: true,
  },
  plans: {
    type: Array,
    required: true,
  },
});
const requestExpand = inject("requestExpand", null);
const requestClose = inject("requestClose", null);
const expandedItemId = inject("currentlyExpandedItemId", null);
function toggle() {
  if (props.id === expandedItemId.value) {
    requestClose(props.id);
  } else {
    requestExpand(props.id);
  }
}
</script>

在面板中,我们这样使用 PaginatedList

<PaginatedList
  :data="planStore.projectsOverview"
  :item="ProjectItem"
  :empty="{
    title: '空空如也',
    cta: '创建项目',
    action: () => router.push('/create-project'),
  }"
  :pageSize="4"
  height="31.6rem"
/>
<!-- height = 20.2rem + 3 × 3rem + 3 × 0.8rem -->

图-5.3_分页列表

重构

在开发组件、绘制 UI 草稿时,我发现有些代码反复出现。所以……提取一下。

路由小字 EasyRouter

图-5.4_路由小字需求
→ ××”的路由小字将会出现多次,所以我们把它提取出来。传一个 to 回调,开发者可以在回调里编写路由语句,点击路由小字组件时调用 to 回调。

<template>
  <div class="easy-router" @click="act">
    <PhArrowRight size="18" weight="bold" class="easy-router__icon" />
    <p class="easy-router__cta">{{ cta }}</p>
  </div>
</template>

<script setup>
import { PhArrowRight } from "@phosphor-icons/vue";
import { playSound } from "@/utilities/tools.js";
const props = defineProps({
  cta: {
    type: String,
    required: true,
  },
  to: {
    type: Function,
    required: true,
  },
});
function act() {
  playSound("easy-router", "to");
  props.to();
}
</script>

Tabs

图-5.5_Tabs 需求
Tabs 通过属性接受一个 tabs 数组,其中均为 tab 对象,格式为 { text: 你想让按钮上显示的文字, name: 你想路由到的 path 的 name }

<template>
  <div class="tabs">
    <RouterLink
      v-for="tab in tabs"
      class="tab"
      :to="{ name: tab.name }"
      @click="playSound('router-link', 'sub-select')"
      >{{ tab.text }}</RouterLink
    >
  </div>
</template>

<script setup>
import { playSound } from "@/utilities/tools.js";
const props = defineProps({
  tabs: {
    type: Array,
    required: true,
  },
});
</script>

图-5.6_Tabs 效果

返回 Back

很多次级 view 需要能路由回上一级 view。我不想让用户点浏览器自带的后退键。
图-5.7_Back 需求
BackEasyRouter 没区别,一定要说区别的话就是 Back 更简单,固定 router.go(-1)Back 不使用任何 props,如果你想在“返回”后面追加更具体的目标,比如“计划 A”,利用 <slot>

<template>
  <div class="back" @click="goback">
    <PhArrowSquareUpLeft class="back__icon" size="18" weight="fill" />
    <p class="back__text">返回<slot></slot></p>
  </div>
</template>

<script setup>
import { playSound } from "@/utilities/tools";
import { PhArrowSquareUpLeft } from "@phosphor-icons/vue";
import { useRouter } from "vue-router";
const router = useRouter();
function goback() {
  router.go(-1);
  playSound("easy-router", "to");
}
</script>

IconTip

项目里很多按钮或信息会以纯 icon 形式展示,用户的光标悬浮到这些 icon 上时应显示提示。
图-5.8_IconTip 需求

<template>
  <span
    class="icon-tip"
    :class="{ [`icon-tip--${type}`]: true }"
    @mouseenter="isTipVisible = true"
    @mouseleave="isTipVisible = false"
    @focusin="isTipVisible = true"
    @focusout="isTipVisible = false"
  >
    <slot></slot
    ><!-- slot 传递 icon(或外观为 icon 的按钮)本身 -->
    <transition name="fade">
      <div v-if="isTipVisible && text" class="icon-tip__label">
        {{ text }}
      </div>
    </transition>
  </span>
</template>

<script setup>
import { ref } from "vue";
const props = defineProps({
  type: {
    type: String,
    default: "text",
    validator: (value) => {
      return ["text", "primary", "red"].includes(value);
    },
  },
  text: {
    type: String,
    default: "",
  },
});
const isTipVisible = ref(false);
</script>

把需要添加 hover 注释的 HTML 元素用 IconTip 包裹起来,用 type 参数控制样式,用 text 参数控制提示文本。


轻量级编辑组件

轻量级编辑组件包括 DeletorTinkerPinner。它们的“轻量”在于用户只需要输入很少的东西,modal.open 就能满足需求,不需要专门设置路由 path

  • 三个组件都更新了数据库中的数据,前端 store 中的数据也需要同步更新(比如,用 Deletor 删除了某条数据后,store 中的对应数据也该被移除)。所以它们需要接受 store 中的 action 作为回调函数的值。顺带一提——Search 亦然。
    • Deletordeletor(dkey) 通常是一个 actionDeletor 会在调用 deletor 时自动传入要被删除的记录的主键值 dkey,你可以在充当 deletor(dkey)action 里利用这个 dkey 填充路径变量、找到 store 里要被删除的对象。如果你在“删除全部”,可以无视这个 dkey
    • Tinker 没有回调函数参数,但是传给它的 Modal 组件里如果使用了 Btn,要给 Btnsender 传递一个 action,同时在传给 panelProps 的对象中包含这个 action
  • Tinker = Adder + Editor。把轻量级添加和轻量级编辑合并是因为它们确实是一回儿事。如果你的记性不错,我们在博客“二、数据通讯(上)”里把 Field 的添加归约进了编辑——添加不过是 origin 为空串的编辑。

Tinker

Tinker 接收五个参数,typepanelComponentpanelPropswidthheight

  • type 可以取 addereditorpencil。前两种取值显示为按钮样式,决定按钮上的文本是“添加”还是“编辑”。当 typepencil 时显示为铅笔 icon 样式。type 只决定 Tinker 的样式,不影响行为。
  • panelComponent 要传递你希望点击按钮后弹出的 Modal 组件,panelProps 传递那个 Modal 组件需要的 props 对象。
  • typeaddereditor 时,widthheight 正常工作;当 typepencil 时,icon 的宽度 = 高度 = width。顺带一提——Deletortypeicon 时也一样。
  • 提醒:如果你的 Modal 用于编辑,你大概需要在 Modal 里设置一个接收被编辑数据的主键的 propspanelProps 里也记得传。这个主键值 tkey 会用来拼接 PATCH /api/××/{tkey}
<template>
  <div class="tinker" :class="{ [`tinker--${type}`]: true }">
    <button
      class="tinker__trigger"
      v-if="type === 'adder' || type === 'editor'"
      @click="
        modal.open({
          component: panelComponent,
          props: {
            ...panelProps,
            enableBackdropClose: false,
          },
        })
      "
    >
      {{ type === "adder" ? "添加" : "编辑" }}
    </button>
    <IconTip v-else text="编辑"
      ><PhNotePencil
        class="tinker__trigger"
        @click="
          modal.open({
            component: panelComponent,
            props: {
              ...panelProps,
              enableBackdropClose: false,
            },
          })
        "
    /></IconTip>
  </div>
</template>

<script setup>
import { useModalStore } from "@/stores/modal.js";
import { PhNotePencil } from "@phosphor-icons/vue";
import IconTip from "@/components/IconTip.vue";
const modal = useModalStore();
const props = defineProps({
  panelComponent: {
    type: [Object, Function, String],
    required: true,
  },
  panelProps: {
    type: Object,
    default: () => ({}),
  },
  type: {
    type: String,
    default: "adder",
    validate: (value) => {
      return ["adder", "editor", "pencil"].includes(value);
    },
  },
  width: {
    type: String,
    default: "9.6rem",
  },
  height: {
    type: String,
    default: "3.8rem",
  },
});
</script>

Pinner

置顶/取消置顶只有 icon 样式的按钮。需要接收试图更新置顶状态的条目的主键 pkey 和当下的置顶状态 isPinned。会调用 pin(pkey) 置顶、调用 unPin(pkey) 取消置顶。这两个回调函数也是来自 actions,编写对应 action 时可以直接利用 pkey 填充路径变量、更新 store 中对应对象的置顶状态。

<template>
  <div class="pinner">
    <IconTip :text="isPinned ? '取消置顶' : '置顶'">
      <PhPushPinSlash
        v-if="isPinned"
        class="pinner__icon"
        @click="unPin(pkey)"
        weight="bold"
      />
      <PhPushPin v-else class="pinner__icon" @click="pin(pkey)" weight="bold" />
    </IconTip>
  </div>
</template>

<script setup>
import { PhPushPin, PhPushPinSlash } from "@phosphor-icons/vue";
import IconTip from "@/components/IconTip.vue";
const props = defineProps({
  pkey: {
    type: Number,
    required: true,
  },
  isPinned: {
    // 与 store 中的状态对接
    type: Boolean,
    required: true,
  },
  pin: {
    // 通常来自 store 的 action,既负责改变数据库里置顶状态,也负责请求成功后更新 store
    // pin(pkey),pkey 由 Pinner 自动传入,于是 action 里可以使用 pkey 填充路径变量
    type: Function,
    required: true,
  },
  unPin: {
    // 通常来自 store 的 action,既负责改变数据库里置顶状态,也负责请求成功后更新 store
    // unPin(pkey)
    type: Function,
    required: true,
  },
  size: {
    type: String,
    default: "2.4rem",
  },
});
</script>

头像框与头像框群 Avatar/AvatarGroup

图-5.9_头像需求
回顾 UI 草稿。想达成“人格仓库”这一页的渲染效果,我的想法是:

  1. 展示所有人格(下图黑色“分发线”):

    • personality store 中有 Map personalities,其键值对格式为 personalityId: { 人格详细信息 }。有一个 getter personalities,所有 AvatarGroup 都利用这个 getter 获取包含所有人格信息的 Map
    • Avatarprops personalityAvatarGroup 对着 personalities v-for,把其中的各个人格信息对象传给子 AvatarAvatar 据此知道自己要展示的人格。
  2. 选定人格:

    • Avatar 追加“已选中”装饰类(下图红色“广播线”):
      • AvatarGroup 设置 props selectedId,这个 props 在不同位置的 AvatarGroup 中会与不同 store 中的不同 getter 绑定,据此获得在某份设置中选中的人格的主键。
      • Avatar 设置 props selectedId,store getterAvatarGroupselectedId propsAvatarselectedId props 直传,相当于 AvatarGroup 把选中的人格主键“广播”给了各 Avatar
      • Avatar 追加“已选中”装饰类    ⟺    \iff personality.personalityId(负责渲染的人格对象的主键) === selectedId(store 逐级广播来的主键)。
    • 改选(下图蓝色“改选线”,包括实线和虚线):
      • AvatarGroup 添加 whenSelect(personalityId) 回调 propsAvatarGroup 对各子 Avatar 提供 requestSelect(personalityId) 函数,当 Avatar 被点击时,调用 requestSelect(personality.personalityId)requestSelect(personality) 中调用 whenSelect(personalityId)whenSelect(personalityId) 是 store 中的特定 action。被调用的 action 负责请求后端、修改对应设置、请求成功后更新对应 store 中的设置信息。
      • 注:对于项目和计划设置,我们在更新数据库设置信息时需要知道项目和计划的主键。我们在 plan store 里维护一个 currentProjectcurrentPlan 对象,whenSelect 函数体内直接利用相关 gettercurrent×× 中获取项目和计划的主键。
      • 注:因为我们一直都在利用 store 中的状态渲染信息,也一直都只用 action 修改状态值,所以不需要手动控制渲染更新
  3. 其他操作:

    • Avatar 根据 personality.isOfficial 决定是否在右下角显示 TinkerDeletor,以及官方头像的特殊样式。
    • AvatarGroup 中添加 readyOnly propsAvatar 中亦然,再次利用广播方案,让所有 Avatar 知道在这个 AvatarGroup 中所有人格都不可修改,不在右下角显示 TinkerDeletor
    • 只有人格仓库页面的 AvatarGroup 允许修改人格(readyOnly = false),所以接下来我们只考虑与 personality store 的交互。
    • Avatarpersonality(或者 personality.personalityId) 分别传给 TinkerDeletor,二者调用 personality store 中专用的 action,发送请求、请求成功后更新 personalities Map 中的特定人格的信息。
    • 用于删除的 action 要注意——如果用户删除的是当前选中的人格,需要在删除后自动把 selectedPersonalityId 改回一个默认值(比如 1,对应永远不会被删除或更改的“诤略参谋”人格)。
    • 图像加载功能可以让 Avatar 里的 <img /> 拿着 personality.personalityId 自行请求,或是使用其他方案……这一部分留给 w_x_yao 完成。所以 Avatar 是个半成品。

下图是“人格仓库”(Personalities.vue)里的 AvatarGroup 配置(省略了 readOnly 等简单细节):
图-5.10_人格仓库 AvatarGroup
下图是“项目设置”里的 AvatarGroup 配置,有差别的是广播线和改选线末端的 store 及其 getteraction
图-5.11_项目设置 AvatarGroup
代码本身倒是远比思路简单,短得我都不习惯了……

AvatarGroup

<template>
  <div class="avatar-group">
    <Avatar
      v-for="personality in personalities"
      :key="personality.personalityId"
      :personality="personality"
      :selectedId="selectedId"
      :readOnly="readOnly"
    />
  </div>
</template>

<script setup>
import Avatar from "@/components/Avatar.vue";
import { provide } from "vue";
const props = defineProps({
  personalities: {
    type: Object,
    required: true,
  },
  selectedId: {
    type: Number,
    required: true,
  },
  whenSelect: {
    type: Function,
    default: (personalityId) => {},
  },
  readOnly: {
    type: Boolean,
    default: false,
  },
  column: {
    type: Number,
    default: 6,
  },
});
function requestSelect(personalityId) {
  props.whenSelect(personalityId);
}
provide("requestSelect", requestSelect);
</script>

Avatar

<template>
  <div
    class="avatar"
    :class="{
      'avatar--selected': personality.personalityId === selectedId,
      'avatar--official': personality.isOfficial,
    }"
  >
    <img
      class="avatar__photo"
      src="../../public/avatars/critistrat.png"
      @click="onSelect"
    />
    <!-- TODO Avatar 是半成品,未实现图片加载功能(留给 w_x_yao),这里的图片仅用作占位符,调用这个函数只是为了观察对不同色调图片的适配程度 -->
    <div class="avatar__name" @click="onSelect">
      {{ personality.name }}
    </div>
    <div v-if="!readOnly && !personality.isOfficial" class="avatar__tools">
      <Tinker
        :panelComponent="Register"
        :panelProps="{}"
        type="pencil"
        width="2.4rem"
        position="top"
      />
      <Deletor
        :dkey="personality.personalityId"
        type="icon"
        :title="personality.name"
        :deletor="
          () => {
            console.log(
              '仅供测试,请 w_x_yao 完成此回调函数(personality store 中的删除 action)'
            );
          }
        "
        width="2.4rem"
        position="top"
      />
    </div>
    <div v-else-if="personality.isOfficial" class="avatar__tools">
      <IconTip text="官方人格" position="top"
        ><PhLightning size="2.4rem"
      /></IconTip>
    </div>
  </div>
</template>

<script setup>
import { inject } from "vue";
import { PhSealCheck, PhLightning } from "@phosphor-icons/vue";
import IconTip from "./IconTip.vue";
import Tinker from "@/components/Tinker.vue";
import Deletor from "@/components/Deletor.vue";
import Register from "@/views/Register.vue"; // TODO 之后把它换成 EditPersonality Modal,现在仅供测试
import { playSound } from "@/utilities/tools.js";
const props = defineProps({
  personality: {
    type: Object,
    required: true,
  },
  selectedId: {
    type: Number,
    required: true,
  },
  readOnly: {
    type: Boolean,
    default: false,
  },
});
const requestSelect = inject("requestSelect", () => {});
function onSelect() {
  requestSelect(props.personality.personalityId);
  playSound("avatar", "selected");
}
</script>

测试一下,在 user store 中加入 me.userSetting.globalPersonality 和用于获取它的 getter myGlobalPersonality 和用于修改它的 action updateGlobalPersonality,然后仅需五行代码(其实一行也行)——

<AvatarGroup
  :personalities="personality.personalities"
  :selectedId="user.myGlobalPersonality"
  :whenSelect="user.updateGlobalPersonality"
/>

就可以(文本、图像等未确定,下图仅为测试)——

图-5.12_AvatarGroup 测试

档位 Slider

图-5.13_Slider 需求
我们需要一个带档位的滑动条:

  • 外部传入 levels 数组,其中是格式为 { id: 档位主键, label: 档位名, bubble: 档位简介 } 的格式,比如 [{ id: 'slow', label: '慢速档', bubble: '适合新手' }, ...]
  • 外部传入 originLevelId,组件加载后默认选中的档位的主键。
  • 外部传入 whenLevelChange(levelId) 钩子,当档位变更时,Slider 自动调用此钩子,把最后选中的档位的主键值传入其中。

首先在轨道上等间距绘制菱形档位。levelPositionsPercent 是一个数组,对应 levels 中各档位在轨道上的位置(以轨道左端点为起点,处于轨道长度 levePositionsPercent[i]% 的位置上)。我们留出轨道两端 5% 的空间,只使用中间的 90%,否则会有菱形档位和对应标签严格位于轨道端点上,不利于与其他组件对齐。我们根据 levels 中档位数量自动均分这 90% 的宽度。

const levelPositionsPercent = computed(() => {
  if (!props.levels || props.levels.length === 0) return [];
  if (props.levels.length === 1) return [50];

  return props.levels.map(
    (_, index) => (index / (props.levels.length - 1)) * 90 + 5
  );
});

我们利用 v-for 和绝对定位(position: absoluteleft: levelPositionsPercent[index]%)在轨道上绘制这些档位。

<div
  class="slider__notch"
  v-for="(level, index) in levels"
  :class="{
    'slider__notch--nearby':
    level.id === nearByLevelId && level.id !== currentSelectedLevelId,
    'slider__notch--selected': level.id === currentSelectedLevelId,
    }"
  :key="level.id"
  :style="{ left: `${levelPositionsPercent[index]}%` }"
  @click="selectLevelByClick(level.id)"
>
  <div class="notch__label">{{ level.label }}</div>
  <div class="notch__diamond"></div>
</div>

之后实现拖拽滑块换挡。我们用 isDragging 记录用户当前是否正在拖动滑块。

  1. 鼠标在滑块上按下时调用 handleThumbMouseDown
<div
   class="slider__thumb"
   :style="{ left: `${thumbPositionPercent}%` }"
   @mousedown.prevent="handleThumbMouseDown"
></div>
  1. handleThumbMouseDown 标记拖拽过程开始(isDragging.value = true),记录拖拽开始时(即鼠标在滑块上按下时)鼠标的初始水平坐标 dragStartX 和滑块的位置 dragStartThumbPercent。为 window 添加真正的“拖拽”和松开鼠标的监听器。
const isDragging = ref(false); // 用户是否正在拖拽滑块
let dragStartX = 0; // 拖拽开始时鼠标在屏幕上的位置(px)
let dragStartThumbPercent = 0; // 拖拽开始时滑块在轨道上的位置(百分比)

// 按下滑块时调用,拖拽操作的起点
const = (event) => {
  if (props.levels.length === 0) return; // 如果根本没有档位数据就不处理拖拽了

  isDragging.value = true; // 标记:开始拖拽!
  dragStartX = event.clientX; // 记录鼠标按下的初始坐标
  dragStartThumbPercent = thumbPositionPercent.value; // 记录滑块开始拖拽时的位置

  // 在 window 对象上添加事件监听器
  // 加在 window 上是因为鼠标拖拽时可能会移出小滑块,甚至移出浏览器——如果只在小滑块上监听 mousemove,一旦鼠标移出去,拖拽就断了
  window.addEventListener("mousemove", handleThumbMouseMove);
  window.addEventListener("mouseup", handleThumbMouseUp);
};
  1. 用户刚刚只是在滑块上按下了鼠标键,还没有拖拽,但我们已经设置了拖拽监听器 handleThumbMouseMove。用户现在正式开始拖拽,拖拽处理函数被高频调用。
    1. event.clientX 获取鼠标在当下的水平坐标,与按下鼠标时记录的初始坐标作差,得到鼠标的水平位移。用此位移除轨道宽度,将单位从像素转为百分比。
    2. 把位移追加到 thumbPositionPercent,于是滑块渲染更新,达成拖拽的视觉效果。
    3. 档位的邻域半径 nearByThreshold 是间距的四分之一,当滑块落入若干档位的邻域中时,将 nearByLevelId.value 设为这些档位里距离滑块最近的档位的主键。通过 :class="{ 'slider__notch--nearby': level.id === nearByLevelId && level.id !== currentSelectedLevelId }" 实现“滑块靠近档位时档位应用特殊样式”效果。
// 滑块当前在轨道上的位置(百分比),比如 50 表示在轨道正中间
const thumbPositionPercent = ref(0);
// 落入滑块邻域的档位对应的主键值
const nearByLevelId = ref(null);

// 在 window 上的拖拽过程里调用
const handleThumbMouseMove = (event) => {
  if (!isDragging.value) return;

  // 阻止默认行为(比如拖拽时不要选中文字)
  event.preventDefault();
  const currentX = event.clientX; // 鼠标坐标
  const trackRect = sliderTrackRef.value.getBoundingClientRect(); // 轨道尺寸
  if (!trackRect.width) return;

  const deltaX = currentX - dragStartX; // 此刻拖拽了多远(px)
  const deltaPercent = (deltaX / trackRect.width) * 100;

  // 更新滑块位置
  const newThumbPosition = Math.max(
    0,
    Math.min(100, dragStartThumbPercent + deltaPercent)
  );
  thumbPositionPercent.value = newThumbPosition;

  /* 为滑块附近的档位(菱形 + label)设置特殊样式 */
  if (props.levels.length > 1) {
    const averageSpace = 90 / (props.levels.length - 1);
    const nearByThreshold = averageSpace / 4;

    let closestDist = Infinity;
    let tempNearById = null;

    levelPositionsPercent.value.forEach((pos, index) => {
      const dist = Math.abs(newThumbPosition - pos);
      if (dist < nearByThreshold && dist < closestDist) {
        closestDist = dist;
        tempNearById = props.levels[index].id;
      }
    });
    nearByLevelId.value = tempNearById;
  } else if (props.levels.length === 1) {
    const nearByThreshold = 15; // 单个档位时,滑块进入 [ 50-15, 50+15 ] 后档位应用特殊样式
    if (Math.abs(newThumbPosition - 50) < nearByThreshold)
      nearByLevelId.value = props.levels[0].id;
    else nearByLevelId.value = null;
  } else nearByLevelId.value = null; // 没有档位则不进行高亮
};
  1. 用户松手,handleThumbMouseUp 被调用。用户松手时滑块通常不会对齐档位,我们自动把它吸附到最近的档位上。
    1. finalizeSelection 找到松手时距离滑块最近的档位,调用 updateThumbAndSelection
    2. updateThumbAndSelection 把滑块的位置更新为 finalizeSelection 选定的档位的位置,实现吸附效果。它还会把轨道下方气泡中的介绍文本 currentSelectedLevelBubble 换成该档位的介绍文本、调用 whenLevelChange 钩子。
    3. whenLevelChange 钩子通常与 store 里的 action 绑定。
// 当鼠标松开时调用 (mouseup),是拖拽操作的终点
const handleThumbMouseUp = () => {
  if (!isDragging.value) return;
  // 拖拽结束,移除监听器
  window.removeEventListener("mousemove", handleThumbMouseMove);
  window.removeEventListener("mouseup", handleThumbMouseUp);
  // 完成最后的吸附和回调
  finalizeSelection();
};
// 鼠标松开导致拖拽结束后最终确定选中的档位(“吸附”、触发回调)
const finalizeSelection = () => {
  // 1. 找到离滑块当前位置最近的那个档位
  const { closestLevelIndex, closestLevelId } = findClosestLevel(
    thumbPositionPercent.value
  );

  // 2. 如果找到了,调用 updateThumbAndSelection 把滑块精确吸附到那个档位上
  if (closestLevelIndex !== -1) {
    updateThumbAndSelection(closestLevelId, true);
  }
  // 标记拖拽结束
  isDragging.value = false;
  nearByLevelId.value = null;
};
// 更新滑块的位置,可选地更新当前选中的档位 id、调用回调函数
// Slider 在加载时也会自动调用此函数初始化滑块,那种时候我们不希望调用回调函数,所以设计了第二个形参
const updateThumbAndSelection = (levelId, triggerCallback = false) => {
  // 找到要对齐的档位在 props.levels 数组中的索引
  const levelIndex = props.levels.findIndex((l) => l.id === levelId);
  if (levelIndex !== -1 && levelPositionsPercent.value.length > levelIndex) {
    // 把滑块的位置更新到这个档位的精确位置上
    thumbPositionPercent.value = levelPositionsPercent.value[levelIndex];

    const oldSelectedLevelId = currentSelectedLevelId.value;
    currentSelectedLevelId.value = levelId;
    currentSelectedLevelBubble.value = props.levels[levelIndex]?.bubble || null;

    if (triggerCallback && oldSelectedLevelId !== levelId) {
      playSound("slider", "change");
      props.whenLevelChange(props.levels[levelIndex].id);
    }
  }
};

图-5.14_Slider 测试

至此,我们已经用这些组件建成了一层较为完整的抽象层。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值