This commit is contained in:
Zyronon
2025-12-21 02:51:07 +08:00
parent 8874689176
commit 6c628be33a
7 changed files with 706 additions and 583 deletions

1
components.d.ts vendored
View File

@@ -80,6 +80,7 @@ declare module 'vue' {
IconFluentDismiss20Regular: typeof import('~icons/fluent/dismiss20-regular')['default']
IconFluentDismissCircle16Regular: typeof import('~icons/fluent/dismiss-circle16-regular')['default']
IconFluentDismissCircle20Filled: typeof import('~icons/fluent/dismiss-circle20-filled')['default']
IconFluentDocument20Regular: typeof import('~icons/fluent/document20-regular')['default']
IconFluentErrorCircle20Filled: typeof import('~icons/fluent/error-circle20-filled')['default']
IconFluentErrorCircle20Regular: typeof import('~icons/fluent/error-circle20-regular')['default']
IconFluentEye16Regular: typeof import('~icons/fluent/eye16-regular')['default']

View File

@@ -25,7 +25,6 @@ let isInitializing = true // 标记是否正在初始化
watch(store.$state, (n: BaseState) => {
// 如果正在初始化,不保存数据,避免覆盖
if (isInitializing) return
console.log('watch')
let data = shakeCommonDict(n)
set(SAVE_DICT_KEY.key, JSON.stringify({val: data, version: SAVE_DICT_KEY.version}))
@@ -132,4 +131,4 @@ onMounted(() => {
v-model="showTransfer"
@ok="init"
/>
</template>
</template>

6
src/apis/dict.ts Normal file
View File

@@ -0,0 +1,6 @@
import http from '@/utils/http.ts'
import { Dict } from '@/types/types.ts'
export function addDict(params?, data?) {
return http<Dict>('dict/addDict', data, params, 'post')
}

6
src/apis/words.ts Normal file
View File

@@ -0,0 +1,6 @@
import http from '@/utils/http.ts'
import { Dict } from '@/types/types.ts'
export function wordDelete(params?, data?) {
return http<Dict>('word/delete', data, params, 'post')
}

View File

@@ -1,45 +1,49 @@
<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";
import { debounce } from "@/utils";
import PopConfirm from "@/components/PopConfirm.vue";
import Empty from "@/components/Empty.vue";
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'
import { debounce } from '@/utils'
import PopConfirm from '@/components/PopConfirm.vue'
import Empty from '@/components/Empty.vue'
import Pagination from '@/components/base/Pagination.vue'
import Checkbox from "@/components/base/checkbox/Checkbox.vue";
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 Checkbox from '@/components/base/checkbox/Checkbox.vue'
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'
const props = withDefaults(defineProps<{
loading?: boolean
showToolbar?: boolean
showPagination?: boolean
exportLoading?: boolean
importLoading?: boolean
request?: Function
list?: any[]
}>(), {
loading: true,
showToolbar: true,
showPagination: true,
exportLoading: false,
importLoading: false,
})
const props = withDefaults(
defineProps<{
loading?: boolean
showToolbar?: boolean
showPagination?: boolean
exportLoading?: boolean
importLoading?: boolean
request?: Function
list?: any[]
}>(),
{
loading: true,
showToolbar: true,
showPagination: true,
exportLoading: false,
importLoading: false,
}
)
const emit = defineEmits<{
add: []
click: [val: {
item: any,
index: number
}],
click: [
val: {
item: any
index: number
},
]
import: [e: Event]
export: []
del: [ids: number[]],
del: [ids: number[]]
sort: [type: Sort, pageNo: number, pageSize: number]
}>()
@@ -89,7 +93,7 @@ let showSortDialog = $ref(false)
let showSearchInput = $ref(false)
let showImportDialog = $ref(false)
const closeImportDialog = () => showImportDialog = false
const closeImportDialog = () => (showImportDialog = false)
function sort(type: Sort) {
if ([Sort.reverse, Sort.random].includes(type)) {
@@ -111,7 +115,7 @@ defineExpose({
scrollToBottom,
scrollToItem,
closeImportDialog,
getData
getData,
})
let loading2 = $ref(false)
@@ -122,7 +126,7 @@ let params = $ref({
total: 0,
list: [],
sortType: null,
searchKey: ''
searchKey: '',
})
function search(key: string) {
@@ -159,182 +163,181 @@ function handlePageNo(e) {
onMounted(async () => {
getData()
})
defineRender(
() => {
const d = (item) => <Checkbox
modelValue={selectIds.includes(item.id)}
onChange={() => toggleSelect(item)}
size="large"/>
defineRender(() => {
const d = item => (
<Checkbox
modelValue={selectIds.includes(item.id)}
onChange={() => toggleSelect(item)}
size="large"
/>
)
return (
<div class="flex flex-col gap-3">
{
props.showToolbar && <div>
{
showSearchInput ? (
<div class="flex gap-4">
<BaseInput
clearable
modelValue={params.searchKey}
onUpdate:modelValue={debounce(e => search(e), 500)}
class="flex-1"
autofocus>
{{
subfix: () => <IconFluentSearch24Regular
class="text-lg text-gray"
/>
}}
</BaseInput>
<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>{selectIds.length} / {params.total}</span>
</div>
<div class="flex gap-2 relative">
{
selectIds.length ?
<PopConfirm title="确认删除所有选中数据?"
onConfirm={handleBatchDel}
>
<BaseIcon class="del" title="删除">
<DeleteIcon/>
</BaseIcon>
</PopConfirm>
: null
}
<BaseIcon
onClick={() => showImportDialog = true}
title="导入">
<IconSystemUiconsImport/>
</BaseIcon>
<BaseIcon
onClick={() => emit('export')}
title="导出">
{props.exportLoading ? <IconEosIconsLoading/> : <IconPhExportLight/>}
</BaseIcon>
<BaseIcon
onClick={() => emit('add')}
title="添加单词">
<IconFluentAdd20Regular/>
</BaseIcon>
<BaseIcon
disabled={!params.list.length}
title="改变顺序"
onClick={() => showSortDialog = !showSortDialog}
>
<IconFluentArrowSort20Regular/>
</BaseIcon>
<BaseIcon
disabled={!params.list.length}
onClick={() => showSearchInput = !showSearchInput}
title="搜索">
<IconFluentSearch20Regular/>
</BaseIcon>
<MiniDialog
modelValue={showSortDialog}
onUpdate:modelValue={e => showSortDialog = e}
style="width: 8rem;"
>
<div class="mini-row-title">
列表顺序设置
</div>
<div class="flex flex-col gap2 btn-no-margin">
<BaseButton onClick={() => sort(Sort.reverse)}>翻转当前页</BaseButton>
<BaseButton onClick={() => sort(Sort.reverseAll)}>翻转所有</BaseButton>
<div class="line"></div>
<BaseButton onClick={() => sort(Sort.random)}>随机当前页</BaseButton>
<BaseButton onClick={() => sort(Sort.randomAll)}>随机所有</BaseButton>
</div>
</MiniDialog>
</div>
</div>
)
}
</div>
}
{
loading2 ?
<div class="h-full w-full center text-4xl">
<IconEosIconsLoading color="gray"/>
</div>
: params.list.length ? (
<>
<div class="flex-1 overflow-auto"
ref={e => listRef = e}>
{params.list.map((item, index) => {
return (
<div class="list-item-wrapper"
key={item.word}
>
{s.default({
checkbox: d,
item,
index: (params.pageSize * (params.pageNo - 1)) + index + 1
})}
</div>
)
})}
</div>
{
props.showPagination && <div class="flex justify-end">
<Pagination
currentPage={params.pageNo}
onUpdate:current-page={handlePageNo}
pageSize={params.pageSize}
onUpdate:page-size={(e) => params.pageSize = e}
pageSizes={[20, 50, 100, 200]}
layout="total,sizes"
total={params.total}/>
</div>
}
</>
) : <Empty/>
}
<Dialog modelValue={showImportDialog}
onUpdate:modelValue={closeImportDialog}
title="导入教程"
>
<div className="w-100 p-4 pt-0">
<div>请按照模板的格式来填写数据</div>
<div class="color-red">单词项为必填其他项可不填</div>
<div>翻译一行一个翻译前面词性后面内容如n.取消多个翻译请换行</div>
<div>例句一行原文一行译文多个请换<span class="color-red"></span></div>
<div>短语一行原文一行译文多个请换<span class="color-red"></span></div>
<div>同义词同根词词源请前往官方词典然后编辑其中某个单词参考其格式</div>
<div class="mt-6">
模板下载地址<a href={`https://${Host}/libs/单词导入模板.xlsx`}>单词导入模板</a>
</div>
<div class="mt-4">
<BaseButton
onClick={() => {
let d: HTMLDivElement = document.querySelector('#upload-trigger')
d.click()
}}
loading={props.importLoading}>导入</BaseButton>
<input
id="upload-trigger"
type="file"
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
onChange={e => emit('import', e)}
class="w-0 h-0 opacity-0"/>
</div>
return (
<div class="flex flex-col gap-3">
{props.showToolbar && (
<div>
{showSearchInput ? (
<div class="flex gap-4">
<BaseInput
clearable
modelValue={params.searchKey}
onUpdate:modelValue={debounce(e => search(e), 500)}
class="flex-1"
autofocus
>
{{
subfix: () => <IconFluentSearch24Regular class="text-lg text-gray" />,
}}
</BaseInput>
<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>
{selectIds.length} / {params.total}
</span>
</div>
</Dialog>
<div class="flex gap-2 relative">
{selectIds.length ? (
<PopConfirm title="确认删除所有选中数据?" onConfirm={handleBatchDel}>
<BaseIcon class="del" title="删除">
<DeleteIcon />
</BaseIcon>
</PopConfirm>
) : null}
<BaseIcon onClick={() => (showImportDialog = true)} title="导入">
<IconSystemUiconsImport />
</BaseIcon>
<BaseIcon onClick={() => emit('export')} title="导出">
{props.exportLoading ? <IconEosIconsLoading /> : <IconPhExportLight />}
</BaseIcon>
<BaseIcon onClick={() => emit('add')} title="添加单词">
<IconFluentAdd20Regular />
</BaseIcon>
<BaseIcon
disabled={!params.list.length}
title="改变顺序"
onClick={() => (showSortDialog = !showSortDialog)}
>
<IconFluentArrowSort20Regular />
</BaseIcon>
<BaseIcon
disabled={!params.list.length}
onClick={() => (showSearchInput = !showSearchInput)}
title="搜索"
>
<IconFluentSearch20Regular />
</BaseIcon>
<MiniDialog
modelValue={showSortDialog}
onUpdate:modelValue={e => (showSortDialog = e)}
style="width: 8rem;"
>
<div class="mini-row-title">列表顺序设置</div>
<div class="flex flex-col gap2 btn-no-margin">
<BaseButton onClick={() => sort(Sort.reverse)}>翻转当前页</BaseButton>
<BaseButton onClick={() => sort(Sort.reverseAll)}>翻转所有</BaseButton>
<div class="line"></div>
<BaseButton onClick={() => sort(Sort.random)}>随机当前页</BaseButton>
<BaseButton onClick={() => sort(Sort.randomAll)}>随机所有</BaseButton>
</div>
</MiniDialog>
</div>
</div>
)}
</div>
)}
<div class="relative flex-1 overflow-hidden">
{params.list.length ? (
<div class="overflow-auto h-full" ref={e => (listRef = e)}>
{params.list.map((item, index) => {
return (
<div class="list-item-wrapper" key={item.word}>
{s.default({
checkbox: d,
item,
index: params.pageSize * (params.pageNo - 1) + index + 1,
})}
</div>
)
})}
</div>
)
}
)
) : !loading2 ? (
<Empty />
) : null}
{loading2 && (
<div class="absolute top-0 left-0 bottom-0 right-0 bg-black bg-op-10 center text-4xl">
<IconEosIconsLoading color="gray" />
</div>
)}
</div>
{props.showPagination && (
<div class="flex justify-end">
<Pagination
currentPage={params.pageNo}
onUpdate:current-page={handlePageNo}
pageSize={params.pageSize}
onUpdate:page-size={e => (params.pageSize = e)}
pageSizes={[20, 50, 100, 200]}
layout="total,sizes"
total={params.total}
/>
</div>
)}
<Dialog
modelValue={showImportDialog}
onUpdate:modelValue={closeImportDialog}
title="导入教程"
>
<div className="w-100 p-4 pt-0">
<div>请按照模板的格式来填写数据</div>
<div class="color-red">单词项为必填其他项可不填</div>
<div>翻译一行一个翻译前面词性后面内容如n.取消多个翻译请换行</div>
<div>
例句一行原文一行译文多个请换<span class="color-red"></span>
</div>
<div>
短语一行原文一行译文多个请换<span class="color-red"></span>
</div>
<div>同义词同根词词源请前往官方词典然后编辑其中某个单词参考其格式</div>
<div class="mt-6">
模板下载地址<a href={`https://${Host}/libs/单词导入模板.xlsx`}>单词导入模板</a>
</div>
<div class="mt-4">
<BaseButton
onClick={() => {
let d: HTMLDivElement = document.querySelector('#upload-trigger')
d.click()
}}
loading={props.importLoading}
>
导入
</BaseButton>
<input
id="upload-trigger"
type="file"
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
onChange={e => emit('import', e)}
class="w-0 h-0 opacity-0"
/>
</div>
</div>
</Dialog>
</div>
)
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,4 +1,4 @@
import { offset } from "@floating-ui/dom";
import { offset } from '@floating-ui/dom'
export const GITHUB = 'https://github.com/zyronon/TypeWords'
export const Host = 'typewords.cc'
@@ -7,25 +7,27 @@ export const Origin = `https://${Host}`
export const APP_NAME = 'Type Words'
const common = {
word_dict_list_version: 1
word_dict_list_version: 1,
}
const map = {
DEV: {
API: 'http://localhost/',
}
},
}
export const ENV = Object.assign(map['DEV'], common)
export let AppEnv = {
TOKEN: localStorage.getItem('token') ?? '',
IS_OFFICIAL: false,
IS_OFFICIAL: true,
IS_LOGIN: false,
CAN_REQUEST: false
CAN_REQUEST: false,
}
AppEnv.IS_LOGIN = !!AppEnv.TOKEN
AppEnv.CAN_REQUEST = AppEnv.IS_LOGIN && AppEnv.IS_OFFICIAL
// AppEnv.IS_OFFICIAL = true
// AppEnv.CAN_REQUEST = true
// console.log('AppEnv.CAN_REQUEST',AppEnv.CAN_REQUEST)
export const RESOURCE_PATH = ENV.API + 'static'
@@ -38,41 +40,41 @@ export const DICT_LIST = {
ARTICLE: {
ALL: `/list/article.json`,
RECOMMENDED: `/list/article.json`,
}
},
}
export const SoundFileOptions = [
{value: '机械键盘', label: '机械键盘'},
{value: '机械键盘1', label: '机械键盘1'},
{value: '机械键盘2', label: '机械键盘2'},
{value: '老式机械键盘', label: '老式机械键盘'},
{value: '笔记本键盘', label: '笔记本键盘'},
{ value: '机械键盘', label: '机械键盘' },
{ value: '机械键盘1', label: '机械键盘1' },
{ value: '机械键盘2', label: '机械键盘2' },
{ value: '老式机械键盘', label: '老式机械键盘' },
{ value: '笔记本键盘', label: '笔记本键盘' },
]
export const APP_VERSION = {
key: 'type-words-app-version',
version: 2
version: 2,
}
export const SAVE_DICT_KEY = {
key: 'typing-word-dict',
version: 4
version: 4,
}
export const SAVE_SETTING_KEY = {
key: 'typing-word-setting',
version: 17
version: 17,
}
export const EXPORT_DATA_KEY = {
key: 'typing-word-export',
version: 4
version: 4,
}
export const LOCAL_FILE_KEY = 'typing-word-files'
export const PracticeSaveWordKey = {
key: 'PracticeSaveWord',
version: 1
version: 1,
}
export const PracticeSaveArticleKey = {
key: 'PracticeSaveArticle',
version: 1
version: 1,
}
export const TourConfig = {
@@ -80,21 +82,22 @@ export const TourConfig = {
defaultStepOptions: {
canClickTarget: false,
classes: 'shadow-md bg-purple-dark',
cancelIcon: {enabled: true},
cancelIcon: { enabled: true },
modalOverlayOpeningPadding: 10,
modalOverlayOpeningRadius: 6,
floatingUIOptions: {
middleware: [offset({mainAxis: 30})]
middleware: [offset({ mainAxis: 30 })],
},
},
total: 7
total: 7,
}
export const LIB_JS_URL = {
SHEPHERD: import.meta.env.MODE === 'development' ?
'https://cdn.jsdelivr.net/npm/shepherd.js@14.5.1/dist/esm/shepherd.mjs'
: Origin + '/libs/Shepherd.14.5.1.mjs',
SHEPHERD:
import.meta.env.MODE === 'development'
? 'https://cdn.jsdelivr.net/npm/shepherd.js@14.5.1/dist/esm/shepherd.mjs'
: Origin + '/libs/Shepherd.14.5.1.mjs',
SNAPDOM: `${Origin}/libs/snapdom.min.js`,
JSZIP: `${Origin}/libs/jszip.min.js`,
XLSX: `${Origin}/libs/xlsx.full.min.js`,
}
}

View File

@@ -1,28 +1,28 @@
<script setup lang="tsx">
import { DictId, Sort } from "@/types/types.ts";
import { DictId, Sort } from '@/types/types.ts'
import { detail } from "@/apis";
import BackIcon from "@/components/BackIcon.vue";
import BaseButton from "@/components/BaseButton.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import BasePage from "@/components/BasePage.vue";
import BaseTable from "@/components/BaseTable.vue";
import PopConfirm from "@/components/PopConfirm.vue";
import WordItem from "@/components/WordItem.vue";
import BaseInput from "@/components/base/BaseInput.vue";
import Textarea from "@/components/base/Textarea.vue";
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 { getCurrentStudyWord } from "@/hooks/dict.ts";
import EditBook from "@/pages/article/components/EditBook.vue";
import PracticeSettingDialog from "@/pages/word/components/PracticeSettingDialog.vue";
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 { add2MyDict, detail } from '@/apis'
import BackIcon from '@/components/BackIcon.vue'
import BaseButton from '@/components/BaseButton.vue'
import BaseIcon from '@/components/BaseIcon.vue'
import BasePage from '@/components/BasePage.vue'
import BaseTable from '@/components/BaseTable.vue'
import PopConfirm from '@/components/PopConfirm.vue'
import WordItem from '@/components/WordItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import Textarea from '@/components/base/Textarea.vue'
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 { getCurrentStudyWord } from '@/hooks/dict.ts'
import EditBook from '@/pages/article/components/EditBook.vue'
import PracticeSettingDialog from '@/pages/word/components/PracticeSettingDialog.vue'
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,
@@ -31,12 +31,13 @@ import {
loadJsLib,
reverse,
shuffle,
useNav
} from "@/utils";
import { MessageBox } from "@/utils/MessageBox.tsx";
import { nanoid } from "nanoid";
import { computed, onMounted, reactive, ref, shallowReactive, shallowRef, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
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'
const runtimeStore = useRuntimeStore()
const base = useBaseStore()
@@ -65,8 +66,8 @@ let wordForm = $ref(getDefaultFormWord())
let wordFormRef = $ref()
const wordRules = reactive({
word: [
{required: true, message: '请输入单词', trigger: 'blur'},
{max: 100, message: '名称不能超过100个字符', trigger: 'blur'},
{ required: true, message: '请输入单词', trigger: 'blur' },
{ max: 100, message: '名称不能超过100个字符', trigger: 'blur' },
],
})
let studyLoading = $ref(false)
@@ -77,8 +78,11 @@ function syncDictInMyStudyList(study = false) {
let rIndex = base.word.bookList.findIndex(v => v.id === runtimeStore.editDict.id)
runtimeStore.editDict.words = allList
let temp = runtimeStore.editDict;
if (!temp.custom && ![DictId.wordKnown, DictId.wordWrong, DictId.wordCollect].includes(temp.id)) {
let temp = runtimeStore.editDict
if (
!temp.custom &&
![DictId.wordKnown, DictId.wordWrong, DictId.wordCollect].includes(temp.id)
) {
temp.custom = true
if (!temp.id.includes('_custom')) {
temp.id += '_custom'
@@ -97,7 +101,7 @@ function syncDictInMyStudyList(study = false) {
async function onSubmitWord() {
// return console.log('wordFormRef',wordFormRef,wordFormRef.validate)
await wordFormRef.validate((valid) => {
await wordFormRef.validate(valid => {
if (valid) {
let data: any = convertToWord(wordForm)
//todo 可以检查的更准确些比如json对比
@@ -128,18 +132,52 @@ async function onSubmitWord() {
})
}
function batchDel(ids: string[]) {
ids.map(id => {
let rIndex2 = allList.findIndex(v => v.id === id)
if (rIndex2 > -1) {
if (id === wordForm.id) {
wordForm = getDefaultFormWord()
async function batchDel(ids: string[]) {
let localHandle = () => {
ids.map(id => {
let rIndex2 = allList.findIndex(v => v.id === id)
if (rIndex2 > -1) {
if (id === wordForm.id) {
wordForm = getDefaultFormWord()
}
allList.splice(rIndex2, 1)
}
})
tableRef.value.getData()
syncDictInMyStudyList()
}
if (AppEnv.CAN_REQUEST) {
if (dict.custom) {
if (dict.sync) {
let res = await wordDelete(null, {
wordIds: ids,
userDictId: dict?.userDictId,
dictId: dict.id,
})
if (res.success) {
tableRef.value.getData()
} else {
return Toast.error(res.msg ?? '删除失败')
}
} else {
localHandle()
}
} else {
let r = await add2MyDict({
id: dict.id,
perDayStudyNumber: dict.perDayStudyNumber,
lastLearnIndex: dict.lastLearnIndex,
complete: dict.complete,
})
if (!r.success) return Toast.error(r.msg)
else {
dict.userDictId = r.data
}
allList.splice(rIndex2, 1)
}
})
tableRef.value.getData()
syncDictInMyStudyList()
} else {
localHandle()
}
}
//把word对象的字段全转成字符串
@@ -150,11 +188,21 @@ function word2Str(word) {
res.phonetic1 = word.phonetic1
res.phonetic0 = word.phonetic0
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.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('"', '')).join('\n\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.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('"', '')
)
.join('\n\n')
: ''
res.etymology = word.etymology.map(v => (v.t + '\n' + v.d).replaceAll('"', '')).join('\n\n')
return res
}
@@ -183,7 +231,7 @@ let isAdd = $ref(false)
let activeTab = $ref<'list' | 'edit'>('list') // 移动端标签页状态
const showBookDetail = computed(() => {
return !(isAdd || isEdit);
return !(isAdd || isEdit)
})
onMounted(async () => {
@@ -192,11 +240,14 @@ onMounted(async () => {
runtimeStore.editDict = getDefaultDict()
} else {
if (!runtimeStore.editDict.id) {
return router.push("/word")
return router.push('/word')
} else {
if (!runtimeStore.editDict.words.length
&& !runtimeStore.editDict.custom
&& ![DictId.wordCollect, DictId.wordWrong, DictId.wordKnown].includes(runtimeStore.editDict.en_name || runtimeStore.editDict.id)
if (
!runtimeStore.editDict.words.length &&
!runtimeStore.editDict.custom &&
![DictId.wordCollect, DictId.wordWrong, DictId.wordKnown].includes(
runtimeStore.editDict.en_name || runtimeStore.editDict.id
)
) {
loading = true
let r = await _getDictDataByUrl(runtimeStore.editDict)
@@ -204,12 +255,10 @@ onMounted(async () => {
}
if (base.word.bookList.find(book => book.id === runtimeStore.editDict.id)) {
if (AppEnv.CAN_REQUEST) {
let res = await detail({id: runtimeStore.editDict.id})
//todo 优化:这里只返回详情
let res = await detail({ id: runtimeStore.editDict.id })
if (res.success) {
runtimeStore.editDict.statistics = res.data.statistics
if (res.data.words.length) {
runtimeStore.editDict.words = res.data.words
}
}
}
}
@@ -230,7 +279,7 @@ let showPracticeSettingDialog = $ref(false)
const store = useBaseStore()
const settingStore = useSettingStore()
const {nav} = useNav()
const { nav } = useNav()
//todo 可以和首页合并
async function startPractice(query = {}) {
@@ -244,10 +293,10 @@ async function startPractice(query = {}) {
perDayStudyNumber: store.sdict.perDayStudyNumber,
custom: store.sdict.custom,
complete: store.sdict.complete,
wordPracticeMode: settingStore.wordPracticeMode
wordPracticeMode: settingStore.wordPracticeMode,
})
let currentStudy = getCurrentStudyWord()
nav('practice-words/' + store.sdict.id, query, {taskWords: currentStudy})
nav('practice-words/' + store.sdict.id, query, { taskWords: currentStudy })
}
async function addMyStudyList() {
@@ -273,39 +322,41 @@ let importLoading = $ref(false)
let tableRef = ref()
function importData(e) {
let file = e.target.files[0];
if (!file) return;
let file = e.target.files[0]
if (!file) return
let reader = new FileReader();
let reader = new FileReader()
reader.onload = async function (s) {
let data = s.target.result;
let data = s.target.result
importLoading = true
const XLSX = await loadJsLib('XLSX', LIB_JS_URL.XLSX);
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 workbook = XLSX.read(data, { type: 'binary' })
let res: any[] = XLSX.utils.sheet_to_json(workbook.Sheets['Sheet1'])
if (res.length) {
let words = res.map(v => {
if (v['单词']) {
let data = null
try {
data = convertToWord({
id: nanoid(6),
word: v['单词'],
phonetic0: v['音标①'] ?? '',
phonetic1: v['音标'] ?? '',
trans: v['翻译'] ?? '',
sentences: v['例句'] ?? '',
phrases: v['短语'] ?? '',
synos: v['近义词'] ?? '',
relWords: v['同根词'] ?? '',
etymology: v['词'] ?? '',
});
} catch (e) {
console.error('导入单词报错' + v['单词'], e.message)
let words = res
.map(v => {
if (v['单词']) {
let data = null
try {
data = convertToWord({
id: nanoid(6),
word: v['单词'],
phonetic0: v['音标'] ?? '',
phonetic1: v['音标②'] ?? '',
trans: v['翻译'] ?? '',
sentences: v['例句'] ?? '',
phrases: v['短语'] ?? '',
synos: v['近义词'] ?? '',
relWords: v['同根词'] ?? '',
etymology: v['词源'] ?? '',
})
} catch (e) {
console.error('导入单词报错' + v['单词'], e.message)
}
return data
}
return data
}
}).filter(v => v);
})
.filter(v => v)
if (words.length) {
let repeat = []
let noRepeat = []
@@ -328,7 +379,7 @@ function importData(e) {
() => {
repeat.map(v => {
runtimeStore.editDict.words[v.index] = v
delete runtimeStore.editDict.words[v.index]["index"]
delete runtimeStore.editDict.words[v.index]['index']
})
},
null,
@@ -352,20 +403,20 @@ function importData(e) {
Toast.success('导入成功!')
}
} else {
Toast.warning('导入失败!原因:没有数据/未认别到数据');
Toast.warning('导入失败!原因:没有数据/未认别到数据')
}
} else {
Toast.warning('导入失败!原因:没有数据');
Toast.warning('导入失败!原因:没有数据')
}
e.target.value = ''
importLoading = false
};
reader.readAsBinaryString(file);
}
reader.readAsBinaryString(file)
}
async function exportData() {
exportLoading = true
const XLSX = await loadJsLib('XLSX', LIB_JS_URL.XLSX);
const XLSX = await loadJsLib('XLSX', LIB_JS_URL.XLSX)
let list = runtimeStore.editDict.words
let filename = runtimeStore.editDict.name
let wb = XLSX.utils.book_new()
@@ -375,88 +426,125 @@ async function exportData() {
单词: t.word,
'音标①': t.phonetic0,
'音标②': t.phonetic1,
'翻译': t.trans,
'例句': t.sentences,
'短语': t.phrases,
'近义词': t.synos,
'同根词': t.relWords,
'词源': t.etymology,
翻译: t.trans,
例句: t.sentences,
短语: t.phrases,
近义词: t.synos,
同根词: t.relWords,
词源: t.etymology,
}
})
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 + ' 导出成功!')
exportLoading = false
}
watch(() => loading, (val) => {
if (!val) return
_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: 'step3',
text: '点击这里开始学习',
attachTo: {element: '#study', on: 'bottom'},
buttons: [
{
text: `下一步3/${TourConfig.total}`,
action() {
tour.next()
addMyStudyList()
}
}
]
});
watch(
() => loading,
val => {
if (!val) return
_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: 'step3',
text: '点击这里开始学习',
attachTo: { element: '#study', on: 'bottom' },
buttons: [
{
text: `下一步3/${TourConfig.total}`,
action() {
tour.next()
addMyStudyList()
},
},
],
})
tour.addStep({
id: 'step4',
text: '这里可以选择学习模式、设置学习数量、修改学习进度',
attachTo: {element: '#mode', on: 'bottom'},
beforeShowPromise() {
return new Promise((resolve) => {
const timer = setInterval(() => {
if (document.querySelector('#mode')) {
clearInterval(timer);
setTimeout(resolve, 500)
}
}, 100);
});
},
buttons: [
{
text: `下一步4/${TourConfig.total}`,
action() {
tour.next()
startPractice({guide: 1})
}
}
]
});
tour.addStep({
id: 'step4',
text: '这里可以选择学习模式、设置学习数量、修改学习进度',
attachTo: { element: '#mode', on: 'bottom' },
beforeShowPromise() {
return new Promise(resolve => {
const timer = setInterval(() => {
if (document.querySelector('#mode')) {
clearInterval(timer)
setTimeout(resolve, 500)
}
}, 100)
})
},
buttons: [
{
text: `下一步4/${TourConfig.total}`,
action() {
tour.next()
startPractice({ guide: 1 })
},
},
],
})
const r = localStorage.getItem('tour-guide');
if (settingStore.first && !r && !isMobile()) {
tour.start();
const r = localStorage.getItem('tour-guide')
if (settingStore.first && !r && !isMobile()) {
tour.start()
}
}, 500)
}
)
const dict = $computed(() => runtimeStore.editDict)
//获取本地单词列表
function getLocalList({ pageNo, pageSize, searchKey }) {
let list = allList
let total = allList.length
if (searchKey.trim()) {
list = allList.filter(v => v.word.toLowerCase().includes(searchKey.trim().toLowerCase()))
total = list.length
}
list = list.slice((pageNo - 1) * pageSize, (pageNo - 1) * pageSize + pageSize)
return { list, total }
}
async function requestList({ pageNo, pageSize, searchKey }) {
if (
!dict.custom &&
![DictId.wordCollect, DictId.wordWrong, DictId.wordKnown].includes(dict.en_name || dict.id)
) {
// 非自定义词典直接请求json
//如果没数据则请求
if (!allList.length) {
let r = await _getDictDataByUrl(dict)
allList = r.words
}
}, 500)
})
async function requestList({pageNo, pageSize, searchKey}) {
if (AppEnv.CAN_REQUEST) {
return getLocalList({ pageNo, pageSize, searchKey })
} else {
let list = allList
let total = allList.length
if (searchKey.trim()) {
list = allList.filter(v => v.word.toLowerCase().includes(searchKey.trim().toLowerCase()))
total = list.length
// 自定义词典
//如果登录了,则请求后端数据
if (AppEnv.CAN_REQUEST) {
//todo 加上sync标记
if (dict.sync || true) {
//todo 优化:这里应该只返回列表
let res = await detail({ id: dict.id, pageNo, pageSize })
if (res.success) {
return { list: res.data.words, total: res.data.length }
}
return { list: [], total: 0 }
}
} else {
//未登录则用本地保存的数据
allList = dict.words
}
list = list.slice((pageNo - 1) * pageSize, (pageNo - 1) * pageSize + pageSize)
return {list, total}
return getLocalList({ pageNo, pageSize, searchKey })
}
}
@@ -469,7 +557,8 @@ function onSort(type: Sort, pageNo: number, pageSize: number) {
} else if ([Sort.random, Sort.randomAll].includes(type)) {
fun = shuffle
}
allList = allList.slice(0, pageSize * (pageNo - 1))
allList = allList
.slice(0, pageSize * (pageNo - 1))
.concat(fun(allList.slice(pageSize * (pageNo - 1), pageSize * (pageNo - 1) + pageSize)))
.concat(allList.slice(pageSize * (pageNo - 1) + pageSize))
runtimeStore.editDict.words = allList
@@ -482,205 +571,221 @@ function onSort(type: Sort, pageNo: number, pageSize: number) {
defineRender(() => {
return (
<BasePage>
{
showBookDetail.value ? <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-actions flex">
<BaseButton loading={studyLoading || loading} type="info"
onClick={() => isEdit = true}>编辑</BaseButton>
<BaseButton id="study" loading={studyLoading || loading} onClick={addMyStudyList}>学习</BaseButton>
<BaseButton loading={studyLoading || loading} onClick={startTest}>测试</BaseButton>
{showBookDetail.value ? (
<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-actions flex">
<BaseButton
loading={studyLoading || loading}
type="info"
onClick={() => (isEdit = true)}
>
编辑
</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>
{/* 移动端标签页导航 */}
{isMob && isOperate && (
<div class="tab-navigation mb-3">
<div
class={`tab-item ${activeTab === 'list' ? 'active' : ''}`}
onClick={() => (activeTab = 'list')}
>
单词列表
</div>
<div
class={`tab-item ${activeTab === 'edit' ? 'active' : ''}`}
onClick={() => (activeTab = 'edit')}
>
{wordForm.id ? '编辑' : '添加'}单词
</div>
</div>
<div class="text-lg mt-2">介绍{runtimeStore.editDict.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>
<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' : ''}`}>
<BaseTable
ref={tableRef}
class="h-full"
request={requestList}
onDel={batchDel}
onSort={onSort}
onAdd={addWord}
onImport={importData}
onExport={exportData}
exportLoading={exportLoading}
importLoading={importLoading}
>
{
(val) =>
<WordItem
showTransPop={false}
showCollectIcon={false}
showMarkIcon={false}
item={val.item}
>
{{
prefix: () => val.checkbox(val.item),
suffix: () => (
<div class='flex flex-col'>
<BaseIcon
class="option-icon"
onClick={() => editWord(val.item)}
title="编辑">
<IconFluentTextEditStyle20Regular/>
</BaseIcon>
<PopConfirm title="确认删除?"
onConfirm={() => batchDel([val.item.id])}
>
<BaseIcon
class="option-icon"
title="删除">
<DeleteIcon/>
</BaseIcon>
</PopConfirm>
</div>
)
}}
</WordItem>
}
</BaseTable>
</div>
{
isOperate ? (
<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"
ref={e => wordFormRef = e}
rules={wordRules}
model={wordForm}
label-width="7rem">
<FormItem label="单词" prop="word">
<BaseInput
modelValue={wordForm.word}
onUpdate:modelValue={e => wordForm.word = e}
>
</BaseInput>
</FormItem>
<FormItem label="英音音标">
<BaseInput
modelValue={wordForm.phonetic0}
onUpdate:modelValue={e => wordForm.phonetic0 = e}
/>
</FormItem>
<FormItem label="美音音标">
<BaseInput
modelValue={wordForm.phonetic1}
onUpdate:modelValue={e => wordForm.phonetic1 = e}/>
</FormItem>
<FormItem label="翻译">
<Textarea
modelValue={wordForm.trans}
onUpdate:modelValue={e => wordForm.trans = e}
placeholder="一行一个翻译前面词性后面内容如n.取消);多个翻译请换行"
autosize={{minRows: 6, maxRows: 10}}/>
</FormItem>
<FormItem label="例句">
<Textarea
modelValue={wordForm.sentences}
onUpdate:modelValue={e => wordForm.sentences = e}
placeholder="一行原文,一行译文;多个请换两行"
autosize={{minRows: 6, maxRows: 10}}/>
</FormItem>
<FormItem label="短语">
<Textarea
modelValue={wordForm.phrases}
onUpdate:modelValue={e => wordForm.phrases = e}
placeholder="一行原文,一行译文;多个请换两行"
autosize={{minRows: 6, maxRows: 10}}/>
</FormItem>
<FormItem label="同义词">
<Textarea
modelValue={wordForm.synos}
onUpdate:modelValue={e => wordForm.synos = e}
placeholder="请参考已有单词格式"
autosize={{minRows: 6, maxRows: 20}}/>
</FormItem>
<FormItem label="同根词">
<Textarea
modelValue={wordForm.relWords}
onUpdate:modelValue={e => wordForm.relWords = e}
placeholder="请参考已有单词格式"
autosize={{minRows: 6, maxRows: 20}}/>
</FormItem>
<FormItem label="词源">
<Textarea
modelValue={wordForm.etymology}
onUpdate:modelValue={e => wordForm.etymology = e}
placeholder="请参考已有单词格式"
autosize={{minRows: 6, maxRows: 10}}/>
</FormItem>
</Form>
<div class="center">
<BaseButton
type="info"
onClick={closeWordForm}>关闭
</BaseButton>
<BaseButton type="primary"
onClick={onSubmitWord}>保存
</BaseButton>
</div>
</div>
) : null
}
<div class="flex flex-1 overflow-hidden content-area">
<div
class={`word-list-section ${isMob && isOperate && activeTab !== 'list' ? 'mobile-hidden' : ''}`}
>
<BaseTable
ref={tableRef}
class="h-full"
request={requestList}
onDel={batchDel}
onSort={onSort}
onAdd={addWord}
onImport={importData}
onExport={exportData}
exportLoading={exportLoading}
importLoading={importLoading}
>
{val => (
<WordItem
showTransPop={false}
showCollectIcon={false}
showMarkIcon={false}
item={val.item}
>
{{
prefix: () => val.checkbox(val.item),
suffix: () => (
<div class="flex flex-col">
<BaseIcon
class="option-icon"
onClick={() => editWord(val.item)}
title="编辑"
>
<IconFluentTextEditStyle20Regular />
</BaseIcon>
<PopConfirm title="确认删除?" onConfirm={() => batchDel([val.item.id])}>
<BaseIcon class="option-icon" title="删除">
<DeleteIcon />
</BaseIcon>
</PopConfirm>
</div>
),
}}
</WordItem>
)}
</BaseTable>
</div>
</div> :
<div class="card mb-0 dict-detail-card">
<div class="dict-header flex justify-between items-center relative">
<BackIcon class="dict-back z-2" onClick={() => {
{isOperate ? (
<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"
ref={e => (wordFormRef = e)}
rules={wordRules}
model={wordForm}
label-width="7rem"
>
<FormItem label="单词" prop="word">
<BaseInput
modelValue={wordForm.word}
onUpdate:modelValue={e => (wordForm.word = e)}
></BaseInput>
</FormItem>
<FormItem label="英音音标">
<BaseInput
modelValue={wordForm.phonetic0}
onUpdate:modelValue={e => (wordForm.phonetic0 = e)}
/>
</FormItem>
<FormItem label="美音音标">
<BaseInput
modelValue={wordForm.phonetic1}
onUpdate:modelValue={e => (wordForm.phonetic1 = e)}
/>
</FormItem>
<FormItem label="翻译">
<Textarea
modelValue={wordForm.trans}
onUpdate:modelValue={e => (wordForm.trans = e)}
placeholder="一行一个翻译前面词性后面内容如n.取消);多个翻译请换行"
autosize={{ minRows: 6, maxRows: 10 }}
/>
</FormItem>
<FormItem label="例句">
<Textarea
modelValue={wordForm.sentences}
onUpdate:modelValue={e => (wordForm.sentences = e)}
placeholder="一行原文,一行译文;多个请换两行"
autosize={{ minRows: 6, maxRows: 10 }}
/>
</FormItem>
<FormItem label="短语">
<Textarea
modelValue={wordForm.phrases}
onUpdate:modelValue={e => (wordForm.phrases = e)}
placeholder="一行原文,一行译文;多个请换两行"
autosize={{ minRows: 6, maxRows: 10 }}
/>
</FormItem>
<FormItem label="同义词">
<Textarea
modelValue={wordForm.synos}
onUpdate:modelValue={e => (wordForm.synos = e)}
placeholder="请参考已有单词格式"
autosize={{ minRows: 6, maxRows: 20 }}
/>
</FormItem>
<FormItem label="同根词">
<Textarea
modelValue={wordForm.relWords}
onUpdate:modelValue={e => (wordForm.relWords = e)}
placeholder="请参考已有单词格式"
autosize={{ minRows: 6, maxRows: 20 }}
/>
</FormItem>
<FormItem label="词源">
<Textarea
modelValue={wordForm.etymology}
onUpdate:modelValue={e => (wordForm.etymology = e)}
placeholder="请参考已有单词格式"
autosize={{ minRows: 6, maxRows: 10 }}
/>
</FormItem>
</Form>
<div class="center">
<BaseButton type="info" onClick={closeWordForm}>
关闭
</BaseButton>
<BaseButton type="primary" onClick={onSubmitWord}>
保存
</BaseButton>
</div>
</div>
) : null}
</div>
</div>
) : (
<div class="card mb-0 dict-detail-card">
<div class="dict-header flex justify-between items-center relative">
<BackIcon
class="dict-back z-2"
onClick={() => {
if (isAdd) {
router.back()
} else {
isEdit = false
}
}}/>
<div class="dict-title absolute page-title text-align-center w-full">
{runtimeStore.editDict.id ? '修改' : '创建'}词典
</div>
</div>
<div class="center">
<EditBook
isAdd={isAdd}
isBook={false}
onClose={formClose}
onSubmit={() => isEdit = isAdd = false}
/>
}}
/>
<div class="dict-title absolute page-title text-align-center w-full">
{runtimeStore.editDict.id ? '修改' : '创建'}词典
</div>
</div>
}
<div class="center">
<EditBook
isAdd={isAdd}
isBook={false}
onClose={formClose}
onSubmit={() => (isEdit = isAdd = false)}
/>
</div>
</div>
)}
<PracticeSettingDialog
showLeftOption
modelValue={showPracticeSettingDialog}
onUpdate:modelValue={val => (showPracticeSettingDialog = val)}
onOk={startPractice}/>
onOk={startPractice}
/>
</BasePage>
)
})