Merge branch 'refs/heads/dev'
# Conflicts: # src/pages/word/components/TypeWord.vue
This commit is contained in:
70
src/App.vue
70
src/App.vue
@@ -1,24 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import {onMounted, watch} from "vue";
|
||||
import {BaseState, useBaseStore} from "@/stores/base.ts";
|
||||
import {useRuntimeStore} from "@/stores/runtime.ts";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import useTheme from "@/hooks/theme.ts";
|
||||
import {loadJsLib, shakeCommonDict} from "@/utils";
|
||||
import {get, set} from 'idb-keyval'
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { BaseState, useBaseStore } from '@/stores/base.ts'
|
||||
import { useRuntimeStore } from '@/stores/runtime.ts'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import useTheme from '@/hooks/theme.ts'
|
||||
import { loadJsLib, shakeCommonDict } from '@/utils'
|
||||
import { get, set } from 'idb-keyval'
|
||||
|
||||
import {useRoute} from "vue-router";
|
||||
import {DictId} from "@/types/types.ts";
|
||||
import {APP_VERSION, AppEnv, LOCAL_FILE_KEY, Origin, SAVE_DICT_KEY, SAVE_SETTING_KEY} from "@/config/env.ts";
|
||||
import {syncSetting} from "@/apis";
|
||||
import {useUserStore} from "@/stores/user.ts";
|
||||
import MigrateDialog from "@/components/MigrateDialog.vue";
|
||||
import { useRoute } from 'vue-router'
|
||||
import { APP_VERSION, AppEnv, DictId, LOCAL_FILE_KEY, Origin, SAVE_DICT_KEY, SAVE_SETTING_KEY } from '@/config/env.ts'
|
||||
import { syncSetting } from '@/apis'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import MigrateDialog from '@/components/MigrateDialog.vue'
|
||||
|
||||
const store = useBaseStore()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const {setTheme} = useTheme()
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
let lastAudioFileIdList = []
|
||||
let isInitializing = true // 标记是否正在初始化
|
||||
@@ -26,22 +25,24 @@ watch(store.$state, (n: BaseState) => {
|
||||
// 如果正在初始化,不保存数据,避免覆盖
|
||||
if (isInitializing) return
|
||||
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))
|
||||
let audioFileIdList = []
|
||||
bookList.forEach(v => {
|
||||
//筛选 audioFileId 字体有值的
|
||||
v.articles.filter(s => !s.audioSrc && s.audioFileId).forEach(a => {
|
||||
//所有 id 存起来,下次直接判断字符串是否相等,因为这个watch会频繁调用
|
||||
audioFileIdList.push(a.audioFileId)
|
||||
})
|
||||
v.articles
|
||||
.filter(s => !s.audioSrc && s.audioFileId)
|
||||
.forEach(a => {
|
||||
//所有 id 存起来,下次直接判断字符串是否相等,因为这个watch会频繁调用
|
||||
audioFileIdList.push(a.audioFileId)
|
||||
})
|
||||
})
|
||||
if (audioFileIdList.toString() !== lastAudioFileIdList.toString()) {
|
||||
let result = []
|
||||
//删除未使用到的文件
|
||||
get(LOCAL_FILE_KEY).then((fileList: Array<{ id: string, file: Blob }>) => {
|
||||
get(LOCAL_FILE_KEY).then((fileList: Array<{ id: string; file: Blob }>) => {
|
||||
if (fileList && fileList.length > 0) {
|
||||
audioFileIdList.forEach(a => {
|
||||
let item = fileList.find(b => b.id === a)
|
||||
@@ -54,13 +55,17 @@ watch(store.$state, (n: BaseState) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => settingStore.$state, (n) => {
|
||||
if (isInitializing) return
|
||||
set(SAVE_SETTING_KEY.key, JSON.stringify({val: n, version: SAVE_SETTING_KEY.version}))
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
syncSetting(null, settingStore.$state)
|
||||
}
|
||||
}, {deep: true})
|
||||
watch(
|
||||
() => settingStore.$state,
|
||||
n => {
|
||||
if (isInitializing) return
|
||||
set(SAVE_SETTING_KEY.key, JSON.stringify({ val: n, version: SAVE_SETTING_KEY.version }))
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
syncSetting(null, settingStore.$state)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
async function init() {
|
||||
isInitializing = true // 开始初始化
|
||||
@@ -76,10 +81,10 @@ async function init() {
|
||||
set(APP_VERSION.key, APP_VERSION.version)
|
||||
} else {
|
||||
get(APP_VERSION.key).then(r => {
|
||||
runtimeStore.isNew = r ? (APP_VERSION.version > Number(r)) : true
|
||||
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)
|
||||
@@ -88,7 +93,7 @@ onMounted(init)
|
||||
let showTransfer = $ref(false)
|
||||
onMounted(() => {
|
||||
if (new URLSearchParams(window.location.search).get('from_old_site') === '1' && location.origin === Origin) {
|
||||
if (localStorage.getItem('__migrated_from_2study_top__')) return;
|
||||
if (localStorage.getItem('__migrated_from_2study_top__')) return
|
||||
setTimeout(() => {
|
||||
showTransfer = true
|
||||
}, 1000)
|
||||
@@ -127,8 +132,5 @@ onMounted(() => {
|
||||
<!-- </transition>-->
|
||||
<!-- </router-view>-->
|
||||
<router-view></router-view>
|
||||
<MigrateDialog
|
||||
v-model="showTransfer"
|
||||
@ok="init"
|
||||
/>
|
||||
<MigrateDialog v-model="showTransfer" @ok="init" />
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import http from '@/utils/http.ts'
|
||||
import { Dict } from '@/types/types.ts'
|
||||
import type { Dict } from '@/types/types.ts'
|
||||
|
||||
export function copyOfficialDict(params?, data?) {
|
||||
return http<Dict>('dict/copyOfficialDict', data, params, 'post')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import http, {axiosInstance, AxiosResponse} from "@/utils/http.ts";
|
||||
import { Dict } from "@/types/types.ts";
|
||||
import type { Dict } from "@/types/types.ts";
|
||||
import { cloneDeep } from "@/utils";
|
||||
|
||||
function remove(data?: any) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import http from '@/utils/http.ts'
|
||||
import { CodeType } from "@/types/types.ts";
|
||||
|
||||
import {CodeType} from "@/types/enum.ts";
|
||||
|
||||
// 用户登录接口
|
||||
export interface LoginParams {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import http from '@/utils/http.ts'
|
||||
import { Dict } from '@/types/types.ts'
|
||||
import type { Dict } from '@/types/types.ts'
|
||||
|
||||
export function wordDelete(params?, data?) {
|
||||
return http<Dict>('word/delete', data, params, 'post')
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
@use "anim" as *;
|
||||
@use 'anim' as *;
|
||||
@use 'shepherd.css';
|
||||
|
||||
:root {
|
||||
--color-reverse-white: white;
|
||||
--color-reverse-black: black;
|
||||
--bg-history: white;
|
||||
--color-item-bg: rgb(228, 230, 232);
|
||||
--color-item-hover: white;
|
||||
//--color-item-active: rgb(75, 110, 175);
|
||||
--color-item-active: rgb(253, 246, 236);
|
||||
--color-item-border: rgb(226, 226, 226);
|
||||
|
||||
--color-tooltip-bg: white;
|
||||
--color-tooltip-shadow: #d9d9d9;
|
||||
--color-font-2: rgb(46, 46, 46);
|
||||
--color-font-3: rgb(102, 116, 135);
|
||||
--color-font-active-1: white;
|
||||
@@ -23,7 +18,7 @@
|
||||
--article-width: 50vw;
|
||||
--article-toolbar-width: 50vw;
|
||||
--article-panel-width: 20rem;
|
||||
--article-panel-margin-left: calc(50% + var(--article-width) / 2 + 1rem);
|
||||
--article-panel-margin-left: calc(50vw + var(--article-width) / 2 + var(--aside-width) / 2 + 1rem);
|
||||
|
||||
--toolbar-width: 50rem;
|
||||
--panel-width: 24rem;
|
||||
@@ -31,8 +26,7 @@
|
||||
--modal-padding: 1.3rem;
|
||||
--space: 0.9rem;
|
||||
--stat-gap: 1rem;
|
||||
--shadow: rgba(0, 0, 0, 0.08) 0px 4px 12px;
|
||||
--word-panel-margin-left: calc(50% + var(--toolbar-width) / 2 + 1rem);
|
||||
--word-panel-margin-left: calc(50vw + var(--aside-width) / 2 + var(--toolbar-width) / 2 + 1rem);
|
||||
--anim-time: 0.5s;
|
||||
|
||||
--color-input-color: black;
|
||||
@@ -48,82 +42,68 @@
|
||||
--font-family: -apple-system, sans-serif;
|
||||
--word-font-family: ui-monospace, sans-serif;
|
||||
--en-article-family: Georgia, sans-serif;
|
||||
--zh-article-family: "Songti SC", "SimSun", "Noto Serif CJK SC", serif;
|
||||
--zh-article-family: 'Songti SC', 'SimSun', 'Noto Serif CJK SC', serif;
|
||||
|
||||
--btn-primary: rgb(75, 85, 99);
|
||||
--btn-info: white;
|
||||
|
||||
--color-primary: #E6E8EB;
|
||||
--color-primary: #e6e8eb;
|
||||
--color-second: rgb(247, 247, 247);
|
||||
--color-third: rgb(226 232 240 / 1);
|
||||
--color-fourth: rgb(193, 193, 193);
|
||||
--color-third: rgb(228, 230, 232);
|
||||
--color-fourth: rgb(218, 220, 222);
|
||||
--color-fifth: rgb(253, 246, 236);
|
||||
|
||||
--color-card-active: #FED7AA;
|
||||
--color-list-item-active: rgb(253, 246, 236);
|
||||
//--color-card-active: #FED7AA;
|
||||
--color-card-active: rgb(253, 246, 236);
|
||||
--color-icon-hightlight: rgb(12, 140, 233);
|
||||
//--color-icon-hightlight: rgb(12, 140, 233);
|
||||
--color-sub-text: gray;
|
||||
--color-main-text: rgb(91, 91, 91);
|
||||
--color-select-bg: rgb(12, 140, 233);
|
||||
--color-select-text: white;
|
||||
|
||||
--color-notice-bg: rgb(247, 247, 247);
|
||||
|
||||
|
||||
//修改的进度条底色
|
||||
--color-progress-bar: #d1d5df !important;
|
||||
--color-progress-bar: #d1d5df;
|
||||
|
||||
--color-label-bg: whitesmoke;
|
||||
--color-link: #2563EB;
|
||||
--color-link: #2563eb;
|
||||
|
||||
--color-card-bg: white;
|
||||
|
||||
|
||||
--bg-card-primary: white;
|
||||
--bg-card-secend: rgb(247, 247, 247);
|
||||
}
|
||||
|
||||
.footer {
|
||||
&.hide {
|
||||
--color-progress-bar: #dbdbdb !important;
|
||||
}
|
||||
--bg-book: rgb(226 232 240);
|
||||
|
||||
--color-line: rgb(226, 226, 226);
|
||||
|
||||
--color-translate-main: black;
|
||||
--color-translate-second: #818181;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--color-reverse-white: black;
|
||||
--color-reverse-black: white;
|
||||
|
||||
--color-primary: #0E1217;
|
||||
--color-second: rgb(30, 31, 34);
|
||||
--color-third: rgb(43, 45, 48);
|
||||
--color-primary: #202124;
|
||||
--color-second: #292a2d;
|
||||
--color-third: #35373a;
|
||||
--color-fourth: rgb(70, 70, 70);
|
||||
--color-fifth: rgb(84, 84, 84);
|
||||
|
||||
--color-card-active: rgb(84, 84, 84);
|
||||
--color-list-item-active: rgb(84, 84, 84);
|
||||
--color-icon-hightlight: rgb(147, 173, 227);
|
||||
--color-sub-text: #b8b8b8;
|
||||
--color-main-text: rgba(249, 250, 251, 0.8);
|
||||
--color-select-bg: rgb(147, 173, 227);
|
||||
--color-select-text: black;
|
||||
--color-notice-bg: rgb(43, 45, 48);
|
||||
|
||||
--bg-history: rgb(43, 45, 48);
|
||||
--color-item-bg: rgb(43, 45, 48);
|
||||
--color-item-hover: rgb(67, 69, 74);
|
||||
--color-item-active: rgb(84, 84, 84);
|
||||
--color-item-border: rgb(41, 41, 41);
|
||||
|
||||
--color-tooltip-bg: #252525;
|
||||
--color-tooltip-shadow: #3b3b3b;
|
||||
--color-tooltip-bg: #35373a;
|
||||
--color-font-2: rgba(255, 255, 255, 0.5);
|
||||
--color-font-3: rgba(255, 255, 255, 0.3);
|
||||
|
||||
--color-sub-gray: #383737;
|
||||
--color-scrollbar: rgb(92, 93, 94);
|
||||
|
||||
--btn-info: transparent;
|
||||
|
||||
--color-input-color: white;
|
||||
--color-input-bg: rgba(14, 18, 23, 1);
|
||||
--color-input-icon: #383737;
|
||||
--color-input-bg: var(--color-third);
|
||||
|
||||
--color-textarea-bg: rgb(43, 45, 48);
|
||||
--color-article: white;
|
||||
@@ -132,16 +112,14 @@ html.dark {
|
||||
|
||||
--color-label-bg: rgb(10, 10, 10);
|
||||
|
||||
--color-card-bg: rgb(30, 31, 34);
|
||||
--color-card-bg: var(--color-second);
|
||||
|
||||
--bg-card-primary: rgb(30, 31, 34);
|
||||
--bg-card-secend: rgb(43, 45, 48);
|
||||
|
||||
.footer {
|
||||
&.hide {
|
||||
--color-progress-bar: var(--color-third) !important;
|
||||
}
|
||||
}
|
||||
--bg-book: #35373a;
|
||||
|
||||
--color-line: rgb(66, 66, 66);
|
||||
}
|
||||
|
||||
@media (max-width: 1720px) {
|
||||
@@ -150,8 +128,17 @@ html.dark {
|
||||
--panel-width: 20rem;
|
||||
--space: 0.5rem;
|
||||
|
||||
--article-toolbar-width: 50rem;
|
||||
--article-panel-width: 18rem;
|
||||
--article-width: 60vw;
|
||||
--article-toolbar-width: 60vw;
|
||||
--article-panel-width: 17rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1560px) {
|
||||
:root {
|
||||
--article-width: 60vw;
|
||||
--article-toolbar-width: 60vw;
|
||||
--article-panel-width: 15rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +149,17 @@ html.dark {
|
||||
--stat-gap: 0.5rem;
|
||||
--space: 0.3rem;
|
||||
|
||||
--article-toolbar-width: 40rem;
|
||||
|
||||
--article-width: 70vw;
|
||||
--article-toolbar-width: 70vw;
|
||||
--article-panel-width: 13rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1150px) {
|
||||
:root {
|
||||
--article-width: 85vw;
|
||||
--article-toolbar-width: 85vw;
|
||||
--article-panel-width: 16rem;
|
||||
}
|
||||
}
|
||||
@@ -189,7 +186,10 @@ html.dark {
|
||||
}
|
||||
|
||||
.anim {
|
||||
transition: background var(--anim-time), color var(--anim-time), border var(--anim-time), opacity var(--anim-time);
|
||||
transition: background var(--anim-time),
|
||||
color var(--anim-time),
|
||||
border var(--anim-time),
|
||||
opacity var(--anim-time);
|
||||
}
|
||||
|
||||
.en-article-family {
|
||||
@@ -203,9 +203,7 @@ html.dark {
|
||||
html,
|
||||
body {
|
||||
//font-size: 1px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
@apply p-0 m-0 overflow-x-hidden;
|
||||
color: var(--color-main-text);
|
||||
font-family: var(--font-family);
|
||||
background: var(--color-primary);
|
||||
@@ -214,42 +212,14 @@ body {
|
||||
}
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
transition: all .3s;
|
||||
@apply relative z-1 h-full w-full flex flex-col;
|
||||
}
|
||||
|
||||
.mobile-page {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
overflow: auto;
|
||||
font-size: 18rem;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@apply fixed left-0 right-0 bottom-0 top-0 overflow-auto font-size-18 w-full h-full flex flex-col;
|
||||
|
||||
& > .page-content {
|
||||
padding: 10rem;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
@apply p-10 box-border overflow-auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,38 +244,33 @@ a {
|
||||
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
::-webkit-scrollbar {
|
||||
width: .5rem;
|
||||
height: .6rem;
|
||||
width: 0.5rem;
|
||||
height: 0.6rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: .1rem;
|
||||
border-radius: 0.1rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-scrollbar);
|
||||
border-radius: .6rem;
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-page-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@apply flex flex-col h-full box-border;
|
||||
padding-bottom: var(--space);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
padding: 0 var(--space);
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
@apply flex-1 overflow-auto;
|
||||
}
|
||||
|
||||
.virtual-list {
|
||||
overflow: overlay;
|
||||
height: 100%;
|
||||
@apply overflow-overlay h-full;
|
||||
padding: 0 var(--space);
|
||||
}
|
||||
|
||||
@@ -314,45 +279,27 @@ a {
|
||||
}
|
||||
|
||||
.common-list-item {
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-item-bg);
|
||||
@apply cursor-pointer w-full box-border bg-third rounded-lg flex justify-between gap-1 transition-all duration-300 p-2;
|
||||
color: var(--color-main-text);
|
||||
font-size: 1.1rem;
|
||||
border-radius: .5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
transition: all .3s;
|
||||
padding: .6rem;
|
||||
gap: .3rem;
|
||||
border: 1px solid var(--color-item-border);
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
gap: .6rem;
|
||||
|
||||
@apply flex gap-1;
|
||||
.title-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .2rem;
|
||||
word-break: break-word;
|
||||
@apply flex flex-col gap-0.5 word-break-break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .1rem;
|
||||
transition: all .3s;
|
||||
@apply flex flex-col gap-0.5 transition-all duration-300;
|
||||
}
|
||||
|
||||
svg {
|
||||
opacity: 0;
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-list-item-active);
|
||||
@apply bg-fifth;
|
||||
|
||||
.item-sub-title {
|
||||
color: var(--color-sub-text);
|
||||
@@ -376,7 +323,7 @@ a {
|
||||
.item-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-main-text);
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -389,7 +336,7 @@ a {
|
||||
}
|
||||
|
||||
.phonetic {
|
||||
font-size: .9rem;
|
||||
font-size: 0.9rem;
|
||||
color: gray;
|
||||
}
|
||||
}
|
||||
@@ -402,7 +349,7 @@ a {
|
||||
|
||||
.word-shadow {
|
||||
color: transparent !important;
|
||||
text-shadow: #b0b0b0 0 0 .5rem;
|
||||
text-shadow: #b0b0b0 0 0 0.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -418,14 +365,14 @@ a {
|
||||
.slide {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
transition: height .3s;
|
||||
transition: height 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.slide-infinite {
|
||||
z-index: 1;
|
||||
margin-top: 0;
|
||||
transition: all .3s;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.slide-list {
|
||||
@@ -449,13 +396,8 @@ a {
|
||||
@apply flex-col;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-xl p-4 mb-8 shadow-lg box-border relative;
|
||||
background: var(--color-second);
|
||||
}
|
||||
|
||||
.card-white {
|
||||
@extend .card;
|
||||
@apply card;
|
||||
background: var(--color-card-bg);
|
||||
}
|
||||
|
||||
@@ -469,7 +411,8 @@ a {
|
||||
|
||||
.book {
|
||||
@extend .anim;
|
||||
@apply p-3 rounded-md relative cursor-pointer bg-third hover:bg-card-active flex flex-col justify-between shrink-0;
|
||||
@apply p-3 rounded-md relative cursor-pointer hover:bg-fifth flex flex-col justify-between shrink-0;
|
||||
background: var(--bg-book);
|
||||
$w: 7rem;
|
||||
width: $w;
|
||||
height: calc($w * 1.4);
|
||||
@@ -477,9 +420,8 @@ a {
|
||||
|
||||
.line {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--color-item-border);
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
@apply hover:text-blue-700;
|
||||
|
||||
}
|
||||
|
||||
.line-white {
|
||||
@@ -504,31 +446,19 @@ a {
|
||||
}
|
||||
|
||||
@keyframes underline {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
border-left: .1rem solid var(--color-article);
|
||||
border-left: 0.1rem solid var(--color-article);
|
||||
}
|
||||
|
||||
50% {
|
||||
border-left: .1rem solid transparent;
|
||||
border-left: 0.1rem solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
#typing-listener {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0.01;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
@apply fixed left-[-9999px] top-[-9999px] w-1 h-1 opacity-0.01 z-[-1] pointer-events-none border-none outline-none bg-transparent text-transparent;
|
||||
font-size: 16px; // 防止iOS缩放
|
||||
color: transparent; // 文字透明
|
||||
}
|
||||
|
||||
.btn-no-margin {
|
||||
@@ -536,3 +466,8 @@ a {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.target-number {
|
||||
@apply text-3xl!;
|
||||
color: rgb(176, 116, 211) !important;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import Tooltip from "@/components/base/Tooltip.vue";
|
||||
import Tooltip from '@/components/base/Tooltip.vue'
|
||||
|
||||
interface IProps {
|
||||
keyboard?: string,
|
||||
keyboard?: string
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
size?: 'small' | 'normal' | 'large',
|
||||
type?: 'primary' | 'link' | 'info' | 'orange'
|
||||
size?: 'small' | 'normal' | 'large'
|
||||
type?: 'primary' | 'info' | 'orange'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<IProps>(), {
|
||||
@@ -16,33 +16,40 @@ withDefaults(defineProps<IProps>(), {
|
||||
})
|
||||
|
||||
defineEmits(['click'])
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip :disabled="!keyboard" :title="`${keyboard}`">
|
||||
<div class="base-button"
|
||||
v-bind="$attrs"
|
||||
@click="e => (!disabled && !loading) && $emit('click',e)"
|
||||
:class="[
|
||||
active && 'active',
|
||||
size,
|
||||
type,
|
||||
(disabled||loading) && 'disabled',
|
||||
]">
|
||||
<span :style="{opacity:loading?0:1}"><slot></slot></span>
|
||||
<IconEosIconsLoading
|
||||
v-if="loading"
|
||||
class="loading"
|
||||
width="18"
|
||||
:color="type === 'info'?'#000000':'#ffffff'"
|
||||
/>
|
||||
<div
|
||||
class="base-button"
|
||||
v-bind="$attrs"
|
||||
@click="e => !disabled && !loading && $emit('click', e)"
|
||||
:class="[active && 'active', size, type, (disabled || loading) && 'disabled']"
|
||||
>
|
||||
<span :style="{ opacity: loading ? 0 : 1 }"><slot></slot></span>
|
||||
<IconEosIconsLoading v-if="loading" class="loading" width="18" :color="type === 'info' ? '#000000' : '#ffffff'" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
<style>
|
||||
:root {
|
||||
--btn-primary: rgb(75, 85, 99);
|
||||
--btn-primary-disabled: #90969e;
|
||||
--btn-primary-hover: rgb(105, 121, 143);
|
||||
--btn-info: white;
|
||||
--btn-info-hover: #eaeaea;
|
||||
--btn-orange: #facc15;
|
||||
--btn-orange-hover: #bfac61;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--btn-info: #1b1b1b;
|
||||
--btn-info-hover: #3a3a3a;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.base-button {
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
@@ -51,13 +58,13 @@ defineEmits(['click'])
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
transition: .1s;
|
||||
transition: all 0.3s;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
border-radius: .3rem;
|
||||
border-radius: 0.3rem;
|
||||
padding: 0 0.9rem;
|
||||
font-size: .9rem;
|
||||
font-size: 0.9rem;
|
||||
height: 2rem;
|
||||
color: white;
|
||||
|
||||
@@ -65,28 +72,29 @@ defineEmits(['click'])
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: .6;
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
user-select: none;
|
||||
color: rgba(#fff, 0.4);
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&.small {
|
||||
border-radius: 0.3rem;
|
||||
padding: 0 0.6rem;
|
||||
height: 1.6rem;
|
||||
font-size: .8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
&.large {
|
||||
padding: 0 1.3rem;
|
||||
height: 2.4rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: .5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
& > span {
|
||||
@@ -101,17 +109,13 @@ defineEmits(['click'])
|
||||
&.primary {
|
||||
background: var(--btn-primary);
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
opacity: 0.6;
|
||||
&.disabled {
|
||||
opacity: 1;
|
||||
background: var(--btn-primary-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
&.link {
|
||||
border-radius: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
border-bottom: 2px solid var(--color-font-2);
|
||||
background: var(--btn-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,22 +125,22 @@ defineEmits(['click'])
|
||||
color: var(--color-main-text);
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
opacity: 0.6;
|
||||
background: var(--btn-info-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.orange {
|
||||
background: #FACC15;
|
||||
background: var(--btn-orange);
|
||||
color: black;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: #fbe27e;
|
||||
background: var(--btn-orange-hover);
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: .4;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<div class="page w-[70vw] 2xl:w-[50vw]">
|
||||
<div class="page 3xl:w-[50vw] 2xl:w-[60vw] xl:w-[70vw] lg:w-[75vw]">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="tsx">
|
||||
import { nextTick, onMounted, useSlots } from 'vue'
|
||||
import { Sort } from '@/types/types.ts'
|
||||
import MiniDialog from '@/components/dialog/MiniDialog.vue'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
@@ -13,11 +12,13 @@ import DeleteIcon from '@/components/icon/DeleteIcon.vue'
|
||||
import Dialog from '@/components/dialog/Dialog.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import { Host } from '@/config/env.ts'
|
||||
import { Sort } from '@/types/enum.ts'
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Dict } from "@/types/types.ts";
|
||||
import type { Dict } from "@/types/types.ts";
|
||||
import Progress from '@/components/base/Progress.vue'
|
||||
import Checkbox from "@/components/base/checkbox/Checkbox.vue";
|
||||
|
||||
@@ -50,7 +50,7 @@ const studyProgress = $computed(() => {
|
||||
@change="$emit('check')"
|
||||
class="absolute left-3 bottom-3 z-2"/>
|
||||
<div class="custom z-1" v-if="item.custom">自定义</div>
|
||||
<div class="custom bg-red! color-white z-1" v-else-if="item.update">更新中</div>
|
||||
<!-- <div class="custom bg-red! color-white z-1" v-else-if="item.update">更新中</div>-->
|
||||
<!-- <div class="sync bg-red! color-white z-1" v-if="!item.sync && isUser && !showCheckbox">未同步</div>-->
|
||||
</div>
|
||||
<div class="text-base mt-1" v-if="item?.cover">{{ item?.name }}</div>
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { IS_DEV } from '@/config/env'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const router = useRouter()
|
||||
|
||||
function goHome() {
|
||||
router.push('/')
|
||||
if (IS_DEV) {
|
||||
router.push('/')
|
||||
} else {
|
||||
location.href = window.atob('aHR0cHM6Ly90eXBld29yZHMuY2M=')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center mb-2" @click="goHome">
|
||||
<img v-show="settingStore.theme === 'dark'" src="/logo-text-white.png" alt="">
|
||||
<img v-show="settingStore.theme !== 'dark'" src="/logo-text-black.png" alt="">
|
||||
<img v-show="settingStore.theme === 'dark'" src="/logo-text-white.png" alt="" />
|
||||
<img v-show="settingStore.theme !== 'dark'" src="/logo-text-black.png" alt="" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {Origin} from "@/config/env.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import {set} from 'idb-keyval'
|
||||
import {defineAsyncComponent} from "vue";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import { Origin } from "@/config/env.ts";
|
||||
import { set } from 'idb-keyval';
|
||||
import { defineAsyncComponent } from "vue";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
@@ -111,4 +110,4 @@ async function transfer() {
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, provide} from "vue"
|
||||
import {ShortcutKey} from "@/types/types.ts"
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import Close from "@/components/icon/Close.vue";
|
||||
import Tooltip from "@/components/base/Tooltip.vue";
|
||||
import {ShortcutKey} from "@/types/enum.ts";
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
let tabIndex = $ref(0)
|
||||
@@ -33,14 +33,9 @@ provide('tabIndex', computed(() => tabIndex))
|
||||
<style scoped lang="scss">
|
||||
|
||||
.panel {
|
||||
border-radius: .5rem;
|
||||
width: var(--panel-width);
|
||||
background: var(--color-second);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-item-border);
|
||||
box-shadow: var(--shadow);
|
||||
@apply shadow-lg flex flex-col h-full rounded-xl;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
|
||||
@@ -18,9 +18,9 @@ export default {
|
||||
validator(value) {
|
||||
// Validate that array items have the correct structure
|
||||
if (Array.isArray(value)) {
|
||||
return value.every(item =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
return value.every(item =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
typeof item.text === 'string' &&
|
||||
['normal', 'bold', 'red', 'redBold'].includes(item.type)
|
||||
)
|
||||
@@ -114,8 +114,8 @@ export default {
|
||||
<div ref="tip" class="pop-confirm-content shadow-2xl">
|
||||
<div class="w-52 title-content">
|
||||
{this.titleItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
<div
|
||||
key={index}
|
||||
style={this.getTextStyle(item.type)}
|
||||
class="title-item"
|
||||
>
|
||||
@@ -140,17 +140,14 @@ export default {
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.pop-confirm-content {
|
||||
position: fixed;
|
||||
background: var(--color-tooltip-bg);
|
||||
padding: 1rem;
|
||||
border-radius: .6rem;
|
||||
transform: translate(-50%, calc(-100% - .6rem));
|
||||
z-index: 999;
|
||||
@apply fixed z-9999 shadow-2xl rounded-lg p-3;
|
||||
|
||||
.title-content {
|
||||
.title-item {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
defineProps<{
|
||||
@@ -8,12 +8,16 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center relative h-screen"
|
||||
:class="!settingStore.showToolbar && 'footer-hide'">
|
||||
<div class="flex justify-center relative" :class="!settingStore.showToolbar && 'footer-hide'">
|
||||
<div class="wrap">
|
||||
<slot name="practice"></slot>
|
||||
</div>
|
||||
<div class="panel-wrap" :style="{left:panelLeft}" :class="{'has-panel': settingStore.showPanel}" @click.self="settingStore.showPanel = false">
|
||||
<div
|
||||
class="panel-wrap"
|
||||
:style="{ left: panelLeft }"
|
||||
:class="{ 'has-panel': settingStore.showPanel }"
|
||||
@click.self="settingStore.showPanel = false"
|
||||
>
|
||||
<slot name="panel"></slot>
|
||||
</div>
|
||||
<div class="footer-wrap">
|
||||
@@ -23,17 +27,11 @@ defineProps<{
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.wrap {
|
||||
transition: all var(--anim-time);
|
||||
height: calc(100vh - 8rem);
|
||||
}
|
||||
|
||||
.footer-hide {
|
||||
.wrap {
|
||||
height: calc(100vh - 3rem) !important;
|
||||
}
|
||||
|
||||
.footer-wrap {
|
||||
bottom: -6rem;
|
||||
}
|
||||
@@ -41,14 +39,14 @@ defineProps<{
|
||||
|
||||
.footer-wrap {
|
||||
position: fixed;
|
||||
bottom: calc(0.8rem + env(safe-area-inset-bottom, 0px));
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px));
|
||||
transition: all var(--anim-time);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.panel-wrap {
|
||||
position: absolute;
|
||||
top: .8rem;
|
||||
position: fixed;
|
||||
top: 0.8rem;
|
||||
z-index: 1;
|
||||
height: calc(100vh - 1.8rem);
|
||||
}
|
||||
@@ -61,24 +59,24 @@ defineProps<{
|
||||
padding: 0 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
.footer-hide {
|
||||
.wrap {
|
||||
height: calc(100vh - 2rem) !important;
|
||||
}
|
||||
|
||||
|
||||
.footer-wrap {
|
||||
bottom: calc(-10rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.footer-wrap {
|
||||
bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
||||
.panel-wrap {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -92,10 +90,10 @@ defineProps<{
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
|
||||
// 当面板未显示时,禁用指针事件
|
||||
pointer-events: none;
|
||||
|
||||
|
||||
// 只有当面板显示时才添加背景蒙版并启用指针事件
|
||||
&.has-panel {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
@@ -110,19 +108,19 @@ defineProps<{
|
||||
height: calc(100vh - 5rem);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.footer-hide {
|
||||
.wrap {
|
||||
height: calc(100vh - 1.5rem) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.footer-wrap {
|
||||
bottom: calc(0.3rem + env(safe-area-inset-bottom, 0px));
|
||||
left: 0.3rem;
|
||||
right: 0.3rem;
|
||||
}
|
||||
|
||||
|
||||
.panel-wrap {
|
||||
padding: 0.5rem;
|
||||
left: 0 !important;
|
||||
|
||||
54
src/components/StageProgress.vue
Normal file
54
src/components/StageProgress.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="flex gap-5 w-full h-3">
|
||||
<template v-for="i of props.stages">
|
||||
<template v-if="i?.children?.length && i.active">
|
||||
<div class="flex gap-1" :style="{ width: i.ratio + '%' }">
|
||||
<template v-for="j of i.children">
|
||||
<Tooltip :title="j.name">
|
||||
<Progress
|
||||
:style="{ width: j.ratio + '%' }"
|
||||
:percentage="j.percentage"
|
||||
:stroke-width="8"
|
||||
:color="j.active ? '#72c240' : '#69b1ff'"
|
||||
:active="j.active"
|
||||
:show-text="false"
|
||||
/>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Tooltip :title="i.name">
|
||||
<Progress
|
||||
:style="{ width: i.ratio + '%' }"
|
||||
:percentage="i.percentage"
|
||||
:stroke-width="8"
|
||||
:color="i.active && props.stages.length > 1 ? '#72c240' : '#69b1ff'"
|
||||
:active="i.active"
|
||||
:show-text="false"
|
||||
/>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import Tooltip from '@/components/base/Tooltip.vue'
|
||||
import Progress from '@/components/base/Progress.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
stages: {
|
||||
name: string
|
||||
active?: boolean
|
||||
percentage: number
|
||||
ratio: number
|
||||
children: {
|
||||
active: boolean
|
||||
name: string
|
||||
percentage: number
|
||||
ratio: number
|
||||
}[]
|
||||
}[]
|
||||
}>()
|
||||
</script>
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,46 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import type { Word } from '@/types/types.ts'
|
||||
import VolumeIcon from '@/components/icon/VolumeIcon.vue'
|
||||
import { usePlayWordAudio } from '@/hooks/sound.ts'
|
||||
import Tooltip from '@/components/base/Tooltip.vue'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import { useWordOptions } from '@/hooks/dict.ts'
|
||||
|
||||
import { Word } from "@/types/types.ts";
|
||||
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
|
||||
import { usePlayWordAudio } from "@/hooks/sound.ts";
|
||||
import Tooltip from "@/components/base/Tooltip.vue";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import { useWordOptions } from "@/hooks/dict.ts";
|
||||
|
||||
withDefaults(defineProps<{
|
||||
item: Word,
|
||||
showTranslate?: boolean
|
||||
showWord?: boolean
|
||||
showTransPop?: boolean
|
||||
showOption?: boolean
|
||||
showCollectIcon?: boolean
|
||||
showMarkIcon?: boolean
|
||||
index?: number
|
||||
active?: boolean
|
||||
}>(), {
|
||||
showTranslate: true,
|
||||
showWord: true,
|
||||
showTransPop: true,
|
||||
showOption: true,
|
||||
showCollectIcon: true,
|
||||
showMarkIcon: true,
|
||||
active: false,
|
||||
})
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
item: Word
|
||||
showTranslate?: boolean
|
||||
showWord?: boolean
|
||||
showTransPop?: boolean
|
||||
showOption?: boolean
|
||||
showCollectIcon?: boolean
|
||||
showMarkIcon?: boolean
|
||||
index?: number
|
||||
active?: boolean
|
||||
}>(),
|
||||
{
|
||||
showTranslate: true,
|
||||
showWord: true,
|
||||
showTransPop: true,
|
||||
showOption: true,
|
||||
showCollectIcon: true,
|
||||
showMarkIcon: true,
|
||||
active: false,
|
||||
}
|
||||
)
|
||||
|
||||
const playWordAudio = usePlayWordAudio()
|
||||
|
||||
const {
|
||||
isWordCollect,
|
||||
toggleWordCollect,
|
||||
isWordSimple,
|
||||
toggleWordSimple
|
||||
} = useWordOptions()
|
||||
const { isWordCollect, toggleWordCollect, isWordSimple, toggleWordSimple } = useWordOptions()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="common-list-item"
|
||||
:class="{active}"
|
||||
>
|
||||
<div class="common-list-item" :class="{ active }">
|
||||
<div class="left">
|
||||
<slot name="prefix" :item="item"></slot>
|
||||
<div class="title-wrapper">
|
||||
@@ -52,10 +47,7 @@ const {
|
||||
</div>
|
||||
<div class="item-sub-title flex flex-col gap-2" v-if="item.trans.length && showTranslate">
|
||||
<div v-for="v in item.trans">
|
||||
<Tooltip
|
||||
v-if="v.cn.length > 30 && showTransPop"
|
||||
:title="v.pos + ' ' + v.cn"
|
||||
>
|
||||
<Tooltip v-if="v.cn.length > 30 && showTransPop" :title="v.pos + ' ' + v.cn">
|
||||
<span>{{ v.pos + ' ' + v.cn.slice(0, 30) + '...' }}</span>
|
||||
</Tooltip>
|
||||
<span v-else>{{ v.pos + ' ' + v.cn }}</span>
|
||||
@@ -66,26 +58,26 @@ const {
|
||||
<div class="right" v-if="showOption">
|
||||
<slot name="suffix" :item="item"></slot>
|
||||
<BaseIcon
|
||||
v-if="showCollectIcon"
|
||||
:class="!isWordCollect(item)?'collect':'fill'"
|
||||
@click.stop="toggleWordCollect(item)"
|
||||
:title="!isWordCollect(item) ? '收藏' : '取消收藏'">
|
||||
<IconFluentStar16Regular v-if="!isWordCollect(item)"/>
|
||||
<IconFluentStar16Filled v-else/>
|
||||
v-if="showCollectIcon"
|
||||
:class="!isWordCollect(item) ? 'collect' : 'fill'"
|
||||
@click.stop="toggleWordCollect(item)"
|
||||
:title="!isWordCollect(item) ? '收藏' : '取消收藏'"
|
||||
>
|
||||
<IconFluentStar16Regular v-if="!isWordCollect(item)" />
|
||||
<IconFluentStar16Filled v-else />
|
||||
</BaseIcon>
|
||||
|
||||
<BaseIcon
|
||||
v-if="showMarkIcon"
|
||||
:class="!isWordSimple(item)?'collect':'fill'"
|
||||
@click.stop="toggleWordSimple(item)"
|
||||
:title="!isWordSimple(item) ? '标记为已掌握' : '取消标记已掌握'">
|
||||
<IconFluentCheckmarkCircle16Regular v-if="!isWordSimple(item)"/>
|
||||
<IconFluentCheckmarkCircle16Filled v-else/>
|
||||
v-if="showMarkIcon"
|
||||
:class="!isWordSimple(item) ? 'collect' : 'fill'"
|
||||
@click.stop="toggleWordSimple(item)"
|
||||
:title="!isWordSimple(item) ? '标记为已掌握' : '取消标记已掌握'"
|
||||
>
|
||||
<IconFluentCheckmarkCircle16Regular v-if="!isWordSimple(item)" />
|
||||
<IconFluentCheckmarkCircle16Filled v-else />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 音量控制 -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {defineComponent, ref, useAttrs, watch, computed} from 'vue';
|
||||
import {ref, useAttrs, watch, computed} from 'vue';
|
||||
import Close from "@/components/icon/Close.vue";
|
||||
import {useDisableEventListener} from "@/hooks/event.ts";
|
||||
|
||||
|
||||
54
src/components/base/OptionButton.vue
Normal file
54
src/components/base/OptionButton.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="flex box-border cp color-white">
|
||||
<div class="option-wrap">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="relative group">
|
||||
<div class="more w-10 rounded-r-lg h-full center box-border transition-all duration-300">
|
||||
<IconFluentChevronDown20Regular />
|
||||
</div>
|
||||
<div
|
||||
class="space-y-2 btn-no-margin pt-2 absolute z-2 right-0 border rounded opacity-0 scale-95 group-hover:opacity-100 group-hover:scale-100 transition-all duration-150 pointer-events-none group-hover:pointer-events-auto"
|
||||
>
|
||||
<slot name="options"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.option-wrap {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
:deep(.base-button) {
|
||||
width: 100%;
|
||||
border-top-right-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
.more {
|
||||
background: var(--btn-primary);
|
||||
border: 1.5px solid transparent;
|
||||
border-left-color: #69788e;
|
||||
&:hover {
|
||||
background: var(--btn-primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.orange-btn {
|
||||
.more {
|
||||
background: var(--btn-orange);
|
||||
color: black;
|
||||
border: 1px solid transparent;
|
||||
border-left-color: #cfb752;
|
||||
&:hover {
|
||||
background: var(--btn-orange-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ interface IProps {
|
||||
textInside?: boolean;
|
||||
strokeWidth?: number;
|
||||
color?: string;
|
||||
active?: boolean;
|
||||
format?: (percentage: number) => string;
|
||||
size?: 'normal' | 'large';
|
||||
}
|
||||
@@ -16,6 +17,7 @@ const props = withDefaults(defineProps<IProps>(), {
|
||||
textInside: false,
|
||||
strokeWidth: 6,
|
||||
color: '#409eff',
|
||||
active: true,
|
||||
format: (percentage) => `${percentage}%`,
|
||||
size: 'normal',
|
||||
});
|
||||
@@ -31,6 +33,7 @@ const trackStyle = computed(() => {
|
||||
const height = props.size === 'large' ? props.strokeWidth * 2.5 : props.strokeWidth;
|
||||
return {
|
||||
height: `${height}px`,
|
||||
opacity: props.active ? 1 : 0.4,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
<template>
|
||||
<div class="inline-flex w-full relative"
|
||||
:class="[disabled && 'disabled']"
|
||||
>
|
||||
<div class="inline-flex w-full relative" :class="[disabled && 'disabled']">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="innerValue"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="maxlength"
|
||||
:rows="rows"
|
||||
:disabled="disabled"
|
||||
:style="textareaStyle"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md outline-none resize-none transition-colors duration-200 box-border"
|
||||
@input="handleInput"
|
||||
ref="textareaRef"
|
||||
v-model="innerValue"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="maxlength"
|
||||
:rows="rows"
|
||||
:disabled="disabled"
|
||||
:style="textareaStyle"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md outline-none resize-none transition-colors duration-200 box-border"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<!-- 字数统计 -->
|
||||
<span
|
||||
v-if="showWordLimit && maxlength"
|
||||
class="absolute bottom-1 right-2 text-xs text-gray-400 select-none"
|
||||
v-if="showWordLimit && maxlength"
|
||||
class="absolute bottom-1 right-2 text-xs text-gray-400 select-none"
|
||||
>
|
||||
{{ innerValue.length }} / {{ maxlength }}
|
||||
</span>
|
||||
@@ -24,36 +22,38 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, computed, nextTick} from "vue"
|
||||
|
||||
import { ref, watch, computed, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string,
|
||||
placeholder?: string,
|
||||
maxlength?: number,
|
||||
rows?: number,
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
maxlength?: number
|
||||
rows?: number
|
||||
autosize: boolean | { minRows?: number; maxRows?: number }
|
||||
showWordLimit?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(["update:modelValue"])
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const innerValue = ref(props.modelValue ?? "")
|
||||
watch(() => props.modelValue, v => (innerValue.value = v ?? ""))
|
||||
const innerValue = ref(props.modelValue ?? '')
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
v => (innerValue.value = v ?? '')
|
||||
)
|
||||
|
||||
const textareaRef = ref<HTMLTextAreaElement>()
|
||||
|
||||
// 样式(用于控制高度)
|
||||
const textareaStyle = computed(() => {
|
||||
return props.autosize ? {height: "auto"} : {}
|
||||
return props.autosize ? { height: 'auto' } : {}
|
||||
})
|
||||
|
||||
// 输入处理
|
||||
const handleInput = (e: Event) => {
|
||||
const val = (e.target as HTMLTextAreaElement).value
|
||||
innerValue.value = val
|
||||
emit("update:modelValue", val)
|
||||
emit('update:modelValue', val)
|
||||
if (props.autosize) nextTick(resizeTextarea)
|
||||
}
|
||||
|
||||
@@ -61,36 +61,38 @@ const handleInput = (e: Event) => {
|
||||
const resizeTextarea = () => {
|
||||
if (!textareaRef.value) return
|
||||
const el = textareaRef.value
|
||||
el.style.height = "auto"
|
||||
el.style.height = 'auto'
|
||||
let height = el.scrollHeight
|
||||
let overflow = "hidden"
|
||||
let overflow = 'hidden'
|
||||
|
||||
if (typeof props.autosize === "object") {
|
||||
const {minRows, maxRows} = props.autosize
|
||||
if (typeof props.autosize === 'object') {
|
||||
const { minRows, maxRows } = props.autosize
|
||||
const lineHeight = 24 // 行高约等于 24px
|
||||
if (minRows) height = Math.max(height, minRows * lineHeight)
|
||||
if (maxRows) {
|
||||
const maxHeight = maxRows * lineHeight
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight
|
||||
overflow = "auto" // 超出时允许滚动
|
||||
overflow = 'auto' // 超出时允许滚动
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
el.style.height = height + "px"
|
||||
el.style.height = height + 'px'
|
||||
el.style.overflowY = overflow
|
||||
}
|
||||
|
||||
watch(innerValue, () => {
|
||||
if (props.autosize) nextTick(resizeTextarea)
|
||||
}, {immediate: true})
|
||||
|
||||
watch(
|
||||
innerValue,
|
||||
() => {
|
||||
if (props.autosize) nextTick(resizeTextarea)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
<style>
|
||||
<style scoped lang="scss">
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
|
||||
textarea {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ export default {
|
||||
</Transition>
|
||||
|
||||
<DefaultNode
|
||||
onClick={() => this.show = false}
|
||||
onmouseenter={(e) => this.showPop(e)}
|
||||
onmouseleave={() => this.show = false}
|
||||
/>
|
||||
@@ -73,14 +72,8 @@ export default {
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.tip {
|
||||
position: fixed;
|
||||
font-size: 1rem;
|
||||
z-index: 9999;
|
||||
border-radius: .3rem;
|
||||
padding: 0.4rem .8rem;
|
||||
color: var(--color-font-1);
|
||||
background: var(--color-tooltip-bg);
|
||||
max-width: 22rem;
|
||||
box-shadow: 0 0 6px 1px var(--color-tooltip-shadow);
|
||||
@apply fixed z-9999 shadow-xl border-item-solid rounded-md px-2.5 py-1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
:disabled="isDisabled"
|
||||
/>
|
||||
<span class="radio__inner"></span>
|
||||
<span class="radio__label">
|
||||
<span class="text-sm">
|
||||
<slot>{{ label }}</slot>
|
||||
</span>
|
||||
</label>
|
||||
@@ -83,11 +83,7 @@ function onClick() {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.radio__label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
|
||||
&.is-checked {
|
||||
.radio__inner {
|
||||
|
||||
@@ -23,7 +23,7 @@ provide('radioGroupValue', groupValue)
|
||||
provide('radioGroupDisabled', props.disabled)
|
||||
provide('updateRadioGroupValue', (val: string | number | boolean) => {
|
||||
if (props.disabled) return
|
||||
groupValue.value = val
|
||||
// groupValue.value = val
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const selectHandler = inject('selectHandler', null);
|
||||
|
||||
// 计算当前选项是否被选中
|
||||
const isSelected = computed(() => {
|
||||
return selectValue === props.value;
|
||||
return selectValue.value === props.value;
|
||||
});
|
||||
|
||||
// 点击选项时调用ElSelect提供的方法
|
||||
@@ -45,20 +45,16 @@ watch(() => props.value, () => {}, { immediate: true });
|
||||
|
||||
<style scoped lang="scss">
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
@apply flex items-center px-2 py-1 cursor-pointer transition-all duration-300;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-third);
|
||||
background-color: var(--color-fourth);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
color: var(--color-select-bg);
|
||||
font-weight: bold;
|
||||
background-color: var(--color-third);
|
||||
background-color: var(--color-fifth);
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
|
||||
@@ -1,197 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, provide, ref, useAttrs, useSlots, VNode, watch} from 'vue';
|
||||
import {useWindowClick} from "@/hooks/event.ts";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, useSlots, VNode, watch } from 'vue'
|
||||
import { useWindowClick } from '@/hooks/event.ts'
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: any;
|
||||
disabled?: boolean;
|
||||
label: string
|
||||
value: any
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
options?: Option[];
|
||||
}>();
|
||||
modelValue: any
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
options?: Option[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const attrs = useAttrs();
|
||||
const emit = defineEmits(['update:modelValue', 'toggle'])
|
||||
|
||||
const isOpen = ref(false);
|
||||
const isReverse = ref(false);
|
||||
const dropdownStyle = ref({}); // Teleport 用的样式
|
||||
const selectedOption = ref<Option | null>(null);
|
||||
const selectRef = ref<HTMLDivElement | null>(null);
|
||||
const dropdownRef = ref<HTMLDivElement | null>(null);
|
||||
const slots = useSlots();
|
||||
const isOpen = ref(false)
|
||||
const isReverse = ref(false)
|
||||
const dropdownStyle = ref({}) // Teleport 用的样式
|
||||
const selectedOption = ref<Option | null>(null)
|
||||
const selectRef = ref<HTMLDivElement | null>(null)
|
||||
const dropdownRef = ref<HTMLDivElement | null>(null)
|
||||
const slots = useSlots()
|
||||
|
||||
const displayValue = computed(() => {
|
||||
return selectedOption.value
|
||||
? selectedOption.value.label
|
||||
: props.placeholder || '请选择';
|
||||
});
|
||||
return selectedOption.value ? selectedOption.value.label : props.placeholder || '请选择'
|
||||
})
|
||||
|
||||
const updateDropdownPosition = async () => {
|
||||
if (!selectRef.value || !dropdownRef.value) return;
|
||||
if (!selectRef.value || !dropdownRef.value) return
|
||||
|
||||
// 等待 DOM 完全渲染(尤其是下拉框高度)
|
||||
await nextTick();
|
||||
await new Promise(requestAnimationFrame);
|
||||
await nextTick()
|
||||
await new Promise(requestAnimationFrame)
|
||||
|
||||
const rect = selectRef.value.getBoundingClientRect();
|
||||
const dropdownHeight = dropdownRef.value.offsetHeight;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const rect = selectRef.value.getBoundingClientRect()
|
||||
const dropdownHeight = dropdownRef.value.offsetHeight
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
|
||||
isReverse.value = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
|
||||
isReverse.value = spaceBelow < dropdownHeight && spaceAbove > spaceBelow
|
||||
|
||||
dropdownStyle.value = {
|
||||
position: 'fixed',
|
||||
left: rect.left + 'px',
|
||||
width: rect.width + 'px',
|
||||
top: !isReverse.value
|
||||
? rect.bottom + 5 + 'px'
|
||||
: 'auto',
|
||||
bottom: isReverse.value
|
||||
? window.innerHeight - rect.top + 5 + 'px'
|
||||
: 'auto',
|
||||
zIndex: 9999
|
||||
};
|
||||
};
|
||||
top: !isReverse.value ? rect.bottom + 5 + 'px' : 'auto',
|
||||
bottom: isReverse.value ? window.innerHeight - rect.top + 5 + 'px' : 'auto',
|
||||
zIndex: 9999,
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
if (props.disabled) return;
|
||||
if (props.disabled) return
|
||||
|
||||
isOpen.value = !isOpen.value;
|
||||
isOpen.value = !isOpen.value
|
||||
emit('toggle', isOpen.value)
|
||||
|
||||
if (isOpen.value) {
|
||||
await nextTick();
|
||||
await new Promise(requestAnimationFrame);
|
||||
await updateDropdownPosition();
|
||||
await nextTick()
|
||||
await new Promise(requestAnimationFrame)
|
||||
await updateDropdownPosition()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const selectOption = (value: any, label: string) => {
|
||||
selectedOption.value = {value, label};
|
||||
emit('update:modelValue', value);
|
||||
isOpen.value = false;
|
||||
};
|
||||
selectedOption.value = { value, label }
|
||||
emit('update:modelValue', value)
|
||||
isOpen.value = false
|
||||
emit('toggle', isOpen.value)
|
||||
}
|
||||
|
||||
let selectValue = $ref(props.modelValue);
|
||||
let selectValue = ref(props.modelValue)
|
||||
|
||||
provide('selectValue', selectValue);
|
||||
provide('selectHandler', selectOption);
|
||||
provide('selectValue', selectValue)
|
||||
provide('selectHandler', selectOption)
|
||||
|
||||
useWindowClick((e: PointerEvent) => {
|
||||
if (!e) return;
|
||||
if (!e) return
|
||||
if (
|
||||
selectRef.value &&
|
||||
!selectRef.value.contains(e.target as Node) &&
|
||||
dropdownRef.value &&
|
||||
!dropdownRef.value.contains(e.target as Node)
|
||||
selectRef.value &&
|
||||
!selectRef.value.contains(e.target as Node) &&
|
||||
dropdownRef.value &&
|
||||
!dropdownRef.value.contains(e.target as Node)
|
||||
) {
|
||||
isOpen.value = false;
|
||||
isOpen.value = false
|
||||
emit('toggle', isOpen.value)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
selectValue = newValue;
|
||||
if (slots.default) {
|
||||
let slot = slots.default();
|
||||
let list = [];
|
||||
if (slot.length === 1) {
|
||||
list = Array.from(slot[0].children as Array<VNode>);
|
||||
} else {
|
||||
list = slot;
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
selectValue.value = newValue
|
||||
if (slots.default) {
|
||||
let slot = slots.default()
|
||||
let list = []
|
||||
if (slot.length === 1) {
|
||||
list = Array.from(slot[0].children as Array<VNode>)
|
||||
} else {
|
||||
list = slot
|
||||
}
|
||||
const option = list.find(opt => opt.props.value === newValue)
|
||||
if (option) {
|
||||
selectedOption.value = option.props
|
||||
}
|
||||
return
|
||||
}
|
||||
const option = list.find(opt => opt.props.value === newValue);
|
||||
if (option) {
|
||||
selectedOption.value = option.props;
|
||||
if (props.options) {
|
||||
const option = props.options.find(opt => opt.value === newValue)
|
||||
if (option) {
|
||||
selectedOption.value = option
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (props.options) {
|
||||
const option = props.options.find(opt => opt.value === newValue);
|
||||
if (option) {
|
||||
selectedOption.value = option;
|
||||
}
|
||||
}
|
||||
}, {immediate: true});
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(() => props.options, (newOptions) => {
|
||||
if (newOptions && props.modelValue) {
|
||||
const option = newOptions.find(opt => opt.value === props.modelValue);
|
||||
if (option) {
|
||||
selectedOption.value = option;
|
||||
watch(
|
||||
() => props.options,
|
||||
newOptions => {
|
||||
if (newOptions && props.modelValue) {
|
||||
const option = newOptions.find(opt => opt.value === props.modelValue)
|
||||
if (option) {
|
||||
selectedOption.value = option
|
||||
}
|
||||
}
|
||||
}
|
||||
}, {immediate: true});
|
||||
|
||||
const handleOptionClick = (option: Option) => {
|
||||
if (option.disabled) return;
|
||||
selectOption(option.value, option.label);
|
||||
};
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const onScrollOrResize = () => {
|
||||
if (isOpen.value) updateDropdownPosition();
|
||||
};
|
||||
if (isOpen.value) updateDropdownPosition()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', onScrollOrResize, true);
|
||||
window.addEventListener('resize', onScrollOrResize);
|
||||
});
|
||||
window.addEventListener('scroll', onScrollOrResize, true)
|
||||
window.addEventListener('resize', onScrollOrResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', onScrollOrResize, true);
|
||||
window.removeEventListener('resize', onScrollOrResize);
|
||||
});
|
||||
window.removeEventListener('scroll', onScrollOrResize, true)
|
||||
window.removeEventListener('resize', onScrollOrResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="select"
|
||||
v-bind="attrs"
|
||||
:class="{ 'is-disabled': disabled, 'is-active': isOpen, 'is-reverse': isReverse }"
|
||||
ref="selectRef"
|
||||
>
|
||||
<div class="select__wrapper" @click="toggleDropdown">
|
||||
<div class="select" ref="selectRef">
|
||||
<div class="select__wrapper" :class="{ disabled: disabled, active: isOpen }" @click="toggleDropdown">
|
||||
<div class="select__label" :class="{ 'is-placeholder': !selectedOption }">
|
||||
{{ displayValue }}
|
||||
</div>
|
||||
<div class="select__suffix">
|
||||
<IconFluentChevronLeft20Filled
|
||||
class="arrow"
|
||||
:class="{ 'is-reverse': isOpen }"
|
||||
width="16"
|
||||
/>
|
||||
</div>
|
||||
<IconFluentChevronLeft20Filled class="select__arrow" :class="{ 'is-reverse': isOpen }" width="16" />
|
||||
</div>
|
||||
|
||||
<teleport to="body">
|
||||
<transition :name="isReverse ? 'zoom-in-bottom' : 'zoom-in-top'" :key="isReverse ? 'bottom' : 'top'">
|
||||
<div
|
||||
class="select__dropdown"
|
||||
v-if="isOpen"
|
||||
ref="dropdownRef"
|
||||
:style="dropdownStyle"
|
||||
>
|
||||
<ul class="select__options">
|
||||
<li
|
||||
v-if="options"
|
||||
v-for="(option, index) in options"
|
||||
:key="index"
|
||||
class="select__option"
|
||||
:class="{
|
||||
'is-selected': option.value === modelValue,
|
||||
'is-disabled': option.disabled
|
||||
}"
|
||||
@click="handleOptionClick(option)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</li>
|
||||
<slot v-else></slot>
|
||||
</ul>
|
||||
<div class="select__dropdown" v-if="isOpen" ref="dropdownRef" :style="dropdownStyle">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
@@ -200,24 +168,27 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
@apply relative w-full;
|
||||
|
||||
&__wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@apply flex items-center justify-between rounded-md cursor-pointer transition-all duration-300;
|
||||
height: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--color-input-bg, #fff);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-select-bg);
|
||||
&:not(.disabled):hover {
|
||||
border-color: #3c89e8;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #1668dc !important;
|
||||
box-shadow: 0 0 2px 1px #166ce4;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-fourth);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,17 +203,12 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
&__suffix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&__arrow {
|
||||
color: #999;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.3s;
|
||||
|
||||
.arrow {
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.is-reverse {
|
||||
&.is-reverse {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
@@ -251,44 +217,16 @@ onBeforeUnmount(() => {
|
||||
.select__dropdown {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--color-input-bg);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.select__options {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.select__option {
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-item-hover);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
color: var(--color-select-bg);
|
||||
font-weight: bold;
|
||||
background-color: var(--color-item-active);
|
||||
}
|
||||
}
|
||||
|
||||
.is-disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
background-color: var(--color-card-bg);
|
||||
@apply shadow-xl rounded-lg border border-gray-300 border-solid;
|
||||
}
|
||||
|
||||
/* 往下展开的动画 */
|
||||
.zoom-in-top-enter-active,
|
||||
.zoom-in-top-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.23, 1, 0.32, 1),
|
||||
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transition:
|
||||
transform 0.3s cubic-bezier(0.23, 1, 0.32, 1),
|
||||
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
@@ -301,8 +239,9 @@ onBeforeUnmount(() => {
|
||||
/* 往上展开的动画 */
|
||||
.zoom-in-bottom-enter-active,
|
||||
.zoom-in-bottom-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.23, 1, 0.32, 1),
|
||||
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transition:
|
||||
transform 0.3s cubic-bezier(0.23, 1, 0.32, 1),
|
||||
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, watch } from "vue";
|
||||
import Tooltip from "@/components/base/Tooltip.vue";
|
||||
import { useEventListener } from "@/hooks/event.ts";
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
import Tooltip from '@/components/base/Tooltip.vue'
|
||||
import { useEventListener } from '@/hooks/event.ts'
|
||||
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import { useRuntimeStore } from '@/stores/runtime.ts'
|
||||
|
||||
export interface ModalProps {
|
||||
modelValue?: boolean,
|
||||
showClose?: boolean,
|
||||
title?: string,
|
||||
content?: string,
|
||||
fullScreen?: boolean;
|
||||
modelValue?: boolean
|
||||
showClose?: boolean
|
||||
title?: string
|
||||
content?: string
|
||||
fullScreen?: boolean
|
||||
padding?: boolean
|
||||
footer?: boolean
|
||||
header?: boolean
|
||||
confirmButtonText?: string
|
||||
cancelButtonText?: string,
|
||||
keyboard?: boolean,
|
||||
closeOnClickBg?: boolean,
|
||||
cancelButtonText?: string
|
||||
keyboard?: boolean
|
||||
closeOnClickBg?: boolean
|
||||
confirm?: any
|
||||
beforeClose?: any
|
||||
}
|
||||
@@ -32,15 +32,10 @@ const props = withDefaults(defineProps<ModalProps>(), {
|
||||
header: true,
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
keyboard: true
|
||||
keyboard: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'close',
|
||||
'ok',
|
||||
'cancel',
|
||||
])
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'ok', 'cancel'])
|
||||
|
||||
let confirmButtonLoading = $ref(false)
|
||||
let zIndex = $ref(999)
|
||||
@@ -56,21 +51,21 @@ async function close() {
|
||||
return
|
||||
}
|
||||
if (props.beforeClose) {
|
||||
if (!await props.beforeClose()) {
|
||||
if (!(await props.beforeClose())) {
|
||||
return
|
||||
}
|
||||
}
|
||||
//记录停留时间,避免时间太短,弹框闪烁
|
||||
let stayTime = Date.now() - openTime;
|
||||
let closeTime = 300;
|
||||
let stayTime = Date.now() - openTime
|
||||
let closeTime = 300
|
||||
if (stayTime < 500) {
|
||||
closeTime += 500 - stayTime;
|
||||
closeTime += 500 - stayTime
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
maskRef?.classList.toggle('bounce-out');
|
||||
modalRef?.classList.toggle('bounce-out');
|
||||
}, 500 - stayTime);
|
||||
maskRef?.classList.toggle('bounce-out')
|
||||
modalRef?.classList.toggle('bounce-out')
|
||||
}, 500 - stayTime)
|
||||
|
||||
setTimeout(() => {
|
||||
emit('update:modelValue', false)
|
||||
@@ -82,20 +77,22 @@ async function close() {
|
||||
runtimeStore.modalList.splice(rIndex, 1)
|
||||
}
|
||||
}, closeTime)
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, n => {
|
||||
// console.log('n', n)
|
||||
if (n) {
|
||||
id = Date.now()
|
||||
runtimeStore.modalList.push({ id, close })
|
||||
zIndex = 999 + runtimeStore.modalList.length
|
||||
visible = true
|
||||
} else {
|
||||
close()
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
n => {
|
||||
if (n) {
|
||||
id = Date.now()
|
||||
runtimeStore.modalList.push({ id, close })
|
||||
zIndex = 999 + runtimeStore.modalList.length
|
||||
visible = true
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.modelValue === undefined) {
|
||||
@@ -139,32 +136,30 @@ async function cancel() {
|
||||
emit('cancel')
|
||||
await close()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="modal-root" :style="{'z-index': zIndex}" v-if="visible">
|
||||
<div class="modal-mask"
|
||||
ref="maskRef"
|
||||
v-if="!fullScreen"
|
||||
@click.stop="closeOnClickBg && close()"></div>
|
||||
<div class="modal"
|
||||
ref="modalRef"
|
||||
:class="[
|
||||
fullScreen?'full':'window'
|
||||
]"
|
||||
>
|
||||
<div class="modal-root" :style="{ 'z-index': zIndex }" v-if="visible">
|
||||
<div
|
||||
class="modal-mask"
|
||||
ref="maskRef"
|
||||
v-if="!fullScreen"
|
||||
@click.stop="closeOnClickBg && close()"
|
||||
></div>
|
||||
<div class="modal" ref="modalRef" :class="[fullScreen ? 'full' : 'window']">
|
||||
<Tooltip title="关闭">
|
||||
<IconFluentDismiss20Regular @click="close"
|
||||
v-if="showClose"
|
||||
class="close cursor-pointer"
|
||||
width="24"/>
|
||||
<IconFluentDismiss20Regular
|
||||
@click="close"
|
||||
v-if="showClose"
|
||||
class="close cursor-pointer"
|
||||
width="24"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div class="modal-header" v-if="header">
|
||||
<div class="title">{{ props.title }}</div>
|
||||
</div>
|
||||
<div class="modal-body" :class="{padding}">
|
||||
<div class="modal-body" :class="{ padding }">
|
||||
<slot></slot>
|
||||
<div v-if="content" class="content max-h-60vh">{{ content }}</div>
|
||||
</div>
|
||||
@@ -174,10 +169,8 @@ async function cancel() {
|
||||
</div>
|
||||
<div class="right">
|
||||
<BaseButton type="info" @click="cancel">{{ cancelButtonText }}</BaseButton>
|
||||
<BaseButton
|
||||
id="dialog-ok"
|
||||
:loading="confirmButtonLoading"
|
||||
@click="ok">{{ confirmButtonText }}
|
||||
<BaseButton id="dialog-ok" :loading="confirmButtonLoading" @click="ok"
|
||||
>{{ confirmButtonText }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,12 +180,7 @@ async function cancel() {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
|
||||
$modal-mask-bg: rgba(#000, .6);
|
||||
$radius: .5rem;
|
||||
$time: 0.3s;
|
||||
$header-height: 4rem;
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
@@ -224,38 +212,21 @@ $header-height: 4rem;
|
||||
}
|
||||
|
||||
.modal-root {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
@apply fixed top-0 left-0 z-999 flex items-center justify-center w-full h-full overflow-hidden;
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: $modal-mask-bg;
|
||||
transition: background 0.3s;
|
||||
@apply fixed top-0 left-0 w-full h-full transition-all duration-300;
|
||||
background: rgba(#000, 0.6);
|
||||
animation: fade-in $time;
|
||||
|
||||
&.bounce-out {
|
||||
background: transparent;
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.window {
|
||||
//width: 75vw;
|
||||
//height: 70vh;
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: $radius;
|
||||
animation: bounce-in $time ease-out;
|
||||
@apply shadow-lg rounded-lg;
|
||||
|
||||
&.bounce-out {
|
||||
opacity: 0;
|
||||
@@ -263,8 +234,7 @@ $header-height: 4rem;
|
||||
}
|
||||
|
||||
.full {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@apply w-full h-full;
|
||||
animation: bounce-in-full $time ease-out;
|
||||
|
||||
&.bounce-out {
|
||||
@@ -274,61 +244,35 @@ $header-height: 4rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
background: var(--color-second);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform $time, opacity $time;
|
||||
@apply relative overflow-hidden flex flex-col transition-all duration-300;
|
||||
background: var(--color-card-bg);
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
right: 1.2rem;
|
||||
top: 1.2rem;
|
||||
z-index: 999;
|
||||
@apply absolute right-1.2rem top-1.2rem z-999;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--modal-padding);
|
||||
padding-bottom: 0;
|
||||
border-radius: $radius $radius 0 0;
|
||||
@apply flex justify-between items-center p-5 pb-0 rounded-t-lg;
|
||||
|
||||
.title {
|
||||
color: var(--color-font-1);
|
||||
font-weight: bold;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.8rem;
|
||||
@apply font-bold text-xl;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
box-sizing: border-box;
|
||||
color: var(--color-main-text);
|
||||
font-weight: 400;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.7rem;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@apply box-border text-main-text font-normal text-base leading-6 w-full flex-1 overflow-hidden flex;
|
||||
|
||||
&.padding {
|
||||
padding: .2rem var(--modal-padding);
|
||||
@apply p-1 px-5;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 25rem;
|
||||
padding: .2rem 1.6rem 1.6rem;
|
||||
@apply w-64 p-2 px-4 pb-4;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--modal-padding);
|
||||
@apply flex justify-between p-5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,37 +35,19 @@ watch(() => props.modelValue, (n) => {
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
|
||||
.mini-row-title {
|
||||
min-height: 2rem;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
@apply text-center text-base font-bold mb-2;
|
||||
color: var(--color-font-1);
|
||||
}
|
||||
|
||||
.mini-row {
|
||||
min-height: 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space);
|
||||
@apply min-h-10 flex justify-between items-center gap-space text-base text-font-1 word-break-keep-all;
|
||||
color: var(--color-font-1);
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.mini-modal {
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
width: 12rem;
|
||||
background: var(--color-second);
|
||||
border-radius: .5rem;
|
||||
box-shadow: 0 0 8px 2px var(--color-item-border);
|
||||
padding: .6rem var(--space);
|
||||
//top: 2.4rem;
|
||||
left: 50%;
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
//margin-top: 10rem;
|
||||
background: var(--color-card-bg);
|
||||
padding: var(--space) 1rem;
|
||||
@apply z-9 absolute left-1/2 transform -translate-x-1/2 shadow-lg rounded-xl w-50;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Article } from '@/types/types.ts'
|
||||
import type { Article } from '@/types/types.ts'
|
||||
import BaseList from '@/components/list/BaseList.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import { useArticleOptions } from '@/hooks/dict.ts'
|
||||
@@ -8,16 +8,17 @@ import BaseIcon from '@/components/BaseIcon.vue'
|
||||
interface IProps {
|
||||
list: Article[]
|
||||
showTranslate?: boolean
|
||||
showDesc?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
list: () => [] as Article[],
|
||||
showTranslate: true,
|
||||
showDesc: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [val: { item: Article; index: number }]
|
||||
title: [val: { item: Article; index: number }]
|
||||
}>()
|
||||
|
||||
let searchKey = $ref('')
|
||||
@@ -88,7 +89,7 @@ defineExpose({ scrollToBottom, scrollToItem })
|
||||
<div class="item-title">
|
||||
<div class="name">
|
||||
<span class="text-sm text-gray-500" v-if="index != undefined && !searchKey">
|
||||
{{ index }}.
|
||||
{{ item.id == -1 ? '' : (index - (props.showDesc ? 1 : 0)) + '.' }}
|
||||
</span>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
@@ -107,9 +108,9 @@ defineExpose({ scrollToBottom, scrollToItem })
|
||||
<IconFluentStar16Regular v-if="!isArticleCollect(item)" />
|
||||
<IconFluentStar16Filled v-else />
|
||||
</BaseIcon>
|
||||
<BaseIcon title="可播放音频" v-if="item.audioSrc || item.audioFileId" noBg>
|
||||
<IconBxVolumeFull class="opacity-100! color-gray" />
|
||||
</BaseIcon>
|
||||
<!-- <BaseIcon title="可播放音频" v-if="item.audioSrc || item.audioFileId" noBg>-->
|
||||
<!-- <IconBxVolumeFull class="opacity-100! color-gray" />-->
|
||||
<!-- </BaseIcon>-->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -29,7 +29,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
//虚拟列表长度限制
|
||||
const limit = 101
|
||||
const limit = 200
|
||||
const settingStore = useSettingStore()
|
||||
const listRef: any = $ref()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {watch} from "vue";
|
||||
import {DictResource} from "@/types/types.ts";
|
||||
import type {DictResource} from "@/types/types.ts";
|
||||
import DictList from "@/components/list/DictList.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -69,16 +69,16 @@ watch(() => props.groupByTag, () => {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
|
||||
|
||||
.category {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
.tags {
|
||||
margin: 0.5rem 0;
|
||||
gap: 0.3rem;
|
||||
|
||||
|
||||
.tag {
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
@@ -98,7 +98,7 @@ watch(() => props.groupByTag, () => {
|
||||
.category {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
.tags {
|
||||
.tag {
|
||||
padding: 0.2rem 0.6rem;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {Dict} from "@/types/types.ts";
|
||||
import type {Dict} from "@/types/types.ts";
|
||||
import Book from "@/components/Book.vue";
|
||||
|
||||
defineProps<{
|
||||
@@ -38,7 +38,7 @@ const emit = defineEmits<{
|
||||
@media (max-width: 768px) {
|
||||
.flex.gap-4.flex-wrap {
|
||||
gap: 0.5rem;
|
||||
|
||||
|
||||
.book {
|
||||
width: 5rem;
|
||||
height: calc(5rem * 1.4);
|
||||
@@ -46,33 +46,33 @@ const emit = defineEmits<{
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
|
||||
.text-base {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
|
||||
.absolute.bottom-4.right-3 {
|
||||
bottom: 0.8rem;
|
||||
right: 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
.absolute.bottom-2.left-3.right-3 {
|
||||
bottom: 0.2rem;
|
||||
left: 0.3rem;
|
||||
right: 0.3rem;
|
||||
}
|
||||
|
||||
|
||||
.absolute.left-3.bottom-3 {
|
||||
left: 0.3rem;
|
||||
bottom: 0.3rem;
|
||||
@@ -85,22 +85,22 @@ const emit = defineEmits<{
|
||||
@media (max-width: 480px) {
|
||||
.flex.gap-4.flex-wrap {
|
||||
gap: 0.3rem;
|
||||
|
||||
|
||||
.book {
|
||||
width: 4.5rem;
|
||||
height: calc(4.5rem * 1.4);
|
||||
padding: 0.4rem;
|
||||
|
||||
|
||||
.text-base {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.6rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
.absolute.bottom-4.right-3 {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import { cloneDeep, throttle } from "@/utils";
|
||||
import { Article } from "@/types/types.ts";
|
||||
import type { Article } from "@/types/types.ts";
|
||||
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
|
||||
import BaseInput from "@/components/base/BaseInput.vue";
|
||||
|
||||
@@ -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,41 +178,5 @@ defineExpose({scrollBottom})
|
||||
.search {
|
||||
margin: .6rem 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
.item {
|
||||
box-sizing: border-box;
|
||||
background: var(--color-item-bg);
|
||||
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 {
|
||||
.right {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-item-active);
|
||||
color: var(--color-font-1);
|
||||
}
|
||||
|
||||
&.draggable {
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import BaseList from "@/components/list/BaseList.vue";
|
||||
import { Word } from "@/types/types.ts";
|
||||
import type { Word } from "@/types/types.ts";
|
||||
import WordItem from "../WordItem.vue";
|
||||
|
||||
withDefaults(defineProps<{
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { Word } from "@/types/types.ts";
|
||||
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
|
||||
import BaseList from "@/components/list/BaseList.vue";
|
||||
import { usePlayWordAudio } from "@/hooks/sound.ts";
|
||||
import Tooltip from "@/components/base/Tooltip.vue";
|
||||
import WordItem from "@/components/WordItem.vue";
|
||||
|
||||
withDefaults(defineProps<{
|
||||
list: Word[],
|
||||
showTranslate?: boolean
|
||||
showWord?: boolean
|
||||
}>(), {
|
||||
list: [],
|
||||
showTranslate: true,
|
||||
showWord: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [val: { item: Word, index: number }],
|
||||
title: [val: { item: Word, index: number }],
|
||||
}>()
|
||||
|
||||
const listRef: any = $ref(null as any)
|
||||
|
||||
function scrollToBottom() {
|
||||
listRef?.scrollToBottom()
|
||||
}
|
||||
|
||||
function scrollToItem(index: number) {
|
||||
listRef?.scrollToItem(index)
|
||||
}
|
||||
|
||||
const playWordAudio = usePlayWordAudio()
|
||||
|
||||
defineExpose({ scrollToBottom, scrollToItem })
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseList ref="listRef" @click="(e: any) => emit('click', e)" :list="list" v-bind="$attrs">
|
||||
<template v-slot:prefix="{ item, index }">
|
||||
<slot name="prefix" :item="item" :index="index"></slot>
|
||||
</template>
|
||||
<template v-slot="{ item, index }">
|
||||
<div class="item-title">
|
||||
<span class="text-sm">{{ index + 1 }}.</span>
|
||||
<span class="word" :class="!showWord && 'word-shadow'">{{ item.word }}</span>
|
||||
<span class="phonetic" :class="!showWord && 'word-shadow'">{{ item.phonetic0 }}</span>
|
||||
<VolumeIcon class="volume" @click="playWordAudio(item.word)"></VolumeIcon>
|
||||
</div>
|
||||
<div class="item-sub-title flex flex-col gap-2" v-if="item.trans.length && showTranslate">
|
||||
<div v-for="v in item.trans">
|
||||
<Tooltip v-if="v.cn.length > 30" :key="item.word" :title="v.pos + ' ' + v.cn">
|
||||
<span>{{ v.pos + ' ' + v.cn.slice(0, 30) + '...' }}</span>
|
||||
</Tooltip>
|
||||
<span v-else>{{ v.pos + ' ' + v.cn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:suffix="{ item, index }">
|
||||
<slot name="suffix" :item="item" :index="index"></slot>
|
||||
</template>
|
||||
</BaseList>
|
||||
</template>
|
||||
33
src/components/setting/ArticleSetting.vue
Normal file
33
src/components/setting/ArticleSetting.vue
Normal file
@@ -0,0 +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'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SettingItem mainTitle="音效" />
|
||||
<SettingItem title="自动播放句子">
|
||||
<Switch v-model="settingStore.articleSound" />
|
||||
</SettingItem>
|
||||
<SettingItem title="结束后播放下一篇">
|
||||
<Switch v-model="settingStore.articleAutoPlayNext" />
|
||||
</SettingItem>
|
||||
<SettingItem title="音量">
|
||||
<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 />
|
||||
</SettingItem>
|
||||
|
||||
<div class="line"></div>
|
||||
<SettingItem title="输入时忽略符号/数字/人名">
|
||||
<Switch v-model="settingStore.ignoreSymbol" />
|
||||
</SettingItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,43 +0,0 @@
|
||||
<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";
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<!-- 文章练习设置-->
|
||||
<!-- 文章练习设置-->
|
||||
<!-- 文章练习设置-->
|
||||
<div>
|
||||
<!-- 发音-->
|
||||
<!-- 发音-->
|
||||
<!-- 发音-->
|
||||
<SettingItem mainTitle="音效"/>
|
||||
<SettingItem title="自动播放句子">
|
||||
<Switch v-model="settingStore.articleSound"/>
|
||||
</SettingItem>
|
||||
<SettingItem title="自动播放下一篇">
|
||||
<Switch v-model="settingStore.articleAutoPlayNext"/>
|
||||
</SettingItem>
|
||||
<SettingItem title="音量">
|
||||
<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/>
|
||||
</SettingItem>
|
||||
|
||||
<div class="line"></div>
|
||||
<SettingItem title="输入时忽略符号/数字/人名">
|
||||
<Switch v-model="settingStore.ignoreSymbol"/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
@@ -1,16 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { ShortcutKey } from "@/types/types.ts";
|
||||
import { SoundFileOptions } from "@/config/env.ts";
|
||||
import { getAudioFileUrl, usePlayAudio } from "@/hooks/sound.ts";
|
||||
import Switch from "@/components/base/Switch.vue";
|
||||
import { Option, Select } from "@/components/base/select";
|
||||
import Textarea from "@/components/base/Textarea.vue";
|
||||
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
|
||||
import Slider from "@/components/base/Slider.vue";
|
||||
import SettingItem from "@/pages/setting/SettingItem.vue";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { useBaseStore } from "@/stores/base.ts";
|
||||
import { SoundFileOptions } from '@/config/env.ts'
|
||||
import { getAudioFileUrl, usePlayAudio } from '@/hooks/sound.ts'
|
||||
import Switch from '@/components/base/Switch.vue'
|
||||
import { Option, Select } from '@/components/base/select'
|
||||
import Textarea from '@/components/base/Textarea.vue'
|
||||
import VolumeIcon from '@/components/icon/VolumeIcon.vue'
|
||||
import Slider from '@/components/base/Slider.vue'
|
||||
import SettingItem from '@/pages/setting/SettingItem.vue'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
import {ShortcutKey} from "@/types/enum.ts";
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const store = useBaseStore()
|
||||
@@ -19,94 +18,79 @@ const simpleWords = $computed({
|
||||
get: () => store.simpleWords.join(','),
|
||||
set: v => {
|
||||
try {
|
||||
store.simpleWords = v.split(',');
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
}
|
||||
store.simpleWords = v.split(',')
|
||||
} catch (e) {}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 通用练习设置-->
|
||||
<!-- 通用练习设置-->
|
||||
<!-- 通用练习设置-->
|
||||
<div>
|
||||
<SettingItem title="忽略大小写"
|
||||
desc="开启后,输入时不区分大小写,如输入“hello”和“Hello”都会被认为是正确的"
|
||||
<SettingItem
|
||||
title="忽略大小写"
|
||||
desc="开启后,输入时不区分大小写,如输入“hello”和“Hello”都会被认为是正确的"
|
||||
>
|
||||
<Switch v-model="settingStore.ignoreCase"/>
|
||||
<Switch v-model="settingStore.ignoreCase" />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem title="允许默写模式下显示提示"
|
||||
:desc="`开启后,可以通过将鼠标移动到单词上或者按快捷键 ${settingStore.shortcutKeyMap[ShortcutKey.ShowWord]} 显示正确答案`"
|
||||
<SettingItem
|
||||
title="允许默写模式下显示提示"
|
||||
:desc="`开启后,可以通过将鼠标移动到单词上或者按快捷键 ${settingStore.shortcutKeyMap[ShortcutKey.ShowWord]} 显示正确答案`"
|
||||
>
|
||||
<Switch v-model="settingStore.allowWordTip"/>
|
||||
<Switch v-model="settingStore.allowWordTip" />
|
||||
</SettingItem>
|
||||
|
||||
<div class="line"></div>
|
||||
<SettingItem title="简单词过滤"
|
||||
desc="开启后,练习的单词中不会包含简单词;文章统计的总词数中不会包含简单词"
|
||||
<SettingItem
|
||||
title="简单词过滤"
|
||||
desc="开启后,练习的单词中不会包含简单词;文章统计的总词数中不会包含简单词"
|
||||
>
|
||||
<Switch v-model="settingStore.ignoreSimpleWord"/>
|
||||
<Switch v-model="settingStore.ignoreSimpleWord" />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem title="简单词列表"
|
||||
class="items-start!"
|
||||
v-if="settingStore.ignoreSimpleWord"
|
||||
>
|
||||
<Textarea
|
||||
placeholder="多个单词用英文逗号隔号"
|
||||
v-model="simpleWords" :autosize="{minRows: 6, maxRows: 10}"/>
|
||||
<SettingItem title="简单词列表" class="items-start!" v-if="settingStore.ignoreSimpleWord">
|
||||
<Textarea
|
||||
placeholder="多个单词用英文逗号隔号"
|
||||
v-model="simpleWords"
|
||||
:autosize="{ minRows: 6, maxRows: 10 }"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<!-- 音效-->
|
||||
<!-- 音效-->
|
||||
<!-- 音效-->
|
||||
<div class="line"></div>
|
||||
<SettingItem main-title="音效"/>
|
||||
<SettingItem title="单词/句子发音口音"
|
||||
desc="仅单词生效,文章固定美音"
|
||||
>
|
||||
<Select v-model="settingStore.soundType"
|
||||
placeholder="请选择"
|
||||
class="w-50!"
|
||||
>
|
||||
<Option label="美音" value="us"/>
|
||||
<Option label="英音" value="uk"/>
|
||||
<SettingItem main-title="音效" />
|
||||
<SettingItem title="单词/句子发音口音" desc="仅单词生效,文章固定美音">
|
||||
<Select v-model="settingStore.soundType" placeholder="请选择" class="w-50!">
|
||||
<Option label="美音" value="us" />
|
||||
<Option label="英音" value="uk" />
|
||||
</Select>
|
||||
</SettingItem>
|
||||
|
||||
<div class="line"></div>
|
||||
<SettingItem title="按键音">
|
||||
<Switch v-model="settingStore.keyboardSound"/>
|
||||
<Switch v-model="settingStore.keyboardSound" />
|
||||
</SettingItem>
|
||||
<SettingItem title="按键音效">
|
||||
<Select v-model="settingStore.keyboardSoundFile"
|
||||
placeholder="请选择"
|
||||
class="w-50!"
|
||||
>
|
||||
<Select v-model="settingStore.keyboardSoundFile" placeholder="请选择" class="w-50!">
|
||||
<Option
|
||||
v-for="item in SoundFileOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
v-for="item in SoundFileOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
>
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span>{{ item.label }}</span>
|
||||
<VolumeIcon
|
||||
:time="100"
|
||||
@click="usePlayAudio(getAudioFileUrl(item.value)[0])"/>
|
||||
<VolumeIcon :time="100" @click="usePlayAudio(getAudioFileUrl(item.value)[0])" />
|
||||
</div>
|
||||
</Option>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
<SettingItem title="音量">
|
||||
<Slider v-model="settingStore.keyboardSoundVolume" showText showValue unit="%"/>
|
||||
<Slider v-model="settingStore.keyboardSoundVolume" showText showValue unit="%" />
|
||||
</SettingItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineAsyncComponent } from "vue";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import CommonSetting from "@/components/setting/CommonSetting.vue";
|
||||
import WordSetting from "@/components/setting/WordSetting.vue";
|
||||
import ArticleSettting from "@/components/setting/ArticleSettting.vue";
|
||||
import ArticleSetting from "@/components/setting/ArticleSetting.vue";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
@@ -39,7 +39,7 @@ let show = $ref(false)
|
||||
<div class="content">
|
||||
<CommonSetting v-if="tabIndex === 0"/>
|
||||
<WordSetting v-if="tabIndex === 1"/>
|
||||
<ArticleSettting v-if="tabIndex === 2"/>
|
||||
<ArticleSetting v-if="tabIndex === 2"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@ let show = $ref(false)
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-right: 1px solid gainsboro;
|
||||
border-right: 2px solid var(--color-line);
|
||||
|
||||
.tabs {
|
||||
padding: 1rem;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
slideTouchMove,
|
||||
slideTouchStart
|
||||
} from "./common";
|
||||
import {SlideType} from "@/types/types.ts";
|
||||
|
||||
import {SlideType} from "@/config/env";
|
||||
|
||||
const props = defineProps({
|
||||
index: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {emitter as bus} from "@/utils/eventBus.ts";
|
||||
import Utils from '@/utils/gm.js'
|
||||
import {SlideType} from "@/types/types.ts";
|
||||
import GM from "@/utils/gm.js";
|
||||
import {SlideType} from "@/config/env";
|
||||
|
||||
export function slideInit(el, state, type) {
|
||||
state.wrapper.width = GM.$getCss(el, 'width')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { offset } from '@floating-ui/dom'
|
||||
import { ShortcutKey, WordPracticeMode, WordPracticeStage } from '@/types/enum.ts'
|
||||
|
||||
export const GITHUB = 'https://github.com/zyronon/TypeWords'
|
||||
export const Host = 'typewords.cc'
|
||||
@@ -68,15 +69,6 @@ export const EXPORT_DATA_KEY = {
|
||||
}
|
||||
export const LOCAL_FILE_KEY = 'typing-word-files'
|
||||
|
||||
export const PracticeSaveWordKey = {
|
||||
key: 'PracticeSaveWord',
|
||||
version: 1,
|
||||
}
|
||||
export const PracticeSaveArticleKey = {
|
||||
key: 'PracticeSaveArticle',
|
||||
version: 1,
|
||||
}
|
||||
|
||||
export const TourConfig = {
|
||||
useModalOverlay: true,
|
||||
defaultStepOptions: {
|
||||
@@ -92,6 +84,7 @@ export const TourConfig = {
|
||||
total: 7,
|
||||
}
|
||||
|
||||
export const IS_DEV = import.meta.env.MODE === 'development'
|
||||
export const LIB_JS_URL = {
|
||||
SHEPHERD:
|
||||
import.meta.env.MODE === 'development'
|
||||
@@ -101,3 +94,103 @@ export const LIB_JS_URL = {
|
||||
JSZIP: `${Origin}/libs/jszip.min.js`,
|
||||
XLSX: `${Origin}/libs/xlsx.full.min.js`,
|
||||
}
|
||||
export const PronunciationApi = 'https://dict.youdao.com/dictvoice?audio='
|
||||
export const DefaultShortcutKeyMap = {
|
||||
[ShortcutKey.EditArticle]: 'Ctrl+E',
|
||||
[ShortcutKey.ShowWord]: 'Escape',
|
||||
[ShortcutKey.Previous]: 'Alt+⬅',
|
||||
[ShortcutKey.Next]: 'Tab',
|
||||
[ShortcutKey.ToggleSimple]: '`',
|
||||
[ShortcutKey.ToggleCollect]: 'Enter',
|
||||
[ShortcutKey.PreviousChapter]: 'Ctrl+⬅',
|
||||
[ShortcutKey.NextChapter]: 'Ctrl+➡',
|
||||
[ShortcutKey.RepeatChapter]: 'Ctrl+Enter',
|
||||
[ShortcutKey.DictationChapter]: 'Alt+Enter',
|
||||
[ShortcutKey.PlayWordPronunciation]: 'Ctrl+P',
|
||||
[ShortcutKey.ToggleShowTranslate]: 'Ctrl+Z',
|
||||
[ShortcutKey.ToggleDictation]: 'Ctrl+I',
|
||||
[ShortcutKey.ToggleTheme]: 'Ctrl+Q',
|
||||
[ShortcutKey.ToggleConciseMode]: 'Ctrl+M',
|
||||
[ShortcutKey.TogglePanel]: 'Ctrl+L',
|
||||
[ShortcutKey.RandomWrite]: 'Ctrl+R',
|
||||
[ShortcutKey.KnowWord]: '1',
|
||||
[ShortcutKey.UnknownWord]: '2',
|
||||
}
|
||||
export const SlideType = {
|
||||
HORIZONTAL: 0,
|
||||
VERTICAL: 1,
|
||||
}
|
||||
export const WordPracticeModeStageMap: Record<WordPracticeMode, WordPracticeStage[]> = {
|
||||
[WordPracticeMode.Free]: [WordPracticeStage.FollowWriteNewWord, WordPracticeStage.Complete],
|
||||
[WordPracticeMode.IdentifyOnly]: [
|
||||
WordPracticeStage.IdentifyNewWord,
|
||||
WordPracticeStage.IdentifyReview,
|
||||
WordPracticeStage.IdentifyReviewAll,
|
||||
WordPracticeStage.Complete,
|
||||
],
|
||||
[WordPracticeMode.DictationOnly]: [
|
||||
WordPracticeStage.DictationNewWord,
|
||||
WordPracticeStage.DictationReview,
|
||||
WordPracticeStage.DictationReviewAll,
|
||||
WordPracticeStage.Complete,
|
||||
],
|
||||
[WordPracticeMode.ListenOnly]: [
|
||||
WordPracticeStage.ListenNewWord,
|
||||
WordPracticeStage.ListenReview,
|
||||
WordPracticeStage.ListenReviewAll,
|
||||
WordPracticeStage.Complete,
|
||||
],
|
||||
[WordPracticeMode.System]: [
|
||||
WordPracticeStage.FollowWriteNewWord,
|
||||
WordPracticeStage.ListenNewWord,
|
||||
WordPracticeStage.DictationNewWord,
|
||||
WordPracticeStage.IdentifyReview,
|
||||
WordPracticeStage.ListenReview,
|
||||
WordPracticeStage.DictationReview,
|
||||
WordPracticeStage.IdentifyReviewAll,
|
||||
WordPracticeStage.ListenReviewAll,
|
||||
WordPracticeStage.DictationReviewAll,
|
||||
WordPracticeStage.Complete,
|
||||
],
|
||||
[WordPracticeMode.Shuffle]: [WordPracticeStage.Shuffle, WordPracticeStage.Complete],
|
||||
[WordPracticeMode.Review]: [
|
||||
WordPracticeStage.IdentifyReview,
|
||||
WordPracticeStage.ListenReview,
|
||||
WordPracticeStage.DictationReview,
|
||||
WordPracticeStage.IdentifyReviewAll,
|
||||
WordPracticeStage.ListenReviewAll,
|
||||
WordPracticeStage.DictationReviewAll,
|
||||
WordPracticeStage.Complete,
|
||||
],
|
||||
}
|
||||
export const WordPracticeStageNameMap: Record<WordPracticeStage, string> = {
|
||||
[WordPracticeStage.FollowWriteNewWord]: '跟写新词',
|
||||
[WordPracticeStage.IdentifyNewWord]: '自测新词',
|
||||
[WordPracticeStage.ListenNewWord]: '听写新词',
|
||||
[WordPracticeStage.DictationNewWord]: '默写新词',
|
||||
[WordPracticeStage.FollowWriteReview]: '跟写上次学习',
|
||||
[WordPracticeStage.IdentifyReview]: '自测上次学习',
|
||||
[WordPracticeStage.ListenReview]: '听写上次学习',
|
||||
[WordPracticeStage.DictationReview]: '默写上次学习',
|
||||
[WordPracticeStage.FollowWriteReviewAll]: '跟写之前学习',
|
||||
[WordPracticeStage.IdentifyReviewAll]: '自测之前学习',
|
||||
[WordPracticeStage.ListenReviewAll]: '听写之前学习',
|
||||
[WordPracticeStage.DictationReviewAll]: '默写之前学习',
|
||||
[WordPracticeStage.Complete]: '完成学习',
|
||||
[WordPracticeStage.Shuffle]: '随机复习',
|
||||
}
|
||||
export const WordPracticeModeNameMap: Record<WordPracticeMode, string> = {
|
||||
[WordPracticeMode.System]: '学习',
|
||||
[WordPracticeMode.Free]: '自由练习',
|
||||
[WordPracticeMode.IdentifyOnly]: '自测',
|
||||
[WordPracticeMode.DictationOnly]: '默写',
|
||||
[WordPracticeMode.ListenOnly]: '听写',
|
||||
[WordPracticeMode.Shuffle]: '随机复习',
|
||||
[WordPracticeMode.Review]: '复习',
|
||||
}
|
||||
export class DictId {
|
||||
static wordCollect = 'wordCollect'
|
||||
static wordWrong = 'wordWrong'
|
||||
static wordKnown = 'wordKnown'
|
||||
static articleCollect = 'articleCollect'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Article, DictId, PracticeArticleWordType, Sentence } from "@/types/types.ts"
|
||||
import type { Article, Sentence } from "@/types/types.ts"
|
||||
import { _nextTick, cloneDeep } from "@/utils"
|
||||
import { usePlayWordAudio } from "@/hooks/sound.ts"
|
||||
import { getSentenceAllText, getSentenceAllTranslateText } from "@/hooks/translate.ts"
|
||||
@@ -6,6 +6,9 @@ import { getDefaultArticleWord, getDefaultDict } from "@/types/func.ts"
|
||||
import { useSettingStore } from "@/stores/setting.ts"
|
||||
import { useBaseStore } from "@/stores/base.ts"
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts"
|
||||
import { nanoid } from 'nanoid'
|
||||
import {PracticeArticleWordType} from "@/types/enum.ts";
|
||||
import { DictId } from '@/config/env.ts'
|
||||
|
||||
function parseSentence(sentence: string) {
|
||||
// 先统一一些常见的“智能引号” -> 直引号,避免匹配问题
|
||||
@@ -368,7 +371,7 @@ export function syncBookInMyStudyList(study = false) {
|
||||
if (!temp.custom && temp.id !== DictId.articleCollect) {
|
||||
temp.custom = true
|
||||
if (!temp.id.includes('_custom')) {
|
||||
temp.id += '_custom'
|
||||
temp.id += '_custom_' + nanoid(6)
|
||||
}
|
||||
}
|
||||
temp.length = temp.articles.length
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Article, TaskWords, Word, WordPracticeMode } from "@/types/types.ts";
|
||||
import { useBaseStore } from "@/stores/base.ts";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { getDefaultWord } from "@/types/func.ts";
|
||||
import { getRandomN, splitIntoN } from "@/utils";
|
||||
import type { Article, Dict, TaskWords, Word } from '@/types/types.ts'
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { getDefaultDict, getDefaultWord } from '@/types/func.ts'
|
||||
import { _getDictDataByUrl, cloneDeep, getRandomN, resourceWrap, shuffle, sleep, splitIntoN } from '@/utils'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { AppEnv, DICT_LIST, DictId } from '@/config/env.ts'
|
||||
import { detail } from '@/apis'
|
||||
import { useRuntimeStore } from '@/stores/runtime.ts'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { DictType } from '@/types/enum.ts'
|
||||
|
||||
export function useWordOptions() {
|
||||
const store = useBaseStore()
|
||||
@@ -57,7 +63,7 @@ export function useWordOptions() {
|
||||
isWordSimple,
|
||||
toggleWordSimple,
|
||||
delWrongWord,
|
||||
delSimpleWord
|
||||
delSimpleWord,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +94,7 @@ export function useArticleOptions() {
|
||||
export function getCurrentStudyWord(): TaskWords {
|
||||
const store = useBaseStore()
|
||||
let data = { new: [], review: [], write: [], shuffle: [] }
|
||||
let dict = store.sdict;
|
||||
let dict = store.sdict
|
||||
let isTest = false
|
||||
let words = dict.words.slice()
|
||||
if (isTest) {
|
||||
@@ -100,56 +106,69 @@ export function getCurrentStudyWord(): TaskWords {
|
||||
const settingStore = useSettingStore()
|
||||
//忽略时是否加上自定义的简单词
|
||||
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
|
||||
const perDay = dict.perDayStudyNumber;
|
||||
let start = dict.lastLearnIndex;
|
||||
let complete = dict.complete;
|
||||
const perDay = dict.perDayStudyNumber
|
||||
let start = dict.lastLearnIndex
|
||||
let complete = dict.complete
|
||||
let isEnd = start >= dict.length - 1
|
||||
if (isTest) {
|
||||
start = 1
|
||||
complete = true
|
||||
}
|
||||
//如果已完成,并且记录在最后,那么直接随机取复习词
|
||||
if (complete && isEnd) {
|
||||
//复习比最小是1
|
||||
let ratio = settingStore.wordReviewRatio || 1
|
||||
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
|
||||
// 先将可用词表全部随机,再按需过滤忽略列表,只取到目标数量为止
|
||||
let shuffled = shuffle(cloneDeep(dict.words))
|
||||
let count = 0
|
||||
data.write = []
|
||||
for (let item of shuffled) {
|
||||
if (!ignoreList.includes(item.word.toLowerCase())) {
|
||||
data.write.push(item)
|
||||
count++
|
||||
if (count >= perDay * ratio) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
let end = start
|
||||
let list = dict.words.slice(start)
|
||||
if (complete) {
|
||||
//如果是已完成,那么把应该学的新词放到复习词组里面
|
||||
//从start往后取perDay个单词,作为当前练习单词
|
||||
for (let item of list) {
|
||||
if (!ignoreList.includes(item.word.toLowerCase())) {
|
||||
if (data.new.length < perDay) {
|
||||
data.new.push(item)
|
||||
} else break
|
||||
}
|
||||
end++
|
||||
}
|
||||
|
||||
//如果复习比大于等于1,或者已完成,那么就取复习词
|
||||
if (settingStore.wordReviewRatio >= 1 || complete) {
|
||||
//从start往前取perDay个单词,作为当前复习单词,取到0为止
|
||||
list = dict.words.slice(0, start).reverse()
|
||||
//但如果已完成,则滚动取值
|
||||
if (complete) list = list.concat(dict.words.slice(end).reverse())
|
||||
for (let item of list) {
|
||||
if (!ignoreList.includes(item.word.toLowerCase())) {
|
||||
if (data.review.length < perDay) {
|
||||
data.review.push(item)
|
||||
} else break
|
||||
}
|
||||
end++
|
||||
}
|
||||
} else {
|
||||
//从start往后取perDay个单词,作为当前练习单词
|
||||
for (let item of list) {
|
||||
if (!ignoreList.includes(item.word.toLowerCase())) {
|
||||
if (data.new.length < perDay) {
|
||||
data.new.push(item)
|
||||
} else break
|
||||
}
|
||||
end++
|
||||
}
|
||||
|
||||
if (settingStore.wordReviewRatio >= 1) {
|
||||
//从start往前取perDay个单词,作为当前复习单词,取到0为止
|
||||
list = dict.words.slice(0, start).reverse()
|
||||
for (let item of list) {
|
||||
if (!ignoreList.includes(item.word.toLowerCase())) {
|
||||
if (data.review.length < perDay) {
|
||||
data.review.push(item)
|
||||
} else break
|
||||
}
|
||||
start--
|
||||
}
|
||||
start--
|
||||
}
|
||||
}
|
||||
|
||||
//如果是自由模式,那么统统设置到new字段里面去
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.Free) {
|
||||
data.new = data.new.length ? data.new : data.review
|
||||
data.review = []
|
||||
return data
|
||||
}
|
||||
// //如果是自由模式,那么统统设置到new字段里面去
|
||||
// if (settingStore.wordPracticeMode === WordPracticeMode.Free) {
|
||||
// data.new = data.new.length ? data.new : data.review
|
||||
// data.review = []
|
||||
// return data
|
||||
// }
|
||||
|
||||
// 上上次更早的单词
|
||||
//默认只取start之前的单词
|
||||
@@ -157,10 +176,10 @@ export function getCurrentStudyWord(): TaskWords {
|
||||
let candidateWords = dict.words.slice(0, start).reverse()
|
||||
//但如果已完成,则滚动取值
|
||||
if (complete) candidateWords = candidateWords.concat(dict.words.slice(end).reverse())
|
||||
candidateWords = candidateWords.filter(w => !ignoreList.includes(w.word.toLowerCase()));
|
||||
candidateWords = candidateWords.filter(w => !ignoreList.includes(w.word.toLowerCase()))
|
||||
// console.log(candidateWords.map(v => v.word))
|
||||
//最终要获取的单词数量
|
||||
const totalNeed = perDay * (settingStore.wordReviewRatio - 1);
|
||||
const totalNeed = perDay * (settingStore.wordReviewRatio - 1)
|
||||
if (candidateWords.length <= totalNeed) {
|
||||
data.write = candidateWords
|
||||
} else {
|
||||
@@ -171,23 +190,100 @@ export function getCurrentStudyWord(): TaskWords {
|
||||
// console.log('groups', groups)
|
||||
|
||||
// 分配数量,靠前组多,靠后组少,例如分配比例 [6,5,4,3,2,1]
|
||||
const ratio = Array.from({ length: days }, (_, i) => i + 1).reverse();
|
||||
const ratioSum = ratio.reduce((a, b) => a + b, 0);
|
||||
const realRatio = ratio.map(r => Math.round(r / ratioSum * totalNeed));
|
||||
const ratio = Array.from({ length: days }, (_, i) => i + 1).reverse()
|
||||
const ratioSum = ratio.reduce((a, b) => a + b, 0)
|
||||
const realRatio = ratio.map(r => Math.round((r / ratioSum) * totalNeed))
|
||||
// console.log(ratio, ratioSum, realRatio, realRatio.reduce((a, b) => a + b, 0))
|
||||
|
||||
// 按比例从每组随机取单词
|
||||
let writeWords: Word[] = [];
|
||||
let writeWords: Word[] = []
|
||||
groups.map((v, i) => {
|
||||
writeWords = writeWords.concat(getRandomN(v, realRatio[i]))
|
||||
})
|
||||
// console.log('writeWords', writeWords)
|
||||
data.write = writeWords;
|
||||
data.write = writeWords
|
||||
}
|
||||
}
|
||||
|
||||
//如果已完成,那么合并写词和复习词
|
||||
if (complete) {
|
||||
// data.new = []
|
||||
// data.review = data.review.concat(data.write)
|
||||
// data.write = []
|
||||
}
|
||||
}
|
||||
// console.log('data-new', data.new.map(v => v.word))
|
||||
// console.log('data-review', data.review.map(v => v.word))
|
||||
// console.log('data-write', data.write.map(v => v.word))
|
||||
return data
|
||||
}
|
||||
|
||||
export function useGetDict() {
|
||||
const store = useBaseStore()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
let loading = ref(false)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
watch(
|
||||
[() => store.load, () => loading],
|
||||
([a, b]) => {
|
||||
if (a && b.value) loadDict()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (!runtimeStore.editDict?.id) {
|
||||
let dictId = route.query?.id
|
||||
if (!dictId) {
|
||||
return router.push('/articles')
|
||||
}
|
||||
loading.value = true
|
||||
} else {
|
||||
loadDict(runtimeStore.editDict)
|
||||
}
|
||||
})
|
||||
|
||||
async function loadDict(dict?: Dict) {
|
||||
if (!dict) {
|
||||
dict = getDefaultDict()
|
||||
let dictId = route.query.id
|
||||
//先在自己的词典列表里面找,如果没有再在资源列表里面找
|
||||
dict = store.article.bookList.find(v => v.id === dictId)
|
||||
let r = await fetch(resourceWrap(DICT_LIST.WORD.ALL))
|
||||
let dict_list = await r.json()
|
||||
if (!dict) dict = dict_list.flat().find(v => v.id === dictId) as Dict
|
||||
}
|
||||
if (dict && dict.id) {
|
||||
if (
|
||||
!dict?.articles?.length &&
|
||||
!dict?.custom &&
|
||||
![DictId.articleCollect].includes(dict.en_name || dict.id) &&
|
||||
!dict?.is_default
|
||||
) {
|
||||
loading.value = true
|
||||
let r = await _getDictDataByUrl(dict, DictType.article)
|
||||
runtimeStore.editDict = r
|
||||
}
|
||||
if (store.article.bookList.find(book => book.id === runtimeStore.editDict.id)) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await detail({ id: runtimeStore.editDict.id })
|
||||
if (res.success) {
|
||||
runtimeStore.editDict.statistics = res.data.statistics
|
||||
if (res.data.articles.length) {
|
||||
runtimeStore.editDict.articles = res.data.articles
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
loading.value = false
|
||||
} else {
|
||||
router.push('/articles')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import {loadJsLib, shakeCommonDict} from "@/utils";
|
||||
import { loadJsLib, shakeCommonDict } from '@/utils'
|
||||
import {
|
||||
APP_NAME,
|
||||
APP_VERSION,
|
||||
EXPORT_DATA_KEY, LIB_JS_URL,
|
||||
EXPORT_DATA_KEY,
|
||||
LIB_JS_URL,
|
||||
LOCAL_FILE_KEY,
|
||||
Origin,
|
||||
PracticeSaveArticleKey,
|
||||
PracticeSaveWordKey,
|
||||
SAVE_DICT_KEY,
|
||||
SAVE_SETTING_KEY
|
||||
} from "@/config/env.ts";
|
||||
import {get} from "idb-keyval";
|
||||
import {saveAs} from "file-saver";
|
||||
import dayjs from "dayjs";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import {ref} from "vue";
|
||||
SAVE_SETTING_KEY,
|
||||
} from '@/config/env.ts'
|
||||
import { get } from 'idb-keyval'
|
||||
import { saveAs } from 'file-saver'
|
||||
import dayjs from 'dayjs'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { ref } from 'vue'
|
||||
import { PRACTICE_ARTICLE_CACHE, PRACTICE_WORD_CACHE } from '@/utils/cache.ts'
|
||||
|
||||
export function useExport() {
|
||||
const store = useBaseStore()
|
||||
@@ -24,60 +24,61 @@ export function useExport() {
|
||||
|
||||
let loading = ref(false)
|
||||
|
||||
async function exportData(notice = '导出成功!', fileName = `${APP_NAME}-User-Data-${dayjs().format('YYYY-MM-DD HH-mm-ss')}.zip`) {
|
||||
async function exportData(
|
||||
notice = '导出成功!',
|
||||
fileName = `${APP_NAME}-User-Data-${dayjs().format('YYYY-MM-DD HH-mm-ss')}.zip`
|
||||
) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const JSZip = await loadJsLib('JSZip', LIB_JS_URL.JSZIP);
|
||||
const JSZip = await loadJsLib('JSZip', LIB_JS_URL.JSZIP)
|
||||
let data = {
|
||||
version: EXPORT_DATA_KEY.version,
|
||||
val: {
|
||||
setting: {
|
||||
version: SAVE_SETTING_KEY.version,
|
||||
val: settingStore.$state
|
||||
val: settingStore.$state,
|
||||
},
|
||||
dict: {
|
||||
version: SAVE_DICT_KEY.version,
|
||||
val: shakeCommonDict(store.$state)
|
||||
val: shakeCommonDict(store.$state),
|
||||
},
|
||||
[PracticeSaveWordKey.key]: {
|
||||
version: PracticeSaveWordKey.version,
|
||||
val: {}
|
||||
[PRACTICE_WORD_CACHE.key]: {
|
||||
version: PRACTICE_WORD_CACHE.version,
|
||||
val: {},
|
||||
},
|
||||
[PracticeSaveArticleKey.key]: {
|
||||
version: PracticeSaveArticleKey.version,
|
||||
val: {}
|
||||
[PRACTICE_ARTICLE_CACHE.key]: {
|
||||
version: PRACTICE_ARTICLE_CACHE.version,
|
||||
val: {},
|
||||
},
|
||||
[APP_VERSION.key]: -1
|
||||
}
|
||||
[APP_VERSION.key]: -1,
|
||||
},
|
||||
}
|
||||
let d = localStorage.getItem(PracticeSaveWordKey.key)
|
||||
let d = localStorage.getItem(PRACTICE_WORD_CACHE.key)
|
||||
if (d) {
|
||||
try {
|
||||
data.val[PracticeSaveWordKey.key] = JSON.parse(d)
|
||||
} catch (e) {
|
||||
}
|
||||
data.val[PRACTICE_WORD_CACHE.key] = JSON.parse(d)
|
||||
} catch (e) {}
|
||||
}
|
||||
let d1 = localStorage.getItem(PracticeSaveArticleKey.key)
|
||||
let d1 = localStorage.getItem(PRACTICE_ARTICLE_CACHE.key)
|
||||
if (d1) {
|
||||
try {
|
||||
data.val[PracticeSaveArticleKey.key] = JSON.parse(d1)
|
||||
} catch (e) {
|
||||
}
|
||||
data.val[PRACTICE_ARTICLE_CACHE.key] = JSON.parse(d1)
|
||||
} catch (e) {}
|
||||
}
|
||||
let r = await get(APP_VERSION.key)
|
||||
data.val[APP_VERSION.key] = r
|
||||
|
||||
const zip = new JSZip();
|
||||
zip.file("data.json", JSON.stringify(data));
|
||||
const zip = new JSZip()
|
||||
zip.file('data.json', JSON.stringify(data))
|
||||
|
||||
const mp3 = zip.folder("mp3");
|
||||
const allRecords = await get(LOCAL_FILE_KEY);
|
||||
const mp3 = zip.folder('mp3')
|
||||
const allRecords = await get(LOCAL_FILE_KEY)
|
||||
for (const rec of allRecords ?? []) {
|
||||
mp3.file(rec.id + ".mp3", rec.file);
|
||||
mp3.file(rec.id + '.mp3', rec.file)
|
||||
}
|
||||
let content = await zip.generateAsync({type: "blob"})
|
||||
saveAs(content, fileName);
|
||||
let content = await zip.generateAsync({ type: 'blob' })
|
||||
saveAs(content, fileName)
|
||||
notice && Toast.success(notice)
|
||||
return content
|
||||
} catch (e) {
|
||||
@@ -91,4 +92,4 @@ export function useExport() {
|
||||
loading,
|
||||
exportData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {onMounted, watchEffect} from "vue"
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import {PronunciationApi} from "@/types/types.ts";
|
||||
|
||||
import {SoundFileOptions} from "@/config/env.ts";
|
||||
import { PronunciationApi, SoundFileOptions } from '@/config/env.ts'
|
||||
|
||||
export function useSound(audioSrcList?: string[], audioFileLength?: number) {
|
||||
let audioList: HTMLAudioElement[] = $ref([])
|
||||
@@ -13,6 +12,7 @@ export function useSound(audioSrcList?: string[], audioFileLength?: number) {
|
||||
if (audioSrcList) setAudio(audioSrcList, audioFileLength)
|
||||
})
|
||||
|
||||
//这里同一个音频弄好几份是为了快速打字是,可同时发音
|
||||
function setAudio(audioSrcList2: string[], audioFileLength2?: number) {
|
||||
if (audioFileLength2) audioLength = audioFileLength2
|
||||
audioList = []
|
||||
@@ -23,6 +23,7 @@ export function useSound(audioSrcList?: string[], audioFileLength?: number) {
|
||||
}
|
||||
|
||||
function play(volume: number = 100) {
|
||||
console.log('play', audioList)
|
||||
index++
|
||||
if (audioList.length > 1 && audioList.length !== audioLength) {
|
||||
audioList[index % audioList.length].volume = volume / 100
|
||||
@@ -33,7 +34,7 @@ export function useSound(audioSrcList?: string[], audioFileLength?: number) {
|
||||
}
|
||||
}
|
||||
|
||||
return {play, setAudio}
|
||||
return { play, setAudio }
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +47,7 @@ export function usePlayKeyboardAudio() {
|
||||
settingStore.keyboardSoundFile = '机械键盘2'
|
||||
}
|
||||
let urlList = getAudioFileUrl(settingStore.keyboardSoundFile)
|
||||
setAudio(urlList, urlList.length === 1 ? 3 : 1)
|
||||
setAudio(urlList, urlList.length === 1 ? 4 : 1)
|
||||
})
|
||||
|
||||
function playAudio() {
|
||||
@@ -150,4 +151,4 @@ export function getAudioFileUrl(name: string) {
|
||||
} else {
|
||||
return [`/sound/key-sounds/${name}.mp3`]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Article, Sentence, TranslateEngine} from "@/types/types.ts";
|
||||
import type {Article, Sentence} from "@/types/types.ts";
|
||||
import Baidu from "@/libs/translate/baidu";
|
||||
import {Translator} from "@/libs/translate/translator/index.ts";
|
||||
import {TranslateEngine} from "@/types/enum.ts";
|
||||
|
||||
export function getSentenceAllTranslateText(article: Article) {
|
||||
return article.sections.map(v => v.map(s => s.translate.trim()).filter(v => v).join(' \n')).filter(v => v).join(' \n\n');
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { useBaseStore } from "@/stores/base.ts";
|
||||
import { useRouter } from "vue-router";
|
||||
import BasePage from "@/components/BasePage.vue";
|
||||
import { myDictList } from '@/apis'
|
||||
import Progress from '@/components/base/Progress.vue'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import BasePage from '@/components/BasePage.vue'
|
||||
import Book from '@/components/Book.vue'
|
||||
import DeleteIcon from '@/components/icon/DeleteIcon.vue'
|
||||
import PopConfirm from '@/components/PopConfirm.vue'
|
||||
import { AppEnv, DICT_LIST, Host, LIB_JS_URL, TourConfig } from '@/config/env.ts'
|
||||
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 type { DictResource } from '@/types/types.ts'
|
||||
import {
|
||||
_getDictDataByUrl,
|
||||
_nextTick,
|
||||
@@ -10,31 +22,21 @@ import {
|
||||
msToHourMinute,
|
||||
resourceWrap,
|
||||
total,
|
||||
useNav
|
||||
} from "@/utils";
|
||||
import { DictResource, DictType } from "@/types/types.ts";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import Book from "@/components/Book.vue";
|
||||
import Progress from '@/components/base/Progress.vue';
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import PopConfirm from "@/components/PopConfirm.vue";
|
||||
import { watch } from "vue";
|
||||
import { getDefaultDict } from "@/types/func.ts";
|
||||
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
|
||||
import dayjs from "dayjs";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
useNav,
|
||||
} from '@/utils'
|
||||
import { getPracticeArticleCache } from '@/utils/cache.ts'
|
||||
import { useFetch } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import isBetween from 'dayjs/plugin/isBetween'
|
||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import { useFetch } from "@vueuse/core";
|
||||
import { AppEnv, DICT_LIST, Host, LIB_JS_URL, PracticeSaveArticleKey, TourConfig } from "@/config/env.ts";
|
||||
import { myDictList } from "@/apis";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { DictType } from '@/types/enum.ts'
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(isBetween)
|
||||
|
||||
const {nav} = useNav()
|
||||
const { nav } = useNav()
|
||||
const base = useBaseStore()
|
||||
const store = useBaseStore()
|
||||
const settingStore = useSettingStore()
|
||||
@@ -42,76 +44,72 @@ const router = useRouter()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
let isSaveData = $ref(false)
|
||||
|
||||
watch(() => store.load, n => {
|
||||
if (n) init()
|
||||
}, {immediate: true})
|
||||
watch(
|
||||
() => store.load,
|
||||
n => {
|
||||
if (n) init()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function init() {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await myDictList({type: "article"})
|
||||
let res = await myDictList({ type: 'article' })
|
||||
if (res.success) {
|
||||
store.setState(Object.assign(store.$state, res.data))
|
||||
}
|
||||
}
|
||||
if (store.article.studyIndex >= 1) {
|
||||
if (!store.sbook.custom && !store.sbook.articles.length) {
|
||||
store.article.bookList[store.article.studyIndex] = await _getDictDataByUrl(store.sbook, DictType.article)
|
||||
store.article.bookList[store.article.studyIndex] = await _getDictDataByUrl(
|
||||
store.sbook,
|
||||
DictType.article
|
||||
)
|
||||
}
|
||||
}
|
||||
let d = localStorage.getItem(PracticeSaveArticleKey.key)
|
||||
let d = getPracticeArticleCache()
|
||||
if (d) {
|
||||
try {
|
||||
let obj = JSON.parse(d)
|
||||
let data = obj.val
|
||||
//如果全是0,说明未进行练习,直接重置
|
||||
if (
|
||||
data.practiceData.sectionIndex === 0 &&
|
||||
data.practiceData.sentenceIndex === 0 &&
|
||||
data.practiceData.wordIndex === 0
|
||||
) {
|
||||
throw new Error()
|
||||
}
|
||||
isSaveData = true
|
||||
} catch (e) {
|
||||
localStorage.removeItem(PracticeSaveArticleKey.key)
|
||||
}
|
||||
isSaveData = true
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => store?.sbook?.id, (n) => {
|
||||
console.log('n', n)
|
||||
if (!n) {
|
||||
_nextTick(async () => {
|
||||
const Shepherd = await loadJsLib('Shepherd', LIB_JS_URL.SHEPHERD);
|
||||
const tour = new Shepherd.Tour(TourConfig);
|
||||
tour.on('cancel', () => {
|
||||
localStorage.setItem('tour-guide', '1');
|
||||
});
|
||||
tour.addStep({
|
||||
id: 'step7',
|
||||
text: '点击这里选择一本书籍开始学习,步骤前面选词典相同,让我们跳过中间步骤,直接开始练习吧',
|
||||
attachTo: {
|
||||
element: '#no-book',
|
||||
on: 'bottom'
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
text: `下一步(7/${TourConfig.total})`,
|
||||
action() {
|
||||
tour.next()
|
||||
nav('/practice-articles/article_nce2', {guide: 1})
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
watch(
|
||||
() => store?.sbook?.id,
|
||||
n => {
|
||||
if (!n) {
|
||||
_nextTick(async () => {
|
||||
const Shepherd = await loadJsLib('Shepherd', LIB_JS_URL.SHEPHERD)
|
||||
const tour = new Shepherd.Tour(TourConfig)
|
||||
tour.on('cancel', () => {
|
||||
localStorage.setItem('tour-guide', '1')
|
||||
})
|
||||
tour.addStep({
|
||||
id: 'step7',
|
||||
text: '点击这里选择一本书籍开始学习,步骤前面选词典相同,让我们跳过中间步骤,直接开始练习吧',
|
||||
attachTo: {
|
||||
element: '#no-book',
|
||||
on: 'bottom',
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
text: `下一步(7/${TourConfig.total})`,
|
||||
action() {
|
||||
tour.next()
|
||||
nav('/practice-articles/article_nce2', { guide: 1 })
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const r = localStorage.getItem('tour-guide');
|
||||
if (settingStore.first && !r && !isMobile()) {
|
||||
tour.start();
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
}, {immediate: true})
|
||||
const r = localStorage.getItem('tour-guide')
|
||||
if (settingStore.first && !r && !isMobile()) {
|
||||
tour.start()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function startStudy() {
|
||||
// console.log(store.sbook.articles[1])
|
||||
@@ -125,7 +123,7 @@ function startStudy() {
|
||||
name: base.sbook.name,
|
||||
custom: base.sbook.custom,
|
||||
complete: base.sbook.complete,
|
||||
s:`name:${base.sbook.name},index:${base.sbook.lastLearnIndex},title:${base.sbook.articles[base.sbook.lastLearnIndex].title}`,
|
||||
s: `name:${base.sbook.name},index:${base.sbook.lastLearnIndex},title:${base.sbook.articles[base.sbook.lastLearnIndex].title}`,
|
||||
})
|
||||
nav('/practice-articles/' + store.sbook.id)
|
||||
} else {
|
||||
@@ -151,7 +149,7 @@ function handleBatchDel() {
|
||||
}
|
||||
})
|
||||
selectIds = []
|
||||
Toast.success("删除成功!")
|
||||
Toast.success('删除成功!')
|
||||
}
|
||||
|
||||
function toggleSelect(item) {
|
||||
@@ -165,7 +163,7 @@ function toggleSelect(item) {
|
||||
|
||||
async function goBookDetail(val: DictResource) {
|
||||
runtimeStore.editDict = getDefaultDict(val)
|
||||
nav('book-detail')
|
||||
nav('book-detail',{id: val.id})
|
||||
}
|
||||
|
||||
const totalSpend = $computed(() => {
|
||||
@@ -176,7 +174,12 @@ const totalSpend = $computed(() => {
|
||||
})
|
||||
const todayTotalSpend = $computed(() => {
|
||||
if (base.sbook.statistics?.length) {
|
||||
return msToHourMinute(total(base.sbook.statistics.filter(v => dayjs(v.startDate).isSame(dayjs(), 'day')), 'spend'))
|
||||
return msToHourMinute(
|
||||
total(
|
||||
base.sbook.statistics.filter(v => dayjs(v.startDate).isSame(dayjs(), 'day')),
|
||||
'spend'
|
||||
)
|
||||
)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
@@ -189,40 +192,42 @@ const totalDay = $computed(() => {
|
||||
})
|
||||
|
||||
const weekList = $computed(() => {
|
||||
const list = Array(7).fill(false);
|
||||
const list = Array(7).fill(false)
|
||||
|
||||
// 获取本周的起止时间
|
||||
const startOfWeek = dayjs().startOf('isoWeek'); // 周一
|
||||
const endOfWeek = dayjs().endOf('isoWeek'); // 周日
|
||||
const startOfWeek = dayjs().startOf('isoWeek') // 周一
|
||||
const endOfWeek = dayjs().endOf('isoWeek') // 周日
|
||||
|
||||
store.sbook.statistics?.forEach(item => {
|
||||
const date = dayjs(item.startDate);
|
||||
const date = dayjs(item.startDate)
|
||||
if (date.isBetween(startOfWeek, endOfWeek, null, '[]')) {
|
||||
let idx = date.day();
|
||||
let idx = date.day()
|
||||
// dayjs().day() 0=周日, 1=周一, ..., 6=周六
|
||||
// 需要转换为 0=周一, ..., 6=周日
|
||||
if (idx === 0) {
|
||||
idx = 6; // 周日放到最后
|
||||
idx = 6 // 周日放到最后
|
||||
} else {
|
||||
idx = idx - 1; // 其余前移一位
|
||||
idx = idx - 1 // 其余前移一位
|
||||
}
|
||||
list[idx] = true;
|
||||
list[idx] = true
|
||||
}
|
||||
});
|
||||
})
|
||||
return list
|
||||
})
|
||||
|
||||
const {data: recommendBookList, isFetching} = useFetch(resourceWrap(DICT_LIST.ARTICLE.RECOMMENDED)).json()
|
||||
const { data: recommendBookList, isFetching } = useFetch(
|
||||
resourceWrap(DICT_LIST.ARTICLE.RECOMMENDED)
|
||||
).json()
|
||||
|
||||
let isNewHost = $ref(window.location.host === Host)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasePage>
|
||||
<div class="mb-4" v-if="!isNewHost">
|
||||
新域名已启用,后续请访问 <a href="https://typewords.cc/words?from_old_site=1">https://typewords.cc</a>。当前
|
||||
2study.top 域名将在不久后停止使用
|
||||
新域名已启用,后续请访问
|
||||
<a href="https://typewords.cc/words?from_old_site=1">https://typewords.cc</a>。当前 2study.top
|
||||
域名将在不久后停止使用
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col md:flex-row justify-between gap-space p-4 md:p-6">
|
||||
@@ -233,10 +238,9 @@ let isNewHost = $ref(window.location.host === Host)
|
||||
quantifier="篇"
|
||||
:item="base.sbook"
|
||||
:show-progress="false"
|
||||
@click="goBookDetail(base.sbook)"/>
|
||||
<Book v-else
|
||||
:is-add="true"
|
||||
@click="router.push('/book-list')"/>
|
||||
@click="goBookDetail(base.sbook)"
|
||||
/>
|
||||
<Book v-else :is-add="true" @click="router.push('/book-list')" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex justify-between items-start">
|
||||
@@ -248,7 +252,8 @@ let isNewHost = $ref(window.location.host === Host)
|
||||
:class="item ? 'bg-[#409eff] color-white' : 'bg-gray-200'"
|
||||
v-for="(item, i) in weekList"
|
||||
:key="i"
|
||||
>{{ i + 1 }}
|
||||
>
|
||||
{{ i + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,67 +263,92 @@ let isNewHost = $ref(window.location.host === Host)
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-3 items-center mt-3 gap-space w-full">
|
||||
<div
|
||||
class="w-full sm:flex-1 rounded-xl p-4 box-border relative bg-[var(--bg-history)] border border-gray-200">
|
||||
class="w-full sm:flex-1 rounded-xl p-4 box-border relative bg-[var(--bg-history)] border border-gray-200"
|
||||
>
|
||||
<div class="text-[#409eff] text-xl font-bold">{{ todayTotalSpend }}</div>
|
||||
<div class="text-gray-500">今日学习时长</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-full sm:flex-1 rounded-xl p-4 box-border relative bg-[var(--bg-history)] border border-gray-200">
|
||||
class="w-full sm:flex-1 rounded-xl p-4 box-border relative bg-[var(--bg-history)] border border-gray-200"
|
||||
>
|
||||
<div class="text-[#409eff] text-xl font-bold">{{ totalDay }}</div>
|
||||
<div class="text-gray-500">总学习天数</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-full sm:flex-1 rounded-xl p-4 box-border relative bg-[var(--bg-history)] border border-gray-200">
|
||||
class="w-full sm:flex-1 rounded-xl p-4 box-border relative bg-[var(--bg-history)] border border-gray-200"
|
||||
>
|
||||
<div class="text-[#409eff] text-xl font-bold">{{ totalSpend }}</div>
|
||||
<div class="text-gray-500">总学习时长</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-3">
|
||||
<Progress class="w-full md:w-auto"
|
||||
size="large"
|
||||
:percentage="base.currentBookProgress"
|
||||
:format="()=> `${ base.sbook?.lastLearnIndex || 0 }/${base.sbook?.length || 0}篇`"
|
||||
:show-text="true"></Progress>
|
||||
<Progress
|
||||
class="w-full md:w-auto"
|
||||
size="large"
|
||||
:percentage="base.currentBookProgress"
|
||||
:format="() => `${base.sbook?.lastLearnIndex || 0}/${base.sbook?.length || 0}篇`"
|
||||
:show-text="true"
|
||||
></Progress>
|
||||
|
||||
<BaseButton size="large" class="w-full md:w-auto"
|
||||
@click="startStudy"
|
||||
:disabled="!base.sbook.name">
|
||||
<BaseButton
|
||||
size="large"
|
||||
class="w-full md:w-auto"
|
||||
@click="startStudy"
|
||||
:disabled="!base.sbook.name"
|
||||
>
|
||||
<div class="flex items-center gap-2 justify-center w-full">
|
||||
<span class="line-height-[2]">{{ isSaveData ? '继续学习' : '开始学习' }}</span>
|
||||
<IconFluentArrowCircleRight16Regular class="text-xl"/>
|
||||
<IconFluentArrowCircleRight16Regular class="text-xl" />
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col">
|
||||
<div class="card flex flex-col">
|
||||
<div class="flex justify-between">
|
||||
<div class="title">我的书籍</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<PopConfirm title="确认删除所有选中书籍?" @confirm="handleBatchDel" v-if="selectIds.length">
|
||||
<PopConfirm
|
||||
title="确认删除所有选中书籍?"
|
||||
@confirm="handleBatchDel"
|
||||
v-if="selectIds.length"
|
||||
>
|
||||
<BaseIcon class="del" title="删除">
|
||||
<DeleteIcon/>
|
||||
<DeleteIcon />
|
||||
</BaseIcon>
|
||||
</PopConfirm>
|
||||
|
||||
<div class="color-link cursor-pointer" v-if="base.article.bookList.length > 1"
|
||||
@click="isMultiple = !isMultiple; selectIds = []">{{ isMultiple ? '取消' : '管理书籍' }}
|
||||
<div
|
||||
class="color-link cursor-pointer"
|
||||
v-if="base.article.bookList.length > 1"
|
||||
@click="
|
||||
() => {
|
||||
isMultiple = !isMultiple
|
||||
selectIds = []
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ isMultiple ? '取消' : '管理书籍' }}
|
||||
</div>
|
||||
<div class="color-link cursor-pointer" @click="nav('book-detail', { isAdd: true })">
|
||||
创建个人书籍
|
||||
</div>
|
||||
<div class="color-link cursor-pointer" @click="nav('book-detail', { isAdd: true })">创建个人书籍</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 flex-wrap mt-4">
|
||||
<Book :is-add="false"
|
||||
:is-user="true"
|
||||
quantifier="篇"
|
||||
:item="item"
|
||||
:checked="selectIds.includes(item.id)"
|
||||
@check="() => toggleSelect(item)"
|
||||
:show-checkbox="isMultiple && j >= 1"
|
||||
v-for="(item, j) in base.article.bookList"
|
||||
@click="goBookDetail(item)"/>
|
||||
<Book :is-add="true" @click="router.push('/book-list')"/>
|
||||
<Book
|
||||
:is-add="false"
|
||||
:is-user="true"
|
||||
quantifier="篇"
|
||||
:item="item"
|
||||
:checked="selectIds.includes(item.id)"
|
||||
@check="() => toggleSelect(item)"
|
||||
:show-checkbox="isMultiple && j >= 1"
|
||||
v-for="(item, j) in base.article.bookList"
|
||||
@click="goBookDetail(item)"
|
||||
/>
|
||||
<Book :is-add="true" @click="router.push('/book-list')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -330,11 +360,14 @@ let isNewHost = $ref(window.location.host === Host)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 flex-wrap mt-4">
|
||||
<Book :is-add="false"
|
||||
quantifier="篇"
|
||||
:item="item as any"
|
||||
v-for="(item, j) in recommendBookList" @click="goBookDetail(item as any)"/>
|
||||
<div class="flex gap-4 flex-wrap mt-4">
|
||||
<Book
|
||||
:is-add="false"
|
||||
quantifier="篇"
|
||||
:item="item as any"
|
||||
v-for="(item, j) in recommendBookList"
|
||||
@click="goBookDetail(item as any)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
|
||||
@@ -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 type { Article } 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 { DictId, 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,25 +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);
|
||||
background: var(--color-second);
|
||||
display: flex;
|
||||
background: var(--color-second);
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
@@ -321,7 +313,7 @@ function updateList(e) {
|
||||
.aslide {
|
||||
width: 14vw;
|
||||
height: 100%;
|
||||
padding: 0 .6rem;
|
||||
padding: 0 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -342,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);
|
||||
}
|
||||
@@ -355,7 +347,7 @@ function updateList(e) {
|
||||
.footer {
|
||||
height: $height;
|
||||
display: flex;
|
||||
gap: .6rem;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
|
||||
@@ -1,154 +1,117 @@
|
||||
<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 } 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 BaseIcon from "@/components/BaseIcon.vue";
|
||||
import { useArticleOptions } from "@/hooks/dict.ts";
|
||||
import { getDefaultArticle, getDefaultDict } from "@/types/func.ts";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import ArticleAudio from "@/pages/article/components/ArticleAudio.vue";
|
||||
import { MessageBox } from "@/utils/MessageBox.tsx";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { useFetch } from "@vueuse/core";
|
||||
import { AppEnv, DICT_LIST } from "@/config/env.ts";
|
||||
import { detail } from "@/apis";
|
||||
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 type { Article, Dict } from '@/types/types'
|
||||
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, onUnmounted, watch } from 'vue'
|
||||
import { _dateFormat, _getDictDataByUrl, _nextTick, msToHourMinute, resourceWrap, total, useNav } 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'
|
||||
import { MessageBox } from '@/utils/MessageBox.tsx'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { useFetch } from '@vueuse/core'
|
||||
import { DICT_LIST } from '@/config/env.ts'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import Switch from '@/components/base/Switch.vue'
|
||||
import { useGetDict } from '@/hooks/dict.ts'
|
||||
import { DictType } from '@/types/enum.ts'
|
||||
|
||||
const runtimeStore = useRuntimeStore()
|
||||
const settingStore = useSettingStore()
|
||||
const base = useBaseStore()
|
||||
const store = useBaseStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const {nav} = useNav()
|
||||
const { nav } = useNav()
|
||||
|
||||
let isEdit = $ref(false)
|
||||
let isAdd = $ref(false)
|
||||
let loading = $ref(false)
|
||||
let studyLoading = $ref(false)
|
||||
|
||||
let selectArticle: Article = $ref(getDefaultArticle())
|
||||
|
||||
// 计算当前选中文章的索引
|
||||
const currentArticleIndex = computed(() => {
|
||||
return runtimeStore.editDict.articles.findIndex(article => article.id === selectArticle.id)
|
||||
})
|
||||
|
||||
// 处理播放下一个音频
|
||||
const handlePlayNext = (nextArticle: Article) => {
|
||||
selectArticle = nextArticle
|
||||
}
|
||||
let selectArticle: Article = $ref(getDefaultArticle({ id: -1 }))
|
||||
|
||||
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)
|
||||
await store.changeBook(sbook)
|
||||
studyLoading = false
|
||||
|
||||
window.umami?.track('startStudyArticle', {
|
||||
name: sbook.name,
|
||||
custom: sbook.custom,
|
||||
complete: sbook.complete,
|
||||
s:`name:${sbook.name},index:${sbook.lastLearnIndex},title:${sbook.articles[sbook.lastLearnIndex].title}`,
|
||||
s: `name:${sbook.name},index:${sbook.lastLearnIndex},title:${sbook.articles[sbook.lastLearnIndex].title}`,
|
||||
})
|
||||
nav('/practice-articles/' + sbook.id)
|
||||
}
|
||||
|
||||
const showBookDetail = computed(() => {
|
||||
return !(isAdd || isEdit);
|
||||
return !(isAdd || isEdit)
|
||||
})
|
||||
|
||||
async function init() {
|
||||
const { loading } = useGetDict()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query?.isAdd) {
|
||||
isAdd = true
|
||||
runtimeStore.editDict = getDefaultDict()
|
||||
} else {
|
||||
if (!runtimeStore.editDict.id) {
|
||||
await router.push("/articles")
|
||||
} else {
|
||||
if (!runtimeStore.editDict?.articles?.length
|
||||
&& !runtimeStore.editDict?.custom
|
||||
&& ![DictId.articleCollect].includes(runtimeStore.editDict.en_name || runtimeStore.editDict.id)
|
||||
&& !runtimeStore.editDict?.is_default
|
||||
) {
|
||||
loading = true
|
||||
let r = await _getDictDataByUrl(runtimeStore.editDict, DictType.article)
|
||||
runtimeStore.editDict = r
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
if (base.article.bookList.find(book => book.id === runtimeStore.editDict.id)) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await detail({id: runtimeStore.editDict.id})
|
||||
if (res.success) {
|
||||
runtimeStore.editDict.statistics = res.data.statistics
|
||||
if (res.data.articles.length) {
|
||||
runtimeStore.editDict.articles = res.data.articles
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (runtimeStore.editDict.articles.length) {
|
||||
selectArticle = runtimeStore.editDict.articles[0]
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
function handleResize() {
|
||||
if (displayMode === 'inline') {
|
||||
positionTranslations()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
|
||||
function formClose() {
|
||||
if (isEdit) isEdit = false
|
||||
else router.back()
|
||||
}
|
||||
|
||||
const {
|
||||
isArticleCollect,
|
||||
toggleArticleCollect
|
||||
} = useArticleOptions()
|
||||
|
||||
const {data: book_list} = useFetch(resourceWrap(DICT_LIST.ARTICLE.ALL)).json()
|
||||
const { data: book_list } = useFetch(resourceWrap(DICT_LIST.ARTICLE.ALL)).json()
|
||||
|
||||
function reset() {
|
||||
MessageBox.confirm(
|
||||
'继续此操作会重置所有文章,并从官方书籍获取最新文章列表,学习记录不会被重置。确认恢复默认吗?',
|
||||
'恢复默认',
|
||||
async () => {
|
||||
let dict = book_list.value.find(v => v.url === runtimeStore.editDict.url) as Dict
|
||||
if (dict && dict.id) {
|
||||
dict = await _getDictDataByUrl(dict, DictType.article)
|
||||
let rIndex = base.article.bookList.findIndex(v => v.id === runtimeStore.editDict.id)
|
||||
if (rIndex > -1) {
|
||||
let item = base.article.bookList[rIndex]
|
||||
item.custom = false
|
||||
item.id = dict.id
|
||||
item.articles = dict.articles
|
||||
if (item.lastLearnIndex >= item.articles.length) {
|
||||
item.lastLearnIndex = item.articles.length - 1
|
||||
}
|
||||
runtimeStore.editDict = item
|
||||
Toast.success('恢复成功')
|
||||
return
|
||||
'继续此操作会重置所有文章,并从官方书籍获取最新文章列表,学习记录不会被重置。确认恢复默认吗?',
|
||||
'恢复默认',
|
||||
async () => {
|
||||
let dict = book_list.value.find(v => v.url === runtimeStore.editDict.url) as Dict
|
||||
if (dict && dict.id) {
|
||||
dict = await _getDictDataByUrl(dict, DictType.article)
|
||||
let rIndex = store.article.bookList.findIndex(v => v.id === runtimeStore.editDict.id)
|
||||
if (rIndex > -1) {
|
||||
let item = store.article.bookList[rIndex]
|
||||
item.custom = false
|
||||
item.id = dict.id
|
||||
item.articles = dict.articles
|
||||
if (item.lastLearnIndex >= item.articles.length) {
|
||||
item.lastLearnIndex = item.articles.length - 1
|
||||
}
|
||||
runtimeStore.editDict = item
|
||||
Toast.success('恢复成功')
|
||||
return
|
||||
}
|
||||
Toast.error('恢复失败')
|
||||
}
|
||||
Toast.error('恢复失败')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -168,6 +131,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) {
|
||||
//如果是最后一个
|
||||
@@ -175,98 +139,294 @@ function next() {
|
||||
selectArticle = runtimeStore.editDict.articles[index + 1]
|
||||
}
|
||||
}
|
||||
|
||||
const list = $computed(() => {
|
||||
return [
|
||||
getDefaultArticle({
|
||||
title: '介绍',
|
||||
id: -1,
|
||||
}),
|
||||
].concat(runtimeStore.editDict.articles)
|
||||
})
|
||||
console.log('list', list)
|
||||
|
||||
let showTranslate = $ref(true)
|
||||
let startPlay = $ref(false)
|
||||
let showDisplayMode = $ref(false)
|
||||
let displayMode = $ref<'card' | 'inline' | 'line'>('inline')
|
||||
let articleWrapperRef = $ref<HTMLElement>()
|
||||
|
||||
const handleVolumeUpdate = (volume: number) => {
|
||||
settingStore.articleSoundVolume = volume
|
||||
}
|
||||
|
||||
const handleSpeedUpdate = (speed: number) => {
|
||||
settingStore.articleSoundSpeed = speed
|
||||
}
|
||||
|
||||
// 计算段落数量
|
||||
const paragraphCount = $computed(() => {
|
||||
if (!selectArticle.text) return 0
|
||||
return selectArticle.text.split('\n\n').filter(p => p.trim()).length
|
||||
})
|
||||
|
||||
// 判断是否应该在段落下显示译文(card 模式且段落数 > 1)
|
||||
const shouldShowInlineTranslation = $computed(() => {
|
||||
return displayMode === 'card' && paragraphCount > 1
|
||||
})
|
||||
|
||||
// 定位翻译到原文下方
|
||||
function positionTranslations() {
|
||||
if (loading.value || selectArticle.id === -1) return
|
||||
_nextTick(() => {
|
||||
const articleRect = articleWrapperRef.getBoundingClientRect()
|
||||
selectArticle.textTranslate.split('\n\n').forEach((paragraph, paraIndex) => {
|
||||
paragraph.split('\n').forEach((sentence, sentIndex) => {
|
||||
const location = `${paraIndex}-${sentIndex}`
|
||||
const sentenceClassName = `.word-${location}-0`
|
||||
const sentenceEl = articleWrapperRef?.querySelector(sentenceClassName)
|
||||
const translateClassName = `.translate-${location}`
|
||||
const translateEl = articleWrapperRef?.querySelector(translateClassName) as HTMLDivElement
|
||||
|
||||
if (sentenceEl && translateEl && sentence) {
|
||||
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'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 监听显示模式和文章变化,重新定位翻译
|
||||
watch([() => displayMode, () => selectArticle.id, () => showTranslate], () => {
|
||||
if (displayMode !== 'card') {
|
||||
positionTranslations()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasePage>
|
||||
<div class="card mb-0 dict-detail-card flex flex-col" v-if="showBookDetail">
|
||||
<div class="dict-header flex justify-between items-center relative">
|
||||
<BackIcon class="dict-back z-2"/>
|
||||
<div class="dict-title absolute text-2xl text-align-center w-full">{{ runtimeStore.editDict.name }}</div>
|
||||
<div class="dict-actions flex">
|
||||
<BaseButton v-if="runtimeStore.editDict.custom && runtimeStore.editDict.url" type="info" @click="reset">
|
||||
恢复默认
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2">
|
||||
<img :src="runtimeStore.editDict?.cover"
|
||||
class="w-30 rounded-md"
|
||||
v-if="runtimeStore.editDict?.cover"
|
||||
alt="">
|
||||
<div class="text-lg">介绍:{{ runtimeStore.editDict.description }}</div>
|
||||
</div>
|
||||
<div class="text-base " v-if="totalSpend">总学习时长:{{ totalSpend }}</div>
|
||||
|
||||
<div class="line my-3"></div>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<div class="left flex-[2] scroll p-0">
|
||||
<ArticleList
|
||||
v-if="runtimeStore.editDict.length"
|
||||
@title="handleCheckedChange"
|
||||
@click="handleCheckedChange"
|
||||
:list="runtimeStore.editDict.articles"
|
||||
:active-id="selectArticle.id">
|
||||
</ArticleList>
|
||||
<Empty v-else/>
|
||||
</div>
|
||||
<div class="right flex-[4] shrink-0 pl-4 overflow-auto">
|
||||
<div v-if="selectArticle.id">
|
||||
<div class="font-family text-base mb-4 pr-2" v-if="currentPractice.length">
|
||||
<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>
|
||||
<div class="en-article-family title text-xl">
|
||||
<div class="text-center text-2xl my-2">
|
||||
<ArticleAudio
|
||||
:article="selectArticle"
|
||||
:autoplay="settingStore.articleAutoPlayNext"
|
||||
@ended="next"/>
|
||||
</div>
|
||||
<div class="text-center text-2xl">{{ selectArticle.title }}</div>
|
||||
<div class="text-2xl" v-if="selectArticle.text">
|
||||
<div class="my-5" v-for="t in selectArticle.text.split('\n\n')">{{ t }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="text-center text-2xl">{{ selectArticle.titleTranslate }}</div>
|
||||
<div class="text-xl" v-if="selectArticle.textTranslate">
|
||||
<div class="my-5" v-for="t in selectArticle.textTranslate.split('\n\n')">{{ t }}</div>
|
||||
</div>
|
||||
<Empty v-else/>
|
||||
</div>
|
||||
<div class="center h-screen">
|
||||
<div
|
||||
class="bg-second w-full 3xl:w-7/10 2xl:w-8/10 xl:w-full 2xl:card 2xl:h-[97vh] h-full p-3 box-border overflow-hidden mb-0"
|
||||
>
|
||||
<div class="flex box-border flex-col h-full" v-if="showBookDetail" v-loading="loading">
|
||||
<div class="dict-header flex justify-between items-center relative">
|
||||
<div class="flex gap-space">
|
||||
<BackIcon class="dict-back z-2" />
|
||||
<div class="dict-title text-2xl text-align-center">{{ runtimeStore.editDict.name }}</div>
|
||||
</div>
|
||||
<div class="dict-actions flex">
|
||||
<BaseButton v-if="runtimeStore.editDict.custom && runtimeStore.editDict.url" type="info" @click="reset">
|
||||
恢复默认
|
||||
</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="startPractice">学习</BaseButton>
|
||||
</div>
|
||||
<Empty v-else/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 overflow-hidden mt-3">
|
||||
<div class="3xl:w-80 2xl:w-60 xl:w-55 lg:w-50 overflow-auto">
|
||||
<ArticleList
|
||||
:show-desc="true"
|
||||
v-if="list.length"
|
||||
@click="handleCheckedChange"
|
||||
:list="list"
|
||||
:active-id="selectArticle.id"
|
||||
>
|
||||
</ArticleList>
|
||||
<Empty v-else />
|
||||
</div>
|
||||
<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
|
||||
:src="runtimeStore.editDict?.cover"
|
||||
class="w-30 rounded-md"
|
||||
v-if="runtimeStore.editDict?.cover"
|
||||
alt=""
|
||||
/>
|
||||
<div class="text-lg">{{ runtimeStore.editDict.description }}</div>
|
||||
</div>
|
||||
<div class="text-base mt-10" v-if="totalSpend">总学习时长:{{ totalSpend }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex-1 overflow-auto pb-30">
|
||||
<div>
|
||||
<div class="flex justify-between items-center relative">
|
||||
<span>
|
||||
<span class="text-3xl">{{ 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="showTranslate = !showTranslate">
|
||||
<IconFluentTranslate16Regular v-if="showTranslate" />
|
||||
<IconFluentTranslateOff16Regular v-else />
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
:disabled="!showTranslate"
|
||||
:title="`切换显示模式`"
|
||||
@click="showDisplayMode = !showDisplayMode"
|
||||
>
|
||||
<IconFluentTextAlignLeft16Regular />
|
||||
</BaseIcon>
|
||||
</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)"/>
|
||||
<div class="dict-title absolute text-2xl text-align-center w-full">{{ runtimeStore.editDict.id ? '修改' : '创建' }}书籍
|
||||
<div class="flex gap-1 mr-4 justify-end" v-if="showDisplayMode">
|
||||
<BaseIcon title="逐行显示" @click="displayMode = 'inline'">
|
||||
<IconFluentTextPositionThrough20Regular />
|
||||
</BaseIcon>
|
||||
<BaseIcon title="单行显示" @click="displayMode = 'line'">
|
||||
<IconFluentTextAlignLeft16Regular />
|
||||
</BaseIcon>
|
||||
<BaseIcon title="对照显示" @click="displayMode = 'card'">
|
||||
<IconFluentAlignSpaceFitVertical20Regular />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-2xl" v-if="selectArticle?.question?.text">
|
||||
<div>Question: {{ selectArticle?.question?.text }}</div>
|
||||
<div
|
||||
class="text-xl color-translate-second"
|
||||
v-if="showTranslate && (displayMode !== 'card' || shouldShowInlineTranslation)"
|
||||
>
|
||||
问题: {{ selectArticle?.question?.translate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="article-content mt-6"
|
||||
:class="[showTranslate && displayMode !== 'card' && 'tall']"
|
||||
ref="articleWrapperRef"
|
||||
>
|
||||
<article>
|
||||
<template v-for="(t, i) in selectArticle.text.split('\n\n')" :key="`para-${i}`">
|
||||
<div class="article-row w-full mb-10">
|
||||
<span
|
||||
:class="displayMode === 'line' && 'block'"
|
||||
v-for="(w, j) in t.split('\n')"
|
||||
:key="`${i}-${j}`"
|
||||
>
|
||||
<span
|
||||
v-for="(s, n) in w.split(' ').filter(Boolean)"
|
||||
:class="`inline-block word-${i}-${j}-${n}`"
|
||||
:key="`${i}-${j}-${n}`"
|
||||
><span>{{ s }}</span>
|
||||
<span class="space"></span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 当 card 模式且段落数 > 1 时,在每个段落下显示对应译文 -->
|
||||
<div
|
||||
v-if="shouldShowInlineTranslation && showTranslate && selectArticle.textTranslate"
|
||||
class="trans-row text-xl color-translate-second -mt-7 mb-10"
|
||||
>
|
||||
<div v-if="selectArticle.textTranslate.split('\n\n')[i]">
|
||||
{{ selectArticle.textTranslate.split('\n\n')[i] }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="text-right italic">
|
||||
<div class="text-2xl" v-if="selectArticle?.quote?.text">{{ selectArticle?.quote?.text }}</div>
|
||||
<div
|
||||
class="trans-row text-xl color-translate-second"
|
||||
v-if="
|
||||
selectArticle?.quote?.translate &&
|
||||
showTranslate &&
|
||||
(displayMode !== 'card' || shouldShowInlineTranslation)
|
||||
"
|
||||
>
|
||||
{{ selectArticle?.quote?.translate }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<template v-if="showTranslate && selectArticle.textTranslate">
|
||||
<div class="translate color-translate-second" v-if="displayMode !== 'card'">
|
||||
<div
|
||||
class="break-words w-full section"
|
||||
v-for="(t, i) in selectArticle.textTranslate.split('\n\n')"
|
||||
>
|
||||
<div v-for="(w, j) in t.split('\n')" :class="`row translate-${i}-${j}`" :key="`${i}-${j}`">
|
||||
<span class="space"></span>
|
||||
<span>{{ w }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- 当段落数 <= 1 时,保持原样在文章末尾显示译文 -->
|
||||
<template v-if="!shouldShowInlineTranslation">
|
||||
<div class="line my-10"></div>
|
||||
<div class="text-xl line-height-normal space-y-5">
|
||||
<div class="mt-2" v-if="selectArticle?.question?.translate">
|
||||
问题: {{ selectArticle?.question?.translate }}
|
||||
</div>
|
||||
<div class="trans-row" v-for="t in selectArticle.textTranslate.split('\n\n')">{{ t }}</div>
|
||||
<div class="trans-row text-right italic">{{ selectArticle?.quote?.translate }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="currentPractice.length">
|
||||
<div class="line my-10"></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
|
||||
v-if="selectArticle.audioSrc || selectArticle.audioFileId"
|
||||
class="border-t-1 border-t-gray-300 border-solid border-0 center gap-2 pt-4"
|
||||
>
|
||||
<ArticleAudio
|
||||
:article="selectArticle"
|
||||
@update-speed="handleSpeedUpdate"
|
||||
@update-volume="handleVolumeUpdate"
|
||||
:autoplay="settingStore.articleAutoPlayNext && startPlay"
|
||||
@ended="next"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>结束后播放下一篇</span>
|
||||
<Switch v-model="settingStore.articleAutoPlayNext" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<Empty v-else />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="center">
|
||||
<EditBook
|
||||
:is-add="isAdd"
|
||||
:is-book="true"
|
||||
@close="formClose"
|
||||
@submit="isEdit = isAdd = false"
|
||||
/>
|
||||
<div class="" v-else>
|
||||
<div class="dict-header flex justify-between items-center relative">
|
||||
<BackIcon class="dict-back z-2" @click="isAdd ? $router.back() : (isEdit = false)" />
|
||||
<div class="dict-title absolute text-2xl text-align-center w-full">
|
||||
{{ runtimeStore.editDict.id ? '修改' : '创建' }}书籍
|
||||
</div>
|
||||
</div>
|
||||
<div class="center">
|
||||
<EditBook :is-add="isAdd" :is-book="true" @close="formClose" @submit="isEdit = isAdd = false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -282,9 +442,63 @@ function next() {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// 打字式显示模式样式(复用 TypingArticle 的样式)
|
||||
$translate-lh: 3.2;
|
||||
$article-lh: 2.4;
|
||||
|
||||
.article-content {
|
||||
position: relative;
|
||||
font-size: 1.6rem;
|
||||
|
||||
&.tall {
|
||||
article {
|
||||
line-height: $article-lh;
|
||||
color: var(--color-article);
|
||||
}
|
||||
}
|
||||
|
||||
.article-row {
|
||||
word-break: keep-all;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.trans-row {
|
||||
@apply cn-article-family font-bold;
|
||||
}
|
||||
|
||||
article {
|
||||
@apply en-article-family;
|
||||
}
|
||||
|
||||
.translate {
|
||||
@apply absolute top-0 left-0 h-full w-full text-xl pointer-events-none font-bold cn-article-family;
|
||||
line-height: $translate-lh;
|
||||
letter-spacing: 0.2rem;
|
||||
|
||||
.row {
|
||||
@apply absolute left-0 w-full opacity-0 transition-all duration-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.space {
|
||||
@apply inline-block w-2 transition-all duration-300;
|
||||
}
|
||||
|
||||
.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);
|
||||
height: calc(100vh - 2rem);
|
||||
}
|
||||
|
||||
.dict-header {
|
||||
@@ -328,4 +542,48 @@ function next() {
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配 - 打字式显示模式
|
||||
@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>
|
||||
```````` ;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { resourceWrap, useNav } from "@/utils";
|
||||
import BasePage from "@/components/BasePage.vue";
|
||||
import { DictResource } from "@/types/types.ts";
|
||||
import type { DictResource } from "@/types/types.ts";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import Empty from "@/components/Empty.vue";
|
||||
|
||||
@@ -1,48 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { computed, onMounted, onUnmounted, provide, watch } from "vue";
|
||||
import { useBaseStore } from "@/stores/base.ts";
|
||||
import { emitter, EventKey, useEvents } from "@/utils/eventBus.ts";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import {
|
||||
Article,
|
||||
ArticleItem,
|
||||
ArticleWord,
|
||||
Dict,
|
||||
DictType,
|
||||
PracticeArticleWordType,
|
||||
ShortcutKey,
|
||||
Statistics,
|
||||
Word
|
||||
} from "@/types/types.ts";
|
||||
import { useDisableEventListener, useOnKeyboardEventListener, useStartKeyboardEventListener } from "@/hooks/event.ts";
|
||||
import useTheme from "@/hooks/theme.ts";
|
||||
import { addStat, setUserDictProp } from '@/apis'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import { _getDictDataByUrl, _nextTick, cloneDeep, isMobile, loadJsLib, msToMinute, resourceWrap, total } from "@/utils";
|
||||
import { usePracticeStore } from "@/stores/practice.ts";
|
||||
import { useArticleOptions } from "@/hooks/dict.ts";
|
||||
import { genArticleSectionData, usePlaySentenceAudio } from "@/hooks/article.ts";
|
||||
import { getDefaultArticle, getDefaultDict, getDefaultWord } from "@/types/func.ts";
|
||||
import TypingArticle from "@/pages/article/components/TypingArticle.vue";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import Panel from "@/components/Panel.vue";
|
||||
import ArticleList from "@/components/list/ArticleList.vue";
|
||||
import EditSingleArticleModal from "@/pages/article/components/EditSingleArticleModal.vue";
|
||||
import Tooltip from "@/components/base/Tooltip.vue";
|
||||
import ConflictNotice from "@/components/ConflictNotice.vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import PracticeLayout from "@/components/PracticeLayout.vue";
|
||||
import ArticleAudio from "@/pages/article/components/ArticleAudio.vue";
|
||||
import { AppEnv, DICT_LIST, LIB_JS_URL, PracticeSaveArticleKey, TourConfig } from "@/config/env.ts";
|
||||
import { addStat, setUserDictProp } from "@/apis";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
import SettingDialog from "@/components/setting/SettingDialog.vue";
|
||||
import Tooltip from '@/components/base/Tooltip.vue'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import ConflictNotice from '@/components/ConflictNotice.vue'
|
||||
import ArticleList from '@/components/list/ArticleList.vue'
|
||||
import Panel from '@/components/Panel.vue'
|
||||
import PracticeLayout from '@/components/PracticeLayout.vue'
|
||||
import SettingDialog from '@/components/setting/SettingDialog.vue'
|
||||
import { AppEnv, DICT_LIST, LIB_JS_URL, TourConfig } from '@/config/env.ts'
|
||||
import { genArticleSectionData, usePlaySentenceAudio } from '@/hooks/article.ts'
|
||||
import { useArticleOptions } from '@/hooks/dict.ts'
|
||||
import { useDisableEventListener, useOnKeyboardEventListener, useStartKeyboardEventListener } from '@/hooks/event.ts'
|
||||
import useTheme from '@/hooks/theme.ts'
|
||||
import ArticleAudio from '@/pages/article/components/ArticleAudio.vue'
|
||||
import EditSingleArticleModal from '@/pages/article/components/EditSingleArticleModal.vue'
|
||||
import TypingArticle from '@/pages/article/components/TypingArticle.vue'
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
import { usePracticeStore } from '@/stores/practice.ts'
|
||||
import { useRuntimeStore } from '@/stores/runtime.ts'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { getDefaultArticle, getDefaultDict, getDefaultWord } from '@/types/func.ts'
|
||||
import type { Article, ArticleItem, ArticleWord, Dict, Statistics, Word } from '@/types/types.ts'
|
||||
import { _getDictDataByUrl, _nextTick, cloneDeep, isMobile, loadJsLib, msToMinute, resourceWrap, total } from '@/utils'
|
||||
import { getPracticeArticleCache, setPracticeArticleCache } from '@/utils/cache.ts'
|
||||
import { emitter, EventKey, useEvents } from '@/utils/eventBus.ts'
|
||||
import { computed, onMounted, onUnmounted, provide, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { DictType, PracticeArticleWordType, ShortcutKey } from '@/types/enum.ts'
|
||||
|
||||
const store = useBaseStore()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
const settingStore = useSettingStore()
|
||||
const statStore = usePracticeStore()
|
||||
const {toggleTheme} = useTheme()
|
||||
const { toggleTheme } = useTheme()
|
||||
|
||||
let articleData = $ref({
|
||||
list: [],
|
||||
@@ -57,6 +49,7 @@ let editArticle = $ref<Article>(getDefaultArticle())
|
||||
let audioRef = $ref<HTMLAudioElement>()
|
||||
let timer = $ref(0)
|
||||
let isFocus = true
|
||||
let isTyped = $ref(false)
|
||||
|
||||
function write() {
|
||||
// console.log('write')
|
||||
@@ -81,9 +74,9 @@ function prev() {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleShowTranslate = () => settingStore.translate = !settingStore.translate
|
||||
const toggleDictation = () => settingStore.dictation = !settingStore.dictation
|
||||
const togglePanel = () => settingStore.showPanel = !settingStore.showPanel
|
||||
const toggleShowTranslate = () => (settingStore.translate = !settingStore.translate)
|
||||
const toggleDictation = () => (settingStore.dictation = !settingStore.dictation)
|
||||
const togglePanel = () => (settingStore.showPanel = !settingStore.showPanel)
|
||||
const skip = () => typingArticleRef?.nextSentence()
|
||||
const collect = () => toggleArticleCollect(articleData.article)
|
||||
const shortcutKeyEdit = () => edit()
|
||||
@@ -94,6 +87,7 @@ function toggleConciseMode() {
|
||||
}
|
||||
|
||||
function next() {
|
||||
setPracticeArticleCache(null)
|
||||
if (store.sbook.lastLearnIndex >= articleData.list.length - 1) {
|
||||
store.sbook.complete = true
|
||||
store.sbook.lastLearnIndex = 0
|
||||
@@ -142,60 +136,67 @@ 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([() => store.load, () => loading], ([a, b]) => {
|
||||
if (a && b) init()
|
||||
}, {immediate: true})
|
||||
watch(
|
||||
[() => store.load, () => loading],
|
||||
([a, b]) => {
|
||||
if (a && b) init()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(() => articleData?.article?.id, id => {
|
||||
if (id) {
|
||||
_nextTick(async () => {
|
||||
const Shepherd = await loadJsLib('Shepherd', LIB_JS_URL.SHEPHERD);
|
||||
const tour = new Shepherd.Tour(TourConfig);
|
||||
tour.on('cancel', () => {
|
||||
localStorage.setItem('tour-guide', '1');
|
||||
});
|
||||
tour.addStep({
|
||||
id: 'step8',
|
||||
text: '这里可以练习文章,只需要按下键盘上对应的按键即可,没有输入框!',
|
||||
attachTo: {
|
||||
element: '#article-content',
|
||||
on: 'auto'
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
text: `关闭`,
|
||||
action() {
|
||||
settingStore.first = false
|
||||
tour.next()
|
||||
setTimeout(() => {
|
||||
showConflictNotice = true
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
const r = localStorage.getItem('tour-guide');
|
||||
if (settingStore.first && !r && !isMobile()) {
|
||||
tour.start();
|
||||
}
|
||||
}, 500)
|
||||
watch(
|
||||
() => articleData?.article?.id,
|
||||
id => {
|
||||
if (id) {
|
||||
_nextTick(async () => {
|
||||
const Shepherd = await loadJsLib('Shepherd', LIB_JS_URL.SHEPHERD)
|
||||
const tour = new Shepherd.Tour(TourConfig)
|
||||
tour.on('cancel', () => {
|
||||
localStorage.setItem('tour-guide', '1')
|
||||
})
|
||||
tour.addStep({
|
||||
id: 'step8',
|
||||
text: '这里可以练习文章,只需要按下键盘上对应的按键即可,没有输入框!',
|
||||
attachTo: {
|
||||
element: '#article-content',
|
||||
on: 'auto',
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
text: `关闭`,
|
||||
action() {
|
||||
settingStore.first = false
|
||||
tour.next()
|
||||
setTimeout(() => {
|
||||
showConflictNotice = true
|
||||
}, 1500)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const r = localStorage.getItem('tour-guide')
|
||||
if (settingStore.first && !r && !isMobile()) {
|
||||
tour.start()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
watch(() => settingStore.$state, (n) => {
|
||||
initAudio()
|
||||
}, {immediate: true, deep: true})
|
||||
watch(
|
||||
() => settingStore.$state,
|
||||
n => {
|
||||
initAudio()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (store.sbook?.articles?.length) {
|
||||
@@ -204,7 +205,6 @@ onMounted(() => {
|
||||
} else {
|
||||
loading = true
|
||||
}
|
||||
|
||||
if (route.query.guide) {
|
||||
showConflictNotice = false
|
||||
} else {
|
||||
@@ -214,58 +214,18 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
runtimeStore.disableEventListener = false
|
||||
let cache = getPracticeArticleCache()
|
||||
//如果有缓存,则更新花费的时间;因为用户不输入不会保存数据
|
||||
if (cache) {
|
||||
cache.statStoreData.spend = statStore.spend
|
||||
setPracticeArticleCache(cache)
|
||||
}
|
||||
clearInterval(timer)
|
||||
savePracticeData(true, false)
|
||||
})
|
||||
|
||||
useStartKeyboardEventListener()
|
||||
useDisableEventListener(() => loading)
|
||||
|
||||
function savePracticeData(init = true, regenerate = true) {
|
||||
let d = localStorage.getItem(PracticeSaveArticleKey.key)
|
||||
if (d) {
|
||||
try {
|
||||
let obj = JSON.parse(d)
|
||||
if (obj.val.practiceData.id !== articleData.article.id) {
|
||||
throw new Error()
|
||||
}
|
||||
if (init) {
|
||||
let data = obj.val
|
||||
//如果全是0,说明未进行练习,直接重置
|
||||
if (
|
||||
data.practiceData.sectionIndex === 0 &&
|
||||
data.practiceData.sentenceIndex === 0 &&
|
||||
data.practiceData.wordIndex === 0
|
||||
) {
|
||||
throw new Error()
|
||||
}
|
||||
//初始化时spend为0,把本地保存的值设置给statStore里面,再保存,保持一致。不然每次进来都是0
|
||||
statStore.$patch(data.statStoreData)
|
||||
}
|
||||
|
||||
obj.val.statStoreData = statStore.$state
|
||||
localStorage.setItem(PracticeSaveArticleKey.key, JSON.stringify(obj))
|
||||
} catch (e) {
|
||||
localStorage.removeItem(PracticeSaveArticleKey.key)
|
||||
regenerate && savePracticeData()
|
||||
}
|
||||
} else {
|
||||
localStorage.setItem(PracticeSaveArticleKey.key, JSON.stringify({
|
||||
version: PracticeSaveArticleKey.version,
|
||||
val: {
|
||||
practiceData: {
|
||||
sectionIndex: 0,
|
||||
sentenceIndex: 0,
|
||||
wordIndex: 0,
|
||||
stringIndex: 0,
|
||||
id: articleData.article.id
|
||||
},
|
||||
statStoreData: statStore.$state,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function setArticle(val: Article) {
|
||||
statStore.wrong = 0
|
||||
statStore.total = 0
|
||||
@@ -276,7 +236,7 @@ function setArticle(val: Article) {
|
||||
articleData.article = val
|
||||
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
|
||||
articleData.article.sections.map((v, i) => {
|
||||
v.map((w) => {
|
||||
v.map(w => {
|
||||
w.words.map(s => {
|
||||
if (!ignoreList.includes(s.word.toLowerCase()) && s.type === PracticeArticleWordType.Word) {
|
||||
statStore.total++
|
||||
@@ -285,12 +245,11 @@ function setArticle(val: Article) {
|
||||
})
|
||||
})
|
||||
|
||||
savePracticeData()
|
||||
isTyped = false
|
||||
clearInterval(timer)
|
||||
timer = setInterval(() => {
|
||||
if (isFocus) {
|
||||
statStore.spend += 1000
|
||||
savePracticeData(false)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
@@ -299,12 +258,13 @@ function setArticle(val: Article) {
|
||||
|
||||
async function complete() {
|
||||
clearInterval(timer)
|
||||
//延时删除缓存,因为可能还有输入,需要保存
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem(PracticeSaveArticleKey.key)
|
||||
setPracticeArticleCache(null)
|
||||
}, 1500)
|
||||
|
||||
//todo 有空了改成实时保存
|
||||
let data: Partial<Statistics> & { title: string, articleId: number } = {
|
||||
let data: Partial<Statistics> & { title: string; articleId: number } = {
|
||||
articleId: articleData.article.id,
|
||||
title: articleData.article.title,
|
||||
spend: statStore.spend,
|
||||
@@ -320,7 +280,7 @@ async function complete() {
|
||||
complete: store.sbook.complete,
|
||||
title: articleData.article.title,
|
||||
spend: Number(statStore.spend / 1000 / 60).toFixed(1),
|
||||
s: ''
|
||||
s: '',
|
||||
}
|
||||
reportData.s = `name:${store.sbook.name},title:${store.sbook.lastLearnIndex}.${data.title},spend:${Number(statStore.spend / 1000 / 60).toFixed(1)}`
|
||||
window.umami?.track('endStudyArticle', reportData)
|
||||
@@ -330,7 +290,8 @@ async function complete() {
|
||||
}
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await addStat({
|
||||
...data, type: 'article',
|
||||
...data,
|
||||
type: 'article',
|
||||
complete: store.sdict.complete,
|
||||
})
|
||||
if (!res.success) {
|
||||
@@ -368,7 +329,7 @@ function saveArticle(val: Article) {
|
||||
setArticle(val)
|
||||
store.sbook.custom = true
|
||||
if (!store.sbook.id.includes('_custom')) {
|
||||
store.sbook.id += '_custom'
|
||||
store.sbook.id += '_custom_' + nanoid(6)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +339,7 @@ function edit(val: Article = articleData.article) {
|
||||
}
|
||||
|
||||
function wrong(word: Word) {
|
||||
let temp = word.word.toLowerCase();
|
||||
let temp = word.word.toLowerCase()
|
||||
//过滤简单词
|
||||
if (settingStore.ignoreSimpleWord) {
|
||||
if (store.simpleWords.includes(temp)) return
|
||||
@@ -401,6 +362,7 @@ function nextWord(word: ArticleWord) {
|
||||
}
|
||||
|
||||
async function changeArticle(val: ArticleItem) {
|
||||
setPracticeArticleCache(null)
|
||||
let rIndex = articleData.list.findIndex(v => v.id === val.item.id)
|
||||
if (rIndex > -1) {
|
||||
store.sbook.lastLearnIndex = rIndex
|
||||
@@ -424,10 +386,7 @@ const handlePlayNext = (nextArticle: Article) => {
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
isArticleCollect,
|
||||
toggleArticleCollect
|
||||
} = useArticleOptions()
|
||||
const { isArticleCollect, toggleArticleCollect } = useArticleOptions()
|
||||
|
||||
function play() {
|
||||
typingArticleRef?.play()
|
||||
@@ -472,7 +431,6 @@ useEvents([
|
||||
[ShortcutKey.EditArticle, shortcutKeyEdit],
|
||||
])
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
isFocus = !document.hidden
|
||||
@@ -483,7 +441,7 @@ onUnmounted(() => {
|
||||
timer && clearInterval(timer)
|
||||
})
|
||||
|
||||
const {playSentenceAudio} = usePlaySentenceAudio()
|
||||
const { playSentenceAudio } = usePlaySentenceAudio()
|
||||
|
||||
function play2(e) {
|
||||
_nextTick(() => {
|
||||
@@ -503,9 +461,7 @@ 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"
|
||||
@@ -519,11 +475,9 @@ provide('currentPractice', currentPractice)
|
||||
/>
|
||||
</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
|
||||
@@ -531,21 +485,25 @@ provide('currentPractice', currentPractice)
|
||||
:static="false"
|
||||
:show-translate="settingStore.translate"
|
||||
@click="changeArticle"
|
||||
:active-id="articleData.article.id??''"
|
||||
:list="articleData.list ">
|
||||
:active-id="articleData.article.id ?? ''"
|
||||
:list="articleData.list"
|
||||
>
|
||||
</ArticleList>
|
||||
</div>
|
||||
</Panel>
|
||||
</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>
|
||||
<div class="footer pb-3">
|
||||
<div class="center h-10">
|
||||
<Tooltip :title="settingStore.showToolbar ? '收起' : '展开'">
|
||||
<IconFluentChevronLeft20Filled
|
||||
@click="settingStore.showToolbar = !settingStore.showToolbar"
|
||||
:class="!settingStore.showToolbar && 'down'"
|
||||
color="#999"
|
||||
class="arrow"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="bottom">
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<div class="stat">
|
||||
@@ -555,6 +513,7 @@ provide('currentPractice', currentPractice)
|
||||
<div class="name">记录</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<!-- <div class="num">{{statStore.spend }}分钟</div>-->
|
||||
<div class="num">{{ Math.floor(statStore.spend / 1000 / 60) }}分钟</div>
|
||||
<div class="line"></div>
|
||||
<div class="name">时间</div>
|
||||
@@ -563,7 +522,7 @@ provide('currentPractice', currentPractice)
|
||||
<div class="num center gap-1">
|
||||
{{ statStore.total }}
|
||||
<Tooltip>
|
||||
<IconFluentQuestionCircle20Regular width="18"/>
|
||||
<IconFluentQuestionCircle20Regular width="18" />
|
||||
<template #reference>
|
||||
<div>
|
||||
统计词数{{ settingStore.ignoreSimpleWord ? '不包含' : '包含' }}简单词,不包含已掌握
|
||||
@@ -579,38 +538,36 @@ provide('currentPractice', currentPractice)
|
||||
<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">
|
||||
<SettingDialog type="article"/>
|
||||
<SettingDialog type="article" />
|
||||
|
||||
<BaseIcon
|
||||
:title="`下一句(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
|
||||
@click="skip">
|
||||
<IconFluentArrowBounce20Regular class="transform-rotate-180"/>
|
||||
<BaseIcon :title="`下一句(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`" @click="skip">
|
||||
<IconFluentArrowBounce20Regular class="transform-rotate-180" />
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
:title="`播放当前句子(${settingStore.shortcutKeyMap[ShortcutKey.PlayWordPronunciation]})`"
|
||||
@click="play">
|
||||
<IconFluentReplay20Regular/>
|
||||
@click="play"
|
||||
>
|
||||
<IconFluentReplay20Regular />
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
@click="settingStore.dictation = !settingStore.dictation"
|
||||
:title="`开关默写模式(${settingStore.shortcutKeyMap[ShortcutKey.ToggleDictation]})`"
|
||||
>
|
||||
<IconFluentEyeOff16Regular v-if="settingStore.dictation"/>
|
||||
<IconFluentEye16Regular v-else/>
|
||||
<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/>
|
||||
@click="settingStore.translate = !settingStore.translate"
|
||||
>
|
||||
<IconFluentTranslate16Regular v-if="settingStore.translate" />
|
||||
<IconFluentTranslateOff16Regular v-else />
|
||||
</BaseIcon>
|
||||
|
||||
<!-- <BaseIcon-->
|
||||
@@ -620,8 +577,9 @@ provide('currentPractice', currentPractice)
|
||||
<!-- />-->
|
||||
<BaseIcon
|
||||
@click="settingStore.showPanel = !settingStore.showPanel"
|
||||
:title="`面板(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`">
|
||||
<IconFluentTextListAbcUppercaseLtr20Regular/>
|
||||
:title="`面板(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`"
|
||||
>
|
||||
<IconFluentTextListAbcUppercaseLtr20Regular />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
@@ -631,47 +589,34 @@ provide('currentPractice', currentPractice)
|
||||
</template>
|
||||
</PracticeLayout>
|
||||
|
||||
<EditSingleArticleModal
|
||||
v-model="showEditArticle"
|
||||
:article="editArticle"
|
||||
@save="saveArticle"
|
||||
/>
|
||||
<EditSingleArticleModal v-model="showEditArticle" :article="editArticle" @save="saveArticle" />
|
||||
|
||||
<ConflictNotice v-if="showConflictNotice"/>
|
||||
<ConflictNotice v-if="showConflictNotice" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.footer {
|
||||
width: var(--article-toolbar-width);
|
||||
@apply bg-primary;
|
||||
|
||||
.bottom {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: .6rem;
|
||||
background: var(--color-second);
|
||||
padding: .5rem var(--space);
|
||||
z-index: 2;
|
||||
@apply relative w-full box-border rounded-lg bg-second shadow-lg z-2;
|
||||
padding: 0.5rem var(--space);
|
||||
border: 1px solid var(--color-item-border);
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
.stat {
|
||||
margin-top: .5rem;
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: var(--stat-gap);
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .3rem;
|
||||
color: gray;
|
||||
@apply flex flex-col items-center gap-1 text-gray-500;
|
||||
|
||||
.num, .name {
|
||||
.num,
|
||||
.name {
|
||||
word-break: keep-all;
|
||||
padding: 0 .4rem;
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
|
||||
.line {
|
||||
@@ -684,17 +629,13 @@ provide('currentPractice', currentPractice)
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: -40%;
|
||||
left: 50%;
|
||||
cursor: pointer;
|
||||
transition: all .5s;
|
||||
transition: all 0.5s;
|
||||
transform: rotate(-90deg);
|
||||
padding: .5rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
|
||||
&.down {
|
||||
top: -70%;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 type { 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>
|
||||
|
||||
@@ -1,69 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {Article, Sentence, TranslateEngine} from "@/types/types.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import EditAbleText from "@/components/EditAbleText.vue";
|
||||
import {getNetworkTranslate, getSentenceAllText, getSentenceAllTranslateText} from "@/hooks/translate.ts";
|
||||
import {genArticleSectionData, splitCNArticle2, splitEnArticle2, usePlaySentenceAudio} from "@/hooks/article.ts";
|
||||
import {_nextTick, _parseLRC, cloneDeep, last} from "@/utils";
|
||||
import {defineAsyncComponent, watch} from "vue";
|
||||
import Empty from "@/components/Empty.vue";
|
||||
import type { Article, Sentence } from '@/types/types.ts'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import EditAbleText from '@/components/EditAbleText.vue'
|
||||
import { getNetworkTranslate, getSentenceAllText, getSentenceAllTranslateText } from '@/hooks/translate.ts'
|
||||
import { genArticleSectionData, splitCNArticle2, splitEnArticle2, usePlaySentenceAudio } from '@/hooks/article.ts'
|
||||
import { _nextTick, _parseLRC, cloneDeep, last } from '@/utils'
|
||||
import { defineAsyncComponent, watch } from 'vue'
|
||||
import Empty from '@/components/Empty.vue'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import * as Comparison from "string-comparison"
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import {getDefaultArticle} from "@/types/func.ts";
|
||||
import copy from "copy-to-clipboard";
|
||||
import {Option, Select} from "@/components/base/select";
|
||||
import Tooltip from "@/components/base/Tooltip.vue";
|
||||
import InputNumber from "@/components/base/InputNumber.vue";
|
||||
import {nanoid} from "nanoid";
|
||||
import {update} from "idb-keyval";
|
||||
import ArticleAudio from "@/pages/article/components/ArticleAudio.vue";
|
||||
import BaseInput from "@/components/base/BaseInput.vue";
|
||||
import Textarea from "@/components/base/Textarea.vue";
|
||||
import {LOCAL_FILE_KEY} from "@/config/env.ts";
|
||||
import PopConfirm from "@/components/PopConfirm.vue";
|
||||
import * as Comparison from 'string-comparison'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import { getDefaultArticle } from '@/types/func.ts'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Option, Select } from '@/components/base/select'
|
||||
import Tooltip from '@/components/base/Tooltip.vue'
|
||||
import InputNumber from '@/components/base/InputNumber.vue'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { update } from 'idb-keyval'
|
||||
import ArticleAudio from '@/pages/article/components/ArticleAudio.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import Textarea from '@/components/base/Textarea.vue'
|
||||
import { LOCAL_FILE_KEY } from '@/config/env.ts'
|
||||
import PopConfirm from '@/components/PopConfirm.vue'
|
||||
import {TranslateEngine} from "@/types/enum.ts";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
interface IProps {
|
||||
article?: Article,
|
||||
article?: Article
|
||||
type?: 'single' | 'batch'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
article: () => getDefaultArticle(),
|
||||
type: 'single'
|
||||
type: 'single',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [val: Article],
|
||||
save: [val: Article]
|
||||
saveAndNext: [val: Article]
|
||||
}>()
|
||||
|
||||
let networkTranslateEngine = $ref('baidu')
|
||||
let progress = $ref(0)
|
||||
let failCount = $ref(0)
|
||||
let textareaRef = $ref<HTMLTextAreaElement>()
|
||||
let resultRef = $ref<HTMLDivElement>()
|
||||
const TranslateEngineOptions = [
|
||||
// {value: 'youdao', label: '有道'},
|
||||
{value: 'baidu', label: '百度'},
|
||||
{ value: 'baidu', label: '百度' },
|
||||
]
|
||||
|
||||
let editArticle = $ref<Article>(getDefaultArticle())
|
||||
|
||||
watch(() => props.article, val => {
|
||||
editArticle = getDefaultArticle(val)
|
||||
progress = 0
|
||||
failCount = 0
|
||||
apply(false)
|
||||
}, {immediate: true})
|
||||
watch(
|
||||
() => props.article,
|
||||
val => {
|
||||
editArticle = getDefaultArticle(val)
|
||||
progress = 0
|
||||
failCount = 0
|
||||
apply(false)
|
||||
_nextTick(() => {
|
||||
resultRef?.scrollTo(0,0)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(() => editArticle.text, (s) => {
|
||||
if (!s.trim()) {
|
||||
editArticle.sections = []
|
||||
watch(
|
||||
() => editArticle.text,
|
||||
s => {
|
||||
if (!s.trim()) {
|
||||
editArticle.sections = []
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
function apply(isHandle: boolean = true) {
|
||||
let text = editArticle.text.trim()
|
||||
@@ -141,11 +151,13 @@ function save(option: 'save' | 'saveAndNext') {
|
||||
return resolve(false)
|
||||
}
|
||||
|
||||
editArticle.lrcPosition = editArticle.sections.map(v => {
|
||||
return v.map((w, j) => {
|
||||
return w.audioPosition ?? []
|
||||
editArticle.lrcPosition = editArticle.sections
|
||||
.map(v => {
|
||||
return v.map((w, j) => {
|
||||
return w.audioPosition ?? []
|
||||
})
|
||||
})
|
||||
}).flat()
|
||||
.flat()
|
||||
|
||||
console.log(editArticle)
|
||||
|
||||
@@ -161,7 +173,7 @@ function save(option: 'save' | 'saveAndNext') {
|
||||
}
|
||||
|
||||
//不知道为什么直接用editArticle,取到是空的默认值
|
||||
defineExpose({save, getEditArticle: () => cloneDeep(editArticle)})
|
||||
defineExpose({ save, getEditArticle: () => cloneDeep(editArticle) })
|
||||
|
||||
// 处理音频文件上传
|
||||
async function handleAudioChange(e: any) {
|
||||
@@ -172,7 +184,7 @@ async function handleAudioChange(e: any) {
|
||||
file: uploadFile,
|
||||
}
|
||||
//把文件存到indexDB
|
||||
await update(LOCAL_FILE_KEY, (val) => {
|
||||
await update(LOCAL_FILE_KEY, val => {
|
||||
if (val) val.push(data)
|
||||
else val = [data]
|
||||
return val
|
||||
@@ -192,32 +204,34 @@ function handleChange(e: any) {
|
||||
if (!uploadFile) return
|
||||
|
||||
// 读取文件内容
|
||||
let reader = new FileReader();
|
||||
reader.readAsText(uploadFile, 'UTF-8');
|
||||
let reader = new FileReader()
|
||||
reader.readAsText(uploadFile, 'UTF-8')
|
||||
reader.onload = function (e) {
|
||||
let lrc: string = e.target.result as string;
|
||||
let lrc: string = e.target.result as string
|
||||
console.log(lrc)
|
||||
if (lrc.trim()) {
|
||||
let lrcList = _parseLRC(lrc)
|
||||
console.log('lrcList', lrcList)
|
||||
if (lrcList.length) {
|
||||
editArticle.lrcPosition = editArticle.sections.map((v, i) => {
|
||||
return v.map((w, j) => {
|
||||
for (let k = 0; k < lrcList.length; k++) {
|
||||
let s = lrcList[k]
|
||||
let d = Comparison.default.cosine.similarity(w.text, s.text)
|
||||
d = Comparison.default.levenshtein.similarity(w.text, s.text)
|
||||
d = Comparison.default.longestCommonSubsequence.similarity(w.text, s.text)
|
||||
// d = Comparison.default.metricLcs.similarity(w.text, s.text)
|
||||
// console.log(w.text, s.text, d)
|
||||
if (d >= 0.8) {
|
||||
w.audioPosition = [s.start, s.end ?? -1]
|
||||
break
|
||||
editArticle.lrcPosition = editArticle.sections
|
||||
.map((v, i) => {
|
||||
return v.map((w, j) => {
|
||||
for (let k = 0; k < lrcList.length; k++) {
|
||||
let s = lrcList[k]
|
||||
// let d = Comparison.default.cosine.similarity(w.text, s.text)
|
||||
// d = Comparison.default.levenshtein.similarity(w.text, s.text)
|
||||
let d = Comparison.default.longestCommonSubsequence.similarity(w.text, s.text)
|
||||
// d = Comparison.default.metricLcs.similarity(w.text, s.text)
|
||||
// console.log(w.text, s.text, d)
|
||||
if (d >= 0.8) {
|
||||
w.audioPosition = [s.start, s.end ?? -1]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return w.audioPosition ?? []
|
||||
return w.audioPosition ?? []
|
||||
})
|
||||
})
|
||||
}).flat()
|
||||
.flat()
|
||||
|
||||
Toast.success('LRC文件解析成功')
|
||||
}
|
||||
@@ -238,12 +252,15 @@ let sentenceAudioRef = $ref<HTMLAudioElement>()
|
||||
let audioRef = $ref<HTMLAudioElement>()
|
||||
|
||||
let nameListRef = $ref<string[]>([])
|
||||
watch(() => showNameDialog, (v) => {
|
||||
if (v) {
|
||||
nameListRef = cloneDeep(Array.isArray(editArticle.nameList) ? editArticle.nameList : [])
|
||||
nameListRef.push('')
|
||||
watch(
|
||||
() => showNameDialog,
|
||||
v => {
|
||||
if (v) {
|
||||
nameListRef = cloneDeep(Array.isArray(editArticle.nameList) ? editArticle.nameList : [])
|
||||
nameListRef.push('')
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
function addName() {
|
||||
nameListRef.push('')
|
||||
@@ -299,12 +316,12 @@ function recordEnd() {
|
||||
editSentence.audioPosition[1] = Number(sentenceAudioRef.currentTime.toFixed(2))
|
||||
}
|
||||
|
||||
const {playSentenceAudio} = usePlaySentenceAudio()
|
||||
const { playSentenceAudio } = usePlaySentenceAudio()
|
||||
|
||||
function saveLrcPosition() {
|
||||
// showEditAudioDialog = false
|
||||
currentSentence.audioPosition = cloneDeep(editSentence.audioPosition)
|
||||
editArticle.lrcPosition = editArticle.sections.map((v, i) => v.map((w, j) => (w.audioPosition ?? []))).flat()
|
||||
editArticle.lrcPosition = editArticle.sections.map((v, i) => v.map((w, j) => w.audioPosition ?? [])).flat()
|
||||
}
|
||||
|
||||
function jumpAudio(time: number) {
|
||||
@@ -347,7 +364,6 @@ function minusStartTime(val: Sentence) {
|
||||
if (val.audioPosition[0] <= 0) return
|
||||
val.audioPosition[0] = Number((val.audioPosition[0] - 0.3).toFixed(2))
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -358,7 +374,7 @@ function minusStartTime(val: Sentence) {
|
||||
<div class="shrink-0">标题:</div>
|
||||
<BaseInput
|
||||
v-model="editArticle.title"
|
||||
:disabled="![100,0].includes(progress)"
|
||||
:disabled="![100, 0].includes(progress)"
|
||||
type="text"
|
||||
placeholder="请填写原文标题"
|
||||
/>
|
||||
@@ -368,29 +384,33 @@ function minusStartTime(val: Sentence) {
|
||||
<Tooltip title="配置人名之后,在练习时自动忽略(可选,默认开启)">
|
||||
<div @click="showNameDialog = true" class="center gap-1 cp">
|
||||
<span>人名配置</span>
|
||||
<IconFluentSettings20Regular/>
|
||||
<IconFluentSettings20Regular />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Textarea v-model="editArticle.text"
|
||||
class="h-full"
|
||||
:disabled="![100,0].includes(progress)"
|
||||
placeholder="请复制原文"
|
||||
:autosize="false"/>
|
||||
<Textarea
|
||||
v-model="editArticle.text"
|
||||
class="h-full"
|
||||
:disabled="![100, 0].includes(progress)"
|
||||
placeholder="请复制原文"
|
||||
:autosize="false"
|
||||
/>
|
||||
<div class="justify-end items-center flex">
|
||||
<Tooltip>
|
||||
<IconFluentQuestionCircle20Regular class="mr-3" width="20"/>
|
||||
<IconFluentQuestionCircle20Regular class="mr-3" width="20" />
|
||||
<template #reference>
|
||||
<div>
|
||||
<div class="mb-2">使用方法</div>
|
||||
<ol class="py-0 pl-5 my-0 text-base color-main">
|
||||
<li>复制原文,然后分句</li>
|
||||
<li>点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span
|
||||
class="color-red font-bold"> 或</span> 手动编辑分句
|
||||
<li>
|
||||
点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span class="color-red font-bold">
|
||||
或</span
|
||||
>
|
||||
手动编辑分句
|
||||
</li>
|
||||
<li>分句规则:一行一句,段落间空一行</li>
|
||||
<li>修改完成后点击 <span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏
|
||||
</li>
|
||||
<li>修改完成后点击 <span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
@@ -405,47 +425,47 @@ function minusStartTime(val: Sentence) {
|
||||
<div class="shrink-0">标题:</div>
|
||||
<BaseInput
|
||||
v-model="editArticle.titleTranslate"
|
||||
:disabled="![100,0].includes(progress)"
|
||||
:disabled="![100, 0].includes(progress)"
|
||||
type="text"
|
||||
placeholder="请填写翻译标题"
|
||||
/>
|
||||
</div>
|
||||
<div class="">正文:<span class="text-sm color-gray">一行一句,段落间空一行</span></div>
|
||||
<Textarea v-model="editArticle.textTranslate"
|
||||
class="h-full"
|
||||
:disabled="![100,0].includes(progress)"
|
||||
placeholder="请填写翻译"
|
||||
:autosize="false"/>
|
||||
<Textarea
|
||||
v-model="editArticle.textTranslate"
|
||||
class="h-full"
|
||||
:disabled="![100, 0].includes(progress)"
|
||||
placeholder="请填写翻译"
|
||||
:autosize="false"
|
||||
/>
|
||||
<div class="justify-between items-center flex">
|
||||
<div class="flex gap-space items-center w-50">
|
||||
<BaseButton @click="startNetworkTranslate"
|
||||
:loading="progress!==0 && progress !== 100">翻译
|
||||
</BaseButton>
|
||||
<Select v-model="networkTranslateEngine"
|
||||
>
|
||||
<Option
|
||||
v-for="item in TranslateEngineOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
<BaseButton @click="startNetworkTranslate" :loading="progress !== 0 && progress !== 100">翻译 </BaseButton>
|
||||
<Select v-model="networkTranslateEngine">
|
||||
<Option v-for="item in TranslateEngineOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</Select>
|
||||
{{ progress }}%
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Tooltip>
|
||||
<IconFluentQuestionCircle20Regular class="mr-3" width="20"/>
|
||||
<IconFluentQuestionCircle20Regular class="mr-3" width="20" />
|
||||
<template #reference>
|
||||
<div>
|
||||
<div class="mb-2">使用方法</div>
|
||||
<ol class="py-0 pl-5 my-0 text-base color-black/60">
|
||||
<li>复制译文,如果没有请点击 <span class="color-red font-bold">翻译</span> 按钮</li>
|
||||
<li>点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span
|
||||
class="color-red font-bold"> 或</span>
|
||||
<li>
|
||||
点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span
|
||||
class="color-red font-bold"
|
||||
>
|
||||
或</span
|
||||
>
|
||||
手动编辑分句
|
||||
</li>
|
||||
<li>分句规则:一行一句,段落间空一行</li>
|
||||
<li>修改完成后点击 <span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏
|
||||
<li>
|
||||
修改完成后点击
|
||||
<span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
@@ -460,38 +480,40 @@ function minusStartTime(val: Sentence) {
|
||||
<div class="flex gap-2">
|
||||
<div class="title">结果</div>
|
||||
<div class="flex gap-2 flex-1 justify-end">
|
||||
<ArticleAudio ref="audioRef" :article="editArticle" :autoplay="false"/>
|
||||
<ArticleAudio ref="audioRef" :article="editArticle" :autoplay="false" />
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="editArticle?.sections?.length">
|
||||
<div class="flex-1 overflow-auto flex flex-col">
|
||||
<div class="flex justify-between bg-black/10 py-2 rounded-lt-md rounded-rt-md">
|
||||
<div class="center flex-[7]">内容:
|
||||
<span class="text-sm color-black/70">均可编辑,编辑后点击应用按钮会自动同步</span></div>
|
||||
<div class="center flex-[7]">
|
||||
内容(
|
||||
<span class="text-sm color-gray-500">均可编辑,编辑后点击应用按钮会自动同步</span>)
|
||||
</div>
|
||||
<div>|</div>
|
||||
<div class="center flex-[3] gap-2">
|
||||
<span>音频</span>
|
||||
<BaseIcon title="音频管理" @click="showAudioDialog = true">
|
||||
<IconIconParkOutlineAddMusic/>
|
||||
<IconIconParkOutlineAddMusic />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-translate">
|
||||
<div class="section rounded-md " v-for="(item,indexI) in editArticle.sections">
|
||||
<div class="article-translate" ref="resultRef">
|
||||
<div class="section rounded-md" v-for="(item, indexI) in editArticle.sections">
|
||||
<div class="section-title text-lg font-bold">第{{ indexI + 1 }}段</div>
|
||||
<div class="sentence" v-for="(sentence,indexJ) in item">
|
||||
<div class="sentence" v-for="(sentence, indexJ) in item">
|
||||
<div class="flex-[7]">
|
||||
<EditAbleText
|
||||
:disabled="![100,0].includes(progress)"
|
||||
:disabled="![100, 0].includes(progress)"
|
||||
:value="sentence.text"
|
||||
@save="(e:string) => saveSentenceText(sentence,e)"
|
||||
@save="(e: string) => saveSentenceText(sentence, e)"
|
||||
/>
|
||||
<EditAbleText
|
||||
class="text-lg!"
|
||||
v-if="sentence.translate"
|
||||
:disabled="![100,0].includes(progress)"
|
||||
:disabled="![100, 0].includes(progress)"
|
||||
:value="sentence.translate"
|
||||
@save="(e:string) => saveSentenceTranslate(sentence,e)"
|
||||
@save="(e: string) => saveSentenceTranslate(sentence, e)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-[2] flex justify-end gap-1 items-center">
|
||||
@@ -500,45 +522,41 @@ function minusStartTime(val: Sentence) {
|
||||
<div>{{ sentence.audioPosition?.[0] ?? 0 }}s</div>
|
||||
<div class="flex gap-1">
|
||||
<BaseIcon
|
||||
@click="setStartTime(sentence,indexI,indexJ)"
|
||||
:title="indexI === 0 && indexJ === 0 ?'设置开始时间':'使用前一句的结束时间'"
|
||||
@click="setStartTime(sentence, indexI, indexJ)"
|
||||
:title="indexI === 0 && indexJ === 0 ? '设置开始时间' : '使用前一句的结束时间'"
|
||||
>
|
||||
<IconFluentMyLocation20Regular v-if="indexI === 0 && indexJ === 0"/>
|
||||
<IconFluentPaddingLeft20Regular v-else/>
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
@click="minusStartTime(sentence)"
|
||||
title="减 0.3 秒"
|
||||
>
|
||||
-.3s
|
||||
<IconFluentMyLocation20Regular v-if="indexI === 0 && indexJ === 0" />
|
||||
<IconFluentPaddingLeft20Regular v-else />
|
||||
</BaseIcon>
|
||||
<BaseIcon @click="minusStartTime(sentence)" title="减 0.3 秒"> -.3s </BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div>-</div>
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div v-if="sentence.audioPosition?.[1] !== -1">{{ sentence.audioPosition?.[1] ?? 0 }}s</div>
|
||||
<div v-else> 结束</div>
|
||||
<BaseIcon
|
||||
@click="setEndTime(sentence,indexI,indexJ)"
|
||||
title="设置结束时间"
|
||||
>
|
||||
<IconFluentMyLocation20Regular/>
|
||||
<div v-else>结束</div>
|
||||
<BaseIcon @click="setEndTime(sentence, indexI, indexJ)" title="设置结束时间">
|
||||
<IconFluentMyLocation20Regular />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<BaseIcon :icon="sentence.audioPosition?.length ? 'basil:edit-outline' : 'basil:add-outline'"
|
||||
title="编辑音频对齐"
|
||||
@click="handleShowEditAudioDialog(sentence,indexI,indexJ)">
|
||||
<BaseIcon
|
||||
:icon="sentence.audioPosition?.length ? 'basil:edit-outline' : 'basil:add-outline'"
|
||||
title="编辑音频对齐"
|
||||
@click="handleShowEditAudioDialog(sentence, indexI, indexJ)"
|
||||
>
|
||||
<IconFluentSpeakerEdit20Regular
|
||||
v-if="sentence.audioPosition?.length && sentence.audioPosition[1]"/>
|
||||
<IconFluentAddSquare20Regular v-else/>
|
||||
v-if="sentence.audioPosition?.length && sentence.audioPosition[1]"
|
||||
/>
|
||||
<IconFluentAddSquare20Regular v-else />
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
title="播放"
|
||||
v-if="sentence.audioPosition?.length"
|
||||
@click="playSentenceAudio(sentence,audioRef)">
|
||||
<IconFluentPlay20Regular/>
|
||||
@click="playSentenceAudio(sentence, audioRef)"
|
||||
>
|
||||
<IconFluentPlay20Regular />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
@@ -550,11 +568,11 @@ function minusStartTime(val: Sentence) {
|
||||
<div class="status">
|
||||
<span>状态:</span>
|
||||
<div class="warning" v-if="failCount">
|
||||
<IconFluentShieldQuestion20Regular/>
|
||||
<IconFluentShieldQuestion20Regular />
|
||||
共有{{ failCount }}句没有翻译!
|
||||
</div>
|
||||
<div class="success" v-else>
|
||||
<IconFluentCheckmarkCircle16Regular/>
|
||||
<IconFluentCheckmarkCircle16Regular />
|
||||
翻译完成!
|
||||
</div>
|
||||
</div>
|
||||
@@ -564,23 +582,22 @@ function minusStartTime(val: Sentence) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<Empty v-else text="没有译文对照~"/>
|
||||
<Empty v-else text="没有译文对照~" />
|
||||
</div>
|
||||
<Dialog title="调整音频时间轴"
|
||||
v-model="showEditAudioDialog"
|
||||
:footer="true"
|
||||
@close="showEditAudioDialog = false"
|
||||
@ok="saveLrcPosition"
|
||||
<Dialog
|
||||
title="调整音频时间轴"
|
||||
v-model="showEditAudioDialog"
|
||||
:footer="true"
|
||||
@close="showEditAudioDialog = false"
|
||||
@ok="saveLrcPosition"
|
||||
>
|
||||
<div class="p-4 pt-0 color-main w-150 flex flex-col gap-2">
|
||||
<div class="">
|
||||
教程:点击音频播放按钮,当播放到句子开始时,点击开始时间的 <span class="color-red">记录</span>
|
||||
按钮;当播放到句子结束时,点击结束时间的 <span class="color-red">记录</span> 按钮,最后再试听是否正确
|
||||
教程:点击音频播放按钮,当播放到句子开始时,点击开始时间的
|
||||
<span class="color-red">记录</span> 按钮;当播放到句子结束时,点击结束时间的
|
||||
<span class="color-red">记录</span> 按钮,最后再试听是否正确
|
||||
</div>
|
||||
<ArticleAudio ref="sentenceAudioRef"
|
||||
:article="editArticle"
|
||||
:autoplay="false"
|
||||
class="w-full"/>
|
||||
<ArticleAudio ref="sentenceAudioRef" :article="editArticle" :autoplay="false" class="w-full" />
|
||||
<div class="flex items-center gap-2 justify-between mb-2" v-if="editSentence.audioPosition?.length">
|
||||
<div>{{ editSentence.text }}</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
@@ -589,10 +606,8 @@ function minusStartTime(val: Sentence) {
|
||||
<span v-if="editSentence.audioPosition?.[1] !== -1"> - {{ editSentence.audioPosition?.[1] }}s</span>
|
||||
<span v-else> - 结束</span>
|
||||
</div>
|
||||
<BaseIcon
|
||||
title="播放"
|
||||
@click="playSentenceAudio(editSentence,sentenceAudioRef)">
|
||||
<IconFluentPlay20Regular/>
|
||||
<BaseIcon title="播放" @click="playSentenceAudio(editSentence, sentenceAudioRef)">
|
||||
<IconFluentPlay20Regular />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
@@ -601,19 +616,19 @@ function minusStartTime(val: Sentence) {
|
||||
<div>开始时间:</div>
|
||||
<div class="flex justify-between flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<InputNumber v-model="editSentence.audioPosition[0]" :precision="2" :step="0.1"/>
|
||||
<InputNumber v-model="editSentence.audioPosition[0]" :precision="2" :step="0.1" />
|
||||
<BaseIcon
|
||||
@click="jumpAudio(editSentence.audioPosition[0])"
|
||||
:title='`跳转至${editSentence.audioPosition[0]}秒`'
|
||||
:title="`跳转至${editSentence.audioPosition[0]}秒`"
|
||||
>
|
||||
<IconFluentMyLocation20Regular/>
|
||||
<IconFluentMyLocation20Regular />
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
v-if="preSentence"
|
||||
@click="setPreEndTimeToCurrentStartTime"
|
||||
:title="`使用前一句的结束时间:${preSentence?.audioPosition?.[1]||0}秒`"
|
||||
:title="`使用前一句的结束时间:${preSentence?.audioPosition?.[1] || 0}秒`"
|
||||
>
|
||||
<IconFluentPaddingLeft20Regular/>
|
||||
<IconFluentPaddingLeft20Regular />
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
@click="editSentence.audioPosition[0] = Number((editSentence.audioPosition[0] - 0.3).toFixed(2))"
|
||||
@@ -635,7 +650,7 @@ function minusStartTime(val: Sentence) {
|
||||
<div>结束时间:</div>
|
||||
<div class="flex justify-between flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<InputNumber v-model="editSentence.audioPosition[1]" :precision="2" :step="0.1"/>
|
||||
<InputNumber v-model="editSentence.audioPosition[1]" :precision="2" :step="0.1" />
|
||||
<span>或</span>
|
||||
<BaseButton size="small" @click="editSentence.audioPosition[1] = -1">结束</BaseButton>
|
||||
</div>
|
||||
@@ -646,41 +661,36 @@ function minusStartTime(val: Sentence) {
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog title="音频管理"
|
||||
v-model="showAudioDialog"
|
||||
:footer="false"
|
||||
@close="showAudioDialog = false"
|
||||
>
|
||||
<Dialog title="音频管理" v-model="showAudioDialog" :footer="false" @close="showAudioDialog = false">
|
||||
<div class="p-4 pt-0 color-main w-150 flex flex-col gap-2">
|
||||
<div class="">
|
||||
1、上传的文件保存在本地电脑上,更换电脑数据将丢失,请及时备份数据
|
||||
<br>
|
||||
<br />
|
||||
2、LRC 文件用于解析句子对应音频的位置,不一定准确,后续可自行修改
|
||||
</div>
|
||||
<!-- <ArticleAudio ref="sentenceAudioRef" :article="editArticle" class="w-full"/>-->
|
||||
<div class="upload relative">
|
||||
<BaseButton>上传音频</BaseButton>
|
||||
<input type="file"
|
||||
accept="audio/*"
|
||||
@change="handleAudioChange"
|
||||
class="w-full h-full absolute left-0 top-0 opacity-0"/>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
@change="handleAudioChange"
|
||||
class="w-full h-full absolute left-0 top-0 opacity-0"
|
||||
/>
|
||||
</div>
|
||||
<div class="upload relative">
|
||||
<BaseButton>上传 LRC 文件</BaseButton>
|
||||
<input type="file"
|
||||
accept=".lrc"
|
||||
@change="handleChange"
|
||||
class="w-full h-full absolute left-0 top-0 opacity-0"/>
|
||||
<input
|
||||
type="file"
|
||||
accept=".lrc"
|
||||
@change="handleChange"
|
||||
class="w-full h-full absolute left-0 top-0 opacity-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog title="人名管理"
|
||||
v-model="showNameDialog"
|
||||
:footer="true"
|
||||
@close="showNameDialog = false"
|
||||
@ok="saveNameList"
|
||||
>
|
||||
<Dialog title="人名管理" v-model="showNameDialog" :footer="true" @close="showNameDialog = false" @ok="saveNameList">
|
||||
<div class="p-4 pt-0 color-main w-150 flex flex-col gap-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-base">配置需要忽略的人名,练习时自动忽略这些名称(可选,默认开启)</div>
|
||||
@@ -688,17 +698,18 @@ function minusStartTime(val: Sentence) {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2" v-for="(name,i) in nameListRef" :key="i">
|
||||
<BaseInput v-model="nameListRef[i]"
|
||||
placeholder="输入名称"
|
||||
size="large"
|
||||
:autofocus="i===nameListRef.length-1"/>
|
||||
<div class="flex items-center gap-2" v-for="(name, i) in nameListRef" :key="i">
|
||||
<BaseInput
|
||||
v-model="nameListRef[i]"
|
||||
placeholder="输入名称"
|
||||
size="large"
|
||||
:autofocus="i === nameListRef.length - 1"
|
||||
/>
|
||||
<BaseButton type="info" @click="removeName(i)">删除</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -782,7 +793,7 @@ function minusStartTime(val: Sentence) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1.2rem;
|
||||
color: #67C23A;
|
||||
color: #67c23a;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -807,7 +818,8 @@ function minusStartTime(val: Sentence) {
|
||||
}
|
||||
|
||||
// 表单元素优化
|
||||
.base-input, .base-textarea {
|
||||
.base-input,
|
||||
.base-textarea {
|
||||
width: 100%;
|
||||
font-size: 16px; // 防止iOS自动缩放
|
||||
}
|
||||
@@ -872,7 +884,8 @@ function minusStartTime(val: Sentence) {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.warning, .success {
|
||||
.warning,
|
||||
.success {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { Dict, DictId, DictType } from "@/types/types.ts";
|
||||
import { cloneDeep } from "@/utils";
|
||||
import type { Dict } from '@/types/types.ts'
|
||||
import { cloneDeep } from '@/utils'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import { onMounted, reactive } from "vue";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
import { useBaseStore } from "@/stores/base.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import { getDefaultDict } from "@/types/func.ts";
|
||||
import { Option, Select } from "@/components/base/select";
|
||||
import BaseInput from "@/components/base/BaseInput.vue";
|
||||
import Form from "@/components/base/form/Form.vue";
|
||||
import FormItem from "@/components/base/form/FormItem.vue";
|
||||
import { addDict } from "@/apis";
|
||||
import { AppEnv } from "@/config/env.ts";
|
||||
import { onMounted, reactive } from 'vue'
|
||||
import { useRuntimeStore } from '@/stores/runtime.ts'
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import { getDefaultDict } from '@/types/func.ts'
|
||||
import { Option, Select } from '@/components/base/select'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import Form from '@/components/base/form/Form.vue'
|
||||
import FormItem from '@/components/base/form/FormItem.vue'
|
||||
import { addDict } from '@/apis'
|
||||
import { AppEnv, DictId } from '@/config/env.ts'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { DictType } from '@/types/enum.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
isAdd: boolean,
|
||||
isAdd: boolean
|
||||
isBook: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
@@ -33,20 +34,20 @@ const DefaultDictForm = {
|
||||
tags: [],
|
||||
translateLanguage: 'zh-CN',
|
||||
language: 'en',
|
||||
type: DictType.article
|
||||
type: DictType.article,
|
||||
}
|
||||
let dictForm: any = $ref(cloneDeep(DefaultDictForm))
|
||||
const dictFormRef = $ref()
|
||||
let loading = $ref(false)
|
||||
const dictRules = reactive({
|
||||
name: [
|
||||
{required: true, message: '请输入名称', trigger: 'blur'},
|
||||
{max: 20, message: '名称不能超过20个字符', trigger: 'blur'},
|
||||
{ required: true, message: '请输入名称', trigger: 'blur' },
|
||||
{ max: 20, message: '名称不能超过20个字符', trigger: 'blur' },
|
||||
],
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
await dictFormRef.validate(async (valid) => {
|
||||
await dictFormRef.validate(async valid => {
|
||||
if (valid) {
|
||||
let data: Dict = getDefaultDict(dictForm)
|
||||
data.type = props.isBook ? DictType.article : DictType.word
|
||||
@@ -77,10 +78,15 @@ async function onSubmit() {
|
||||
} else {
|
||||
let rIndex = source.bookList.findIndex(v => v.id === data.id)
|
||||
//任意修改,都将其变为自定义词典
|
||||
if (!data.custom && ![DictId.wordKnown, DictId.wordWrong, DictId.wordCollect, DictId.articleCollect].includes(data.en_name || data.id)) {
|
||||
if (
|
||||
!data.custom &&
|
||||
![DictId.wordKnown, DictId.wordWrong, DictId.wordCollect, DictId.articleCollect].includes(
|
||||
data.en_name || data.id
|
||||
)
|
||||
) {
|
||||
data.custom = true
|
||||
if (!data.id.includes('_custom')) {
|
||||
data.id += '_custom'
|
||||
data.id += '_custom_' + nanoid(6)
|
||||
}
|
||||
}
|
||||
runtimeStore.editDict = data
|
||||
@@ -105,36 +111,31 @@ onMounted(() => {
|
||||
dictForm = cloneDeep(runtimeStore.editDict)
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-120 mt-4">
|
||||
<Form
|
||||
ref="dictFormRef"
|
||||
:rules="dictRules"
|
||||
:model="dictForm"
|
||||
label-width="8rem">
|
||||
<Form ref="dictFormRef" :rules="dictRules" :model="dictForm" label-width="8rem">
|
||||
<FormItem label="名称" prop="name">
|
||||
<BaseInput v-model="dictForm.name"/>
|
||||
<BaseInput v-model="dictForm.name" />
|
||||
</FormItem>
|
||||
<FormItem label="描述">
|
||||
<BaseInput v-model="dictForm.description" textarea/>
|
||||
<BaseInput v-model="dictForm.description" textarea />
|
||||
</FormItem>
|
||||
<FormItem label="原文语言" v-if="false">
|
||||
<Select v-model="dictForm.language" placeholder="请选择选项">
|
||||
<Option label="英语" value="en"/>
|
||||
<Option label="德语" value="de"/>
|
||||
<Option label="日语" value="ja"/>
|
||||
<Option label="代码" value="code"/>
|
||||
<Option label="英语" value="en" />
|
||||
<Option label="德语" value="de" />
|
||||
<Option label="日语" value="ja" />
|
||||
<Option label="代码" value="code" />
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="译文语言" v-if="false">
|
||||
<Select v-model="dictForm.translateLanguage" placeholder="请选择选项">
|
||||
<Option label="中文" value="zh-CN"/>
|
||||
<Option label="英语" value="en"/>
|
||||
<Option label="德语" value="de"/>
|
||||
<Option label="日语" value="ja"/>
|
||||
<Option label="中文" value="zh-CN" />
|
||||
<Option label="英语" value="en" />
|
||||
<Option label="德语" value="de" />
|
||||
<Option label="日语" value="ja" />
|
||||
</Select>
|
||||
</FormItem>
|
||||
<div class="center">
|
||||
@@ -145,7 +146,4 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
|
||||
</style>
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {Article} from "@/types/types.ts";
|
||||
import type {Article} from "@/types/types.ts";
|
||||
import {useDisableEventListener} from "@/hooks/event.ts";
|
||||
import EditArticle from "@/pages/article/components/EditArticle.vue";
|
||||
import {getDefaultArticle} from "@/types/func.ts";
|
||||
|
||||
@@ -32,7 +32,7 @@ const isMoveBottom = $computed(() => {
|
||||
.word-space {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 0.8rem;
|
||||
width: 0.6rem;
|
||||
height: 1.5rem;
|
||||
box-sizing: border-box;
|
||||
margin: 0 1px;
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import {inject, onMounted, onUnmounted, watch} from "vue"
|
||||
import {Article, ArticleWord, PracticeArticleWordType, Sentence, ShortcutKey, Word} from "@/types/types.ts";
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import {usePlayBeep, usePlayKeyboardAudio, usePlayWordAudio} from "@/hooks/sound.ts";
|
||||
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
|
||||
import {_dateFormat, _nextTick, isMobile, msToHourMinute, total} from "@/utils";
|
||||
import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import QuestionForm from "@/pages/article/components/QuestionForm.vue";
|
||||
import {getDefaultArticle, getDefaultWord} from "@/types/func.ts";
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import TypingWord from "@/pages/article/components/TypingWord.vue";
|
||||
import Space from "@/pages/article/components/Space.vue";
|
||||
import {useWordOptions} from "@/hooks/dict.ts";
|
||||
import nlp from "compromise/three";
|
||||
import {nanoid} from "nanoid";
|
||||
import {usePracticeStore} from "@/stores/practice.ts";
|
||||
import {PracticeSaveArticleKey} from "@/config/env.ts";
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import { useWordOptions } from '@/hooks/dict.ts'
|
||||
import { usePlayBeep, usePlayKeyboardAudio, usePlayWordAudio } from '@/hooks/sound.ts'
|
||||
import QuestionForm from '@/pages/article/components/QuestionForm.vue'
|
||||
import Space from '@/pages/article/components/Space.vue'
|
||||
import TypingWord from '@/pages/article/components/TypingWord.vue'
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
import { usePracticeStore } from '@/stores/practice.ts'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { getDefaultArticle, getDefaultWord } from '@/types/func.ts'
|
||||
import type { Article, ArticleWord, Sentence, Word } from '@/types/types.ts'
|
||||
import { _dateFormat, _nextTick, isMobile, msToHourMinute, total } from '@/utils'
|
||||
import { emitter, EventKey, useEvents } from '@/utils/eventBus.ts'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css'
|
||||
import nlp from 'compromise/three'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { inject, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { getPracticeArticleCache, setPracticeArticleCache } from '@/utils/cache.ts'
|
||||
import { PracticeArticleWordType, ShortcutKey } from '@/types/enum.ts'
|
||||
|
||||
interface IProps {
|
||||
article: Article,
|
||||
sectionIndex?: number,
|
||||
sentenceIndex?: number,
|
||||
wordIndex?: number,
|
||||
stringIndex?: number,
|
||||
article: Article
|
||||
sectionIndex?: number
|
||||
sentenceIndex?: number
|
||||
wordIndex?: number
|
||||
stringIndex?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
@@ -37,16 +39,18 @@ const props = withDefaults(defineProps<IProps>(), {
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
ignore: [],
|
||||
wrong: [val: Word],
|
||||
play: [val: {
|
||||
sentence: Sentence,
|
||||
handle: boolean
|
||||
}],
|
||||
nextWord: [val: ArticleWord],
|
||||
complete: [],
|
||||
next: [],
|
||||
replay: [],
|
||||
ignore: []
|
||||
wrong: [val: Word]
|
||||
play: [
|
||||
val: {
|
||||
sentence: Sentence
|
||||
handle: boolean
|
||||
},
|
||||
]
|
||||
nextWord: [val: ArticleWord]
|
||||
complete: []
|
||||
next: []
|
||||
replay: []
|
||||
}>()
|
||||
|
||||
let typeArticleRef = $ref<HTMLInputElement>()
|
||||
@@ -79,62 +83,60 @@ const playBeep = usePlayBeep()
|
||||
const playKeyboardAudio = usePlayKeyboardAudio()
|
||||
const playWordAudio = usePlayWordAudio()
|
||||
|
||||
const {
|
||||
toggleWordCollect,
|
||||
} = useWordOptions()
|
||||
const { toggleWordCollect } = useWordOptions()
|
||||
|
||||
const store = useBaseStore()
|
||||
const settingStore = useSettingStore()
|
||||
const statStore = usePracticeStore()
|
||||
const isMob = isMobile()
|
||||
|
||||
watch([() => sectionIndex, () => sentenceIndex, () => wordIndex, () => stringIndex], ([a, b, c,]) => {
|
||||
localStorage.setItem(PracticeSaveArticleKey.key, JSON.stringify({
|
||||
version: PracticeSaveArticleKey.version,
|
||||
val: {
|
||||
watch([() => sectionIndex, () => sentenceIndex, () => wordIndex, () => stringIndex], ([a, b, c]) => {
|
||||
if (a !== 0 || b !== 0 || c !== 0) {
|
||||
setPracticeArticleCache({
|
||||
practiceData: {
|
||||
sectionIndex,
|
||||
sentenceIndex,
|
||||
wordIndex,
|
||||
stringIndex,
|
||||
id: props.article.id
|
||||
},
|
||||
statStoreData: statStore.$state,
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
checkCursorPosition(a, b, c)
|
||||
})
|
||||
|
||||
// watch(() => props.article.id, init, {immediate: true})
|
||||
|
||||
watch(() => settingStore.translate, () => {
|
||||
checkTranslateLocation().then(() => checkCursorPosition())
|
||||
})
|
||||
|
||||
watch(() => isEnd, n => {
|
||||
if (n) {
|
||||
_nextTick(() => {
|
||||
typeArticleRef?.scrollTo({top: typeArticleRef.scrollHeight, behavior: "smooth"})
|
||||
})
|
||||
} else {
|
||||
typeArticleRef?.scrollTo({top: 0, behavior: "smooth"})
|
||||
watch(
|
||||
() => settingStore.translate,
|
||||
() => {
|
||||
checkTranslateLocation().then(() => checkCursorPosition())
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
watch(
|
||||
() => isEnd,
|
||||
n => {
|
||||
if (n) {
|
||||
_nextTick(() => {
|
||||
typeArticleRef?.scrollTo({ top: typeArticleRef.scrollHeight, behavior: 'smooth' })
|
||||
})
|
||||
} else {
|
||||
typeArticleRef?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function init() {
|
||||
if (!props.article.id) return
|
||||
isSpace = isEnd = false
|
||||
let d = localStorage.getItem(PracticeSaveArticleKey.key)
|
||||
|
||||
let d = getPracticeArticleCache()
|
||||
if (d) {
|
||||
try {
|
||||
let obj = JSON.parse(d)
|
||||
let data = obj.val
|
||||
statStore.$patch(data.statStoreData)
|
||||
jump(data.practiceData.sectionIndex, data.practiceData.sentenceIndex, data.practiceData.wordIndex)
|
||||
} catch (e) {
|
||||
localStorage.removeItem(PracticeSaveArticleKey.key)
|
||||
init()
|
||||
}
|
||||
sectionIndex = d.practiceData.sectionIndex
|
||||
sentenceIndex = d.practiceData.sentenceIndex
|
||||
wordIndex = d.practiceData.wordIndex
|
||||
jump(sectionIndex, sentenceIndex, wordIndex)
|
||||
statStore.$patch(d.statStoreData)
|
||||
} else {
|
||||
wrong = input = ''
|
||||
sectionIndex = 0
|
||||
@@ -142,17 +144,17 @@ function init() {
|
||||
wordIndex = 0
|
||||
stringIndex = 0
|
||||
//todo 这在直接修改不太合理
|
||||
props.article.sections.map((v) => {
|
||||
v.map((w) => {
|
||||
props.article.sections.map(v => {
|
||||
v.map(w => {
|
||||
w.words.map(s => {
|
||||
s.input = ''
|
||||
})
|
||||
})
|
||||
})
|
||||
typeArticleRef?.scrollTo({top: 0, behavior: "smooth"})
|
||||
typeArticleRef?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
_nextTick(() => {
|
||||
emit('play', {sentence: props.article.sections[sectionIndex][sentenceIndex], handle: false})
|
||||
emit('play', { sentence: props.article.sections[sectionIndex][sentenceIndex], handle: false })
|
||||
if (isNameWord()) next()
|
||||
})
|
||||
checkTranslateLocation().then(() => checkCursorPosition())
|
||||
@@ -163,26 +165,31 @@ function checkCursorPosition(a = sectionIndex, b = sentenceIndex, c = wordIndex)
|
||||
// console.log('checkCursorPosition')
|
||||
_nextTick(() => {
|
||||
// 选中目标元素
|
||||
const currentWord = document.querySelector(`.section:nth-of-type(${a + 1}) .sentence:nth-of-type(${b + 1}) .word:nth-of-type(${c + 1})`);
|
||||
const currentWord = document.querySelector(
|
||||
`.section:nth-of-type(${a + 1}) .sentence:nth-of-type(${b + 1}) .word:nth-of-type(${c + 1})`
|
||||
)
|
||||
if (currentWord) {
|
||||
// 在 currentWord 内找 .word-end
|
||||
const end = currentWord.querySelector('.word-end');
|
||||
const end = currentWord.querySelector('.word-end')
|
||||
if (end) {
|
||||
// 获取 articleWrapper 的位置
|
||||
const articleRect = articleWrapperRef.getBoundingClientRect();
|
||||
const endRect = end.getBoundingClientRect();
|
||||
const articleRect = articleWrapperRef.getBoundingClientRect()
|
||||
const endRect = end.getBoundingClientRect()
|
||||
//如果当前输入位置大于屏幕的0.7高度,就滚动屏幕的1/3
|
||||
if (endRect.y > window.innerHeight * 0.7) {
|
||||
typeArticleRef?.scrollTo({top: typeArticleRef.scrollTop + window.innerHeight * 0.3, behavior: "smooth"})
|
||||
typeArticleRef?.scrollTo({
|
||||
top: typeArticleRef.scrollTop + window.innerHeight * 0.3,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
// 计算相对位置
|
||||
cursor = {
|
||||
top: endRect.top - articleRect.top,
|
||||
left: endRect.left - articleRect.left,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
},)
|
||||
})
|
||||
}
|
||||
|
||||
function checkTranslateLocation() {
|
||||
@@ -227,10 +234,8 @@ function processMobileCharacter(char: string) {
|
||||
const fakeEvent = {
|
||||
key: char,
|
||||
code,
|
||||
preventDefault() {
|
||||
},
|
||||
stopPropagation() {
|
||||
},
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
} as unknown as KeyboardEvent
|
||||
onTyping(fakeEvent)
|
||||
}
|
||||
@@ -254,12 +259,18 @@ function handleMobileBeforeInput(event: InputEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const normalize = (s: string) => s.toLowerCase().trim()
|
||||
const namePatterns = $computed(() => {
|
||||
return Array.from(new Set((props.article?.nameList ?? []).map(normalize).filter(Boolean).map(s => s.split(/\s+/).filter(Boolean)).flat().concat([
|
||||
'Mr', 'Mrs', 'Ms', 'Dr', 'Miss',
|
||||
].map(normalize))))
|
||||
return Array.from(
|
||||
new Set(
|
||||
(props.article?.nameList ?? [])
|
||||
.map(normalize)
|
||||
.filter(Boolean)
|
||||
.map(s => s.split(/\s+/).filter(Boolean))
|
||||
.flat()
|
||||
.concat(['Mr', 'Mrs', 'Ms', 'Dr', 'Miss'].map(normalize))
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const isNameWord = () => {
|
||||
@@ -288,9 +299,9 @@ function nextSentence() {
|
||||
// if (!store.allIgnoreWords.includes(currentWord.word.toLowerCase()) && currentWord.type === PracticeArticleWordType.Word) {
|
||||
// statisticsStore.inputNumber++
|
||||
// }
|
||||
isSpace = false;
|
||||
isSpace = false
|
||||
input = wrong = ''
|
||||
stringIndex = 0;
|
||||
stringIndex = 0
|
||||
wordIndex = 0
|
||||
sentenceIndex++
|
||||
if (!currentSection[sentenceIndex]) {
|
||||
@@ -302,21 +313,20 @@ function nextSentence() {
|
||||
emit('complete')
|
||||
} else {
|
||||
if (isNameWord()) next()
|
||||
emit('play', {sentence: props.article.sections[sectionIndex][0], handle: false})
|
||||
emit('play', { sentence: props.article.sections[sectionIndex][0], handle: false })
|
||||
}
|
||||
} else {
|
||||
if (isNameWord()) next()
|
||||
emit('play', {sentence: currentSection[sentenceIndex], handle: false})
|
||||
|
||||
emit('play', { sentence: currentSection[sentenceIndex], handle: false })
|
||||
}
|
||||
lock = false
|
||||
focusMobileInput()
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
isSpace = false;
|
||||
isSpace = false
|
||||
input = wrong = ''
|
||||
stringIndex = 0;
|
||||
stringIndex = 0
|
||||
|
||||
let currentSection = props.article.sections[sectionIndex]
|
||||
let currentSentence = currentSection[sentenceIndex]
|
||||
@@ -324,18 +334,21 @@ const next = () => {
|
||||
|
||||
// 检查下一个单词是否存在
|
||||
if (wordIndex + 1 < currentSentence.words.length) {
|
||||
wordIndex++;
|
||||
wordIndex++
|
||||
currentWord = currentSentence.words[wordIndex]
|
||||
//这里把未输入的单词补全,因为删除时会用到input
|
||||
currentSentence.words.slice(0, wordIndex).forEach((word, i) => {
|
||||
word.input = word.input + word.word.slice(word.input?.length ?? 0)
|
||||
})
|
||||
if ([PracticeArticleWordType.Symbol, PracticeArticleWordType.Number].includes(currentWord.type) && settingStore.ignoreSymbol) {
|
||||
if (
|
||||
[PracticeArticleWordType.Symbol, PracticeArticleWordType.Number].includes(currentWord.type) &&
|
||||
settingStore.ignoreSymbol
|
||||
) {
|
||||
next()
|
||||
} else if (isNameWord()) {
|
||||
next()
|
||||
} else {
|
||||
emit('nextWord', currentWord);
|
||||
emit('nextWord', currentWord)
|
||||
}
|
||||
} else {
|
||||
nextSentence()
|
||||
@@ -345,8 +358,8 @@ const next = () => {
|
||||
function onTyping(e: KeyboardEvent) {
|
||||
debugger
|
||||
if (!props.article.sections.length) return
|
||||
if (isTyping || isEnd) return;
|
||||
isTyping = true;
|
||||
if (isTyping || isEnd) return
|
||||
isTyping = true
|
||||
// console.log('keyDown', e.key, e.code, e.keyCode)
|
||||
try {
|
||||
let currentSection = props.article.sections[sectionIndex]
|
||||
@@ -370,7 +383,6 @@ function onTyping(e: KeyboardEvent) {
|
||||
// }, 500)
|
||||
}
|
||||
} else {
|
||||
|
||||
// if (isNameWord(currentWord)) {
|
||||
// isSpace = false
|
||||
// next()
|
||||
@@ -411,7 +423,7 @@ function onTyping(e: KeyboardEvent) {
|
||||
e.preventDefault()
|
||||
} catch (e) {
|
||||
//todo 上报
|
||||
localStorage.removeItem(PracticeSaveArticleKey.key)
|
||||
setPracticeArticleCache(null)
|
||||
init()
|
||||
} finally {
|
||||
isTyping = false
|
||||
@@ -420,14 +432,14 @@ function onTyping(e: KeyboardEvent) {
|
||||
|
||||
function play() {
|
||||
let currentSection = props.article.sections[sectionIndex]
|
||||
emit('play', {sentence: currentSection[sentenceIndex], handle: true})
|
||||
emit('play', { sentence: currentSection[sentenceIndex], handle: true })
|
||||
}
|
||||
|
||||
function del() {
|
||||
if (wrong) {
|
||||
wrong = ''
|
||||
} else {
|
||||
if (isEnd) return;
|
||||
if (isEnd) return
|
||||
if (isSpace) {
|
||||
isSpace = false
|
||||
}
|
||||
@@ -473,11 +485,11 @@ function del() {
|
||||
}
|
||||
|
||||
function showSentence(i1: number = sectionIndex, i2: number = sentenceIndex, i3: number = wordIndex) {
|
||||
hoverIndex = {sectionIndex: i1, sentenceIndex: i2, wordIndex: i3}
|
||||
hoverIndex = { sectionIndex: i1, sentenceIndex: i2, wordIndex: i3 }
|
||||
}
|
||||
|
||||
function hideSentence() {
|
||||
hoverIndex = {sectionIndex: -1, sentenceIndex: -1, wordIndex: -1}
|
||||
hoverIndex = { sectionIndex: -1, sentenceIndex: -1, wordIndex: -1 }
|
||||
}
|
||||
|
||||
function jump(i, j, w, sentence?) {
|
||||
@@ -500,22 +512,22 @@ function jump(i, j, w, sentence?) {
|
||||
})
|
||||
})
|
||||
if (sentence) {
|
||||
emit('play', {sentence: sentence, handle: false})
|
||||
emit('play', { sentence: sentence, handle: false })
|
||||
}
|
||||
}
|
||||
|
||||
function onContextMenu(e: MouseEvent, sentence: Sentence, i, j, w) {
|
||||
const selectedText = window.getSelection().toString();
|
||||
console.log(selectedText);
|
||||
const selectedText = window.getSelection().toString()
|
||||
console.log(selectedText)
|
||||
//prevent the browser's default menu
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
//show your menu
|
||||
ContextMenu.showContextMenu({
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
items: [
|
||||
{
|
||||
label: "收藏单词",
|
||||
label: '收藏单词',
|
||||
onClick: () => {
|
||||
let word = props.article.sections[i][j].words[w]
|
||||
let text = word.word
|
||||
@@ -530,46 +542,46 @@ function onContextMenu(e: MouseEvent, sentence: Sentence, i, j, w) {
|
||||
}
|
||||
if (!text.length) text = word.word
|
||||
console.log('text', text)
|
||||
toggleWordCollect(getDefaultWord({word: text, id: nanoid()}))
|
||||
toggleWordCollect(getDefaultWord({ word: text, id: nanoid() }))
|
||||
Toast.success(text + ' 添加成功')
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "复制",
|
||||
label: '复制',
|
||||
children: [
|
||||
{
|
||||
label: "复制句子",
|
||||
label: '复制句子',
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(sentence.text).then(r => {
|
||||
Toast.success('已复制')
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "复制单词",
|
||||
label: '复制单词',
|
||||
onClick: () => {
|
||||
let word = props.article.sections[i][j].words[w]
|
||||
navigator.clipboard.writeText(word.word).then(r => {
|
||||
Toast.success('已复制')
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "从这开始",
|
||||
label: '从这开始',
|
||||
onClick: () => {
|
||||
jump(i, j, w + 1, sentence)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "播放句子",
|
||||
label: '播放句子',
|
||||
onClick: () => {
|
||||
emit('play', {sentence: sentence, handle: true})
|
||||
}
|
||||
emit('play', { sentence: sentence, handle: true })
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "语法分析",
|
||||
label: '语法分析',
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(sentence.text).then(r => {
|
||||
Toast.success('已复制!随后将打开语法分析网站!')
|
||||
@@ -577,28 +589,28 @@ function onContextMenu(e: MouseEvent, sentence: Sentence, i, j, w) {
|
||||
window.open('https://enpuz.com/')
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "有道词典翻译",
|
||||
label: '有道词典翻译',
|
||||
children: [
|
||||
{
|
||||
label: "翻译单词",
|
||||
label: '翻译单词',
|
||||
onClick: () => {
|
||||
let word = props.article.sections[i][j].words[w]
|
||||
window.open(`https://www.youdao.com/result?word=${word.word}&lang=en`, '_blank')
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "翻译句子",
|
||||
label: '翻译句子',
|
||||
onClick: () => {
|
||||
window.open(`https://www.youdao.com/result?word=${sentence.text}&lang=en`, '_blank')
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
],
|
||||
},
|
||||
]
|
||||
});
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -612,7 +624,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(EventKey.resetWord,)
|
||||
emitter.off(EventKey.resetWord)
|
||||
emitter.off(EventKey.onTyping, onTyping)
|
||||
})
|
||||
|
||||
@@ -621,7 +633,22 @@ useEvents([
|
||||
[ShortcutKey.UnknownWord, onTyping],
|
||||
])
|
||||
|
||||
defineExpose({showSentence, play, del, hideSentence, nextSentence, init})
|
||||
defineExpose({
|
||||
showSentence,
|
||||
play,
|
||||
del,
|
||||
hideSentence,
|
||||
nextSentence,
|
||||
init,
|
||||
getIndex: () => {
|
||||
return {
|
||||
sectionIndex,
|
||||
sentenceIndex,
|
||||
wordIndex,
|
||||
stringIndex,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function isCurrent(i: number, j: number, w: number) {
|
||||
return `${i}${j}${w}` === currentIndex
|
||||
@@ -630,7 +657,6 @@ function isCurrent(i: number, j: number, w: number) {
|
||||
let showQuestions = $ref(false)
|
||||
|
||||
const currentPractice = inject('currentPractice', [])
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -647,83 +673,91 @@ const currentPractice = inject('currentPractice', [])
|
||||
@beforeinput="handleMobileBeforeInput"
|
||||
@input="handleMobileInput"
|
||||
/>
|
||||
<header class="mb-4">
|
||||
<div class="title"><span class="font-family text-3xl">{{
|
||||
store.sbook.lastLearnIndex + 1
|
||||
}}. </span>{{ props.article?.title ?? '' }}
|
||||
<header class="pt-10 pb-6">
|
||||
<div class="text-center">
|
||||
<span class="text-3xl">{{ store.sbook.lastLearnIndex + 1 }}. </span>
|
||||
<span class="text-3xl">{{ props.article?.title ?? '' }}</span>
|
||||
<span class="ml-6 text-2xl" v-if="settingStore.translate">{{ props.article?.titleTranslate }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-2xl" v-if="props.article?.question?.text">
|
||||
<div>Question: {{ props.article?.question?.text }}</div>
|
||||
<div class="text-xl color-translate-second" v-if="settingStore.translate">
|
||||
问题: {{ props.article?.question?.translate }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="titleTranslate" v-if="settingStore.translate">{{ props.article.titleTranslate }}</div>
|
||||
</header>
|
||||
|
||||
<div id="article-content" class="article-content"
|
||||
:class="[
|
||||
settingStore.translate && 'tall',
|
||||
settingStore.dictation && 'dictation',
|
||||
]"
|
||||
ref="articleWrapperRef">
|
||||
<div
|
||||
id="article-content"
|
||||
class="article-content"
|
||||
:class="[settingStore.translate && 'tall', settingStore.dictation && 'dictation']"
|
||||
ref="articleWrapperRef"
|
||||
>
|
||||
<article>
|
||||
<div class="section" v-for="(section,indexI) in props.article.sections">
|
||||
<span class="sentence"
|
||||
v-for="(sentence,indexJ) in section">
|
||||
<span
|
||||
v-for="(word,indexW) in sentence.words"
|
||||
@contextmenu="e=>onContextMenu(e,sentence,indexI,indexJ,indexW)"
|
||||
class="word"
|
||||
:class="[(sectionIndex>indexI
|
||||
?'wrote':
|
||||
(sectionIndex>=indexI &&sentenceIndex>indexJ)
|
||||
?'wrote' :
|
||||
(sectionIndex>=indexI &&sentenceIndex>=indexJ && wordIndex>indexW)
|
||||
?'wrote':
|
||||
(sectionIndex>=indexI &&sentenceIndex>=indexJ && wordIndex>=indexW && stringIndex>=word.word.length)
|
||||
?'wrote':
|
||||
''),
|
||||
indexW === 0 && `word${indexI}-${indexJ}`,
|
||||
]">
|
||||
<span class="word-wrap"
|
||||
@mouseenter="settingStore.allowWordTip && showSentence(indexI,indexJ,indexW)"
|
||||
@mouseleave="hideSentence"
|
||||
:class="[
|
||||
hoverIndex.sectionIndex === indexI && hoverIndex.sentenceIndex === indexJ && hoverIndex.wordIndex === indexW
|
||||
&&'hover-show',
|
||||
word.type === PracticeArticleWordType.Number && 'font-family text-xl'
|
||||
]"
|
||||
@click="playWordAudio(word.word)"
|
||||
>
|
||||
<TypingWord :word="word"
|
||||
:is-typing="true"
|
||||
v-if="isCurrent(indexI,indexJ,indexW) && !isSpace"/>
|
||||
<TypingWord :word="word" :is-typing="false" v-else/>
|
||||
<span class="border-bottom" v-if="settingStore.dictation"></span>
|
||||
</span>
|
||||
<Space
|
||||
v-if="word.nextSpace"
|
||||
class="word-end"
|
||||
:is-wrong="false"
|
||||
:is-wait="isCurrent(indexI,indexJ,indexW) && isSpace"
|
||||
:is-shake="isCurrent(indexI,indexJ,indexW) && isSpace && wrong !== ''"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="sentence-translate-mobile"
|
||||
v-if="isMob && settingStore.translate && sentence.translate">
|
||||
{{ sentence.translate }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="section" v-for="(section, indexI) in props.article.sections">
|
||||
<span class="sentence" v-for="(sentence, indexJ) in section">
|
||||
<span
|
||||
v-for="(word, indexW) in sentence.words"
|
||||
@contextmenu="e => onContextMenu(e, sentence, indexI, indexJ, indexW)"
|
||||
class="word"
|
||||
:class="[
|
||||
sectionIndex > indexI
|
||||
? 'wrote'
|
||||
: sectionIndex >= indexI && sentenceIndex > indexJ
|
||||
? 'wrote'
|
||||
: sectionIndex >= indexI && sentenceIndex >= indexJ && wordIndex > indexW
|
||||
? 'wrote'
|
||||
: sectionIndex >= indexI &&
|
||||
sentenceIndex >= indexJ &&
|
||||
wordIndex >= indexW &&
|
||||
stringIndex >= word.word.length
|
||||
? 'wrote'
|
||||
: '',
|
||||
indexW === 0 && `word${indexI}-${indexJ}`,
|
||||
]"
|
||||
>
|
||||
<span
|
||||
class="word-wrap"
|
||||
@mouseenter="settingStore.allowWordTip && showSentence(indexI, indexJ, indexW)"
|
||||
@mouseleave="hideSentence"
|
||||
:class="[
|
||||
hoverIndex.sectionIndex === indexI &&
|
||||
hoverIndex.sentenceIndex === indexJ &&
|
||||
hoverIndex.wordIndex === indexW &&
|
||||
'hover-show',
|
||||
word.type === PracticeArticleWordType.Number && 'font-family text-xl',
|
||||
]"
|
||||
@click="playWordAudio(word.word)"
|
||||
>
|
||||
<TypingWord :word="word" :is-typing="true" v-if="isCurrent(indexI, indexJ, indexW) && !isSpace" />
|
||||
<TypingWord :word="word" :is-typing="false" v-else />
|
||||
<span class="border-bottom" v-if="settingStore.dictation"></span>
|
||||
</span>
|
||||
<Space
|
||||
v-if="word.nextSpace"
|
||||
class="word-end"
|
||||
:is-wrong="false"
|
||||
:is-wait="isCurrent(indexI, indexJ, indexW) && isSpace"
|
||||
:is-shake="isCurrent(indexI, indexJ, indexW) && isSpace && wrong !== ''"
|
||||
/>
|
||||
</span>
|
||||
<span class="sentence-translate-mobile" v-if="isMob && settingStore.translate && sentence.translate">
|
||||
{{ sentence.translate }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
<div class="translate" v-show="settingStore.translate">
|
||||
<template v-for="(v,indexI) in props.article.sections">
|
||||
<div class="row"
|
||||
:class="[
|
||||
`translate${indexI+'-'+indexJ}`,
|
||||
(sectionIndex>indexI
|
||||
?'wrote':
|
||||
(sectionIndex>=indexI &&sentenceIndex>indexJ)
|
||||
?'wrote' :
|
||||
''),
|
||||
]"
|
||||
v-for="(item,indexJ) in v">
|
||||
<template v-for="(v, indexI) in props.article.sections">
|
||||
<div
|
||||
class="row"
|
||||
:class="[
|
||||
`translate${indexI + '-' + indexJ}`,
|
||||
sectionIndex > indexI ? 'wrote' : sectionIndex >= indexI && sentenceIndex > indexJ ? 'wrote' : '',
|
||||
]"
|
||||
v-for="(item, indexJ) in v"
|
||||
>
|
||||
<span class="space"></span>
|
||||
<Transition name="fade">
|
||||
<span class="text" v-if="item.translate">{{ item.translate }}</span>
|
||||
@@ -731,50 +765,43 @@ const currentPractice = inject('currentPractice', [])
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="cursor" v-if="!isEnd" :style="{top:cursor.top+'px',left:cursor.left+'px'}"></div>
|
||||
<div class="cursor" v-if="!isEnd" :style="{ top: cursor.top + 'px', left: cursor.left + 'px' }"></div>
|
||||
</div>
|
||||
|
||||
<div class="options flex justify-center" v-if="isEnd">
|
||||
<BaseButton
|
||||
@click="emit('replay')">重新练习
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="store.sbook.lastLearnIndex < store.sbook.articles.length - 1"
|
||||
@click="emit('next')">下一篇
|
||||
<BaseButton @click="emit('replay')">重新练习 </BaseButton>
|
||||
<BaseButton v-if="store.sbook.lastLearnIndex < store.sbook.articles.length - 1" @click="emit('next')"
|
||||
>下一篇
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="font-family text-base pr-2 mb-50 mt-10" v-if="currentPractice.length && isEnd">
|
||||
<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"
|
||||
:class="i === currentPractice.length-1 && 'color-red!'"
|
||||
v-for="(item,i) in currentPractice">
|
||||
<span :class="i === currentPractice.length-1 ? 'color-red':'color-gray'"
|
||||
>{{
|
||||
i === currentPractice.length - 1 ? '当前' : i + 1
|
||||
}}. {{ _dateFormat(item.startDate) }}</span>
|
||||
<div
|
||||
class="item border border-item border-solid mt-2 p-2 bg-[var(--bg-history)] rounded-md flex justify-between"
|
||||
:class="i === currentPractice.length - 1 && 'color-red!'"
|
||||
v-for="(item, i) in currentPractice"
|
||||
>
|
||||
<span :class="i === currentPractice.length - 1 ? 'color-red' : 'color-gray'"
|
||||
>{{ i === currentPractice.length - 1 ? '当前' : i + 1 }}. {{ _dateFormat(item.startDate) }}</span
|
||||
>
|
||||
<span>{{ msToHourMinute(item.spend) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="false">
|
||||
<div class="center">
|
||||
<BaseButton @click="showQuestions =! showQuestions">显示题目</BaseButton>
|
||||
<BaseButton @click="showQuestions = !showQuestions">显示题目</BaseButton>
|
||||
</div>
|
||||
<div class="toggle" v-if="showQuestions">
|
||||
<QuestionForm :questions="article.questions"
|
||||
:duration="300"
|
||||
:immediateFeedback="false"
|
||||
:randomize="true"
|
||||
/>
|
||||
<QuestionForm :questions="article.questions" :duration="300" :immediateFeedback="false" :randomize="true" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.wrote {
|
||||
color: grey;
|
||||
}
|
||||
@@ -783,31 +810,10 @@ $translate-lh: 3.2;
|
||||
$article-lh: 2.4;
|
||||
|
||||
.typing-article {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
color: var(--color-article);
|
||||
width: var(--article-width);
|
||||
font-size: 1.6rem;
|
||||
|
||||
header {
|
||||
word-wrap: break-word;
|
||||
position: relative;
|
||||
padding-top: 3rem;
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
font-size: 2.2rem;
|
||||
font-family: var(--en-article-family);
|
||||
}
|
||||
|
||||
.titleTranslate {
|
||||
@extend .title;
|
||||
font-size: 1.2rem;
|
||||
margin-top: 0.5rem;
|
||||
font-family: var(--zh-article-family);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
margin-bottom: 10rem;
|
||||
|
||||
.mobile-input {
|
||||
position: absolute;
|
||||
@@ -825,7 +831,7 @@ $article-lh: 2.4;
|
||||
.border-bottom {
|
||||
display: inline-block !important;
|
||||
}
|
||||
.translate{
|
||||
.translate {
|
||||
color: var(--color-reverse-black);
|
||||
}
|
||||
}
|
||||
@@ -842,7 +848,8 @@ $article-lh: 2.4;
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--en-article-family);
|
||||
|
||||
.wrote, .hover-show {
|
||||
.wrote,
|
||||
.hover-show {
|
||||
:deep(.hide) {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
@@ -866,7 +873,7 @@ $article-lh: 2.4;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.sentence {
|
||||
transition: all .3s;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.word {
|
||||
@@ -874,7 +881,7 @@ $article-lh: 2.4;
|
||||
|
||||
.word-wrap {
|
||||
position: relative;
|
||||
transition: background-color .3s;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.border-bottom {
|
||||
@@ -900,7 +907,7 @@ $article-lh: 2.4;
|
||||
width: 100%;
|
||||
font-size: 1.2rem;
|
||||
line-height: $translate-lh;
|
||||
letter-spacing: .2rem;
|
||||
letter-spacing: 0.2rem;
|
||||
font-family: var(--zh-article-family);
|
||||
font-weight: bold;
|
||||
color: #818181;
|
||||
@@ -910,10 +917,10 @@ $article-lh: 2.4;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition: all .3s;
|
||||
transition: all 0.3s;
|
||||
|
||||
.space {
|
||||
transition: all .3s;
|
||||
transition: all 0.3s;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="tsx">
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import Space from "@/pages/article/components/Space.vue";
|
||||
import { PracticeArticleWordType } from "@/types/types.ts";
|
||||
//引入这个编译就报错
|
||||
// import {ArticleWord} from "@/types/types.ts";
|
||||
|
||||
import {PracticeArticleWordType} from "@/types/enum.ts";
|
||||
import type {ArticleWord} from "@/types/types.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
word: any,
|
||||
word: ArticleWord,
|
||||
isTyping: boolean,
|
||||
}>()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ShortcutKey } from '@/types/types.ts'
|
||||
import Logo from '@/components/Logo.vue'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -7,6 +6,8 @@ import useTheme from '@/hooks/theme.ts'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import { useRuntimeStore } from '@/stores/runtime.ts'
|
||||
import { jump2Feedback } from '@/utils'
|
||||
import { watch } from 'vue'
|
||||
import { ShortcutKey } from '@/types/enum.ts'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
@@ -17,13 +18,19 @@ const { toggleTheme, getTheme } = useTheme()
|
||||
function goHome() {
|
||||
window.location.href = '/'
|
||||
}
|
||||
watch(
|
||||
() => settingStore.sideExpand,
|
||||
n => {
|
||||
document.documentElement.style.setProperty('--aside-width', n ? '12rem' : '4.5rem')
|
||||
},{immediate: true}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout anim">
|
||||
<!-- 第一个aside 占位用-->
|
||||
<div class="aside space" :class="{ expand: settingStore.sideExpand }"></div>
|
||||
<div class="aside anim fixed" :class="{ expand: settingStore.sideExpand }">
|
||||
<div class="aside space"></div>
|
||||
<div class="aside anim fixed">
|
||||
<div class="top">
|
||||
<Logo v-if="settingStore.sideExpand" />
|
||||
<div class="row" @click="goHome">
|
||||
@@ -42,11 +49,7 @@ function goHome() {
|
||||
<div class="row" @click="router.push('/setting')">
|
||||
<IconFluentSettings20Regular />
|
||||
<span v-if="settingStore.sideExpand">设置</span>
|
||||
<div
|
||||
class="red-point"
|
||||
:class="!settingStore.sideExpand && 'top-1 right-0'"
|
||||
v-if="runtimeStore.isNew"
|
||||
></div>
|
||||
<div class="red-point" :class="!settingStore.sideExpand && 'top-1 right-0'" v-if="runtimeStore.isNew"></div>
|
||||
</div>
|
||||
<div class="row" @click="router.push('/feedback')">
|
||||
<IconFluentCommentEdit20Regular />
|
||||
@@ -88,42 +91,27 @@ function goHome() {
|
||||
<IconFluentHome20Regular />
|
||||
<span>主页</span>
|
||||
</div>
|
||||
<div
|
||||
class="nav-item"
|
||||
@click="router.push('/words')"
|
||||
:class="{ active: $route.path.includes('/words') }"
|
||||
>
|
||||
<div class="nav-item" @click="router.push('/words')" :class="{ active: $route.path.includes('/words') }">
|
||||
<IconFluentTextUnderlineDouble20Regular />
|
||||
<span>单词</span>
|
||||
</div>
|
||||
<div
|
||||
class="nav-item"
|
||||
@click="router.push('/articles')"
|
||||
:class="{ active: $route.path.includes('/articles') }"
|
||||
>
|
||||
<div class="nav-item" @click="router.push('/articles')" :class="{ active: $route.path.includes('/articles') }">
|
||||
<IconFluentBookLetter20Regular />
|
||||
<span>文章</span>
|
||||
</div>
|
||||
<div
|
||||
class="nav-item"
|
||||
@click="router.push('/setting')"
|
||||
:class="{ active: $route.path === '/setting' }"
|
||||
>
|
||||
<div class="nav-item" @click="router.push('/setting')" :class="{ active: $route.path === '/setting' }">
|
||||
<IconFluentSettings20Regular />
|
||||
<span>设置</span>
|
||||
<div class="red-point" v-if="runtimeStore.isNew"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="nav-toggle"
|
||||
@click="settingStore.mobileNavCollapsed = !settingStore.mobileNavCollapsed"
|
||||
>
|
||||
<div class="nav-toggle" @click="settingStore.mobileNavCollapsed = !settingStore.mobileNavCollapsed">
|
||||
<IconFluentChevronDown20Filled v-if="!settingStore.mobileNavCollapsed" />
|
||||
<IconFluentChevronUp20Filled v-else />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 z-1 relative main-content">
|
||||
<div class="flex-1 z-1 relative main-content overflow-x-hidden">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,7 +134,7 @@ function goHome() {
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
box-shadow: rgb(0 0 0 / 3%) 0px 0px 12px 0px;
|
||||
width: 4.5rem;
|
||||
width: var(--aside-width);
|
||||
z-index: 2;
|
||||
|
||||
.row {
|
||||
@@ -167,10 +155,6 @@ function goHome() {
|
||||
font-size: 1.3rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.expand {
|
||||
width: var(--aside-width);
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端顶部菜单栏
|
||||
|
||||
@@ -1,274 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
let logList = [
|
||||
{
|
||||
date: '2026/01/06',
|
||||
content: '优化书籍详情页面',
|
||||
},
|
||||
{
|
||||
date: '2025/12/30',
|
||||
content: '移除“继续默写”选项',
|
||||
},
|
||||
{
|
||||
date: '2025/12/29',
|
||||
content: '单词练习界面,底部工具栏新增音频设置按钮',
|
||||
},
|
||||
{
|
||||
date: '2025/12/27',
|
||||
content: '优化进度条展示,现可展示当前阶段、所有阶段',
|
||||
},
|
||||
{
|
||||
date: '2025/12/23',
|
||||
content: '新增复习、自测、默写、听写模式',
|
||||
},
|
||||
{
|
||||
date: '2025/12/20',
|
||||
content: '新增资源分享页面',
|
||||
},
|
||||
{
|
||||
date: '2025/12/17',
|
||||
content: '新增帮助页面',
|
||||
},
|
||||
{
|
||||
date: '2025/12/16',
|
||||
content: '修复弹框内边距太小;单词、文章、通用设置在设置页面、练习界面均可进行设置',
|
||||
},
|
||||
{
|
||||
date: '2025/12/15',
|
||||
content: '修复在黑暗模式下,翻译颜色不正确;支持中文符号输入',
|
||||
},
|
||||
{
|
||||
date: '2025/12/11',
|
||||
content: '修复音标显示错误问题,优化反馈页面',
|
||||
},
|
||||
{
|
||||
date: '2025/12/10',
|
||||
content: '新增选项:复习比(单词练习时,复习词与新词的比例)',
|
||||
},
|
||||
{
|
||||
date: '2025/12/5',
|
||||
content: '解决练习界面无法复制、全选的问题',
|
||||
},
|
||||
{
|
||||
date: '2025/12/3',
|
||||
content: '单词、文章设置修改为弹框,更方便',
|
||||
},
|
||||
{
|
||||
date: '2025/12/3',
|
||||
content: '录入新概念(三、四)部分音频,优化文章相关功能',
|
||||
},
|
||||
{
|
||||
date: '2025/12/2',
|
||||
content: '完成新概念(一)音频,优化文章管理页面',
|
||||
},
|
||||
{
|
||||
date: '2025/11/30',
|
||||
content: '文章里的单词可点击播放',
|
||||
},
|
||||
{
|
||||
date: '2025/11/29',
|
||||
content: '修改 Slider 组件显示bug,新增 IE 浏览器检测提示',
|
||||
},
|
||||
{
|
||||
date: '2025/11/28',
|
||||
content: '新增引导框、 新增词典测试模式(由大佬hebeihang 开发)',
|
||||
},
|
||||
{
|
||||
date: '2025/11/25',
|
||||
content: '文章练习新增人名忽略功能(新概念一已全部适配),上传了新概念(一)1-18 音频',
|
||||
},
|
||||
{
|
||||
date: '2025/11/23',
|
||||
content: '优化练习完成结算界面,新增分享功能',
|
||||
},
|
||||
{
|
||||
date: '2025/11/22',
|
||||
content: '适配移动端',
|
||||
},
|
||||
{
|
||||
date: '2025/11/16',
|
||||
content: '自测单词时,不认识单词可以直接输入,自动标识为错误单词,无需按2',
|
||||
},
|
||||
{
|
||||
date: '2025/11/15',
|
||||
content: '练习单词时,底部工具栏新增“跳到下一阶段”按钮',
|
||||
},
|
||||
{
|
||||
date: '2025/11/14',
|
||||
content:
|
||||
'新增文章练习时可跳过空格:如果在单词的最后一位上,不按空格直接输入下一个字母的话,自动跳下一个单词,按空格也自动跳下一个单词',
|
||||
},
|
||||
{
|
||||
date: '2025/11/13',
|
||||
content: '新增文章练习时“输入时忽略符号/数字”选项',
|
||||
},
|
||||
{
|
||||
date: '2025/11/6',
|
||||
content: '新增随机复习功能',
|
||||
},
|
||||
{
|
||||
date: '2025/10/30',
|
||||
content: '集成PWA基础配置,支持用户以类App形式打开项目',
|
||||
},
|
||||
{
|
||||
date: '2025/10/26',
|
||||
content: '进一步完善单词练习,解决复习数量太多的问题',
|
||||
},
|
||||
{
|
||||
date: '2025/10/8',
|
||||
content: '文章支持自动播放下一篇',
|
||||
},
|
||||
{
|
||||
date: '2025/9/14',
|
||||
content: '完善文章编辑、导入、导出等功能',
|
||||
},
|
||||
{
|
||||
date: '2025/8/10',
|
||||
content: '2.0版本发布,全新UI,全新逻辑,新增短语、例句、近义词等功能',
|
||||
},
|
||||
{
|
||||
date: '2025/7/19',
|
||||
content: '1.0版本发布',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="log-item">
|
||||
<div class="log-item" v-for="item in logList" :key="item.date">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/12/20</div>
|
||||
<div>内容:新增资源分享页面</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/12/17</div>
|
||||
<div>内容:新增帮助页面</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/12/16</div>
|
||||
<div>内容:修复弹框内边距太小;单词、文章、通用设置在设置页面、练习界面均可进行设置</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/12/15</div>
|
||||
<div>内容:修复在黑暗模式下,翻译颜色不正确;支持中文符号输入</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/12/11</div>
|
||||
<div>内容:修复音标显示错误问题,优化反馈页面</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/12/10</div>
|
||||
<div>内容:新增选项:复习比(单词练习时,复习词与新词的比例)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/12/5</div>
|
||||
<div>内容:解决练习界面无法复制、全选的问题</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/12/3</div>
|
||||
<div>内容:单词、文章设置修改为弹框,更方便</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/12/3</div>
|
||||
<div>内容:录入新概念(三、四)部分音频,优化文章相关功能</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/12/2</div>
|
||||
<div>内容:完成新概念(一)音频,优化文章管理页面</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/30</div>
|
||||
<div>内容:文章里的单词可点击播放</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/29</div>
|
||||
<div>内容:修改 Slider 组件显示bug,新增 IE 浏览器检测提示</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/28</div>
|
||||
<div>内容:新增引导框、 新增<a href="https://github.com/zyronon/TypeWords/pull/175" target="_blank">词典测试模式(由大佬
|
||||
hebeihang 开发)</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/25</div>
|
||||
<div>内容:文章练习新增人名忽略功能(新概念一已全部适配),上传了新概念(一)1-18 音频</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/23</div>
|
||||
<div>内容:优化练习完成结算界面,新增分享功能</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/22</div>
|
||||
<div>内容:适配移动端</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/16</div>
|
||||
<div>内容:自测单词时,不认识单词可以直接输入,自动标识为错误单词,无需按2</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/15</div>
|
||||
<div>内容:练习单词时,底部工具栏新增“跳到下一阶段”按钮</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/14</div>
|
||||
<div>内容:新增文章练习时可跳过空格:如果在单词的最后一位上,不按空格直接输入下一个字母的话,自动跳下一个单词,
|
||||
按空格也自动跳下一个单词
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/13</div>
|
||||
<div>内容:新增文章练习时“输入时忽略符号/数字”选项</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/6</div>
|
||||
<div>内容:新增随机复习功能</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/10/30</div>
|
||||
<div>内容:集成PWA基础配置,支持用户以类App形式打开项目</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/10/26</div>
|
||||
<div>内容:进一步完善单词练习,解决复习数量太多的问题</div>
|
||||
</div>
|
||||
<div class="text-base mt-1">
|
||||
<ol>
|
||||
<li>
|
||||
<div class="title"><b>智能模式优化</b></div>
|
||||
<div class="desc">练习时新增四种练习模式:学习、自测、听写、默写。</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="title"><b>学习模式</b></div>
|
||||
<div class="desc">
|
||||
<ul>
|
||||
<li>仅在练习新词时出现。</li>
|
||||
<li>采用「跟写 / 拼写」方式进行学习。</li>
|
||||
<li>每 7 个单词会 <b>强制进行听写</b>,解决原来“一次练太多,听写时已忘记”的问题。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="title"><b>自测模式(新增)</b></div>
|
||||
<div class="desc">
|
||||
<ul>
|
||||
<li>仅在复习已学单词时出现。</li>
|
||||
<li>不再强制拼写,提供「我认识」与「不认识」选项。</li>
|
||||
<li>选择「我认识」后,该单词在后续听写或默写中将不再出现,<b>显著减少复习数量</b>。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="title"><b>听写模式</b></div>
|
||||
<div class="desc">原有逻辑保持不变。</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="title"><b>默写模式(新增)</b></div>
|
||||
<div class="desc">
|
||||
<ul>
|
||||
<li>仅显示释义,不自动发音,不显示单词长度。</li>
|
||||
<li>适合强化拼写记忆的场景。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<b>说明:</b>
|
||||
<div>本次更新重点解决了“复习单词数量过多、效率偏低”的问题。</div>
|
||||
<div>通过引入「复习」与「默写」两种模式,使复习流程更加灵活、高效。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/10/8</div>
|
||||
<div>内容:文章支持自动播放下一篇</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/9/14</div>
|
||||
<div>内容:完善文章编辑、导入、导出等功能</div>
|
||||
</div>
|
||||
<div class="text-base mt-1">
|
||||
<div>1、文章的音频管理功能,目前已可添加音频、设置句子与音频的对应位置</div>
|
||||
<div>2、文章可导入、导出</div>
|
||||
<div>3、单词可导入、导出</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/8/10</div>
|
||||
<div>内容:2.0版本发布,全新UI,全新逻辑,新增短语、例句、近义词等功能</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/7/19</div>
|
||||
<div>内容:1.0版本发布</div>
|
||||
<div>日期:{{ item.date }}</div>
|
||||
<div>内容:{{ item.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,10 +146,8 @@
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.log-item {
|
||||
border-bottom: 1px solid var(--color-input-border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { getShortcutKey, useEventListener } from "@/hooks/event.ts";
|
||||
import { checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting, cloneDeep, loadJsLib, sleep } from "@/utils";
|
||||
import { DefaultShortcutKeyMap } from "@/types/types.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import { useBaseStore } from "@/stores/base.ts";
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { getShortcutKey, useEventListener } from '@/hooks/event.ts'
|
||||
import { checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting, cloneDeep, loadJsLib, sleep } from '@/utils'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
import {
|
||||
APP_NAME,
|
||||
APP_VERSION,
|
||||
AppEnv,
|
||||
DefaultShortcutKeyMap,
|
||||
Host,
|
||||
IS_DEV,
|
||||
LIB_JS_URL,
|
||||
LOCAL_FILE_KEY,
|
||||
PracticeSaveArticleKey,
|
||||
PracticeSaveWordKey
|
||||
} from "@/config/env.ts";
|
||||
import BasePage from "@/components/BasePage.vue";
|
||||
} from '@/config/env.ts'
|
||||
import BasePage from '@/components/BasePage.vue'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import { set } from "idb-keyval";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
import { useExport } from "@/hooks/export.ts";
|
||||
import MigrateDialog from "@/components/MigrateDialog.vue";
|
||||
import Log from "@/pages/setting/Log.vue";
|
||||
import About from "@/components/About.vue";
|
||||
import CommonSetting from "@/components/setting/CommonSetting.vue";
|
||||
import ArticleSettting from "@/components/setting/ArticleSettting.vue";
|
||||
import WordSetting from "@/components/setting/WordSetting.vue";
|
||||
import { set } from 'idb-keyval'
|
||||
import { useRuntimeStore } from '@/stores/runtime.ts'
|
||||
import { useExport } from '@/hooks/export.ts'
|
||||
import MigrateDialog from '@/components/MigrateDialog.vue'
|
||||
import Log from '@/pages/setting/Log.vue'
|
||||
import About from '@/components/About.vue'
|
||||
import CommonSetting from '@/components/setting/CommonSetting.vue'
|
||||
import ArticleSetting from '@/components/setting/ArticleSetting.vue'
|
||||
import WordSetting from '@/components/setting/WordSetting.vue'
|
||||
import { PRACTICE_ARTICLE_CACHE, PRACTICE_WORD_CACHE } from '@/utils/cache.ts'
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleDisabledDialogEscKey: [val: boolean]
|
||||
@@ -37,7 +38,7 @@ const runtimeStore = useRuntimeStore()
|
||||
const store = useBaseStore()
|
||||
|
||||
//@ts-ignore
|
||||
const gitLastCommitHash = ref(LATEST_COMMIT_HASH);
|
||||
const gitLastCommitHash = ref(LATEST_COMMIT_HASH)
|
||||
|
||||
let editShortcutKey = $ref('')
|
||||
|
||||
@@ -45,19 +46,25 @@ const disabledDefaultKeyboardEvent = $computed(() => {
|
||||
return editShortcutKey && tabIndex === 3
|
||||
})
|
||||
|
||||
watch(() => disabledDefaultKeyboardEvent, v => {
|
||||
emit('toggleDisabledDialogEscKey', !!v)
|
||||
})
|
||||
watch(
|
||||
() => disabledDefaultKeyboardEvent,
|
||||
v => {
|
||||
emit('toggleDisabledDialogEscKey', !!v)
|
||||
}
|
||||
)
|
||||
|
||||
// 监听编辑快捷键状态变化,自动聚焦输入框
|
||||
watch(() => editShortcutKey, (newVal) => {
|
||||
if (newVal) {
|
||||
// 使用nextTick确保DOM已更新
|
||||
nextTick(() => {
|
||||
focusShortcutInput()
|
||||
})
|
||||
watch(
|
||||
() => editShortcutKey,
|
||||
newVal => {
|
||||
if (newVal) {
|
||||
// 使用nextTick确保DOM已更新
|
||||
nextTick(() => {
|
||||
focusShortcutInput()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
useEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (!disabledDefaultKeyboardEvent) return
|
||||
@@ -80,9 +87,15 @@ useEventListener('keydown', (e: KeyboardEvent) => {
|
||||
settingStore.shortcutKeyMap[editShortcutKey] = ''
|
||||
} else {
|
||||
// 忽略单独的修饰键
|
||||
if (shortcutKey === 'Ctrl+' || shortcutKey === 'Alt+' || shortcutKey === 'Shift+' ||
|
||||
e.key === 'Control' || e.key === 'Alt' || e.key === 'Shift') {
|
||||
return;
|
||||
if (
|
||||
shortcutKey === 'Ctrl+' ||
|
||||
shortcutKey === 'Alt+' ||
|
||||
shortcutKey === 'Shift+' ||
|
||||
e.key === 'Control' ||
|
||||
e.key === 'Alt' ||
|
||||
e.key === 'Shift'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(settingStore.shortcutKeyMap)) {
|
||||
@@ -120,26 +133,26 @@ function focusShortcutInput() {
|
||||
// 快捷键中文名称映射
|
||||
function getShortcutKeyName(key: string): string {
|
||||
const shortcutKeyNameMap = {
|
||||
'ShowWord': '显示单词',
|
||||
'EditArticle': '编辑文章',
|
||||
'Next': '下一个',
|
||||
'Previous': '上一个',
|
||||
'ToggleSimple': '切换已掌握状态',
|
||||
'ToggleCollect': '切换收藏状态',
|
||||
'NextChapter': '下一组',
|
||||
'PreviousChapter': '上一组',
|
||||
'RepeatChapter': '重复本组',
|
||||
'DictationChapter': '默写本组',
|
||||
'PlayWordPronunciation': '播放发音',
|
||||
'ToggleShowTranslate': '切换显示翻译',
|
||||
'ToggleDictation': '切换默写模式',
|
||||
'ToggleTheme': '切换主题',
|
||||
'ToggleConciseMode': '切换简洁模式',
|
||||
'TogglePanel': '切换面板',
|
||||
'RandomWrite': '随机默写',
|
||||
'NextRandomWrite': '继续随机默写',
|
||||
'KnowWord': '认识单词',
|
||||
'UnknownWord': '不认识单词',
|
||||
ShowWord: '显示单词',
|
||||
EditArticle: '编辑文章',
|
||||
Next: '下一个',
|
||||
Previous: '上一个',
|
||||
ToggleSimple: '切换已掌握状态',
|
||||
ToggleCollect: '切换收藏状态',
|
||||
NextChapter: '下一组',
|
||||
PreviousChapter: '上一组',
|
||||
RepeatChapter: '重复本组',
|
||||
DictationChapter: '默写本组',
|
||||
PlayWordPronunciation: '播放发音',
|
||||
ToggleShowTranslate: '切换显示翻译',
|
||||
ToggleDictation: '切换默写模式',
|
||||
ToggleTheme: '切换主题',
|
||||
ToggleConciseMode: '切换简洁模式',
|
||||
TogglePanel: '切换面板',
|
||||
RandomWrite: '随机默写',
|
||||
NextRandomWrite: '继续随机默写',
|
||||
KnowWord: '认识单词',
|
||||
UnknownWord: '不认识单词',
|
||||
}
|
||||
|
||||
return shortcutKeyNameMap[key] || key
|
||||
@@ -162,10 +175,10 @@ function importJson(str: string, notice: boolean = true) {
|
||||
val: {
|
||||
setting: {},
|
||||
dict: {},
|
||||
[PracticeSaveWordKey.key]: {},
|
||||
[PracticeSaveArticleKey.key]: {},
|
||||
[PRACTICE_WORD_CACHE.key]: {},
|
||||
[PRACTICE_ARTICLE_CACHE.key]: {},
|
||||
[APP_VERSION.key]: {},
|
||||
}
|
||||
},
|
||||
}
|
||||
try {
|
||||
obj = JSON.parse(str)
|
||||
@@ -178,9 +191,9 @@ function importJson(str: string, notice: boolean = true) {
|
||||
store.setState(baseState)
|
||||
if (obj.version >= 3) {
|
||||
try {
|
||||
let save: any = obj.val[PracticeSaveWordKey.key] || {}
|
||||
let save: any = obj.val[PRACTICE_WORD_CACHE.key] || {}
|
||||
if (save.val && Object.keys(save.val).length > 0) {
|
||||
localStorage.setItem(PracticeSaveWordKey.key, JSON.stringify(obj.val[PracticeSaveWordKey.key]))
|
||||
localStorage.setItem(PRACTICE_WORD_CACHE.key, JSON.stringify(obj.val[PRACTICE_WORD_CACHE.key]))
|
||||
}
|
||||
} catch (e) {
|
||||
//todo 上报
|
||||
@@ -188,9 +201,9 @@ function importJson(str: string, notice: boolean = true) {
|
||||
}
|
||||
if (obj.version >= 4) {
|
||||
try {
|
||||
let save: any = obj.val[PracticeSaveArticleKey.key] || {}
|
||||
let save: any = obj.val[PRACTICE_ARTICLE_CACHE.key] || {}
|
||||
if (save.val && Object.keys(save.val).length > 0) {
|
||||
localStorage.setItem(PracticeSaveArticleKey.key, JSON.stringify(obj.val[PracticeSaveArticleKey.key]))
|
||||
localStorage.setItem(PRACTICE_ARTICLE_CACHE.key, JSON.stringify(obj.val[PRACTICE_ARTICLE_CACHE.key]))
|
||||
}
|
||||
} catch (e) {
|
||||
//todo 上报
|
||||
@@ -198,7 +211,7 @@ function importJson(str: string, notice: boolean = true) {
|
||||
try {
|
||||
let r: any = obj.val[APP_VERSION.key] || -1
|
||||
set(APP_VERSION.key, r)
|
||||
runtimeStore.isNew = r ? (APP_VERSION.version > Number(r)) : true
|
||||
runtimeStore.isNew = r ? APP_VERSION.version > Number(r) : true
|
||||
} catch (e) {
|
||||
//todo 上报
|
||||
}
|
||||
@@ -213,64 +226,66 @@ function importJson(str: string, notice: boolean = true) {
|
||||
|
||||
let timer = -1
|
||||
async function beforeImport() {
|
||||
importLoading = true
|
||||
await exportData('已自动备份数据', 'TypeWords数据备份.zip')
|
||||
await sleep(1500)
|
||||
if (!IS_DEV) {
|
||||
importLoading = true
|
||||
await exportData('已自动备份数据', 'TypeWords数据备份.zip')
|
||||
await sleep(1500)
|
||||
}
|
||||
let d: HTMLDivElement = document.querySelector('#import')
|
||||
d.click()
|
||||
timer = setTimeout(()=>importLoading = false, 1000)
|
||||
timer = setTimeout(() => (importLoading = false), 1000)
|
||||
}
|
||||
|
||||
async function importData(e) {
|
||||
clearTimeout(timer)
|
||||
importLoading = true
|
||||
let file = e.target.files[0]
|
||||
if (!file) return importLoading = false
|
||||
if (file.name.endsWith(".json")) {
|
||||
let reader = new FileReader();
|
||||
if (!file) return (importLoading = false)
|
||||
if (file.name.endsWith('.json')) {
|
||||
let reader = new FileReader()
|
||||
reader.onload = function (v) {
|
||||
let str: any = v.target.result;
|
||||
let str: any = v.target.result
|
||||
if (str) {
|
||||
importJson(str)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file);
|
||||
} else if (file.name.endsWith(".zip")) {
|
||||
reader.readAsText(file)
|
||||
} else if (file.name.endsWith('.zip')) {
|
||||
try {
|
||||
const JSZip = await loadJsLib('JSZip', LIB_JS_URL.JSZIP);
|
||||
const zip = await JSZip.loadAsync(file);
|
||||
const JSZip = await loadJsLib('JSZip', LIB_JS_URL.JSZIP)
|
||||
const zip = await JSZip.loadAsync(file)
|
||||
|
||||
const dataFile = zip.file("data.json");
|
||||
const dataFile = zip.file('data.json')
|
||||
if (!dataFile) {
|
||||
return Toast.error("缺少 data.json,导入失败");
|
||||
return Toast.error('缺少 data.json,导入失败')
|
||||
}
|
||||
|
||||
const mp3Folder = zip.folder("mp3");
|
||||
const mp3Folder = zip.folder('mp3')
|
||||
if (mp3Folder) {
|
||||
const records: { id: string; file: Blob }[] = [];
|
||||
const records: { id: string; file: Blob }[] = []
|
||||
for (const filename in zip.files) {
|
||||
if (filename.startsWith("mp3/") && filename.endsWith(".mp3")) {
|
||||
const entry = zip.file(filename);
|
||||
if (!entry) continue;
|
||||
const blob = await entry.async("blob");
|
||||
const id = filename.replace(/^mp3\//, "").replace(/\.mp3$/, "");
|
||||
records.push({ id, file: blob });
|
||||
if (filename.startsWith('mp3/') && filename.endsWith('.mp3')) {
|
||||
const entry = zip.file(filename)
|
||||
if (!entry) continue
|
||||
const blob = await entry.async('blob')
|
||||
const id = filename.replace(/^mp3\//, '').replace(/\.mp3$/, '')
|
||||
records.push({ id, file: blob })
|
||||
}
|
||||
}
|
||||
await set(LOCAL_FILE_KEY, records);
|
||||
await set(LOCAL_FILE_KEY, records)
|
||||
}
|
||||
|
||||
const str = await dataFile.async("string");
|
||||
const str = await dataFile.async('string')
|
||||
importJson(str, false)
|
||||
|
||||
Toast.success("导入成功!");
|
||||
Toast.success('导入成功!')
|
||||
} catch (e) {
|
||||
Toast.error(e?.message || e || '导入失败')
|
||||
} finally {
|
||||
importLoading = false
|
||||
}
|
||||
} else {
|
||||
Toast.error("不支持的文件类型");
|
||||
Toast.error('不支持的文件类型')
|
||||
}
|
||||
importLoading = false
|
||||
}
|
||||
@@ -288,55 +303,59 @@ function transferOk() {
|
||||
|
||||
<template>
|
||||
<BasePage>
|
||||
<div class="setting text-md card flex flex-col" style="height: calc(100vh - 3rem);">
|
||||
<div class="setting text-md card flex flex-col" style="height: calc(100vh - 3rem)">
|
||||
<div class="page-title text-align-center">设置</div>
|
||||
<div class="flex flex-1 overflow-hidden gap-4">
|
||||
<div class="left">
|
||||
<div class="tabs">
|
||||
<div class="tab" :class="tabIndex === 0 && 'active'" @click="tabIndex = 0">
|
||||
<IconFluentSettings20Regular width="20"/>
|
||||
<IconFluentSettings20Regular width="20" />
|
||||
<span>通用设置</span>
|
||||
</div>
|
||||
<div class="tab" :class="tabIndex === 1 && 'active'" @click="tabIndex = 1">
|
||||
<IconFluentTextUnderlineDouble20Regular width="20"/>
|
||||
<IconFluentTextUnderlineDouble20Regular width="20" />
|
||||
<span>单词设置</span>
|
||||
</div>
|
||||
<div class="tab" :class="tabIndex === 2 && 'active'" @click="tabIndex = 2">
|
||||
<IconFluentBookLetter20Regular width="20"/>
|
||||
<IconFluentBookLetter20Regular width="20" />
|
||||
<span>文章设置</span>
|
||||
</div>
|
||||
<div class="tab" :class="tabIndex === 4 && 'active'" @click="tabIndex = 4">
|
||||
<IconFluentDatabasePerson20Regular width="20"/>
|
||||
<IconFluentDatabasePerson20Regular width="20" />
|
||||
<span>数据管理</span>
|
||||
</div>
|
||||
|
||||
<div class="tab" :class="tabIndex === 3 && 'active'" @click="tabIndex = 3">
|
||||
<IconFluentKeyboardLayoutFloat20Regular width="20"/>
|
||||
<IconFluentKeyboardLayoutFloat20Regular width="20" />
|
||||
<span>快捷键设置</span>
|
||||
</div>
|
||||
|
||||
<div class="tab" :class="tabIndex === 5 && 'active'" @click="()=>{
|
||||
tabIndex = 5
|
||||
runtimeStore.isNew = false
|
||||
set(APP_VERSION.key,APP_VERSION.version)
|
||||
}">
|
||||
<IconFluentTextBulletListSquare20Regular width="20"/>
|
||||
<div
|
||||
class="tab"
|
||||
:class="tabIndex === 5 && 'active'"
|
||||
@click="
|
||||
() => {
|
||||
tabIndex = 5
|
||||
runtimeStore.isNew = false
|
||||
set(APP_VERSION.key, APP_VERSION.version)
|
||||
}
|
||||
"
|
||||
>
|
||||
<IconFluentTextBulletListSquare20Regular width="20" />
|
||||
<span>更新日志</span>
|
||||
<div class="red-point" v-if="runtimeStore.isNew"></div>
|
||||
</div>
|
||||
<div class="tab" :class="tabIndex === 6 && 'active'" @click="tabIndex = 6">
|
||||
<IconFluentPerson20Regular width="20"/>
|
||||
<IconFluentPerson20Regular width="20" />
|
||||
<span>关于</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-line"></div>
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden pr-4 content">
|
||||
|
||||
<CommonSetting v-if="tabIndex === 0"/>
|
||||
<WordSetting v-if="tabIndex === 1"/>
|
||||
<ArticleSettting v-if="tabIndex === 2"/>
|
||||
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden pr-4 content">
|
||||
<CommonSetting v-if="tabIndex === 0" />
|
||||
<WordSetting v-if="tabIndex === 1" />
|
||||
<ArticleSetting v-if="tabIndex === 2" />
|
||||
|
||||
<div class="body" v-if="tabIndex === 3">
|
||||
<div class="row">
|
||||
@@ -348,10 +367,16 @@ function transferOk() {
|
||||
<label class="item-title">{{ getShortcutKeyName(item[0]) }}</label>
|
||||
<div class="wrapper" @click="editShortcutKey = item[0]">
|
||||
<div class="set-key" v-if="editShortcutKey === item[0]">
|
||||
<input ref="shortcutInput" :value="item[1]?item[1]:'未设置快捷键'" readonly type="text"
|
||||
@blur="handleInputBlur">
|
||||
<span @click.stop="editShortcutKey = ''">按键盘进行设置,<span
|
||||
class="text-red!">设置完成点击这里</span></span>
|
||||
<input
|
||||
ref="shortcutInput"
|
||||
:value="item[1] ? item[1] : '未设置快捷键'"
|
||||
readonly
|
||||
type="text"
|
||||
@blur="handleInputBlur"
|
||||
/>
|
||||
<span @click.stop="editShortcutKey = ''"
|
||||
>按键盘进行设置,<span class="text-red!">设置完成点击这里</span></span
|
||||
>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="item[1]">{{ item[1] }}</div>
|
||||
@@ -374,27 +399,33 @@ function transferOk() {
|
||||
<b class="text-red">保存在本地浏览器中</b>。如果您需要在不同的设备、浏览器上使用 {{ APP_NAME }},
|
||||
您需要手动进行数据导出和导入
|
||||
</div>
|
||||
<BaseButton :loading="exportLoading" size="large" class="mt-3" @click="exportData()">导出数据备份(ZIP)</BaseButton>
|
||||
<BaseButton :loading="exportLoading" size="large" class="mt-3" @click="exportData()"
|
||||
>导出数据备份(ZIP)</BaseButton
|
||||
>
|
||||
<div class="text-gray text-sm mt-2">💾 导出的ZIP文件包含所有学习数据,可在其他设备上导入恢复</div>
|
||||
|
||||
<div class="line mt-15 mb-3"></div>
|
||||
|
||||
<div>请注意,导入数据将<b class="text-red"> 完全覆盖 </b>当前所有数据,请谨慎操作。执行导入操作时,会先自动备份当前数据到您的电脑中,供您随时恢复
|
||||
<div>
|
||||
请注意,导入数据将<b class="text-red"> 完全覆盖 </b
|
||||
>当前所有数据,请谨慎操作。执行导入操作时,会先自动备份当前数据到您的电脑中,供您随时恢复
|
||||
</div>
|
||||
<div class="flex gap-space mt-3">
|
||||
<BaseButton size="large"
|
||||
@click="beforeImport"
|
||||
:loading="importLoading">导入数据恢复</BaseButton>
|
||||
<input type="file"
|
||||
id="import"
|
||||
class="w-0 h-0 opacity-0"
|
||||
accept="application/json,.zip,application/zip"
|
||||
@change="importData">
|
||||
<BaseButton size="large" @click="beforeImport" :loading="importLoading">导入数据恢复</BaseButton>
|
||||
<input
|
||||
type="file"
|
||||
id="import"
|
||||
class="w-0 h-0 opacity-0"
|
||||
accept="application/json,.zip,application/zip"
|
||||
@change="importData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="isNewHost">
|
||||
<div class="line my-3"></div>
|
||||
<div>请注意,如果本地已有使用记录,请先备份当前数据,迁移数据后将<b class="text-red"> 完全覆盖 </b>当前所有数据,请谨慎操作。
|
||||
<div>
|
||||
请注意,如果本地已有使用记录,请先备份当前数据,迁移数据后将<b class="text-red"> 完全覆盖 </b
|
||||
>当前所有数据,请谨慎操作。
|
||||
</div>
|
||||
<div class="flex gap-space mt-3">
|
||||
<BaseButton @click="showTransfer = true">迁移 2study.top 网站数据</BaseButton>
|
||||
@@ -403,33 +434,26 @@ function transferOk() {
|
||||
</div>
|
||||
|
||||
<!-- 日志-->
|
||||
<Log v-if="tabIndex === 5"/>
|
||||
<Log v-if="tabIndex === 5" />
|
||||
|
||||
<div v-if="tabIndex === 6" class="center flex-col">
|
||||
<About/>
|
||||
<div class="text-md color-gray mt-10">
|
||||
Build {{ gitLastCommitHash }}
|
||||
</div>
|
||||
<About />
|
||||
<div class="text-md color-gray mt-10">Build {{ gitLastCommitHash }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
|
||||
<MigrateDialog
|
||||
v-model="showTransfer"
|
||||
@ok="transferOk"
|
||||
/>
|
||||
<MigrateDialog v-model="showTransfer" @ok="transferOk" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.col-line {
|
||||
border-right: 2px solid gainsboro;
|
||||
border-right: 2px solid var(--color-line);
|
||||
}
|
||||
|
||||
.setting {
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -437,18 +461,18 @@ function transferOk() {
|
||||
align-items: center;
|
||||
|
||||
.tabs {
|
||||
padding: .6rem 0;
|
||||
padding: 0.6rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .6rem;
|
||||
gap: 0.6rem;
|
||||
|
||||
.tab {
|
||||
@apply cursor-pointer flex items-center relative;
|
||||
padding: .6rem .9rem;
|
||||
border-radius: .5rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 10rem;
|
||||
gap: .6rem;
|
||||
transition: all .5s;
|
||||
gap: 0.6rem;
|
||||
transition: all 0.5s;
|
||||
|
||||
&:hover {
|
||||
background: var(--btn-primary);
|
||||
@@ -480,8 +504,6 @@ function transferOk() {
|
||||
|
||||
span {
|
||||
text-align: right;
|
||||
//width: 30rem;
|
||||
font-size: .7rem;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
@@ -491,17 +513,16 @@ function transferOk() {
|
||||
input {
|
||||
width: 9rem;
|
||||
box-sizing: border-box;
|
||||
margin-right: .6rem;
|
||||
margin-right: 0.6rem;
|
||||
height: 1.8rem;
|
||||
outline: none;
|
||||
font-size: 1rem;
|
||||
border: 1px solid gray;
|
||||
border-radius: .2rem;
|
||||
padding: 0 .3rem;
|
||||
border-radius: 0.2rem;
|
||||
padding: 0 0.3rem;
|
||||
background: var(--color-second);
|
||||
color: var(--color-font-1);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,7 +536,7 @@ function transferOk() {
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: .9rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,7 +549,7 @@ function transferOk() {
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
padding-right: .6rem;
|
||||
padding-right: 0.6rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -590,7 +611,8 @@ function transferOk() {
|
||||
}
|
||||
|
||||
// 补充:选择器和输入框优化
|
||||
.base-select, .base-input {
|
||||
.base-select,
|
||||
.base-input {
|
||||
width: 100% !important;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ async function check() {
|
||||
console.parse(str)
|
||||
// console.log(str)
|
||||
let data = checkAndUpgradeSaveDict(str)
|
||||
console.log('data', data)
|
||||
// console.log('data', data)
|
||||
// this.setState(data)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {CodeType} from "@/types/types.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import {sendCode} from "@/apis/user.ts";
|
||||
import {PHONE_CONFIG} from "@/config/auth.ts";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import {CodeType} from "@/types/enum.ts";
|
||||
|
||||
let isSendingCode = $ref(false)
|
||||
let codeCountdown = $ref(0)
|
||||
@@ -63,4 +63,4 @@ async function sendVerificationCode() {
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,6 @@ import BaseButton from "@/components/BaseButton.vue";
|
||||
import {PASSWORD_CONFIG, PHONE_CONFIG} from "@/config/auth.ts";
|
||||
import {changeEmailApi, changePhoneApi, setPassword, updateUserInfoApi, User} from "@/apis/user.ts";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import {CodeType} from "@/types/types.ts";
|
||||
import FormItem from "@/components/base/form/FormItem.vue";
|
||||
import Form from "@/components/base/form/Form.vue";
|
||||
import {FormInstance} from "@/components/base/form/types.ts";
|
||||
@@ -18,6 +17,7 @@ import {_dateFormat, cloneDeep, jump2Feedback} from "@/utils";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import Code from "@/pages/user/Code.vue";
|
||||
import {MessageBox} from "@/utils/MessageBox.tsx";
|
||||
import {CodeType} from "@/types/enum.ts";
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
@@ -626,4 +626,4 @@ function onFileChange(e) {
|
||||
.item {
|
||||
@apply flex items-center justify-between min-h-14;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<script setup lang="tsx">
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BaseInput from "@/components/base/BaseInput.vue";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import { APP_NAME } from "@/config/env.ts";
|
||||
import { useUserStore } from "@/stores/user.ts";
|
||||
import { loginApi, LoginParams, registerApi, resetPasswordApi } from "@/apis/user.ts";
|
||||
import { accountRules, codeRules, passwordRules, phoneRules } from "@/utils/validation.ts";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import FormItem from "@/components/base/form/FormItem.vue";
|
||||
import Form from "@/components/base/form/Form.vue";
|
||||
import Notice from "@/pages/user/Notice.vue";
|
||||
import { FormInstance } from "@/components/base/form/types.ts";
|
||||
import { PASSWORD_CONFIG, PHONE_CONFIG } from "@/config/auth.ts";
|
||||
import { CodeType, ImportStatus } from "@/types/types.ts";
|
||||
import Code from "@/pages/user/Code.vue";
|
||||
import { isNewUser, jump2Feedback, sleep, useNav } from "@/utils";
|
||||
import Header from "@/components/Header.vue";
|
||||
import PopConfirm from "@/components/PopConfirm.vue";
|
||||
import { useExport } from "@/hooks/export.ts";
|
||||
import { getProgress, upload, uploadImportData } from "@/apis";
|
||||
import { Exception } from "sass";
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import { APP_NAME } from '@/config/env.ts'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import { loginApi, LoginParams, registerApi, resetPasswordApi } from '@/apis/user.ts'
|
||||
import { accountRules, codeRules, passwordRules, phoneRules } from '@/utils/validation.ts'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import FormItem from '@/components/base/form/FormItem.vue'
|
||||
import Form from '@/components/base/form/Form.vue'
|
||||
import Notice from '@/pages/user/Notice.vue'
|
||||
import { FormInstance } from '@/components/base/form/types.ts'
|
||||
import { PASSWORD_CONFIG, PHONE_CONFIG } from '@/config/auth.ts'
|
||||
import Code from '@/pages/user/Code.vue'
|
||||
import { isNewUser, jump2Feedback, sleep, useNav } from '@/utils'
|
||||
import Header from '@/components/Header.vue'
|
||||
import PopConfirm from '@/components/PopConfirm.vue'
|
||||
import { useExport } from '@/hooks/export.ts'
|
||||
import { getProgress, upload, uploadImportData } from '@/apis'
|
||||
import { Exception } from 'sass'
|
||||
import { CodeType, ImportStatus } from '@/types/enum.ts'
|
||||
|
||||
// 状态管理
|
||||
const userStore = useUserStore()
|
||||
@@ -41,28 +41,25 @@ let waitForImportConfirmation = $ref(true)
|
||||
|
||||
const QR_EXPIRE_TIME = 5 * 60 * 1000 // 5分钟过期
|
||||
|
||||
|
||||
let phoneLoginForm = $ref({phone: '', code: ''})
|
||||
let phoneLoginForm = $ref({ phone: '', code: '' })
|
||||
let phoneLoginFormRef = $ref<FormInstance>()
|
||||
let phoneLoginFormRules = {
|
||||
phone: phoneRules,
|
||||
code: codeRules
|
||||
code: codeRules,
|
||||
}
|
||||
|
||||
|
||||
let loginForm2 = $ref({account: '', password: ''})
|
||||
let loginForm2 = $ref({ account: '', password: '' })
|
||||
let loginForm2Ref = $ref<FormInstance>()
|
||||
let loginForm2Rules = {
|
||||
account: accountRules,
|
||||
password: passwordRules,
|
||||
}
|
||||
|
||||
|
||||
const registerForm = $ref({
|
||||
account: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
code: ''
|
||||
code: '',
|
||||
})
|
||||
let registerFormRef = $ref<FormInstance>()
|
||||
// 注册表单规则和引用
|
||||
@@ -71,23 +68,23 @@ let registerFormRules = {
|
||||
code: codeRules,
|
||||
password: passwordRules,
|
||||
confirmPassword: [
|
||||
{required: true, message: '请再次输入密码', trigger: 'blur'},
|
||||
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: any, value: any) => {
|
||||
if (value !== registerForm.password) {
|
||||
throw new Error('两次密码输入不一致')
|
||||
}
|
||||
}, trigger: 'blur'
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
const forgotForm = $ref({
|
||||
account: '',
|
||||
code: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
confirmPassword: '',
|
||||
})
|
||||
let forgotFormRef = $ref<FormInstance>()
|
||||
// 忘记密码表单规则和引用
|
||||
@@ -96,13 +93,14 @@ let forgotFormRules = {
|
||||
code: codeRules,
|
||||
newPassword: passwordRules,
|
||||
confirmPassword: [
|
||||
{required: true, message: '请再次输入新密码', trigger: 'blur'},
|
||||
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: any, value: any) => {
|
||||
if (value !== forgotForm.newPassword) {
|
||||
throw new Error('两次密码输入不一致')
|
||||
}
|
||||
}, trigger: 'blur'
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -122,17 +120,17 @@ function loginSuccess(token: string) {
|
||||
|
||||
// 统一登录处理
|
||||
async function handleLogin() {
|
||||
currentFormRef.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
currentFormRef.validate(async valid => {
|
||||
if (!valid) return
|
||||
try {
|
||||
loading = true
|
||||
let data = {}
|
||||
//手机号登录
|
||||
if (loginType === 'code') {
|
||||
data = {...phoneLoginForm, type: 'code'}
|
||||
data = { ...phoneLoginForm, type: 'code' }
|
||||
} else {
|
||||
//密码登录
|
||||
data = {...loginForm2, type: 'pwd'}
|
||||
data = { ...loginForm2, type: 'pwd' }
|
||||
}
|
||||
let res = await loginApi(data as LoginParams)
|
||||
if (res.success) {
|
||||
@@ -153,7 +151,7 @@ async function handleLogin() {
|
||||
|
||||
// 注册
|
||||
async function handleRegister() {
|
||||
registerFormRef.validate(async (valid) => {
|
||||
registerFormRef.validate(async valid => {
|
||||
if (!valid) return
|
||||
try {
|
||||
loading = true
|
||||
@@ -175,7 +173,7 @@ async function handleRegister() {
|
||||
|
||||
// 忘记密码
|
||||
async function handleForgotPassword() {
|
||||
forgotFormRef.validate(async (valid) => {
|
||||
forgotFormRef.validate(async valid => {
|
||||
if (!valid) return
|
||||
try {
|
||||
loading = true
|
||||
@@ -224,7 +222,8 @@ async function handleWechatLogin() {
|
||||
// wechatQRUrl = response.qrUrl
|
||||
|
||||
// 暂时使用占位二维码
|
||||
wechatQRUrl = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2Y1ZjVmNSIvPgogIDx0ZXh0IHg9IjUwJSIgeT0iNTAlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OTk5OSI+55So5o6l566h55CG6L295Lit6K+B77yBPC90ZXh0Pgo8L3N2Zz4K'
|
||||
wechatQRUrl =
|
||||
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2Y1ZjVmNSIvPgogIDx0ZXh0IHg9IjUwJSIgeT0iNTAlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OTk5OSI+55So5o6l566h55CG6L295Lit6K+B77yBPC90ZXh0Pgo8L3N2Zz4K'
|
||||
|
||||
// 模拟轮询检查扫码状态
|
||||
qrCheckInterval = setInterval(async () => {
|
||||
@@ -246,7 +245,6 @@ async function handleWechatLogin() {
|
||||
qrCheckInterval = null
|
||||
Toast.info('二维码已过期,请点击刷新')
|
||||
}, QR_EXPIRE_TIME)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Wechat login error:', error)
|
||||
Toast.error('微信登录失败')
|
||||
@@ -288,13 +286,13 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
enum ImportStep {
|
||||
CONFIRMATION,//等待确认
|
||||
PROCESSING,//处理中
|
||||
SUCCESS,//成功
|
||||
FAIL,//失败
|
||||
CONFIRMATION, //等待确认
|
||||
PROCESSING, //处理中
|
||||
SUCCESS, //成功
|
||||
FAIL, //失败
|
||||
}
|
||||
|
||||
const {exportData} = useExport()
|
||||
const { exportData } = useExport()
|
||||
let importStep = $ref<ImportStep>(ImportStep.CONFIRMATION)
|
||||
let isImporting = $ref(false)
|
||||
let reason = $ref('')
|
||||
@@ -311,9 +309,9 @@ async function startSync() {
|
||||
let res = await exportData('')
|
||||
reason = '上传数据中'
|
||||
let formData = new FormData()
|
||||
formData.append('file', res, "example.zip")
|
||||
formData.append('file', res, 'example.zip')
|
||||
let result = await uploadImportData(formData, progressEvent => {
|
||||
let percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
let percent = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
reason = `上传进度(${percent}%)`
|
||||
})
|
||||
if (result.success) {
|
||||
@@ -342,7 +340,7 @@ async function startSync() {
|
||||
}
|
||||
}, 2000)
|
||||
} else {
|
||||
throw new Error(`同步失败,${result.msg ? ('原因: ' + result.msg) : ''},请联系管理员`)
|
||||
throw new Error(`同步失败,${result.msg ? '原因: ' + result.msg : ''},请联系管理员`)
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.error(error.message || '同步失败')
|
||||
@@ -356,13 +354,9 @@ function logout() {
|
||||
waitForImportConfirmation = false
|
||||
}
|
||||
|
||||
function forgetData() {
|
||||
function forgetData() {}
|
||||
|
||||
}
|
||||
|
||||
function goHome(){
|
||||
|
||||
}
|
||||
function goHome() {}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -379,88 +373,83 @@ function goHome(){
|
||||
<!-- Tab切换 -->
|
||||
<div class="center gap-8 mb-6">
|
||||
<div
|
||||
class="center cp transition-colors"
|
||||
:class="loginType === 'code' ? 'link font-medium' : 'text-gray-600'"
|
||||
@click="loginType = 'code'"
|
||||
class="center cp transition-colors"
|
||||
:class="loginType === 'code' ? 'link font-medium' : 'text-gray-600'"
|
||||
@click="loginType = 'code'"
|
||||
>
|
||||
<div>
|
||||
<span>验证码登录</span>
|
||||
<div
|
||||
v-opacity="loginType === 'code'"
|
||||
class="mt-1 h-0.5 bg-blue-600"
|
||||
></div>
|
||||
<div v-opacity="loginType === 'code'" class="mt-1 h-0.5 bg-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="center cp transition-colors"
|
||||
:class="loginType === 'password' ? 'link font-medium' : 'text-gray-600'"
|
||||
@click="loginType = 'password'"
|
||||
class="center cp transition-colors"
|
||||
:class="loginType === 'password' ? 'link font-medium' : 'text-gray-600'"
|
||||
@click="loginType = 'password'"
|
||||
>
|
||||
<div>
|
||||
<span>密码登录</span>
|
||||
<div
|
||||
v-opacity="loginType === 'password'"
|
||||
class="mt-1 h-0.5 bg-blue-600"
|
||||
></div>
|
||||
<div v-opacity="loginType === 'password'" class="mt-1 h-0.5 bg-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证码登录表单 -->
|
||||
<Form
|
||||
v-if="loginType === 'code'"
|
||||
ref="phoneLoginFormRef"
|
||||
:rules="phoneLoginFormRules"
|
||||
:model="phoneLoginForm">
|
||||
v-if="loginType === 'code'"
|
||||
ref="phoneLoginFormRef"
|
||||
:rules="phoneLoginFormRules"
|
||||
:model="phoneLoginForm"
|
||||
>
|
||||
<FormItem prop="phone">
|
||||
<BaseInput v-model="phoneLoginForm.phone"
|
||||
type="tel"
|
||||
name="username"
|
||||
autocomplete="tel"
|
||||
size="large"
|
||||
placeholder="请输入手机号"
|
||||
<BaseInput
|
||||
v-model="phoneLoginForm.phone"
|
||||
type="tel"
|
||||
name="username"
|
||||
autocomplete="tel"
|
||||
size="large"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem prop="code">
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="phoneLoginForm.code"
|
||||
type="code"
|
||||
size="large"
|
||||
:max-length="PHONE_CONFIG.codeLength"
|
||||
placeholder="请输入验证码"
|
||||
v-model="phoneLoginForm.code"
|
||||
type="code"
|
||||
size="large"
|
||||
:max-length="PHONE_CONFIG.codeLength"
|
||||
placeholder="请输入验证码"
|
||||
/>
|
||||
<Code
|
||||
:validate-field="() => phoneLoginFormRef.validateField('phone')"
|
||||
:type="CodeType.Login"
|
||||
:val="phoneLoginForm.phone"
|
||||
/>
|
||||
<Code :validate-field="() => phoneLoginFormRef.validateField('phone')"
|
||||
:type="CodeType.Login"
|
||||
:val="phoneLoginForm.phone"/>
|
||||
</div>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<!-- 密码登录表单 -->
|
||||
<Form
|
||||
v-else
|
||||
ref="loginForm2Ref"
|
||||
:rules="loginForm2Rules"
|
||||
:model="loginForm2">
|
||||
<Form v-else ref="loginForm2Ref" :rules="loginForm2Rules" :model="loginForm2">
|
||||
<FormItem prop="account">
|
||||
<BaseInput v-model="loginForm2.account"
|
||||
type="email"
|
||||
name="username"
|
||||
autocomplete="email"
|
||||
size="large"
|
||||
placeholder="请输入手机号/邮箱地址"
|
||||
<BaseInput
|
||||
v-model="loginForm2.account"
|
||||
type="email"
|
||||
name="username"
|
||||
autocomplete="email"
|
||||
size="large"
|
||||
placeholder="请输入手机号/邮箱地址"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem prop="password">
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="loginForm2.password"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
size="large"
|
||||
placeholder="请输入密码"
|
||||
v-model="loginForm2.password"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
size="large"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
@@ -470,14 +459,7 @@ function goHome(){
|
||||
<span v-if="loginType === 'code'">,未注册的手机号将自动注册</span>
|
||||
</Notice>
|
||||
|
||||
<BaseButton
|
||||
class="w-full"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</BaseButton>
|
||||
<BaseButton class="w-full" size="large" :loading="loading" @click="handleLogin"> 登录 </BaseButton>
|
||||
|
||||
<!-- 底部操作链接 - 只在密码登录时显示 -->
|
||||
<div class="mt-4 flex justify-between text-sm" v-opacity="loginType !== 'code'">
|
||||
@@ -488,131 +470,116 @@ function goHome(){
|
||||
|
||||
<!-- 注册模式 -->
|
||||
<div v-else-if="currentMode === 'register'">
|
||||
<Header @click="switchMode('login')" title="注册新账号"/>
|
||||
<Header @click="switchMode('login')" title="注册新账号" />
|
||||
|
||||
<Form
|
||||
ref="registerFormRef"
|
||||
:rules="registerFormRules"
|
||||
:model="registerForm">
|
||||
<Form ref="registerFormRef" :rules="registerFormRules" :model="registerForm">
|
||||
<FormItem prop="account">
|
||||
<BaseInput
|
||||
v-model="registerForm.account"
|
||||
type="tel"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
size="large"
|
||||
placeholder="请输入手机号/邮箱地址"
|
||||
v-model="registerForm.account"
|
||||
type="tel"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
size="large"
|
||||
placeholder="请输入手机号/邮箱地址"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem prop="code">
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="registerForm.code"
|
||||
type="code"
|
||||
size="large"
|
||||
placeholder="请输入验证码"
|
||||
:max-length="PHONE_CONFIG.codeLength"
|
||||
v-model="registerForm.code"
|
||||
type="code"
|
||||
size="large"
|
||||
placeholder="请输入验证码"
|
||||
:max-length="PHONE_CONFIG.codeLength"
|
||||
/>
|
||||
<Code
|
||||
:validate-field="() => registerFormRef.validateField('account')"
|
||||
:type="CodeType.Register"
|
||||
:val="registerForm.account"
|
||||
/>
|
||||
<Code :validate-field="() => registerFormRef.validateField('account')"
|
||||
:type="CodeType.Register"
|
||||
:val="registerForm.account"/>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem prop="password">
|
||||
<BaseInput
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
size="large"
|
||||
:placeholder="`请设置密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
size="large"
|
||||
:placeholder="`请设置密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem prop="confirmPassword">
|
||||
<BaseInput
|
||||
v-model="registerForm.confirmPassword"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
size="large"
|
||||
placeholder="请再次输入密码"
|
||||
v-model="registerForm.confirmPassword"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
size="large"
|
||||
placeholder="请再次输入密码"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<Notice/>
|
||||
|
||||
<BaseButton
|
||||
class="w-full"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleRegister"
|
||||
>
|
||||
注册
|
||||
</BaseButton>
|
||||
<Notice />
|
||||
|
||||
<BaseButton class="w-full" size="large" :loading="loading" @click="handleRegister"> 注册 </BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- 忘记密码模式 -->
|
||||
<div v-else-if="currentMode === 'forgot'">
|
||||
<Header @click="switchMode('login')" title="重置密码"/>
|
||||
<Header @click="switchMode('login')" title="重置密码" />
|
||||
|
||||
<Form
|
||||
ref="forgotFormRef"
|
||||
:rules="forgotFormRules"
|
||||
:model="forgotForm">
|
||||
<Form ref="forgotFormRef" :rules="forgotFormRules" :model="forgotForm">
|
||||
<FormItem prop="account">
|
||||
<BaseInput
|
||||
v-model="forgotForm.account"
|
||||
type="tel"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
size="large"
|
||||
placeholder="请输入手机号/邮箱地址"
|
||||
v-model="forgotForm.account"
|
||||
type="tel"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
size="large"
|
||||
placeholder="请输入手机号/邮箱地址"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem prop="code">
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="forgotForm.code"
|
||||
type="code"
|
||||
size="large"
|
||||
placeholder="请输入验证码"
|
||||
:max-length="PHONE_CONFIG.codeLength"
|
||||
v-model="forgotForm.code"
|
||||
type="code"
|
||||
size="large"
|
||||
placeholder="请输入验证码"
|
||||
:max-length="PHONE_CONFIG.codeLength"
|
||||
/>
|
||||
<Code
|
||||
:validate-field="() => forgotFormRef.validateField('account')"
|
||||
:type="CodeType.ResetPwd"
|
||||
:val="forgotForm.account"
|
||||
/>
|
||||
<Code :validate-field="() => forgotFormRef.validateField('account')"
|
||||
:type="CodeType.ResetPwd"
|
||||
:val="forgotForm.account"/>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem prop="newPassword">
|
||||
<BaseInput
|
||||
v-model="forgotForm.newPassword"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
size="large"
|
||||
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
|
||||
v-model="forgotForm.newPassword"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
size="large"
|
||||
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem prop="confirmPassword">
|
||||
<BaseInput
|
||||
v-model="forgotForm.confirmPassword"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
size="large"
|
||||
placeholder="请再次输入新密码"
|
||||
v-model="forgotForm.confirmPassword"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
size="large"
|
||||
placeholder="请再次输入新密码"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<BaseButton
|
||||
class="w-full mt-2"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleForgotPassword"
|
||||
>
|
||||
<BaseButton class="w-full mt-2" size="large" :loading="loading" @click="handleForgotPassword">
|
||||
重置密码
|
||||
</BaseButton>
|
||||
</div>
|
||||
@@ -622,43 +589,42 @@ function goHome(){
|
||||
<div v-if="currentMode === 'login'" class="center flex-col bg-gray-100 rounded-xl px-12">
|
||||
<div class="relative w-40 h-40 bg-white rounded-xl overflow-hidden shadow-xl">
|
||||
<img
|
||||
v-if="showWechatQR"
|
||||
:src="wechatQRUrl"
|
||||
alt="微信登录二维码"
|
||||
class="w-full h-full"
|
||||
:class="{ 'opacity-30': qrStatus === 'expired' }"
|
||||
v-if="showWechatQR"
|
||||
:src="wechatQRUrl"
|
||||
alt="微信登录二维码"
|
||||
class="w-full h-full"
|
||||
:class="{ 'opacity-30': qrStatus === 'expired' }"
|
||||
/>
|
||||
<!-- 扫描成功蒙层 -->
|
||||
<div
|
||||
v-if="qrStatus === 'scanned'"
|
||||
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
|
||||
v-if="qrStatus === 'scanned'"
|
||||
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
|
||||
>
|
||||
<IconFluentCheckmarkCircle20Filled class="color-green text-4xl"/>
|
||||
<IconFluentCheckmarkCircle20Filled class="color-green text-4xl" />
|
||||
<div class="text-base text-gray-700 font-medium">扫描成功</div>
|
||||
<div class="text-xs text-gray-600">微信中轻触允许即可登录</div>
|
||||
</div>
|
||||
<!-- 取消登录蒙层 -->
|
||||
<div
|
||||
v-if="qrStatus === 'cancelled'"
|
||||
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
|
||||
v-if="qrStatus === 'cancelled'"
|
||||
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
|
||||
>
|
||||
<IconFluentErrorCircle20Regular class="color-red text-4xl"/>
|
||||
<IconFluentErrorCircle20Regular class="color-red text-4xl" />
|
||||
<div class="text-base text-gray-700 font-medium">你已取消此次登录</div>
|
||||
<div class="text-xs text-gray-600">你可<span class="color-link" @click="refreshQRCode">再次登录</span>,或关闭窗口
|
||||
<div class="text-xs text-gray-600">
|
||||
你可<span class="color-link" @click="refreshQRCode">再次登录</span>,或关闭窗口
|
||||
</div>
|
||||
</div>
|
||||
<!-- 过期蒙层 -->
|
||||
<div
|
||||
v-if=" qrStatus === 'expired'"
|
||||
class="absolute top-0 left-0 right-0 bottom-0 bg-opacity-95 center backdrop-blur-sm"
|
||||
v-if="qrStatus === 'expired'"
|
||||
class="absolute top-0 left-0 right-0 bottom-0 bg-opacity-95 center backdrop-blur-sm"
|
||||
>
|
||||
<IconFluentArrowClockwise20Regular
|
||||
@click="refreshQRCode"
|
||||
class="cp text-4xl"/>
|
||||
<IconFluentArrowClockwise20Regular @click="refreshQRCode" class="cp text-4xl" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 center gap-space">
|
||||
<IconIxWechatLogo class="text-xl color-green"/>
|
||||
<IconIxWechatLogo class="text-xl color-green" />
|
||||
<span class="text-sm text-gray-600">微信扫码登录</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -675,12 +641,13 @@ function goHome(){
|
||||
</div>
|
||||
<div class="flex gap-space justify-end">
|
||||
<template v-if="importStep === ImportStep.CONFIRMATION">
|
||||
<PopConfirm :title="[
|
||||
{text:'您的用户数据将以压缩包自动下载到您的电脑中,以便您随时恢复',type:'normal'},
|
||||
{text:'随后网站的用户数据将被删除',type:'redBold'},
|
||||
{text:'是否确认继续?',type:'normal'},
|
||||
]"
|
||||
@confirm="forgetData"
|
||||
<PopConfirm
|
||||
:title="[
|
||||
{ text: '您的用户数据将以压缩包自动下载到您的电脑中,以便您随时恢复', type: 'normal' },
|
||||
{ text: '随后网站的用户数据将被删除', type: 'redBold' },
|
||||
{ text: '是否确认继续?', type: 'normal' },
|
||||
]"
|
||||
@confirm="forgetData"
|
||||
>
|
||||
<BaseButton type="info">不同步</BaseButton>
|
||||
</PopConfirm>
|
||||
@@ -692,22 +659,14 @@ function goHome(){
|
||||
<div>
|
||||
<div class="title text-align-center">正在导入中</div>
|
||||
<ol class="pl-4">
|
||||
<li>
|
||||
您的用户数据已自动下载到您的电脑中,以便随时恢复
|
||||
</li>
|
||||
<li>
|
||||
随后将开始数据同步
|
||||
</li>
|
||||
<li>
|
||||
如果您的数据量很大,这将是一个耗时操作
|
||||
</li>
|
||||
<li class="color-red-5 font-bold">
|
||||
请耐心等待,请勿关闭此页面
|
||||
</li>
|
||||
<li>您的用户数据已自动下载到您的电脑中,以便随时恢复</li>
|
||||
<li>随后将开始数据同步</li>
|
||||
<li>如果您的数据量很大,这将是一个耗时操作</li>
|
||||
<li class="color-red-5 font-bold">请耐心等待,请勿关闭此页面</li>
|
||||
</ol>
|
||||
<div class="flex items-center justify-between gap-2 mt-10">
|
||||
<span>当前进度: {{ reason }}</span>
|
||||
<IconEosIconsLoading class="text-xl"/>
|
||||
<IconEosIconsLoading class="text-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -737,4 +696,5 @@ function goHome(){
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>]
|
||||
</template>
|
||||
]
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<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'
|
||||
@@ -15,7 +13,7 @@ import Form from '@/components/base/form/Form.vue'
|
||||
import FormItem from '@/components/base/form/FormItem.vue'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import DeleteIcon from '@/components/icon/DeleteIcon.vue'
|
||||
import { AppEnv, LIB_JS_URL, PracticeSaveWordKey, TourConfig } from '@/config/env.ts'
|
||||
import { AppEnv, DictId, LIB_JS_URL, TourConfig } from '@/config/env.ts'
|
||||
import { getCurrentStudyWord } from '@/hooks/dict.ts'
|
||||
import EditBook from '@/pages/article/components/EditBook.vue'
|
||||
import PracticeSettingDialog from '@/pages/word/components/PracticeSettingDialog.vue'
|
||||
@@ -23,22 +21,15 @@ 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 { Sort } from '@/types/enum.ts'
|
||||
|
||||
const runtimeStore = useRuntimeStore()
|
||||
const base = useBaseStore()
|
||||
@@ -80,13 +71,10 @@ 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'
|
||||
temp.id += '_custom_' + nanoid(6)
|
||||
}
|
||||
}
|
||||
temp.length = temp.words.length
|
||||
@@ -192,17 +180,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')
|
||||
@@ -289,7 +273,7 @@ const { nav } = useNav()
|
||||
|
||||
//todo 可以和首页合并
|
||||
async function startPractice(query = {}) {
|
||||
localStorage.removeItem(PracticeSaveWordKey.key)
|
||||
localStorage.removeItem(PRACTICE_WORD_CACHE.key)
|
||||
studyLoading = true
|
||||
await base.changeDict(runtimeStore.editDict)
|
||||
studyLoading = false
|
||||
@@ -520,10 +504,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
|
||||
|
||||
//如果没数据则请求
|
||||
@@ -581,50 +562,40 @@ 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}>
|
||||
测试
|
||||
</BaseButton>
|
||||
<BaseButton id="study" loading={studyLoading || loading} onClick={addMyStudyList}>
|
||||
学习
|
||||
</BaseButton>
|
||||
<BaseButton loading={studyLoading || loading} onClick={startTest}>
|
||||
测试
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg mt-2">介绍:{runtimeStore.editDict.description}</div>
|
||||
<div class="line my-3"></div>
|
||||
{dict.description && (
|
||||
<>
|
||||
<div class="text-lg mt-2">介绍:{dict.description}</div>
|
||||
<div class="line my-3"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 移动端标签页导航 */}
|
||||
{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"
|
||||
@@ -640,6 +611,8 @@ defineRender(() => {
|
||||
{val => (
|
||||
<WordItem
|
||||
showTransPop={false}
|
||||
onClick={() => editWord(val.item)}
|
||||
index={val.index}
|
||||
showCollectIcon={false}
|
||||
showMarkIcon={false}
|
||||
item={val.item}
|
||||
@@ -648,11 +621,7 @@ defineRender(() => {
|
||||
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])}>
|
||||
@@ -668,9 +637,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"
|
||||
@@ -680,22 +647,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
|
||||
@@ -776,12 +734,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>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { _nextTick, groupBy, isMobile, loadJsLib, resourceWrap, useNav } from "@/utils";
|
||||
import BasePage from "@/components/BasePage.vue";
|
||||
import { DictResource } from "@/types/types.ts";
|
||||
import type { DictResource } from "@/types/types.ts";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import Empty from "@/components/Empty.vue";
|
||||
@@ -58,7 +58,7 @@ const groupedByCategoryAndTag = $computed(() => {
|
||||
data.push([key, groupByDictTags(value)])
|
||||
}
|
||||
[data[2], data[3]] = [data[3], data[2]];
|
||||
console.log('data', data)
|
||||
// console.log('data', data)
|
||||
return data
|
||||
})
|
||||
|
||||
@@ -138,7 +138,7 @@ watch(dict_list, (val) => {
|
||||
v-if="searchList.length "
|
||||
@selectDict="selectDict"
|
||||
:list="searchList"
|
||||
quantifier="个词"
|
||||
quantifier="词"
|
||||
:select-id="'-1'"/>
|
||||
<Empty v-else text="没有相关词典"/>
|
||||
</div>
|
||||
@@ -147,7 +147,7 @@ watch(dict_list, (val) => {
|
||||
v-for="item in groupedByCategoryAndTag"
|
||||
:select-id="store.sdict.id"
|
||||
@selectDict="selectDict"
|
||||
quantifier="个词"
|
||||
quantifier="词"
|
||||
:groupByTag="item[1]"
|
||||
:category="item[0]"
|
||||
/>
|
||||
|
||||
@@ -1,35 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
||||
|
||||
import Statistics from '@/pages/word/Statistics.vue'
|
||||
import Statistics from '@/pages/word/components/Statistics.vue'
|
||||
import { emitter, EventKey, useEvents } from '@/utils/eventBus.ts'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { useRuntimeStore } from '@/stores/runtime.ts'
|
||||
import {
|
||||
Dict,
|
||||
PracticeData,
|
||||
ShortcutKey,
|
||||
TaskWords,
|
||||
Word,
|
||||
WordPracticeMode,
|
||||
WordPracticeType,
|
||||
} from '@/types/types.ts'
|
||||
import {
|
||||
useDisableEventListener,
|
||||
useOnKeyboardEventListener,
|
||||
useStartKeyboardEventListener,
|
||||
} from '@/hooks/event.ts'
|
||||
import type { Dict, PracticeData, TaskWords, Word } from '@/types/types.ts'
|
||||
import { useDisableEventListener, useOnKeyboardEventListener, useStartKeyboardEventListener } from '@/hooks/event.ts'
|
||||
import useTheme from '@/hooks/theme.ts'
|
||||
import { getCurrentStudyWord, useWordOptions } from '@/hooks/dict.ts'
|
||||
import {
|
||||
_getDictDataByUrl,
|
||||
_nextTick,
|
||||
cloneDeep,
|
||||
isMobile,
|
||||
loadJsLib,
|
||||
resourceWrap,
|
||||
shuffle,
|
||||
} from '@/utils'
|
||||
import { _getDictDataByUrl, _nextTick, cloneDeep, isMobile, loadJsLib, resourceWrap, shuffle } from '@/utils'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Footer from '@/pages/word/components/Footer.vue'
|
||||
import Panel from '@/components/Panel.vue'
|
||||
@@ -45,10 +25,13 @@ import { getDefaultDict, getDefaultWord } from '@/types/func.ts'
|
||||
import ConflictNotice from '@/components/ConflictNotice.vue'
|
||||
import PracticeLayout from '@/components/PracticeLayout.vue'
|
||||
|
||||
import { AppEnv, DICT_LIST, LIB_JS_URL, PracticeSaveWordKey, TourConfig } from '@/config/env.ts'
|
||||
import { AppEnv, DICT_LIST, LIB_JS_URL, TourConfig, WordPracticeModeStageMap } from '@/config/env.ts'
|
||||
import { ToastInstance } from '@/components/base/toast/type.ts'
|
||||
import { watchOnce } from '@vueuse/core'
|
||||
import { setUserDictProp } from '@/apis'
|
||||
import GroupList from '@/pages/word/components/GroupList.vue'
|
||||
import { getPracticeWordCache, setPracticeWordCache } from '@/utils/cache.ts'
|
||||
import { ShortcutKey, WordPracticeMode, WordPracticeStage, WordPracticeType } from '@/types/enum.ts'
|
||||
|
||||
const { isWordCollect, toggleWordCollect, isWordSimple, toggleWordSimple } = useWordOptions()
|
||||
const settingStore = useSettingStore()
|
||||
@@ -113,11 +96,11 @@ async function loadDict() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.load,
|
||||
n => {
|
||||
if (n && loading) loadDict()
|
||||
},
|
||||
{ immediate: true }
|
||||
() => store.load,
|
||||
n => {
|
||||
if (n && loading) loadDict()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -138,111 +121,137 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
let cache = getPracticeWordCache()
|
||||
if (cache) {
|
||||
savePracticeData()
|
||||
}
|
||||
timer && clearInterval(timer)
|
||||
})
|
||||
|
||||
watchOnce(
|
||||
() => data.words.length,
|
||||
(newVal, oldVal) => {
|
||||
//如果是从无值变有值,代表是开始
|
||||
if (!oldVal && newVal) {
|
||||
_nextTick(async () => {
|
||||
const Shepherd = await loadJsLib('Shepherd', LIB_JS_URL.SHEPHERD)
|
||||
const tour = new Shepherd.Tour(TourConfig)
|
||||
tour.on('cancel', () => {
|
||||
localStorage.setItem('tour-guide', '1')
|
||||
})
|
||||
tour.addStep({
|
||||
id: 'step5',
|
||||
text: '这里可以练习拼写单词,只需要按下键盘上对应的按键即可,没有输入框!',
|
||||
attachTo: { element: '#word', on: 'bottom' },
|
||||
buttons: [
|
||||
{
|
||||
text: `下一步(5/${TourConfig.total})`,
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
})
|
||||
() => data.words.length,
|
||||
(newVal, oldVal) => {
|
||||
//如果是从无值变有值,代表是开始
|
||||
if (!oldVal && newVal) {
|
||||
_nextTick(async () => {
|
||||
const Shepherd = await loadJsLib('Shepherd', LIB_JS_URL.SHEPHERD)
|
||||
const tour = new Shepherd.Tour(TourConfig)
|
||||
tour.on('cancel', () => {
|
||||
localStorage.setItem('tour-guide', '1')
|
||||
})
|
||||
tour.addStep({
|
||||
id: 'step5',
|
||||
text: '这里可以练习拼写单词,只需要按下键盘上对应的按键即可,没有输入框!',
|
||||
attachTo: { element: '#word', on: 'bottom' },
|
||||
buttons: [
|
||||
{
|
||||
text: `下一步(5/${TourConfig.total})`,
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
tour.addStep({
|
||||
id: 'step6',
|
||||
text: '这里是文章练习',
|
||||
attachTo: { element: '#article', on: 'top' },
|
||||
buttons: [
|
||||
{
|
||||
text: `下一步(6/${TourConfig.total})`,
|
||||
action() {
|
||||
tour.next()
|
||||
router.push('/articles')
|
||||
},
|
||||
tour.addStep({
|
||||
id: 'step6',
|
||||
text: '这里是文章练习',
|
||||
attachTo: { element: '#article', on: 'top' },
|
||||
buttons: [
|
||||
{
|
||||
text: `下一步(6/${TourConfig.total})`,
|
||||
action() {
|
||||
tour.next()
|
||||
router.push('/articles')
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const r = localStorage.getItem('tour-guide')
|
||||
if (settingStore.first && !r && !isMobile()) {
|
||||
tour.start()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
const r = localStorage.getItem('tour-guide')
|
||||
if (settingStore.first && !r && !isMobile()) {
|
||||
tour.start()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
useStartKeyboardEventListener()
|
||||
useDisableEventListener(() => loading)
|
||||
|
||||
function initData(initVal: TaskWords, init: boolean = false) {
|
||||
let d = localStorage.getItem(PracticeSaveWordKey.key)
|
||||
let d = getPracticeWordCache()
|
||||
if (d && init) {
|
||||
try {
|
||||
let obj = JSON.parse(d)
|
||||
let s = obj.val
|
||||
taskWords = Object.assign(taskWords, s.taskWords)
|
||||
//这里直接赋值的话,provide后的inject获取不到最新值
|
||||
data = Object.assign(data, s.practiceData)
|
||||
statStore.$patch(s.statStoreData)
|
||||
} catch (e) {
|
||||
localStorage.removeItem(PracticeSaveWordKey.key)
|
||||
initData(initVal, true)
|
||||
}
|
||||
taskWords = Object.assign(taskWords, d.taskWords)
|
||||
//这里直接赋值的话,provide后的inject获取不到最新值
|
||||
data = Object.assign(data, d.practiceData)
|
||||
statStore.$patch(d.statStoreData)
|
||||
} else {
|
||||
// taskWords = initVal
|
||||
//不能直接赋值,会导致 inject 的数据为默认值
|
||||
taskWords = Object.assign(taskWords, initVal)
|
||||
//如果 shuffle 数组不为空,就说明是复习
|
||||
if (taskWords.shuffle.length === 0) {
|
||||
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.Shuffle) {
|
||||
settingStore.wordPracticeType = WordPracticeType.Dictation
|
||||
data.words = taskWords.shuffle
|
||||
statStore.stage = WordPracticeStage.Shuffle
|
||||
statStore.total = taskWords.shuffle.length
|
||||
statStore.newWordNumber = 0
|
||||
statStore.reviewWordNumber = 0
|
||||
statStore.writeWordNumber = statStore.total
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.Review) {
|
||||
if (taskWords.review.length) {
|
||||
data.words = taskWords.review
|
||||
statStore.stage = WordPracticeStage.IdentifyReview
|
||||
} else if (taskWords.write.length) {
|
||||
data.words = taskWords.write
|
||||
statStore.stage = WordPracticeStage.IdentifyReviewAll
|
||||
}
|
||||
statStore.total = taskWords.review.length + taskWords.write.length
|
||||
statStore.newWordNumber = 0
|
||||
statStore.reviewWordNumber = taskWords.review.length
|
||||
statStore.writeWordNumber = taskWords.write.length
|
||||
} else {
|
||||
if (taskWords.new.length === 0) {
|
||||
if (taskWords.review.length) {
|
||||
settingStore.wordPracticeType = WordPracticeType.Identify
|
||||
statStore.step = 3
|
||||
data.words = taskWords.review
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.System) {
|
||||
statStore.stage = WordPracticeStage.IdentifyReview
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.Free) {
|
||||
statStore.stage = WordPracticeModeStageMap[settingStore.wordPracticeMode][0]
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.IdentifyOnly) {
|
||||
statStore.stage = WordPracticeStage.IdentifyReview
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.DictationOnly) {
|
||||
statStore.stage = WordPracticeStage.DictationReview
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.ListenOnly) {
|
||||
statStore.stage = WordPracticeStage.ListenReview
|
||||
}
|
||||
} else {
|
||||
if (taskWords.write.length) {
|
||||
settingStore.wordPracticeType = WordPracticeType.Identify
|
||||
data.words = taskWords.write
|
||||
statStore.step = 6
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.System) {
|
||||
statStore.stage = WordPracticeStage.IdentifyReviewAll
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.Free) {
|
||||
statStore.stage = WordPracticeModeStageMap[settingStore.wordPracticeMode][0]
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.IdentifyOnly) {
|
||||
statStore.stage = WordPracticeStage.IdentifyReviewAll
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.DictationOnly) {
|
||||
statStore.stage = WordPracticeStage.DictationReviewAll
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.ListenOnly) {
|
||||
statStore.stage = WordPracticeStage.ListenReviewAll
|
||||
}
|
||||
} else {
|
||||
Toast.warning('没有可学习的单词!')
|
||||
router.push('/word')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settingStore.wordPracticeType = WordPracticeType.FollowWrite
|
||||
data.words = taskWords.new
|
||||
statStore.step = 0
|
||||
statStore.stage = WordPracticeModeStageMap[settingStore.wordPracticeMode][0]
|
||||
}
|
||||
statStore.total = taskWords.review.length + taskWords.new.length + taskWords.write.length
|
||||
statStore.newWordNumber = taskWords.new.length
|
||||
statStore.reviewWordNumber = taskWords.review.length
|
||||
statStore.writeWordNumber = taskWords.write.length
|
||||
} else {
|
||||
settingStore.wordPracticeType = WordPracticeType.Dictation
|
||||
data.words = taskWords.shuffle
|
||||
statStore.step = 10
|
||||
statStore.total = taskWords.shuffle.length
|
||||
statStore.newWordNumber = 0
|
||||
statStore.reviewWordNumber = 0
|
||||
statStore.writeWordNumber = statStore.total
|
||||
}
|
||||
|
||||
data.index = 0
|
||||
@@ -259,7 +268,6 @@ function initData(initVal: TaskWords, init: boolean = false) {
|
||||
timer = setInterval(() => {
|
||||
if (isFocus) {
|
||||
statStore.spend += 1000
|
||||
savePracticeData()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
@@ -275,30 +283,60 @@ const nextWord: Word = $computed(() => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => settingStore.wordPracticeType,
|
||||
n => {
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.Free) return
|
||||
switch (n) {
|
||||
case WordPracticeType.Spell:
|
||||
case WordPracticeType.Dictation:
|
||||
settingStore.dictation = true
|
||||
settingStore.translate = true
|
||||
break
|
||||
case WordPracticeType.Listen:
|
||||
settingStore.dictation = true
|
||||
settingStore.translate = false
|
||||
break
|
||||
case WordPracticeType.FollowWrite:
|
||||
settingStore.dictation = false
|
||||
settingStore.translate = true
|
||||
break
|
||||
case WordPracticeType.Identify:
|
||||
settingStore.dictation = false
|
||||
settingStore.translate = false
|
||||
break
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
() => settingStore.wordPracticeType,
|
||||
n => {
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.Free) return
|
||||
switch (n) {
|
||||
case WordPracticeType.Spell:
|
||||
case WordPracticeType.Dictation:
|
||||
settingStore.dictation = true
|
||||
settingStore.translate = true
|
||||
break
|
||||
case WordPracticeType.Listen:
|
||||
settingStore.dictation = true
|
||||
settingStore.translate = false
|
||||
break
|
||||
case WordPracticeType.FollowWrite:
|
||||
settingStore.dictation = false
|
||||
settingStore.translate = true
|
||||
break
|
||||
case WordPracticeType.Identify:
|
||||
settingStore.dictation = false
|
||||
settingStore.translate = false
|
||||
break
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => statStore.stage,
|
||||
n => {
|
||||
switch (n) {
|
||||
case WordPracticeStage.DictationNewWord:
|
||||
case WordPracticeStage.DictationReview:
|
||||
case WordPracticeStage.DictationReviewAll:
|
||||
case WordPracticeStage.Shuffle:
|
||||
settingStore.wordPracticeType = WordPracticeType.Dictation
|
||||
break
|
||||
case WordPracticeStage.ListenNewWord:
|
||||
case WordPracticeStage.ListenReview:
|
||||
case WordPracticeStage.ListenReviewAll:
|
||||
settingStore.wordPracticeType = WordPracticeType.Listen
|
||||
break
|
||||
case WordPracticeStage.FollowWriteNewWord:
|
||||
case WordPracticeStage.FollowWriteReview:
|
||||
case WordPracticeStage.FollowWriteReviewAll:
|
||||
settingStore.wordPracticeType = WordPracticeType.FollowWrite
|
||||
break
|
||||
case WordPracticeStage.IdentifyNewWord:
|
||||
case WordPracticeStage.IdentifyReview:
|
||||
case WordPracticeStage.IdentifyReviewAll:
|
||||
settingStore.wordPracticeType = WordPracticeType.Identify
|
||||
break
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const groupSize = 7
|
||||
@@ -324,29 +362,30 @@ function wordLoop() {
|
||||
|
||||
let toastInstance: ToastInstance = null
|
||||
|
||||
function goNextStep(originList, mode, msg) {
|
||||
function nextStage(originList: Word[], log: string = '', toast: boolean = false) {
|
||||
//每次都判断,因为每次都可能新增已掌握的单词
|
||||
let list = originList.filter(v => !data.excludeWords.includes(v.word))
|
||||
console.log(msg)
|
||||
console.log(log)
|
||||
statStore.stage = statStore.nextStage
|
||||
if (list.length) {
|
||||
if (toastInstance) toastInstance.close()
|
||||
toastInstance = Toast.info('输入完成后按空格键切换下一个', { duration: 5000 })
|
||||
if (toast) {
|
||||
if (toastInstance) toastInstance.close()
|
||||
toastInstance = Toast.info('输入完成后按空格键切换下一个', { duration: 5000 })
|
||||
}
|
||||
data.words = list
|
||||
settingStore.wordPracticeType = mode
|
||||
data.index = 0
|
||||
statStore.step++
|
||||
} else {
|
||||
console.log(msg + ':无单词略过')
|
||||
statStore.step += 3
|
||||
next()
|
||||
console.log(log + ':无单词略过')
|
||||
next(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function next(isTyping: boolean = true) {
|
||||
debugger
|
||||
if (isTyping) statStore.inputWordNumber++
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.Free) {
|
||||
if (data.index === data.words.length - 1) {
|
||||
data.wrongWords = data.wrongWords.filter(v => (!data.excludeWords.includes(v.word)))
|
||||
data.wrongWords = data.wrongWords.filter(v => !data.excludeWords.includes(v.word))
|
||||
if (data.wrongWords.length) {
|
||||
isTypingWrongWord.value = true
|
||||
settingStore.wordPracticeType = WordPracticeType.FollowWrite
|
||||
@@ -358,23 +397,26 @@ async function next(isTyping: boolean = true) {
|
||||
console.log('自由模式,全完学完了')
|
||||
showStatDialog = true
|
||||
clearInterval(timer)
|
||||
setTimeout(() => localStorage.removeItem(PracticeSaveWordKey.key), 300)
|
||||
setTimeout(() => setPracticeWordCache(null), 300)
|
||||
}
|
||||
} else {
|
||||
data.index++
|
||||
}
|
||||
} else {
|
||||
if (data.index === data.words.length - 1) {
|
||||
if (statStore.step === 0 || isTypingWrongWord.value) {
|
||||
//如果手动敲的,才轮询
|
||||
if ((statStore.stage === WordPracticeStage.FollowWriteNewWord || isTypingWrongWord.value) && isTyping) {
|
||||
if (settingStore.wordPracticeType !== WordPracticeType.Spell) {
|
||||
//回到最后一组的开始位置
|
||||
data.index = Math.floor(data.index / groupSize) * groupSize
|
||||
emitter.emit(EventKey.resetWord)
|
||||
settingStore.wordPracticeType = WordPracticeType.Spell
|
||||
//如果单词是已掌握的,则跳过
|
||||
if (isWordSimple(word)) next(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
data.wrongWords = data.wrongWords.filter(v => (!data.excludeWords.includes(v.word)))
|
||||
data.wrongWords = data.wrongWords.filter(v => !data.excludeWords.includes(v.word))
|
||||
if (data.wrongWords.length) {
|
||||
isTypingWrongWord.value = true
|
||||
settingStore.wordPracticeType = WordPracticeType.FollowWrite
|
||||
@@ -384,58 +426,71 @@ async function next(isTyping: boolean = true) {
|
||||
data.wrongWords = []
|
||||
} else {
|
||||
isTypingWrongWord.value = false
|
||||
console.log('当前学完了,没错词', statStore.total, statStore.step, data.index)
|
||||
//学完了,这里第 7 步如果无单词,加 3 就是 9 了
|
||||
if (statStore.step >= 8) {
|
||||
console.log('当前学完了,没错词', statStore.total, statStore.stage, data.index)
|
||||
|
||||
const complete = () => {
|
||||
console.log('全完学完了')
|
||||
showStatDialog = true
|
||||
clearInterval(timer)
|
||||
setTimeout(() => localStorage.removeItem(PracticeSaveWordKey.key), 300)
|
||||
return;
|
||||
setTimeout(() => setPracticeWordCache(null), 300)
|
||||
}
|
||||
|
||||
//开始默写之前
|
||||
if (statStore.step === 7) {
|
||||
return goNextStep(shuffle(taskWords.write), WordPracticeType.Dictation, '开始默写之前')
|
||||
}
|
||||
|
||||
//开始听写之前
|
||||
if (statStore.step === 6) {
|
||||
return goNextStep(shuffle(taskWords.write), WordPracticeType.Listen, '开始听写之前')
|
||||
}
|
||||
|
||||
//开始自测之前
|
||||
if (statStore.step === 5) {
|
||||
return goNextStep(taskWords.write, WordPracticeType.Identify, '开始自测之前')
|
||||
}
|
||||
|
||||
//开始默写上次
|
||||
if (statStore.step === 4) {
|
||||
return goNextStep(shuffle(taskWords.review), WordPracticeType.Dictation, '开始默写上次')
|
||||
}
|
||||
|
||||
//开始听写上次
|
||||
if (statStore.step === 3) {
|
||||
return goNextStep(shuffle(taskWords.review), WordPracticeType.Listen, '开始听写上次')
|
||||
}
|
||||
|
||||
//开始自测昨日
|
||||
if (statStore.step === 2) {
|
||||
return goNextStep(taskWords.review, WordPracticeType.Identify, '开始自测昨日')
|
||||
}
|
||||
|
||||
//开始默写新词
|
||||
if (statStore.step === 1) {
|
||||
return goNextStep(shuffle(taskWords.new), WordPracticeType.Dictation, '开始默写新词')
|
||||
}
|
||||
|
||||
//开始听写新词
|
||||
if (statStore.step === 0) {
|
||||
return goNextStep(shuffle(taskWords.new), WordPracticeType.Listen, '开始听写新词')
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.System) {
|
||||
if (statStore.stage === WordPracticeStage.FollowWriteNewWord) {
|
||||
nextStage(shuffle(taskWords.new), '开始听写新词', true)
|
||||
} else if (statStore.stage === WordPracticeStage.ListenNewWord) {
|
||||
nextStage(shuffle(taskWords.new), '开始默写新词')
|
||||
} else if (statStore.stage === WordPracticeStage.DictationNewWord) {
|
||||
nextStage(taskWords.review, '开始自测昨日')
|
||||
} else if (statStore.stage === WordPracticeStage.IdentifyReview) {
|
||||
nextStage(shuffle(taskWords.review), '开始听写上次', true)
|
||||
} else if (statStore.stage === WordPracticeStage.ListenReview) {
|
||||
nextStage(shuffle(taskWords.review), '开始默写上次')
|
||||
} else if (statStore.stage === WordPracticeStage.DictationReview) {
|
||||
nextStage(taskWords.write, '开始自测之前')
|
||||
} else if (statStore.stage === WordPracticeStage.IdentifyReviewAll) {
|
||||
nextStage(shuffle(taskWords.write), '开始听写之前', true)
|
||||
} else if (statStore.stage === WordPracticeStage.ListenReviewAll) {
|
||||
nextStage(shuffle(taskWords.write), '开始默写之前')
|
||||
} else if (statStore.stage === WordPracticeStage.DictationReviewAll) {
|
||||
complete()
|
||||
}
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.ListenOnly) {
|
||||
if (statStore.stage === WordPracticeStage.ListenNewWord) {
|
||||
nextStage(taskWords.review, '开始听写昨日', true)
|
||||
} else if (statStore.stage === WordPracticeStage.ListenReview) {
|
||||
nextStage(taskWords.write, '开始听写之前')
|
||||
} else if (statStore.stage === WordPracticeStage.ListenReviewAll) complete()
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.DictationOnly) {
|
||||
if (statStore.stage === WordPracticeStage.DictationNewWord) {
|
||||
nextStage(taskWords.review, '开始默写昨日', true)
|
||||
} else if (statStore.stage === WordPracticeStage.DictationReview) {
|
||||
nextStage(taskWords.write, '开始默写之前')
|
||||
} else if (statStore.stage === WordPracticeStage.DictationReviewAll) complete()
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.IdentifyOnly) {
|
||||
if (statStore.stage === WordPracticeStage.IdentifyNewWord) {
|
||||
nextStage(taskWords.review, '开始自测昨日')
|
||||
} else if (statStore.stage === WordPracticeStage.IdentifyReview) {
|
||||
nextStage(taskWords.write, '开始自测之前')
|
||||
} else if (statStore.stage === WordPracticeStage.IdentifyReviewAll) complete()
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.Shuffle) {
|
||||
if (statStore.stage === WordPracticeStage.Shuffle) complete()
|
||||
} else if (settingStore.wordPracticeMode === WordPracticeMode.Review) {
|
||||
if (statStore.stage === WordPracticeStage.IdentifyReview) {
|
||||
nextStage(shuffle(taskWords.review), '开始听写昨日', true)
|
||||
} else if (statStore.stage === WordPracticeStage.ListenReview) {
|
||||
nextStage(shuffle(taskWords.review), '开始默写昨日')
|
||||
} else if (statStore.stage === WordPracticeStage.DictationReview) {
|
||||
nextStage(taskWords.write, '开始自测之前')
|
||||
} else if (statStore.stage === WordPracticeStage.IdentifyReviewAll) {
|
||||
nextStage(shuffle(taskWords.write), '开始听写之前', true)
|
||||
} else if (statStore.stage === WordPracticeStage.ListenReviewAll) {
|
||||
nextStage(shuffle(taskWords.write), '开始默写之前')
|
||||
} else if (statStore.stage === WordPracticeStage.DictationReviewAll) complete()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (statStore.step === 0) {
|
||||
if (statStore.stage === WordPracticeStage.FollowWriteNewWord) {
|
||||
wordLoop()
|
||||
} else {
|
||||
if (isTypingWrongWord.value) wordLoop()
|
||||
@@ -443,12 +498,12 @@ async function next(isTyping: boolean = true) {
|
||||
}
|
||||
}
|
||||
}
|
||||
savePracticeData()
|
||||
//如果单词是已掌握的,则跳过
|
||||
if (isWordSimple(word)) next(false)
|
||||
}
|
||||
|
||||
function skipStep() {
|
||||
data.index = data.words.length - 1
|
||||
settingStore.wordPracticeType = WordPracticeType.Spell
|
||||
data.wrongWords = []
|
||||
next(false)
|
||||
}
|
||||
@@ -478,18 +533,11 @@ function onTypeWrong() {
|
||||
}
|
||||
|
||||
function savePracticeData() {
|
||||
// console.log('savePracticeData')
|
||||
localStorage.setItem(
|
||||
PracticeSaveWordKey.key,
|
||||
JSON.stringify({
|
||||
version: PracticeSaveWordKey.version,
|
||||
val: {
|
||||
taskWords,
|
||||
practiceData: data,
|
||||
statStoreData: statStore.$state,
|
||||
},
|
||||
})
|
||||
)
|
||||
setPracticeWordCache({
|
||||
taskWords,
|
||||
practiceData: data,
|
||||
statStoreData: statStore.$state,
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => data.index, savePracticeData)
|
||||
@@ -512,21 +560,15 @@ useOnKeyboardEventListener(onKeyDown, onKeyUp)
|
||||
|
||||
function repeat() {
|
||||
console.log('重学一遍')
|
||||
setPracticeWordCache(null)
|
||||
let temp = cloneDeep(taskWords)
|
||||
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
|
||||
//随机练习单独处理
|
||||
if (taskWords.shuffle.length) {
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.Shuffle) {
|
||||
temp.shuffle = shuffle(temp.shuffle.filter(v => !ignoreList.includes(v.word)))
|
||||
} else {
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.System) settingStore.dictation = false
|
||||
if (store.sdict.lastLearnIndex === 0 && store.sdict.complete) {
|
||||
//如果是刚刚完成,那么学习进度要从length减回去,因为lastLearnIndex为0了,同时改complete为false
|
||||
store.sdict.lastLearnIndex = store.sdict.length - statStore.newWordNumber
|
||||
store.sdict.complete = false
|
||||
} else {
|
||||
//将学习进度减回去
|
||||
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex - statStore.newWordNumber
|
||||
}
|
||||
//将学习进度减回去
|
||||
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex - statStore.newWordNumber
|
||||
//排除已掌握单词
|
||||
temp.new = temp.new.filter(v => !ignoreList.includes(v.word))
|
||||
temp.review = temp.review.filter(v => !ignoreList.includes(v.word))
|
||||
@@ -594,25 +636,34 @@ function togglePanel() {
|
||||
}
|
||||
|
||||
async function continueStudy() {
|
||||
setPracticeWordCache(null)
|
||||
let temp = cloneDeep(taskWords)
|
||||
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
|
||||
|
||||
//随机练习单独处理
|
||||
if (taskWords.shuffle.length) {
|
||||
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.Shuffle) {
|
||||
temp.shuffle = shuffle(store.sdict.words.filter(v => !ignoreList.includes(v.word))).slice(
|
||||
0,
|
||||
runtimeStore.routeData.total
|
||||
0,
|
||||
runtimeStore.routeData.total ?? temp.shuffle.length
|
||||
)
|
||||
if (showStatDialog) showStatDialog = false
|
||||
} else {
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.System) settingStore.dictation = false
|
||||
//这里判断是否显示结算弹框,如果显示了结算弹框的话,就不用加进度了
|
||||
if (!showStatDialog) {
|
||||
console.log('没学完,强行跳过')
|
||||
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex + statStore.newWordNumber
|
||||
// 忽略单词数
|
||||
const ignoreCount = ignoreList.filter(word => store.sdict.words.some(w => w.word.toLowerCase() === word)).length
|
||||
// 如果lastLearnIndex已经超过可学单词数,则判定完成
|
||||
if (store.sdict.lastLearnIndex + ignoreCount >= store.sdict.length) {
|
||||
store.sdict.complete = true
|
||||
store.sdict.lastLearnIndex = store.sdict.length
|
||||
}
|
||||
} else {
|
||||
console.log('学完了,正常下一组')
|
||||
showStatDialog = false
|
||||
}
|
||||
|
||||
temp = getCurrentStudyWord()
|
||||
}
|
||||
emitter.emit(EventKey.resetWord)
|
||||
@@ -626,6 +677,21 @@ async function continueStudy() {
|
||||
}
|
||||
}
|
||||
|
||||
async function jumpToGroup(group: number) {
|
||||
setPracticeWordCache(null)
|
||||
console.log('没学完,强行跳过', group)
|
||||
store.sdict.lastLearnIndex = (group - 1) * store.sdict.perDayStudyNumber
|
||||
emitter.emit(EventKey.resetWord)
|
||||
initData(getCurrentStudyWord())
|
||||
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await setUserDictProp(null, { ...store.sdict, type: 'word' })
|
||||
if (!res.success) {
|
||||
Toast.error(res.msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function randomWrite() {
|
||||
console.log('随机默写')
|
||||
data.words = shuffle(data.words)
|
||||
@@ -633,24 +699,9 @@ function randomWrite() {
|
||||
settingStore.dictation = true
|
||||
}
|
||||
|
||||
function nextRandomWrite() {
|
||||
console.log('继续随机默写')
|
||||
initData(getCurrentStudyWord())
|
||||
randomWrite()
|
||||
showStatDialog = false
|
||||
}
|
||||
|
||||
useEvents([
|
||||
[EventKey.repeatStudy, repeat],
|
||||
[EventKey.continueStudy, continueStudy],
|
||||
[EventKey.randomWrite, nextRandomWrite],
|
||||
[
|
||||
EventKey.changeDict,
|
||||
() => {
|
||||
initData(getCurrentStudyWord())
|
||||
},
|
||||
],
|
||||
|
||||
[ShortcutKey.ShowWord, show],
|
||||
[ShortcutKey.Previous, prev],
|
||||
[ShortcutKey.Next, skip],
|
||||
@@ -666,26 +717,25 @@ useEvents([
|
||||
[ShortcutKey.ToggleConciseMode, toggleConciseMode],
|
||||
[ShortcutKey.TogglePanel, togglePanel],
|
||||
[ShortcutKey.RandomWrite, randomWrite],
|
||||
[ShortcutKey.NextRandomWrite, nextRandomWrite],
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PracticeLayout v-loading="loading" panelLeft="var(--word-panel-margin-left)">
|
||||
<template v-slot:practice>
|
||||
<div class="practice-word">
|
||||
<div class="absolute z-1 top-4 w-full" v-if="settingStore.showNearWord">
|
||||
<div class="practice-word mb-50">
|
||||
<div
|
||||
class="fixed z-1 top-4 w-full"
|
||||
style="left: calc(50vw + var(--aside-width) / 2 - var(--toolbar-width) / 2); width: var(--toolbar-width)"
|
||||
v-if="settingStore.showNearWord"
|
||||
>
|
||||
<div class="center gap-2 cursor-pointer float-left" @click="prev" v-if="prevWord">
|
||||
<IconFluentArrowLeft16Regular class="arrow" width="22" />
|
||||
<Tooltip :title="`上一个(${settingStore.shortcutKeyMap[ShortcutKey.Previous]})`">
|
||||
<div class="word">{{ prevWord.word }}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
class="center gap-2 cursor-pointer float-right mr-3"
|
||||
@click="next(false)"
|
||||
v-if="nextWord"
|
||||
>
|
||||
<div class="center gap-2 cursor-pointer float-right mr-3" @click="next(false)" v-if="nextWord">
|
||||
<Tooltip :title="`下一个(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`">
|
||||
<div class="word" :class="settingStore.dictation && 'word-shadow'">
|
||||
{{ nextWord.word }}
|
||||
@@ -694,48 +744,45 @@ useEvents([
|
||||
<IconFluentArrowRight16Regular class="arrow" width="22" />
|
||||
</div>
|
||||
</div>
|
||||
<TypeWord
|
||||
ref="typingRef"
|
||||
:word="word"
|
||||
@wrong="onTypeWrong"
|
||||
@complete="next"
|
||||
@know="onWordKnow"
|
||||
/>
|
||||
<TypeWord ref="typingRef" :word="word" @wrong="onTypeWrong" @complete="next" @know="onWordKnow" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:panel>
|
||||
<Panel>
|
||||
<template v-slot:title>
|
||||
<!-- <span>{{ store.sdict.name }} ({{ data.index + 1 }} / {{ data.words.length }})</span>-->
|
||||
<div class="center gap-space">
|
||||
<span
|
||||
>{{ store.sdict.name }} ({{ store.sdict.lastLearnIndex }} /
|
||||
{{ store.sdict.length }})</span
|
||||
>
|
||||
<div class="center gap-1">
|
||||
<span>{{ store.sdict.name }}</span>
|
||||
|
||||
<GroupList
|
||||
@click="jumpToGroup"
|
||||
v-if="taskWords.new.length && settingStore.wordPracticeMode !== WordPracticeMode.Shuffle"
|
||||
/>
|
||||
<BaseIcon
|
||||
@click="continueStudy"
|
||||
:title="`下一组(${settingStore.shortcutKeyMap[ShortcutKey.NextChapter]})`"
|
||||
v-if="
|
||||
taskWords.new.length &&
|
||||
![WordPracticeMode.Review, WordPracticeMode.Shuffle].includes(settingStore.wordPracticeMode)
|
||||
"
|
||||
@click="continueStudy"
|
||||
:title="`下一组(${settingStore.shortcutKeyMap[ShortcutKey.NextChapter]})`"
|
||||
>
|
||||
<IconFluentArrowRight16Regular class="arrow" width="22" />
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
@click="randomWrite"
|
||||
:title="`随机默写(${settingStore.shortcutKeyMap[ShortcutKey.RandomWrite]})`"
|
||||
>
|
||||
|
||||
<BaseIcon @click="randomWrite" :title="`随机默写(${settingStore.shortcutKeyMap[ShortcutKey.RandomWrite]})`">
|
||||
<IconFluentArrowShuffle16Regular class="arrow" width="22" />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</template>
|
||||
<div class="panel-page-item pl-4">
|
||||
<WordList
|
||||
v-if="data.words.length"
|
||||
:is-active="settingStore.showPanel"
|
||||
:static="false"
|
||||
:show-word="!settingStore.dictation"
|
||||
:show-translate="settingStore.translate"
|
||||
:list="data.words"
|
||||
:activeIndex="data.index"
|
||||
@click="(val: any) => (data.index = val.index)"
|
||||
v-if="data.words.length"
|
||||
:is-active="settingStore.showPanel"
|
||||
:static="false"
|
||||
:show-word="!settingStore.dictation"
|
||||
:show-translate="settingStore.translate"
|
||||
:list="data.words"
|
||||
:activeIndex="data.index"
|
||||
@click="(val: any) => (data.index = val.index)"
|
||||
>
|
||||
</WordList>
|
||||
<Empty v-else />
|
||||
@@ -744,12 +791,12 @@ useEvents([
|
||||
</template>
|
||||
<template v-slot:footer>
|
||||
<Footer
|
||||
:is-simple="isWordSimple(word)"
|
||||
@toggle-simple="toggleWordSimpleWrapper"
|
||||
:is-collect="isWordCollect(word)"
|
||||
@toggle-collect="toggleWordCollect(word)"
|
||||
@skip="next(false)"
|
||||
@skipStep="skipStep"
|
||||
:is-simple="isWordSimple(word)"
|
||||
@toggle-simple="toggleWordSimpleWrapper"
|
||||
:is-collect="isWordCollect(word)"
|
||||
@toggle-collect="toggleWordCollect(word)"
|
||||
@skip="next(false)"
|
||||
@skipStep="skipStep"
|
||||
/>
|
||||
</template>
|
||||
</PracticeLayout>
|
||||
|
||||
@@ -5,7 +5,7 @@ import BaseButton from '@/components/BaseButton.vue'
|
||||
import VolumeIcon from '@/components/icon/VolumeIcon.vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useBaseStore} from '@/stores/base.ts'
|
||||
import {Dict, Word} from '@/types/types.ts'
|
||||
import type {Dict, Word} from '@/types/types.ts'
|
||||
import {_getDictDataByUrl, shuffle} from '@/utils'
|
||||
import {useRuntimeStore} from '@/stores/runtime.ts'
|
||||
import {usePlayBeep, usePlayCorrect, usePlayWordAudio} from '@/hooks/sound.ts'
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useNav,
|
||||
} from '@/utils'
|
||||
import BasePage from '@/components/BasePage.vue'
|
||||
import { DictResource, WordPracticeMode } from '@/types/types.ts'
|
||||
import type { DictResource } from '@/types/types.ts'
|
||||
import { watch } from 'vue'
|
||||
import { getCurrentStudyWord } from '@/hooks/dict.ts'
|
||||
import { useRuntimeStore } from '@/stores/runtime.ts'
|
||||
@@ -28,18 +28,14 @@ import PracticeSettingDialog from '@/pages/word/components/PracticeSettingDialog
|
||||
import ChangeLastPracticeIndexDialog from '@/pages/word/components/ChangeLastPracticeIndexDialog.vue'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { useFetch } from '@vueuse/core'
|
||||
import {
|
||||
AppEnv,
|
||||
DICT_LIST,
|
||||
Host,
|
||||
LIB_JS_URL,
|
||||
PracticeSaveWordKey,
|
||||
TourConfig,
|
||||
} from '@/config/env.ts'
|
||||
import { AppEnv, DICT_LIST, Host, LIB_JS_URL, Origin, TourConfig, WordPracticeModeNameMap } from '@/config/env.ts'
|
||||
import { myDictList } from '@/apis'
|
||||
import PracticeWordListDialog from '@/pages/word/components/PracticeWordListDialog.vue'
|
||||
import ShufflePracticeSettingDialog from '@/pages/word/components/ShufflePracticeSettingDialog.vue'
|
||||
import { deleteDict } from '@/apis/dict.ts'
|
||||
import OptionButton from '@/components/base/OptionButton.vue'
|
||||
import { getPracticeWordCache, setPracticeWordCache } from '@/utils/cache.ts'
|
||||
import { WordPracticeMode } from '@/types/enum.ts'
|
||||
|
||||
const store = useBaseStore()
|
||||
const settingStore = useSettingStore()
|
||||
@@ -105,16 +101,10 @@ async function init() {
|
||||
}
|
||||
}
|
||||
if (!currentStudy.new.length && store.sdict.words.length) {
|
||||
let d = localStorage.getItem(PracticeSaveWordKey.key)
|
||||
let d = getPracticeWordCache()
|
||||
if (d) {
|
||||
try {
|
||||
let obj = JSON.parse(d)
|
||||
currentStudy = obj.val.taskWords
|
||||
isSaveData = true
|
||||
} catch (e) {
|
||||
localStorage.removeItem(PracticeSaveWordKey.key)
|
||||
currentStudy = getCurrentStudyWord()
|
||||
}
|
||||
currentStudy = d.taskWords
|
||||
isSaveData = true
|
||||
} else {
|
||||
currentStudy = getCurrentStudyWord()
|
||||
}
|
||||
@@ -122,11 +112,18 @@ async function init() {
|
||||
loading = false
|
||||
}
|
||||
|
||||
function startPractice() {
|
||||
function startPractice(practiceMode: WordPracticeMode, resetCache: boolean = false): void {
|
||||
if (store.sdict.id) {
|
||||
if (!store.sdict.words.length) {
|
||||
return Toast.warning('没有单词可学习!')
|
||||
Toast.warning('没有单词可学习!')
|
||||
return
|
||||
}
|
||||
|
||||
if (resetCache) {
|
||||
setPracticeWordCache(null)
|
||||
}
|
||||
settingStore.wordPracticeMode = practiceMode
|
||||
|
||||
window.umami?.track('startStudyWord', {
|
||||
name: store.sdict.name,
|
||||
index: store.sdict.lastLearnIndex,
|
||||
@@ -144,6 +141,16 @@ function startPractice() {
|
||||
}
|
||||
}
|
||||
|
||||
function freePractice() {
|
||||
startPractice(WordPracticeMode.Free, settingStore.wordPracticeMode !== WordPracticeMode.Free && isSaveData)
|
||||
}
|
||||
function systemPractice() {
|
||||
startPractice(
|
||||
settingStore.wordPracticeMode === WordPracticeMode.Free ? WordPracticeMode.System : settingStore.wordPracticeMode,
|
||||
settingStore.wordPracticeMode === WordPracticeMode.Free && isSaveData
|
||||
)
|
||||
}
|
||||
|
||||
let showPracticeSettingDialog = $ref(false)
|
||||
let showShufflePracticeSettingDialog = $ref(false)
|
||||
let showChangeLastPracticeIndexDialog = $ref(false)
|
||||
@@ -195,11 +202,7 @@ function toggleSelect(item) {
|
||||
|
||||
const progressTextLeft = $computed(() => {
|
||||
if (store.sdict.complete) return '已学完,进入总复习阶段'
|
||||
return '已学习' + store.currentStudyProgress + '%'
|
||||
})
|
||||
const progressTextRight = $computed(() => {
|
||||
// if (store.sdict.complete) return store.sdict?.length
|
||||
return store.sdict?.lastLearnIndex
|
||||
return '当前进度:已学' + store.currentStudyProgress + '%'
|
||||
})
|
||||
|
||||
function check(cb: Function) {
|
||||
@@ -214,7 +217,7 @@ function check(cb: Function) {
|
||||
async function savePracticeSetting() {
|
||||
Toast.success('修改成功')
|
||||
isSaveData = false
|
||||
localStorage.removeItem(PracticeSaveWordKey.key)
|
||||
setPracticeWordCache(null)
|
||||
await store.changeDict(runtimeStore.editDict)
|
||||
currentStudy = getCurrentStudyWord()
|
||||
}
|
||||
@@ -229,8 +232,8 @@ async function onShufflePracticeSettingOk(total) {
|
||||
complete: store.sdict.complete,
|
||||
})
|
||||
isSaveData = false
|
||||
localStorage.removeItem(PracticeSaveWordKey.key)
|
||||
|
||||
setPracticeWordCache(null)
|
||||
settingStore.wordPracticeMode = WordPracticeMode.Shuffle
|
||||
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
|
||||
currentStudy.shuffle = shuffle(
|
||||
store.sdict.words.slice(0, store.sdict.lastLearnIndex).filter(v => !ignoreList.includes(v.word))
|
||||
@@ -248,26 +251,35 @@ async function onShufflePracticeSettingOk(total) {
|
||||
async function saveLastPracticeIndex(e) {
|
||||
Toast.success('修改成功')
|
||||
runtimeStore.editDict.lastLearnIndex = e
|
||||
// runtimeStore.editDict.complete = e >= runtimeStore.editDict.length - 1
|
||||
showChangeLastPracticeIndexDialog = false
|
||||
isSaveData = false
|
||||
localStorage.removeItem(PracticeSaveWordKey.key)
|
||||
setPracticeWordCache(null)
|
||||
await store.changeDict(runtimeStore.editDict)
|
||||
currentStudy = getCurrentStudyWord()
|
||||
}
|
||||
|
||||
const { data: recommendDictList, isFetching } = useFetch(
|
||||
resourceWrap(DICT_LIST.WORD.RECOMMENDED)
|
||||
).json()
|
||||
const { data: recommendDictList, isFetching } = useFetch(resourceWrap(DICT_LIST.WORD.RECOMMENDED)).json()
|
||||
|
||||
let isNewHost = $ref(window.location.host === Host)
|
||||
|
||||
const systemPracticeText = $computed(() => {
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.Free) {
|
||||
return '开始学习'
|
||||
} else {
|
||||
return isSaveData
|
||||
? '继续' + WordPracticeModeNameMap[settingStore.wordPracticeMode]
|
||||
: '开始' + WordPracticeModeNameMap[settingStore.wordPracticeMode]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasePage>
|
||||
<div class="mb-4" v-if="!isNewHost">
|
||||
新域名已启用,后续请访问
|
||||
<a href="https://typewords.cc/words?from_old_site=1">https://typewords.cc</a>。当前 2study.top
|
||||
域名将在不久后停止使用
|
||||
<a class="mr-4" :href="`${Origin}/words?from_old_site=1`">{{ Origin }}</a
|
||||
>当前 2study.top 域名将在不久后停止使用
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col md:flex-row gap-4">
|
||||
@@ -282,21 +294,23 @@ let isNewHost = $ref(window.location.host === Host)
|
||||
</div>
|
||||
|
||||
<template v-if="store.sdict.id">
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<div class="">当前进度:{{ progressTextLeft }}</div>
|
||||
<Progress
|
||||
size="large"
|
||||
:percentage="store.currentStudyProgress"
|
||||
:show-text="false"
|
||||
></Progress>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="text-sm flex justify-between">
|
||||
<span>已完成 {{ progressTextRight }} 词 / 共 {{ store.sdict.words.length }} 词</span>
|
||||
<span v-if="store.sdict.id">
|
||||
<span v-opacity="store.sdict.id && store.sdict.lastLearnIndex < store.sdict.length">
|
||||
预计完成日期:{{
|
||||
_getAccomplishDate(store.sdict.words.length, store.sdict.perDayStudyNumber)
|
||||
_getAccomplishDate(
|
||||
store.sdict.words.length - store.sdict.lastLearnIndex,
|
||||
store.sdict.perDayStudyNumber
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<Progress size="large" :percentage="store.currentStudyProgress" :show-text="false"></Progress>
|
||||
|
||||
<div class="text-sm flex justify-between">
|
||||
<span>{{ progressTextLeft }}</span>
|
||||
<span> {{ store.sdict?.lastLearnIndex }} / {{ store.sdict.words.length }} 词</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mt-4 gap-4">
|
||||
<BaseButton type="info" size="small" @click="router.push('/dict-list')">
|
||||
@@ -331,10 +345,7 @@ let isNewHost = $ref(window.location.host === Host)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 w-full mt-4 md:mt-0"
|
||||
:class="!store.sdict.id && 'opacity-30 cursor-not-allowed'"
|
||||
>
|
||||
<div class="flex-1 w-full mt-4 md:mt-0" :class="!store.sdict.id && 'opacity-30 cursor-not-allowed'">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="p-2 center rounded-full bg-white">
|
||||
@@ -343,10 +354,7 @@ let isNewHost = $ref(window.location.host === Host)
|
||||
<div class="text-xl font-bold">
|
||||
{{ isSaveData ? '上次任务' : '今日任务' }}
|
||||
</div>
|
||||
<span
|
||||
class="color-link cursor-pointer"
|
||||
v-if="store.sdict.id"
|
||||
@click="showPracticeWordListDialog = true"
|
||||
<span class="color-link cursor-pointer" v-if="store.sdict.id" @click="showPracticeWordListDialog = true"
|
||||
>词表</span
|
||||
>
|
||||
</div>
|
||||
@@ -370,90 +378,98 @@ let isNewHost = $ref(window.location.host === Host)
|
||||
<div class="num">{{ currentStudy.new.length }}</div>
|
||||
<div class="txt">新词数</div>
|
||||
</div>
|
||||
<template v-if="settingStore.wordPracticeMode === WordPracticeMode.System">
|
||||
<div class="stat">
|
||||
<div class="num">{{ currentStudy.review.length }}</div>
|
||||
<div class="txt">复习上次</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="num">{{ currentStudy.write.length }}</div>
|
||||
<div class="txt">复习之前</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-end mt-4">
|
||||
<BaseButton
|
||||
size="large"
|
||||
class="flex-1"
|
||||
:disabled="!store.sdict.id"
|
||||
:loading="loading"
|
||||
@click="startPractice"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="line-height-[2]">{{ isSaveData ? '继续学习' : '开始学习' }}</span>
|
||||
<IconFluentArrowCircleRight16Regular class="text-xl" />
|
||||
</div>
|
||||
</BaseButton>
|
||||
|
||||
<div v-if="false" class="w-full flex box-border cp color-white">
|
||||
<div
|
||||
@click="startPractice"
|
||||
class="flex-1 rounded-l-lg center gap-2 py-1 bg-[var(--btn-primary)] hover:opacity-50"
|
||||
>
|
||||
<span class="line-height-[2]">{{ isSaveData ? '继续学习' : '开始学习' }}</span>
|
||||
<IconFluentArrowCircleRight16Regular class="text-xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="w-10 rounded-r-lg h-full center bg-[var(--btn-primary)] hover:bg-gray border-solid border-2 border-l-gray border-transparent box-border"
|
||||
>
|
||||
<IconFluentChevronDown20Regular />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="space-y-2 pt-2 absolute z-2 right-0 border rounded opacity-0 scale-95 group-hover:opacity-100 group-hover:scale-100 transition-all duration-150 pointer-events-none group-hover:pointer-events-auto"
|
||||
>
|
||||
<div>
|
||||
<BaseButton
|
||||
size="large"
|
||||
type="orange"
|
||||
:loading="loading"
|
||||
@click="check(() => (showShufflePracticeSettingDialog = true))"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="line-height-[2]">随机复习</span>
|
||||
<IconFluentArrowShuffle20Filled class="text-xl" />
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div>
|
||||
<BaseButton
|
||||
size="large"
|
||||
type="orange"
|
||||
:loading="loading"
|
||||
@click="check(() => (showShufflePracticeSettingDialog = true))"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="line-height-[2]">重新学习</span>
|
||||
<IconFluentArrowShuffle20Filled class="text-xl" />
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="num">{{ currentStudy.review.length }}</div>
|
||||
<div class="txt">复习上次</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="num">{{ currentStudy.write.length }}</div>
|
||||
<div class="txt">复习之前</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-end mt-4 gap-4 btn-no-margin">
|
||||
<OptionButton
|
||||
:class="settingStore.wordPracticeMode !== WordPracticeMode.Free ? 'flex-1 orange-btn' : 'primary-btn'"
|
||||
>
|
||||
<BaseButton
|
||||
size="large"
|
||||
:type="settingStore.wordPracticeMode !== WordPracticeMode.Free ? 'orange' : 'primary'"
|
||||
:disabled="!store.sdict.id"
|
||||
:loading="loading"
|
||||
@click="systemPractice"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="line-height-[2]">{{ systemPracticeText }}</span>
|
||||
<IconFluentArrowCircleRight16Regular class="text-xl" />
|
||||
</div>
|
||||
</BaseButton>
|
||||
<template #options>
|
||||
<BaseButton
|
||||
class="w-full"
|
||||
v-if="
|
||||
settingStore.wordPracticeMode !== WordPracticeMode.System &&
|
||||
settingStore.wordPracticeMode !== WordPracticeMode.Free
|
||||
"
|
||||
@click="startPractice(WordPracticeMode.System, true)"
|
||||
>
|
||||
智能学习
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
class="w-full"
|
||||
v-if="settingStore.wordPracticeMode !== WordPracticeMode.Review"
|
||||
:disabled="!currentStudy.review.length && !currentStudy.write.length"
|
||||
@click="startPractice(WordPracticeMode.Review, true)"
|
||||
>
|
||||
复习
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="w-full"
|
||||
v-if="settingStore.wordPracticeMode !== WordPracticeMode.Shuffle"
|
||||
:disabled="store.sdict.lastLearnIndex < 10 && !store.sdict.complete"
|
||||
@click="check(() => (showShufflePracticeSettingDialog = true))"
|
||||
>
|
||||
随机复习
|
||||
</BaseButton>
|
||||
|
||||
<!-- <BaseButton-->
|
||||
<!-- class="w-full"-->
|
||||
<!-- v-if="settingStore.wordPracticeMode !== WordPracticeMode.IdentifyOnly"-->
|
||||
<!-- @click="startPractice(WordPracticeMode.IdentifyOnly, true)"-->
|
||||
<!-- >-->
|
||||
<!-- {{ WordPracticeModeNameMap[WordPracticeMode.IdentifyOnly] }}-->
|
||||
<!-- </BaseButton>-->
|
||||
<!-- <BaseButton-->
|
||||
<!-- class="w-full"-->
|
||||
<!-- v-if="settingStore.wordPracticeMode !== WordPracticeMode.ListenOnly"-->
|
||||
<!-- @click="startPractice(WordPracticeMode.ListenOnly, true)"-->
|
||||
<!-- >-->
|
||||
<!-- {{ WordPracticeModeNameMap[WordPracticeMode.ListenOnly] }}-->
|
||||
<!-- </BaseButton>-->
|
||||
<!-- <BaseButton-->
|
||||
<!-- class="w-full"-->
|
||||
<!-- v-if="settingStore.wordPracticeMode !== WordPracticeMode.DictationOnly"-->
|
||||
<!-- @click="startPractice(WordPracticeMode.DictationOnly, true)"-->
|
||||
<!-- >-->
|
||||
<!-- {{ WordPracticeModeNameMap[WordPracticeMode.DictationOnly] }}-->
|
||||
<!-- </BaseButton>-->
|
||||
</template>
|
||||
</OptionButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="store.sdict.id && store.sdict.lastLearnIndex"
|
||||
:class="settingStore.wordPracticeMode === WordPracticeMode.Free ? 'flex-1' : ''"
|
||||
:type="settingStore.wordPracticeMode === WordPracticeMode.Free ? 'orange' : 'primary'"
|
||||
size="large"
|
||||
type="orange"
|
||||
:loading="loading"
|
||||
@click="check(() => (showShufflePracticeSettingDialog = true))"
|
||||
@click="freePractice()"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="line-height-[2]">随机复习</span>
|
||||
<IconFluentArrowShuffle20Filled class="text-xl" />
|
||||
<span class="line-height-[2]">
|
||||
{{
|
||||
settingStore.wordPracticeMode === WordPracticeMode.Free && isSaveData ? '继续自由练习' : '自由练习'
|
||||
}}
|
||||
</span>
|
||||
<IconStreamlineColorPenDrawFlat class="text-xl" />
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
@@ -464,11 +480,7 @@ let isNewHost = $ref(window.location.host === Host)
|
||||
<div class="flex justify-between">
|
||||
<div class="title">我的词典</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<PopConfirm
|
||||
title="确认删除所有选中词典?"
|
||||
@confirm="handleBatchDel"
|
||||
v-if="selectIds.length"
|
||||
>
|
||||
<PopConfirm title="确认删除所有选中词典?" @confirm="handleBatchDel" v-if="selectIds.length">
|
||||
<BaseIcon class="del" title="删除">
|
||||
<DeleteIcon />
|
||||
</BaseIcon>
|
||||
@@ -486,15 +498,13 @@ let isNewHost = $ref(window.location.host === Host)
|
||||
>
|
||||
{{ isManageDict ? '取消' : '管理词典' }}
|
||||
</div>
|
||||
<div class="color-link cursor-pointer" @click="nav('dict-detail', { isAdd: true })">
|
||||
创建个人词典
|
||||
</div>
|
||||
<div class="color-link cursor-pointer" @click="nav('dict-detail', { isAdd: true })">创建个人词典</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 flex-wrap mt-4">
|
||||
<Book
|
||||
:is-add="false"
|
||||
quantifier="个词"
|
||||
quantifier="词"
|
||||
:item="item"
|
||||
:checked="selectIds.includes(item.id)"
|
||||
@check="() => toggleSelect(item)"
|
||||
@@ -517,7 +527,7 @@ let isNewHost = $ref(window.location.host === Host)
|
||||
<div class="flex gap-4 flex-wrap mt-4 min-h-50">
|
||||
<Book
|
||||
:is-add="false"
|
||||
quantifier="个词"
|
||||
quantifier="词"
|
||||
:item="item as any"
|
||||
v-for="(item, j) in recommendDictList"
|
||||
@click="goDictDetail(item as any)"
|
||||
@@ -526,23 +536,13 @@ let isNewHost = $ref(window.location.host === Host)
|
||||
</div>
|
||||
</BasePage>
|
||||
|
||||
<PracticeSettingDialog
|
||||
:show-left-option="false"
|
||||
v-model="showPracticeSettingDialog"
|
||||
@ok="savePracticeSetting"
|
||||
/>
|
||||
<PracticeSettingDialog :show-left-option="false" v-model="showPracticeSettingDialog" @ok="savePracticeSetting" />
|
||||
|
||||
<ChangeLastPracticeIndexDialog
|
||||
v-model="showChangeLastPracticeIndexDialog"
|
||||
@ok="saveLastPracticeIndex"
|
||||
/>
|
||||
<ChangeLastPracticeIndexDialog v-model="showChangeLastPracticeIndexDialog" @ok="saveLastPracticeIndex" />
|
||||
|
||||
<PracticeWordListDialog :data="currentStudy" v-model="showPracticeWordListDialog" />
|
||||
|
||||
<ShufflePracticeSettingDialog
|
||||
v-model="showShufflePracticeSettingDialog"
|
||||
@ok="onShufflePracticeSettingOk"
|
||||
/>
|
||||
<ShufflePracticeSettingDialog v-model="showShufflePracticeSettingDialog" @ok="onShufflePracticeSettingOk" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,105 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { inject, Ref } from "vue"
|
||||
import { usePracticeStore } from "@/stores/practice.ts";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { PracticeData, ShortcutKey } from "@/types/types.ts";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import Tooltip from "@/components/base/Tooltip.vue";
|
||||
import Progress from '@/components/base/Progress.vue'
|
||||
import SettingDialog from "@/components/setting/SettingDialog.vue";
|
||||
import { inject, Ref } from 'vue'
|
||||
import { usePracticeStore } from '@/stores/practice.ts'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import type { PracticeData, TaskWords } from '@/types/types.ts'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import Tooltip from '@/components/base/Tooltip.vue'
|
||||
import SettingDialog from '@/components/setting/SettingDialog.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
import VolumeSettingMiniDialog from '@/pages/word/components/VolumeSettingMiniDialog.vue'
|
||||
import StageProgress from '@/components/StageProgress.vue'
|
||||
import { ShortcutKey, WordPracticeMode, WordPracticeStage } from '@/types/enum.ts'
|
||||
import { WordPracticeModeNameMap, WordPracticeModeStageMap, WordPracticeStageNameMap } from '@/config/env.ts'
|
||||
|
||||
const statStore = usePracticeStore()
|
||||
const store = useBaseStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
defineProps<{
|
||||
showEdit?: boolean,
|
||||
isCollect: boolean,
|
||||
showEdit?: boolean
|
||||
isCollect: boolean
|
||||
isSimple: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleCollect: [],
|
||||
toggleSimple: [],
|
||||
edit: [],
|
||||
skip: [],
|
||||
skipStep:[]
|
||||
toggleCollect: []
|
||||
toggleSimple: []
|
||||
edit: []
|
||||
skip: []
|
||||
skipStep: []
|
||||
}>()
|
||||
|
||||
let practiceData = inject<PracticeData>('practiceData')
|
||||
let isTypingWrongWord = inject<Ref<boolean>>('isTypingWrongWord')
|
||||
let practiceTaskWords = inject<TaskWords>('practiceTaskWords')
|
||||
|
||||
function format(val: number, suffix: string = '', check: number = -1) {
|
||||
return val === check ? '-' : (val + suffix)
|
||||
return val === check ? '-' : val + suffix
|
||||
}
|
||||
|
||||
const status = $computed(() => {
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.Free) return '自由练习'
|
||||
if (isTypingWrongWord.value) return '复习错词'
|
||||
return getStepStr(statStore.step)
|
||||
return statStore.getStageName
|
||||
})
|
||||
|
||||
function getStepStr(step: number) {
|
||||
let str = ''
|
||||
switch (step) {
|
||||
case 0:
|
||||
str += `学习新词`
|
||||
break
|
||||
case 1:
|
||||
str += `听写新词`
|
||||
break
|
||||
case 2:
|
||||
str += `默写新词`
|
||||
break
|
||||
case 3:
|
||||
str += `自测上次学习`
|
||||
break
|
||||
case 4:
|
||||
str += '听写上次学习'
|
||||
break
|
||||
case 5:
|
||||
str += '默写上次学习'
|
||||
break
|
||||
case 6:
|
||||
str += '自测之前学习'
|
||||
break
|
||||
case 7:
|
||||
str += '听写之前学习'
|
||||
break
|
||||
case 8:
|
||||
str += '默写之前学习'
|
||||
break
|
||||
case 9:
|
||||
str += '学习完成'
|
||||
break
|
||||
case 10:
|
||||
str += '随机复习'
|
||||
break
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
const progress = $computed(() => {
|
||||
if (!practiceData.words.length) return 0
|
||||
return ((practiceData.index / practiceData.words.length) * 100)
|
||||
return (practiceData.index / practiceData.words.length) * 100
|
||||
})
|
||||
|
||||
const stages = $computed(() => {
|
||||
let DEFAULT_BAR = {
|
||||
name: '',
|
||||
ratio: 100,
|
||||
percentage: (practiceData.index / practiceData.words.length) * 100,
|
||||
active: true,
|
||||
}
|
||||
if ([WordPracticeMode.Shuffle, WordPracticeMode.Free].includes(settingStore.wordPracticeMode)) {
|
||||
return [DEFAULT_BAR]
|
||||
} else {
|
||||
// 阶段映射:将 WordPracticeStage 映射到 stageIndex 和 childIndex
|
||||
const stageMap: Partial<Record<WordPracticeStage, { stageIndex: number; childIndex: number }>> = {
|
||||
[WordPracticeStage.FollowWriteNewWord]: { stageIndex: 0, childIndex: 0 },
|
||||
[WordPracticeStage.IdentifyNewWord]: { stageIndex: 0, childIndex: 0 },
|
||||
[WordPracticeStage.ListenNewWord]: { stageIndex: 0, childIndex: 1 },
|
||||
[WordPracticeStage.DictationNewWord]: { stageIndex: 0, childIndex: 2 },
|
||||
[WordPracticeStage.IdentifyReview]: { stageIndex: 1, childIndex: 0 },
|
||||
[WordPracticeStage.ListenReview]: { stageIndex: 1, childIndex: 1 },
|
||||
[WordPracticeStage.DictationReview]: { stageIndex: 1, childIndex: 2 },
|
||||
[WordPracticeStage.IdentifyReviewAll]: { stageIndex: 2, childIndex: 0 },
|
||||
[WordPracticeStage.ListenReviewAll]: { stageIndex: 2, childIndex: 1 },
|
||||
[WordPracticeStage.DictationReviewAll]: { stageIndex: 2, childIndex: 2 },
|
||||
}
|
||||
|
||||
// 获取当前阶段的配置
|
||||
const currentStageConfig = stageMap[statStore.stage]
|
||||
if (!currentStageConfig) {
|
||||
return stages
|
||||
}
|
||||
|
||||
const { stageIndex, childIndex } = currentStageConfig
|
||||
const currentProgress = (practiceData.index / practiceData.words.length) * 100
|
||||
|
||||
if (
|
||||
[WordPracticeMode.IdentifyOnly, WordPracticeMode.DictationOnly, WordPracticeMode.ListenOnly].includes(
|
||||
settingStore.wordPracticeMode
|
||||
)
|
||||
) {
|
||||
const stages = [
|
||||
{
|
||||
name: `新词:${WordPracticeModeNameMap[settingStore.wordPracticeMode]}`,
|
||||
ratio: 33,
|
||||
percentage: 0,
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
name: `上次学习:${WordPracticeModeNameMap[settingStore.wordPracticeMode]}`,
|
||||
ratio: 33,
|
||||
percentage: 0,
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
name: `之前学习:${WordPracticeModeNameMap[settingStore.wordPracticeMode]}`,
|
||||
ratio: 33,
|
||||
percentage: 0,
|
||||
active: false,
|
||||
},
|
||||
]
|
||||
|
||||
// 设置已完成阶段的百分比和比例
|
||||
for (let i = 0; i < stageIndex; i++) {
|
||||
stages[i].percentage = 100
|
||||
stages[i].ratio = 33
|
||||
}
|
||||
|
||||
// 设置当前激活的阶段
|
||||
stages[stageIndex].active = true
|
||||
stages[stageIndex].percentage = (practiceData.index / practiceData.words.length) * 100
|
||||
|
||||
return stages
|
||||
} else {
|
||||
// 阶段配置:定义每个阶段组的基础信息
|
||||
const stageConfigs = [
|
||||
{
|
||||
name: '新词',
|
||||
ratio: 70,
|
||||
children: [{ name: '新词:跟写' }, { name: '新词:听写' }, { name: '新词:默写' }],
|
||||
},
|
||||
{
|
||||
name: '上次学习:复习',
|
||||
ratio: 15,
|
||||
children: [{ name: '上次学习:自测' }, { name: '上次学习:听写' }, { name: '上次学习:默写' }],
|
||||
},
|
||||
{
|
||||
name: '之前学习:复习',
|
||||
ratio: 15,
|
||||
children: [{ name: '之前学习:自测' }, { name: '之前学习:听写' }, { name: '之前学习:默写' }],
|
||||
},
|
||||
]
|
||||
|
||||
// 初始化 stages
|
||||
const stages = stageConfigs.map(config => ({
|
||||
name: config.name,
|
||||
percentage: 0,
|
||||
ratio: config.ratio,
|
||||
active: false,
|
||||
children: config.children.map(child => ({
|
||||
name: child.name,
|
||||
percentage: 0,
|
||||
ratio: 33,
|
||||
active: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
// 设置已完成阶段的百分比和比例
|
||||
for (let i = 0; i < stageIndex; i++) {
|
||||
stages[i].percentage = 100
|
||||
stages[i].ratio = 15
|
||||
}
|
||||
|
||||
// 设置当前激活的阶段
|
||||
stages[stageIndex].ratio = 70
|
||||
stages[stageIndex].active = true
|
||||
|
||||
// 根据类型设置子阶段的进度
|
||||
const currentStageChildren = stages[stageIndex].children
|
||||
|
||||
if (childIndex === 0) {
|
||||
// 跟写/自测:只激活第一个子阶段
|
||||
currentStageChildren[0].active = true
|
||||
currentStageChildren[0].percentage = currentProgress
|
||||
} else if (childIndex === 1) {
|
||||
// 听写:第一个完成,第三个未开始,第二个进行中
|
||||
currentStageChildren[0].active = false
|
||||
currentStageChildren[1].active = true
|
||||
currentStageChildren[2].active = false
|
||||
currentStageChildren[0].percentage = 100
|
||||
currentStageChildren[1].percentage = currentProgress
|
||||
currentStageChildren[2].percentage = 0
|
||||
} else if (childIndex === 2) {
|
||||
// 默写:前两个完成,第三个进行中
|
||||
currentStageChildren[0].active = false
|
||||
currentStageChildren[1].active = false
|
||||
currentStageChildren[2].active = true
|
||||
currentStageChildren[0].percentage = 100
|
||||
currentStageChildren[1].percentage = 100
|
||||
currentStageChildren[2].percentage = currentProgress
|
||||
}
|
||||
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.System) {
|
||||
return stages
|
||||
}
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.Review) {
|
||||
stages.shift()
|
||||
if (stageIndex === 1) stages[1].ratio = 30
|
||||
if (stageIndex === 2) stages[0].ratio = 30
|
||||
|
||||
console.log('stages', stages, childIndex)
|
||||
|
||||
return stages
|
||||
}
|
||||
}
|
||||
}
|
||||
return [DEFAULT_BAR]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="footer">
|
||||
<Tooltip :title="settingStore.showToolbar?'收起':'展开'">
|
||||
<Tooltip :title="settingStore.showToolbar ? '收起' : '展开'">
|
||||
<IconFluentChevronLeft20Filled
|
||||
@click="settingStore.showToolbar = !settingStore.showToolbar"
|
||||
class="arrow"
|
||||
:class="!settingStore.showToolbar && 'down'"
|
||||
color="#999"/>
|
||||
@click="settingStore.showToolbar = !settingStore.showToolbar"
|
||||
class="arrow"
|
||||
:class="!settingStore.showToolbar && 'down'"
|
||||
color="#999"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<div class="bottom">
|
||||
<Progress :percentage="progress"
|
||||
:stroke-width="8"
|
||||
color="#69b1ff"
|
||||
:show-text="false"/>
|
||||
<StageProgress :stages="stages" />
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="stat">
|
||||
@@ -109,7 +229,7 @@ const progress = $computed(() => {
|
||||
<div class="name">{{ status }}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<!-- <div class="num">{{ statStore.spend }}分钟</div>-->
|
||||
<!-- <div class="num">{{ statStore.spend }}分钟</div>-->
|
||||
<div class="num">{{ Math.floor(statStore.spend / 1000 / 60) }}分钟</div>
|
||||
<div class="line"></div>
|
||||
<div class="name">时间</div>
|
||||
@@ -126,72 +246,92 @@ const progress = $computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-center items-center" id="toolbar-icons">
|
||||
<SettingDialog type="word"/>
|
||||
<SettingDialog type="word" />
|
||||
|
||||
<VolumeSettingMiniDialog />
|
||||
|
||||
<BaseIcon
|
||||
v-if="statStore.step < 9"
|
||||
@click="emit('skipStep')"
|
||||
:title="`跳到下一阶段:${getStepStr(statStore.step+1)}`">
|
||||
<IconFluentArrowRight16Regular/>
|
||||
</BaseIcon>
|
||||
|
||||
<BaseIcon
|
||||
:class="!isSimple?'collect':'fill'"
|
||||
@click="$emit('toggleSimple')"
|
||||
:title="(!isSimple ? '标记为已掌握' : '取消标记已掌握')+`(${settingStore.shortcutKeyMap[ShortcutKey.ToggleSimple]})`">
|
||||
<IconFluentCheckmarkCircle16Regular v-if="!isSimple"/>
|
||||
<IconFluentCheckmarkCircle16Filled v-else/>
|
||||
</BaseIcon>
|
||||
|
||||
<BaseIcon
|
||||
:class="!isCollect?'collect':'fill'"
|
||||
@click.stop="$emit('toggleCollect')"
|
||||
:title="(!isCollect ? '收藏' : '取消收藏')+`(${settingStore.shortcutKeyMap[ShortcutKey.ToggleCollect]})`">
|
||||
<IconFluentStarAdd16Regular v-if="!isCollect"/>
|
||||
<IconFluentStar16Filled v-else/>
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
@click="emit('skip')"
|
||||
:title="`跳过当前单词(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`">
|
||||
<IconFluentArrowBounce20Regular class="transform-rotate-180"/>
|
||||
</BaseIcon>
|
||||
|
||||
<BaseIcon
|
||||
@click="settingStore.dictation = !settingStore.dictation"
|
||||
:title="`开关默写模式(${settingStore.shortcutKeyMap[ShortcutKey.ToggleDictation]})`"
|
||||
v-if="settingStore.wordPracticeMode !== WordPracticeMode.Free"
|
||||
@click="emit('skipStep')"
|
||||
:title="`跳到下一阶段:${WordPracticeStageNameMap[statStore.nextStage]}`"
|
||||
>
|
||||
<IconFluentEyeOff16Regular v-if="settingStore.dictation"/>
|
||||
<IconFluentEye16Regular v-else/>
|
||||
<IconFluentArrowRight16Regular />
|
||||
</BaseIcon>
|
||||
|
||||
<div class="relative z-999 group">
|
||||
<div
|
||||
class="space-y-2 btn-no-margin pb-2 left-1/2 -transform-translate-x-1/2 absolute z-999 bottom-full scale-95 opacity-0 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 pointer-events-none group-hover:pointer-events-auto"
|
||||
>
|
||||
<BaseButton size="normal" type="info" class="w-full" @click="$emit('toggleSimple')">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconFluentCheckmarkCircle16Regular v-if="!isSimple" />
|
||||
<IconFluentCheckmarkCircle16Filled v-else />
|
||||
<span>
|
||||
{{
|
||||
(!isSimple ? '标记已掌握' : '取消已掌握') +
|
||||
`(${settingStore.shortcutKeyMap[ShortcutKey.ToggleSimple]})`
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</BaseButton>
|
||||
<BaseButton size="normal" type="info" class="w-full" @click="$emit('toggleCollect')">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconFluentStarAdd16Regular v-if="!isCollect" />
|
||||
<IconFluentStar16Filled v-else />
|
||||
<span>
|
||||
{{
|
||||
(!isCollect ? '收藏' : '取消收藏') + `(${settingStore.shortcutKeyMap[ShortcutKey.ToggleCollect]})`
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</BaseButton>
|
||||
<BaseButton size="normal" type="info" class="w-full" @click="$emit('skip')">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconFluentArrowBounce20Regular class="transform-rotate-180" />
|
||||
<span> 跳过单词({{ settingStore.shortcutKeyMap[ShortcutKey.Next] }})</span>
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<BaseIcon>
|
||||
<IconPhMicrosoftWordLogoLight />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
|
||||
<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/>
|
||||
:title="`开关释义显示(${settingStore.shortcutKeyMap[ShortcutKey.ToggleShowTranslate]})`"
|
||||
@click="settingStore.translate = !settingStore.translate"
|
||||
>
|
||||
<IconFluentTranslate16Regular v-if="settingStore.translate" />
|
||||
<IconFluentTranslateOff16Regular v-else />
|
||||
</BaseIcon>
|
||||
|
||||
<BaseIcon
|
||||
@click="settingStore.showPanel = !settingStore.showPanel"
|
||||
:title="`单词本(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`">
|
||||
<IconFluentTextListAbcUppercaseLtr20Regular/>
|
||||
@click="settingStore.showPanel = !settingStore.showPanel"
|
||||
:title="`单词本(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`"
|
||||
>
|
||||
<IconFluentTextListAbcUppercaseLtr20Regular />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-wrap flex gap-3 items-center color-gray">
|
||||
<span class="shrink-0">{{ status }}</span>
|
||||
<Progress :percentage="progress"
|
||||
:stroke-width="8"
|
||||
color="#69b1ff"
|
||||
:show-text="false"/>
|
||||
<StageProgress :stages="stages" />
|
||||
<div class="num">{{ `${practiceData.index + 1}/${practiceData.words.length}` }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.footer {
|
||||
flex-shrink: 0;
|
||||
width: var(--toolbar-width);
|
||||
@@ -208,28 +348,14 @@ const progress = $computed(() => {
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: .6rem;
|
||||
background: var(--color-second);
|
||||
padding: .2rem var(--space) calc(.4rem + env(safe-area-inset-bottom, 0px)) var(--space);
|
||||
border: 1px solid var(--color-item-border);
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 10;
|
||||
@apply relative w-full box-border rounded-xl bg-second shadow-lg z-10;
|
||||
padding: 0.2rem var(--space) calc(0.4rem + env(safe-area-inset-bottom, 0px)) var(--space);
|
||||
|
||||
.stat {
|
||||
margin-top: .5rem;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: var(--stat-gap);
|
||||
@apply flex justify-around gap-[var(--stat-gap)] mt-2;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .3rem;
|
||||
color: gray;
|
||||
@apply flex flex-col items-center gap-1 text-gray;
|
||||
|
||||
.line {
|
||||
height: 1px;
|
||||
@@ -242,8 +368,8 @@ const progress = $computed(() => {
|
||||
|
||||
.progress-wrap {
|
||||
width: var(--toolbar-width);
|
||||
transition: all .3s;
|
||||
padding: 0 .6rem;
|
||||
transition: all 0.3s;
|
||||
padding: 0 0.6rem;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
@@ -255,9 +381,9 @@ const progress = $computed(() => {
|
||||
top: -40%;
|
||||
left: 50%;
|
||||
cursor: pointer;
|
||||
transition: all .5s;
|
||||
transition: all 0.5s;
|
||||
transform: rotate(-90deg);
|
||||
padding: .5rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
|
||||
&.down {
|
||||
@@ -271,39 +397,39 @@ const progress = $computed(() => {
|
||||
@media (max-width: 768px) {
|
||||
.footer {
|
||||
width: 100%;
|
||||
|
||||
|
||||
.bottom {
|
||||
padding: 0.3rem 0.5rem 0.5rem 0.5rem;
|
||||
border-radius: 0.4rem;
|
||||
|
||||
|
||||
.stat {
|
||||
margin-top: 0.3rem;
|
||||
gap: 0.2rem;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
|
||||
|
||||
.row {
|
||||
min-width: 3.5rem;
|
||||
gap: 0.2rem;
|
||||
|
||||
|
||||
.num {
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
.name {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 移动端按钮组调整 - 改为网格布局
|
||||
.flex.gap-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.4rem;
|
||||
justify-content: center;
|
||||
|
||||
|
||||
.base-icon {
|
||||
padding: 0.3rem;
|
||||
font-size: 1rem;
|
||||
@@ -315,13 +441,13 @@ const progress = $computed(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.progress-wrap {
|
||||
width: 100%;
|
||||
padding: 0 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.arrow {
|
||||
font-size: 1rem;
|
||||
padding: 0.3rem;
|
||||
@@ -334,40 +460,40 @@ const progress = $computed(() => {
|
||||
.footer {
|
||||
.bottom {
|
||||
padding: 0.2rem 0.3rem 0.3rem 0.3rem;
|
||||
|
||||
|
||||
.stat {
|
||||
margin-top: 0.2rem;
|
||||
gap: 0.1rem;
|
||||
|
||||
|
||||
.row {
|
||||
min-width: 3rem;
|
||||
gap: 0.1rem;
|
||||
|
||||
|
||||
.num {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
|
||||
.name {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
|
||||
// 隐藏部分统计信息,只保留关键数据
|
||||
&:nth-child(n+3) {
|
||||
&:nth-child(n + 3) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.flex.gap-2 {
|
||||
gap: 0.2rem;
|
||||
|
||||
|
||||
.base-icon {
|
||||
padding: 0.2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.progress-wrap {
|
||||
padding: 0 0.3rem;
|
||||
bottom: 0.3rem;
|
||||
|
||||
116
src/pages/word/components/GroupList.vue
Normal file
116
src/pages/word/components/GroupList.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import RadioGroup from '@/components/base/radio/RadioGroup.vue'
|
||||
import Radio from '@/components/base/radio/Radio.vue'
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
const store = useBaseStore()
|
||||
|
||||
const isVisible = ref(false)
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const itemRefs = ref<(HTMLElement | null)[]>([])
|
||||
|
||||
// 计算每个组的词数
|
||||
const getGroupWordCount = (groupIndex: number) => {
|
||||
const totalLength = store.sdict.length
|
||||
const perDay = store.sdict.perDayStudyNumber
|
||||
const totalGroups = store.groupLength
|
||||
|
||||
// 如果是最后一组且不能被整除,则显示余数
|
||||
if (groupIndex === totalGroups && totalLength % perDay !== 0) {
|
||||
return totalLength % perDay
|
||||
}
|
||||
return perDay
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isVisible.value = true
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isVisible.value = false
|
||||
}
|
||||
|
||||
// 当弹框显示时,自动滚动到选中的item
|
||||
watch(isVisible, async newVal => {
|
||||
if (newVal) {
|
||||
// 等待DOM更新和过渡动画开始
|
||||
await nextTick()
|
||||
// 再等待一小段时间确保元素已渲染
|
||||
const currentIndex = store.currentGroup - 1 // currentGroup是1-based,数组是0-based
|
||||
const targetItem = itemRefs.value[currentIndex]
|
||||
const container = scrollContainer.value
|
||||
|
||||
if (targetItem && container) {
|
||||
// 计算目标item相对于容器的位置
|
||||
const itemTop = targetItem.offsetTop
|
||||
const itemHeight = targetItem.offsetHeight
|
||||
const containerHeight = container.clientHeight
|
||||
|
||||
// 滚动到目标item,使其居中显示
|
||||
container.scrollTo({
|
||||
top: itemTop - containerHeight / 2 + itemHeight / 2,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const setItemRef = (el: HTMLElement | null, index: number) => {
|
||||
if (el) {
|
||||
itemRefs.value[index] = el
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [value: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative z-999" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
|
||||
<div
|
||||
class="pt-2 left-1/2 -transform-translate-x-1/2 absolute z-999 top-full transition-all duration-300"
|
||||
:class="{
|
||||
'opacity-0 scale-95 pointer-events-none': !isVisible,
|
||||
'opacity-100 scale-100 pointer-events-auto': isVisible,
|
||||
}"
|
||||
>
|
||||
<RadioGroup :model-value="store.currentGroup">
|
||||
<div class="card-white">
|
||||
<div ref="scrollContainer" class="max-h-70 overflow-y-auto space-y-2">
|
||||
<div
|
||||
:ref="el => setItemRef(el as HTMLElement, value - 1)"
|
||||
class="break-keep flex bg-primary px-3 py-1 rounded-md hover:bg-card-active anim border border-solid border-item"
|
||||
:class="{
|
||||
'bg-card-active!': value === store.currentGroup,
|
||||
}"
|
||||
@click="emit('click', value)"
|
||||
v-for="(value) in store.groupLength"
|
||||
:key="value"
|
||||
>
|
||||
<Radio :value="value" :label="`第${value}组`" />
|
||||
<span class="text-sm ml-2">{{ getGroupWordCount(value) }}词</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div class="target">第{{ store.currentGroup }}组</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.target {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
text-decoration: underline dashed gray;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 0.3rem;
|
||||
&:hover {
|
||||
text-decoration: underline dashed transparent;
|
||||
color: white;
|
||||
background: var(--color-icon-hightlight);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { _getAccomplishDays } from "@/utils";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import Checkbox from "@/components/base/checkbox/Checkbox.vue";
|
||||
import Slider from "@/components/base/Slider.vue";
|
||||
import { defineAsyncComponent, watch } from "vue";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import ChangeLastPracticeIndexDialog from "@/pages/word/components/ChangeLastPracticeIndexDialog.vue";
|
||||
import Tooltip from "@/components/base/Tooltip.vue";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
import BaseInput from "@/components/base/BaseInput.vue";
|
||||
import InputNumber from "@/components/base/InputNumber.vue";
|
||||
import { _getAccomplishDays } from '@/utils'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import Checkbox from '@/components/base/checkbox/Checkbox.vue'
|
||||
import Slider from '@/components/base/Slider.vue'
|
||||
import { defineAsyncComponent, watch } from 'vue'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import ChangeLastPracticeIndexDialog from '@/pages/word/components/ChangeLastPracticeIndexDialog.vue'
|
||||
import Tooltip from '@/components/base/Tooltip.vue'
|
||||
import { useRuntimeStore } from '@/stores/runtime.ts'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import InputNumber from '@/components/base/InputNumber.vue'
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
@@ -21,124 +20,128 @@ const runtimeStore = useRuntimeStore()
|
||||
const model = defineModel()
|
||||
|
||||
defineProps<{
|
||||
showLeftOption: boolean,
|
||||
showLeftOption: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
ok: [];
|
||||
ok: []
|
||||
}>()
|
||||
|
||||
let show = $ref(false)
|
||||
let tempPerDayStudyNumber = $ref(0)
|
||||
let tempWordReviewRatio = $ref(0)
|
||||
let tempLastLearnIndex = $ref(0)
|
||||
let temPracticeMode = $ref(0)
|
||||
let tempDisableShowPracticeSettingDialog = $ref(false)
|
||||
|
||||
|
||||
function changePerDayStudyNumber() {
|
||||
runtimeStore.editDict.perDayStudyNumber = tempPerDayStudyNumber
|
||||
runtimeStore.editDict.lastLearnIndex = tempLastLearnIndex
|
||||
settings.wordPracticeMode = temPracticeMode
|
||||
settings.wordReviewRatio = tempWordReviewRatio
|
||||
settings.disableShowPracticeSettingDialog = tempDisableShowPracticeSettingDialog
|
||||
emit('ok')
|
||||
}
|
||||
|
||||
watch(() => model.value, (n) => {
|
||||
if (n) {
|
||||
if (runtimeStore.editDict.id) {
|
||||
tempPerDayStudyNumber = runtimeStore.editDict.perDayStudyNumber
|
||||
tempLastLearnIndex = runtimeStore.editDict.lastLearnIndex
|
||||
temPracticeMode = settings.wordPracticeMode
|
||||
tempWordReviewRatio = settings.wordReviewRatio
|
||||
tempDisableShowPracticeSettingDialog = settings.disableShowPracticeSettingDialog
|
||||
} else {
|
||||
Toast.warning('请先选择一本词典')
|
||||
watch(
|
||||
() => model.value,
|
||||
n => {
|
||||
if (n) {
|
||||
if (runtimeStore.editDict.id) {
|
||||
tempPerDayStudyNumber = runtimeStore.editDict.perDayStudyNumber
|
||||
tempLastLearnIndex = runtimeStore.editDict.lastLearnIndex
|
||||
tempWordReviewRatio = settings.wordReviewRatio
|
||||
tempDisableShowPracticeSettingDialog = settings.disableShowPracticeSettingDialog
|
||||
} else {
|
||||
Toast.warning('请先选择一本词典')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="model"
|
||||
title="学习设置"
|
||||
padding
|
||||
:footer="true"
|
||||
@ok="changePerDayStudyNumber">
|
||||
<Dialog v-model="model" title="学习设置" padding :footer="true" @ok="changePerDayStudyNumber">
|
||||
<div class="target-modal color-main" id="mode">
|
||||
<div class="center">
|
||||
<div class="flex gap-4 text-center h-30 w-85">
|
||||
<div class="mode-item" :class="temPracticeMode == 0 && 'active'" @click=" temPracticeMode = 0">
|
||||
<div class="title text-align-center">智能模式</div>
|
||||
<div class="desc mt-2">自动规划学习、复习、听写、默写</div>
|
||||
</div>
|
||||
<div class="mode-item" :class="temPracticeMode == 1 && 'active'" @click=" temPracticeMode = 1">
|
||||
<div class="title">自由模式</div>
|
||||
<div class="desc mt-2">自由练习,系统不强制复习与默写</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<span>共<span class="text-3xl mx-2 inner">{{ runtimeStore.editDict.length }}</span>个单词,</span>
|
||||
<span>预计<span
|
||||
class="text-3xl mx-2 inner">{{
|
||||
_getAccomplishDays(runtimeStore.editDict.length - tempLastLearnIndex, tempPerDayStudyNumber)
|
||||
}}</span>天完成</span>
|
||||
<span
|
||||
>共<span class="target-number mx-2">{{ runtimeStore.editDict.length }}</span
|
||||
>个单词,</span
|
||||
>
|
||||
<span
|
||||
>预计<span class="target-number mx-2">{{
|
||||
_getAccomplishDays(
|
||||
runtimeStore.editDict.length - tempLastLearnIndex,
|
||||
tempPerDayStudyNumber
|
||||
)
|
||||
}}</span
|
||||
>天完成</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4 mb-8 flex gap-1 items-end justify-center">
|
||||
<span>从第</span>
|
||||
<div class="w-20">
|
||||
<BaseInput v-model="tempLastLearnIndex"/>
|
||||
<BaseInput class="target-number" v-model="tempLastLearnIndex" />
|
||||
</div>
|
||||
<span>个开始,每日</span>
|
||||
<div class="w-16">
|
||||
<BaseInput v-model="tempPerDayStudyNumber"/>
|
||||
<BaseInput class="target-number" v-model="tempPerDayStudyNumber" />
|
||||
</div>
|
||||
<span>个新词</span>
|
||||
<template v-if="temPracticeMode === 0">
|
||||
<span>,复习</span>
|
||||
<div class="inner -translate-y-1 mx-1">{{ tempPerDayStudyNumber * tempWordReviewRatio }}</div>
|
||||
<span>个</span>
|
||||
</template>
|
||||
<span>,复习</span>
|
||||
<div class="target-number mx-2">
|
||||
{{ tempPerDayStudyNumber * tempWordReviewRatio }}
|
||||
</div>
|
||||
<span>个</span>
|
||||
</div>
|
||||
|
||||
<div class="flex mb-4 gap-space" v-if="temPracticeMode === 0">
|
||||
<Tooltip title="复习词与新词的比例">
|
||||
<div class="flex items-center gap-1 w-20">
|
||||
<span>复习比</span>
|
||||
<IconFluentQuestionCircle20Regular/>
|
||||
<div class="mb-4 space-y-2">
|
||||
<div class="flex items-center gap-space">
|
||||
<Tooltip title="复习词与新词的比例">
|
||||
<div class="flex items-center gap-1 w-20 break-keep">
|
||||
<span>复习比</span>
|
||||
<IconFluentQuestionCircle20Regular />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<InputNumber :min="0" :max="10" v-model="tempWordReviewRatio" />
|
||||
</div>
|
||||
<div class="flex" v-if="!tempWordReviewRatio">
|
||||
<div class="w-23 flex-shrink-0"></div>
|
||||
<div class="text-sm text-gray-500">
|
||||
<div>未完成学习时,复习数量按照设置的复习比生成,为0则不复习</div>
|
||||
<div>完成学习后,新词数量固定为0,复习数量按照比例生成(若复习比小于1,以 1 计算)</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<InputNumber :min="0" :max="10" v-model="tempWordReviewRatio"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mb-4 gap-space">
|
||||
<span class="shrink-0 w-20">每日学习</span>
|
||||
<Slider :min="10"
|
||||
:step="10"
|
||||
show-text
|
||||
class="mt-1"
|
||||
:max="200" v-model="tempPerDayStudyNumber"/>
|
||||
<Slider
|
||||
:min="10"
|
||||
:step="10"
|
||||
show-text
|
||||
class="mt-1"
|
||||
:max="200"
|
||||
v-model="tempPerDayStudyNumber"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-space">
|
||||
<span class="shrink-0 w-20">学习进度</span>
|
||||
<div class="flex-1">
|
||||
<Slider :min="0"
|
||||
:step="10"
|
||||
show-text
|
||||
class="my-1"
|
||||
:max="runtimeStore.editDict.words.length" v-model="tempLastLearnIndex"/>
|
||||
<Slider
|
||||
:min="0"
|
||||
:step="10"
|
||||
show-text
|
||||
class="my-1"
|
||||
:max="runtimeStore.editDict.words.length"
|
||||
v-model="tempLastLearnIndex"
|
||||
/>
|
||||
<BaseButton @click="show = true">从词典选起始位置</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-slot:footer-left v-if="showLeftOption">
|
||||
<div class="flex items-center">
|
||||
<Checkbox v-model="tempDisableShowPracticeSettingDialog"/>
|
||||
<Checkbox v-model="tempDisableShowPracticeSettingDialog" />
|
||||
<Tooltip title="可在设置页面更改">
|
||||
<span class="text-sm">保持默认,不再显示</span>
|
||||
</Tooltip>
|
||||
@@ -146,24 +149,20 @@ watch(() => model.value, (n) => {
|
||||
</template>
|
||||
</Dialog>
|
||||
<ChangeLastPracticeIndexDialog
|
||||
v-model="show"
|
||||
@ok="e => {
|
||||
v-model="show"
|
||||
@ok="
|
||||
e => {
|
||||
tempLastLearnIndex = e
|
||||
show = false
|
||||
}"
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.target-modal {
|
||||
width: 35rem;
|
||||
|
||||
:deep(.inner) {
|
||||
font-size: 1.8rem;
|
||||
color: rgb(176, 116, 211)
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
@apply w-50% border border-blue border-solid p-2 rounded-lg cursor-pointer;
|
||||
}
|
||||
@@ -212,7 +211,8 @@ watch(() => model.value, (n) => {
|
||||
}
|
||||
|
||||
// 滑块控件
|
||||
.flex.mb-4, .flex.mb-6 {
|
||||
.flex.mb-4,
|
||||
.flex.mb-6 {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import BaseTable from "@/components/BaseTable.vue";
|
||||
import WordItem from "@/components/WordItem.vue";
|
||||
import { defineAsyncComponent } from "vue";
|
||||
import { TaskWords } from "@/types/types.ts";
|
||||
import type { TaskWords } from "@/types/types.ts";
|
||||
import Checkbox from "@/components/base/checkbox/Checkbox.vue";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
@@ -31,10 +31,10 @@ watch(() => model.value, (n) => {
|
||||
:footer="true"
|
||||
:padding="true"
|
||||
@ok="emit('ok',num)">
|
||||
<div class="target-modal color-main">
|
||||
<div class="w-120 color-main">
|
||||
<div class="flex gap-4 items-end mb-2">
|
||||
<span>随机复习:<span class="font-bold">{{ store.sdict.name }}</span></span>
|
||||
<span class="text-3xl lh">{{ num }}</span>个单词
|
||||
<span class="target-number">{{ num }}</span>个单词
|
||||
</div>
|
||||
<div class="flex gap-space">
|
||||
<span class="shrink-0">随机数量:</span>
|
||||
@@ -45,25 +45,13 @@ watch(() => model.value, (n) => {
|
||||
:max="store.sdict.lastLearnIndex"
|
||||
v-model="num"/>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm text-gray-500">只能复习已学习过的单词</span>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.target-modal {
|
||||
width: 30rem;
|
||||
|
||||
.lh {
|
||||
color: rgb(176, 116, 211)
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
@apply w-50% border border-blue border-solid p-2 rounded-lg cursor-pointer;
|
||||
}
|
||||
|
||||
.active {
|
||||
@apply bg-blue color-white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { useBaseStore } from "@/stores/base.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import { ShortcutKey, Statistics, TaskWords } from "@/types/types.ts";
|
||||
import { emitter, EventKey, useEvents } from "@/utils/eventBus.ts";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { usePracticeStore } from "@/stores/practice.ts";
|
||||
import dayjs from "dayjs";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
import { defineAsyncComponent, inject, watch } from "vue";
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import type { Statistics, TaskWords } from '@/types/types.ts'
|
||||
import { emitter, EventKey, useEvents } from '@/utils/eventBus.ts'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { usePracticeStore } from '@/stores/practice.ts'
|
||||
import dayjs from 'dayjs'
|
||||
import isBetween from 'dayjs/plugin/isBetween'
|
||||
import { defineAsyncComponent, inject, watch } from 'vue'
|
||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import { msToHourMinute } from "@/utils";
|
||||
import Progress from "@/components/base/Progress.vue";
|
||||
import ChannelIcons from "@/components/ChannelIcons/ChannelIcons.vue";
|
||||
import { AppEnv } from "@/config/env.ts";
|
||||
import { addStat } from "@/apis";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import { msToHourMinute } from '@/utils'
|
||||
import Progress from '@/components/base/Progress.vue'
|
||||
import ChannelIcons from '@/components/ChannelIcons/ChannelIcons.vue'
|
||||
import { AppEnv } from '@/config/env.ts'
|
||||
import { addStat } from '@/apis'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import { ShortcutKey, WordPracticeMode } from '@/types/enum.ts'
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(isBetween)
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
const store = useBaseStore()
|
||||
const settingStore = useSettingStore()
|
||||
const statStore = usePracticeStore()
|
||||
const model = defineModel({default: false})
|
||||
const model = defineModel({ default: false })
|
||||
let list = $ref([])
|
||||
let dictIsEnd = $ref(false)
|
||||
let practiceTaskWords = inject<TaskWords>('practiceTaskWords')
|
||||
|
||||
function calcWeekList() {
|
||||
// 获取本周的起止时间
|
||||
const startOfWeek = dayjs().startOf('isoWeek'); // 周一
|
||||
const endOfWeek = dayjs().endOf('isoWeek'); // 周日
|
||||
const startOfWeek = dayjs().startOf('isoWeek') // 周一
|
||||
const endOfWeek = dayjs().endOf('isoWeek') // 周日
|
||||
// 初始化 7 天的数组,默认 false
|
||||
const weekList = Array(7).fill(false);
|
||||
const weekList = Array(7).fill(false)
|
||||
|
||||
store.sdict.statistics.forEach(item => {
|
||||
const date = dayjs(item.startDate);
|
||||
const date = dayjs(item.startDate)
|
||||
if (date.isBetween(startOfWeek, endOfWeek, null, '[]')) {
|
||||
let idx = date.day();
|
||||
let idx = date.day()
|
||||
// dayjs().day() 0=周日, 1=周一, ..., 6=周六
|
||||
// 需要转换为 0=周一, ..., 6=周日
|
||||
if (idx === 0) {
|
||||
idx = 6; // 周日放到最后
|
||||
idx = 6 // 周日放到最后
|
||||
} else {
|
||||
idx = idx - 1; // 其余前移一位
|
||||
idx = idx - 1 // 其余前移一位
|
||||
}
|
||||
weekList[idx] = true;
|
||||
weekList[idx] = true
|
||||
}
|
||||
});
|
||||
weekList[2] = true;
|
||||
list = weekList;
|
||||
})
|
||||
list = weekList
|
||||
}
|
||||
|
||||
// 监听 model 弹窗打开时重新计算
|
||||
watch(model, async (newVal) => {
|
||||
watch(model, async newVal => {
|
||||
if (newVal) {
|
||||
dictIsEnd = false;
|
||||
dictIsEnd = false
|
||||
let data: Statistics = {
|
||||
spend: statStore.spend,
|
||||
startDate: statStore.startDate,
|
||||
total: statStore.total,
|
||||
wrong: statStore.wrong,
|
||||
new: statStore.newWordNumber,
|
||||
review: statStore.reviewWordNumber + statStore.writeWordNumber
|
||||
review: statStore.reviewWordNumber + statStore.writeWordNumber,
|
||||
}
|
||||
window.umami?.track('endStudyWord', {
|
||||
name: store.sdict.name,
|
||||
@@ -72,15 +72,21 @@ watch(model, async (newVal) => {
|
||||
perDayStudyNumber: store.sdict.perDayStudyNumber,
|
||||
custom: store.sdict.custom,
|
||||
complete: store.sdict.complete,
|
||||
str: `name:${store.sdict.name},per:${store.sdict.perDayStudyNumber},spend:${Number(statStore.spend / 1000 / 60).toFixed(1)},index:${store.sdict.lastLearnIndex}`
|
||||
str: `name:${store.sdict.name},per:${store.sdict.perDayStudyNumber},spend:${Number(statStore.spend / 1000 / 60).toFixed(1)},index:${store.sdict.lastLearnIndex}`,
|
||||
})
|
||||
|
||||
//如果 shuffle 数组不为空,就说明是复习,不用修改 lastLearnIndex
|
||||
if (!practiceTaskWords.shuffle.length) {
|
||||
if (settingStore.wordPracticeMode !== WordPracticeMode.Shuffle) {
|
||||
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex + statStore.newWordNumber
|
||||
if (store.sdict.lastLearnIndex >= store.sdict.length) {
|
||||
dictIsEnd = true;
|
||||
// 检查已忽略的单词数量,是否全部完成
|
||||
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
|
||||
// 忽略单词数
|
||||
const ignoreCount = ignoreList.filter(word => store.sdict.words.some(w => w.word.toLowerCase() === word)).length
|
||||
// 如果lastLearnIndex已经超过可学单词数,则判定完成
|
||||
if (store.sdict.lastLearnIndex + ignoreCount >= store.sdict.length) {
|
||||
dictIsEnd = true
|
||||
store.sdict.complete = true
|
||||
store.sdict.lastLearnIndex = 0
|
||||
store.sdict.lastLearnIndex = store.sdict.length
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +104,11 @@ watch(model, async (newVal) => {
|
||||
}
|
||||
|
||||
store.sdict.statistics.push(data as any)
|
||||
calcWeekList(); // 新增:计算本周学习记录
|
||||
calcWeekList() // 新增:计算本周学习记录
|
||||
}
|
||||
})
|
||||
|
||||
const close = () => model.value = false
|
||||
const close = () => (model.value = false)
|
||||
|
||||
useEvents([
|
||||
//特意注释掉,因为在练习界面用快捷键下一组时,需要判断是否在结算界面
|
||||
@@ -143,66 +149,53 @@ const formattedStudyTime = $computed(() => {
|
||||
return time.replace('小时', 'h ').replace('分钟', 'm')
|
||||
})
|
||||
|
||||
calcWeekList(); // 新增:计算本周学习记录
|
||||
|
||||
calcWeekList() // 新增:计算本周学习记录
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="model"
|
||||
:close-on-click-bg="false"
|
||||
:header="false"
|
||||
:keyboard="false"
|
||||
:show-close="false">
|
||||
<Dialog v-model="model" :close-on-click-bg="false" :header="false" :keyboard="false" :show-close="false">
|
||||
<div class="p-8 pr-3 bg-[var(--bg-card-primary)] rounded-2xl space-y-6">
|
||||
<!-- Header Section -->
|
||||
<div class="text-center relative">
|
||||
<div
|
||||
class="text-3xl font-bold mb-2 bg-gradient-to-r from-purple-500 to-purple-700 bg-clip-text text-transparent">
|
||||
<template v-if="practiceTaskWords.shuffle.length">
|
||||
🎯 随机复习完成
|
||||
</template>
|
||||
<template v-else>
|
||||
🎉 今日任务完成
|
||||
</template>
|
||||
class="text-3xl font-bold mb-2 bg-gradient-to-r from-purple-500 to-purple-700 bg-clip-text text-transparent"
|
||||
>
|
||||
<template v-if="practiceTaskWords.shuffle.length"> 🎯 复习完成 </template>
|
||||
<template v-else> 🎉 今日任务完成 </template>
|
||||
</div>
|
||||
<p class="font-medium text-lg">{{ encouragementText }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<!-- Study Time -->
|
||||
<div class="item">
|
||||
<IconFluentClock20Regular class="text-purple-500"/>
|
||||
<IconFluentClock20Regular class="text-purple-500" />
|
||||
<div class="text-sm mb-1 font-medium">学习时长</div>
|
||||
<div class="text-xl font-bold">{{ formattedStudyTime }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Accuracy Rate -->
|
||||
<div class="item">
|
||||
<IconFluentTarget20Regular class="text-purple-500"/>
|
||||
<IconFluentTarget20Regular class="text-purple-500" />
|
||||
<div class="text-sm mb-1 font-medium">正确率</div>
|
||||
<div class="text-xl font-bold">{{ accuracyRate }}%</div>
|
||||
</div>
|
||||
|
||||
<!-- New Words -->
|
||||
<div class="item">
|
||||
<IconFluentSparkle20Regular class="text-purple-500"/>
|
||||
<IconFluentSparkle20Regular class="text-purple-500" />
|
||||
<div class="text-sm mb-1 font-medium">新词</div>
|
||||
<div class="text-xl font-bold ">{{ statStore.newWordNumber }}</div>
|
||||
<div class="text-xl font-bold">{{ statStore.newWordNumber }}</div>
|
||||
</div>
|
||||
|
||||
<!-- New Words -->
|
||||
<div class="item">
|
||||
<IconFluentBook20Regular class="text-purple-500"/>
|
||||
<IconFluentBook20Regular class="text-purple-500" />
|
||||
<div class="text-sm mb-1 font-medium">复习</div>
|
||||
<div class="text-xl font-bold">{{ statStore.reviewWordNumber + statStore.writeWordNumber }}</div>
|
||||
<div class="text-xl font-bold">
|
||||
{{ statStore.reviewWordNumber + statStore.writeWordNumber }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full gap-3 flex">
|
||||
<div class="space-y-6 flex-1">
|
||||
|
||||
<!-- Weekly Progress -->
|
||||
<div class="bg-[--bg-card-secend] rounded-xl p-2">
|
||||
<div class="text-center mb-4">
|
||||
@@ -216,8 +209,10 @@ calcWeekList(); // 新增:计算本周学习记录
|
||||
:class="item ? 'bg-green-500 text-white shadow-lg' : 'bg-white text-gray-700'"
|
||||
>
|
||||
<div class="font-semibold mb-1">{{ i + 1 }}</div>
|
||||
<div class="w-2 h-2 rounded-full mx-auto mb-1"
|
||||
:class="item ? 'bg-white bg-opacity-30' : 'bg-gray-300'"></div>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full mx-auto mb-1"
|
||||
:class="item ? 'bg-white bg-opacity-30' : 'bg-gray-300'"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,44 +223,39 @@ calcWeekList(); // 新增:计算本周学习记录
|
||||
<div class="text-xl font-semibold">学习进度</div>
|
||||
<div class="text-2xl font-bold text-purple-600">{{ studyProgress }}%</div>
|
||||
</div>
|
||||
<Progress :percentage="studyProgress" size="large" :show-text="false"/>
|
||||
<Progress :percentage="studyProgress" size="large" :show-text="false" />
|
||||
<div class="flex justify-between text-sm font-medium mt-4">
|
||||
<span>已学习: {{ store.sdict.lastLearnIndex }}</span>
|
||||
<span>总词数: {{ store.sdict.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChannelIcons/>
|
||||
<ChannelIcons />
|
||||
</div>
|
||||
<!-- Action Buttons -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="flex min-w-130 justify-center">
|
||||
<BaseButton
|
||||
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.RepeatChapter]"
|
||||
@click="options(EventKey.repeatStudy)">
|
||||
@click="options(EventKey.repeatStudy)"
|
||||
>
|
||||
<div class="center gap-2">
|
||||
<IconFluentArrowClockwise20Regular/>
|
||||
<IconFluentArrowClockwise20Regular />
|
||||
重学一遍
|
||||
</div>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="settingStore.wordPracticeMode !== WordPracticeMode.Review"
|
||||
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextChapter]"
|
||||
@click="options(EventKey.continueStudy)">
|
||||
@click="options(EventKey.continueStudy)"
|
||||
>
|
||||
<div class="center gap-2">
|
||||
<IconFluentPlay20Regular/>
|
||||
<IconFluentPlay20Regular />
|
||||
{{ dictIsEnd ? '从头开始练习' : '再来一组' }}
|
||||
</div>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextRandomWrite]"
|
||||
@click="options(EventKey.randomWrite)">
|
||||
<div class="center gap-2">
|
||||
<IconFluentPen20Regular/>
|
||||
继续默写
|
||||
</div>
|
||||
</BaseButton>
|
||||
<BaseButton @click="$router.back">
|
||||
<div class="center gap-2">
|
||||
<IconFluentHome20Regular/>
|
||||
<IconFluentHome20Regular />
|
||||
返回主页
|
||||
</div>
|
||||
</BaseButton>
|
||||
@@ -274,7 +264,6 @@ calcWeekList(); // 新增:计算本周学习记录
|
||||
</Dialog>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
// 弹窗容器优化
|
||||
@@ -359,12 +348,10 @@ calcWeekList(); // 新增:计算本周学习记录
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
@apply bg-[var(--bg-card-secend)] rounded-xl p-2 text-center border border-gray-100;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,21 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ShortcutKey, Word, WordPracticeType } from "@/types/types.ts";
|
||||
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { usePlayBeep, usePlayCorrect, usePlayKeyboardAudio, usePlayWordAudio } from "@/hooks/sound.ts";
|
||||
import { emitter, EventKey, useEvents } from "@/utils/eventBus.ts";
|
||||
import { onMounted, onUnmounted, watch } from "vue";
|
||||
import SentenceHightLightWord from "@/pages/word/components/SentenceHightLightWord.vue";
|
||||
import { usePracticeStore } from "@/stores/practice.ts";
|
||||
import { getDefaultWord } from "@/types/func.ts";
|
||||
import { _nextTick, last } from "@/utils";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import Space from "@/pages/article/components/Space.vue";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import Tooltip from "@/components/base/Tooltip.vue";
|
||||
import type { Word } from '@/types/types.ts'
|
||||
import VolumeIcon from '@/components/icon/VolumeIcon.vue'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { usePlayBeep, usePlayCorrect, usePlayKeyboardAudio, usePlayWordAudio } from '@/hooks/sound.ts'
|
||||
import { emitter, EventKey, useEvents } from '@/utils/eventBus.ts'
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
import SentenceHightLightWord from '@/pages/word/components/SentenceHightLightWord.vue'
|
||||
import { usePracticeStore } from '@/stores/practice.ts'
|
||||
import { getDefaultWord } from '@/types/func.ts'
|
||||
import { _nextTick, last } from '@/utils'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import Space from '@/pages/article/components/Space.vue'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import Tooltip from '@/components/base/Tooltip.vue'
|
||||
import { ShortcutKey, WordPracticeStage, WordPracticeType } from '@/types/enum.ts'
|
||||
|
||||
interface IProps {
|
||||
word: Word,
|
||||
word: Word
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
@@ -23,9 +24,9 @@ const props = withDefaults(defineProps<IProps>(), {
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: [],
|
||||
wrong: [],
|
||||
know: [],
|
||||
complete: []
|
||||
wrong: []
|
||||
know: []
|
||||
}>()
|
||||
|
||||
let input = $ref('')
|
||||
@@ -63,8 +64,8 @@ function updateCurrentWordInfo() {
|
||||
word: props.word.word,
|
||||
input: input,
|
||||
inputLock: inputLock,
|
||||
containsSpace: props.word.word.includes(' ')
|
||||
};
|
||||
containsSpace: props.word.word.includes(' '),
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.word, reset, { deep: true })
|
||||
@@ -73,25 +74,28 @@ function reset() {
|
||||
wrong = input = ''
|
||||
wordRepeatCount = 0
|
||||
showWordResult = inputLock = false
|
||||
wordCompletedTime = 0 // 重置时间戳
|
||||
wordCompletedTime = 0 // 重置时间戳
|
||||
if (settingStore.wordSound) {
|
||||
if (settingStore.wordPracticeType !== WordPracticeType.Dictation) {
|
||||
volumeIconRef?.play(400, true)
|
||||
}
|
||||
}
|
||||
// 更新当前单词信息
|
||||
updateCurrentWordInfo();
|
||||
updateCurrentWordInfo()
|
||||
checkCursorPosition()
|
||||
}
|
||||
|
||||
// 监听输入变化,更新当前单词信息
|
||||
watch(() => input, () => {
|
||||
updateCurrentWordInfo();
|
||||
})
|
||||
watch(
|
||||
() => input,
|
||||
() => {
|
||||
updateCurrentWordInfo()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化当前单词信息
|
||||
updateCurrentWordInfo();
|
||||
updateCurrentWordInfo()
|
||||
|
||||
emitter.on(EventKey.resetWord, reset)
|
||||
emitter.on(EventKey.onTyping, onTyping)
|
||||
@@ -229,7 +233,7 @@ async function onTyping(e: KeyboardEvent) {
|
||||
input += letter
|
||||
wrong = ''
|
||||
playKeyboardAudio()
|
||||
updateCurrentWordInfo();
|
||||
updateCurrentWordInfo()
|
||||
inputLock = false
|
||||
} else if (settingStore.wordPracticeType === WordPracticeType.Identify && !showWordResult) {
|
||||
//当自测模式下,按1和2会单独处理,如果按其他键则自动默认为不认识
|
||||
@@ -294,12 +298,15 @@ async function onTyping(e: KeyboardEvent) {
|
||||
}, 500)
|
||||
}
|
||||
// 更新当前单词信息
|
||||
updateCurrentWordInfo();
|
||||
updateCurrentWordInfo()
|
||||
//不需要把inputLock设为false,输入完成不能再输入了,只能删除,删除会打开锁
|
||||
if (input.toLowerCase() === word.toLowerCase()) {
|
||||
wordCompletedTime = Date.now() // 记录单词完成的时间戳
|
||||
wordCompletedTime = Date.now() // 记录单词完成的时间戳
|
||||
playCorrect()
|
||||
if ([WordPracticeType.Listen, WordPracticeType.Identify].includes(settingStore.wordPracticeType) && !showWordResult) {
|
||||
if (
|
||||
[WordPracticeType.Listen, WordPracticeType.Identify].includes(settingStore.wordPracticeType) &&
|
||||
!showWordResult
|
||||
) {
|
||||
showWordResult = true
|
||||
}
|
||||
if ([WordPracticeType.FollowWrite, WordPracticeType.Spell].includes(settingStore.wordPracticeType)) {
|
||||
@@ -339,7 +346,7 @@ function del() {
|
||||
}
|
||||
}
|
||||
// 更新当前单词信息
|
||||
updateCurrentWordInfo();
|
||||
updateCurrentWordInfo()
|
||||
}
|
||||
|
||||
function showWord() {
|
||||
@@ -349,16 +356,8 @@ function showWord() {
|
||||
}
|
||||
showFullWord = true
|
||||
//系统设定的默认模式情况下,如果看了单词统计到错词里面去
|
||||
switch (statStore.step) {
|
||||
case 1:
|
||||
case 2:
|
||||
case 4:
|
||||
case 5:
|
||||
case 7:
|
||||
case 8:
|
||||
case 10:
|
||||
emit('wrong')
|
||||
break
|
||||
if (statStore.stage !== WordPracticeStage.FollowWriteNewWord) {
|
||||
emit('wrong')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,7 +390,7 @@ function hideWordInTranslation(text: string, word: string): string {
|
||||
// 创建正则表达式,匹配单词本身及其常见变形(如复数、过去式等)
|
||||
const wordBase = word.toLowerCase()
|
||||
const patterns = [
|
||||
`\\b${escapeRegExp(wordBase)}\\b`, // 单词本身
|
||||
`\\b${escapeRegExp(wordBase)}\\b`, // 单词本身
|
||||
`\\b${escapeRegExp(wordBase)}s\\b`, // 复数形式
|
||||
`\\b${escapeRegExp(wordBase)}es\\b`, // 复数形式
|
||||
`\\b${escapeRegExp(wordBase)}ed\\b`, // 过去式
|
||||
@@ -418,32 +417,32 @@ watch([() => input, () => showFullWord, () => settingStore.dictation], checkCurs
|
||||
function checkCursorPosition() {
|
||||
_nextTick(() => {
|
||||
// 选中目标元素
|
||||
const cursorEl = document.querySelector(`.cursor`);
|
||||
const inputList = document.querySelectorAll(`.l`);
|
||||
if (!typingWordRef) return;
|
||||
const typingWordRect = typingWordRef.getBoundingClientRect();
|
||||
const cursorEl = document.querySelector(`.cursor`)
|
||||
const inputList = document.querySelectorAll(`.l`)
|
||||
if (!typingWordRef) return
|
||||
const typingWordRect = typingWordRef.getBoundingClientRect()
|
||||
|
||||
if (inputList.length) {
|
||||
let inputRect = last(Array.from(inputList)).getBoundingClientRect();
|
||||
let inputRect = last(Array.from(inputList)).getBoundingClientRect()
|
||||
cursor = {
|
||||
top: inputRect.top + inputRect.height - cursorEl.clientHeight - typingWordRect.top,
|
||||
left: inputRect.right - typingWordRect.left - 3,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const dictation = document.querySelector(`.dictation`);
|
||||
const dictation = document.querySelector(`.dictation`)
|
||||
let elRect
|
||||
if (dictation) {
|
||||
elRect = dictation.getBoundingClientRect();
|
||||
elRect = dictation.getBoundingClientRect()
|
||||
} else {
|
||||
const letter = document.querySelector(`.letter`);
|
||||
elRect = letter.getBoundingClientRect();
|
||||
const letter = document.querySelector(`.letter`)
|
||||
elRect = letter.getBoundingClientRect()
|
||||
}
|
||||
cursor = {
|
||||
top: elRect.top + elRect.height - cursorEl.clientHeight - typingWordRect.top,
|
||||
left: elRect.left - typingWordRect.left - 3,
|
||||
};
|
||||
}
|
||||
}
|
||||
},)
|
||||
})
|
||||
}
|
||||
|
||||
useEvents([
|
||||
@@ -456,42 +455,64 @@ useEvents([
|
||||
<div class="typing-word" ref="typingWordRef" v-if="word.word.length">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="flex gap-1 mt-30">
|
||||
<div class="phonetic"
|
||||
:class="!(!settingStore.dictation || showFullWord || showWordResult) && 'word-shadow'"
|
||||
v-if="settingStore.soundType === 'uk' && word.phonetic0">[{{ word.phonetic0 }}]
|
||||
<div
|
||||
class="phonetic"
|
||||
:class="!(!settingStore.dictation || showFullWord || showWordResult) && 'word-shadow'"
|
||||
v-if="settingStore.soundType === 'uk' && word.phonetic0"
|
||||
>
|
||||
[{{ word.phonetic0 }}]
|
||||
</div>
|
||||
<div class="phonetic"
|
||||
:class="((settingStore.dictation || [WordPracticeType.Spell,WordPracticeType.Listen,WordPracticeType.Dictation].includes(settingStore.wordPracticeType)) && !showFullWord && !showWordResult) && 'word-shadow'"
|
||||
v-if="settingStore.soundType === 'us' && word.phonetic1">[{{ word.phonetic1 }}]
|
||||
<div
|
||||
class="phonetic"
|
||||
:class="
|
||||
(settingStore.dictation ||
|
||||
[WordPracticeType.Spell, WordPracticeType.Listen, WordPracticeType.Dictation].includes(
|
||||
settingStore.wordPracticeType
|
||||
)) &&
|
||||
!showFullWord &&
|
||||
!showWordResult &&
|
||||
'word-shadow'
|
||||
"
|
||||
v-if="settingStore.soundType === 'us' && word.phonetic1"
|
||||
>
|
||||
[{{ word.phonetic1 }}]
|
||||
</div>
|
||||
<VolumeIcon
|
||||
:title="`发音(${settingStore.shortcutKeyMap[ShortcutKey.PlayWordPronunciation]})`"
|
||||
ref="volumeIconRef" :simple="true" :cb="() => playWordAudio(word.word)"/>
|
||||
:title="`发音(${settingStore.shortcutKeyMap[ShortcutKey.PlayWordPronunciation]})`"
|
||||
ref="volumeIconRef"
|
||||
:simple="true"
|
||||
:cb="() => playWordAudio(word.word)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
:title="(settingStore.dictation)
|
||||
? `可以按快捷键 ${settingStore.shortcutKeyMap[ShortcutKey.ShowWord]} 显示正确答案`
|
||||
: ''
|
||||
">
|
||||
<div id="word" class="word my-1"
|
||||
:class="wrong && 'is-wrong'"
|
||||
:style="{fontSize: settingStore.fontSize.wordForeignFontSize +'px'}"
|
||||
@mouseenter="showWord"
|
||||
@mouseleave="mouseleave"
|
||||
:title="
|
||||
settingStore.dictation ? `可以按快捷键 ${settingStore.shortcutKeyMap[ShortcutKey.ShowWord]} 显示正确答案` : ''
|
||||
"
|
||||
>
|
||||
<div
|
||||
id="word"
|
||||
class="word my-1"
|
||||
:class="wrong && 'is-wrong'"
|
||||
:style="{ fontSize: settingStore.fontSize.wordForeignFontSize + 'px' }"
|
||||
@mouseenter="showWord"
|
||||
@mouseleave="mouseleave"
|
||||
>
|
||||
<div v-if="settingStore.wordPracticeType === WordPracticeType.Dictation">
|
||||
<div class="letter text-align-center w-full inline-block"
|
||||
v-opacity="!settingStore.dictation || showWordResult || showFullWord">
|
||||
<div
|
||||
class="letter text-align-center w-full inline-block"
|
||||
v-opacity="!settingStore.dictation || showWordResult || showFullWord"
|
||||
>
|
||||
{{ word.word }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 w-120 dictation"
|
||||
:style="{minHeight: settingStore.fontSize.wordForeignFontSize +'px'}"
|
||||
:class="showWordResult ? (right ? 'right' : 'wrong') : ''">
|
||||
class="mt-2 w-120 dictation"
|
||||
:style="{ minHeight: settingStore.fontSize.wordForeignFontSize + 'px' }"
|
||||
:class="showWordResult ? (right ? 'right' : 'wrong') : ''"
|
||||
>
|
||||
<template v-for="i in input">
|
||||
<span class="l" v-if="i !== ' '">{{ i }}</span>
|
||||
<Space class="l" v-else :is-wrong="showWordResult ? (!right) : false" :is-wait="!showWordResult"/>
|
||||
<Space class="l" v-else :is-wrong="showWordResult ? !right : false" :is-wait="!showWordResult" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -499,46 +520,72 @@ useEvents([
|
||||
<span class="input" v-if="input">{{ input }}</span>
|
||||
<span class="wrong" v-if="wrong">{{ wrong }}</span>
|
||||
<span class="letter" v-if="settingStore.dictation && !showFullWord">
|
||||
{{ displayWord.split('').map((v) => (v === ' ' ? ' ' : '_')).join('') }}
|
||||
</span>
|
||||
{{
|
||||
displayWord
|
||||
.split('')
|
||||
.map(v => (v === ' ' ? ' ' : '_'))
|
||||
.join('')
|
||||
}}
|
||||
</span>
|
||||
<span class="letter" v-else>{{ displayWord }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div class="mt-4 flex gap-4"
|
||||
v-if="settingStore.wordPracticeType === WordPracticeType.Identify && !showWordResult">
|
||||
<div
|
||||
class="mt-4 flex gap-4"
|
||||
v-if="settingStore.wordPracticeType === WordPracticeType.Identify && !showWordResult"
|
||||
>
|
||||
<BaseButton
|
||||
:keyboard="`快捷键(${settingStore.shortcutKeyMap[ShortcutKey.KnowWord]})`"
|
||||
size="large" @click="know">我认识
|
||||
:keyboard="`快捷键(${settingStore.shortcutKeyMap[ShortcutKey.KnowWord]})`"
|
||||
size="large"
|
||||
@click="know"
|
||||
>我认识
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:keyboard="`快捷键(${settingStore.shortcutKeyMap[ShortcutKey.UnknownWord]})`"
|
||||
size="large" @click="unknown">不认识
|
||||
:keyboard="`快捷键(${settingStore.shortcutKeyMap[ShortcutKey.UnknownWord]})`"
|
||||
size="large"
|
||||
@click="unknown"
|
||||
>不认识
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="translate flex flex-col gap-2 my-3"
|
||||
v-opacity="settingStore.translate || showWordResult || showFullWord"
|
||||
:style="{
|
||||
fontSize: settingStore.fontSize.wordTranslateFontSize +'px',
|
||||
}"
|
||||
<div
|
||||
class="translate flex flex-col gap-2 my-3"
|
||||
v-opacity="settingStore.translate || showWordResult || showFullWord"
|
||||
:style="{
|
||||
fontSize: settingStore.fontSize.wordTranslateFontSize + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="flex" v-for="v in word.trans">
|
||||
<div class="shrink-0" :class="v.pos ? 'w-12 en-article-family' : '-ml-3'">{{ v.pos }}</div>
|
||||
<div class="shrink-0" :class="v.pos ? 'w-12 en-article-family' : '-ml-3'">
|
||||
{{ v.pos }}
|
||||
</div>
|
||||
<span v-if="!settingStore.dictation || showWordResult || showFullWord">{{ v.cn }}</span>
|
||||
<span v-else v-html="hideWordInTranslation(v.cn, word.word)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="other anim"
|
||||
v-opacity="![WordPracticeType.Listen,WordPracticeType.Dictation,WordPracticeType.Identify].includes(settingStore.wordPracticeType) || showFullWord || showWordResult">
|
||||
<div
|
||||
class="other anim"
|
||||
v-opacity="
|
||||
![WordPracticeType.Listen, WordPracticeType.Dictation, WordPracticeType.Identify].includes(
|
||||
settingStore.wordPracticeType
|
||||
) ||
|
||||
showFullWord ||
|
||||
showWordResult
|
||||
"
|
||||
>
|
||||
<div class="line-white my-3"></div>
|
||||
<template v-if="word?.sentences?.length">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="sentence" v-for="item in word.sentences">
|
||||
<SentenceHightLightWord class="text-xl" :text="item.c" :word="word.word"
|
||||
:dictation="!(!settingStore.dictation || showFullWord || showWordResult)"/>
|
||||
<SentenceHightLightWord
|
||||
class="text-xl"
|
||||
:text="item.c"
|
||||
:word="word.word"
|
||||
:dictation="!(!settingStore.dictation || showFullWord || showWordResult)"
|
||||
/>
|
||||
<div class="text-base anim" v-opacity="settingStore.translate || showFullWord || showWordResult">
|
||||
{{ item.cn }}
|
||||
</div>
|
||||
@@ -552,8 +599,12 @@ useEvents([
|
||||
<div class="label">短语</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-4" v-for="item in word.phrases">
|
||||
<SentenceHightLightWord class="en" :text="item.c" :word="word.word"
|
||||
:dictation="!(!settingStore.dictation || showFullWord || showWordResult)"/>
|
||||
<SentenceHightLightWord
|
||||
class="en"
|
||||
:text="item.c"
|
||||
:word="word.word"
|
||||
:dictation="!(!settingStore.dictation || showFullWord || showWordResult)"
|
||||
/>
|
||||
<div class="cn anim" v-opacity="settingStore.translate || showFullWord || showWordResult">
|
||||
{{ item.cn }}
|
||||
</div>
|
||||
@@ -562,11 +613,11 @@ useEvents([
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="(settingStore.translate || !settingStore.dictation)">
|
||||
<template v-if="settingStore.translate || !settingStore.dictation">
|
||||
<template v-if="word?.synos?.length">
|
||||
<div class="line-white my-3"></div>
|
||||
<div class="flex">
|
||||
<div class='label'>同近义词</div>
|
||||
<div class="label">同近义词</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex" v-for="item in word.synos">
|
||||
<div class="pos line-height-1.4rem!">{{ item.pos }}</div>
|
||||
@@ -575,7 +626,7 @@ useEvents([
|
||||
{{ item.cn }}
|
||||
</div>
|
||||
<div class="anim" v-opacity="!settingStore.dictation || showFullWord || showWordResult">
|
||||
<span class="en" v-for="(i,j) in item.ws">
|
||||
<span class="en" v-for="(i, j) in item.ws">
|
||||
{{ i }} {{ j !== item.ws.length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -586,8 +637,10 @@ useEvents([
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div class="anim"
|
||||
v-opacity="(settingStore.translate && !settingStore.dictation) || showFullWord || showWordResult">
|
||||
<div
|
||||
class="anim"
|
||||
v-opacity="(settingStore.translate && !settingStore.dictation) || showFullWord || showWordResult"
|
||||
>
|
||||
<template v-if="word?.etymology?.length">
|
||||
<div class="line-white my-3"></div>
|
||||
|
||||
@@ -624,26 +677,32 @@ useEvents([
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cursor"
|
||||
:style="{top:cursor.top+'px',left:cursor.left+'px',height: settingStore.fontSize.wordForeignFontSize +'px'}"></div>
|
||||
<div
|
||||
class="cursor"
|
||||
:style="{
|
||||
top: cursor.top + 'px',
|
||||
left: cursor.left + 'px',
|
||||
height: settingStore.fontSize.wordForeignFontSize + 'px',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dictation {
|
||||
border-bottom: 2px solid black;
|
||||
border-bottom: 2px solid gray;
|
||||
}
|
||||
|
||||
.typing-word {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
//overflow: auto;
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
color: var(--color-font-2);
|
||||
padding-bottom: 8rem;
|
||||
|
||||
.phonetic, .translate {
|
||||
.phonetic,
|
||||
.translate {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
@@ -656,10 +715,10 @@ useEvents([
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
font-family: var(--en-article-family);
|
||||
letter-spacing: .3rem;
|
||||
letter-spacing: 0.3rem;
|
||||
|
||||
|
||||
.input, .right {
|
||||
.input,
|
||||
.right {
|
||||
color: rgb(22, 163, 74);
|
||||
}
|
||||
|
||||
@@ -708,7 +767,6 @@ useEvents([
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
|
||||
.typing-word {
|
||||
padding: 0 0.5rem 12rem;
|
||||
|
||||
@@ -718,7 +776,8 @@ useEvents([
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.phonetic, .translate {
|
||||
.phonetic,
|
||||
.translate {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@@ -794,7 +853,8 @@ useEvents([
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
|
||||
.phonetic, .translate {
|
||||
.phonetic,
|
||||
.translate {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
128
src/pages/word/components/VolumeSettingMiniDialog.vue
Normal file
128
src/pages/word/components/VolumeSettingMiniDialog.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import Switch from '@/components/base/Switch.vue'
|
||||
import { Option, Select } from '@/components/base/select'
|
||||
import MiniDialog from '@/components/dialog/MiniDialog.vue'
|
||||
import VolumeIcon from '@/components/icon/VolumeIcon.vue'
|
||||
import { SoundFileOptions } from '@/config/env.ts'
|
||||
import { useWindowClick } from '@/hooks/event.ts'
|
||||
import { getAudioFileUrl, usePlayAudio } from '@/hooks/sound.ts'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { emitter, EventKey } from '@/utils/eventBus.ts'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
let timer = 0
|
||||
//停止切换事件,因为hover到select时会跳出mini-dialog
|
||||
let selectIsOpen = false
|
||||
let show = $ref(false)
|
||||
|
||||
useWindowClick(() => {
|
||||
if (selectIsOpen) {
|
||||
selectIsOpen = false
|
||||
} else {
|
||||
show = false
|
||||
}
|
||||
})
|
||||
|
||||
function toggle(val: boolean) {
|
||||
if (selectIsOpen) return
|
||||
clearTimeout(timer)
|
||||
if (val) {
|
||||
emitter.emit(EventKey.closeOther)
|
||||
show = val
|
||||
} else {
|
||||
timer = setTimeout(() => {
|
||||
show = val
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
function selectToggle(e: boolean) {
|
||||
//这里要延时设置,因为关闭的时候,如果太早设置了false了,useWindowClick的事件就会把弹框关闭
|
||||
setTimeout(() => (selectIsOpen = e))
|
||||
}
|
||||
|
||||
function eventCheck(e) {
|
||||
const isSelfOrChild = e.currentTarget.contains(e.target)
|
||||
if (isSelfOrChild) {
|
||||
//如果下拉框打开的情况就不拦截
|
||||
if (selectIsOpen) return
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="setting" @click="eventCheck">
|
||||
<BaseIcon @mouseenter="toggle(true)" @mouseleave="toggle(false)">
|
||||
<IconClarityVolumeUpLine />
|
||||
</BaseIcon>
|
||||
<MiniDialog width="18rem" @mouseenter="toggle(true)" @mouseleave="toggle(false)" v-model="show">
|
||||
<div class="mini-row-title">音效设置</div>
|
||||
<div class="mini-row">
|
||||
<label class="item-title">单词自动发音</label>
|
||||
<div class="wrapper">
|
||||
<Switch v-model="settingStore.wordSound" inline-prompt active-text="开" inactive-text="关" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-row">
|
||||
<label class="item-title">单词发音口音</label>
|
||||
<div class="wrapper">
|
||||
<Select v-model="settingStore.soundType" @toggle="selectToggle" placeholder="请选择" size="small">
|
||||
<Option label="美音" value="us" />
|
||||
<Option label="英音" value="uk" />
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-row">
|
||||
<label class="item-title">按键音</label>
|
||||
<div class="wrapper">
|
||||
<Switch v-model="settingStore.keyboardSound" inline-prompt active-text="开" inactive-text="关" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-row">
|
||||
<label class="item-title">按键音效</label>
|
||||
<div class="wrapper">
|
||||
<Select v-model="settingStore.keyboardSoundFile" @toggle="selectToggle" placeholder="请选择" size="small">
|
||||
<Option v-for="item in SoundFileOptions" :key="item.value" :label="item.label" :value="item.value">
|
||||
<div class="el-option-row">
|
||||
<span>{{ item.label }}</span>
|
||||
<VolumeIcon :time="100" @click="usePlayAudio(getAudioFileUrl(item.value)[0])" />
|
||||
</div>
|
||||
</Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-row">
|
||||
<label class="item-title">效果音</label>
|
||||
<div class="wrapper">
|
||||
<Switch v-model="settingStore.effectSound" inline-prompt active-text="开" inactive-text="关" />
|
||||
</div>
|
||||
</div>
|
||||
</MiniDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.wrapper {
|
||||
width: 50%;
|
||||
position: relative;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.setting {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.el-option-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.icon-wrapper {
|
||||
transform: translateX(10rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +1,71 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { Dict, DictId, Word } from "../types/types.ts"
|
||||
import { _getStudyProgress, checkAndUpgradeSaveDict, shakeCommonDict } from "@/utils";
|
||||
import { shallowReactive } from "vue";
|
||||
import { getDefaultDict } from "@/types/func.ts";
|
||||
import { Dict, Word } from '../types/types.ts'
|
||||
import { _getStudyProgress, checkAndUpgradeSaveDict, shakeCommonDict } from '@/utils'
|
||||
import { shallowReactive } from 'vue'
|
||||
import { getDefaultDict } from '@/types/func.ts'
|
||||
import { get, set } from 'idb-keyval'
|
||||
import { AppEnv, SAVE_DICT_KEY } from "@/config/env.ts";
|
||||
import { add2MyDict, dictListVersion, myDictList } from "@/apis";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import { AppEnv, DictId, SAVE_DICT_KEY } from '@/config/env.ts'
|
||||
import { add2MyDict, dictListVersion, myDictList } from '@/apis'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
|
||||
export interface BaseState {
|
||||
simpleWords: string[],
|
||||
simpleWords: string[]
|
||||
load: boolean
|
||||
word: {
|
||||
studyIndex: number,
|
||||
bookList: Dict[],
|
||||
},
|
||||
studyIndex: number
|
||||
bookList: Dict[]
|
||||
}
|
||||
article: {
|
||||
bookList: Dict[],
|
||||
studyIndex: number,
|
||||
},
|
||||
bookList: Dict[]
|
||||
studyIndex: number
|
||||
}
|
||||
dictListVersion: number
|
||||
}
|
||||
|
||||
export const getDefaultBaseState = (): BaseState => ({
|
||||
simpleWords: [
|
||||
'a', 'an',
|
||||
'i', 'my', 'me', 'you', 'your', 'he', 'his', 'she', 'her', 'it',
|
||||
'what', 'who', 'where', 'how', 'when', 'which',
|
||||
'be', 'am', 'is', 'was', 'are', 'were', 'do', 'did', 'can', 'could', 'will', 'would',
|
||||
'the', 'that', 'this', 'and', 'not', 'no', 'yes',
|
||||
'to', 'of', 'for', 'at', 'in'
|
||||
'a',
|
||||
'an',
|
||||
'i',
|
||||
'my',
|
||||
'me',
|
||||
'you',
|
||||
'your',
|
||||
'he',
|
||||
'his',
|
||||
'she',
|
||||
'her',
|
||||
'it',
|
||||
'what',
|
||||
'who',
|
||||
'where',
|
||||
'how',
|
||||
'when',
|
||||
'which',
|
||||
'be',
|
||||
'am',
|
||||
'is',
|
||||
'was',
|
||||
'are',
|
||||
'were',
|
||||
'do',
|
||||
'did',
|
||||
'can',
|
||||
'could',
|
||||
'will',
|
||||
'would',
|
||||
'the',
|
||||
'that',
|
||||
'this',
|
||||
'and',
|
||||
'not',
|
||||
'no',
|
||||
'yes',
|
||||
'to',
|
||||
'of',
|
||||
'for',
|
||||
'at',
|
||||
'in',
|
||||
],
|
||||
load: false,
|
||||
word: {
|
||||
@@ -40,18 +76,18 @@ export const getDefaultBaseState = (): BaseState => ({
|
||||
id: DictId.wordKnown,
|
||||
en_name: DictId.wordCollect,
|
||||
name: '已掌握',
|
||||
description: '已掌握后的单词不会出现在练习中'
|
||||
description: '已掌握后的单词不会出现在练习中',
|
||||
}),
|
||||
],
|
||||
studyIndex: -1,
|
||||
},
|
||||
article: {
|
||||
bookList: [
|
||||
getDefaultDict({ id: DictId.articleCollect, en_name: DictId.articleCollect, name: '收藏' })
|
||||
getDefaultDict({ id: DictId.articleCollect, en_name: DictId.articleCollect, name: '收藏' }),
|
||||
],
|
||||
studyIndex: -1,
|
||||
},
|
||||
dictListVersion: 1
|
||||
dictListVersion: 1,
|
||||
})
|
||||
|
||||
export const useBaseStore = defineStore('base', {
|
||||
@@ -75,7 +111,9 @@ export const useBaseStore = defineStore('base', {
|
||||
return this.known.words.map((v: Word) => v.word.toLowerCase())
|
||||
},
|
||||
allIgnoreWords() {
|
||||
return this.known.words.map((v: Word) => v.word.toLowerCase()).concat(this.simpleWords.map((v: string) => v.toLowerCase()))
|
||||
return this.known.words
|
||||
.map((v: Word) => v.word.toLowerCase())
|
||||
.concat(this.simpleWords.map((v: string) => v.toLowerCase()))
|
||||
},
|
||||
sdict(): Dict {
|
||||
if (this.word.studyIndex >= 0) {
|
||||
@@ -83,6 +121,15 @@ export const useBaseStore = defineStore('base', {
|
||||
}
|
||||
return getDefaultDict()
|
||||
},
|
||||
groupLength(): number {
|
||||
return Math.ceil(this.sdict.length / this.sdict.perDayStudyNumber)
|
||||
},
|
||||
currentGroup(): number {
|
||||
//当能除尽时,应该加1
|
||||
let s = this.sdict.lastLearnIndex % this.sdict.perDayStudyNumber
|
||||
let d = this.sdict.lastLearnIndex / this.sdict.perDayStudyNumber
|
||||
return Math.floor(s === 0 ?( d + 1) : d)
|
||||
},
|
||||
currentStudyProgress(): number {
|
||||
if (!this.sdict.length) return 0
|
||||
return _getStudyProgress(this.sdict.lastLearnIndex, this.sdict.length)
|
||||
@@ -90,7 +137,9 @@ export const useBaseStore = defineStore('base', {
|
||||
getDictCompleteDate(): number {
|
||||
if (!this.sdict.length) return 0
|
||||
if (!this.sdict.perDayStudyNumber) return 0
|
||||
return Math.ceil((this.sdict.length - this.sdict.lastLearnIndex) / this.sdict.perDayStudyNumber)
|
||||
return Math.ceil(
|
||||
(this.sdict.length - this.sdict.lastLearnIndex) / this.sdict.perDayStudyNumber
|
||||
)
|
||||
},
|
||||
sbook(): Dict {
|
||||
return this.article.bookList[this.article.studyIndex] ?? {}
|
||||
@@ -136,7 +185,7 @@ export const useBaseStore = defineStore('base', {
|
||||
Object.assign(data, res.data)
|
||||
}
|
||||
}
|
||||
console.log('data', data)
|
||||
// console.log('data', data)
|
||||
this.setState(data)
|
||||
} catch (e) {
|
||||
console.error('读取本地dict数据失败', e)
|
||||
@@ -172,6 +221,7 @@ export const useBaseStore = defineStore('base', {
|
||||
this.word.bookList[this.word.studyIndex].perDayStudyNumber = val.perDayStudyNumber
|
||||
this.word.bookList[this.word.studyIndex].lastLearnIndex = val.lastLearnIndex
|
||||
this.word.bookList[this.word.studyIndex].userDictId = val.userDictId
|
||||
this.word.bookList[this.word.studyIndex].complete = val.complete
|
||||
} else {
|
||||
this.word.bookList.push(getDefaultDict(val))
|
||||
this.word.studyIndex = this.word.bookList.length - 1
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import {defineStore} from "pinia"
|
||||
import { defineStore } from 'pinia'
|
||||
import { useSettingStore } from './setting'
|
||||
import {WordPracticeStage} from "@/types/enum.ts";
|
||||
import { WordPracticeModeStageMap, WordPracticeStageNameMap } from '@/config/env.ts'
|
||||
|
||||
export interface PracticeState {
|
||||
step: number,
|
||||
startDate: number,
|
||||
spend: number,
|
||||
total: number,
|
||||
newWordNumber: number,
|
||||
reviewWordNumber: number,
|
||||
writeWordNumber: number,
|
||||
inputWordNumber: number,//当前总输入了多少个单词(不包含跳过)
|
||||
wrong: number,
|
||||
stage: WordPracticeStage
|
||||
startDate: number
|
||||
spend: number
|
||||
total: number
|
||||
newWordNumber: number
|
||||
reviewWordNumber: number
|
||||
writeWordNumber: number
|
||||
inputWordNumber: number //当前总输入了多少个单词(不包含跳过)
|
||||
wrong: number
|
||||
}
|
||||
|
||||
export const usePracticeStore = defineStore('practice', {
|
||||
state: (): PracticeState => {
|
||||
return {
|
||||
step: 0,
|
||||
stage: WordPracticeStage.FollowWriteNewWord,
|
||||
spend: 0,
|
||||
startDate: Date.now(),
|
||||
total: 0,
|
||||
@@ -26,4 +29,15 @@ export const usePracticeStore = defineStore('practice', {
|
||||
wrong: 0,
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
getStageName: (state) => {
|
||||
return WordPracticeStageNameMap[state.stage]
|
||||
},
|
||||
nextStage: (state) => {
|
||||
const settingStore = useSettingStore()
|
||||
const stages = WordPracticeModeStageMap[settingStore.wordPracticeMode]
|
||||
const index = stages.findIndex(v => v === state.stage)
|
||||
return stages[index + 1]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {defineStore} from "pinia"
|
||||
import {Dict} from "@/types/types.ts";
|
||||
import type {Dict} from "@/types/types.ts";
|
||||
import {getDefaultDict} from "@/types/func.ts";
|
||||
|
||||
export interface RuntimeState {
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { checkAndUpgradeSaveSetting, cloneDeep } from "@/utils";
|
||||
import { DefaultShortcutKeyMap, WordPracticeMode, WordPracticeType } from "@/types/types.ts";
|
||||
import { get } from "idb-keyval";
|
||||
import { AppEnv, SAVE_SETTING_KEY } from "@/config/env.ts";
|
||||
import { getSetting } from "@/apis";
|
||||
import { AppEnv, DefaultShortcutKeyMap, SAVE_SETTING_KEY } from '@/config/env.ts'
|
||||
import { getSetting } from '@/apis'
|
||||
import { WordPracticeMode, WordPracticeType } from '@/types/enum.ts'
|
||||
|
||||
export interface SettingState {
|
||||
soundType: string,
|
||||
soundType: string
|
||||
|
||||
wordSound: boolean,
|
||||
wordSoundVolume: number,
|
||||
wordSoundSpeed: number,
|
||||
wordReviewRatio:number //单词复习比例
|
||||
wordSound: boolean
|
||||
wordSoundVolume: number
|
||||
wordSoundSpeed: number
|
||||
wordReviewRatio: 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
|
||||
|
||||
103
src/types/enum.ts
Normal file
103
src/types/enum.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
export enum DictType {
|
||||
collect = 'collect',
|
||||
simple = 'simple',
|
||||
wrong = 'wrong',
|
||||
known = 'known',
|
||||
word = 'word',
|
||||
article = 'article',
|
||||
}
|
||||
export enum Sort {
|
||||
normal = 0,
|
||||
random = 1,
|
||||
reverse = 2,
|
||||
reverseAll = 3,
|
||||
randomAll = 4,
|
||||
}
|
||||
|
||||
export enum ShortcutKey {
|
||||
ShowWord = 'ShowWord',
|
||||
EditArticle = 'EditArticle',
|
||||
Next = 'Next',
|
||||
Previous = 'Previous',
|
||||
ToggleSimple = 'ToggleSimple',
|
||||
ToggleCollect = 'ToggleCollect',
|
||||
NextChapter = 'NextChapter',
|
||||
PreviousChapter = 'PreviousChapter',
|
||||
RepeatChapter = 'RepeatChapter',
|
||||
DictationChapter = 'DictationChapter',
|
||||
PlayWordPronunciation = 'PlayWordPronunciation',
|
||||
ToggleShowTranslate = 'ToggleShowTranslate',
|
||||
ToggleDictation = 'ToggleDictation',
|
||||
ToggleTheme = 'ToggleTheme',
|
||||
ToggleConciseMode = 'ToggleConciseMode',
|
||||
TogglePanel = 'TogglePanel',
|
||||
RandomWrite = 'RandomWrite',
|
||||
KnowWord = 'KnowWord',
|
||||
UnknownWord = 'UnknownWord',
|
||||
}
|
||||
|
||||
export enum TranslateEngine {
|
||||
Baidu = 0,
|
||||
}
|
||||
|
||||
export enum PracticeArticleWordType {
|
||||
Symbol,
|
||||
Number,
|
||||
Word,
|
||||
}
|
||||
|
||||
//练习模式
|
||||
export enum WordPracticeMode {
|
||||
System = 0,
|
||||
Free = 1,
|
||||
IdentifyOnly = 2, // 独立自测模式
|
||||
DictationOnly = 3, // 独立默写模式
|
||||
ListenOnly = 4, // 独立听写模式
|
||||
Shuffle = 5, // 随机复习模式
|
||||
Review = 6, // 复习模式
|
||||
}
|
||||
|
||||
//练习类型
|
||||
export enum WordPracticeType {
|
||||
FollowWrite, //跟写
|
||||
Spell,
|
||||
Identify,
|
||||
Listen,
|
||||
Dictation,
|
||||
}
|
||||
|
||||
export enum CodeType {
|
||||
Login = 0,
|
||||
Register = 1,
|
||||
ResetPwd = 2,
|
||||
ChangeEmail = 3,
|
||||
ChangePhoneNew = 4,
|
||||
ChangePhoneOld = 5,
|
||||
}
|
||||
|
||||
export enum ImportStatus {
|
||||
Idle = 0,
|
||||
Success = 1,
|
||||
Fail = 2,
|
||||
}
|
||||
|
||||
//练习阶段
|
||||
export enum WordPracticeStage {
|
||||
FollowWriteNewWord = 0,
|
||||
IdentifyNewWord = 1,
|
||||
ListenNewWord = 2,
|
||||
DictationNewWord = 3,
|
||||
|
||||
FollowWriteReview = 4,
|
||||
IdentifyReview = 5,
|
||||
ListenReview = 6,
|
||||
DictationReview = 7,
|
||||
|
||||
FollowWriteReviewAll = 8,
|
||||
IdentifyReviewAll = 9,
|
||||
ListenReviewAll = 10,
|
||||
DictationReviewAll = 11,
|
||||
|
||||
Shuffle = 12,
|
||||
Complete = 13,
|
||||
}
|
||||
@@ -1,25 +1,26 @@
|
||||
import { Article, ArticleWord, Dict, DictType, PracticeArticleWordType, Word } from "@/types/types.ts";
|
||||
import type { Article, ArticleWord, Dict, Word } from '@/types/types.ts'
|
||||
import { shallowReactive } from "vue";
|
||||
import { cloneDeep } from "@/utils";
|
||||
import { nanoid } from "nanoid";
|
||||
import { DictType, PracticeArticleWordType } from '@/types/enum.ts'
|
||||
|
||||
export function getDefaultWord(val: Partial<Word> = {}): Word {
|
||||
return {
|
||||
custom: false,
|
||||
id: nanoid(6),
|
||||
"word": "",
|
||||
"phonetic0": "",
|
||||
"phonetic1": "",
|
||||
"trans": [],
|
||||
"sentences": [],
|
||||
"phrases": [],
|
||||
"synos": [],
|
||||
"relWords": {
|
||||
"root": "",
|
||||
"rels": []
|
||||
word: '',
|
||||
phonetic0: '',
|
||||
phonetic1: '',
|
||||
trans: [],
|
||||
sentences: [],
|
||||
phrases: [],
|
||||
synos: [],
|
||||
relWords: {
|
||||
root: '',
|
||||
rels: [],
|
||||
},
|
||||
"etymology": [],
|
||||
...val
|
||||
etymology: [],
|
||||
...val,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,254 +1,152 @@
|
||||
export type Word = {
|
||||
id?: string,
|
||||
custom?: boolean,
|
||||
word: string,
|
||||
phonetic0: string,
|
||||
phonetic1: string,
|
||||
trans: {
|
||||
pos: string,
|
||||
cn: string,
|
||||
}[],
|
||||
sentences: {
|
||||
c: string,//content
|
||||
cn: string,
|
||||
}[],
|
||||
phrases: {
|
||||
c: string,
|
||||
cn: string,
|
||||
}[],
|
||||
synos: {
|
||||
pos: string,
|
||||
cn: string,
|
||||
ws: string[]
|
||||
}[],
|
||||
relWords: {
|
||||
root: string,
|
||||
rels: {
|
||||
pos: string,
|
||||
words: {
|
||||
c: string,
|
||||
cn: string,
|
||||
}[],
|
||||
}[]
|
||||
},
|
||||
etymology: {
|
||||
t: string,//title
|
||||
d: string,//desc
|
||||
}[],
|
||||
}
|
||||
import { DictType, PracticeArticleWordType } from '@/types/enum.ts'
|
||||
|
||||
export const PronunciationApi = 'https://dict.youdao.com/dictvoice?audio='
|
||||
export type Word = {
|
||||
id?: string
|
||||
custom?: boolean
|
||||
word: string
|
||||
phonetic0: string
|
||||
phonetic1: string
|
||||
trans: {
|
||||
pos: string
|
||||
cn: string
|
||||
}[]
|
||||
sentences: {
|
||||
c: string //content
|
||||
cn: string
|
||||
}[]
|
||||
phrases: {
|
||||
c: string
|
||||
cn: string
|
||||
}[]
|
||||
synos: {
|
||||
pos: string
|
||||
cn: string
|
||||
ws: string[]
|
||||
}[]
|
||||
relWords: {
|
||||
root: string
|
||||
rels: {
|
||||
pos: string
|
||||
words: {
|
||||
c: string
|
||||
cn: string
|
||||
}[]
|
||||
}[]
|
||||
}
|
||||
etymology: {
|
||||
t: string //title
|
||||
d: string //desc
|
||||
}[]
|
||||
}
|
||||
|
||||
export type TranslateLanguageType = 'en' | 'zh-CN' | 'ja' | 'de' | 'common' | ''
|
||||
export type LanguageType = 'en' | 'ja' | 'de' | 'code'
|
||||
|
||||
export enum DictType {
|
||||
collect = 'collect',
|
||||
simple = 'simple',
|
||||
wrong = 'wrong',
|
||||
known = 'known',
|
||||
word = 'word',
|
||||
article = 'article',
|
||||
}
|
||||
|
||||
export interface ArticleWord extends Word {
|
||||
nextSpace: boolean,
|
||||
symbolPosition: 'start' | 'end' | '',
|
||||
input: string
|
||||
type: PracticeArticleWordType
|
||||
nextSpace: boolean
|
||||
symbolPosition: 'start' | 'end' | ''
|
||||
input: string
|
||||
type: PracticeArticleWordType
|
||||
}
|
||||
|
||||
export interface Sentence {
|
||||
text: string,
|
||||
translate: string,
|
||||
words: ArticleWord[],
|
||||
audioPosition: number[]
|
||||
text: string
|
||||
translate: string
|
||||
words: ArticleWord[]
|
||||
audioPosition: number[]
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
id?: number,
|
||||
title: string,
|
||||
titleTranslate: string,
|
||||
text: string,
|
||||
textTranslate: string,
|
||||
newWords: Word[],
|
||||
sections: Sentence[][],
|
||||
audioSrc: string,
|
||||
audioFileId: string,
|
||||
lrcPosition: number[][],
|
||||
nameList: string[],
|
||||
questions: {
|
||||
stem: string,
|
||||
options: string[],
|
||||
correctAnswer: string[],
|
||||
explanation: string
|
||||
}[]
|
||||
id?: number | string
|
||||
title: string
|
||||
titleTranslate: string
|
||||
text: string
|
||||
textTranslate: string
|
||||
newWords: Word[]
|
||||
sections: Sentence[][]
|
||||
audioSrc: string
|
||||
audioFileId: string
|
||||
lrcPosition: number[][]
|
||||
nameList: string[]
|
||||
questions: {
|
||||
stem: string
|
||||
options: string[]
|
||||
correctAnswer: string[]
|
||||
explanation: string
|
||||
}[]
|
||||
quote?: {
|
||||
start: number
|
||||
text: string
|
||||
translate: string
|
||||
end: number
|
||||
}
|
||||
question?: {
|
||||
start: number
|
||||
text: string
|
||||
translate: string
|
||||
end: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface Statistics {
|
||||
startDate: number,//开始日期
|
||||
spend: number,//花费时间
|
||||
total: number//单词数量
|
||||
new: number//新学单词数量
|
||||
review: number//复习单词数量
|
||||
wrong: number//错误数
|
||||
}
|
||||
|
||||
export enum Sort {
|
||||
normal = 0,
|
||||
random = 1,
|
||||
reverse = 2,
|
||||
reverseAll = 3,
|
||||
randomAll = 4,
|
||||
}
|
||||
|
||||
export enum ShortcutKey {
|
||||
ShowWord = 'ShowWord',
|
||||
EditArticle = 'EditArticle',
|
||||
Next = 'Next',
|
||||
Previous = 'Previous',
|
||||
ToggleSimple = 'ToggleSimple',
|
||||
ToggleCollect = 'ToggleCollect',
|
||||
NextChapter = 'NextChapter',
|
||||
PreviousChapter = 'PreviousChapter',
|
||||
RepeatChapter = 'RepeatChapter',
|
||||
DictationChapter = 'DictationChapter',
|
||||
PlayWordPronunciation = 'PlayWordPronunciation',
|
||||
ToggleShowTranslate = 'ToggleShowTranslate',
|
||||
ToggleDictation = 'ToggleDictation',
|
||||
ToggleTheme = 'ToggleTheme',
|
||||
ToggleConciseMode = 'ToggleConciseMode',
|
||||
TogglePanel = 'TogglePanel',
|
||||
RandomWrite = 'RandomWrite',
|
||||
NextRandomWrite = 'NextRandomWrite',
|
||||
KnowWord = 'KnowWord',
|
||||
UnknownWord = 'UnknownWord',
|
||||
}
|
||||
|
||||
export const DefaultShortcutKeyMap = {
|
||||
[ShortcutKey.EditArticle]: 'Ctrl+E',
|
||||
[ShortcutKey.ShowWord]: 'Escape',
|
||||
[ShortcutKey.Previous]: 'Alt+⬅',
|
||||
[ShortcutKey.Next]: 'Tab',
|
||||
[ShortcutKey.ToggleSimple]: '`',
|
||||
[ShortcutKey.ToggleCollect]: 'Enter',
|
||||
[ShortcutKey.PreviousChapter]: 'Ctrl+⬅',
|
||||
[ShortcutKey.NextChapter]: 'Ctrl+➡',
|
||||
[ShortcutKey.RepeatChapter]: 'Ctrl+Enter',
|
||||
[ShortcutKey.DictationChapter]: 'Alt+Enter',
|
||||
[ShortcutKey.PlayWordPronunciation]: 'Ctrl+P',
|
||||
[ShortcutKey.ToggleShowTranslate]: 'Ctrl+Z',
|
||||
[ShortcutKey.ToggleDictation]: 'Ctrl+I',
|
||||
[ShortcutKey.ToggleTheme]: 'Ctrl+Q',
|
||||
[ShortcutKey.ToggleConciseMode]: 'Ctrl+M',
|
||||
[ShortcutKey.TogglePanel]: 'Ctrl+L',
|
||||
[ShortcutKey.RandomWrite]: 'Ctrl+R',
|
||||
[ShortcutKey.NextRandomWrite]: 'Ctrl+Shift+R',
|
||||
[ShortcutKey.KnowWord]: '1',
|
||||
[ShortcutKey.UnknownWord]: '2',
|
||||
}
|
||||
|
||||
export enum TranslateEngine {
|
||||
Baidu = 0,
|
||||
startDate: number //开始日期
|
||||
spend: number //花费时间
|
||||
total: number //单词数量
|
||||
new: number //新学单词数量
|
||||
review: number //复习单词数量
|
||||
wrong: number //错误数
|
||||
title: string //文章标题
|
||||
}
|
||||
|
||||
export type DictResource = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
url: string
|
||||
length: number
|
||||
category: string
|
||||
tags: string[]
|
||||
translateLanguage: TranslateLanguageType
|
||||
//todo 可以考虑删除了
|
||||
type?: DictType
|
||||
version?: number
|
||||
language: LanguageType
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
url: string
|
||||
length: number
|
||||
category: string
|
||||
tags: string[]
|
||||
translateLanguage: TranslateLanguageType
|
||||
//todo 可以考虑删除了
|
||||
type?: DictType
|
||||
version?: number
|
||||
language: LanguageType
|
||||
}
|
||||
|
||||
export interface Dict extends DictResource {
|
||||
lastLearnIndex: number,
|
||||
perDayStudyNumber: number,
|
||||
words: Word[],
|
||||
articles: Article[],
|
||||
statistics: Statistics[],
|
||||
custom: boolean,//是否是自定义词典
|
||||
complete: boolean,//是否学习完成,学完了设为true,然后lastLearnIndex重置
|
||||
//后端字段
|
||||
en_name?: string
|
||||
createdBy?: string
|
||||
category_id?: number
|
||||
is_default?: boolean
|
||||
update?: boolean
|
||||
cover?: string
|
||||
sync?: boolean
|
||||
userDictId?: number
|
||||
lastLearnIndex: number
|
||||
perDayStudyNumber: number
|
||||
words: Word[]
|
||||
articles: Article[]
|
||||
statistics: Statistics[]
|
||||
custom: boolean //是否是自定义词典
|
||||
complete: boolean //是否学习完成,学完了设为true,然后lastLearnIndex重置
|
||||
//后端字段
|
||||
en_name?: string
|
||||
createdBy?: string
|
||||
category_id?: number
|
||||
is_default?: boolean
|
||||
update?: boolean
|
||||
cover?: string
|
||||
sync?: boolean
|
||||
userDictId?: number
|
||||
}
|
||||
|
||||
export interface ArticleItem {
|
||||
item: Article,
|
||||
index: number
|
||||
}
|
||||
|
||||
export const SlideType = {
|
||||
HORIZONTAL: 0,
|
||||
VERTICAL: 1,
|
||||
item: Article
|
||||
index: number
|
||||
}
|
||||
|
||||
export interface PracticeData {
|
||||
index: number,
|
||||
words: Word[],
|
||||
wrongWords: Word[],
|
||||
excludeWords: string[],
|
||||
index: number
|
||||
words: Word[]
|
||||
wrongWords: Word[]
|
||||
excludeWords: string[]
|
||||
}
|
||||
|
||||
export interface TaskWords {
|
||||
new: Word[],
|
||||
review: Word[],
|
||||
write: Word[],
|
||||
shuffle: Word[],
|
||||
new: Word[]
|
||||
review: Word[]
|
||||
write: Word[]
|
||||
shuffle: Word[]
|
||||
}
|
||||
|
||||
export class DictId {
|
||||
static wordCollect = 'wordCollect'
|
||||
static wordWrong = 'wordWrong'
|
||||
static wordKnown = 'wordKnown'
|
||||
static articleCollect = 'articleCollect'
|
||||
}
|
||||
|
||||
export enum PracticeArticleWordType {
|
||||
Symbol,
|
||||
Number,
|
||||
Word
|
||||
}
|
||||
|
||||
//练习模式
|
||||
export enum WordPracticeMode {
|
||||
System = 0,
|
||||
Free = 1
|
||||
}
|
||||
|
||||
//练习类型
|
||||
export enum WordPracticeType {
|
||||
FollowWrite,//跟写
|
||||
Spell,
|
||||
Identify,
|
||||
Listen,
|
||||
Dictation
|
||||
}
|
||||
|
||||
export enum CodeType {
|
||||
Login = 0,
|
||||
Register = 1,
|
||||
ResetPwd = 2,
|
||||
ChangeEmail = 3,
|
||||
ChangePhoneNew = 4,
|
||||
ChangePhoneOld = 5
|
||||
}
|
||||
|
||||
export enum ImportStatus {
|
||||
Idle = 0,
|
||||
Success = 1,
|
||||
Fail = 2
|
||||
}
|
||||
92
src/utils/cache.ts
Normal file
92
src/utils/cache.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { PracticeData, TaskWords } from '@/types/types.ts'
|
||||
import { PracticeState } from '@/stores/practice.ts'
|
||||
import { IS_DEV } from '@/config/env'
|
||||
|
||||
export const PRACTICE_WORD_CACHE = {
|
||||
key: 'PracticeSaveWord',
|
||||
version: 1,
|
||||
}
|
||||
export const PRACTICE_ARTICLE_CACHE = {
|
||||
key: 'PracticeSaveArticle',
|
||||
version: 1,
|
||||
}
|
||||
|
||||
export type PracticeWordCache = {
|
||||
taskWords: TaskWords
|
||||
practiceData: PracticeData
|
||||
statStoreData: PracticeState
|
||||
}
|
||||
|
||||
export type PracticeArticleCache = {
|
||||
practiceData: {
|
||||
sectionIndex: number
|
||||
sentenceIndex: number
|
||||
wordIndex: number
|
||||
}
|
||||
statStoreData: PracticeState
|
||||
}
|
||||
|
||||
export function getPracticeWordCache(): PracticeWordCache | null {
|
||||
let d = localStorage.getItem(PRACTICE_WORD_CACHE.key)
|
||||
if (d) {
|
||||
try {
|
||||
//todo 记得删除
|
||||
if (IS_DEV) {
|
||||
// throw new Error('开发环境,抛出错误跳过缓存')
|
||||
}
|
||||
let obj = JSON.parse(d)
|
||||
if (obj.version !== PRACTICE_WORD_CACHE.version) {
|
||||
throw new Error()
|
||||
}
|
||||
return obj.val
|
||||
} catch (e) {
|
||||
localStorage.removeItem(PRACTICE_WORD_CACHE.key)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getPracticeArticleCache(): PracticeArticleCache | null {
|
||||
let d = localStorage.getItem(PRACTICE_ARTICLE_CACHE.key)
|
||||
if (d) {
|
||||
try {
|
||||
let obj = JSON.parse(d)
|
||||
if (obj.version !== PRACTICE_ARTICLE_CACHE.version) {
|
||||
throw new Error()
|
||||
}
|
||||
return obj.val
|
||||
} catch (e) {
|
||||
localStorage.removeItem(PRACTICE_ARTICLE_CACHE.key)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function setPracticeWordCache(cache: PracticeWordCache | null) {
|
||||
if (cache) {
|
||||
localStorage.setItem(
|
||||
PRACTICE_WORD_CACHE.key,
|
||||
JSON.stringify({
|
||||
version: PRACTICE_WORD_CACHE.version,
|
||||
val: cache,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
localStorage.removeItem(PRACTICE_WORD_CACHE.key)
|
||||
}
|
||||
}
|
||||
|
||||
export function setPracticeArticleCache(cache: PracticeArticleCache | null) {
|
||||
debugger
|
||||
if (cache) {
|
||||
localStorage.setItem(
|
||||
PRACTICE_ARTICLE_CACHE.key,
|
||||
JSON.stringify({
|
||||
version: PRACTICE_ARTICLE_CACHE.version,
|
||||
val: cache,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
localStorage.removeItem(PRACTICE_ARTICLE_CACHE.key)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {onMounted, onUnmounted} from "vue";
|
||||
export const emitter = mitt()
|
||||
export const EventKey = {
|
||||
resetWord: 'resetWord',
|
||||
changeDict: 'changeDict',
|
||||
openStatModal: 'openStatModal',
|
||||
openWordListModal: 'openWordListModal',
|
||||
closeOther: 'closeOther',
|
||||
@@ -17,7 +16,6 @@ export const EventKey = {
|
||||
editDict: 'editDict',
|
||||
openMyDictDialog: 'openMyDictDialog',
|
||||
stateInitEnd: 'stateInitEnd',
|
||||
randomWrite: 'randomWrite',
|
||||
}
|
||||
|
||||
export function useEvent(key: string, func: any) {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { BaseState, getDefaultBaseState, useBaseStore } from "@/stores/base.ts";
|
||||
import { getDefaultSettingState, SettingState } from "@/stores/setting.ts";
|
||||
import { Dict, DictId, DictResource, DictType } from "@/types/types.ts";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
import { BaseState, getDefaultBaseState, useBaseStore } from '@/stores/base.ts'
|
||||
import { getDefaultSettingState, SettingState } from '@/stores/setting.ts'
|
||||
import type { Dict, DictResource } from '@/types/types.ts'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRuntimeStore } from '@/stores/runtime.ts'
|
||||
import dayjs from 'dayjs'
|
||||
import { AppEnv, RESOURCE_PATH, SAVE_DICT_KEY, SAVE_SETTING_KEY } from "@/config/env.ts";
|
||||
import { nextTick } from "vue";
|
||||
import { AppEnv, DictId, RESOURCE_PATH, SAVE_DICT_KEY, SAVE_SETTING_KEY } from '@/config/env.ts'
|
||||
import { nextTick } from 'vue'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import { getDefaultDict, getDefaultWord } from "@/types/func.ts";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import { getDefaultDict, getDefaultWord } from '@/types/func.ts'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import {DictType} from "@/types/enum.ts";
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(duration)
|
||||
|
||||
export function no() {
|
||||
Toast.warning('未现实')
|
||||
@@ -60,7 +61,9 @@ export function checkAndUpgradeSaveDict(val: any) {
|
||||
return defaultState
|
||||
} else {
|
||||
// 版本不匹配时,尽量保留数据而不是直接返回默认状态
|
||||
console.warn(`数据版本不匹配: 当前版本 ${version}, 期望版本 ${SAVE_DICT_KEY.version},尝试保留数据`)
|
||||
console.warn(
|
||||
`数据版本不匹配: 当前版本 ${version}, 期望版本 ${SAVE_DICT_KEY.version},尝试保留数据`
|
||||
)
|
||||
try {
|
||||
checkRiskKey(defaultState, state)
|
||||
// 尝试保留 bookList 数据
|
||||
@@ -133,7 +136,8 @@ export function checkAndUpgradeSaveSetting(val: any) {
|
||||
export function shakeCommonDict(n: BaseState): BaseState {
|
||||
let data: BaseState = cloneDeep(n)
|
||||
data.word.bookList.map((v: Dict) => {
|
||||
if (!v.custom && ![DictId.wordKnown, DictId.wordWrong, DictId.wordCollect].includes(v.id)) v.words = []
|
||||
if (!v.custom && ![DictId.wordKnown, DictId.wordWrong, DictId.wordCollect].includes(v.id))
|
||||
v.words = []
|
||||
})
|
||||
data.article.bookList.map((v: Dict) => {
|
||||
if (!v.custom && ![DictId.articleCollect].includes(v.id)) v.articles = []
|
||||
@@ -159,10 +163,10 @@ export function useNav() {
|
||||
if (data) {
|
||||
runtimeStore.routeData = cloneDeep(data)
|
||||
}
|
||||
router.push({path, query})
|
||||
router.push({ path, query })
|
||||
}
|
||||
|
||||
return {nav, push: nav, back: router.back}
|
||||
return { nav, push: nav, back: router.back }
|
||||
}
|
||||
|
||||
export function _dateFormat(val: any, format: string = 'YYYY/MM/DD HH:mm'): string {
|
||||
@@ -175,17 +179,17 @@ export function _dateFormat(val: any, format: string = 'YYYY/MM/DD HH:mm'): stri
|
||||
}
|
||||
|
||||
export function msToHourMinute(ms) {
|
||||
const d = dayjs.duration(ms);
|
||||
const hours = d.hours();
|
||||
const minutes = d.minutes();
|
||||
const seconds = d.seconds();
|
||||
if (hours) return `${hours}小时${minutes}分钟`;
|
||||
if (minutes) return `${minutes}分钟`;
|
||||
return `${seconds}秒`;
|
||||
const d = dayjs.duration(ms)
|
||||
const hours = d.hours()
|
||||
const minutes = d.minutes()
|
||||
const seconds = d.seconds()
|
||||
if (hours) return `${hours}小时${minutes}分钟`
|
||||
if (minutes) return `${minutes}分钟`
|
||||
return `${seconds}秒`
|
||||
}
|
||||
|
||||
export function msToMinute(ms) {
|
||||
return `${Math.floor(dayjs.duration(ms).asMinutes())}分钟`;
|
||||
return `${Math.floor(dayjs.duration(ms).asMinutes())}分钟`
|
||||
}
|
||||
|
||||
//获取完成天数
|
||||
@@ -218,44 +222,47 @@ export function _copy(val: string) {
|
||||
navigator.clipboard.writeText(val)
|
||||
}
|
||||
|
||||
export function _parseLRC(lrc: string): { start: number, end: number, text: string }[] {
|
||||
const lines = lrc.split("\n").filter(line => line.trim() !== "");
|
||||
const regex = /\[(\d{2}):(\d{2}\.\d{2})\](.*)/;
|
||||
let parsed: any = [];
|
||||
export function _parseLRC(lrc: string): { start: number; end: number; text: string }[] {
|
||||
const lines = lrc.split('\n').filter(line => line.trim() !== '')
|
||||
const regex = /\[(\d{2}):(\d{2}\.\d{2})\](.*)/
|
||||
let parsed: any = []
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let match = lines[i].match(regex);
|
||||
let match = lines[i].match(regex)
|
||||
if (match) {
|
||||
let start = parseFloat(match[1]) * 60 + parseFloat(match[2]); // 转换成秒
|
||||
let text = match[3].trim();
|
||||
let start = parseFloat(match[1]) * 60 + parseFloat(match[2]) // 转换成秒
|
||||
let text = match[3].trim()
|
||||
|
||||
// 计算结束时间(下一个时间戳)
|
||||
let nextMatch = lines[i + 1] ? lines[i + 1].match(regex) : null;
|
||||
let end = nextMatch ? parseFloat(nextMatch[1]) * 60 + parseFloat(nextMatch[2]) : null;
|
||||
let nextMatch = lines[i + 1] ? lines[i + 1].match(regex) : null
|
||||
let end = nextMatch ? parseFloat(nextMatch[1]) * 60 + parseFloat(nextMatch[2]) : null
|
||||
|
||||
parsed.push({start, end, text});
|
||||
parsed.push({ start, end, text })
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
return parsed
|
||||
}
|
||||
|
||||
export async function sleep(time: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, time));
|
||||
return new Promise(resolve => setTimeout(resolve, time))
|
||||
}
|
||||
|
||||
export async function _getDictDataByUrl(val: DictResource, type: DictType = DictType.word): Promise<Dict> {
|
||||
export async function _getDictDataByUrl(
|
||||
val: DictResource,
|
||||
type: DictType = DictType.word
|
||||
): Promise<Dict> {
|
||||
// await sleep(2000);
|
||||
let dictResourceUrl = `/dicts/${val.language}/word/${val.url}`
|
||||
if (type === DictType.article) {
|
||||
dictResourceUrl = `/dicts/${val.language}/article/${val.url}`;
|
||||
dictResourceUrl = `/dicts/${val.language}/article/${val.url}`
|
||||
}
|
||||
let s = await fetch(resourceWrap(dictResourceUrl, val.version)).then(r => r.json())
|
||||
if (s) {
|
||||
if (type === DictType.word) {
|
||||
return getDefaultDict({...val, words: s})
|
||||
return getDefaultDict({ ...val, words: s })
|
||||
} else {
|
||||
return getDefaultDict({...val, articles: s})
|
||||
return getDefaultDict({ ...val, articles: s })
|
||||
}
|
||||
}
|
||||
return getDefaultDict()
|
||||
@@ -263,97 +270,97 @@ export async function _getDictDataByUrl(val: DictResource, type: DictType = Dict
|
||||
|
||||
//从字符串里面转换为Word格式
|
||||
export function convertToWord(raw: any) {
|
||||
const safeString = (str) => (typeof str === 'string' ? str.trim() : '');
|
||||
const safeString = str => (typeof str === 'string' ? str.trim() : '')
|
||||
const safeSplit = (str, sep) =>
|
||||
safeString(str) ? safeString(str).split(sep).filter(Boolean) : [];
|
||||
safeString(str) ? safeString(str).split(sep).filter(Boolean) : []
|
||||
|
||||
// 1. trans
|
||||
const trans = safeSplit(raw.trans, '\n').map(line => {
|
||||
const match = safeString(line).match(/^([^\s.]+\.?)\s*(.*)$/);
|
||||
const match = safeString(line).match(/^([^\s.]+\.?)\s*(.*)$/)
|
||||
if (match) {
|
||||
let pos = safeString(match[1]);
|
||||
let cn = safeString(match[2]);
|
||||
let pos = safeString(match[1])
|
||||
let cn = safeString(match[2])
|
||||
|
||||
// 如果 pos 不是常规词性(不以字母开头),例如 "【名】"
|
||||
if (!/^[a-zA-Z]+\.?$/.test(pos)) {
|
||||
cn = safeString(line); // 整行放到 cn
|
||||
pos = ''; // pos 置空
|
||||
cn = safeString(line) // 整行放到 cn
|
||||
pos = '' // pos 置空
|
||||
}
|
||||
|
||||
return {pos, cn};
|
||||
return { pos, cn }
|
||||
}
|
||||
return {pos: '', cn: safeString(line)};
|
||||
});
|
||||
return { pos: '', cn: safeString(line) }
|
||||
})
|
||||
|
||||
// 2. sentences
|
||||
const sentences = safeSplit(raw.sentences, '\n\n').map(block => {
|
||||
const [c, cn] = block.split('\n');
|
||||
return {c: safeString(c), cn: safeString(cn)};
|
||||
});
|
||||
const [c, cn] = block.split('\n')
|
||||
return { c: safeString(c), cn: safeString(cn) }
|
||||
})
|
||||
|
||||
// 3. phrases
|
||||
const phrases = safeSplit(raw.phrases, '\n\n').map(block => {
|
||||
const [c, cn] = block.split('\n');
|
||||
return {c: safeString(c), cn: safeString(cn)};
|
||||
});
|
||||
const [c, cn] = block.split('\n')
|
||||
return { c: safeString(c), cn: safeString(cn) }
|
||||
})
|
||||
|
||||
// 4. synos
|
||||
const synos = safeSplit(raw.synos, '\n\n').map(block => {
|
||||
const lines = block.split('\n').map(safeString);
|
||||
const [posCn, wsStr] = lines;
|
||||
let pos = '';
|
||||
let cn = '';
|
||||
const lines = block.split('\n').map(safeString)
|
||||
const [posCn, wsStr] = lines
|
||||
let pos = ''
|
||||
let cn = ''
|
||||
|
||||
if (posCn) {
|
||||
const posMatch = posCn.match(/^([a-zA-Z.]+)(.*)$/);
|
||||
pos = posMatch ? safeString(posMatch[1]) : '';
|
||||
cn = posMatch ? safeString(posMatch[2]) : safeString(posCn);
|
||||
const posMatch = posCn.match(/^([a-zA-Z.]+)(.*)$/)
|
||||
pos = posMatch ? safeString(posMatch[1]) : ''
|
||||
cn = posMatch ? safeString(posMatch[2]) : safeString(posCn)
|
||||
}
|
||||
const ws = wsStr ? wsStr.split('/').map(safeString) : [];
|
||||
const ws = wsStr ? wsStr.split('/').map(safeString) : []
|
||||
|
||||
return {pos, cn, ws};
|
||||
});
|
||||
return { pos, cn, ws }
|
||||
})
|
||||
|
||||
// 5. relWords
|
||||
const relWordsText = safeString(raw.relWords);
|
||||
let root = '';
|
||||
const rels = [];
|
||||
const relWordsText = safeString(raw.relWords)
|
||||
let root = ''
|
||||
const rels = []
|
||||
|
||||
if (relWordsText) {
|
||||
const relLines = relWordsText.split('\n').filter(Boolean);
|
||||
const relLines = relWordsText.split('\n').filter(Boolean)
|
||||
if (relLines.length > 0) {
|
||||
root = safeString(relLines[0].replace(/^词根:/, ''));
|
||||
let currentPos = '';
|
||||
let currentWords = [];
|
||||
root = safeString(relLines[0].replace(/^词根:/, ''))
|
||||
let currentPos = ''
|
||||
let currentWords = []
|
||||
|
||||
for (let i = 1; i < relLines.length; i++) {
|
||||
const line = relLines[i].trim();
|
||||
if (!line) continue;
|
||||
const line = relLines[i].trim()
|
||||
if (!line) continue
|
||||
|
||||
if (/^[a-z]+\./i.test(line)) {
|
||||
if (currentPos && currentWords.length > 0) {
|
||||
rels.push({pos: currentPos, words: currentWords});
|
||||
rels.push({ pos: currentPos, words: currentWords })
|
||||
}
|
||||
currentPos = safeString(line.replace(':', ''));
|
||||
currentWords = [];
|
||||
currentPos = safeString(line.replace(':', ''))
|
||||
currentWords = []
|
||||
} else if (line.includes(':')) {
|
||||
const [c, cn] = line.split(':');
|
||||
currentWords.push({c: safeString(c), cn: safeString(cn)});
|
||||
const [c, cn] = line.split(':')
|
||||
currentWords.push({ c: safeString(c), cn: safeString(cn) })
|
||||
}
|
||||
}
|
||||
if (currentPos && currentWords.length > 0) {
|
||||
rels.push({pos: currentPos, words: currentWords});
|
||||
rels.push({ pos: currentPos, words: currentWords })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. etymology
|
||||
const etymology = safeSplit(raw.etymology, '\n\n').map(block => {
|
||||
const lines = block.split('\n').map(safeString);
|
||||
const t = lines.shift() || '';
|
||||
const d = lines.join('\n').trim();
|
||||
return {t, d};
|
||||
});
|
||||
const lines = block.split('\n').map(safeString)
|
||||
const t = lines.shift() || ''
|
||||
const d = lines.join('\n').trim()
|
||||
return { t, d }
|
||||
})
|
||||
|
||||
return getDefaultWord({
|
||||
id: raw.id,
|
||||
@@ -364,68 +371,68 @@ export function convertToWord(raw: any) {
|
||||
sentences,
|
||||
phrases,
|
||||
synos,
|
||||
relWords: {root, rels},
|
||||
relWords: { root, rels },
|
||||
etymology,
|
||||
custom: true
|
||||
});
|
||||
custom: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function cloneDeep<T>(val: T) {
|
||||
export function cloneDeep<T>(val: T): T {
|
||||
return JSON.parse(JSON.stringify(val))
|
||||
}
|
||||
|
||||
export function shuffle<T>(array: T[]): T[] {
|
||||
const result = array.slice(); // 复制数组,避免修改原数组
|
||||
const result = array.slice() // 复制数组,避免修改原数组
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1)); // 生成 0 ~ i 的随机索引
|
||||
[result[i], result[j]] = [result[j], result[i]]; // 交换元素
|
||||
const j = Math.floor(Math.random() * (i + 1)) // 生成 0 ~ i 的随机索引
|
||||
;[result[i], result[j]] = [result[j], result[i]] // 交换元素
|
||||
}
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
|
||||
export function last<T>(array: T[]): T | undefined {
|
||||
return array.length > 0 ? array[array.length - 1] : undefined;
|
||||
return array.length > 0 ? array[array.length - 1] : undefined
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: any[]) => void>(func: T, wait: number) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
func.apply(this, args);
|
||||
}, wait);
|
||||
};
|
||||
func.apply(this, args)
|
||||
}, wait)
|
||||
}
|
||||
}
|
||||
|
||||
export function throttle<T extends (...args: any[]) => void>(func: T, wait: number) {
|
||||
let lastTime = 0;
|
||||
let lastTime = 0
|
||||
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
|
||||
const now = Date.now();
|
||||
const now = Date.now()
|
||||
if (now - lastTime >= wait) {
|
||||
func.apply(this, args);
|
||||
lastTime = now;
|
||||
func.apply(this, args)
|
||||
lastTime = now
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function reverse<T>(array: T[]): T[] {
|
||||
return array.slice().reverse();
|
||||
return array.slice().reverse()
|
||||
}
|
||||
|
||||
export function groupBy<T extends Record<string, any>>(array: T[], key: string) {
|
||||
return array.reduce<Record<string, T[]>>((result, item) => {
|
||||
const groupKey = String(item[key]);
|
||||
(result[groupKey] ||= []).push(item);
|
||||
return result;
|
||||
}, {});
|
||||
const groupKey = String(item[key])
|
||||
;(result[groupKey] ||= []).push(item)
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
|
||||
//随机取N个
|
||||
export function getRandomN(arr: any[], n: number) {
|
||||
const copy = [...arr]
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]] // 交换
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[copy[i], copy[j]] = [copy[j], copy[i]] // 交换
|
||||
}
|
||||
return copy.slice(0, n)
|
||||
}
|
||||
@@ -434,8 +441,8 @@ export function getRandomN(arr: any[], n: number) {
|
||||
export function splitIntoN(arr: any[], n: number) {
|
||||
const result = []
|
||||
const len = arr.length
|
||||
const base = Math.floor(len / n) // 每份至少这么多
|
||||
let extra = len % n // 前几份多 1 个
|
||||
const base = Math.floor(len / n) // 每份至少这么多
|
||||
let extra = len % n // 前几份多 1 个
|
||||
|
||||
let index = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
@@ -448,43 +455,43 @@ export function splitIntoN(arr: any[], n: number) {
|
||||
}
|
||||
|
||||
export async function loadJsLib(key: string, url: string) {
|
||||
if (window[key]) return window[key];
|
||||
if (window[key]) return window[key]
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
const script = document.createElement('script')
|
||||
// 判断是否是 .mjs 文件,如果是,则使用 type="module"
|
||||
if (url.endsWith(".mjs")) {
|
||||
script.type = "module"; // 需要加上 type="module"
|
||||
script.src = url;
|
||||
if (url.endsWith('.mjs')) {
|
||||
script.type = 'module' // 需要加上 type="module"
|
||||
script.src = url
|
||||
script.onload = async () => {
|
||||
try {
|
||||
// 使用动态 import 加载模块
|
||||
const module = await import(url); // 动态导入 .mjs 模块
|
||||
window[key] = module.default || module; // 将模块挂到 window 对象
|
||||
resolve(window[key]);
|
||||
const module = await import(url) // 动态导入 .mjs 模块
|
||||
window[key] = module.default || module // 将模块挂到 window 对象
|
||||
resolve(window[key])
|
||||
} catch (err) {
|
||||
reject(`${key} 加载失败: ${err.message}`);
|
||||
reject(`${key} 加载失败: ${err.message}`)
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 如果是非 .mjs 文件,直接按原方式加载
|
||||
script.src = url;
|
||||
script.onload = () => resolve(window[key]);
|
||||
script.src = url
|
||||
script.onload = () => resolve(window[key])
|
||||
}
|
||||
script.onerror = () => reject(key + " 加载失败");
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
script.onerror = () => reject(key + ' 加载失败')
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
export function total(arr, key) {
|
||||
return arr.reduce((a, b) => {
|
||||
a += b[key];
|
||||
a += b[key]
|
||||
return a
|
||||
}, 0);
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function resourceWrap(resource: string, version?: number) {
|
||||
if (AppEnv.IS_OFFICIAL) {
|
||||
if (resource.includes('.json')) resource = resource.replace('.json', '');
|
||||
if (resource.includes('.json')) resource = resource.replace('.json', '')
|
||||
if (!resource.includes('http')) resource = RESOURCE_PATH + resource
|
||||
if (version === undefined) {
|
||||
const store = useBaseStore()
|
||||
@@ -492,7 +499,7 @@ export function resourceWrap(resource: string, version?: number) {
|
||||
}
|
||||
return `${resource}_v${version}.json`
|
||||
}
|
||||
return resource;
|
||||
return resource
|
||||
}
|
||||
|
||||
// check if it is a new user
|
||||
@@ -501,9 +508,11 @@ export async function isNewUser() {
|
||||
let base = useBaseStore()
|
||||
console.log(JSON.stringify(base.$state))
|
||||
console.log(JSON.stringify(getDefaultBaseState()))
|
||||
return JSON.stringify(base.$state) === JSON.stringify({...getDefaultBaseState(), ...{load: true}})
|
||||
return (
|
||||
JSON.stringify(base.$state) === JSON.stringify({ ...getDefaultBaseState(), ...{ load: true } })
|
||||
)
|
||||
}
|
||||
|
||||
export function jump2Feedback() {
|
||||
window.open('https://v.wjx.cn/vm/ev0W7fv.aspx#', '_blank');
|
||||
window.open('https://v.wjx.cn/vm/ev0W7fv.aspx#', '_blank')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user