AI课,Story模板

Story 模块实战 Demo(仿绘本/有声故事交互)

该 Demo 基于 Vue + TypeScript 实现仿 Story 交互的核心功能,适配儿童绘本、有声故事类场景,支持绘本式滑动浏览、音频与文字逐词高亮同步、横竖屏自适应、翻译触发、故事重启/返回原生应用等能力。

一、资源地址

1. 远端 Demo 预览

可直接访问体验完整功能:
https://public.yitong.com/demo/story/index.html#/
(预览效果:

2. 离线包下载

完整代码离线包,可本地部署:
https://public.yitong.com/demo/story/story-demo.zip

二、核心功能

  1. 横竖屏自适应:监听设备方向变化(orientationchange/resize),自动切换布局样式(portrait 竖屏/landscape 横屏);
  2. 音频同步交互
    • 封面音频播放完成后自动进入正文;
    • 正文音频播放时,基于时间轴实现文字逐词高亮;
    • 滑动切换页面时自动暂停当前音频,播放新页面音频;
  3. 滑动浏览:基于 Swiper 实现故事页面左右滑动,内置分页指示器;
  4. 翻译临时触发:触摸“译”按钮显示文本翻译,松开自动隐藏;
  5. 流程闭环:支持故事重启、播放完成后进入结束页、点击“Next”调用原生桥接方法返回应用;
  6. 交互引导:初始显示“Go”按钮,点击后加载数据并隐藏按钮,启动故事流程。

三、核心代码解析

1. 模板结构(Template)

核心分为「启动按钮、封面页、Swiper 正文页、结束页」4 个模块,通过 pageName 控制显隐状态,结合 Swiper 实现页面滑动切换。

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
<template>
<div class="picbook" :class="[orientation]">
<!-- 启动按钮:点击后隐藏并加载故事数据 -->
<div class="autoplay" @click="start" :class="{'active': hideGo}">Go</div>

<!-- 封面页:展示绘本信息,播放封面音频 -->
<div class="start" :class="{'active': pageName === 'start'}">
<div class="cover">
<img :src="picbook.coverImageUrl" />
<div>
<h2>{{ picbook.name }}</h2>
<p>Word Count {{ picbook.wordCount }}</p>
<audio :src="picbook.coverAudioUrl" @ended="startAudioEndedCallback" id="audioPlayer-1" />
</div>
</div>
</div>

<!-- Swiper 正文页:滑动切换,音频同步文字高亮 -->
<swiper
class="swiper"
ref="mySwiper"
@slideChangeTransitionStart="changeTransitionStart"
@slideChangeTransitionEnd="changeTransitionEnd"
@transitionStart="transitionStart"
:class="{'active': pageName === 'swiper'}"
>
<swiper-slide v-for="(item, index) in pageContents" :key="index">
<img :src="item.imageUrl" />
<div class="text">
<!-- 原文/翻译切换显示 -->
<p v-if="showTranslation">{{ item.translation }}</p>
<p v-else>
<span
v-for="(textItem, textIndex) in item.text.split(' ')"
:key="textIndex"
:class="{'active': item.readIndex === textIndex}"
>{{ textItem }}</span>
</p>
<!-- 翻译触发按钮(触摸显示/松开隐藏) -->
<span class="translation" @touchstart="showTranslation = true" @touchend="showTranslation = false">译</span>
<!-- 正文音频:时间更新同步文字高亮,播放结束切换下一页 -->
<audio
:id="'audioPlayer'+index"
:src="item.audioUrl"
@timeupdate="audioReadCallback"
@ended="audioReadEndedCallback"
/>
</div>
</swiper-slide>
<div class="swiper-pagination" slot="pagination"></div>
</swiper>

<!-- 结束页:提供重启/返回应用功能 -->
<div class="end" :class="{'active': pageName === 'end'}">
<div class="cover-end">
<img :src="picbook.coverImageUrl" />
<div>
<div @click="restart">Restart o</div>
<div @click="backToApp">Next ></div>
</div>
</div>
</div>
</div>
</template>

2. 脚本逻辑(Script + TypeScript)

核心处理音频播放/暂停、文字高亮同步、页面切换、横竖屏检测等逻辑,基于 vue-property-decorator 开发,类型定义清晰。

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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
<script lang="ts">
import { Swiper, SwiperSlide } from "vue-awesome-swiper";
import { Vue, Component } from "vue-property-decorator";
import axios from "axios";

// 正文页面数据类型定义
interface PageItem {
imageUrl: string;
translation: string;
text: string;
readIndex: number; // 文字高亮索引
[propName: string]: any; // 兼容扩展字段
}

// 扩展全局Window,适配原生应用桥接方法
declare global {
interface Window {
yt: any; // 原生应用桥接对象
}
}

@Component({
name: "StoryDemo",
components: { Swiper, SwiperSlide },
computed: {
// 获取Swiper实例
swiper() {
return (this.$refs.mySwiper as any).$swiper;
},
},
})
export default class Home extends Vue {
title = "Story Demo";
pageName = ""; // 控制页面显隐:start/swiper/end
showTranslation = false; // 是否显示翻译文本
orientation = ""; // 横竖屏标识:portrait/landscape
picbook = { name: "", coverImageUrl: "", coverAudioUrl: "", wordCount: 1 }; // 绘本基础信息
hideGo = false; // 是否隐藏启动按钮
isPlaying = false; // 音频是否播放中
playingIndex = 0; // 当前播放音频的页面索引
pageContents: PageItem[] = []; // 正文页面数据列表
swiper: any; // Swiper实例
timeupdate: any; // 音频时间监听定时器

mounted() {
// 初始化横竖屏检测,监听设备方向变化
this.orient();
window.addEventListener(
"onorientationchange" in window ? "orientationchange" : "resize",
() => this.orient(),
false
);
}

// 检测设备横竖屏
orient() {
if (window.orientation === 0 || window.orientation === 180) {
this.orientation = "portrait"; // 竖屏
} else if (window.orientation === 90 || window.orientation === -90) {
this.orientation = "landscape"; // 横屏
}
}

// 加载故事数据(从story.json获取)
asyncData() {
return axios({ url: "story.json", method: "get" })
.then((res: any) => {
this.picbook = res.data.picbook;
this.pageContents = res.data.picbook.pageContents;
})
.catch((err) => console.error("故事数据加载失败:", err));
}

// 启动故事流程
async start() {
this.hideGo = true; // 隐藏启动按钮
await this.asyncData(); // 加载数据
this.pageName = "start"; // 显示封面页
await this.delay(1); // 延迟1秒
this.audioPlay(-1); // 播放封面音频(index=-1标识封面)
}

// 重启故事
async restart() {
this.pageName = "start";
await this.delay(0.5);
this.audioPlay(-1);
}

// 延迟函数(Promise封装)
delay(speed: number) {
return new Promise((resolve) => setTimeout(() => resolve(1), speed * 1000));
}

// 播放音频(index=-1:封面音频;≥0:正文页音频)
audioPlay(index: number) {
this.isPlaying = true;
this.playingIndex = index;
const audioDom = document.getElementById(`audioPlayer${index}`) as HTMLAudioElement;
audioDom.load();
audioDom.play();
if (index > -1) this.changeTextColor(index); // 正文音频同步文字高亮
}

// 重置文字高亮状态
resetTextColor(index: number) {
this.pageContents[index].readIndex = -1;
}

// 音频时间轴同步文字高亮(核心逻辑)
changeTextColor(index: number) {
const pageItem = this.pageContents[index];
const audioDom = document.getElementById(`audioPlayer${index}`) as HTMLAudioElement;
// 每25ms检测一次音频进度,匹配文字高亮索引
this.timeupdate = setInterval(() => {
pageItem.wordStartTimes.forEach((time: number, textIndex: number) => {
if (audioDom.currentTime * 1000 >= time) {
pageItem.readIndex = textIndex; // 更新高亮索引
}
});
}, 25);
}

// 暂停音频并清除定时器
audioPause(index: number) {
const audioDom = document.getElementById(`audioPlayer${index}`) as HTMLAudioElement;
audioDom.pause();
if (this.timeupdate) {
clearInterval(this.timeupdate);
this.timeupdate = null;
}
}

// 封面音频播放完成:切换到正文页
async startAudioEndedCallback() {
this.isPlaying = false;
await this.delay(1);
this.pageName = "swiper"; // 显示正文页
await this.delay(0.5);
this.audioPlay(0); // 播放第一页正文音频
}

// 正文音频播放完成:切换下一页/结束页
async audioReadEndedCallback() {
this.isPlaying = false;
const newIndex = this.swiper.activeIndex + 1;
if (this.pageContents.length === newIndex) {
// 所有页面播放完成,显示结束页
this.pageName = "end";
await this.delay(0.5);
this.swiper.slideTo(0, 0, false); // 重置Swiper到第一页
} else {
// 切换到下一页
this.swiper.slideTo(newIndex, 500, true);
}
}

// Swiper滑动完成回调:暂停上一页音频,播放当前页音频
changeTransitionEnd(item: any) {
if (this.isPlaying) {
this.audioPause(this.playingIndex);
this.resetTextColor(this.playingIndex);
}
this.resetTextColor(item.activeIndex);
this.audioPlay(item.activeIndex);
}

// 调用原生应用桥接方法返回应用
backToApp() {
window.yt?.backToApp();
}

// 预留扩展回调(可自定义逻辑)
audioReadCallback(item: any) { console.log("音频时间更新:", item); }
changeTransitionStart(item: any) { console.log("滑动开始:", item); }
transitionStart(item: any) { console.log("过渡动画开始:", item); }
}
</script>

3. 样式(SCSS)

核心实现页面显隐过渡、横竖屏布局适配、文字高亮、交互按钮样式,保证全屏沉浸式展示。

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
153
154
155
156
157
158
159
160
161
<style lang="scss" scoped>
// 启动按钮:点击后收缩隐藏
.autoplay {
width: 60px;
height: 60px;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.5s ease-in-out;
background-color: lightseagreen;
color: #fff;
border-radius: 50%;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 100;
opacity: 1;
}
.autoplay.active {
width: 0;
height: 0;
opacity: 0;
z-index: 5;
}

// 容器基础样式:全屏、黑色背景
.picbook {
position: relative;
background-color: #000;
overflow: hidden;
&.portrait { height: 56vw; } // 竖屏高度适配
}

// 全屏布局:封面/正文/结束页均占满屏幕
.picbook, .start, .end, .swiper, .swiper-wrapper {
width: 100%;
height: 100%;
}

// 封面页:初始左移隐藏,active时归位
.start {
z-index: 6;
background-color: #fff;
left: -100%;
top: 0;
transition: left 0.3s ease-in-out;
display: flex;
justify-content: center;
align-items: center;
.cover {
width: 60%;
height: 60%;
display: flex;
flex-direction: row;
> img { height: 100%; border-radius: 10px; }
> div {
display: flex;
flex-direction: column;
text-align: left;
justify-content: center;
padding-left: 30px;
h2 { color: #000; font-size: 24px; }
p { font-size: 18px; color: #999; margin-top: 10px; }
}
}
}

// 结束页:初始右移隐藏,active时归位
.end {
z-index: 9;
background-color: #fff;
left: 100%;
top: 0;
display: flex;
justify-content: center;
align-items: center;
.cover-end {
width: 60%;
height: 90%;
display: flex;
flex-direction: column;
align-items: center;
> img {
border-radius: 10px;
height: 80%;
padding-bottom: 20px;
}
> div {
display: flex;
flex-direction: row;
> div {
width: 150px;
height: 36px;
border: 2px solid lightseagreen;
display: flex;
justify-content: center;
align-items: center;
border-radius: 20px;
margin: 0 10px;
&:first-child { color: lightseagreen; }
&:last-child { background-color: lightseagreen; color: #fff; }
}
}
}
}

// Swiper正文页:初始透明,active时显示
.swiper {
position: relative;
opacity: 0;
transition: all 0.5s ease-in-out;
z-index: 3;
.swiper-wrapper > div {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
height: 100%;
> img { height: 100%; width: auto; }
> div {
display: flex;
width: 100%;
height: 100%;
background-color: #fff;
justify-content: center;
align-items: center;
position: relative;
> p {
padding: 0 50px;
word-break: break-all;
color: #000;
> span {
margin: 0 4px;
font-size: 22px;
&.active { color: lightseagreen; } // 文字高亮样式
}
}
// 翻译按钮样式
.translation {
width: 30px;
height: 30px;
line-height: 30px;
border-radius: 50%;
background-color: #ccc;
box-shadow: 1px 2px 3px #000;
color: #fff;
text-align: center;
position: absolute;
top: 50%;
right: 15px;
transform: translateY(-50%);
}
}
}
}

// 页面显隐控制:active时展示
.start.active, .end.active { left: 0; }
.swiper.active { opacity: 1; }
</style>

四、核心逻辑总结

1. 关键流程

Go 按钮 → 加载数据 → 封面音频播放 → 正文页滑动+音频同步文字高亮 → 播放完成进入结束页 → 重启/返回应用。

2. 核心技术点

  • 音频同步:通过 setInterval 监听音频 currentTime,匹配 wordStartTimes 实现文字逐词高亮;
  • 滑动交互:Swiper 切换时自动暂停上一页音频、播放当前页音频,保证音频与页面同步;
  • 适配性:监听设备方向变化,动态切换横竖屏样式,兼容移动端展示;
  • 桥接能力:集成 window.yt.backToApp() 实现与原生应用的交互。

总结

  1. 该 Demo 的核心是音频与视觉交互的同步,通过时间轴匹配实现文字高亮,结合 Swiper 完成故事页面的流畅流转;
  2. 横竖屏适配、翻译临时触发、流程闭环(重启/返回)是提升用户体验的关键优化点;
  3. 代码基于 Vue + TypeScript 开发,类型定义清晰,预留扩展回调,便于根据业务需求二次开发。

AI课,Story模板
https://cszy.top/20200907-ai-story/
作者
csorz
发布于
2020年9月7日
许可协议