原神大地图——2.UI交互层开发

** 注意!!!** map与ui-layer应该具有同一个父元素,而不是父子关系,否则map会捕获所有鼠标事件

本节将开发


整体ui搭建

首先编写左侧filter-config代码

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
<template>
<div id="map-container">
<div id="map">
<div class="ui-layer">
<div class="filter-container">

</div>
</div>
</div>
</div>
</template>

<style scoped>
.ui-layer .filter-container{
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 415px;
padding: 20px;
}
.ui-layer .filter-container .filter-content{
position: relative;
display: flex;
flex-direction: column;
background-color: #3b4354;
width: 100%;
height: 100%;
border-radius: 12px;
}
</style>


基本的ui框架搭建完成分析官网中的界面,可以进行组件拆分
在component下新建FilterHeader.vue组件并在filter-container中引入该组件
首先完成header部分效果

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
<style scoped>
.filter-header {
display: flex;
align-items: center;
padding: 12px 7px 7px 10px;
color: #d3bc8e;
}
.header-icon {
width: 40px;
height: 40px;
background-image: url("../assets/images/ui/xumi-icon.png");
background-size: cover;
margin-right: 5px;
}
.header-name{
font-size: 18px;
font-weight: bold;

margin-right: 10px;
}
.switch-btn{
display: flex;
align-items: center;
padding: 4px 8px 4px 4px;
border-radius: 12px;
background-color: rgb(74, 83, 102);
}
.switch-icon{
width: 16px;
height: 16px;
background-image: url("../assets/images/ui/switch-btn.png");
background-size: cover;
}
.switch-text{
font-size: 12px;
}
</style>

编写关闭按钮代码和样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.close-btn{
position: absolute;
top: 32px;
right: -44px;
width: 64px;
height: 40px;
background-image: url("../assets/images/ui/close-bg.png");
background-size: cover;
display: flex;
align-items: center;
padding-left: 18px;
box-sizing: border-box;
z-index: 10;
}
.close-icon{
width: 24px;
height: 24px;
background-image: url("../assets/images/ui/close-icon.png");
background-size: cover;
}

新建LocationBtn.vue并在Filter-header上方引入。实现一个占位组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div class="location-btn">

</div>
</template>

<style scoped>
.location-btn{
position: absolute;
top: 84px;
right: -30px;
width: 40px;
height: 40px;
background-color: rgba(50,57,71,0.8);
border-radius: 8px;
cursor: pointer;
}

新建SelectedArea组件,内容与LocationBtn类似,调整以下top值到合适的位置即可

新建FilterMain组件,编写相应UI代码并引入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
<div class="filter-main">
<div class="filter-main-left">

</div>
<div class="filter-main-right"></div>
</div>
</template>

<style scoped>
.filter-main{
flex: 1;
display: flex;

}
.filter-main-left{
width: 97px;
background: #323947;
}
.filter-main-right{
flex: 1;
}
</style>

最终的filter区域HTML结构:

效果展示:

交互栏开发

本节搭建搜索框和左侧的筛选列表
搜索框HTML部分

1
2
3
4
<div class="search-container">
<div class="search-icon"></div>
<div class="search-tip">搜索</div>
</div>

CSS部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.search-container {
height: 32px;
width:355px;
display: flex;
align-items: center;
background-color: #323947;
border-radius: 22px;
padding-left: 10px;
margin: 10px auto;
font-size: 12px;
color: #9b9c9f;
}
.search-icon {
width: 16px;
height: 16px;
background-image: url("../assets/images/ui/search-icon.png");
background-size: cover;
margin: 0 5px 0 1px;
}

效果:

接着开发筛选列表部分

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

<template>
<div class="filter-main">
<div class="filter-main-left">
<div class="filter-type-item" v-for="item in 20" :key="item">
<div class="item-name">传送点</div>
</div>
</div>
<div class="filter-main-right"></div>
</div>
</template>

<style scoped>
.filter-main{
flex: 1;
display: flex;
overflow: hidden;
}

.filter-main-left{
width: 97px;
background: #323947;
overflow-y: auto;
}
.filter-main-left::-webkit-scrollbar{
display: none;
}
.filter-main-right{
flex: 1;
}
.filter-type-item{
display: flex;
align-items: center;
padding: 16px 16px;
}
.item-name{
color: hsla(39, 34%, 89%, 0.75);
font-size: 12px
}
</style>

增加小角标

1
2
3
4
5
6
7
8
9
.item-count{
position: absolute;
top: 5px;
right: 4px;
border-radius: 6px;
line-height: 12px;
font-size: 9px;
color: #d3bc8e;
}


添加筛选列表点击功能

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
<script setup lang="ts">
import {ref} from "vue";

const activeTypeIndex = ref(0)

function onTypeItemClick(index: number) {
activeTypeIndex.value=index

}
</script>

<template>
<div class="filter-main">
<div class="filter-main-left">
<div class="filter-type-item" :class="{active: item === activeTypeIndex}" v-for="item in 20" :key="item" @click="onTypeItemClick(item)">
<div class="item-name">传送点</div>
<div class="item-count">3</div>
</div>
</div>
<div class="filter-main-right"></div>
</div>
</template>
<style>
//...
.active {
background-image: url("../assets/images/ui/filter-item-dec.png"), url("../assets/images/ui/filter-item-dec2.png");
background-size: 5px 100%, 24px 100%;
background-position: 0 0, 5px 0;
background-repeat: no-repeat;
background-color: #3b4354;
//将文字往右边推一点空间,显示出动态
padding: 16px 20px;
color: #d3bc8e !important;
}

</style>

标点列表展示

实现这部分内容首先分析数据结构需要用两个数组

可先完成UI界面搭建,即在filter-main-right部分编写代码

1
2
3
  <div class="filter-main-right">
<!-- 此处编写 ...-->
</div>

搭建好基本结构

1
2
3
4
5
6
7
<div class="filter-main-right">
<div class="filter-content-item" v-for="i in 10" :key = "i">
<div class="content-head">
<div class="head-title">露天宝箱</div>
</div>
</div>
</div>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<style>
.filter-main-right{
flex: 1;
padding: 0 3px;
}
.filter-content-item{

}
.content-head{
padding: 16px 0 0 2px
}
.head-title{
color: #d3bc8e;
font-size: 14px;

}
</style>


完成title部分继续完善body部分
完成列表布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.content-body{
display: flex;
flex-wrap: wrap;
}
.content-item{

}
.item-icon-container{
position: relative;
width: 57px;
height:57px;
border-radius: 6px;
background: #323947;
}


完成item角标布局

1
2
3
4
5
6
7
8
9
10
11
.icon-count{
position: absolute;
font-size: 10px;
right: 0;
bottom: 0;
line-height: 13px;
color: #9b9c9f;
background-color: #323947;
padding: 0 4px;
border-radius: 6px 0 6px 0;
}


完成列表文字布局

1
2
3
4
5
6
7
8
9
.content-item-name{
margin-top: 5px;
font-size:12px;
color: hsla(39, 34%, 89%, 0.75);
max-width: 57px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}


然后给content-body以及content-item加margin以美化显示效果

部分美化效果没有写全,最终效果如下图所示

添加滚动

axios及mockjs引入

封装axios请求,建立一下目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//@/ts/api/base-request.ts

import axios, {type AxiosInstance} from "axios";

export class BaseRequest {
private axiosInst: AxiosInstance;

constructor(host: string) {
this.axiosInst = axios.create({
baseURL: host,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
timeout: 15000,
})
}

sendRequest(method: string, path: string, params: any = null, data: any = null): AxiosInstance {
return this.axiosInst.request({method, url:path, params,data})
}
}

export const mainRequest = new BaseRequest('http://127.0.0.1/map')
1
2
3
4
5
6
//@/ts/api/index.ts
import {mainRequest} from "./base-request.ts";

export function getMapFilterTree(){
return mainRequest.sendRequest('get', '/label/tree')
}

withCredentials字段解读:有些时候我们会发一些跨域请求,比如 domain-a.com 站点发送一个 api.domain-b.com/get 的请求,默认情况下,浏览器会根据同源策略限制这种跨域请求,但是可以通过 CORS (opens new window)技术解决跨域问题。

widthCredentials在同源下(相同域下是无效的),也就是相同域下都会请求写在cookie。

在同域的情况下,我们发送请求会默认携带当前域下的 cookie,但是在跨域的情况下,默认是不会携带请求域下的 cookie 的,比如 domain-a.com 站点发送一个 api.domain-b.com/get 的请求,默认是不会携带 api.domain-b.com 域下的 cookie,如果我们想携带(很多情况下是需要的),只需要设置请求的 xhr 对象的 withCredentials 为 true 即可。

跨域情况下,需要携带请求域下的cookie那么就需要配置xhr对象的withCredentials。

然后在@/mock/index.ts下面引入mock,并在main.ts中执行mock


执行后正确输出响应内容,但是data部分需要优化

切换到@/ts/api/base-request.ts文件,修改sendRequest方法为:

1
2
3
4
5
6
7
8
9
10
11
sendRequest(method: string, path: string, params: any = null, data: any = null): AxiosInstance {
// return this.axiosInst.request({method, url:path, params,data})
return new Promise((resolve, reject)=>{
this.axiosInst
.request({method, url:path, params,data})
.then((res)=>{
resolve(res.data)
})
.catch(reject)
})
}

替换静态标点列表

根据请求数据结构替换template中的占位数据就行。此处掠过
接下来开发标点选中功能

通过给响应式数据添加属性的方式确定被选中的元素,所以在content-item上添加点击事件,并将传进来的数据用Reflecct.set设置属性

1
2
3
function onFilterItemClick(child: any){
Reflect.set(child, 'active', !child.active)
}

然后给itemActive添加CSS属性,使用after伪类添加边框

1
2
3
4
5
6
7
8
9
10
11
12
.itemActive .item-icon-container::after{
position: absolute;
display: block;
content: '';
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0.5px solid #d3bc8e;
box-sizing: border-box;
z-index: 2;
}

完善细节,添加selected-icon

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
//...
<div class="item-icon-container">
<div :style="{backgroundImage: `url(${child.icon})`}" class="icon-pic"></div>
<div class="icon-count">11161</div>
<div class="selected-icon" v-if="child.active" ></div>
</div>
</template>
<style>
.selected-icon{
position: absolute;
top: 0;
right: -1px;
width: 24px;
height: 14px;
background-image: url("../assets/images/ui/select-icon.png");
background-size: cover;
}

</style>

接着联动左侧筛选列表的右上角标点选中数量。
在item-count处添加getActiveCount函数。统计思路即为统计所有child中active的数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script>
function getActiveCount(item: any){
let res = 0
for (let i = 0; i < item.children.length; i++) {
let child = item.children[i]
if(child.active){
res++
}
}
return res
}
</script>
<template>
<div class="filter-main-left">
<div v-for="item in filterTree" :key="item.id" :class="{active: item === activeTypeIndex}" class="filter-type-item"
@click="onTypeItemClick(item)">
<div class="item-name">{{item.name}}</div>
<div class="item-count" v-if="getActiveCount(item) != 0">{{getActiveCount(item)}}</div>
</div>
</div>
</template>

然后实现选中左侧筛选列表是右侧滚动到指定区域,使用scrollIntoView方法即可
首先需要标记滚动锚点,我们在filter-content-item上使用filterContentItem${index}做滚动锚点

然后在onTypeItemClick事件中添加响应的处理事件

1
2
3
4
5
function onTypeItemClick(index: number) {
activeTypeIndex.value = index

document.querySelector(`#filterContentItem${index}`)?.scrollIntoView({behavior: 'smooth'})
}

BUG修复:Flex元素最后一行靠左显示

由于flex容器的justify-content的值设置为space-bewteen,当容器类元素数量大于1且小于4时会导致布局异常,如下图

为了解决这个问题,可以在容器上添加::after伪类并设flex为1或auto解决

1
2
3
4
.content-body::after{
content: '';
flex: 1;
}


可参考:让CSS flex布局最后一行列表左对齐的N种方法

快速定位展示

本节开发

转到LocationBtn组件首先绘制UI部分。

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
<script lang="ts" setup>

</script>

<template>
<div class="location-btn">
<div class="location-icon">
</div>
<div class="location-content">
<div class="location-title">
快速定位
</div>
<div class="content-areas">
<div class="area-item" v-for="item in 10" :key="item">
<div class="area-parent">
<div class="parent-icon"></div>
<div class="parent-name">莱因山</div>
</div>
<div class="area-child" v-for="i in 5":key="i">赤望台</div>
</div>
</div>
</div>
</div>
</template>

<style scoped>
.location-btn {
position: absolute;
top: 84px;
right: -30px;
width: 40px;
height: 40px;
background-color: rgba(50, 57, 71, 0.8);
border-radius: 8px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
.location-btn:hover .location-content {
visibility: visible;
}
.location-icon{
height: 24px;
width: 24px;
background-image: url("../assets/images/ui/location-btn.png");
background-size: cover;
}
.location-content{
transition: all 0.5s ease;
visibility: hidden;
position: absolute;
top: 0;
width: 192px;
padding: 10px 20px;
left: 60px;
background-color: #3b4354;
border-radius: 12px;
}
.location-title{
font-size: 16px;
color: #d3bc8e;
}
.content-areas{
height: 500px;
overflow-y: auto;
}
.content-areas::-webkit-scrollbar {
display: none;
}
.area-item{

}
.area-parent{
display: flex;
align-items: center;
height: 48px;
}
.area-parent:hover .parent-name,.area-child{
color: #ece5d8;
}
.area-parent:hover .parent-icon{
background-image: url("../assets/images/ui/location-icon-h.png");
}
.parent-icon{
width: 12px;
height: 12px;
background-image: url("../assets/images/ui/location-icon-n.png");
background-size: cover;
margin-right: 5px;
}
.parent-name{
color: #d3bc8e;
font-size: 14px;
}
.area-child{
display: flex;
align-items: center;
height: 48px;
color: hsla(39, 34%, 89%, 0.75);
padding: 0 17px;
font-size: 14px;
}
</style>

难点:在location-btn进行hover时,快速定位面板因为visibility的属性会消失的很快。所以可以用一个小技巧,在location-content处添加transition: all 0.5s ease;这样hover在location-btn的时候再移出,location-content面板就不会立马消失了

最终效果:

最后对快速定位模块接入接口

1.编写接口mock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//src/mock/index.ts
function mockMapAnchorList() {
mock.mock(new RegExp(`${MAIN_HOST}/map_anchor/list($|\\?.*)`), {
code: 0,
data: MapAnchorList,
message: '成功'
})

}


//src/ts/api/index.ts
export function getMapAnchorList(){
return mainRequest.sendRequest('get', '/map_anchor/list')
}

2.替换静态数据为动态数据
(略

已选中区域开发

1.ui搭建
转至selectedArea组件,进行静态页面搭建

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
<template>
<div class="selected-area">
<div class="selected-count">2</div>
<div class="selected-icon">
</div>
</div>
</template>

<style scoped>
.selected-area {
position: absolute;
top: 136px;
right: -30px;
width: 40px;
height: 40px;
background-color: rgba(50, 57, 71, 0.8);
border-radius: 8px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
.selected-count{
position: absolute;
top: -4px;
right: -4px;
background-color: #ff5e41;
height: 12px;
width: 12px;
text-align: center;
line-height: 12px;
font-size: 11px;
border-radius: 6px;
padding: 0 3px;
color: #ece5d8;
}
.selected-icon {
width: 24px;
height: 24px;
background-image: url("../assets/images/ui/cart-icon.png");
background-size: cover;

}
</style>

效果展示:

接着开发下拉列表,开发之前可以先将selected-count与selected-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
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
<template>
<div class="selected-area">
<!-- <div class="selected-count">2</div>-->
<!-- <div class="selected-icon">-->
<!-- </div>-->
<div class="list-container">
<div class="up-container">
<div class="up-icon">
</div>
</div>
<div class="selected-item" v-for="item in 3" :key="item">
<div class="item-container">
<div class="item-icon"></div>
</div>
<div class="icon-delete"></div>
</div>
</div>
</div>
</template>

<style scoped>
.selected-area {
position: absolute;
top: 136px;
right: -30px;
width: 40px;
height: fit-content;
padding: 12px 0;
background-color: rgba(50, 57, 71, 0.8);
border-radius: 8px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
.up-container{
margin-bottom: 8px;
display: flex;
justify-content: center;
}
.up-icon{
width: 12px;
height: 12px;
background-image: url("../assets/images/ui/arrow-top.png");
background-size: cover;
}
.selected-item{
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
position: relative;
}
.selected-item:hover .item-container{
border-color: #d3bc8e;
}
.selected-item:hover .icon-delete{
display: block;
}
.item-container{
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 4px;
border: 1px solid hsla(0, 0%, 100%, 0.16);
overflow: hidden;

}
.icon-delete{
display: none;
position: absolute;
top: 0;
right: 2px;
width: 12px;
height: 12px;
background-repeat: no-repeat;
background-position: 50%;
background-size: 100%;
background-image: url("../assets/images/ui/delete-icon.svg");
}
.item-icon{
width: 100%;
height: 100%;
background-size: cover;
background-image: url("../assets/images/ui/arrow-top.png");
}

接着编写交互逻辑
因为涉及到不同组件的数据交流,所以使用pinia进行管理

创建home.ts文件,对filterTree进行封装

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
//src/stores/home.ts
import {defineStore} from "pinia";
import {ref, watch} from "vue";

export const useHomeStore = defineStore('home', () => {
const filterTree = ref<any[]>([])
const selectedFilterItems = ref<any []>([])

watch(filterTree, ()=>{
calcSelectedFilterItems()
},{deep: true})

function setFilterTree(data: any[]) {
filterTree.value = data
}

function calcSelectedFilterItems(){
let res: any[] = []
for (let i = 0; i < filterTree.value.length; i++){
const item = filterTree.value[i]
const activeItems = item.children.filter((child: any)=>{
return child.active
})
res = res.concat(activeItems)
}
selectedFilterItems.value = res
console.log(selectedFilterItems)
}
return {
filterTree,
setFilterTree
}
})

然后在FilterMain.vue中更改filterTree的数据源

1
2
3
4
5
6
7
8
<script>
const {filterTree} = storeToRefs(store)
//...
async function init(){
const res = await getMapFilterTree()
store.setFilterTree(res.data)
}
</script>

打印验证程序正常后即可在SelectedArea.vue组件中引入。在实现左右组件联动之前可以先实现展开与关闭的逻辑

效果展示:

继续实现数据渲染逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div class="selected-area" v-if="selectedFilterItems.length > 0">
<div v-if="!expanded" class="selected-count">{{ selectedFilterItems.length }}</div>
<div v-if="!expanded" class="selected-icon" @click="onSelectedClick"></div>

<div v-else class="list-container">
<div class="up-container" @click="onSelectedClick">
<div class="up-icon">
</div>
</div>
<div v-for="item in selectedFilterItems" :key="item.id" class="selected-item" @click="onSelectedItemClick(item)">
<div class="item-container">
<div class="item-icon" :style="{
backgroundImage: `url(${item.icon})`
}"></div>
</div>
<div class="icon-delete"></div>
</div>
</div>
</div>
</template>

最后注意删除选中项的逻辑为

1
2
3
4
function onSelectedItemClick(item){
//移除选中项的逻辑
Reflect.set(item, 'active', false)
}

效果展示:


原神大地图——2.UI交互层开发
https://nanxfu.github.io/2024/10/01/原神大地图——2-UI交互层开发/
Beitragsautor
nanxfu
Veröffentlicht am
October 1, 2024
Urheberrechtshinweis