1.修复音量控制条显示问题(自适应位置)

2.修复输入文章时,播放新句子会重置音量的问题
3.修复文章练习无法同步音量、播放速度问题(只同步练习场景,编辑、预览未同步)
4.修改音量控制条样式,显示音量比例
This commit is contained in:
XIAO钧i
2025-11-14 14:44:24 +08:00
parent a1851f4798
commit aabd95e5df
9 changed files with 994 additions and 1675 deletions

1707
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,12 +16,12 @@ import { syncSetting } from "@/apis";
const store = useBaseStore()
const runtimeStore = useRuntimeStore()
const settingStore = useSettingStore()
const {setTheme} = useTheme()
const { setTheme } = useTheme()
let lastAudioFileIdList = []
watch(store.$state, (n: BaseState) => {
let data = shakeCommonDict(n)
set(SAVE_DICT_KEY.key, JSON.stringify({val: data, version: SAVE_DICT_KEY.version}))
set(SAVE_DICT_KEY.key, JSON.stringify({ val: data, version: SAVE_DICT_KEY.version }))
//筛选自定义和收藏
let bookList = data.article.bookList.filter(v => v.custom || [DictId.articleCollect].includes(v.id))
@@ -49,12 +49,12 @@ watch(store.$state, (n: BaseState) => {
}
})
watch(settingStore.$state, (n) => {
set(SAVE_SETTING_KEY.key, JSON.stringify({val: n, version: SAVE_SETTING_KEY.version}))
watch(() => settingStore.$state, (n) => {
set(SAVE_SETTING_KEY.key, JSON.stringify({ val: n, version: SAVE_SETTING_KEY.version }))
if (CAN_REQUEST) {
syncSetting(null, settingStore.$state)
}
})
}, { deep: true })
async function init() {
await store.init()
@@ -67,7 +67,7 @@ async function init() {
runtimeStore.isNew = r ? (APP_VERSION.version > Number(r)) : true
})
}
window.umami?.track('host', {host: window.location.host})
window.umami?.track('host', { host: window.location.host })
}
onMounted(init)
@@ -106,6 +106,4 @@ watch(() => route.path, (to, from) => {
<router-view></router-view>
</template>
<style scoped lang="scss">
</style>
<style scoped lang="scss"></style>

View File

@@ -9,6 +9,7 @@ interface IProps {
currentTime?: number;
playbackRate?: number;
disabled?: boolean;
}
const props = withDefaults(defineProps<IProps>(), {
@@ -17,11 +18,13 @@ const props = withDefaults(defineProps<IProps>(), {
volume: 1,
currentTime: 0,
playbackRate: 1,
disabled: false
disabled: false,
});
const emit = defineEmits<{
ended: []
(e: 'ended'): [],
(e: 'update-volume', volume: number): void,
(e: 'update-speed', volume: number): void
}>();
const attrs = useAttrs();
@@ -30,17 +33,20 @@ const attrs = useAttrs();
const audioRef = ref<HTMLAudioElement>();
const progressBarRef = ref<HTMLDivElement>();
const volumeBarRef = ref<HTMLDivElement>();
const volumeFillRef = ref<HTMLElement>();
// 状态管理
const isPlaying = ref(false);
const isLoading = ref(false);
const duration = ref(0);
const currentTime = ref(0);
// const volume = ref(props.volume);
const volume = ref(props.volume);
const playbackRate = ref(props.playbackRate);
const isDragging = ref(false);
const isVolumeDragging = ref(false);
const isVolumeHovering = ref(false); // 添加音量控制hover状态变量
const volumePosition = ref('top') // 音量控制位置,'top'或'down'
const error = ref('');
// 计算属性
@@ -89,13 +95,13 @@ const toggleMute = () => {
const changePlaybackRate = () => {
if (!audioRef.value || props.disabled) return;
const rates = [0.5, 0.75, 1, 1.25, 1.5, 2];
const currentIndex = rates.indexOf(playbackRate.value);
const nextIndex = (currentIndex + 1) % rates.length;
playbackRate.value = rates[nextIndex];
audioRef.value.playbackRate = playbackRate.value;
// 提交更新播放速度事件
emit('update-speed', playbackRate.value);
};
// 事件处理
@@ -108,6 +114,10 @@ const handleLoadedData = () => {
};
const handleLoadedMetadata = () => {
if (audioRef.value) {
audioRef.value.volume = volume.value;
}
duration.value = audioRef.value?.duration || 0;
};
@@ -250,26 +260,18 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
const startX = event.clientX;
const startY = event.clientY;
let hasMoved = false;
let lastVolume = 0; // 记录最后音量
const moveThreshold = 3; // 移动阈值,超过这个距离才认为是拖拽
let lastVolume = 0; // 记录最后音量
const moveThreshold = 3; // 超过这个距离才认为是拖拽
// 获取DOM元素引用
const volumeFill = volumeBarRef.value.querySelector('.volume-fill') as HTMLElement;
const volumeThumb = volumeBarRef.value.querySelector('.volume-thumb') as HTMLElement;
const volumeFill = volumeFillRef.value;
// 立即跳转到点击位置
// 计算点击位置对应音量百分比(最上 100%,最下 0%
const clickY = event.clientY - rect.top;
// 计算百分比最上面是0%最下面是100%
const percentage = Math.max(0, Math.min(1, clickY / rect.height));
const percentage = 1 - Math.max(0, Math.min(1, clickY / rect.height));
// 直接更新DOM样式
if (volumeFill && volumeThumb) {
// 更新 UI 与音量
if (volumeFill) {
volumeFill.style.height = `${percentage * 100}%`;
// 设置top而不是bottom
volumeThumb.style.top = `${percentage * 100}%`;
// 重置left样式
volumeThumb.style.left = '50%';
}
volume.value = percentage;
@@ -277,6 +279,7 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
lastVolume = percentage;
isVolumeDragging.value = true;
// 鼠标移动时调整音量
const handleMouseMove = (e: MouseEvent) => {
const deltaX = Math.abs(e.clientX - startX);
const deltaY = Math.abs(e.clientY - startY);
@@ -286,47 +289,42 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
}
if (!hasMoved) return;
// 禁用过渡动画
if (volumeFill && volumeThumb) {
if (volumeFill) {
volumeFill.style.transition = 'none';
volumeThumb.style.transition = 'none';
}
const rect = volumeBarRef.value!.getBoundingClientRect();
const clickY = e.clientY - rect.top;
// 计算百分比最上面是0%最下面是100%
const percentage = Math.max(0, Math.min(1, clickY / rect.height));
const moveY = e.clientY - rect.top;
const percentage = 1 - Math.max(0, Math.min(1, moveY / rect.height));
// 直接更新DOM样式不使用响应式变量
if (volumeFill && volumeThumb) {
if (volumeFill) {
volumeFill.style.height = `${percentage * 100}%`;
// 设置top而不是bottom
volumeThumb.style.top = `${percentage * 100}%`;
}
// 更新响应式变量和音频音量
volume.value = percentage;
lastVolume = percentage;
// 实时更新音频音量
if (audioRef.value) {
audioRef.value.volume = percentage;
}
};
// 鼠标释放时结束拖动
const handleMouseUp = () => {
isVolumeDragging.value = false;
// 恢复过渡动画
if (volumeFill && volumeThumb) {
if (volumeFill) {
volumeFill.style.transition = '';
volumeThumb.style.transition = '';
}
// 如果是拖拽在结束时更新audio元素到最终音量
if (hasMoved && audioRef.value) {
audioRef.value.volume = lastVolume;
}
// 提交更新音量事件
emit('update-volume', Math.floor(volume.value * 100));
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
@@ -335,6 +333,20 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
document.addEventListener('mouseup', handleMouseUp);
};
// 音量控制鼠标移入事件,自动调整音量控制条位置
const onVolumeSectionEnter = (e: MouseEvent) => {
isVolumeHovering.value = true;
const section = e.target as HTMLElement
const top = section.getBoundingClientRect().top + window.scrollY
const dropdownH = section.querySelector('.volume-dropdown').clientHeight
if (top < dropdownH * 1.25) {
volumePosition.value = 'down'
} else {
volumePosition.value = 'top'
}
}
// 监听属性变化
watch(() => props.src, (newSrc) => {
if (audioRef.value) {
@@ -377,52 +389,29 @@ watch(() => props.playbackRate, (newRate) => {
}
});
defineExpose({audioRef})
defineExpose({ audioRef })
</script>
<template>
<div
class="custom-audio"
:class="{ 'disabled': disabled||error, 'has-error': error }"
v-bind="attrs"
>
<div class="custom-audio" :class="{ 'disabled': disabled || error, 'has-error': error }" v-bind="attrs">
<!-- 隐藏的原生audio元素 -->
<audio
ref="audioRef"
:src="src"
preload="auto"
:autoplay="autoplay"
:loop="loop"
:controls="false"
@loadstart="handleLoadStart"
@loadeddata="handleLoadedData"
@loadedmetadata="handleLoadedMetadata"
@canplaythrough="handleCanPlayThrough"
@play="handlePlay"
@pause="handlePause"
@ended="handleEnded"
@error="handleError"
@timeupdate="handleTimeUpdate"
@volumechange="handleVolumeChange"
@ratechange="handleRateChange"
/>
<audio ref="audioRef" :src="src" preload="auto" :autoplay="autoplay" :loop="loop" :controls="false"
@loadstart="handleLoadStart" @loadeddata="handleLoadedData" @loadedmetadata="handleLoadedMetadata"
@canplaythrough="handleCanPlayThrough" @play="handlePlay" @pause="handlePause" @ended="handleEnded"
@error="handleError" @timeupdate="handleTimeUpdate" @volumechange="handleVolumeChange"
@ratechange="handleRateChange" />
<!-- 自定义控制界面 -->
<div class="audio-container">
<!-- 播放/暂停按钮 -->
<button
class="play-button"
:class="{ 'loading': isLoading }"
@click="togglePlay"
:disabled="disabled"
:aria-label="isPlaying ? '暂停' : '播放'"
>
<button class="play-button" :class="{ 'loading': isLoading }" @click="togglePlay" :disabled="disabled"
:aria-label="isPlaying ? '暂停' : '播放'">
<div v-if="isLoading" class="loading-spinner"></div>
<svg v-else-if="isPlaying" class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
</button>
@@ -431,70 +420,40 @@ defineExpose({audioRef})
<!-- 时间显示 -->
<span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
<!-- 进度条 -->
<div
class="progress-container"
@mousedown="handleProgressMouseDown"
ref="progressBarRef"
>
<div class="progress-container" @mousedown="handleProgressMouseDown" ref="progressBarRef">
<div class="progress-track">
<div
class="progress-fill"
:style="{ width: progress + '%' }"
></div>
<div
class="progress-thumb"
:style="{ left: progress + '%' }"
></div>
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
<div class="progress-thumb" :style="{ left: progress + '%' }"></div>
</div>
</div>
</div>
<!-- 音量控制 -->
<div
class="volume-section"
@mouseenter="isVolumeHovering = true"
@mouseleave="isVolumeHovering = false"
>
<button
class="volume-button"
@click="toggleMute"
:disabled="disabled"
:aria-label="volume > 0 ? '静音' : '取消静音'"
>
<div class="volume-section" @mouseenter="onVolumeSectionEnter" @mouseleave="isVolumeHovering = false">
<button class="volume-button" tabindex="-1" @click="toggleMute" :disabled="disabled"
:aria-label="volume > 0 ? '静音' : '取消静音'">
<IconBxVolumeMute v-if="volume === 0" class="icon"></IconBxVolumeMute>
<IconBxVolumeLow v-else-if="volume < 0.5" class="icon"></IconBxVolumeLow>
<IconBxVolumeFull v-else class="icon"></IconBxVolumeFull>
</button>
<!-- 音量下拉控制条 -->
<div class="volume-dropdown" :class="{ 'active': isVolumeHovering || isVolumeDragging }">
<div
class="volume-container"
@mousedown="handleVolumeMouseDown"
ref="volumeBarRef"
>
<div class="volume-dropdown" :class="[{ 'active': isVolumeHovering || isVolumeDragging }, volumePosition]">
<div class="volume-container" @mousedown="handleVolumeMouseDown" ref="volumeBarRef">
<div class="volume-track">
<div
class="volume-fill"
:style="{ height: volumeProgress + '%', top: 0 }"
></div>
<div
class="volume-thumb"
:style="{ top: volumeProgress + '%' }"
></div>
<div class="volume-fill" ref="volumeFillRef" :style="{ height: volumeProgress + '%', bottom: 0 }"></div>
</div>
<div class="volume-num">
<span>{{ Math.floor(volumeProgress) }}%</span>
</div>
</div>
</div>
</div>
<!-- 播放速度控制 -->
<button
class="speed-button"
@click="changePlaybackRate"
:disabled="disabled"
:aria-label="`播放速度: ${playbackRate}x`"
>
<button class="speed-button" @click="changePlaybackRate" :disabled="disabled"
:aria-label="`播放速度: ${playbackRate}x`">
{{ playbackRate }}x
</button>
</div>
@@ -641,6 +600,7 @@ defineExpose({audioRef})
.volume-section {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-shrink: 0;
position: relative;
@@ -671,13 +631,9 @@ defineExpose({audioRef})
.volume-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--color-primary);
border-radius: 4px;
border-radius: 8px;
padding: 8px;
margin-top: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
opacity: 0;
visibility: hidden;
@@ -688,6 +644,14 @@ defineExpose({audioRef})
opacity: 1;
visibility: visible;
}
&.top {
bottom: 42px;
}
&.down {
top: 42px;
}
}
.volume-container {
@@ -705,35 +669,41 @@ defineExpose({audioRef})
width: 6px;
height: 100%;
background: var(--color-second);
border-radius: 2px;
overflow: hidden;
border-radius: 6px;
// overflow: hidden;
}
.volume-num {
display: flex;
position: absolute;
bottom: 0;
font-size: 12px;
color: #333;
transform: scale(0.85);
line-height: normal;
}
.volume-fill {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: var(--fill-height);
background: var(--color-fourth);
border-radius: 2px;
}
border-radius: 6px;
display: flex;
justify-content: center;
.volume-thumb {
position: absolute;
left: 50%;
top: var(--thumb-top);
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
background: var(--color-fourth);
border-radius: 50%;
box-shadow: var(--audio-volume-thumb-shadow);
cursor: grab;
opacity: 1;
transition: all 0.2s ease;
&:active {
cursor: grabbing;
&::before {
content: "";
position: absolute;
top: 0;
width: 10px;
height: 10px;
border-radius: 100%;
background: var(--color-fourth);
transform: translateY(-50%);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
cursor: grab;
}
}
@@ -772,6 +742,7 @@ defineExpose({audioRef})
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}

View File

@@ -4,12 +4,16 @@ import { Article } from "@/types/types.ts";
import BaseList from "@/components/list/BaseList.vue";
import BaseInput from "@/components/base/BaseInput.vue";
const props = withDefaults(defineProps<{
list: Article[],
showTranslate?: boolean
}>(), {
list: [],
interface IProps {
list: Article[];
showTranslate?: boolean;
activeId: string | number;
}
const props = withDefaults(defineProps<IProps>(), {
list: () => [] as Article[],
showTranslate: true,
activeId: ""
})
const emit = defineEmits<{
@@ -62,27 +66,20 @@ function scrollToItem(index: number) {
listRef?.scrollToItem(index)
}
defineExpose({scrollToBottom, scrollToItem})
defineExpose({ scrollToBottom, scrollToItem })
</script>
<template>
<div class="list">
<div class="search">
<BaseInput
clearable
v-model="searchKey"
>
<BaseInput clearable v-model="searchKey">
<template #subfix>
<IconFluentSearch24Regular class="text-lg text-gray"/>
<IconFluentSearch24Regular class="text-lg text-gray" />
</template>
</BaseInput>
</div>
<BaseList
ref="listRef"
@click="(e:any) => emit('click',e)"
:list="localList"
v-bind="$attrs">
<BaseList ref="listRef" @click="(e: any) => emit('click', e)" :list="localList" v-bind="$attrs">
<template v-slot:prefix="{ item, index }">
<slot name="prefix" :item="item" :index="index"></slot>
</template>
@@ -91,7 +88,7 @@ defineExpose({scrollToBottom, scrollToItem})
<div class="name"> {{ `${searchKey ? '' : (index + 1) + '. '}${item.title}` }}</div>
</div>
<div class="item-sub-title" v-if="item.titleTranslate && showTranslate">
<div class="item-translate"> {{ ` ${item.titleTranslate}` }}</div>
<div class="item-translate"> {{ ` ${item.titleTranslate}` }}</div>
</div>
</template>
<template v-slot:suffix="{ item, index }">

View File

@@ -1,126 +1,131 @@
import { Article, DictId, PracticeArticleWordType, Sentence } from "@/types/types.ts";
import { _nextTick, cloneDeep } from "@/utils";
import { usePlayWordAudio } from "@/hooks/sound.ts";
import { getSentenceAllText, getSentenceAllTranslateText } from "@/hooks/translate.ts";
import { getDefaultArticleWord } from "@/types/func.ts";
import { useSettingStore } from "@/stores/setting.ts";
import { useBaseStore } from "@/stores/base.ts";
import { useRuntimeStore } from "@/stores/runtime.ts";
import { Article, DictId, PracticeArticleWordType, Sentence } from "@/types/types.ts"
import { _nextTick, cloneDeep } from "@/utils"
import { usePlayWordAudio } from "@/hooks/sound.ts"
import { getSentenceAllText, getSentenceAllTranslateText } from "@/hooks/translate.ts"
import { getDefaultArticleWord } from "@/types/func.ts"
import { useSettingStore } from "@/stores/setting.ts"
import { useBaseStore } from "@/stores/base.ts"
import { useRuntimeStore } from "@/stores/runtime.ts"
function parseSentence(sentence: string) {
// 先统一一些常见的“智能引号” -> 直引号,避免匹配问题
sentence = sentence
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // 各种单引号 → '
.replace(/[\u201C\u201D\u201E\u201F]/g, '"'); // 各种双引号 → "
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // 各种双引号 → "
const len = sentence.length;
const tokens = [];
let i = 0;
const len = sentence.length
const tokens = []
let i = 0
while (i < len) {
const ch = sentence[i];
const ch = sentence[i]
// 跳过空白(但不把空白作为 token
if (/\s/.test(ch)) {
i++;
continue;
i++
continue
}
const rest = sentence.slice(i);
const rest = sentence.slice(i)
// 1) 货币 + 数字($1,000.50 或 ¥200 或 €100.5
let m = rest.match(/^[\$¥€£]\d{1,3}(?:,\d{3})*(?:\.\d+)?%?/);
let m = rest.match(/^[\$¥€£]\d{1,3}(?:,\d{3})*(?:\.\d+)?%?/)
if (m) {
tokens.push({word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number});
i += m[0].length;
continue;
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number })
i += m[0].length
continue
}
// 2) 数字/小数/百分比100% 3.14 1,000.00
m = rest.match(/^\d{1,3}(?:,\d{3})*(?:\.\d+)?%?/);
m = rest.match(/^\d{1,3}(?:,\d{3})*(?:\.\d+)?%?/)
if (m) {
tokens.push({word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number});
i += m[0].length;
continue;
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number })
i += m[0].length
continue
}
// 3) 带点缩写或多段缩写U.S. U.S.A. e.g. i.e. Ph.D.
m = rest.match(/^[A-Za-z]+(?:\.[A-Za-z]+)+\.?/);
m = rest.match(/^[A-Za-z]+(?:\.[A-Za-z]+)+\.?/)
if (m) {
tokens.push({word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word});
i += m[0].length;
continue;
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word })
i += m[0].length
continue
}
// 4) 单词(包含撇号/连字符,如 it's, o'clock, we'll, mother-in-law
m = rest.match(/^[A-Za-z0-9]+(?:[\'\-][A-Za-z0-9]+)*/);
m = rest.match(/^[A-Za-z0-9]+(?:[\'\-][A-Za-z0-9]+)*/)
if (m) {
tokens.push({word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word});
i += m[0].length;
continue;
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word })
i += m[0].length
continue
}
// 5) 其它可视符号(标点)——单字符处理(连续标点会被循环拆为单字符)
// 包括:.,!?;:"'()-[]{}<>/\\@#%^&*~`等非单词非空白字符
if (/[^\w\s]/.test(ch)) {
tokens.push({word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol});
i += 1;
continue;
tokens.push({ word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol })
i += 1
continue
}
// 6) 回退方案:把当前字符当作一个 token防止意外丢失
tokens.push({word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol});
i += 1;
tokens.push({ word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol })
i += 1
}
// 计算 nextSpace查看当前 token 的 end 到下一个 token 的 start 之间是否含空白
const result = tokens.map((t, idx) => {
const next = tokens[idx + 1];
const between = next ? sentence.slice(t.end, next.start) : sentence.slice(t.end);
const nextSpace = /\s/.test(between);
return getDefaultArticleWord({word: t.word, nextSpace, type: t.type});
});
const next = tokens[idx + 1]
const between = next ? sentence.slice(t.end, next.start) : sentence.slice(t.end)
const nextSpace = /\s/.test(between)
return getDefaultArticleWord({ word: t.word, nextSpace, type: t.type })
})
return result;
return result
}
//生成文章段落数据
export function genArticleSectionData(article: Article): number {
let text = article.text.trim()
let sections: Sentence[][] = []
text.split('\n\n').filter(Boolean).map((sectionText, i) => {
let section: Sentence[] = []
sections.push(section)
sectionText.trim().split('\n').filter(Boolean).map((item, i, arr) => {
item = item.trim()
//如果没有空格,导致修改一行一行的数据时,汇总时全没有空格了,库无法正常断句
//所以要保证最后一个是空格但防止用户打N个空格就去掉再加上一个空格只需要一个即可
//2025/10/1:最后一句不需要空格
if (i < arr.length - 1) item += ' '
let sentence: Sentence = cloneDeep({
text: item,
translate: '',
words: parseSentence(item),
audioPosition: [0, 0],
})
section.push(sentence)
text
.split("\n\n")
.filter(Boolean)
.map((sectionText, i) => {
let section: Sentence[] = []
sections.push(section)
sectionText
.trim()
.split("\n")
.filter(Boolean)
.map((item, i, arr) => {
item = item.trim()
//如果没有空格,导致修改一行一行的数据时,汇总时全没有空格了,库无法正常断句
//所以要保证最后一个是空格但防止用户打N个空格就去掉再加上一个空格只需要一个即可
//2025/10/1:最后一句不需要空格
if (i < arr.length - 1) item += " "
let sentence: Sentence = cloneDeep({
text: item,
translate: "",
words: parseSentence(item),
audioPosition: [0, 0]
})
section.push(sentence)
})
})
})
sections = sections.filter(v => v.length)
sections = sections.filter((v) => v.length)
article.sections = sections
let failCount = 0
let translateList = article.textTranslate?.split('\n\n') || []
let translateList = article.textTranslate?.split("\n\n") || []
for (let i = 0; i < article.sections.length; i++) {
let v = article.sections[i]
let sList = []
try {
let s = translateList[i]
sList = s.split('\n')
} catch (e) {
}
sList = s.split("\n")
} catch (e) {}
for (let j = 0; j < v.length; j++) {
let sentence = v[j]
@@ -159,167 +164,167 @@ export function genArticleSectionData(article: Article): number {
export function splitEnArticle2(text: string): string {
text = text.trim()
if (!text && false) {
// text = `It was Sunday. I never get up early on Sundays. I sometimes stay in bed until lunchtime. Last Sunday I got up very late. I looked out of the window. It was dark outside. 'What a day!' I thought. 'It's raining again. ' Just then, the telephone rang. It was my aunt Lucy. 'I've just arrived by train, ' she said. 'I'm coming to see you. '
//
// 'But I'm still having breakfast, ' I said.
// 'What are you doing?' she asked.
// 'I'm having breakfast, ' I repeated.
// 'Dear me,$3.000' she said. 'Do you always get up so late? It's one o'clock!'`
// text = `While it is yet to be seen what direction the second Trump administration will take globally in its China policy, VOA traveled to the main island of Mahe in Seychelles to look at how China and the U.S. have impacted the country, and how each is fairing in that competition for influence there.`
// text = `It was Sunday. I never get up early on Sundays. I sometimes stay in bed until lunchtime. Last Sunday I got up very late. I looked out of the window. It was dark outside. 'What a day!' I thought. 'It's raining again. ' Just then, the telephone rang. It was my aunt Lucy. 'I've just arrived by train, ' she said. 'I'm coming to see you. '
//
// 'But I'm still having breakfast, ' I said.
// 'What are you doing?' she asked.
// 'I'm having breakfast, ' I repeated.
// 'Dear me,$3.000' she said. 'Do you always get up so late? It's one o'clock!'`
// text = `While it is yet to be seen what direction the second Trump administration will take globally in its China policy, VOA traveled to the main island of Mahe in Seychelles to look at how China and the U.S. have impacted the country, and how each is fairing in that competition for influence there.`
// text = "It was Sunday. I never get up early on Sundays. I sometimes stay in bed until lunchtime. Last Sunday I got up very late. I looked out of the window. It was dark outside. 'What a day!' I thought. 'It's raining again.' Just then, the telephone rang. It was my aunt Lucy. 'I've just arrived by train,' she said. 'I'm coming to see you.'\n\n 'But I'm still having breakfast,' I said.\n\n 'What are you doing?' she asked.\n\n 'I'm having breakfast,' I repeated.\n\n 'Dear me,' she said. 'Do you always get up so late? It's one o'clock!'"
}
if (!text) return '';
if (!text) return ""
const abbreviations = [
'Mr', 'Mrs', 'Ms', 'Dr', 'Prof', 'Sr', 'Jr',
'St', 'Co', 'Ltd', 'Inc', 'e.g', 'i.e', 'U.S.A', 'U.S', 'U.K', 'etc'
];
const abbreviations = ["Mr", "Mrs", "Ms", "Dr", "Prof", "Sr", "Jr", "St", "Co", "Ltd", "Inc", "e.g", "i.e", "U.S.A", "U.S", "U.K", "etc"]
function isSentenceEnd(text, idx) {
const before = text.slice(0, idx + 1);
const after = text.slice(idx + 1);
const before = text.slice(0, idx + 1)
const after = text.slice(idx + 1)
const abbrevPattern = new RegExp('\\b(' + abbreviations.join('|') + ')\\.$', 'i');
if (abbrevPattern.test(before)) return false;
if (/\d+\.$/.test(before)) return false;
if (/\d+\.\d/.test(text.slice(idx - 1, idx + 2))) return false;
if (/%/.test(after)) return false;
if (/[\$¥€]\d/.test(before + after)) return false;
const abbrevPattern = new RegExp("\\b(" + abbreviations.join("|") + ")\\.$", "i")
if (abbrevPattern.test(before)) return false
if (/\d+\.$/.test(before)) return false
if (/\d+\.\d/.test(text.slice(idx - 1, idx + 2))) return false
if (/%/.test(after)) return false
if (/[\$¥€]\d/.test(before + after)) return false
return true;
return true
}
function normalizeQuotes(text) {
const isWord = ch => /\w/.test(ch);
let res = [];
let singleOpen = false;
let doubleOpen = false;
const isWord = (ch) => /\w/.test(ch)
let res = []
let singleOpen = false
let doubleOpen = false
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const ch = text[i]
if (ch === "'") {
const prev = i > 0 ? text[i - 1] : '';
const nxt = i + 1 < text.length ? text[i + 1] : '';
const prev = i > 0 ? text[i - 1] : ""
const nxt = i + 1 < text.length ? text[i + 1] : ""
if (isWord(prev) && isWord(nxt)) {
res.push("'");
continue;
res.push("'")
continue
}
if (singleOpen) {
if (res.length && res[res.length - 1] === ' ') res.pop();
res.push("'");
singleOpen = false;
if (res.length && res[res.length - 1] === " ") res.pop()
res.push("'")
singleOpen = false
} else {
res.push("'");
singleOpen = true;
res.push("'")
singleOpen = true
}
} else if (ch === '"') {
if (doubleOpen) {
if (res.length && res[res.length - 1] === ' ') res.pop();
res.push('"');
doubleOpen = false;
if (res.length && res[res.length - 1] === " ") res.pop()
res.push('"')
doubleOpen = false
} else {
res.push('"');
doubleOpen = true;
res.push('"')
doubleOpen = true
}
} else {
res.push(ch);
res.push(ch)
}
}
return res.join('');
return res.join("")
}
let rawParagraphs = text.replaceAll('\n\n', '`^`').replaceAll('\n', '').split('`^`')
let rawParagraphs = text.replaceAll("\n\n", "`^`").replaceAll("\n", "").split("`^`")
const formattedParagraphs = rawParagraphs.map(p => {
p = p.trim();
if (!p) return '';
const formattedParagraphs = rawParagraphs.map((p) => {
p = p.trim()
if (!p) return ""
p = p.replace(/\n/g, ' ');
p = normalizeQuotes(p);
p = p.replace(/\n/g, " ")
p = normalizeQuotes(p)
const tentative: string[] = p.match(/[^.!?。!?]+[.!?。!?'"”’)]*/g) || [];
const tentative: string[] = p.match(/[^.!?。!?]+[.!?。!?'"”’)]*/g) || []
const sentences = [];
tentative.forEach(segment => {
segment = segment.trim();
if (!segment) return;
const sentences = []
tentative.forEach((segment) => {
segment = segment.trim()
if (!segment) return
const lastCharIdx = segment.length - 1;
const lastCharIdx = segment.length - 1
if (/[.!?。!?]/.test(segment[lastCharIdx])) {
const globalIdx = p.indexOf(segment);
const globalIdx = p.indexOf(segment)
if (!isSentenceEnd(p, globalIdx + segment.length - 1)) {
if (sentences.length > 0) {
sentences[sentences.length - 1] += ' ' + segment;
sentences[sentences.length - 1] += " " + segment
} else {
sentences.push(segment);
sentences.push(segment)
}
return;
return
}
}
sentences.push(segment);
});
sentences.push(segment)
})
const finalSentences = [];
let i = 0;
const finalSentences = []
let i = 0
while (i < sentences.length) {
let cur = sentences[i];
let cur = sentences[i]
if (i + 1 < sentences.length) {
const nxt = sentences[i + 1];
const nxt = sentences[i + 1]
if (/['"”’)\]]$/.test(cur) && /^[a-z]|^(I|You|She|He|They|We)\b/i.test(nxt)) {
finalSentences.push(cur + ' ' + nxt);
i += 2;
continue;
finalSentences.push(cur + " " + nxt)
i += 2
continue
}
}
finalSentences.push(cur);
i += 1;
finalSentences.push(cur)
i += 1
}
return finalSentences.join('\n');
});
return finalSentences.join("\n")
})
return formattedParagraphs.filter(p => p).join('\n\n');
return formattedParagraphs.filter((p) => p).join("\n\n")
}
export function splitCNArticle2(text: string): string {
if (!text && false) {
// text = "飞机误点了,侦探们在机场等了整整一上午。他们正期待从南非来的一个装着钻石的贵重包裹。数小时以前,有人向警方报告,说有人企图偷走这些钻石。当飞机到达时,一些侦探等候在主楼内,另一些侦探则守候在停机坪上。有两个人把包裹拿下飞机,进了海关。这时两个侦探把住门口,另外两个侦探打开了包裹。令他们吃惊的是,那珍贵的包裹里面装的全是石头和沙子!"
// text = `那是个星期天,而在星期天我是从来不早起的,有时我要一直躺到吃午饭的时候。上个星期天,我起得很晚。我望望窗外,外面一片昏暗。“鬼天气!”我想,“又下雨了。”正在这时,电话铃响了。是我姑母露西打来的。“我刚下火车,”她说,“我这就来看你。”
// “但我还在吃早饭,”我说。
// “你在干什么?”她问道。
// “我正在吃早饭,”我又说了一遍。
// “天啊”她说“你总是起得这么晚吗现在已经1点钟了”`
// text = `上星期我去看戏。我的座位很好,戏很有意思,但我却无法欣赏。一青年男子与一青年女子坐在我的身后,大声地说着话。我非常生气,因为我听不见演员在说什么。我回过头去怒视着那一男一女,他们却毫不理会。最后,我忍不住了,又一次回过头去,生气地说:“我一个字也听不见了!”
// “不关你的事,”那男的毫不客气地说,“这是私人间的谈话!”`
// text = `那是个星期天,而在星期天我是从来不早起的,有时我要一直躺到吃午饭的时候。上个星期天,我起得很晚。我望望窗外,外面一片昏暗。“鬼天气!”我想,“又下雨了。”正在这时,电话铃响了。是我姑母露西打来的。“我刚下火车,”她说,“我这就来看你。”
// “但我还在吃早饭,”我说。
// “你在干什么?”她问道。
// “我正在吃早饭,”我又说了一遍。
// “天啊”她说“你总是起得这么晚吗现在已经1点钟了”`
// text = `上星期我去看戏。我的座位很好,戏很有意思,但我却无法欣赏。一青年男子与一青年女子坐在我的身后,大声地说着话。我非常生气,因为我听不见演员在说什么。我回过头去怒视着那一男一女,他们却毫不理会。最后,我忍不住了,又一次回过头去,生气地说:“我一个字也听不见了!”
// “不关你的事,”那男的毫不客气地说,“这是私人间的谈话!”`
}
const segmenterJa = new Intl.Segmenter("zh-CN", {granularity: "sentence"});
const segmenterJa = new Intl.Segmenter("zh-CN", { granularity: "sentence" })
let sectionTextList = text.replaceAll('\n\n', '`^`').replaceAll('\n', '').split('`^`')
let sectionTextList = text.replaceAll("\n\n", "`^`").replaceAll("\n", "").split("`^`")
let s = sectionTextList.filter(v => v).map((rowSection, i) => {
const segments = segmenterJa.segment(rowSection);
let ss = ''
Array.from(segments).map(sentenceRow => {
let row = sentenceRow.segment
if (row) {
//这个库总是会把反引号给断句到上一行末尾
//而 sentence-splitter 这个库总是会把反引号给断句到下一行开头
if (row[row.length - 1] === "“") {
row = row.substring(0, row.length - 1)
ss += (row + '\n') + '“'
} else {
ss += (row + '\n')
let s = sectionTextList
.filter((v) => v)
.map((rowSection, i) => {
const segments = segmenterJa.segment(rowSection)
let ss = ""
Array.from(segments).map((sentenceRow) => {
let row = sentenceRow.segment
if (row) {
//这个库总是会把反引号给断句到上一行末尾
//而 sentence-splitter 这个库总是会把反引号给断句到下一行开头
if (row[row.length - 1] === "“") {
row = row.substring(0, row.length - 1)
ss += row + "\n" + "“"
} else {
ss += row + "\n"
}
}
}
})
return ss
})
return ss
}).join('\n').trim()
.join("\n")
.trim()
return s
}
export function getTranslateText(article: Article) {
return article.textTranslate
.split('\n\n').filter(v => v)
return article.textTranslate.split("\n\n").filter((v) => v)
}
export function usePlaySentenceAudio() {
@@ -327,14 +332,14 @@ export function usePlaySentenceAudio() {
const settingStore = useSettingStore()
let timer = $ref(0)
function playSentenceAudio(sentence: Sentence, ref?: HTMLAudioElement,) {
function playSentenceAudio(sentence: Sentence, ref?: HTMLAudioElement) {
if (sentence.audioPosition?.length && ref && ref.src) {
clearTimeout(timer)
if (ref.played) {
ref.pause()
}
let start = sentence.audioPosition[0];
ref.volume = settingStore.wordSoundVolume / 100
let start = sentence.audioPosition[0]
// ref.volume = settingStore.wordSoundVolume / 100
ref.currentTime = start
ref.play()
let end = sentence.audioPosition?.[1]
@@ -342,9 +347,9 @@ export function usePlaySentenceAudio() {
if (end && end !== -1) {
timer = setTimeout(() => {
console.log('停')
console.log("停")
ref.pause()
}, (end - start) / ref.playbackRate * 1000)
}, ((end - start) / ref.playbackRate) * 1000)
}
} else {
playWordAudio(sentence.text)
@@ -361,8 +366,8 @@ export function syncBookInMyStudyList(study = false) {
_nextTick(() => {
const base = useBaseStore()
const runtimeStore = useRuntimeStore()
let rIndex = base.article.bookList.findIndex(v => v.id === runtimeStore.editDict.id)
let temp = cloneDeep(runtimeStore.editDict);
let rIndex = base.article.bookList.findIndex((v) => v.id === runtimeStore.editDict.id)
let temp = cloneDeep(runtimeStore.editDict)
if (!temp.custom && temp.id !== DictId.articleCollect) {
temp.custom = true
}
@@ -375,4 +380,4 @@ export function syncBookInMyStudyList(study = false) {
if (study) base.article.studyIndex = base.article.bookList.length - 1
}
}, 100)
}
}

View File

@@ -13,6 +13,7 @@ import loadingDirective from './directives/loading.tsx'
const pinia = createPinia()
const app = createApp(App)
app.use(VueVirtualScroller)
app.use(pinia)
app.use(router)

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, provide, watch } from "vue";
import { computed, onMounted, onUnmounted, provide, watch, nextTick } from "vue";
import { useBaseStore } from "@/stores/base.ts";
import { emitter, EventKey, useEvents } from "@/utils/eventBus.ts";
import { useSettingStore } from "@/stores/setting.ts";
@@ -42,7 +42,7 @@ const store = useBaseStore()
const runtimeStore = useRuntimeStore()
const settingStore = useSettingStore()
const statStore = usePracticeStore()
const {toggleTheme} = useTheme()
const { toggleTheme } = useTheme()
let articleData = $ref({
list: [],
@@ -53,6 +53,7 @@ let typingArticleRef = $ref<any>()
let loading = $ref<boolean>(false)
let allWrongWords = new Set()
let editArticle = $ref<Article>(getDefaultArticle())
let audioRef = $ref<HTMLAudioElement>()
let timer = $ref(0)
let isFocus = true
@@ -131,10 +132,33 @@ async function init() {
router.push('/articles')
}
}
const initAudio = () => {
nextTick(() => {
audioRef.volume = settingStore.articleSoundVolume / 100
audioRef.playbackRate = settingStore.articleSoundSpeed
})
}
const handleVolumeUpdate = (volume: number) => {
settingStore.setState({
articleSoundVolume: volume
})
}
const handleSpeedUpdate = (speed: number) => {
settingStore.setState({
articleSoundSpeed: speed
})
}
watch(() => store.load, (n) => {
if (n && loading) init()
}, {immediate: true})
}, { immediate: true })
watch(() => settingStore.$state, (n) => {
initAudio()
}, { immediate: true, deep: true })
onMounted(() => {
if (store.sbook?.articles?.length) {
@@ -166,9 +190,9 @@ function savePracticeData(init = true, regenerate = true) {
let data = obj.val
//如果全是0说明未进行练习直接重置
if (
data.practiceData.sectionIndex === 0 &&
data.practiceData.sentenceIndex === 0 &&
data.practiceData.wordIndex === 0
data.practiceData.sectionIndex === 0 &&
data.practiceData.sentenceIndex === 0 &&
data.practiceData.wordIndex === 0
) {
throw new Error()
}
@@ -255,7 +279,7 @@ async function complete() {
}
if (CAN_REQUEST) {
let res = await addStat({...data, type: 'article'})
let res = await addStat({ ...data, type: 'article' })
if (!res.success) {
Toast.error(res.msg)
}
@@ -344,6 +368,7 @@ async function changeArticle(val: ArticleItem) {
}
}
}
initAudio()
}
const handlePlayNext = (nextArticle: Article) => {
@@ -372,7 +397,6 @@ function onKeyUp() {
}
async function onKeyDown(e: KeyboardEvent) {
// console.log('e', e)
switch (e.key) {
case 'Backspace':
typingArticleRef.del()
@@ -414,13 +438,13 @@ onUnmounted(() => {
timer && clearInterval(timer)
})
let audioRef = $ref<HTMLAudioElement>()
const {playSentenceAudio} = usePlaySentenceAudio()
const { playSentenceAudio } = usePlaySentenceAudio()
function play2(e) {
if (settingStore.articleSound || e.handle) {
playSentenceAudio(e.sentence, audioRef)
}
nextTick(() => {
if (settingStore.articleSound || e.handle) {
playSentenceAudio(e.sentence, audioRef)
}
})
}
const currentPractice = computed(() => {
@@ -433,43 +457,26 @@ const currentPractice = computed(() => {
provide('currentPractice', currentPractice)
</script>
<template>
<PracticeLayout
v-loading="loading"
panelLeft="var(--article-panel-margin-left)">
<PracticeLayout v-loading="loading" panelLeft="var(--article-panel-margin-left)">
<template v-slot:practice>
<TypingArticle
ref="typingArticleRef"
@wrong="wrong"
@next="next"
@nextWord="nextWord"
@play="play2"
@replay="setArticle(articleData.article)"
@complete="complete"
:article="articleData.article"
/>
<TypingArticle ref="typingArticleRef" @wrong="wrong" @next="next" @nextWord="nextWord" @play="play2"
@replay="setArticle(articleData.article)" @complete="complete" :article="articleData.article" />
</template>
<template v-slot:panel>
<Panel :style="{width:'var(--article-panel-width)'}">
<Panel :style="{ width: 'var(--article-panel-width)' }">
<template v-slot:title>
<span>{{
store.sbook.name
}} ({{ store.sbook.lastLearnIndex + 1 }} / {{ articleData.list.length }})</span>
<span>{{
store.sbook.name
}} ({{ store.sbook.lastLearnIndex + 1 }} / {{ articleData.list.length }})</span>
</template>
<div class="panel-page-item pl-4">
<ArticleList
:isActive="settingStore.showPanel"
:static="false"
:show-translate="settingStore.translate"
@click="changeArticle"
:active-id="articleData.article.id"
:list="articleData.list ">
<template v-slot:suffix="{item,index}">
<BaseIcon
:class="!isArticleCollect(item) ? 'collect' : 'fill'"
@click.stop="toggleArticleCollect(item)"
:title="!isArticleCollect(item) ? '收藏' : '取消收藏'">
<IconFluentStar16Regular v-if="!isArticleCollect(item)"/>
<IconFluentStar16Filled v-else/>
<ArticleList :isActive="settingStore.showPanel" :static="false" :show-translate="settingStore.translate"
@click="changeArticle" :active-id="articleData.article.id" :list="articleData.list">
<template v-slot:suffix="{ item, index }">
<BaseIcon :class="!isArticleCollect(item) ? 'collect' : 'fill'" @click.stop="toggleArticleCollect(item)"
:title="!isArticleCollect(item) ? '收藏' : '取消收藏'">
<IconFluentStar16Regular v-if="!isArticleCollect(item)" />
<IconFluentStar16Filled v-else />
</BaseIcon>
</template>
</ArticleList>
@@ -478,12 +485,9 @@ provide('currentPractice', currentPractice)
</template>
<template v-slot:footer>
<div class="footer">
<Tooltip :title="settingStore.showToolbar?'收起':'展开'">
<IconFluentChevronLeft20Filled
@click="settingStore.showToolbar = !settingStore.showToolbar"
class="arrow"
:class="!settingStore.showToolbar && 'down'"
color="#999"/>
<Tooltip :title="settingStore.showToolbar ? '收起' : '展开'">
<IconFluentChevronLeft20Filled @click="settingStore.showToolbar = !settingStore.showToolbar" class="arrow"
:class="!settingStore.showToolbar && 'down'" color="#999" />
</Tooltip>
<div class="bottom">
<div class="flex justify-between items-center gap-2">
@@ -502,7 +506,7 @@ provide('currentPractice', currentPractice)
<div class="num center gap-1">
{{ statStore.total }}
<Tooltip>
<IconFluentQuestionCircle20Regular width="18"/>
<IconFluentQuestionCircle20Regular width="18" />
<template #reference>
<div>
统计词数{{ settingStore.ignoreSimpleWord ? '不包含' : '包含' }}简单词不包含已掌握
@@ -515,37 +519,29 @@ provide('currentPractice', currentPractice)
<div class="name">单词总数</div>
</div>
</div>
<ArticleAudio
ref="audioRef"
:article="articleData.article"
:autoplay="settingStore.articleAutoPlayNext"
@ended="settingStore.articleAutoPlayNext && next()"></ArticleAudio>
<ArticleAudio ref="audioRef" :article="articleData.article" :autoplay="settingStore.articleAutoPlayNext"
@ended="settingStore.articleAutoPlayNext && next()" @update-speed="handleSpeedUpdate"
@update-volume="handleVolumeUpdate"></ArticleAudio>
<div class="flex flex-col items-center justify-center gap-1">
<div class="flex gap-2 center">
<VolumeSetting/>
<BaseIcon
:title="`下一句(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
@click="skip">
<IconFluentArrowBounce20Regular class="transform-rotate-180"/>
<VolumeSetting />
<BaseIcon :title="`下一句(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`" @click="skip">
<IconFluentArrowBounce20Regular class="transform-rotate-180" />
</BaseIcon>
<BaseIcon
:title="`重听(${settingStore.shortcutKeyMap[ShortcutKey.PlayWordPronunciation]})`"
@click="play">
<IconFluentReplay20Regular/>
<BaseIcon :title="`重听(${settingStore.shortcutKeyMap[ShortcutKey.PlayWordPronunciation]})`"
@click="play">
<IconFluentReplay20Regular />
</BaseIcon>
<BaseIcon
@click="settingStore.dictation = !settingStore.dictation"
:title="`开关默写模式(${settingStore.shortcutKeyMap[ShortcutKey.ToggleDictation]})`"
>
<IconFluentEyeOff16Regular v-if="settingStore.dictation"/>
<IconFluentEye16Regular v-else/>
<BaseIcon @click="settingStore.dictation = !settingStore.dictation"
:title="`开关默写模式(${settingStore.shortcutKeyMap[ShortcutKey.ToggleDictation]})`">
<IconFluentEyeOff16Regular v-if="settingStore.dictation" />
<IconFluentEye16Regular v-else />
</BaseIcon>
<BaseIcon
:title="`开关释义显示(${settingStore.shortcutKeyMap[ShortcutKey.ToggleShowTranslate]})`"
@click="settingStore.translate = !settingStore.translate">
<IconFluentTranslate16Regular v-if="settingStore.translate"/>
<IconFluentTranslateOff16Regular v-else/>
<BaseIcon :title="`开关释义显示(${settingStore.shortcutKeyMap[ShortcutKey.ToggleShowTranslate]})`"
@click="settingStore.translate = !settingStore.translate">
<IconFluentTranslate16Regular v-if="settingStore.translate" />
<IconFluentTranslateOff16Regular v-else />
</BaseIcon>
<!-- <BaseIcon-->
@@ -553,10 +549,9 @@ provide('currentPractice', currentPractice)
<!-- icon="tabler:edit"-->
<!-- @click="emitter.emit(ShortcutKey.EditArticle)"-->
<!-- />-->
<BaseIcon
@click="settingStore.showPanel = !settingStore.showPanel"
:title="`面板(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`">
<IconFluentTextListAbcUppercaseLtr20Regular/>
<BaseIcon @click="settingStore.showPanel = !settingStore.showPanel"
:title="`面板(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`">
<IconFluentTextListAbcUppercaseLtr20Regular />
</BaseIcon>
</div>
</div>
@@ -566,17 +561,12 @@ provide('currentPractice', currentPractice)
</template>
</PracticeLayout>
<EditSingleArticleModal
v-model="showEditArticle"
:article="editArticle"
@save="saveArticle"
/>
<EditSingleArticleModal v-model="showEditArticle" :article="editArticle" @save="saveArticle" />
<ConflictNotice/>
<ConflictNotice />
</template>
<style scoped lang="scss">
.footer {
width: var(--article-toolbar-width);

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Article } from "@/types/types.ts";
import { watch } from "vue";
import { ref, watch, nextTick } from "vue";
import { get } from "idb-keyval";
import Audio from "@/components/base/Audio.vue";
import { LOCAL_FILE_KEY } from "@/config/env.ts";
@@ -10,12 +10,43 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
ended: []
(e: 'ended'): [],
(e: 'update-volume', volume: number): void,
(e: 'update-speed', volume: number): void
}>();
let file = $ref(null)
let instance = $ref<{ audioRef: HTMLAudioElement }>({audioRef: null})
let instance = $ref<{ audioRef: HTMLAudioElement }>({ audioRef: null })
const pendingUpdates = ref({})
const handleVolumeUpdate = (volume: number) => {
emit('update-volume', volume)
}
const handleSpeedUpdate = (speed: number) => {
emit('update-speed', speed)
}
const setAudioRefValue = (key: string, value: any) => {
if (instance?.audioRef) {
switch (key) {
case 'currentTime':
instance.audioRef.currentTime = value;
break;
case 'volume':
instance.audioRef.volume = value;
break;
case 'playbackRate':
instance.audioRef.playbackRate = value;
break;
default:
break
}
} else {
// 如果audioRef还未初始化先存起来等初始化后再设置 => watch监听instance变化
pendingUpdates.value[key] = value
}
}
watch(() => props.article.audioFileId, async () => {
if (!props.article.audioSrc && props.article.audioFileId) {
@@ -29,7 +60,15 @@ watch(() => props.article.audioFileId, async () => {
} else {
file = null
}
}, {immediate: true})
}, { immediate: true })
// 监听instance变化设置之前pending的值
watch(() => instance, (newVal) => {
Object.entries(pendingUpdates.value).forEach(([key, value]) => {
setAudioRefValue(key, value)
});
pendingUpdates.value = {};
}, { immediate: true })
//转发一遍这里Proxy的默认值不能为{}可能是vue做了什么
defineExpose(new Proxy({
@@ -52,21 +91,18 @@ defineExpose(new Proxy({
return target[key]
},
set(_, key, value) {
if (key === 'currentTime') instance.audioRef.currentTime = value
if (key === 'volume') return instance.audioRef.volume = value
setAudioRefValue(key as string, value)
return true
}
}))
</script>
<template>
<Audio v-bind="$attrs" ref="instance"
v-if="props.article.audioSrc"
:src="props.article.audioSrc"
@ended="emit('ended')"/>
<Audio v-bind="$attrs" ref="instance"
v-else-if="file"
:src="file"
@ended="emit('ended')"
/>
<Audio v-bind="$attrs" ref="instance" v-if="props.article.audioSrc" :src="props.article.audioSrc"
@ended="emit('ended')" @update-volume="handleVolumeUpdate" @update-speed="handleSpeedUpdate" />
<Audio v-bind="$attrs" ref="instance" v-else-if="file" :src="file" @ended="emit('ended')"
@update-volume="handleVolumeUpdate" @update-speed="handleSpeedUpdate" />
</template>

View File

@@ -1,48 +1,48 @@
import {defineStore} from "pinia"
import {checkAndUpgradeSaveSetting, cloneDeep} from "@/utils";
import {DefaultShortcutKeyMap, WordPracticeMode, WordPracticeType} from "@/types/types.ts";
import {get} from "idb-keyval";
import {CAN_REQUEST, SAVE_SETTING_KEY} from "@/config/env.ts";
import {getSetting} from "@/apis";
import { defineStore } from "pinia"
import { checkAndUpgradeSaveSetting, cloneDeep } from "@/utils"
import { DefaultShortcutKeyMap, WordPracticeMode, WordPracticeType } from "@/types/types.ts"
import { get } from "idb-keyval"
import { CAN_REQUEST, SAVE_SETTING_KEY } from "@/config/env.ts"
import { getSetting } from "@/apis"
export interface SettingState {
soundType: string,
soundType: string
wordSound: boolean,
wordSoundVolume: number,
wordSoundSpeed: number,
wordSound: boolean
wordSoundVolume: number
wordSoundSpeed: number
articleSound: boolean,
articleAutoPlayNext: boolean,
articleSoundVolume: number,
articleSoundSpeed: number,
articleSound: boolean
articleAutoPlayNext: boolean
articleSoundVolume: number
articleSoundSpeed: number
keyboardSound: boolean,
keyboardSoundVolume: number,
keyboardSoundFile: string,
keyboardSound: boolean
keyboardSoundVolume: number
keyboardSoundFile: string
effectSound: boolean,
effectSoundVolume: number,
effectSound: boolean
effectSoundVolume: number
repeatCount: number, //重复次数
repeatCustomCount?: number, //自定义重复次数
dictation: boolean,//显示默写
translate: boolean, //显示翻译
repeatCount: number //重复次数
repeatCustomCount?: number //自定义重复次数
dictation: boolean //显示默写
translate: boolean //显示翻译
showNearWord: boolean //显示上/下一个词
ignoreCase: boolean //忽略大小写
allowWordTip: boolean //默写时时否允许查看提示
waitTimeForChangeWord: number // 切下一个词的等待时间
fontSize: {
articleForeignFontSize: number,
articleTranslateFontSize: number,
wordForeignFontSize: number,
wordTranslateFontSize: number,
},
showToolbar: boolean, //收起/展开工具栏
showPanel: boolean, // 收起/展开面板
sideExpand: boolean, //收起/展开左侧侧边栏
theme: string,
shortcutKeyMap: Record<string, string>,
articleForeignFontSize: number
articleTranslateFontSize: number
wordForeignFontSize: number
wordTranslateFontSize: number
}
showToolbar: boolean //收起/展开工具栏
showPanel: boolean // 收起/展开面板
sideExpand: boolean //收起/展开左侧侧边栏
theme: string
shortcutKeyMap: Record<string, string>
first: boolean
firstTime: number
load: boolean
@@ -57,7 +57,7 @@ export interface SettingState {
}
export const getDefaultSettingState = (): SettingState => ({
soundType: 'us',
soundType: "us",
wordSound: true,
wordSoundVolume: 100,
@@ -70,7 +70,7 @@ export const getDefaultSettingState = (): SettingState => ({
keyboardSound: true,
keyboardSoundVolume: 100,
keyboardSoundFile: '笔记本键盘',
keyboardSoundFile: "笔记本键盘",
effectSound: true,
effectSoundVolume: 100,
@@ -87,12 +87,12 @@ export const getDefaultSettingState = (): SettingState => ({
articleForeignFontSize: 48,
articleTranslateFontSize: 20,
wordForeignFontSize: 48,
wordTranslateFontSize: 20,
wordTranslateFontSize: 20
},
showToolbar: true,
showPanel: true,
sideExpand: false,
theme: 'auto',
theme: "auto",
shortcutKeyMap: cloneDeep(DefaultShortcutKeyMap),
first: true,
firstTime: Date.now(),
@@ -107,7 +107,7 @@ export const getDefaultSettingState = (): SettingState => ({
ignoreSymbol: true
})
export const useSettingStore = defineStore('setting', {
export const useSettingStore = defineStore("setting", {
state: (): SettingState => {
return getDefaultSettingState()
},
@@ -116,7 +116,7 @@ export const useSettingStore = defineStore('setting', {
this.$patch(obj)
},
init() {
return new Promise(async resolve => {
return new Promise(async (resolve) => {
//TODO 后面记得删除了
let configStr = localStorage.getItem(SAVE_SETTING_KEY.key)
let configStr2 = await get(SAVE_SETTING_KEY.key)
@@ -131,9 +131,9 @@ export const useSettingStore = defineStore('setting', {
Object.assign(data, res.data)
}
}
this.setState({...data, load: true})
this.setState({ ...data, load: true })
resolve(true)
})
}
}
},
})