This commit is contained in:
Zyronon
2025-12-19 01:45:13 +08:00
parent 36ddd399b6
commit eba448dbd5
22 changed files with 506 additions and 783 deletions

View File

@@ -1,313 +0,0 @@
<script setup lang="tsx">
import { nextTick, useSlots } from "vue";
import { Sort } from "@/types/types.ts";
import MiniDialog from "@/components/dialog/MiniDialog.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import BaseButton from "@/components/BaseButton.vue";
import { cloneDeep, debounce, reverse, shuffle } from "@/utils";
import PopConfirm from "@/components/PopConfirm.vue";
import Empty from "@/components/Empty.vue";
import Pagination from '@/components/base/Pagination.vue'
import Toast from '@/components/base/toast/Toast.ts'
import Checkbox from "@/components/base/checkbox/Checkbox.vue";
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
import Dialog from "@/components/dialog/Dialog.vue";
import BaseInput from "@/components/base/BaseInput.vue";
import { Host } from "@/config/env.ts";
let list = defineModel('list')
const props = withDefaults(defineProps<{
loading?: boolean
showToolbar?: boolean
showPagination?: boolean
exportLoading?: boolean
importLoading?: boolean
del?: Function
batchDel?: Function
add?: Function
total: number
}>(), {
loading: true,
showToolbar: true,
showPagination: 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))
}
if (!props.showPagination) return list.value
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">
<BaseInput
clearable
modelValue={searchKey}
onUpdate:modelValue={debounce(e => searchKey = e)}
class="flex-1"
autofocus>
{{
subfix: () => <IconFluentSearch24Regular
class="text-lg text-gray"
/>
}}
</BaseInput>
<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>
{
props.showPagination && <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={props.total}/>
</div>
}
</>
) : <Empty/>
}
<Dialog modelValue={showImportDialog}
onUpdate:modelValue={closeImportDialog}
title="导入教程"
>
<div className="w-100 p-4 pt-0">
<div>请按照模板的格式来填写数据</div>
<div class="color-red">单词项为必填其他项可不填</div>
<div>翻译一行一个翻译前面词性后面内容如n.取消多个翻译请换行</div>
<div>例句一行原文一行译文多个请换<span class="color-red"></span></div>
<div>短语一行原文一行译文多个请换<span class="color-red"></span></div>
<div>同义词同根词词源请前往官方词典然后编辑其中某个单词参考其格式</div>
<div class="mt-6">
模板下载地址<a href={`https://${Host}/libs/单词导入模板.xlsx`}>单词导入模板</a>
</div>
<div class="mt-4">
<BaseButton
onClick={() => {
let d: HTMLDivElement = document.querySelector('#upload-trigger')
d.click()
}}
loading={props.importLoading}>导入</BaseButton>
<input
id="upload-trigger"
type="file"
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
onChange={e => emit('importData', e)}
class="w-0 h-0 opacity-0"/>
</div>
</div>
</Dialog>
</div>
)
}
)
</script>
<style scoped lang="scss">
</style>

View File

@@ -1,44 +1,60 @@
<script setup lang="ts">
import {Word} from "@/types/types.ts";
import { Word } from "@/types/types.ts";
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
import {usePlayWordAudio} from "@/hooks/sound.ts";
import { usePlayWordAudio } from "@/hooks/sound.ts";
import Tooltip from "@/components/base/Tooltip.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import { useWordOptions } from "@/hooks/dict.ts";
withDefaults(defineProps<{
item: Word,
showTranslate?: boolean
showWord?: boolean
showTransPop?: boolean
hiddenOptionIcon?: boolean
showOption?: boolean
showCollectIcon?: boolean
showMarkIcon?: boolean
index?: number
active?: boolean
}>(), {
showTranslate: true,
showWord: true,
showTransPop: true,
hiddenOptionIcon: false,
showOption: true,
showCollectIcon: true,
showMarkIcon: true,
active: false,
})
const playWordAudio = usePlayWordAudio()
const {
isWordCollect,
toggleWordCollect,
isWordSimple,
toggleWordSimple
} = useWordOptions()
</script>
<template>
<div class="common-list-item"
:class="{hiddenOptionIcon}"
:class="{active}"
>
<div class="left">
<slot name="prefix" :item="item"></slot>
<div class="title-wrapper">
<div class="item-title">
<span class="text-sm translate-y-0.7 text-gray-500" v-if="index != undefined">{{ index }}.</span>
<span class="word" :class="!showWord && 'word-shadow'">{{ item.word }}</span>
<span class="phonetic">{{ item.phonetic0 }}</span>
<span class="phonetic text-gray">{{ 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"
v-if="v.cn.length > 30 && showTransPop"
:title="v.pos + ' ' + v.cn"
>
<span>{{ v.pos + ' ' + v.cn.slice(0, 30) + '...' }}</span>
</Tooltip>
@@ -47,13 +63,29 @@ const playWordAudio = usePlayWordAudio()
</div>
</div>
</div>
<div class="right">
<div class="right" v-if="showOption">
<slot name="suffix" :item="item"></slot>
<BaseIcon
v-if="showCollectIcon"
:class="!isWordCollect(item)?'collect':'fill'"
@click.stop="toggleWordCollect(item)"
:title="!isWordCollect(item) ? '收藏' : '取消收藏'">
<IconFluentStar16Regular v-if="!isWordCollect(item)"/>
<IconFluentStar16Filled v-else/>
</BaseIcon>
<BaseIcon
v-if="showMarkIcon"
:class="!isWordSimple(item)?'collect':'fill'"
@click.stop="toggleWordSimple(item)"
:title="!isWordSimple(item) ? '标记为已掌握' : '取消标记已掌握'">
<IconFluentCheckmarkCircle16Regular v-if="!isWordSimple(item)"/>
<IconFluentCheckmarkCircle16Filled v-else/>
</BaseIcon>
</div>
</div>
</template>
<style scoped lang="scss">
</style>

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { Article } from "@/types/types.ts";
import BaseList from "@/components/list/BaseList.vue";
import BaseInput from "@/components/base/BaseInput.vue";
import { Article } from '@/types/types.ts'
import BaseList from '@/components/list/BaseList.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import { useArticleOptions } from '@/hooks/dict.ts'
import BaseIcon from '@/components/BaseIcon.vue'
interface IProps {
list: Article[];
showTranslate?: boolean;
list: Article[]
showTranslate?: boolean
}
const props = withDefaults(defineProps<IProps>(), {
@@ -15,8 +16,8 @@ const props = withDefaults(defineProps<IProps>(), {
})
const emit = defineEmits<{
click: [val: { item: Article, index: number }],
title: [val: { item: Article, index: number }],
click: [val: { item: Article; index: number }]
title: [val: { item: Article; index: number }]
}>()
let searchKey = $ref('')
@@ -24,10 +25,13 @@ let localList = $computed(() => {
if (searchKey) {
//把搜索内容,分词之后,判断是否有这个词,比单纯遍历包含体验更好
let t = searchKey.toLowerCase()
let strings = t.split(' ').filter(v => v);
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)
return (
item.title.toLowerCase().includes(value) ||
item.titleTranslate.toLowerCase().includes(value)
)
})
})
try {
@@ -38,16 +42,15 @@ let localList = $computed(() => {
res.push(props.list[d - 1])
}
}
} catch (err) {
}
} catch (err) {}
return res.sort((a: Article, b: Article) => {
//使完整包含的条目更靠前
const aMatch = a.title.toLowerCase().includes(t);
const bMatch = b.title.toLowerCase().includes(t);
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; // 都匹配或都不匹配,保持原顺序
if (aMatch && !bMatch) return -1 // a 靠前
if (!aMatch && bMatch) return 1 // b 靠前
return 0 // 都匹配或都不匹配,保持原顺序
})
} else {
return props.list
@@ -63,9 +66,9 @@ function scrollToBottom() {
function scrollToItem(index: number) {
listRef?.scrollToItem(index)
}
const { isArticleCollect, toggleArticleCollect } = useArticleOptions()
defineExpose({ scrollToBottom, scrollToItem })
</script>
<template>
@@ -77,23 +80,38 @@ defineExpose({ scrollToBottom, scrollToItem })
</template>
</BaseInput>
</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>
<BaseList ref="listRef" @click="(e: any) => emit('click', e)" :list="localList" v-bind="$attrs">
<template v-slot="{ item, index, active }">
<div class="common-list-item" :class="{ active }">
<div class="left">
<div class="title-wrapper">
<div class="item-title">
<div class="name">
<span class="text-sm text-gray-500" v-if="index != undefined && !searchKey">
{{ index }}.
</span>
{{ item.title }}
</div>
</div>
<div class="item-sub-title" v-if="item.titleTranslate && showTranslate">
<div class="item-translate">{{ ` ${item.titleTranslate}` }}</div>
</div>
</div>
</div>
<div class="right">
<BaseIcon
:class="!isArticleCollect(item) ? 'collect' : 'fill'"
@click.stop="toggleArticleCollect(item)"
:title="!isArticleCollect(item) ? '收藏' : '取消收藏'"
>
<IconFluentStar16Regular v-if="!isArticleCollect(item)" />
<IconFluentStar16Filled v-else />
</BaseIcon>
<BaseIcon title="可播放音频" v-if="item.audioSrc || item.audioFileId">
<IconBxVolumeFull class="opacity-100! color-gray" />
</BaseIcon>
</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>

View File

@@ -1,26 +1,31 @@
<script setup lang="ts">
import { useSettingStore } from "@/stores/setting.ts";
import { useSettingStore } from '@/stores/setting.ts'
import { nextTick, watch } from 'vue'
const props = withDefaults(defineProps<{
list?: any[],
activeIndex?: number,
activeId?: number | string,
isActive?: boolean
static?: boolean
}>(), {
list: [],
activeIndex: -1,
activeId: '',
isActive: false,
static: true
})
const props = withDefaults(
defineProps<{
list?: any[]
activeIndex?: number
activeId?: number | string
isActive?: boolean
static?: boolean
}>(),
{
list: [],
activeIndex: -1,
activeId: '',
isActive: false,
static: true,
}
)
const emit = defineEmits<{
click: [val: {
item: any,
index: number
}],
click: [
val: {
item: any
index: number
},
]
}>()
//虚拟列表长度限制
@@ -41,35 +46,45 @@ function scrollViewToCenter(index: number) {
if (props.list.length > limit) {
listRef?.scrollToItem(index)
} else {
listRef?.children[index]?.scrollIntoView({block: 'center', behavior: 'smooth'})
listRef?.children[index]?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
})
}
watch(() => localActiveIndex, (n: any) => {
if (props.static) return
if (settingStore.showPanel) {
scrollViewToCenter(n)
}
}, {immediate: true})
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)
watch(
() => localActiveIndex,
(n: any) => {
if (props.static) return
if (settingStore.showPanel) {
scrollViewToCenter(n)
}
})
})
},
{ immediate: true }
)
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(() => {
@@ -86,91 +101,51 @@ function scrollToItem(index: number) {
if (props.list.length > limit) {
listRef?.scrollToItem(index)
} else {
listRef?.children[index]?.scrollIntoView({block: 'center', behavior: 'smooth'})
listRef?.children[index]?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
})
}
function itemIsActive(item: any, index: number) {
return props.activeId ?
props.activeId == item.id
: props.activeIndex === index
return props.activeId ? props.activeId == item.id : props.activeIndex === index
}
defineExpose({scrollToBottom, scrollToItem})
defineExpose({ scrollToBottom, scrollToItem })
</script>
<template>
<DynamicScroller
v-if="list.length>limit"
:items="list"
ref="listRef"
:min-item-size="90"
class="scroller"
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"
: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 class="list-item-wrapper" v-for="(item, index) in props.list" :key="item.title">
<div @click="emit('click', { item, index })">
<slot :item="item" :index="index" :active="itemIsActive(item, index)"></slot>
</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 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 @click="emit('click', { item, index })">
<slot :item="item" :index="index" :active="itemIsActive(item, index)"></slot>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.scroller {
flex: 1;
//padding: 0 var(--space);

View File

@@ -1,11 +1,8 @@
<script setup lang="ts">
import { Word } from "@/types/types.ts";
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
import BaseList from "@/components/list/BaseList.vue";
import { usePlayWordAudio } from "@/hooks/sound.ts";
import Tooltip from "@/components/base/Tooltip.vue";
import WordItem from "@/components/WordItem.vue";
import { Word } from "@/types/types.ts";
import WordItem from "../WordItem.vue";
withDefaults(defineProps<{
list: Word[],
@@ -19,7 +16,6 @@ withDefaults(defineProps<{
const emit = defineEmits<{
click: [val: { item: Word, index: number }],
title: [val: { item: Word, index: number }],
}>()
const listRef: any = $ref(null as any)
@@ -32,22 +28,18 @@ function scrollToItem(index: number) {
listRef?.scrollToItem(index)
}
const playWordAudio = usePlayWordAudio()
defineExpose({ scrollToBottom, scrollToItem })
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 }">
<WordItem :item="item"/>
</template>
<template v-slot:suffix="{ item, index }">
<slot name="suffix" :item="item" :index="index"></slot>
</template>
<BaseList
ref="listRef"
@click="(e:any) => emit('click',e)"
:list="list"
v-bind="$attrs">
<template v-slot="{ item, index, active }">
<WordItem :item="item" :index="index" :active="active" />
</template>
</BaseList>
</template>