我们已经在前四篇博客里讨论了 FormWrapper
、Field
、Txt
、Btn
、TaskBtn
、Search
和 Deletor
等组件。接下来我们会改进旧组件、开发新组件,用这些组件组成抽象层。未来我们会利用此抽象层快速开发业务功能。
- 作者:魂兮归乡(CSDN:@theColumnSpace)
- 为获取最好的阅读体验,建议在飞书平台阅读此文:五、可复用组件
- 如果要利用这些组件开发功能,请阅读:四、组件/教程文档
目录
FormWrapper
、Field
、Btn
及其变种
严重问题
我后知后觉地发现 Btn
有严重漏洞——如果我在响应到来前就路由走了,Btn
会被卸载,响应到来后就不会调用 whenReady
。StdResponse.code
、StdResponse.userVisibilty
和 StdResponse.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"
/>
还有一个问题——大部分 POST
、PATCH
和 DELETE
请求都不要求 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
可以分为两种情况:
- 下有一个
Btn
。这种情况下我们对着任意一个Field
按回车时都模拟点击这个Btn
即可。 - 下有多个
Btn
。这种情况下无法自动确认对着某个Field
按回车键时该模拟点击哪个Btn
。
先解决第二种情况。
- 在
Field
的<input />
上监听回车键抬起的事件,触发'enter-pressed'
事件。
<input @keyup.enter="simulateClickBtn" />
const emit = defineEmits(['enter-pressed']);
function simulateClickBtn() { emit("enter-pressed"); }
- 暴露
Btn
的send
方法(defineExpose({ send })
)。 - 在表单中监听
Field
的'enter-pressed'
事件,事件发生时调用对应Btn
的send
方法。以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"
>
再解决第一种情况。
- 为
FormWrapper
添加autoBindEnter
props
,默认为true
,当同一个FormWrapper
下有多个Btn
时,必须将autoBindEnter
设为false
。 FormWrapper
向Btn
提供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 草稿对多页列表提出了以下需求:
- 列表中的条目是其他
.vue
里定义好的组件,这些组件通过props
获取要渲染的数据。 - 可以自定义列表的一页至多包含几个条目。
- 其中一种组件可以展开/收起。列表里在同一页上显示的所有这种组件在同一时刻最多只有一个展开,这个展开了其他的就收起了。展开/收起不应该影响列表的高度。
- 底部有页码栏,一般状态下显示“首页”“上一页”“下一页”“尾页”按钮,四个按钮中间是当前页码和前后几个临近的页码, 你也可以直接点那几个页码。
- 只有一页时不显示页码栏,只有两页时不显示“首页”和“尾页”按钮。
- 有多页时可以按
←
→
键翻动页面,如果到了尽头还在继续翻,会循环回来,像是模运算一样。 - 可以自定义条目的排序方式。
- 列表为空时显示
Empty
提示。
先解决需求一和八。GlobalModalWrapper
已经玩过 <component :is>
的把戏了。
- 假设
PaginatedList
内的条目是X
组件的样式。为PaginatedList
设置item
属性,将X
组件传给item
。我们在PaginatedList
里v-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
是一个默认值为7
的props
,用户通过它控制“在四个特殊按钮的中间最多显示多少个页码”。
最后处理互斥展开需求。PaginatedList
维护 expandedItemId
,记录正处于展开状态的条目的 d.id
。列表会向所有条目组件提供 requestExpand
函数、展示当前展开的条目 expandedItemId
。当组件看到列表公示的 expandedItemId
与自己的 d.id
相同时,组件展开。
- 组件上的“展开”按钮被点击时,组件用自己的
d.id
调用requestExpand
请求展开。 requestExpand
先检查组件传的实参d.id
是否处于当前页,如果不处于,不予处理。- 否则将
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
组件的 props
与 projects
中任务概览对象的结构完全对应,它使用列表提供的 requestExpand
和 requestClose
申请展开/收起,根据列表公示的 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 -->
重构
在开发组件、绘制 UI 草稿时,我发现有些代码反复出现。所以……提取一下。
路由小字 EasyRouter
“→ ××
”的路由小字将会出现多次,所以我们把它提取出来。传一个 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
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>
返回 Back
很多次级 view
需要能路由回上一级 view
。我不想让用户点浏览器自带的后退键。
Back
和 EasyRouter
没区别,一定要说区别的话就是 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 上时应显示提示。
<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
参数控制提示文本。
轻量级编辑组件
轻量级编辑组件包括 Deletor
、Tinker
和 Pinner
。它们的“轻量”在于用户只需要输入很少的东西,modal.open
就能满足需求,不需要专门设置路由 path
。
- 三个组件都更新了数据库中的数据,前端 store 中的数据也需要同步更新(比如,用
Deletor
删除了某条数据后,store 中的对应数据也该被移除)。所以它们需要接受 store 中的action
作为回调函数的值。顺带一提——Search
亦然。Deletor
的deletor(dkey)
通常是一个action
。Deletor
会在调用deletor
时自动传入要被删除的记录的主键值dkey
,你可以在充当deletor(dkey)
的action
里利用这个dkey
填充路径变量、找到 store 里要被删除的对象。如果你在“删除全部”,可以无视这个dkey
。Tinker
没有回调函数参数,但是传给它的 Modal 组件里如果使用了Btn
,要给Btn
的sender
传递一个action
,同时在传给panelProps
的对象中包含这个action
。
Tinker
=Adder
+Editor
。把轻量级添加和轻量级编辑合并是因为它们确实是一回儿事。如果你的记性不错,我们在博客“二、数据通讯(上)”里把Field
的添加归约进了编辑——添加不过是origin
为空串的编辑。
Tinker
Tinker
接收五个参数,type
、panelComponent
、panelProps
、width
和 height
。
type
可以取adder
、editor
或pencil
。前两种取值显示为按钮样式,决定按钮上的文本是“添加”还是“编辑”。当type
为pencil
时显示为铅笔 icon 样式。type
只决定Tinker
的样式,不影响行为。panelComponent
要传递你希望点击按钮后弹出的 Modal 组件,panelProps
传递那个 Modal 组件需要的props
对象。- 当
type
为adder
或editor
时,width
和height
正常工作;当type
为pencil
时,icon 的宽度 = 高度 =width
。顺带一提——Deletor
的type
为icon
时也一样。 - 提醒:如果你的 Modal 用于编辑,你大概需要在 Modal 里设置一个接收被编辑数据的主键的
props
,panelProps
里也记得传。这个主键值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
回顾 UI 草稿。想达成“人格仓库”这一页的渲染效果,我的想法是:
-
展示所有人格(下图黑色“分发线”):
personality
store 中有Map
personalities
,其键值对格式为personalityId: { 人格详细信息 }
。有一个getter
personalities
,所有AvatarGroup
都利用这个getter
获取包含所有人格信息的Map
。Avatar
有props
personality
,AvatarGroup
对着personalities
v-for
,把其中的各个人格信息对象传给子Avatar
,Avatar
据此知道自己要展示的人格。
-
选定人格:
Avatar
追加“已选中”装饰类(下图红色“广播线”):- 给
AvatarGroup
设置props
selectedId
,这个props
在不同位置的AvatarGroup
中会与不同 store 中的不同getter
绑定,据此获得在某份设置中选中的人格的主键。 - 给
Avatar
设置props
selectedId
,storegetter
→AvatarGroup
的selectedId
props
→Avatar
的selectedId
props
直传,相当于AvatarGroup
把选中的人格主键“广播”给了各Avatar
。 Avatar
追加“已选中”装饰类 ⟺ \iff ⟺personality.personalityId(负责渲染的人格对象的主键) === selectedId
(store 逐级广播来的主键)。
- 给
- 改选(下图蓝色“改选线”,包括实线和虚线):
- 给
AvatarGroup
添加whenSelect(personalityId)
回调props
。AvatarGroup
对各子Avatar
提供requestSelect(personalityId)
函数,当Avatar
被点击时,调用requestSelect(personality.personalityId)
。requestSelect(personality)
中调用whenSelect(personalityId)
,whenSelect(personalityId)
是 store 中的特定action
。被调用的action
负责请求后端、修改对应设置、请求成功后更新对应 store 中的设置信息。 - 注:对于项目和计划设置,我们在更新数据库设置信息时需要知道项目和计划的主键。我们在
plan
store 里维护一个currentProject
和currentPlan
对象,whenSelect
函数体内直接利用相关getter
从current××
中获取项目和计划的主键。 - 注:因为我们一直都在利用 store 中的状态渲染信息,也一直都只用
action
修改状态值,所以不需要手动控制渲染更新。
- 给
-
其他操作:
Avatar
根据personality.isOfficial
决定是否在右下角显示Tinker
和Deletor
,以及官方头像的特殊样式。AvatarGroup
中添加readyOnly
props
,Avatar
中亦然,再次利用广播方案,让所有Avatar
知道在这个AvatarGroup
中所有人格都不可修改,不在右下角显示Tinker
和Deletor
。- 只有人格仓库页面的
AvatarGroup
允许修改人格(readyOnly = false
),所以接下来我们只考虑与personality
store 的交互。 Avatar
将personality
(或者personality.personalityId
) 分别传给Tinker
和Deletor
,二者调用personality
store 中专用的action
,发送请求、请求成功后更新personalities
Map
中的特定人格的信息。- 用于删除的
action
要注意——如果用户删除的是当前选中的人格,需要在删除后自动把selectedPersonalityId
改回一个默认值(比如1
,对应永远不会被删除或更改的“诤略参谋”人格)。 - 图像加载功能可以让
Avatar
里的<img />
拿着personality.personalityId
自行请求,或是使用其他方案……这一部分留给 w_x_yao 完成。所以Avatar
是个半成品。
下图是“人格仓库”(Personalities.vue
)里的 AvatarGroup
配置(省略了 readOnly
等简单细节):
下图是“项目设置”里的 AvatarGroup
配置,有差别的是广播线和改选线末端的 store 及其 getter
、action
:
代码本身倒是远比思路简单,短得我都不习惯了……
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"
/>
就可以(文本、图像等未确定,下图仅为测试)——
档位 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: absolute
、left: 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
记录用户当前是否正在拖动滑块。
- 鼠标在滑块上按下时调用
handleThumbMouseDown
。
<div
class="slider__thumb"
:style="{ left: `${thumbPositionPercent}%` }"
@mousedown.prevent="handleThumbMouseDown"
></div>
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);
};
- 用户刚刚只是在滑块上按下了鼠标键,还没有拖拽,但我们已经设置了拖拽监听器
handleThumbMouseMove
。用户现在正式开始拖拽,拖拽处理函数被高频调用。- 用
event.clientX
获取鼠标在当下的水平坐标,与按下鼠标时记录的初始坐标作差,得到鼠标的水平位移。用此位移除轨道宽度,将单位从像素转为百分比。 - 把位移追加到
thumbPositionPercent
,于是滑块渲染更新,达成拖拽的视觉效果。 - 档位的邻域半径
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; // 没有档位则不进行高亮
};
- 用户松手,
handleThumbMouseUp
被调用。用户松手时滑块通常不会对齐档位,我们自动把它吸附到最近的档位上。finalizeSelection
找到松手时距离滑块最近的档位,调用updateThumbAndSelection
。updateThumbAndSelection
把滑块的位置更新为finalizeSelection
选定的档位的位置,实现吸附效果。它还会把轨道下方气泡中的介绍文本currentSelectedLevelBubble
换成该档位的介绍文本、调用whenLevelChange
钩子。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);
}
}
};
至此,我们已经用这些组件建成了一层较为完整的抽象层。