前言

前端基础都了解的差不多了,天天看 CSS 啥的也啥都记不住,不如动手做。所以这篇文章记录 Vue 框架的一些实际案例。写基础都太没意思了,跟记流水账一样,所以就不写那些基础操作了。

初始化项目

1
2
3
4
5
6
7
8
9
10
11
12
# 使用 npm 进行安装
npm create npm create vue@latest
# 使用 pnpm 进行安装
pnpm create npm create vue@latest
# 下载依赖
npm/pnpm install
# 启动项目
pnpm dev

# 其他依赖,如 lucide
npm install @lucide/vue
pnpm add @lucide/vue

项目结构

  • Vue-app
    • src
      • stores <– 状态管理说是…
      • api <– 网络请求封装(自己加的)
      • assets <– css样式与图片
      • components <– 暂时不知道有啥用
      • router <– 路由
      • views <– 页面
    • App.vue <– 主入口
    • main.ts <– 脚本主入口
  • index.html
  • … <– 一堆默认配置文件
  • vite.config.ts <– vite配置文件

实际场景

将返回信息以列表形式显示

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

...

const newSongs = ref<NewSong[]>([]);

const fetchNewSongs = async () => {
try {
const data = await api.get<PersonalizedNewSongRes>('/personalized/newsong');

newSongs.value = (data.result || []).map((item) => ({
id: item.id,
name: item.name,
cover: item.picUrl,
artist: item.song?.artists?.map((a) => a.name).join('/') || '',
}));
// console.log(newSongs.value);
} catch(err) {
console.log("推荐新音乐失败", err);
}
}

...

</script>

<template>
...
<ul class="song-list">
<li
class="song-item"
v-for="song in newSongs"
:key="song.id"
>
<div class="song-cover">
<img :src="song.cover" :alt="song.name">
</div>
<div class="song-info">
<p class="song-name">{{ song.name }}</p>
<p class="song-artist">{{ song.artist }}</p>
</div>
</li>
</ul>
...
</template>

注: 这里已经对网络请求进行了封装,所以直接用 api.get<>('...'); 就能直接从后端获取数据。

加载效果的实现

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

...

const singerRankLoading.value = false;
const singerRank = ref<Artist[]>([]);

const singerRankLoading = ref<boolean>(false);

const fetchSingerRank = async () => {
singerRankLoading.value = true;
try {
const data = await api.get<TopArtistsRes>('/top/artists', { limit: 20 });

singerRank.value = (data.artists || []).map((item, index) => ({
id: item.id,
name: item.name,
rank: index,
avatar: item.picUrl,
}));
singerRankLoading.value =false;
// console.log(singerRank.value);
} catch (err) {
console.log("获取歌手榜单失败", err);
singerRankLoading.value = false;
}
}

...

</script>

<template>
...
<h2 class="section-title section-title--sub section-title--singer">歌手榜单</h2>
<div class="tip" v-if="singerRankLoading">数据加载中...</div>
<div v-else-if="!singerRank.length">数据获取失败</div>
<template v-else>
<div class="singer-carousel" v-if="singerSlides.length">
...
</div>
</template>
...
</template>

<template>标签可以在不加块元素的情况下实现对内部元素的控制,通常配合 v-if 和 v-else 来使用

简单的翻页效果的实现

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

...

interface Artist {
id: number;
name: string;
rank: number;
avatar: string;
}

...

const singerRank = ref<Artist[]>([]);
const currentSingerSlide = ref<number>(0);

const SINGER_PAGE_SIZE = 5;

const singerSlides = computed(() => {
const list = singerRank.value || [];
const sliders: Artist[][] = [];
for (let i = 0; i < list.length; i += SINGER_PAGE_SIZE) {
sliders.push(list.slice(i, i + SINGER_PAGE_SIZE));
}
return sliders;
});

const prevSingerSlide = () => {
if (!singerSlides.value.length) return;
currentSingerSlide.value = (currentSingerSlide.value - 1 + singerSlides.value.length) % singerSlides.value.length;
}

const nextSingerSlide = () => {
if (!singerSlides.value.length) return;
currentSingerSlide.value = (currentSingerSlide.value + 1) % singerSlides.value.length;
}
...

</script>

<template>
...
<div
v-for="(sliders, index) in singerSlides"
:key="index"
class="singer-slide"
v-show="index == currentSingerSlide"
>
<ul class="singer-list">
<li v-for="singer in sliders" :key="singer.id" class="singer-item">
<div class="singer-avatar">
<img :src="singer.avatar" :alt="singer.name">
</div>
<span class="singer-name">{{ singer.rank + 1}} {{ singer.name }}</span>
</li>
</ul>
</div>
...
<div class="singer-carousel-controls" v-if="singerSlides.length > 1">
<button class="singer-arrow" @click="prevSingerSlide"><ChevronLeft :stroke-width="3" /></button>
<button class="singer-arrow" @click="nextSingerSlide"><ChevronRight :stroke-width="3" /></button>
</div>
...
</template>

注: 这里用了 Icons-Lucide 的图标元素<ChevronRight/>

1
2
3
# 安装 lucide
npm install @lucide/vue
pnpm add @lucide/vue

实现页面的跳转

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script setup lang="ts">

...
const router = useRouter();
const pageChange = (id:number) => {
router.push({
name: "pagename",
id: {id},
});
}
...

</script>

<template>
...
<ul>
<li @click = "pageChange(something.id)">
...
<li>
<ul>
...
</template>

点击元素就能跳转页面

从页面 url 中获取信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup lang="ts">

...
const route = useRoute();
const somethingId = computed(() => Number(route.query.id) || 0);


onMounted(() => {
console.log(songthingId);
});
...

</script>

<template>
...
<p>{{ somethingId }}<p>
...
</template>

这样就能把信息在控制台和页面中显示出来了,onMounted()是生命周期函数…目前只知道把函数丢进去它就能跑

实现音乐的播放

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

...

const audioRef = ref<HTMLAudioElement | null>(null);

const audioUrl = ref<string>('');
const currentTime = ref<number>(0);
const duration = ref<number>(0);
const isPlay = ref<boolean>(false);

const fetchSongUrl = async () => {
const id: number = songId.value;
if (!id) return;
try {
const data = await api.get<SongUrlRes>("/song/url", { id: id });
audioUrl.value = data.data[0]?.url || '';
console.log(audioUrl.value);
duration.value = 0;
currentTime.value = 0;
isPlay.value = false;

} catch(err) {
console.log("获取音乐播放地址失败", err);
audioUrl.value = '';
isPlay.value = false;
}
}

const handleLoadedMetadata = () => {
const audio = audioRef.value;
if (!audio) return;
duration.value = audio.duration || 0;
currentTime.value = audio.currentTime || 0;
}

const handleTogglePlay = () => {
const audio = audioRef.value;
if (!audio) return;
if (audio.paused) {
audio.play().then(() => {
isPlay.value = true;
}).catch(() => {});
} else {
audio.pause();
isPlay.value = false;
}
}

const handleAudioEnded = () => {
isPlay.value = false;
}

const handleTimeUpdate = () => {
const audio = audioRef.value;
if(!audio) return;
currentTime.value = audio.currentTime || 0;
}

...

</script>

<template>
...
<div class="controls-main">
<button class="btn-circle btn-large" @click="handleTogglePlay">
<Pause v-if="isPlay"/>
<Play v-else/>
</button>
</div>
...
<audio
v-if="audioUrl"
class="audio-hidden"
ref="audioRef"
:src="audioUrl"
@loadedmetadata="handleLoadedMetadata"
@timeupdate="handleTimeUpdate"
@ended="handleAudioEnded"
></audio>
...
</template>

还要使用 audio 元素,这个还是第一次接触并使用

实现进度条控制歌曲

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

...

const currentTime = ref<number>(0);
const duration = ref<number>(0);

const formateTime = (sec: number) => {
if (!sec || !Number.isFinite(sec)) return '00:00';
const s = Math.floor(sec);
const m = Math.floor(s / 60);
const rs = s % 60;
const mm = m.toString().padStart(2, '0');
const ss = rs.toString().padStart(2, '0');
return `${mm}:${ss}`;
}

const handleProgressClick = (event: MouseEvent) => {
const bar = event.currentTarget as HTMLElement;
const audio = audioRef.value;
if(!audio || !bar) return;

const rect = bar.getBoundingClientRect();
if (rect.width === 0) return;
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));

const newTime = duration.value * ratio;
audio.currentTime = newTime;
currentTime.value = newTime;
}

...

</script>

<template>
...
<div class="progress-wrap">
<span class="time-label">{{ formateTime(currentTime) }}</span>
<div class="progress-bar" @click="handleProgressClick">
<div class="progress-inner" :style="{ width: duration ? `${(currentTime / duration) * 100}%`: '0%' }"></div>
</div>
<span class="time-label">{{ formateTime(duration) }}</span>
</div>
...
</template>

通过 progress-bar 和 progress-inner 实现进度条效果

二维码登录逻辑的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
<script setup lang="ts">

...
// 获取二维码所需要的 key
const loginKey = ref<string>('');

// 二维码图片的 url
const qrImg = ref<string>('');

// 轮询监听器
const qrCheckTimer = ref<number | null>(null);

// 用户数据
const userStore = useUserStore();

// 获取登录二维码的 key

const fetchloginKey = async () => {
try {
const data = await api.get<LoginQrKeyRes>("/login/qr/key");
loginKey.value = data.data?.unikey || '';
// console.log(loginKey.value);
} catch(err) {
console.log("获取二维码 Key 失败", err);
loginKey.value = '';
}
}

// 携带 key 获取登录二维码的图片

const fetchQrImg = async (key: string) => {
if (!key) return;
try {
const data = await api.get<LoginQrCreateRes>("/login/qr/create",{
key,
timeStamp: Date.now(),
ua: "pc",
qrimg: true,
});
qrImg.value = data.data?.qrimg || '';
// console.log(qrImg.value);
} catch(err) {
console.log("获取二维码图片失败", err);
qrImg.value = '';
}
}

// 监听获取到 key 就获取二维码

watch(() => loginKey.value, (val: string) => {
if (val) {
fetchQrImg(val);
}
})

// 轮询监听是否扫描二维码

const startQrCheak = (key: string) => {
if (!key) return;
if (qrCheckTimer.value) {
clearInterval(qrCheckTimer.value);
}
qrCheckTimer.value = setInterval(async () => {
try {
const data = await api.get<LoginQrCheckRes>("/login/qr/check", {
key,
timeStamp: Date.now(),
ua: "pc",
})

// 如果 803 扫码登录成功,则将用户数据存入浏览器
if (data.code === 803) {
if (qrCheckTimer.value) clearInterval(qrCheckTimer.value);
qrCheckTimer.value = null;

// 保存获取到的 cookie
userStore.setCookie(data.cookie);

// 要携带 cookie 才能获取到用户数据
try {
const statusRes = await api.get<LoginStatusRes>("/login/status", {
timeStamp: Date.now(),
ua: "pc",
cookie: userStore.cookie,
})

const profile = statusRes.data?.profile;

// 保存获取到的用户数据
if (profile) {
userStore.setUser({
id: profile.userId,
avatar: profile.avatarUrl,
nickname: profile.nickname,
})
}
router.push('/');
} catch (err) {
console.log("获取登录状态失败", err);
}

// 如果二维码过期,则重新获取二维码

} else if(data.code === 800) {

if (qrCheckTimer.value) clearInterval(qrCheckTimer.value);
qrCheckTimer.value = null;
fetchloginKey();
}
} catch (err) {
console.log("检查二维码登录状态失败", err);
}
}, 3000)
}

// 如果获取到二维码图片,就开启轮询监听器
watch(() => qrImg.value, (val: string) => {
if (val && loginKey.value) {
startQrCheak(loginKey.value);
}
})



onMounted(() => {
fetchloginKey();
})

// 离开页面时卸载轮询监听事件
onBeforeUnmount(() => {
if (qrCheckTimer.value) {
clearInterval(qrCheckTimer.value);
qrCheckTimer.value = null;
}
})

...

</script>

<template>
...
<div class="qrcode-placeholder">
<template v-if="qrImg">
<img :src="qrImg" alt="登录二维码">
</template>
<template v-else>
<span>二维码加载中...</span>
</template>
</div>
...
</template>

这部分还有用户状态管理相关的函数 (暂存用户数据),但是没有在这写出来

结语

依旧为了方便翻阅复习,于是写在了博客上。还有一大堆 interface 就不在文章里列举了…