座位组件
将使用Vue 3
创建一个座位组件。座位组件是一个常见的UI组件,用于显示座位信息,并允许用户选择或取消选择座位。该组件是通过js
去创建的,没有类型提示,所以加入了一部分jsdoc
,使编辑器可以识别类型
组件不太美观!!!
1、创建页面
这个组件不依赖于脚手架,所以选择创建一个html
文件,导入vue.global.js
在文件根目录下创建index.html
文件,并写入以下内容
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
http-equiv="x-ua-compatible"
content="ie=edge"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0 ,user-scalable=no"
/>
<title>document</title>
<script src="http://tuchuang.wxsushang.com/2023/12/05/f5e8d377589b9.js"></script>
<link
rel="stylesheet"
href="./css/index.css"
/>
<script src="./js/vue.global.js"></script>
<style type="text/css">
* {
margin: 0;
padding: 0;
}
body {
-webkit-tap-highlight-color: transparent;
}
[v-clack] {
display: none;
}
</style>
</head>
<body>
<div id="app"></div>
</body>
<script type="module">
const { createApp, setup,defineComponent} = Vue;
const data = defineComponent({
setup() {
return {
};
},
});
const app = createApp(data);
app.mount('#app');
</script>
</html>
2、创建js/index.js 文件
写入以下代码
import _Seat, { seatProps } from './Seat.js';
import { withInstall } from './utils.js';
const Seat = withInstall(_Seat);
export { Seat, seatProps };
export * from './utils.js';
export * from './constant.js';
3、创建js/contant.js 文件
const SEAT_STATE = {
NULL: 0, // 空,没位置
IDLE: 'available', //可选
SELECTED: 'selected', // 已选
LOCKED: 'unavailable', //坏了
};
const camelizeRE = /-(\w)/g;
export { SEAT_STATE, camelizeRE };
4、创建js/utils.js文件
4.1 编写withInstall函数
作用: 为组件添加安装方法
function withInstall(options) {
options.install = (app) => {
const { name } = options;
if (name) {
app.component(name, options);
app.component(camelize(`-${name}`), options);
}
};
return options;
}
4.2 编写 camelize 函数
作用: 将一个以连字符分隔的字符串转换为驼峰命名法(Camel Case)的字符串
const camelizeRE= /-(\w)/g;
const camelize = (str) => str.replace(camelizeRE, (_, c) => c.toUpperCase());
4.3 编写createNameSpace 函数
作用: 创建命名空间
这个函数在创建组件的时候有用,生成组件名
function createNameSpace(name) {
const prefixedName = `n-${name}`;
return [prefixedName, createBEM(prefixedName)];
}
4.4 编写 createBEM 函数
作用: 创建一个生成BEM类名的函数
function createBEM(name) {
return (el, mods) => {
if (el && typeof el !== 'string') {
mods = el;
el = '';
}
el = el ? `${name}__${el}` : name;
return `${el}${genBem(el, mods)}`;
};
}
4.5 编写 genBem 函数
作用: 生成BEM类名
function genBem(name, mods) {
if (!mods) {
return '';
}
if (typeof mods === 'string') {
return ` ${name}--${mods}`;
}
if (Array.isArray(mods)) {
return mods.reduce((ret, item) => ret + genBem(name, item), '');
}
return Object.keys(mods).reduce(
(ret, key) => ret + (mods[key] ? genBem(name, key) : ''),
''
);
}
4.6 编写makeStringProp和makeArrayProp函数
/**
* 创建一个用于Vue字符串属性的定义函数
* @param {string} str - 用于定义属性的初始值
* @returns {object} - 包含Vue属性类型的对象
*/
function makeStringProp(str) {
return { type: String, default: str };
}
/**
* 创建一个数组类型的属性
* @returns {object} 包含type为Array和default为函数的对象
*/
function makeArrayProp() {
return { type: Array, default: () => [] };
}
4.7 编写toArray函数
作用: 将字符串转换成数组
function toArray(str) {
if (typeof str !== 'string') {
throw new Error('function arguments must be string');
}
return str.split(',').filter(Boolean);
}
4.8 编写 createSeat 函数
作用: 创建座位数组
/**
* @description 座位类型
* @typedef {Object} Seat
* @property {string | null} [title] - 座位标题
* @property {Boolean} choose - 是否选中
* @property {Number} xCoordinate - x坐标
* @property {Number} yCoordinate - y坐标
* @property {Number} number - 座位号
* @property {String | null} src - 座位图片
* @property {String} type - 座位类型
*/
/**
* 创建座位
* @param {Array<string | Number>|string} horizontal - 水平
* @param {Array<string | Number>|string} vertical - 垂直
* @returns {Array<Seat>} - 座位数组
* @throws {Error} - 如果horizontal和vertical没有同时为数组或字符串则抛出错误
*/
function createSeat(horizontal, vertical, title = null) {
if (typeof horizontal === 'undefined' || typeof vertical === 'undefined') {
throw new Error(
'Function arguments horizontal and vertical cannot be undefined'
);
}
if (typeof horizontal === 'string') {
horizontal = toArray(horizontal);
} else if (!Array.isArray(horizontal)) {
throw new Error(
'Function argument horizontal must be a string or an array'
);
}
if (typeof vertical === 'string') {
vertical = toArray(vertical);
} else if (!Array.isArray(vertical)) {
throw new Error('Function argument vertical must be a string or an array');
}
/**
* @type {Array<Seat>}
*/
const seats = [];
for (let x = 0; x < horizontal.length; x++) {
for (let y = 0; y < vertical.length; y++) {
const seat = {
id: x * vertical.length + y + 1,
title,
choose: false,
xCoordinate: x + 1,
yCoordinate: y + 1,
number: x * vertical.length + y + 1,
type: SEAT_STATE.IDLE,
src: null,
};
seat.choose = seat.type == SEAT_STATE.SELECTED;
seats.push(seat);
}
}
return seats;
}
function isArray(obj) {
return Array.isArray(obj);
}
5、创建js/hooks.js文件
useSeat
钩子函数提供了处理座位选择和相关操作的功能,返回了一些用于在组件中使用的属性和方法
返回的属性和方法
seats
:Vue ref 对象,保存着一个座位对象数组selected
:通过筛选 seats 数组来返回一个已选择的座位数组,并按照它们的 ID 进行排序。updateSeat
:接受座位对象数组作为参数,并使用新的值更新 seatschangeType
:处理单个座位类型函数,接受座位对象和 type 参数,并根据提供的类型更新座位的属性(如 src、title 和 money)singleChoice
:处理单个座位选择的函数,它将选中的座位标记为SEAT_STATE.SELECTED
,并更新其他座位的选择状态multipleChoice
:处理多个座位选择的函数,它根据座位的选择状态更新座位的类型selectClick
:处理已选择座位点击的函数,它根据点击的座位项更新座位的选择状态totalMoney
:用于计算选中位置的总价格
import { createSeat } from './utils.js';
import { SEAT_STATE } from './constant.js';
/**
* @typedef {Object} Type
* @property {String} title - 名称
* @property {String} [price] - 价格
* @property {String} [state] - 状态
* @property {String} [icon] - 图标
* @reference
*/
const { computed, ref, onMounted } = Vue;
const useSeat = (options) => {
const { horizontal, vertical, seats: _seats } = options;
/**
* @type {import('vue').Ref<import('./utils').Seat>}
*/
const seats = ref(_seats ? _seats : createSeat(horizontal, vertical));
const selected = computed(() => {
const arr = seats.value
.filter((seat) => seat.type === SEAT_STATE.SELECTED)
.sort((a, b) => a.id - b.id);
return arr;
});
const select = selected.value.map((item) => item.id);
/**
* @description 更新座位
* @param {import('./utils').Seat[]} _seats
* @returns {import('./utils').Seat[]}
*/
const updateSeat = (_seats) => {
const newSeat = _seats.map((seat, i) => {
if (seats.value[i]) {
seat = { ...seat, ...seats.value[i] };
}
return seat;
});
seats.value = newSeat;
};
/**
* @description 更改座位的类型
* @param {import('./utils').Seat} seat 座位
* @param {Type} type
*/
const changeType = (seat, type) => {
const index = seats.value.findIndex((item) => item.id === seat.id);
if (index !== -1) {
seats.value[index] = {
...seat,
src: type.icon,
title: type.title,
money: type.price,
};
}
};
/**
* @description 选座(单选)
* @param {import('./utils.js').Seat} seat 座位
* @reference
*/
const singleChoice = (seat) => {
const index = seats.value.findIndex((item) => item.id === seat.id);
if (seat.type === SEAT_STATE.LOCKED) return alert('已被选择了');
seats.value.forEach((_, i) => {
_.type =
i === index
? SEAT_STATE.SELECTED
: _.type === SEAT_STATE.LOCKED
? SEAT_STATE.LOCKED
: select.includes(_.id)
? SEAT_STATE.SELECTED
: SEAT_STATE.IDLE;
_.choose = i === index;
});
};
/**
* @description 选座(多选)
* @param {import('./utils.js').Seat} seat 座位
* @reference
*/
const multipleChoice = (seat) => {
const index = seats.value.findIndex((item) => item.id === seat.id);
const selIndex = selected.value.findIndex((item) => item.id === seat.id);
if (seat.type === SEAT_STATE.LOCKED) return alert('已被选择了');
seats.value[index].choose = selIndex === -1;
seats.value[index].type =
selIndex === -1 ? SEAT_STATE.SELECTED : SEAT_STATE.IDLE;
};
/**
* @description 点击已选择
* @param {import('./utils.js').Seat} seat 点击项
* @reference
*/
const selectClick = (_seat) => {
const index = seats.value.findIndex((item) => item.id === _seat.id);
seats.value = seats.value.map((seat, i) => ({
...seat,
choose: i === index ? !seat.choose : seat.choose,
type: i === index ? SEAT_STATE.IDLE : seat.type,
}));
};
/**
* @description 计算选中位置的价格
* @reference
*/
const totalMoney = computed(() => {
return seats.value.reduce((pre, cur) => {
return pre + (cur.type === SEAT_STATE.SELECTED ? Number(cur.money) : 0);
}, 0);
});
onMounted(() => {
seats.value = seats.value.map((seat) => {
return {
...seat,
type: seat.choose ? SEAT_STATE.LOCKED : SEAT_STATE.IDLE,
};
});
});
return {
seats,
selected,
updateSeat,
changeType,
singleChoice,
multipleChoice,
selectClick,
totalMoney,
};
};
export { useSeat };
6、创建js/Seat.js文件
这个文件用于编写组件代码
类型
/**
* @typedef {Object} Type
* @property {String} title - 名称
* @property {String} [price] - 价格
* @property {String} [state] - 状态
* @property {String} [icon] - 图标
* @reference
*/
/**
* @typedef {Object} SeatProps
* @property {Array<import('./utils.js').Seat>} [seats] -座位
* @property {String} [tag] -标签
* @property {String | Array<String | Number>} [horizontal] -行
* @property {String | Array<String | Number>} [vertical] -列
* @property {Array<Type>} [types] -类型
* @property {String} [status] -状态
* @property {Boolean} [multiple] -多选
*/
组件代码
const { defineComponent, createVNode, createTextVNode } = Vue;
import { SEAT_STATE } from './constant.js';
import {
createNameSpace,
makeStringProp,
makeArrayProp,
toArray,
isArray,
} from './utils.js';
const [name, bem] = createNameSpace('seat');
/**
* @description
* @type {SeatProps}
* @reference
*/
const seatProps = {
tag: makeStringProp('div'),
horizontal: makeStringProp(''), // '行'
vertical: makeStringProp(''), // '列'
seats: makeArrayProp(), // '座位'
types: makeArrayProp(), // '类型' 修改座位的类型需要传参数 []
status: makeStringProp('show'), // '状态' 展示还是可修改
multiple: Boolean, // 是否可多选
};
const stdin_default = defineComponent({
props: seatProps,
name,
emits: ['change-type', 'selected', 'before-change', 'click-seat'],
setup(props, { emit, slots }) {
/**
* @type {import('vue').Ref<import('./utils.js').Seat>}
*/
const renderRowHeader = () => {
if (slots.header) return slots.header();
const { horizontal, tag } = props;
const row = isArray(horizontal) ? horizontal : toArray(horizontal);
return createVNode(
tag,
{
class: bem('header'),
},
row.map((r, index) => {
return createVNode(
'span',
{
class: [{ [bem('row-first')]: index == 0 }, bem('row-item')],
key: `xAxis_${index}`,
},
[createTextVNode(r)]
);
})
);
};
const renderColumn = () => {
if (slots.column) return slots.column;
const { vertical, tag } = props;
const column = isArray(vertical) ? vertical : toArray(vertical);
return createVNode(
tag,
{ class: bem('column') },
column.map((col, index) => {
return createVNode(
'span',
{ class: bem('column-item'), key: `yAxis_${index}` },
[createTextVNode(col)]
);
})
);
};
/**
* @description 点击事件
* @param {Event} e 事件
* @param {import('./utils.js').Seat} seat 座位信息
* @reference
*/
const onClick = (e, seat) => {
e.preventDefault();
const { status, multiple } = props;
status !== 'show'
? emit('change-type', seat)
: multiple
? emit('selected', seat)
: emit('click-seat', seat);
};
const renderContainer = () => {
if (slots.body) return slots.body();
const { horizontal, vertical, tag, types, seats } = props;
const row = isArray(horizontal) ? horizontal : toArray(horizontal);
const column = isArray(vertical) ? vertical : toArray(vertical);
/**
* @description
* @type {Type[]}
* @reference
*/
return createVNode(tag, { class: bem('container') }, [
renderColumn(),
createVNode(
tag,
{
class: bem('body'),
style: {
gridTemplateColumns: `repeat(${row.length}, 1fr)`,
gridTemplateRows: `repeat(${column.length}, 1fr)`,
},
},
seats.map((seat, index) => {
return createVNode(
'span',
{
class: [
seat.choose ? bem(SEAT_STATE.SELECTED) : bem(seat.type),
bem('item'),
],
key: index,
role: 'button',
style: {
backgroundImage: `url(${seat.src})`,
pointerEvent:
seat.type === SEAT_STATE.LOCKED ? 'none' : 'all',
cursor:
seat.type === SEAT_STATE.LOCKED ? 'no-drop' : 'pointer',
},
'data-y': seat.yCoordinate,
'data-x': seat.xCoordinate,
'data-tile': seat.title,
onClick: (e) => onClick(e, seat),
},
[createTextVNode(seat.number)]
);
})
),
]);
};
return () => {
const { tag } = props;
return createVNode(
tag,
{
class: bem(['map']),
},
slots.default ? slots.default() : [renderRowHeader(), renderContainer()]
);
};
},
});
export { stdin_default as default, seatProps };
7、组件样式
7.1 css/index.scss
@import url(./reset.css);
@import url(./var.css);
$parent: '.n-seat';
#{$parent} {
display: flex;
flex-direction: column;
overflow: auto;
background-color: #7fffd4;
width: 8rem;
margin: auto;
padding: 0.2rem;
height: 8rem;
&__header,
&__column,
&__body {
display: flex;
span {
-webkit-text-emphasis: none;
text-emphasis: none;
text-align: center;
color: brown;
background-color: antiquewhite;
font-size: var(--font-size);
padding: calc(var(--seat-size) / 6);
display: block;
box-sizing: border-box;
width: calc(var(--seat-size) + var(--seat-size) / 3);
height: calc(var(--seat-size) + var(--seat-size) / 3);
cursor: pointer;
}
}
&__header {
width: -webkit-max-content;
width: -moz-max-content;
width: max-content;
margin-left: calc(var(--seat-size) + var(--seat-margin) * 2);
span {
&:not(:first-child) {
margin-left: var(--seat-margin);
}
}
}
&__column {
flex-direction: column;
span {
&:not(:last-child) {
margin-bottom: var(--seat-margin);
}
}
}
&__container {
display: flex;
margin-top: var(--seat-margin);
}
&__body {
display: grid;
gap: calc(var(--seat-margin));
margin-left: var(--seat-margin);
span {
border: 1px solid brown;
border-radius: 0.05rem;
background-color: transparent;
&#{$parent}__item {
background-size: cover;
background-repeat: no-repeat;
background-position: 0 0;
}
&#{$parent}__selected {
background-color: red;
color: #fff;
}
&#{$parent}__unavailable {
background-color: #fff;
}
}
}
}
7.2 reset.css
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
line-height: 1;
}
ol,
ul {
list-style: none;
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
7.3 var.css
:root {
--font-size: 0.6rem;
--bg-color: #fff;
--animation-delay: 0.15s;
--seat-size: 0.9rem;
--seat-margin: 0.3rem;
}
用法
Props
序号 | 名称 | 说明 |
---|---|---|
1 | seats | 座位 |
2 | horizontal | 控制列数 |
3 | vertical | 控制行数 |
4 | types | 类型 |
5 | status | 组件展示的形式 默认值 show 可选值 update |
6 | multiple | 是否可以多选 默认值 false 可选值 true |
Event
序号 | 名称 | 说明 |
---|---|---|
1 | click-seat | 选择哪个座位 |
2 | change-type | 改变哪个座位的类型(只有 status 不为 show 时有效) |
3 | selected | 选择哪个座位(只有 multiple 为 true 时有效) |
Slots
序号 | Slot name | Description |
---|---|---|
1 | default | 自定义内容 |
2 | header | 自定义头部 |
3 | body | 自定义内容 |
4 | column | 自定义列 |