This commit is contained in:
Zyronon
2026-01-05 19:34:32 +08:00
committed by GitHub
parent 4fd3e51961
commit 0c86906b4d
15 changed files with 819 additions and 3883 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ const props = withDefaults(
defineProps<{
loading?: boolean
showToolbar?: boolean
showCheckbox?: boolean
showPagination?: boolean
exportLoading?: boolean
importLoading?: boolean
@@ -26,6 +27,7 @@ const props = withDefaults(
}>(),
{
loading: true,
showCheckbox: false,
showToolbar: true,
showPagination: true,
exportLoading: false,
@@ -48,6 +50,7 @@ const emit = defineEmits<{
}>()
let listRef: any = $ref()
let showCheckbox = $ref(false)
function scrollToBottom() {
nextTick(() => {
@@ -167,11 +170,7 @@ onMounted(async () => {
defineRender(() => {
const d = item => (
<Checkbox
modelValue={selectIds.includes(item.id)}
onChange={() => toggleSelect(item)}
size="large"
/>
<Checkbox modelValue={selectIds.includes(item.id)} onChange={() => toggleSelect(item)} size="large" />
)
return (
@@ -194,27 +193,33 @@ defineRender(() => {
<BaseButton onClick={cancelSearch}>取消</BaseButton>
</div>
) : (
<div class="flex justify-between">
<div class="flex gap-2 items-center">
<Checkbox
disabled={!params.list.length}
onChange={() => toggleSelectAll()}
modelValue={selectAll}
size="large"
/>
<span>
<div class="flex justify-between items-center">
{showCheckbox ? (
<div class="flex gap-2 items-center">
<Checkbox
disabled={!params.list.length}
onChange={() => toggleSelectAll()}
modelValue={selectAll}
size="large"
/>
<span>
{selectIds.length} / {params.total}
</span>
</div>
</div>
) : <div>{params.total}</div>}
<div class="flex gap-2 relative">
{selectIds.length ? (
{selectIds.length && showCheckbox ? (
<PopConfirm title="确认删除所有选中数据?" onConfirm={handleBatchDel}>
<BaseIcon class="del" title="删除">
<DeleteIcon />
</BaseIcon>
<BaseButton type="info">确认</BaseButton>
</PopConfirm>
) : null}
<BaseIcon onClick={() => (showCheckbox = !showCheckbox)} title="批量删除">
<DeleteIcon />
</BaseIcon>
<BaseIcon onClick={() => (showImportDialog = true)} title="导入">
<IconSystemUiconsImport />
</BaseIcon>
@@ -265,7 +270,7 @@ defineRender(() => {
return (
<div class="list-item-wrapper" key={item.word}>
{s.default({
checkbox: d,
checkbox: showCheckbox ? d : () => void 0,
item,
index: params.pageSize * (params.pageNo - 1) + index + 1,
})}
@@ -297,11 +302,7 @@ defineRender(() => {
</div>
)}
<Dialog
modelValue={showImportDialog}
onUpdate:modelValue={closeImportDialog}
title="导入教程"
>
<Dialog modelValue={showImportDialog} onUpdate:modelValue={closeImportDialog} title="导入教程">
<div className="w-100 p-4 pt-0">
<div>请按照模板的格式来填写数据</div>
<div class="color-red">单词项为必填其他项可不填</div>

View File

@@ -418,8 +418,6 @@ defineExpose({ audioRef })
<!-- 进度条区域 -->
<div class="progress-section">
<!-- 时间显示 -->
<span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
<!-- 进度条 -->
<div class="progress-container" @mousedown="handleProgressMouseDown" ref="progressBarRef">
<div class="progress-track">
@@ -427,7 +425,8 @@ defineExpose({ audioRef })
<div class="progress-thumb" :style="{ left: progress + '%' }"></div>
</div>
</div>
<!-- 时间显示 -->
<span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
</div>
<!-- 音量控制 -->

View File

@@ -1,14 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import BaseInput from "@/components/base/BaseInput.vue";
import { computed, onMounted, onUnmounted, ref } from 'vue'
import BaseInput from '@/components/base/BaseInput.vue'
interface IProps {
currentPage?: number;
pageSize?: number;
pageSizes?: number[];
layout?: string;
total: number;
hideOnSinglePage?: boolean;
currentPage?: number
pageSize?: number
pageSizes?: number[]
layout?: string
total: number
hideOnSinglePage?: boolean
}
const props = withDefaults(defineProps<IProps>(), {
@@ -17,55 +17,68 @@ const props = withDefaults(defineProps<IProps>(), {
pageSizes: () => [10, 20, 30, 40, 50, 100],
layout: 'prev, pager, next',
hideOnSinglePage: false,
});
})
const emit = defineEmits<{
'update:currentPage': [val: number];
'update:pageSize': [val: number];
'size-change': [val: number];
'current-change': [val: number];
}>();
'update:currentPage': [val: number]
'update:pageSize': [val: number]
'size-change': [val: number]
'current-change': [val: number]
}>()
const internalCurrentPage = ref(props.currentPage);
const internalPageSize = ref(props.pageSize);
const internalCurrentPage = ref(props.currentPage)
const jumpTarget = $ref('')
const internalPageSize = ref(props.pageSize)
// 计算总页数
const pageCount = computed(() => {
return Math.max(1, Math.ceil(props.total / internalPageSize.value));
});
return Math.max(1, Math.ceil(props.total / internalPageSize.value))
})
// 可用于显示的页码数量,会根据容器宽度动态计算
const availablePagerCount = ref(5); // 默认值
const availablePagerCount = ref(5) // 默认值
// 是否显示分页
const shouldShow = computed(() => {
return props.hideOnSinglePage ? pageCount.value > 1 : true;
});
return props.hideOnSinglePage ? pageCount.value > 1 : true
})
// 处理页码变化
function jumpPage(val: number) {
if (Number(val) > pageCount.value) val = pageCount.value;
if (Number(val) <= 0) val = 1;
internalCurrentPage.value = val;
emit('update:currentPage', Number(val));
emit('current-change', Number(val));
if (Number(val) > pageCount.value) val = pageCount.value
if (Number(val) <= 0) val = 1
internalCurrentPage.value = val
emit('update:currentPage', Number(val))
emit('current-change', Number(val))
}
function jumpToTarget() {
let d = Number(jumpTarget)
if (d > pageCount.value) {
// 这里如果目标值大于页码,那么将目标值作为下标计算,计算出对应的页码再跳转
// 按目标值-1整除每页数量定位属于第几页
let page = Math.floor((d - 1) / internalPageSize.value) + 1
jumpPage(page)
} else {
jumpPage(d)
}
}
// 处理每页条数变化
function handleSizeChange(val: number) {
internalPageSize.value = val;
emit('update:pageSize', val);
emit('size-change', val);
internalPageSize.value = val
emit('update:pageSize', val)
emit('size-change', val)
// 重新计算可用页码数量
calculateAvailablePagerCount();
calculateAvailablePagerCount()
// 重新计算当前页,确保当前页在有效范围内
const newPageCount = Math.ceil(props.total / val);
const newPageCount = Math.ceil(props.total / val)
if (internalCurrentPage.value > newPageCount) {
internalCurrentPage.value = newPageCount;
emit('update:currentPage', newPageCount);
emit('current-change', newPageCount);
internalCurrentPage.value = newPageCount
emit('update:currentPage', newPageCount)
emit('current-change', newPageCount)
}
}
@@ -73,97 +86,85 @@ function handleSizeChange(val: number) {
function calculateAvailablePagerCount() {
// 在下一个渲染周期执行确保DOM已更新
setTimeout(() => {
const paginationEl = document.querySelector('.pagination') as HTMLElement;
if (!paginationEl) return;
const paginationEl = document.querySelector('.pagination') as HTMLElement
if (!paginationEl) return
const containerWidth = paginationEl.offsetWidth;
const buttonWidth = 38; // 按钮宽度包括margin
const availableWidth = containerWidth - 120; // 减去其他元素占用的空间(前后按钮等)
const containerWidth = paginationEl.offsetWidth
const buttonWidth = 38 // 按钮宽度包括margin
const availableWidth = containerWidth - 120 // 减去其他元素占用的空间(前后按钮等)
// 计算可以显示多少个页码按钮
const maxPagers = Math.max(3, Math.floor(availableWidth / buttonWidth) - 2); // 减2是因为第一页和最后一页始终显示
availablePagerCount.value = maxPagers;
}, 0);
const maxPagers = Math.max(3, Math.floor(availableWidth / buttonWidth) - 2) // 减2是因为第一页和最后一页始终显示
availablePagerCount.value = maxPagers
}, 0)
}
// 监听窗口大小变化
onMounted(() => {
window.addEventListener('resize', calculateAvailablePagerCount);
window.addEventListener('resize', calculateAvailablePagerCount)
// 初始计算
calculateAvailablePagerCount();
});
calculateAvailablePagerCount()
})
// 组件卸载时移除监听器
onUnmounted(() => {
window.removeEventListener('resize', calculateAvailablePagerCount);
window.removeEventListener('resize', calculateAvailablePagerCount)
})
// 上一页
function prev() {
const newPage = internalCurrentPage.value - 1;
const newPage = internalCurrentPage.value - 1
if (newPage >= 1) {
jumpPage(newPage);
jumpPage(newPage)
}
}
// 下一页
function next() {
const newPage = internalCurrentPage.value + 1;
const newPage = internalCurrentPage.value + 1
if (newPage <= pageCount.value) {
jumpPage(newPage);
jumpPage(newPage)
}
}
</script>
<template>
<div class="pagination" v-if="shouldShow">
<div class="pagination-container">
<!-- 总数 -->
<span v-if="layout.includes('total')" class="total text-base"> {{ total }} </span>
<!-- 上一页 -->
<button
class="btn-prev"
:disabled="internalCurrentPage <= 1"
@click="prev"
>
<IconFluentChevronLeft20Filled/>
<button class="btn-prev" :disabled="internalCurrentPage <= 1" @click="prev">
<IconFluentChevronLeft20Filled />
</button>
<!-- 页码 -->
<div class="flex items-center">
<div class="w-12">
<BaseInput v-model="internalCurrentPage"
@enter="jumpPage(internalCurrentPage)"
class="text-center"/>
<BaseInput v-model="internalCurrentPage" @enter="jumpPage(internalCurrentPage)" class="text-center" />
</div>
<span class="mx-2">/</span>
<span class="text-base">{{ pageCount }}</span>
</div>
<!-- 下一页 -->
<button
class="btn-next"
:disabled="internalCurrentPage >= pageCount"
@click="next"
>
<IconFluentChevronLeft20Filled class="transform-rotate-180"/>
<button class="btn-next" :disabled="internalCurrentPage >= pageCount" @click="next">
<IconFluentChevronLeft20Filled class="transform-rotate-180" />
</button>
<!-- 每页条数选择器 -->
<div v-if="layout.includes('sizes')" class="sizes">
<select
:value="internalPageSize"
@change="handleSizeChange(Number($event.target.value))"
>
<option v-for="item in pageSizes" :key="item" :value="item">
{{ item }}/
</option>
<select :value="internalPageSize" @change="handleSizeChange(Number($event.target.value))">
<option v-for="item in pageSizes" :key="item" :value="item">{{ item }}/</option>
</select>
</div>
<!-- 总数 -->
<span v-if="layout.includes('total')" class="total text-base">
{{ total }}
</span>
<div class="flex items-center gap-1 ml-2">
跳至
<div class="w-15">
<BaseInput placeholder="页/序号" v-model="jumpTarget" @enter="jumpToTarget" class="text-center" />
</div>
</div>
</div>
</div>
</template>
@@ -186,7 +187,8 @@ function next() {
justify-content: flex-end;
}
.btn-prev, .btn-next {
.btn-prev,
.btn-next {
display: inline-flex;
justify-content: center;
align-items: center;
@@ -200,7 +202,7 @@ function next() {
padding: 0 0.375rem;
margin: 0.25rem 0.25rem;
background-color: transparent;
transition: all .3s;
transition: all 0.3s;
&:disabled {
opacity: 0.3;
@@ -216,7 +218,7 @@ function next() {
.sizes {
border: 1px solid var(--color-input-border);
border-radius: 0.25rem;
padding-right: .2rem;
padding-right: 0.2rem;
background-color: var(--color-bg);
overflow: hidden;
@@ -242,7 +244,6 @@ function next() {
}
.total {
margin: 0.25rem 0.5rem;
color: var(--color-main-text);
}
}

View File

@@ -71,44 +71,29 @@ const ballSize = computed(() => switchHeight.value - 4);
<style scoped lang="scss">
.switch {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
outline: none;
background-color: #DCDFE6;
position: relative;
transition: background-color 0.3s;
@apply inline-flex items-center cursor-pointer user-select-none outline-none bg-gray-200 position-relative transition-all duration-300;
&.disabled {
cursor: not-allowed;
opacity: 0.6;
@apply cursor-not-allowed opacity-60;
}
&.checked {
background-color: #409eff;
@apply bg-blue-500;
}
.ball {
background-color: #fff;
border-radius: 50%;
transition: transform 0.3s;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.2);
position: absolute;
@apply bg-white rounded-full transition-transform duration-300 box-shadow-sm absolute;
}
.text {
position: absolute;
@apply absolute text-xs text-white user-select-none;
font-size: 0.75rem;
color: #fff;
user-select: none;
&.left {
margin-left: 6px;
@apply ml-1.5;
}
&.right {
right: 6px;
@apply right-1.5;
}
}
}

View File

@@ -111,8 +111,8 @@ defineExpose({scrollBottom})
</template>
</BaseInput>
</div>
<transition-group name="drag" class="list" tag="div">
<div class="item"
<transition-group name="drag" class="space-y-3" tag="div">
<div class="common-list-item"
:class="[
(selectItem.id === item.id) && 'active',
draggable && 'draggable',
@@ -178,42 +178,5 @@ defineExpose({scrollBottom})
.search {
margin: .6rem 0;
}
.list {
.item {
box-sizing: border-box;
background: var(--color-second);
color: var(--color-font-1);
border-radius: .5rem;
margin-bottom: .6rem;
padding: .6rem;
display: flex;
justify-content: space-between;
transition: all .3s;
.right {
display: flex;
flex-direction: column;
transition: all .3s;
opacity: 0;
}
&:hover {
background: var(--color-third);
.right {
opacity: 1;
}
}
&.active {
background: var(--color-fourth);
color: var(--color-font-1);
}
&.draggable {
cursor: move;
}
}
}
}
</style>

View File

@@ -1,43 +1,33 @@
<script setup lang="ts">
import Switch from '@/components/base/Switch.vue'
import Slider from '@/components/base/Slider.vue'
import SettingItem from '@/pages/setting/SettingItem.vue'
import { useSettingStore } from '@/stores/setting.ts'
import Switch from "@/components/base/Switch.vue";
import Slider from "@/components/base/Slider.vue";
import SettingItem from "@/pages/setting/SettingItem.vue";
import { useSettingStore } from "@/stores/setting.ts";
const settingStore = useSettingStore()
</script>
<template>
<!-- 文章练习设置-->
<!-- 文章练习设置-->
<!-- 文章练习设置-->
<div>
<!-- 发音-->
<!-- 发音-->
<!-- 发音-->
<SettingItem mainTitle="音效"/>
<SettingItem mainTitle="音效" />
<SettingItem title="自动播放句子">
<Switch v-model="settingStore.articleSound"/>
<Switch v-model="settingStore.articleSound" />
</SettingItem>
<SettingItem title="自动播放下一篇">
<Switch v-model="settingStore.articleAutoPlayNext"/>
<SettingItem title="结束后播放下一篇">
<Switch v-model="settingStore.articleAutoPlayNext" />
</SettingItem>
<SettingItem title="音量">
<Slider v-model="settingStore.articleSoundVolume" showText showValue unit="%"/>
<Slider v-model="settingStore.articleSoundVolume" showText showValue unit="%" />
</SettingItem>
<SettingItem title="倍速">
<Slider v-model="settingStore.articleSoundSpeed" :step="0.1" :min="0.5" :max="3" showText showValue/>
<Slider v-model="settingStore.articleSoundSpeed" :step="0.1" :min="0.5" :max="3" showText showValue />
</SettingItem>
<div class="line"></div>
<SettingItem title="输入时忽略符号/数字/人名">
<Switch v-model="settingStore.ignoreSymbol"/>
<Switch v-model="settingStore.ignoreSymbol" />
</SettingItem>
</div>
</template>
<style scoped lang="scss">
</style>
<style scoped lang="scss"></style>

View File

@@ -25,9 +25,6 @@ const simpleWords = $computed({
</script>
<template>
<!-- 通用练习设置-->
<!-- 通用练习设置-->
<!-- 通用练习设置-->
<div>
<SettingItem
title="忽略大小写"

View File

@@ -12,17 +12,7 @@ const settingStore = useSettingStore()
</script>
<template>
<!-- 单词练习设置-->
<!-- 单词练习设置-->
<!-- 单词练习设置-->
<div>
<!-- <SettingItem title="练习模式">-->
<!-- <RadioGroup v-model="settingStore.wordPracticeMode" class="flex-col gap-0!">-->
<!-- <Radio :value="WordPracticeMode.System" label="智能模式:自动规划学习、复习、听写、默写"/>-->
<!-- <Radio :value="WordPracticeMode.Free" label="自由模式:系统不强制复习与默写"/>-->
<!-- </RadioGroup>-->
<!-- </SettingItem>-->
<SettingItem title="显示上一个/下一个单词"
desc="开启后,练习中会在上方显示上一个/下一个单词"
>
@@ -134,4 +124,4 @@ const settingStore = useSettingStore()
<style scoped lang="scss">
</style>
</style>

View File

@@ -1,22 +1,22 @@
<script setup lang="ts">
import {Article, DictId} from "@/types/types.ts";
import BaseButton from "@/components/BaseButton.vue";
import {_nextTick, cloneDeep, loadJsLib} from "@/utils";
import {useBaseStore} from "@/stores/base.ts";
import { Article, DictId } from '@/types/types.ts'
import BaseButton from '@/components/BaseButton.vue'
import { _nextTick, cloneDeep, loadJsLib } from '@/utils'
import { useBaseStore } from '@/stores/base.ts'
import List from "@/components/list/List.vue";
import {useWindowClick} from "@/hooks/event.ts";
import {MessageBox} from "@/utils/MessageBox.tsx";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {nanoid} from "nanoid";
import EditArticle from "@/pages/article/components/EditArticle.vue";
import List from '@/components/list/List.vue'
import { useWindowClick } from '@/hooks/event.ts'
import { MessageBox } from '@/utils/MessageBox.tsx'
import { useRuntimeStore } from '@/stores/runtime.ts'
import { nanoid } from 'nanoid'
import EditArticle from '@/pages/article/components/EditArticle.vue'
import Toast from '@/components/base/toast/Toast.ts'
import {getDefaultArticle} from "@/types/func.ts";
import BackIcon from "@/components/BackIcon.vue";
import MiniDialog from "@/components/dialog/MiniDialog.vue";
import {onMounted} from "vue";
import { LIB_JS_URL, Origin } from "@/config/env.ts";
import {syncBookInMyStudyList} from "@/hooks/article.ts";
import { getDefaultArticle } from '@/types/func.ts'
import BackIcon from '@/components/BackIcon.vue'
import MiniDialog from '@/components/dialog/MiniDialog.vue'
import { onMounted } from 'vue'
import { LIB_JS_URL, Origin } from '@/config/env.ts'
import { syncBookInMyStudyList } from '@/hooks/article.ts'
const base = useBaseStore()
const runtimeStore = useRuntimeStore()
@@ -43,31 +43,31 @@ function checkDataChange() {
editArticle.textTranslate = editArticle.textTranslate.trim()
if (
editArticle.title !== article.title ||
editArticle.titleTranslate !== article.titleTranslate ||
editArticle.text !== article.text ||
editArticle.textTranslate !== article.textTranslate
editArticle.title !== article.title ||
editArticle.titleTranslate !== article.titleTranslate ||
editArticle.text !== article.text ||
editArticle.textTranslate !== article.textTranslate
) {
return MessageBox.confirm(
'检测到数据有变动,是否保存?',
'提示',
async () => {
let r = await editArticleRef.save('save')
if (r) resolve(true)
},
() => resolve(true),
'检测到数据有变动,是否保存?',
'提示',
async () => {
let r = await editArticleRef.save('save')
if (r) resolve(true)
},
() => resolve(true)
)
}
} else {
if (editArticle.title.trim() && editArticle.text.trim()) {
return MessageBox.confirm(
'检测到数据有变动,是否保存?',
'提示',
async () => {
let r = await editArticleRef.save('save')
if (r) resolve(true)
},
() => resolve(true),
'检测到数据有变动,是否保存?',
'提示',
async () => {
let r = await editArticleRef.save('save')
if (r) resolve(true)
},
() => resolve(true)
)
}
}
@@ -114,7 +114,7 @@ function saveAndNext(val: Article) {
}
let showExport = $ref(false)
useWindowClick(() => showExport = false)
useWindowClick(() => (showExport = false))
onMounted(() => {
if (runtimeStore.editDict.articles.length) {
@@ -129,26 +129,28 @@ function importData(e: any) {
let file = e.target.files[0]
if (!file) return
// no()
let reader = new FileReader();
let reader = new FileReader()
reader.onload = async function (s) {
importLoading = true
const XLSX = await loadJsLib('XLSX', LIB_JS_URL.XLSX);
let data = s.target.result;
let workbook = XLSX.read(data, {type: 'binary'});
let res: any[] = XLSX.utils.sheet_to_json(workbook.Sheets['Sheet1']);
const XLSX = await loadJsLib('XLSX', LIB_JS_URL.XLSX)
let data = s.target.result
let workbook = XLSX.read(data, { type: 'binary' })
let res: any[] = XLSX.utils.sheet_to_json(workbook.Sheets['Sheet1'])
if (res.length) {
let articles = res.map(v => {
if (v['原文标题'] && v['原文正文']) {
return getDefaultArticle({
id: nanoid(6),
title: String(v['原文标题']),
titleTranslate: String(v['文标题']),
text: String(v['原文正文']),
textTranslate: String(v['文正文']),
audioSrc: String(v['音频地址']),
})
}
}).filter(v => v)
let articles = res
.map(v => {
if (v['原文标题'] && v['原文正文']) {
return getDefaultArticle({
id: nanoid(6),
title: String(v['文标题']),
titleTranslate: String(v['译文标题']),
text: String(v['文正文']),
textTranslate: String(v['译文正文']),
audioSrc: String(v['音频地址']),
})
}
})
.filter(v => v)
let repeat = []
let noRepeat = []
@@ -166,22 +168,22 @@ function importData(e: any) {
if (repeat.length) {
MessageBox.confirm(
'文章"' + repeat.map(v => v.title).join(', ') + '" 已存在,是否覆盖原有文章?',
'检测到重复文章',
() => {
repeat.map(v => {
runtimeStore.editDict.articles[v.index] = v
delete runtimeStore.editDict.articles[v.index]["index"]
})
setTimeout(listEl?.scrollToBottom, 100)
},
null,
() => {
e.target.value = ''
importLoading = false
syncBookInMyStudyList()
Toast.success('导入成功!')
}
'文章"' + repeat.map(v => v.title).join(', ') + '" 已存在,是否覆盖原有文章?',
'检测到重复文章',
() => {
repeat.map(v => {
runtimeStore.editDict.articles[v.index] = v
delete runtimeStore.editDict.articles[v.index]['index']
})
setTimeout(listEl?.scrollToBottom, 100)
},
null,
() => {
e.target.value = ''
importLoading = false
syncBookInMyStudyList()
Toast.success('导入成功!')
}
)
} else {
syncBookInMyStudyList()
@@ -192,14 +194,14 @@ function importData(e: any) {
}
e.target.value = ''
importLoading = false
};
reader.readAsBinaryString(file);
}
reader.readAsBinaryString(file)
}
async function exportData(val: { type: string, data?: Article }) {
async function exportData(val: { type: string; data?: Article }) {
exportLoading = true
const XLSX = await loadJsLib('XLSX', LIB_JS_URL.XLSX);
const {type, data} = val
const XLSX = await loadJsLib('XLSX', LIB_JS_URL.XLSX)
const { type, data } = val
let list = []
let filename = ''
if (type === 'item') {
@@ -224,7 +226,7 @@ async function exportData(val: { type: string, data?: Article }) {
})
wb.Sheets['Sheet1'] = XLSX.utils.json_to_sheet(sheetData)
wb.SheetNames = ['Sheet1']
XLSX.writeFile(wb, `${filename}.xlsx`);
XLSX.writeFile(wb, `${filename}.xlsx`)
Toast.success(filename + ' 导出成功!')
showExport = false
exportLoading = false
@@ -240,51 +242,48 @@ function updateList(e) {
<div class="add-article">
<div class="aslide">
<header class="flex gap-2 items-center">
<BackIcon/>
<BackIcon />
<div class="text-xl">{{ runtimeStore.editDict.name }}</div>
</header>
<List
ref="listEl"
:list="runtimeStore.editDict.articles"
@update:list="updateList"
:select-item="article"
@del-select-item="article = getDefaultArticle()"
@select-item="selectArticle"
ref="listEl"
:list="runtimeStore.editDict.articles"
@update:list="updateList"
:select-item="article"
@del-select-item="article = getDefaultArticle()"
@select-item="selectArticle"
>
<template v-slot="{item,index}">
<div class="name">
<span class="text-sm text-gray-500" v-if="index != undefined">
{{ index + 1}}.
</span>
{{ item.title }}</div>
<div class="translate-name"> {{ ` ${item.titleTranslate}` }}</div>
<template v-slot="{ item, index }">
<div>
<div class="name">
<span class="text-sm text-gray-500" v-if="index != undefined"> {{ index + 1 }}. </span>
{{ item.title }}
</div>
<div class="translate-name">{{ ` ${item.titleTranslate}` }}</div>
</div>
</template>
</List>
<div class="add" v-if="!article.title">
正在添加新文章...
</div>
<div class="add" v-if="!article.title">正在添加新文章...</div>
<div class="footer">
<div class="import">
<BaseButton :loading="importLoading">导入</BaseButton>
<input type="file"
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
@change="importData">
<input
type="file"
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
@change="importData"
/>
</div>
<div class="export"
style="position: relative"
@click.stop="null">
<div class="export" style="position: relative" @click.stop="null">
<BaseButton @click="showExport = true">导出</BaseButton>
<MiniDialog
v-model="showExport"
style="width: 8rem;bottom: calc(100% + 1rem);top:unset;"
>
<div class="mini-row-title">
导出选项
</div>
<MiniDialog v-model="showExport" style="width: 8rem; bottom: calc(100% + 1rem); top: unset">
<div class="mini-row-title">导出选项</div>
<div class="flex">
<BaseButton :loading="exportLoading" @click="exportData({type:'all'})">全部</BaseButton>
<BaseButton :loading="exportLoading" :disabled="!article.id"
@click="exportData({type:'item',data:article})">当前
<BaseButton :loading="exportLoading" @click="exportData({ type: 'all' })">全部</BaseButton>
<BaseButton
:loading="exportLoading"
:disabled="!article.id"
@click="exportData({ type: 'item', data: article })"
>当前
</BaseButton>
</div>
</MiniDialog>
@@ -292,24 +291,18 @@ function updateList(e) {
<BaseButton @click="add">新增</BaseButton>
</div>
</div>
<EditArticle
ref="editArticleRef"
type="batch"
@save="saveArticle"
@saveAndNext="saveAndNext"
:article="article"/>
<EditArticle ref="editArticleRef" type="batch" @save="saveArticle" @saveAndNext="saveAndNext" :article="article" />
</div>
</template>
<style scoped lang="scss">
.add-article {
width: 100%;
height: 100vh;
box-sizing: border-box;
color: var(--color-font-1);
display: flex;
background: var(--color-second);
.close {
position: absolute;
@@ -320,7 +313,7 @@ function updateList(e) {
.aslide {
width: 14vw;
height: 100%;
padding: 0 .6rem;
padding: 0 0.6rem;
display: flex;
flex-direction: column;
@@ -341,12 +334,12 @@ function updateList(e) {
.add {
width: 100%;
box-sizing: border-box;
border-radius: .5rem;
margin-bottom: .6rem;
padding: .6rem;
border-radius: 0.5rem;
margin-bottom: 0.6rem;
padding: 0.6rem;
display: flex;
justify-content: space-between;
transition: all .3s;
transition: all 0.3s;
color: var(--color-font-active-1);
background: var(--color-select-bg);
}
@@ -354,7 +347,7 @@ function updateList(e) {
.footer {
height: $height;
display: flex;
gap: .6rem;
gap: 0.6rem;
align-items: center;
justify-content: flex-end;

View File

@@ -1,16 +1,15 @@
<script setup lang="ts">
import BasePage from '@/components/BasePage.vue'
import BackIcon from '@/components/BackIcon.vue'
import Empty from '@/components/Empty.vue'
import ArticleList from '@/components/list/ArticleList.vue'
import { useBaseStore } from '@/stores/base.ts'
import { Article, Dict, DictId, DictType, ShortcutKey } from '@/types/types.ts'
import { Article, Dict, DictId, DictType } from '@/types/types.ts'
import { useRuntimeStore } from '@/stores/runtime.ts'
import BaseButton from '@/components/BaseButton.vue'
import { useRoute, useRouter } from 'vue-router'
import EditBook from '@/pages/article/components/EditBook.vue'
import { computed, onMounted } from 'vue'
import { _dateFormat, _getDictDataByUrl, msToHourMinute, resourceWrap, total, useNav } from '@/utils'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { _dateFormat, _getDictDataByUrl, isMobile, msToHourMinute, resourceWrap, total, useNav, _nextTick } from '@/utils'
import { getDefaultArticle, getDefaultDict } from '@/types/func.ts'
import Toast from '@/components/base/toast/Toast.ts'
import ArticleAudio from '@/pages/article/components/ArticleAudio.vue'
@@ -20,6 +19,7 @@ import { useFetch } from '@vueuse/core'
import { AppEnv, DICT_LIST } from '@/config/env.ts'
import { detail } from '@/apis'
import BaseIcon from '@/components/BaseIcon.vue'
import Switch from '@/components/base/Switch.vue'
const runtimeStore = useRuntimeStore()
const settingStore = useSettingStore()
@@ -49,12 +49,11 @@ function handleCheckedChange(val) {
selectArticle = val.item
}
async function addMyStudyList() {
async function startPractice() {
let sbook = runtimeStore.editDict
if (!sbook.articles.length) {
return Toast.warning('没有文章可学习!')
}
studyLoading = true
await base.changeBook(sbook)
studyLoading = false
@@ -108,7 +107,23 @@ async function init() {
}
}
onMounted(init)
onMounted(() => {
init()
if (displayMode === 'typing-style') {
positionTranslations()
}
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
function handleResize() {
if (displayMode === 'typing-style') {
positionTranslations()
}
}
function formClose() {
if (isEdit) isEdit = false
@@ -160,6 +175,7 @@ const totalSpend = $computed(() => {
function next() {
if (!settingStore.articleAutoPlayNext) return
startPlay = true
let index = runtimeStore.editDict.articles.findIndex(v => v.id === selectArticle.id)
if (index > -1) {
//如果是最后一个
@@ -177,8 +193,106 @@ const list = $computed(() => {
].concat(runtimeStore.editDict.articles)
})
let showAudio = $ref(false)
let showTranslate = $ref(true)
let startPlay = $ref(false)
let displayMode = $ref<'normal' | 'typing-style'>('normal')
let articleWrapperRef = $ref<HTMLElement>()
const isMob = isMobile()
const handleVolumeUpdate = (volume: number) => {
settingStore.articleSoundVolume = volume
}
const handleSpeedUpdate = (speed: number) => {
settingStore.articleSoundSpeed = speed
}
// 解析文本为段落和句子结构
interface ParsedSentence {
text: string
translate: string
}
interface ParsedParagraph {
sentences: ParsedSentence[]
}
function parseTextToSections(text: string, textTranslate: string): ParsedParagraph[] {
if (!text) return []
// 按段落分割(双换行)
const textParagraphs = text.split('\n\n').filter(p => p.trim())
const translateParagraphs = textTranslate ? textTranslate.split('\n\n').filter(p => p.trim()) : []
// 句子分割正则:按句号、问号、感叹号分割,但保留标点
const sentenceRegex = /([^.!?]+[.!?]+)/g
return textParagraphs.map((para, paraIndex) => {
// 分割句子
const sentences = para.match(sentenceRegex) || [para]
const translateSentences = translateParagraphs[paraIndex]
? (translateParagraphs[paraIndex].match(sentenceRegex) || [translateParagraphs[paraIndex]])
: []
return {
sentences: sentences.map((sent, sentIndex) => ({
text: sent.trim(),
translate: translateSentences[sentIndex]?.trim() || '',
})),
}
})
}
// 计算解析后的文章结构
const parsedArticle = $computed(() => {
if (!selectArticle.text || displayMode !== 'typing-style') return null
return parseTextToSections(selectArticle.text, selectArticle.textTranslate || '')
})
// 定位翻译到原文下方
function positionTranslations() {
if (!parsedArticle || isMob || !articleWrapperRef) return
return new Promise<void>(resolve => {
_nextTick(() => {
if (!articleWrapperRef) {
resolve()
return
}
const articleRect = articleWrapperRef.getBoundingClientRect()
parsedArticle?.forEach((paragraph, paraIndex) => {
paragraph.sentences.forEach((sentence, sentIndex) => {
const location = `${paraIndex}-${sentIndex}`
const sentenceClassName = `.sentence-${location}`
const sentenceEl = articleWrapperRef?.querySelector(sentenceClassName)
const translateClassName = `.translate-${location}`
const translateEl = articleWrapperRef?.querySelector(translateClassName) as HTMLDivElement
if (sentenceEl && translateEl && sentence.translate) {
const sentenceRect = sentenceEl.getBoundingClientRect()
translateEl.style.opacity = '1'
translateEl.style.top = sentenceRect.top - articleRect.top + 24 + 'px'
const spaceEl = translateEl.firstElementChild as HTMLElement
if (spaceEl) {
spaceEl.style.width = sentenceRect.left - articleRect.left + 'px'
}
}
})
})
resolve()
}, 300)
})
}
// 监听显示模式和文章变化,重新定位翻译
watch([() => displayMode, () => selectArticle.id, () => showTranslate], () => {
if (displayMode === 'typing-style') {
positionTranslations()
}
})
</script>
<template>
@@ -196,7 +310,7 @@ let showTranslate = $ref(true)
</BaseButton>
<BaseButton :loading="studyLoading || loading" type="info" @click="isEdit = true">编辑</BaseButton>
<BaseButton type="info" @click="router.push('batch-edit-article')">文章管理</BaseButton>
<BaseButton :loading="studyLoading || loading" @click="addMyStudyList">学习</BaseButton>
<BaseButton :loading="studyLoading || loading" @click="startPractice">学习</BaseButton>
</div>
</div>
<div class="flex flex-1 overflow-hidden mt-3">
@@ -211,8 +325,8 @@ let showTranslate = $ref(true)
</ArticleList>
<Empty v-else />
</div>
<div class="flex-1 shrink-0 pl-4 overflow-auto">
<div v-if="selectArticle.id">
<div class="flex-1 shrink-0 pl-4 flex flex-col overflow-hidden">
<template v-if="selectArticle.id">
<template v-if="selectArticle.id === -1">
<div class="flex gap-4 mt-2">
<img
@@ -226,64 +340,133 @@ let showTranslate = $ref(true)
<div class="text-base" v-if="totalSpend">总学习时长{{ totalSpend }}</div>
</template>
<template v-else>
<div class="">
<div class="text-3xl flex justify-between items-center relative">
<span>
<span class="font-bold">{{ selectArticle.title }}</span>
<span class="ml-6 text-2xl" v-if="showTranslate">{{ selectArticle.titleTranslate }}</span>
</span>
<div>
<BaseIcon title="显示音频" @click="showAudio = !showAudio">
<IconBxVolumeFull />
</BaseIcon>
<BaseIcon :title="`开关释义显示`" @click="showTranslate = !showTranslate">
<IconFluentTranslate16Regular v-if="showTranslate" />
<IconFluentTranslateOff16Regular v-else />
</BaseIcon>
<div class="flex-1 space-y-10 overflow-auto pb-30">
<div>
<div class="flex justify-between items-center relative">
<span class="text-3xl">
<span class="font-bold">{{ selectArticle.title }}</span>
<span class="ml-6 text-2xl" v-if="showTranslate">{{ selectArticle.titleTranslate }}</span>
</span>
<div class="flex items-center gap-2 mr-4">
<BaseIcon :title="`切换显示模式`" @click="displayMode = displayMode === 'normal' ? 'typing-style' : 'normal'">
<IconFluentTextParagraph16Regular v-if="displayMode === 'normal'" />
<IconFluentTextAlignLeft16Regular v-else />
</BaseIcon>
<BaseIcon :title="`开关释义显示`" @click="showTranslate = !showTranslate">
<IconFluentTranslate16Regular v-if="showTranslate" />
<IconFluentTranslateOff16Regular v-else />
</BaseIcon>
</div>
</div>
<div class="mt-2 text-2xl" v-if="selectArticle?.question?.text">
Question: {{ selectArticle?.question?.text }}
</div>
</div>
<!-- 普通显示模式 -->
<template v-if="displayMode === 'normal'">
<div class="text-2xl en-article-family space-y-5" v-if="selectArticle.text">
<div v-for="t in selectArticle.text.split('\n\n')">{{ t }}</div>
<div class="text-right italic">{{ selectArticle?.quote?.text }}</div>
</div>
<template v-if="showTranslate">
<div class="line"></div>
<div class="text-xl line-height-normal space-y-5" v-if="selectArticle.textTranslate">
<div class="mt-2" v-if="selectArticle?.question?.translate">
问题: {{ selectArticle?.question?.translate }}
</div>
<div v-for="t in selectArticle.textTranslate.split('\n\n')">{{ t }}</div>
<div class="text-right italic">{{ selectArticle?.quote?.translate }}</div>
</div>
<Empty v-else />
</template>
</template>
<!-- 打字式显示模式 -->
<template v-else-if="displayMode === 'typing-style' && parsedArticle">
<div
class="article-content"
:class="[showTranslate && 'tall']"
ref="articleWrapperRef"
>
<article>
<div class="section" v-for="(paragraph, paraIndex) in parsedArticle" :key="paraIndex">
<span
class="sentence"
:class="`sentence-${paraIndex}-${sentIndex}`"
v-for="(sentence, sentIndex) in paragraph.sentences"
:key="`${paraIndex}-${sentIndex}`"
>
{{ sentence.text }}&nbsp;
</span>
</div>
<div class="text-right italic" v-if="selectArticle?.quote?.text">
{{ selectArticle?.quote?.text }}
</div>
</article>
<div class="translate" v-show="showTranslate">
<template v-for="(paragraph, paraIndex) in parsedArticle" :key="`t-${paraIndex}`">
<div
class="row"
:class="`translate-${paraIndex}-${sentIndex}`"
v-for="(sentence, sentIndex) in paragraph.sentences"
:key="`${paraIndex}-${sentIndex}`"
>
<span class="space"></span>
<Transition name="fade">
<span class="text" v-if="sentence.translate">{{ sentence.translate }}</span>
</Transition>
</div>
</template>
<div class="text-right italic" v-if="selectArticle?.quote?.translate">
{{ selectArticle?.quote?.translate }}
</div>
</div>
</div>
<!-- 移动端显示翻译 -->
<template v-if="isMob && showTranslate">
<div class="sentence-translate-mobile" v-for="(paragraph, paraIndex) in parsedArticle" :key="`m-${paraIndex}`">
<div v-for="(sentence, sentIndex) in paragraph.sentences" :key="`${paraIndex}-${sentIndex}`">
<div v-if="sentence.translate" class="mt-2">{{ sentence.translate }}</div>
</div>
</div>
</template>
</template>
<template v-if="currentPractice.length">
<div class="line"></div>
<div class="font-family text-base pr-2">
<div class="text-2xl font-bold">学习记录</div>
<div class="mt-1 mb-3">总学习时长{{ msToHourMinute(total(currentPractice, 'spend')) }}</div>
<div
class="item border border-item border-solid mt-2 p-2 bg-[var(--bg-history)] rounded-md flex justify-between"
v-for="i in currentPractice"
>
<span class="color-gray">{{ _dateFormat(i.startDate) }}</span>
<span>{{ msToHourMinute(i.spend) }}</span>
</div>
</div>
</template>
</div>
<div class="border-t-1 border-t-gray-300 border-solid border-0 center gap-2 pt-4">
<ArticleAudio
v-if="showAudio"
class="mt-4"
:article="selectArticle"
:autoplay="settingStore.articleAutoPlayNext"
@update-speed="handleSpeedUpdate"
@update-volume="handleVolumeUpdate"
:autoplay="(settingStore.articleAutoPlayNext && startPlay)"
@ended="next"
/>
<div class="mb-4 mt-4 text-2xl" v-if="selectArticle?.question?.text">
Question: {{ selectArticle?.question?.text }}
</div>
<div class="text-2xl line-height-normal en-article-family" v-if="selectArticle.text">
<div class="my-6" v-for="t in selectArticle.text.split('\n\n')">{{ t }}</div>
<div class="text-right italic mb-5">{{ selectArticle?.quote?.text }}</div>
</div>
</div>
<div class="line my-10"></div>
<div class="mt-6" v-if="showTranslate">
<div class="text-xl line-height-normal" v-if="selectArticle.textTranslate">
<div class="my-5" v-for="t in selectArticle.textTranslate.split('\n\n')">{{ t }}</div>
<div class="text-right italic mb-5">{{ selectArticle?.quote?.translate }}</div>
</div>
<Empty v-else />
</div>
<div class="font-family text-base mb-4 pr-2" v-if="currentPractice.length">
<div class="line my-10"></div>
<div class="text-2xl font-bold">学习记录</div>
<div class="mt-1 mb-3">总学习时长{{ msToHourMinute(total(currentPractice, 'spend')) }}</div>
<div
class="item border border-item border-solid mt-2 p-2 bg-[var(--bg-history)] rounded-md flex justify-between"
v-for="i in currentPractice"
>
<span class="color-gray">{{ _dateFormat(i.startDate) }}</span>
<span>{{ msToHourMinute(i.spend) }}</span>
<div class="flex items-center gap-1">
<span>结束后播放下一篇</span>
<Switch v-model="settingStore.articleAutoPlayNext" />
</div>
</div>
</template>
</div>
</template>
<Empty v-else />
</div>
</div>
</div>
<div class="card mb-0 dict-detail-card" v-else>
<div class="dict-header flex justify-between items-center relative">
<BackIcon class="dict-back z-2" @click="isAdd ? $router.back() : (isEdit = false)" />
@@ -311,6 +494,76 @@ let showTranslate = $ref(true)
flex-wrap: wrap;
}
// 打字式显示模式样式(复用 TypingArticle 的样式)
$translate-lh: 3.2;
$article-lh: 2.4;
.article-content {
position: relative;
color: var(--color-article);
font-size: 1.6rem;
&.tall {
article {
line-height: $article-lh;
}
}
article {
word-break: keep-all;
word-wrap: break-word;
white-space: pre-wrap;
font-family: var(--en-article-family);
.section {
margin-bottom: 1.5rem;
.sentence {
transition: all 0.3s;
display: inline;
}
}
}
.translate {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
font-size: 1.2rem;
line-height: $translate-lh;
letter-spacing: 0.2rem;
font-family: var(--zh-article-family);
font-weight: bold;
color: #818181;
.row {
position: absolute;
left: 0;
width: 100%;
opacity: 0;
transition: all 0.3s;
.space {
transition: all 0.3s;
display: inline-block;
}
}
}
}
.sentence-translate-mobile {
display: none;
margin-top: 0.4rem;
font-size: 0.9rem;
line-height: 1.4;
color: var(--color-font-3);
font-family: var(--zh-article-family);
word-break: break-word;
}
@media (max-width: 768px) {
.dict-detail-card {
height: calc(100vh - 2rem);
@@ -356,5 +609,49 @@ let showTranslate = $ref(true)
}
}
}
// 移动端适配 - 打字式显示模式
@media (max-width: 768px) {
.article-content {
article {
.section {
margin-bottom: 1rem;
.sentence {
font-size: 1rem;
line-height: 1.6;
word-break: break-word;
margin-bottom: 0.5rem;
}
}
}
.translate {
display: none;
}
}
.sentence-translate-mobile {
display: block;
}
}
@media (max-width: 480px) {
.article-content {
article {
.section {
.sentence {
font-size: 0.9rem;
line-height: 1.5;
}
}
}
}
.sentence-translate-mobile {
font-size: 0.85rem;
line-height: 1.35;
}
}
</style>
```````` ;

View File

@@ -145,15 +145,11 @@ const initAudio = () => {
}
const handleVolumeUpdate = (volume: number) => {
settingStore.setState({
articleSoundVolume: volume,
})
settingStore.articleSoundVolume = volume
}
const handleSpeedUpdate = (speed: number) => {
settingStore.setState({
articleSoundSpeed: speed,
})
settingStore.articleSoundSpeed = speed
}
watch(
@@ -517,7 +513,7 @@ provide('currentPractice', currentPractice)
/>
</Tooltip>
</div>
<div class="bottom ">
<div class="bottom">
<div class="flex justify-between items-center gap-2">
<div class="stat">
<div class="row">
@@ -551,8 +547,6 @@ provide('currentPractice', currentPractice)
<ArticleAudio
ref="audioRef"
:article="articleData.article"
:autoplay="settingStore.articleAutoPlayNext"
@ended="settingStore.articleAutoPlayNext && next()"
@update-speed="handleSpeedUpdate"
@update-volume="handleVolumeUpdate"
></ArticleAudio>

View File

@@ -1,19 +1,19 @@
<script setup lang="ts">
import { Article } from "@/types/types.ts";
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";
import { Article } from '@/types/types.ts'
import { ref, watch } from 'vue'
import { get } from 'idb-keyval'
import Audio from '@/components/base/Audio.vue'
import { LOCAL_FILE_KEY } from '@/config/env.ts'
const props = defineProps<{
article: Article
}>()
const emit = defineEmits<{
(e: 'ended'): [],
(e: 'update-volume', volume: number): void,
(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 })
@@ -31,14 +31,14 @@ const setAudioRefValue = (key: string, value: any) => {
if (instance?.audioRef) {
switch (key) {
case 'currentTime':
instance.audioRef.currentTime = value;
break;
instance.audioRef.currentTime = value
break
case 'volume':
instance.audioRef.volume = value;
break;
instance.audioRef.volume = value
break
case 'playbackRate':
instance.audioRef.playbackRate = value;
break;
instance.audioRef.playbackRate = value
break
default:
break
}
@@ -48,61 +48,85 @@ const setAudioRefValue = (key: string, value: any) => {
}
}
watch(() => props.article.audioFileId, async () => {
if (!props.article.audioSrc && props.article.audioFileId) {
let list = await get(LOCAL_FILE_KEY)
if (list) {
let rItem = list.find((file) => file.id === props.article.audioFileId)
if (rItem) {
file = URL.createObjectURL(rItem.file)
watch(
() => props.article.audioFileId,
async () => {
if (!props.article.audioSrc && props.article.audioFileId) {
let list = await get(LOCAL_FILE_KEY)
if (list) {
let rItem = list.find(file => file.id === props.article.audioFileId)
if (rItem) {
file = URL.createObjectURL(rItem.file)
}
}
} else {
file = null
}
} 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 })
watch(
() => instance,
newVal => {
Object.entries(pendingUpdates.value).forEach(([key, value]) => {
setAudioRefValue(key, value)
})
pendingUpdates.value = {}
},
{ immediate: true }
)
//转发一遍这里Proxy的默认值不能为{}可能是vue做了什么
defineExpose(new Proxy({
currentTime: 0,
played: false,
src: '',
volume: 0,
playbackRate: 1,
play: () => void 0,
pause: () => void 0,
}, {
get(target, key) {
if (key === 'currentTime') return instance?.audioRef?.currentTime
if (key === 'played') return instance?.audioRef?.played
if (key === 'src') return instance?.audioRef?.src
if (key === 'volume') return instance?.audioRef?.volume
if (key === 'playbackRate') return instance?.audioRef?.playbackRate
if (key === 'play') instance?.audioRef?.play()
if (key === 'pause') instance?.audioRef?.pause()
return target[key]
},
set(_, key, value) {
setAudioRefValue(key as string, value)
return true
}
}))
defineExpose(
new Proxy(
{
currentTime: 0,
played: false,
src: '',
volume: 0,
playbackRate: 1,
play: () => void 0,
pause: () => void 0,
},
{
get(target, key) {
if (key === 'currentTime') return instance?.audioRef?.currentTime
if (key === 'played') return instance?.audioRef?.played
if (key === 'src') return instance?.audioRef?.src
if (key === 'volume') return instance?.audioRef?.volume
if (key === 'playbackRate') return instance?.audioRef?.playbackRate
if (key === 'play') instance?.audioRef?.play()
if (key === 'pause') instance?.audioRef?.pause()
return target[key]
},
set(_, key, 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')" @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" />
<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,7 +1,7 @@
<script setup lang="tsx">
import { DictId, Sort } from '@/types/types.ts'
import { add2MyDict, detail } from '@/apis'
import { detail } from '@/apis'
import BackIcon from '@/components/BackIcon.vue'
import BaseButton from '@/components/BaseButton.vue'
import BaseIcon from '@/components/BaseIcon.vue'
@@ -23,23 +23,14 @@ import { useBaseStore } from '@/stores/base.ts'
import { useRuntimeStore } from '@/stores/runtime.ts'
import { useSettingStore } from '@/stores/setting.ts'
import { getDefaultDict } from '@/types/func.ts'
import {
_getDictDataByUrl,
_nextTick,
convertToWord,
isMobile,
loadJsLib,
reverse,
shuffle,
useNav,
} from '@/utils'
import { _getDictDataByUrl, _nextTick, convertToWord, isMobile, loadJsLib, reverse, shuffle, useNav } from '@/utils'
import { MessageBox } from '@/utils/MessageBox.tsx'
import { nanoid } from 'nanoid'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { wordDelete } from '@/apis/words.ts'
import { copyOfficialDict } from '@/apis/dict.ts'
import {PRACTICE_WORD_CACHE} from "@/utils/cache.ts";
import { PRACTICE_WORD_CACHE } from '@/utils/cache.ts'
const runtimeStore = useRuntimeStore()
const base = useBaseStore()
@@ -81,10 +72,7 @@ function syncDictInMyStudyList(study = false) {
runtimeStore.editDict.words = allList
let temp = runtimeStore.editDict
if (
!temp.custom &&
![DictId.wordKnown, DictId.wordWrong, DictId.wordCollect].includes(temp.id)
) {
if (!temp.custom && ![DictId.wordKnown, DictId.wordWrong, DictId.wordCollect].includes(temp.id)) {
temp.custom = true
if (!temp.id.includes('_custom')) {
temp.id += '_custom_' + nanoid(6)
@@ -193,17 +181,13 @@ function word2Str(word) {
res.trans = word.trans.map(v => (v.pos + v.cn).replaceAll('"', '')).join('\n')
res.sentences = word.sentences.map(v => (v.c + '\n' + v.cn).replaceAll('"', '')).join('\n\n')
res.phrases = word.phrases.map(v => (v.c + '\n' + v.cn).replaceAll('"', '')).join('\n\n')
res.synos = word.synos
.map(v => (v.pos + v.cn + '\n' + v.ws.join('/')).replaceAll('"', ''))
.join('\n\n')
res.synos = word.synos.map(v => (v.pos + v.cn + '\n' + v.ws.join('/')).replaceAll('"', '')).join('\n\n')
res.relWords = word.relWords.root
? '词根:' +
word.relWords.root +
'\n\n' +
word.relWords.rels
.map(v =>
(v.pos + '\n' + v.words.map(v => v.c + ':' + v.cn).join('\n')).replaceAll('"', '')
)
.map(v => (v.pos + '\n' + v.words.map(v => v.c + ':' + v.cn).join('\n')).replaceAll('"', ''))
.join('\n\n')
: ''
res.etymology = word.etymology.map(v => (v.t + '\n' + v.d).replaceAll('"', '')).join('\n\n')
@@ -521,10 +505,7 @@ function getLocalList({ pageNo, pageSize, searchKey }) {
}
async function requestList({ pageNo, pageSize, searchKey }) {
if (
!dict.custom &&
![DictId.wordCollect, DictId.wordWrong, DictId.wordKnown].includes(dict.en_name || dict.id)
) {
if (!dict.custom && ![DictId.wordCollect, DictId.wordWrong, DictId.wordKnown].includes(dict.en_name || dict.id)) {
// 非自定义词典直接请求json
//如果没数据则请求
@@ -582,15 +563,9 @@ defineRender(() => {
<div className="card mb-0 dict-detail-card flex flex-col">
<div class="dict-header flex justify-between items-center relative">
<BackIcon class="dict-back z-2" />
<div class="dict-title absolute page-title text-align-center w-full">
{runtimeStore.editDict.name}
</div>
<div class="dict-title absolute page-title text-align-center w-full">{runtimeStore.editDict.name}</div>
<div class="dict-actions flex">
<BaseButton
loading={studyLoading || loading}
type="info"
onClick={() => (isEdit = true)}
>
<BaseButton loading={studyLoading || loading} type="info" onClick={() => (isEdit = true)}>
编辑
</BaseButton>
<BaseButton loading={studyLoading || loading} type="info" onClick={startTest}>
@@ -611,25 +586,17 @@ defineRender(() => {
{/* 移动端标签页导航 */}
{isMob && isOperate && (
<div class="tab-navigation mb-3">
<div
class={`tab-item ${activeTab === 'list' ? 'active' : ''}`}
onClick={() => (activeTab = 'list')}
>
<div class={`tab-item ${activeTab === 'list' ? 'active' : ''}`} onClick={() => (activeTab = 'list')}>
单词列表
</div>
<div
class={`tab-item ${activeTab === 'edit' ? 'active' : ''}`}
onClick={() => (activeTab = 'edit')}
>
<div class={`tab-item ${activeTab === 'edit' ? 'active' : ''}`} onClick={() => (activeTab = 'edit')}>
{wordForm.id ? '编辑' : '添加'}单词
</div>
</div>
)}
<div class="flex flex-1 overflow-hidden content-area">
<div
class={`word-list-section ${isMob && isOperate && activeTab !== 'list' ? 'mobile-hidden' : ''}`}
>
<div class={`word-list-section ${isMob && isOperate && activeTab !== 'list' ? 'mobile-hidden' : ''}`}>
<BaseTable
ref={tableRef}
class="h-full"
@@ -643,21 +610,14 @@ defineRender(() => {
importLoading={importLoading}
>
{val => (
<WordItem
showTransPop={false}
showCollectIcon={false}
showMarkIcon={false}
item={val.item}
>
<WordItem showTransPop={false}
onClick={() => editWord(val.item)}
index={val.index} showCollectIcon={false} showMarkIcon={false} item={val.item}>
{{
prefix: () => val.checkbox(val.item),
suffix: () => (
<div class="flex flex-col">
<BaseIcon
class="option-icon"
onClick={() => editWord(val.item)}
title="编辑"
>
<BaseIcon class="option-icon" onClick={() => editWord(val.item)} title="编辑">
<IconFluentTextEditStyle20Regular />
</BaseIcon>
<PopConfirm title="确认删除?" onConfirm={() => batchDel([val.item.id])}>
@@ -673,9 +633,7 @@ defineRender(() => {
</BaseTable>
</div>
{isOperate ? (
<div
class={`edit-section flex-1 flex flex-col ${isMob && activeTab !== 'edit' ? 'mobile-hidden' : ''}`}
>
<div class={`edit-section flex-1 flex flex-col ${isMob && activeTab !== 'edit' ? 'mobile-hidden' : ''}`}>
<div class="common-title">{wordForm.id ? '修改' : '添加'}单词</div>
<Form
class="flex-1 overflow-auto pr-2"
@@ -685,22 +643,13 @@ defineRender(() => {
label-width="7rem"
>
<FormItem label="单词" prop="word">
<BaseInput
modelValue={wordForm.word}
onUpdate:modelValue={e => (wordForm.word = e)}
></BaseInput>
<BaseInput modelValue={wordForm.word} onUpdate:modelValue={e => (wordForm.word = e)}></BaseInput>
</FormItem>
<FormItem label="英音音标">
<BaseInput
modelValue={wordForm.phonetic0}
onUpdate:modelValue={e => (wordForm.phonetic0 = e)}
/>
<BaseInput modelValue={wordForm.phonetic0} onUpdate:modelValue={e => (wordForm.phonetic0 = e)} />
</FormItem>
<FormItem label="美音音标">
<BaseInput
modelValue={wordForm.phonetic1}
onUpdate:modelValue={e => (wordForm.phonetic1 = e)}
/>
<BaseInput modelValue={wordForm.phonetic1} onUpdate:modelValue={e => (wordForm.phonetic1 = e)} />
</FormItem>
<FormItem label="翻译">
<Textarea
@@ -781,12 +730,7 @@ defineRender(() => {
</div>
</div>
<div class="center">
<EditBook
isAdd={isAdd}
isBook={false}
onClose={formClose}
onSubmit={() => (isEdit = isAdd = false)}
/>
<EditBook isAdd={isAdd} isBook={false} onClose={formClose} onSubmit={() => (isEdit = isAdd = false)} />
</div>
</div>
)}

View File

@@ -104,6 +104,7 @@ export interface Statistics {
new: number //新学单词数量
review: number //复习单词数量
wrong: number //错误数
title: string //文章标题
}
export enum Sort {