Merge branch 'refs/heads/dev'

# Conflicts:
#	src/pages/word/components/TypeWord.vue
This commit is contained in:
Zyronon
2026-01-07 01:01:58 +08:00
237 changed files with 8447 additions and 9321 deletions

View File

@@ -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>

View File

@@ -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')

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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')

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}
// 移动端适配

View File

@@ -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;
}

View File

@@ -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;

View 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>

View File

@@ -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>

View File

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

View File

@@ -1,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";

View 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>

View File

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

View File

@@ -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,
};
});

View File

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

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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)
})

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -29,7 +29,7 @@ const emit = defineEmits<{
}>()
//虚拟列表长度限制
const limit = 101
const limit = 200
const settingStore = useSettingStore()
const listRef: any = $ref()

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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<{

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

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

View File

@@ -9,7 +9,8 @@ import {
slideTouchMove,
slideTouchStart
} from "./common";
import {SlideType} from "@/types/types.ts";
import {SlideType} from "@/config/env";
const props = defineProps({
index: {

View File

@@ -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')

View File

@@ -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'
}

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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,
}
}
}

View File

@@ -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`]
}
}
}

View File

@@ -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');

View File

@@ -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>

View File

@@ -1,22 +1,22 @@
<script setup lang="ts">
import {Article, DictId} from "@/types/types.ts";
import BaseButton from "@/components/BaseButton.vue";
import {_nextTick, cloneDeep, loadJsLib} from "@/utils";
import {useBaseStore} from "@/stores/base.ts";
import 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;

View File

@@ -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>
```````` ;

View File

@@ -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";

View File

@@ -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);
}
}

View File

@@ -1,19 +1,19 @@
<script setup lang="ts">
import { Article } from "@/types/types.ts";
import { ref, watch, nextTick } from "vue";
import { get } from "idb-keyval";
import Audio from "@/components/base/Audio.vue";
import { LOCAL_FILE_KEY } from "@/config/env.ts";
import 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>

View File

@@ -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 />
2LRC 文件用于解析句子对应音频的位置不一定准确后续可自行修改
</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;
}
}

View File

@@ -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>

View File

@@ -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";

View File

@@ -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;

View File

@@ -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
}}.&nbsp;&nbsp;{{ _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 }}.&nbsp;&nbsp;{{ _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;
}
}

View File

@@ -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()

View File

@@ -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);
}
}
// 移动端顶部菜单栏

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
]

View File

@@ -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>
)}

View File

@@ -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]"
/>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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">

View File

@@ -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;

View 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>

View File

@@ -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;

View File

@@ -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'))

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 === ' ' ? '&nbsp;' : '_')).join('') }}
</span>
{{
displayWord
.split('')
.map(v => (v === ' ' ? '&nbsp;' : '_'))
.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;
}

View 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>

View File

@@ -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

View File

@@ -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]
}
}
})

View File

@@ -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 {

View File

@@ -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
View 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,
}

View File

@@ -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,
}
}

View File

@@ -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
View 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)
}
}

View File

@@ -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) {

View File

@@ -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')
}