Develop mobile pages

This commit is contained in:
zyronon
2024-01-02 22:35:46 +08:00
parent c9eed4da54
commit 5747a5502c
65 changed files with 178 additions and 178 deletions

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import BaseIcon from "@/components/BaseIcon.vue";
import WordList from "@/components/list/WordList.vue";
import WordList from "@/pages/pc/components/list/WordList.vue";
import BaseButton from "@/components/BaseButton.vue";
import PopConfirm from "@/components/PopConfirm.vue";
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
import {$computed, $ref} from "vue/macros";
import {Dict, DictType} from "@/types.ts";
import {useRouter} from "vue-router";

View File

@@ -4,23 +4,23 @@ import {useBaseStore} from "@/stores/base.ts"
import {$computed, $ref} from "vue/macros"
import {computed, onMounted, onUnmounted, provide, watch} from "vue"
import {Dict, DictType, ShortcutKey} from "@/types.ts"
import PopConfirm from "@/components/PopConfirm.vue"
import PopConfirm from "@/pages/pc/components/PopConfirm.vue"
import BaseButton from "@/components/BaseButton.vue";
import {useSettingStore} from "@/stores/setting.ts";
import Close from "@/components/icon/Close.vue";
import Empty from "@/components/Empty.vue";
import {useArticleOptions, useWordOptions} from "@/hooks/dict.ts";
import {Icon} from "@iconify/vue";
import Tooltip from "@/components/Tooltip.vue";
import IconWrapper from "@/components/IconWrapper.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {useRouter} from "vue-router";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {cloneDeep} from "lodash-es";
import WordList from "@/components/list/WordList.vue";
import ArticleList from "@/components/list/ArticleList.vue";
import Slide from "@/components/Slide.vue";
import WordList from "@/pages/pc/components/list/WordList.vue";
import ArticleList from "@/pages/pc/components/list/ArticleList.vue";
import Slide from "@/pages/pc/components/Slide.vue";
import SlideHorizontal from "@/components/slide/SlideHorizontal.vue";
import SlideItem from "@/components/slide/SlideItem.vue";
import CollectList from "@/pages/mobile/components/CollectList.vue";

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import BaseIcon from "@/components/BaseIcon.vue";
import WordList from "@/components/list/WordList.vue";
import WordList from "@/pages/pc/components/list/WordList.vue";
import BaseButton from "@/components/BaseButton.vue";
import PopConfirm from "@/components/PopConfirm.vue";
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
import {$ref} from "vue/macros";
import {Dict, DictType} from "@/types.ts";
import {useBaseStore} from "@/stores/base.ts";

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import BaseIcon from "@/components/BaseIcon.vue";
import WordList from "@/components/list/WordList.vue";
import WordList from "@/pages/pc/components/list/WordList.vue";
import BaseButton from "@/components/BaseButton.vue";
import PopConfirm from "@/components/PopConfirm.vue";
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
import {$ref} from "vue/macros";
import {Dict, DictType} from "@/types.ts";
import {useBaseStore} from "@/stores/base.ts";

View File

@@ -1,14 +1,14 @@
<script setup lang="ts">
import {Icon} from "@iconify/vue";
import IconWrapper from "@/components/IconWrapper.vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import useTheme from "@/hooks/theme.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {$ref} from "vue/macros";
import SlideItem from "@/components/slide/SlideItem.vue";
import SlideHorizontal from "@/components/slide/SlideHorizontal.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import WordList from "@/components/list/WordList.vue";
import WordList from "@/pages/pc/components/list/WordList.vue";
import {useRouter} from "vue-router";
import {useBaseStore} from "@/stores/base.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";

View File

@@ -13,8 +13,8 @@ import BaseButton from "@/components/BaseButton.vue";
import Options from "@/pages/pc/practice/Options.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import MobilePanel from "@/pages/mobile/components/MobilePanel.vue";
import MiniDialog from "@/components/dialog/MiniDialog.vue";
import WordList from "@/components/list/WordList.vue";
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import WordList from "@/pages/pc/components/list/WordList.vue";
import Empty from "@/components/Empty.vue";
import {Icon} from "@iconify/vue";
import router from "@/router.ts";

View File

@@ -9,7 +9,7 @@ import {usePlayBeep, usePlayCorrect, usePlayKeyboardAudio, usePlayWordAudio, use
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {cloneDeep} from "lodash-es";
import {onUnmounted, watch, onMounted, nextTick} from "vue";
import Tooltip from "@/components/Tooltip.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
interface IProps {
word: Word,

View File

@@ -12,16 +12,16 @@ import Typing from "@/pages/mobile/practice/practice-word/Typing.vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {useWordOptions} from "@/hooks/dict.ts";
import BaseIcon from "@/components/BaseIcon.vue";
import WordList from "@/components/list/WordList.vue";
import WordList from "@/pages/pc/components/list/WordList.vue";
import Empty from "@/components/Empty.vue";
import MiniDialog from "@/components/dialog/MiniDialog.vue";
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import BaseButton from "@/components/BaseButton.vue";
import SlideHorizontal from "@/components/slide/SlideHorizontal.vue";
import SlideItem from "@/components/slide/SlideItem.vue";
import MobilePanel from "@/pages/mobile/components/MobilePanel.vue";
import router from "@/router.ts";
import {Icon} from "@iconify/vue";
import IconWrapper from "@/components/IconWrapper.vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import useTheme from "@/hooks/theme.ts";
interface IProps {

View File

@@ -0,0 +1,110 @@
<template>
<div id="background" class="anim">
<img src="../../../assets/img/moon.png" alt="" id="moon" style="display:none">
<Transition name="fade">
<canvas ref="canvas" v-show="settingStore.theme === 'dark'"/>
</Transition>
</div>
</template>
<script setup lang="ts">
import {onMounted} from "vue"
import {useSettingStore} from "@/stores/setting.ts";
const canvas = $ref<HTMLCanvasElement>()
const settingStore = useSettingStore()
onMounted(() => {
// console.log('canvas;', canvas)
let ctx = canvas.getContext("2d");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
let maxRadius = 1,
stars = [];
let Star = function (x: number, y: number, r: number) {
this.x = x;
this.y = y;
this.r = r;
};
Star.prototype = {
paint: function () {
ctx.save();
ctx.beginPath();
ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
ctx.fillStyle = "rgba(255,255,255," + this.r + ")";
ctx.fill();
ctx.restore();
},
};
function drawBg() {
for (let i = 0; i < 1000; i++) {
let r = Math.random() * maxRadius;
let x = Math.random() * canvas.width;
let y = Math.random() * 2 * canvas.height - canvas.height;
let star = new Star(x, y, r);
stars.push(star);
star.paint();
}
}
drawBg()
function drawMoon() {
let moon: HTMLImageElement = document.getElementById("moon");
let centerX = canvas.width - 200,
centerY = 100,
width = 80;
let index = 0;
for (let i = 0; i < 10; i++) {
ctx.save();
ctx.beginPath();
ctx.arc(
centerX + width / 2,
centerY + width / 2,
width / 2 + index,
0,
2 * Math.PI
);
ctx.fillStyle = "rgba(240,219,120,0.05)";
index += 2;
ctx.fill();
ctx.restore();
}
if (moon.complete) {
ctx.drawImage(moon, centerX, centerY, width, width);
} else {
moon.onload = function () {
ctx.drawImage(moon, centerX, centerY, width, width);
};
}
}
drawMoon()
})
</script>
<style scoped lang="scss">
#background {
position: fixed;
width: 100vw;
height: 100vh;
left: 0;
top: 0;
background-color: var(--color-main-bg);
canvas {
width: 100vw;
height: 100vh;
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import {Icon} from "@iconify/vue";
import Close from "@/components/icon/Close.vue";
import BaseButton from "@/components/BaseButton.vue";
import {watch} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
import {$ref} from "vue/macros";
import {isMobile} from "@/utils";
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) {
show = true
}
})
</script>
<template>
<transition name="right">
<div class="CollectNotice"
:class="{mobile}"
v-if="show">
<div class="notice">
坚持练习提高外语能力
<span class="active">Typing Word</span>
保存为书签永不迷失
</div>
<div class="wrapper">
<transition name="fade">
<div class="collect" v-if="showNotice">
<div class="href-wrapper">
<div class="round">
<div class="href">typing-word.ttentau.top</div>
<Icon
width="22"
icon="mdi:star-outline"/>
</div>
<div class="right">
👈
<Icon
class="star"
width="22"
icon="mdi:star"/>
点亮它!
</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: 20rem;
display: flex;
flex-direction: column;
align-items: center;
background: var(--color-second-bg);
padding: 30rem;
border-radius: 12rem;
width: 500rem;
gap: 40rem;
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: 10rem;
}
.notice {
margin-top: 30rem;
}
.active {
color: var(--color-main-active);
}
.wrapper {
.collect {
display: flex;
flex-direction: column;
align-items: center;
.href-wrapper {
display: flex;
font-size: 16rem;
align-items: center;
gap: 10rem;
.round {
color: var(--color-font-1);
border-radius: 50rem;
padding: 10rem 10rem;
padding-left: 20rem;
gap: 30rem;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--color-main-bg);
.href {
font-size: 14rem;
}
}
.star {
color: var(--color-main-active);
}
.right {
display: flex;
align-items: center;
}
}
.collect-keyboard {
margin-top: 20rem;
font-size: 16rem;
span {
margin-left: 10rem;
}
}
}
}
.close-wrapper {
right: var(--space);
top: var(--space);
position: absolute;
font-size: 14rem;
display: flex;
justify-content: flex-end;
align-items: center;
color: var(--color-font-1);
gap: 10rem;
}
}
</style>

View File

@@ -0,0 +1,177 @@
<script setup lang="ts">
import {DictResource, languageCategoryOptions} from "@/types.ts";
import {$computed, $ref} from "vue/macros";
import {dictionaryResources} from "@/assets/dictionary.ts";
import {groupBy} from "lodash-es";
import {useBaseStore} from "@/stores/base.ts";
import DictList from "@/pages/pc/components/list/DictList.vue";
import DictGroup from "@/pages/pc/components/list/DictGroup.vue";
const emit = defineEmits<{
add: [],
selectDict: [val: { dict: any, index: number }]
}>()
const store = useBaseStore()
let currentLanguage = $ref('my')
let currentTranslateLanguage = $ref('common')
let groupByLanguage = groupBy(dictionaryResources, 'language')
let translateLanguageList = $ref([])
function groupByDictTags(dictList: DictResource[]) {
return dictList.reduce<Record<string, DictResource[]>>((result, dict) => {
dict.tags.forEach((tag) => {
if (Object.prototype.hasOwnProperty.call(result, tag)) {
result[tag].push(dict)
} else {
result[tag] = [dict]
}
})
return result
}, {})
}
const groupByTranslateLanguage = $computed(() => {
let data: any
if (currentLanguage === 'article') {
let articleList = dictionaryResources.filter(v => v.type === 'article')
data = groupBy(articleList, 'translateLanguage')
} else if (currentLanguage === 'my') {
data = {
common: store.myDictList.concat([{id: '',} as any])
}
} else {
data = groupBy(groupByLanguage[currentLanguage], 'translateLanguage')
}
// console.log('groupByTranslateLanguage', data)
translateLanguageList = Object.keys(data)
currentTranslateLanguage = translateLanguageList[0]
return data
})
const groupedByCategoryAndTag = $computed(() => {
const currentTranslateLanguageDictList = groupByTranslateLanguage[currentTranslateLanguage]
const groupByCategory = groupBy(currentTranslateLanguageDictList, 'category')
let data = []
for (const [key, value] of Object.entries(groupByCategory)) {
data.push([key, groupByDictTags(value)])
}
// console.log('groupedByCategoryAndTag', data)
return data
})
</script>
<template>
<div class="dict-list-panel">
<header>
<div class="tabs">
<div class="tab"
:class="currentLanguage === item.id && 'active'"
@click="currentLanguage = item.id"
v-for="item in languageCategoryOptions">
<img :src='item.flag' alt=""/>
<span>{{ item.name }}</span>
</div>
</div>
</header>
<div class="page-content">
<div class="dict-list-wrapper">
<template v-if="currentLanguage === 'my'">
<DictList
@add="emit('add')"
@selectDict="e => emit('selectDict',e)"
:select-id="store.currentDict.id"
:list="groupByTranslateLanguage['common']"/>
</template>
<template v-else>
<div class="translate">
<span>翻译</span>
<el-radio-group v-model="currentTranslateLanguage">
<el-radio-button border v-for="i in translateLanguageList" :label="i">{{ $t(i) }}</el-radio-button>
</el-radio-group>
</div>
<DictGroup
v-for="item in groupedByCategoryAndTag"
:select-id="store.currentDict.id"
@selectDict="e => emit('selectDict',e)"
:groupByTag="item[1]"
:category="item[0]"
/>
</template>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
.dict-list-panel {
width: 50%;
height: 100%;
$header-height: 60rem;
padding: var(--space);
padding-top: 0;
box-sizing: border-box;
header {
display: flex;
justify-content: space-between;
align-items: center;
height: $header-height;
.tabs {
display: flex;
gap: 20rem;
.tab {
color: var(--color-font-1);
cursor: pointer;
padding: 10rem;
padding-bottom: 5rem;
transition: all .5s;
border-bottom: 2px solid transparent;
display: flex;
align-items: center;
gap: 6rem;
&.active {
$main: rgb(64, 158, 255);
border-bottom: 2px solid $main;
}
img {
height: 30rem;
}
}
}
}
.page-content {
display: flex;
height: calc(100% - $header-height);
.dict-list-wrapper {
flex: 1;
overflow: auto;
height: 100%;
padding-right: 10rem;
.translate {
display: flex;
align-items: center;
color: var(--color-font-1);
margin-bottom: 30rem;
& > span {
font-size: 22rem;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import BaseButton from "@/components/BaseButton.vue";
import {$ref} from "vue/macros";
import {watchEffect} from "vue";
interface IProps {
value: string,
}
const props = withDefaults(defineProps<IProps>(), {
value: '',
})
const emit = defineEmits([
'save'
])
let editVal = $ref('')
let edit = $ref(false)
watchEffect(() => {
editVal = props.value
})
function save() {
emit('save', editVal)
edit = false
}
function toggle() {
edit = !edit
}
</script>
<template>
<div
v-if="edit"
class="edit-text">
<el-input
v-model="editVal"
ref="inputRef"
autosize
autofocus
type="textarea"
:input-style="`color: var(--color-font-1);font-size: 16rem;`"
/>
<div class="options">
<BaseButton @click="toggle">取消</BaseButton>
<BaseButton @click="save">保存</BaseButton>
</div>
</div>
<div
v-else
class="text"
:style="`font-size: 16rem;`"
@click="toggle">
{{ value }}
</div>
</template>
<style scoped lang="scss">
.edit-text {
margin-top: 10rem;
color: var(--color-font-1);
.options {
margin-top: 10rem;
gap: 10rem;
display: flex;
justify-content: flex-end;
}
}
.text {
color: var(--color-font-1);
min-height: 18rem;
}
</style>

View File

@@ -0,0 +1,262 @@
<template>
<Teleport to="body">
<canvas ref="canvas"/>
</Teleport>
</template>
<script setup>
import {onMounted} from "vue";
import {getRandom} from "@/utils/index.ts";
import boom from '@/assets/sound/boom.mp3'
import shotfire from '@/assets/sound/shotfire.mp3'
import {useSound} from "@/hooks/sound.ts";
const canvas = $ref()
const {play: playBoom} = useSound([boom], 3)
const {play: playShotFire} = useSound([shotfire], 3)
onMounted(() => {
let ctx = canvas.getContext("2d");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
let bigBooms = [];
let lastTime;
let raf = window.requestAnimationFrame
let count = 0
let isBreak = false
function initAnimate() {
lastTime = new Date();
animate();
}
initAnimate()
function animate() {
ctx.save();
ctx.globalCompositeOperation = "destination-out";
ctx.globalAlpha = 0.1;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
let newTime = new Date();
if (newTime - lastTime > 200 + (window.innerHeight - 767) / 2 && count < 11) {
let boomArea
let startX
if (count % 2 === 0) {
startX = getRandom(0, canvas.width * 0.1);
// startX = getRandom(500, 700);
boomArea = {
x: getRandom(canvas.width * 0.1, canvas.width * 0.3),
y: getRandom(50, 200)
}
} else {
startX = getRandom(canvas.width * 0.9, canvas.width);
boomArea = {
x: getRandom(canvas.width * 0.7, canvas.width * 0.9),
y: getRandom(50, 200)
}
}
let bigBoom = new Boom(
startX,
2,
"#FFF",
boomArea
);
bigBooms.push(bigBoom);
lastTime = newTime;
count++
}
bigBooms.map((itemI) => {
if (!itemI.dead) {
itemI._move();
} else {
itemI.booms.map((itemJ, index) => {
if (!itemJ.dead) {
itemJ.moveTo();
} else if (index === itemI.booms.length - 1) {
bigBooms.splice(bigBooms.indexOf(itemI), 1);
}
});
if (bigBooms.length === 0) {
setTimeout(() => {
isBreak = true
}, 500)
}
}
});
if (!isBreak) {
raf(animate);
} else {
canvas.style.display = 'none'
}
}
class Boom {
booms = [];
x;
y;
r;
color;
shape;
boomArea;
theta;
dead;
ba;
constructor(x, r, color, boomArea, shape) {
this.x = x;
this.y = canvas.height + r;
this.r = r;
// console.log(this.x, this.y, this.r, boomArea)
this.color = color;
this.shape = shape || false;
this.boomArea = boomArea;
this.theta = 0;
this.dead = false;
this.ba = getRandom(80, 200);
// playShotFire()
}
_move() {
let dx = this.boomArea.x - this.x,
dy = this.boomArea.y - this.y;
this.x = this.x + dx * 0.01;
this.y = this.y + dy * 0.01;
// console.log(this.x, this.y, dx, this.ba)
if (Math.abs(dx) <= this.ba && Math.abs(dy) <= this.ba) {
this._boom();
this.dead = true;
} else {
this._paint();
this._drawLight();
}
}
_paint() {
ctx.save();
ctx.beginPath();
ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
ctx.fillStyle = this.color;
ctx.fill();
ctx.restore();
}
_drawLight() {
ctx.save();
ctx.fillStyle = "rgba(255,228,150,0.3)";
ctx.beginPath();
ctx.arc(
this.x,
this.y,
this.r + 3 * Math.random() + 1,
0,
2 * Math.PI
);
ctx.fill();
ctx.restore();
}
_boom() {
let fireNum = getRandom(100, 300);
let style = getRandom(0, 10) >= 5 ? 1 : 2;
let color;
if (style === 1) {
color = {
a: getRandom(128, 255),
b: getRandom(128, 255),
c: getRandom(128, 255),
};
}
let fanwei = fireNum;
// playBoom()
for (let i = 0; i < fireNum; i++) {
if (style === 2) {
color = {
a: getRandom(128, 255),
b: getRandom(128, 255),
c: getRandom(128, 255),
};
}
let a = getRandom(-Math.PI, Math.PI);
let x = getRandom(0, fanwei) * Math.cos(a) + this.x;
let y = getRandom(0, fanwei) * Math.sin(a) + this.y;
let radius = getRandom(0, 2);
let frag = new Firework(this.x, this.y, radius, color, x, y);
this.booms.push(frag);
}
}
}
class Firework {
tx = 0;
ty = 0;
x = 0;
y = 0;
dead = false;
centerX = 0;
centerY = 0;
radius = 0;
color = 0;
constructor(x, y, radius, color, tx, ty) {
this.tx = tx;
this.ty = ty;
this.x = x;
this.y = y;
this.dead = false;
this.centerX = x;
this.centerY = y;
this.radius = radius;
this.color = color;
}
paint() {
ctx.fillStyle = `rgba(${this.color.a},${this.color.b},${this.color.c})`;
ctx.fillRect(
this.x - this.radius,
this.y - this.radius,
this.radius * 2,
this.radius * 2
);
}
moveTo() {
this.ty = this.ty + 0.3;
let dx = this.tx - this.x,
dy = this.ty - this.y;
this.x = Math.abs(dx) < 0.1 ? this.tx : this.x + dx * 0.1;
this.y = Math.abs(dy) < 0.1 ? this.ty : this.y + dy * 0.1;
if (dx === 0 && Math.abs(dy) <= 80) {
this.dead = true;
}
this.paint();
}
}
})
</script>
<style scoped lang="scss">
canvas {
z-index: 99999;
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
background: transparent;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div class="icon-wrapper">
<slot></slot>
</div>
</template>
<style scoped lang="scss">
$w: 22rem;
.icon-wrapper {
cursor: pointer;
//padding: 2rem;
width: 26rem;
height: 26rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 3rem;
background: transparent;
transition: all .3s;
color: var(--color-main-active);
&:hover {
background: var(--color-main-active);
color: white;
}
:deep(svg) {
width: $w;
height: $w;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import {$ref} from "vue/macros";
import {Icon} from "@iconify/vue";
import Close from "@/components/icon/Close.vue";
import {useDisableEventListener, useWindowClick} from "@/hooks/event.ts";
import {watch} from "vue";
defineProps<{
modelValue: string
}>()
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)
</script>
<template>
<div class="base-input"
:class="{focus}"
ref="inputEl"
>
<Icon icon="fluent:search-24-regular"
width="20"/>
<input type="text"
:value="modelValue"
@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">
@import "@/assets/css/style";
.base-input {
border: 1px solid var(--color-second-bg);
border-radius: 6rem;
overflow: hidden;
padding: 3rem 5rem;
transition: all .3s;
display: flex;
align-items: center;
transition: all .3s;
background: var(--color-input-bg);
:deep(svg) {
transition: all .3s;
color: var(--color-input-icon);
}
&.focus {
border: 1px solid var(--color-main-active);
:deep(svg) {
color: gray;
}
}
input {
font-family: var(--font-family);
font-size: 18rem;
outline: none;
min-height: 20rem;
flex: 1;
box-sizing: border-box;
outline: none;
border: none;
background: transparent;
&[readonly] {
cursor: not-allowed;
opacity: .7;
}
}
}
</style>

View File

@@ -0,0 +1,30 @@
<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="logo" @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">
.logo {
position: fixed;
left: var(--space);
top: var(--space);
z-index: 1;
img {
cursor: pointer;
height: 35rem;
}
}
</style>

View File

@@ -0,0 +1,123 @@
<script lang="jsx">
import {nextTick, Teleport, Transition} from "vue";
export default {
name: "PopConfirm",
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
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" className="pop-confirm-content">
<div className="text">
{this.title}
</div>
<div className="options">
<div onClick={() => this.show = false}>取消</div>
<div className="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: 15rem;
border-radius: 4rem;
transform: translate(-50%, calc(-100% - 10rem));
box-shadow: 0 0 6px 1px var(--color-tooltip-shadow);
z-index: 999;
.text {
color: var(--color-font-1);
text-align: start;
font-size: 14rem;
width: 150rem;
min-width: 150rem;
}
.options {
margin-top: 15rem;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12rem;
font-size: 12rem;
div {
cursor: pointer;
}
.main {
color: gray;
background: $bg-color;
padding: 3rem 10rem;
border-radius: 4rem;
}
}
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import {ShortcutKey} from "@/types.ts";
import {$ref} from "vue/macros";
import FeedbackModal from "@/pages/pc/components/toolbar/FeedbackModal.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import {Icon} from "@iconify/vue";
import useTheme from "@/hooks/theme.ts";
import {useSettingStore} from "@/stores/setting.ts";
let showFeedbackModal = $ref(false)
const {toggleTheme} = useTheme()
const settingStore = useSettingStore()
</script>
<template>
<div class="right-bar">
<Tooltip
:title="`切换主题(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.ToggleTheme]})`"
>
<IconWrapper>
<Icon icon="ep:moon" v-if="settingStore.theme === 'dark'"
@click="toggleTheme"/>
<Icon icon="tabler:sun" v-else @click="toggleTheme"/>
</IconWrapper>
</Tooltip>
<a href="https://github.com/zyronon/typing-word" target="_blank">
<BaseIcon
title="Github地址"
icon="mdi:github"/>
</a>
</div>
</template>
<style scoped lang="scss">
.right-bar {
position: fixed;
right: var(--space);
top: var(--space);
z-index: 1;
display: flex;
gap: 10rem;
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="ring">
<svg height="100%" width="100%">
<circle class="circle-full"
cx="40rem"
cy="40rem"
r="35rem"
fill="none"
stroke-width="6rem"
stroke-linecap="round"></circle>
<circle v-if="props.percentage" ref="circleEl"
class="circle-detail"
cx="40rem"
cy="40rem"
r="35rem"
fill="none"
stroke-width="6rem"
stroke-linecap="round"
stroke-dasharray="0,10000"></circle>
</svg>
<span class="value">{{ props.value }}</span>
<span class="desc">{{ props.desc }}</span>
</div>
</template>
<script setup lang="ts">
import {onMounted} from "vue"
const props = withDefaults(defineProps<{
percentage?: number,
value: string | number,
desc: string,
}>(), {
percentage: 90,
})
const circleEl = $ref(null)
onMounted(() => {
if (props.percentage) {
let circleLength = Math.floor(2 * Math.PI * 40);
let val = Number(props.percentage.toFixed(0));
circleEl.setAttribute("stroke-dasharray", "" + circleLength * val / 100 + "rem,10000");
}
})
</script>
<style scoped lang="scss">
@import "@/assets/css/variable";
$w: 80rem;
$w2: calc($w / 2);
.ring {
font-size: 16rem;
width: $w;
height: $w;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
position: relative;
margin-bottom: 6rem;
svg {
position: absolute;
.circle-full {
$item-hover: rgb(75, 75, 75);
stroke: $item-hover;
}
.circle-detail {
transform-origin: $w2 $w2;
transform: rotate(-90deg);
stroke: var(--color-main-active);
}
}
.value {
}
.desc {
font-size: 12rem;
opacity: .6;
}
}
</style>

View File

@@ -0,0 +1,577 @@
<script setup lang="ts">
import {Icon} from '@iconify/vue';
import {ref, watch} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
import {getAudioFileUrl, useChangeAllSound, usePlayAudio, useWatchAllSound} from "@/hooks/sound.ts";
import {getShortcutKey, useDisableEventListener, useEventListener} from "@/hooks/event.ts";
import {$computed, $ref} from "vue/macros";
import {cloneDeep} from "lodash-es";
import {DefaultShortcutKeyMap, Dict, DictType, ShortcutKey} from "@/types.ts";
import BaseButton from "@/components/BaseButton.vue";
import {APP_NAME, EXPORT_DATA_KEY, SAVE_DICT_KEY, SAVE_SETTING_KEY, SoundFileOptions} from "@/utils/const.ts";
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
import {BaseState, useBaseStore} from "@/stores/base.ts";
import * as copy from "copy-to-clipboard";
import {saveAs} from "file-saver";
import {checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting, shakeCommonDict} from "@/utils";
import {dayjs} from "element-plus";
const emit = defineEmits<{
toggleDisabledDialogEscKey: [val: boolean]
}>()
const tabIndex = $ref(0)
const settingStore = useSettingStore()
const store = useBaseStore()
//@ts-ignore
const gitLastCommitHash = ref(LATEST_COMMIT_HASH);
useDisableEventListener(() => undefined)
useWatchAllSound()
let editShortcutKey = $ref('')
const disabledDefaultKeyboardEvent = $computed(() => {
return editShortcutKey && tabIndex === 2
})
watch(() => disabledDefaultKeyboardEvent, v => {
emit('toggleDisabledDialogEscKey', !!v)
})
useEventListener('keydown', (e: KeyboardEvent) => {
if (!disabledDefaultKeyboardEvent) return
e.preventDefault()
let shortcutKey = getShortcutKey(e)
// console.log('e', e, e.keyCode, e.ctrlKey, e.altKey, e.shiftKey)
// console.log('key', shortcutKey)
// if (shortcutKey[shortcutKey.length-1] === '+') {
// settingStore.shortcutKeyMap[editShortcutKey] = DefaultShortcutKeyMap[editShortcutKey]
// return ElMessage.warning('设备失败!')
// }
if (editShortcutKey) {
if (shortcutKey === 'Delete') {
settingStore.shortcutKeyMap[editShortcutKey] = ''
} else {
for (const [k, v] of Object.entries(settingStore.shortcutKeyMap)) {
if (v === shortcutKey && k !== editShortcutKey) {
settingStore.shortcutKeyMap[editShortcutKey] = DefaultShortcutKeyMap[editShortcutKey]
return ElMessage.warning('快捷键重复!')
}
}
settingStore.shortcutKeyMap[editShortcutKey] = shortcutKey
}
}
})
function resetShortcutKeyMap() {
editShortcutKey = ''
settingStore.shortcutKeyMap = cloneDeep(DefaultShortcutKeyMap)
ElMessage.success('恢复成功')
}
function exportData() {
let data = {
version: EXPORT_DATA_KEY.version,
val: {
setting: {
version: SAVE_SETTING_KEY.version,
val: settingStore.$state
},
dict: {
version: SAVE_DICT_KEY.version,
val: shakeCommonDict(store.$state)
}
}
}
let blob = new Blob([JSON.stringify(data)], {type: "text/plain;charset=utf-8"});
let date = new Date()
let dateStr = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`
saveAs(blob, `${APP_NAME}-User-Data-${dateStr}.json`);
ElMessage.success('导出成功!')
}
function importData(e) {
let file = e.target.files[0]
if (!file) return
// no()
let reader = new FileReader();
reader.onload = function (v) {
let str = v.target.result;
if (str) {
let obj = JSON.parse(str)
if (obj.version === EXPORT_DATA_KEY.version) {
} else {
//TODO
}
let data = obj.val
let settingState = checkAndUpgradeSaveSetting(data.setting)
settingStore.setState(settingState)
let dictState = checkAndUpgradeSaveDict(data.dict)
store.init(dictState)
ElMessage.success('导入成功!')
}
}
reader.readAsText(file);
}
</script>
<template>
<div class="setting">
<div class="left">
<div class="tabs">
<div class="tab" :class="tabIndex === 0 && 'active'" @click="tabIndex = 0">
<Icon icon="bx:headphone" width="20" color="#0C8CE9"/>
<span>音效设置</span>
</div>
<div class="tab" :class="tabIndex === 2 && 'active'" @click="tabIndex = 2">
<Icon icon="material-symbols:keyboard-outline" width="20" color="#0C8CE9"/>
<span>快捷键设置</span>
</div>
<div class="tab" :class="tabIndex === 1 && 'active'" @click="tabIndex = 1">
<Icon icon="icon-park-outline:setting-config" width="20" color="#0C8CE9"/>
<span>其他设置</span>
</div>
<div class="tab" :class="tabIndex === 3 && 'active'" @click="tabIndex = 3">
<Icon icon="mdi:database-cog-outline" width="20" color="#0C8CE9"/>
<span>数据管理</span>
</div>
</div>
<div class="git-log">
Build {{ gitLastCommitHash }}
</div>
</div>
<div class="content">
<div v-if="tabIndex === 0">
<div class="row">
<label class="main-title">所有音效</label>
<div class="wrapper">
<el-switch v-model="settingStore.allSound"
@change="useChangeAllSound"
inline-prompt
active-text=""
inactive-text=""
/>
</div>
</div>
<div class="line"></div>
<div class="row">
<label class="item-title">单词/句子自动发音</label>
<div class="wrapper">
<el-switch v-model="settingStore.wordSound"
inline-prompt
active-text=""
inactive-text=""
/>
</div>
</div>
<div class="row">
<label class="sub-title">单词/句子发音口音</label>
<div class="wrapper">
<el-select v-model="settingStore.wordSoundType"
placeholder="请选择"
>
<el-option label="美音" value="us"/>
<el-option label="英音" value="uk"/>
</el-select>
</div>
</div>
<div class="row">
<label class="sub-title">音量</label>
<div class="wrapper">
<el-slider v-model="settingStore.wordSoundVolume"/>
<span>{{ settingStore.wordSoundVolume }}%</span>
</div>
</div>
<div class="row">
<label class="sub-title">倍速</label>
<div class="wrapper">
<el-slider v-model="settingStore.wordSoundSpeed" :step="0.1" :min="0.5" :max="3"/>
<span>{{ settingStore.wordSoundSpeed }}</span>
</div>
</div>
<div class="line"></div>
<div class="row">
<label class="item-title">按键音</label>
<div class="wrapper">
<el-switch v-model="settingStore.keyboardSound"
inline-prompt
active-text=""
inactive-text=""
/>
</div>
</div>
<div class="row">
<label class="item-title">按键音效</label>
<div class="wrapper">
<el-select v-model="settingStore.keyboardSoundFile"
placeholder="请选择"
>
<el-option
v-for="item in SoundFileOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>
<div class="el-option-row">
<span>{{ item.label }}</span>
<VolumeIcon
:time="100"
@click="usePlayAudio(getAudioFileUrl(item.value)[0])"/>
</div>
</el-option>
</el-select>
</div>
</div>
<div class="row">
<label class="sub-title">音量</label>
<div class="wrapper">
<el-slider v-model="settingStore.keyboardSoundVolume"/>
<span>{{ settingStore.keyboardSoundVolume }}%</span>
</div>
</div>
<div class="line"></div>
<!-- <div class="row">-->
<!-- <label class="item-title">释义发音</label>-->
<!-- <div class="wrapper">-->
<!-- <el-switch v-model="settingStore.translateSound"-->
<!-- inline-prompt-->
<!-- active-text="开"-->
<!-- inactive-text="关"-->
<!-- />-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="row">-->
<!-- <label class="sub-title">音量</label>-->
<!-- <div class="wrapper">-->
<!-- <el-slider v-model="settingStore.translateSoundVolume"/>-->
<!-- <span>{{ settingStore.translateSoundVolume }}%</span>-->
<!-- </div>-->
<!-- </div>-->
<div class="line"></div>
<div class="row">
<label class="item-title">效果音(章节结算页烟花音效)</label>
<div class="wrapper">
<el-switch v-model="settingStore.effectSound"
inline-prompt
active-text="开"
inactive-text="关"
/>
</div>
</div>
<div class="row">
<label class="sub-title">音量</label>
<div class="wrapper">
<el-slider v-model="settingStore.effectSoundVolume"/>
<span>{{ settingStore.effectSoundVolume }}%</span>
</div>
</div>
</div>
<div v-if="tabIndex === 1">
<div class="row">
<label class="item-title">显示上一个/下一个单词</label>
<div class="wrapper">
<el-switch v-model="settingStore.showNearWord"
inline-prompt
active-text="开"
inactive-text="关"
/>
</div>
</div>
<div class="desc">
开启后,练习中会在上方显示上一个/下一个单词
</div>
<div class="line"></div>
<div class="row">
<label class="item-title">忽略大小写</label>
<div class="wrapper">
<el-switch v-model="settingStore.ignoreCase"
inline-prompt
active-text="开"
inactive-text="关"
/>
</div>
</div>
<div class="desc">
开启后输入时不区分大小写如输入“hello”和“Hello”都会被认为是正确的
</div>
<div class="line"></div>
<div class="row">
<label class="item-title">允许默写模式下显示提示</label>
<div class="wrapper">
<el-switch v-model="settingStore.allowWordTip"
inline-prompt
active-text="开"
inactive-text="关"
/>
</div>
</div>
<div class="desc">
开启后,可以通过鼠标 hover 单词或者按 {{ settingStore.shortcutKeyMap[ShortcutKey.ShowWord] }} 显示正确答案
</div>
<div class="line"></div>
<div class="row">
<label class="item-title">字体设置(仅可调整单词练习)</label>
</div>
<div class="row">
<label class="sub-title">外语字体</label>
<div class="wrapper">
<el-slider
:min="10"
:max="100"
v-model="settingStore.fontSize.wordForeignFontSize"/>
<span>{{ settingStore.fontSize.wordForeignFontSize }}</span>
</div>
</div>
<div class="row">
<label class="sub-title">中文字体</label>
<div class="wrapper">
<el-slider
:min="10"
:max="100"
v-model="settingStore.fontSize.wordTranslateFontSize"/>
<span>{{ settingStore.fontSize.wordTranslateFontSize }}</span>
</div>
</div>
<div class="line"></div>
<div class="row">
<label class="item-title">其他设置</label>
</div>
<div class="row">
<label class="sub-title">切换下一个单词时间</label>
<div class="wrapper">
<el-input-number v-model="settingStore.waitTimeForChangeWord"
:min="6"
:max="100"
type="number"
/>
<span>毫秒</span>
</div>
</div>
</div>
<div class="body" v-if="tabIndex === 2">
<div class="row">
<label class="main-title">功能</label>
<div class="wrapper">快捷键(点击可修改)</div>
</div>
<div class="scroll">
<div class="row" v-for="item of Object.entries(settingStore.shortcutKeyMap)">
<label class="item-title">{{ $t(item[0]) }}</label>
<div class="wrapper" @click="editShortcutKey = item[0]">
<div class="set-key" v-if="editShortcutKey === item[0]">
<input :value="item[1]?item[1]:'未设置快捷键'" readonly type="text" @blur="editShortcutKey = ''">
<span @click.stop="editShortcutKey = ''">直接按键盘进行设置</span>
</div>
<div v-else>
<div v-if="item[1]">{{ item[1] }}</div>
<span v-else>未设置快捷键</span>
</div>
</div>
</div>
</div>
<div class="row footer">
<label class="item-title"></label>
<div class="wrapper">
<BaseButton @click="resetShortcutKeyMap">恢复默认</BaseButton>
</div>
</div>
</div>
<div v-if="tabIndex === 3">
<div class="row">
<div class="main-title">数据导出</div>
</div>
<div class="row">
<label class="sub-title">
目前用户的所有数据(自定义设置、自定义词典、练习进度等)
<b>仅保存在本地</b>
。如果您需要在不同的设备、浏览器或者其他非官方部署上使用 {{ APP_NAME }} 您需要手动进行数据同步和保存。
</label>
</div>
<div class="row">
<BaseButton @click="exportData">数据导出</BaseButton>
</div>
<div class="row">
<div class="main-title">数据导入</div>
</div>
<div class="row">
<label class="sub-title">
请注意,导入数据将
<b style="color: red"> 完全覆盖 </b>
当前数据。请谨慎操作。
</label>
</div>
<div class="row">
<div class="import hvr-grow">
<BaseButton>数据导入</BaseButton>
<input type="file"
accept="application/json"
@change="importData">
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
.setting {
width: 40vw;
height: 70vh;
display: flex;
color: var(--color-font-1);
.left {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
.tabs {
padding: 10rem 20rem;
display: flex;
flex-direction: column;
//align-items: center;
//justify-content: center;
gap: 10rem;
.tab {
cursor: pointer;
padding: 10rem 15rem;
border-radius: 8rem;
display: flex;
align-items: center;
gap: 10rem;
&.active {
background: var(--color-item-bg);
}
}
}
.git-log {
font-size: 10rem;
color: gray;
margin-bottom: 5rem;
}
}
.content {
background: var(--color-header-bg);
flex: 1;
height: 100%;
overflow: auto;
padding: 10rem var(--space);
.row {
min-height: 40rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: calc(var(--space) * 5);
.wrapper {
height: 30rem;
flex: 1;
display: flex;
justify-content: flex-end;
gap: var(--space);
span {
text-align: right;
//width: 30rem;
font-size: 12rem;
color: gray;
}
.set-key {
align-items: center;
input {
width: 150rem;
box-sizing: border-box;
margin-right: 10rem;
height: 28rem;
outline: none;
font-size: 16rem;
border: 1px solid gray;
border-radius: 3rem;
padding: 0 5rem;
background: var(--color-second-bg);
color: var(--color-font-1);
}
}
}
.main-title {
font-size: 22rem;
}
.item-title {
font-size: 16rem;
}
.sub-title {
font-size: 14rem;
}
}
.body {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.scroll {
flex: 1;
padding-right: 10rem;
overflow: auto;
}
.footer {
margin-bottom: 20rem;
}
.desc {
margin-bottom: 10rem;
font-size: 12rem;
}
.line {
border-bottom: 1px solid #c4c3c3;
}
}
}
.el-option-row {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
.icon-wrapper {
transform: translateX(10rem);
}
}
.import {
display: inline-flex;
position: relative;
input {
position: absolute;
height: 100%;
width: 100%;
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import {$computed} from "vue/macros";
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

@@ -0,0 +1,84 @@
<script lang="jsx">
import {nextTick, Teleport, Transition} from "vue";
export default {
name: "Tooltip",
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) return
e.stopPropagation()
let rect = e.target.getBoundingClientRect()
this.show = true
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 Vnode = this.$slots.default()[0]
return <>
{
this.show && this.title && (
<Teleport to="body">
<Transition name="fade">
<div ref="tip" className="tip">
{this.title}
</div>
</Transition>
</Teleport>
)
}
<Vnode
onClick={() => this.show = false}
onmouseenter={(e) => this.showPop(e)}
onmouseleave={() => this.show = false}
/>
</>
}
}
</script>
<style lang="scss" scoped>
@import "@/assets/css/style";
.tip {
position: fixed;
font-size: 14rem;
z-index: 9999;
border-radius: 4rem;
padding: 10rem;
color: var(--color-font-1);
background: var(--color-tooltip-bg);
//box-shadow: 1px 1px 6px #bbbbbb;
box-shadow: 0 0 6px 1px var(--color-tooltip-shadow);
}
</style>

View File

@@ -0,0 +1,504 @@
<script setup lang="ts">
import {Article, DefaultArticle, Sentence, TranslateEngine, TranslateType} from "@/types.ts";
import BaseButton from "@/components/BaseButton.vue";
import EditAbleText from "@/pages/pc/components/EditAbleText.vue";
import {Icon} from "@iconify/vue";
import {
getNetworkTranslate,
getSentenceAllText,
getSentenceAllTranslateText,
renewSectionTexts,
renewSectionTranslates
} from "@/hooks/translate.ts";
import * as copy from "copy-to-clipboard";
import {$ref} from "vue/macros";
import {MessageBox} from "@/utils/MessageBox.tsx";
import {getSplitTranslateText} from "@/hooks/article.ts";
import {cloneDeep} from "lodash-es";
import {watch} from "vue";
import Empty from "@/components/Empty.vue";
interface IProps {
article?: Article,
type?: 'single' | 'batch'
}
const props = withDefaults(defineProps<IProps>(), {
article: () => cloneDeep(DefaultArticle),
type: 'single'
})
const emit = defineEmits<{
save: [val: Article],
saveAndNext: [val: Article]
}>()
let networkTranslateEngine = $ref('baidu')
let progress = $ref(0)
let failCount = $ref(0)
let textareaRef = $ref<HTMLTextAreaElement>()
const TranslateEngineOptions = [
{value: 'baidu', label: '百度'},
{value: 'youdao', label: '有道'},
]
let editArticle = $ref<Article>(cloneDeep(DefaultArticle))
watch(() => props.article, val => {
editArticle = cloneDeep(val)
progress = 0
failCount = 0
if (editArticle.text.trim()) {
if (editArticle.useTranslateType === TranslateType.custom) {
if (editArticle.textCustomTranslate.trim()) {
if (!editArticle.textCustomTranslateIsFormat) {
let r = getSplitTranslateText(editArticle.textCustomTranslate)
if (r) {
editArticle.textCustomTranslate = r
ElMessage({
message: '检测到本地翻译未格式化,已自动格式化',
type: 'success',
duration: 3000
})
}
}
}
}
}
renewSections()
// console.log('ar', article)
}, {immediate: true})
function renewSections() {
if (editArticle.text.trim()) {
renewSectionTexts(editArticle)
if (editArticle.useTranslateType === TranslateType.custom) {
failCount = renewSectionTranslates(editArticle, editArticle.textCustomTranslate)
}
if (editArticle.useTranslateType === TranslateType.network) {
failCount = renewSectionTranslates(editArticle, editArticle.textNetworkTranslate)
}
} else {
editArticle.sections = []
}
}
function appendTranslate(str: string) {
let selectionStart = textareaRef.selectionStart;
let selectionEnd = textareaRef.selectionEnd;
if (editArticle.useTranslateType === TranslateType.custom) {
editArticle.textCustomTranslate = editArticle.textCustomTranslate.slice(0, selectionStart) + str + editArticle.textCustomTranslate.slice(selectionEnd)
}
if (editArticle.useTranslateType === TranslateType.network) {
editArticle.textNetworkTranslate = editArticle.textNetworkTranslate.slice(0, selectionStart) + str + editArticle.textNetworkTranslate.slice(selectionEnd)
}
}
function onPaste(event: ClipboardEvent) {
event.preventDefault()
// @ts-ignore
let paste = (event.clipboardData || window.clipboardData).getData("text");
return MessageBox.confirm(
'是否需要自动分句',
'提示',
() => {
let r = getSplitTranslateText(paste)
if (r) {
appendTranslate(r)
renewSections()
}
},
() => {
appendTranslate(paste)
renewSections()
}, null,
{
confirmButtonText: '需要',
cancelButtonText: '关闭',
}
)
}
function onBlur() {
document.removeEventListener('paste', onPaste);
}
function onFocus() {
document.addEventListener('paste', onPaste);
}
async function startNetworkTranslate() {
if (!editArticle.title.trim()) {
return ElMessage.error('请填写标题!')
}
if (!editArticle.text.trim()) {
return ElMessage.error('请填写正文!')
}
renewSectionTexts(editArticle)
editArticle.textNetworkTranslate = ''
//注意!!!
//这里需要用异步因为watch了article.networkTranslate改变networkTranslate了之后会重新设置article.sections
//导致getNetworkTranslate里面拿到的article.sections是废弃的值
setTimeout(async () => {
await getNetworkTranslate(editArticle, TranslateEngine.Baidu, true, (v: number) => {
progress = v
})
failCount = 0
copy(JSON.stringify(editArticle.sections))
})
}
function saveSentenceTranslate(sentence: Sentence, val: string) {
sentence.translate = val
if (editArticle.useTranslateType === TranslateType.custom) {
editArticle.textCustomTranslate = getSentenceAllTranslateText(editArticle)
}
if (editArticle.useTranslateType === TranslateType.network) {
editArticle.textNetworkTranslate = getSentenceAllTranslateText(editArticle)
}
renewSections()
}
function saveSentenceText(sentence: Sentence, val: string) {
sentence.text = val
editArticle.text = getSentenceAllText(editArticle)
renewSections()
}
function save(option: 'save' | 'saveAndNext') {
// return console.log(cloneDeep(editArticle))
return new Promise((resolve: Function) => {
// console.log('article', article)
// copy(JSON.stringify(article))
editArticle.title = editArticle.title.trim()
editArticle.titleTranslate = editArticle.titleTranslate.trim()
editArticle.text = editArticle.text.trim()
editArticle.textCustomTranslate = editArticle.textCustomTranslate.trim()
editArticle.textNetworkTranslate = editArticle.textNetworkTranslate.trim()
if (!editArticle.title) {
ElMessage.error('请填写标题!')
return resolve(false)
}
if (!editArticle.text) {
ElMessage.error('请填写正文!')
return resolve(false)
}
const saveTemp = () => {
editArticle.textCustomTranslateIsFormat = true
emit(option as any, editArticle)
return resolve(true)
}
if (editArticle.useTranslateType === TranslateType.network) {
if (!editArticle.textNetworkTranslate) {
return MessageBox.confirm(
'您选择了“网络翻译”,但译文内容却为空白,是否修改为“不需要翻译”并保存?',
'提示',
() => {
editArticle.useTranslateType = TranslateType.none
saveTemp()
},
() => void 0,
)
}
}
if (editArticle.useTranslateType === TranslateType.custom) {
if (!editArticle.textCustomTranslate) {
return MessageBox.confirm(
'您选择了“本地翻译”,但译文内容却为空白,是否修改为“不需要翻译”并保存?',
'提示',
() => {
editArticle.useTranslateType = TranslateType.none
saveTemp()
},
() => void 0,
)
}
}
saveTemp()
})
}
//不知道为什么直接用editArticle取到是空的默认值
defineExpose({save, getEditArticle: () => cloneDeep(editArticle)})
</script>
<template>
<div class="content">
<div class="row">
<div class="title">①原文</div>
<div class="item">
<div class="label">标题</div>
<textarea
v-model="editArticle.title"
type="textarea"
class="base-textarea"
placeholder="请填写原文标题"
>
</textarea>
</div>
<div class="item basic">
<div class="label">正文</div>
<textarea
v-model="editArticle.text"
@input="renewSections"
:readonly="![100,0].includes(progress)"
type="textarea"
class="base-textarea"
placeholder="请填写原文正文"
>
</textarea>
</div>
</div>
<div class="row">
<div class="title">②译文</div>
<div class="item">
<div class="label">
<span>标题</span>
<el-radio-group
v-model="editArticle.useTranslateType"
@change="renewSections"
>
<el-radio-button :label="TranslateType.custom">本地翻译</el-radio-button>
<el-radio-button :label="TranslateType.network">网络翻译</el-radio-button>
<el-radio-button :label="TranslateType.none">不需要翻译</el-radio-button>
</el-radio-group>
</div>
<textarea
v-model="editArticle.titleTranslate"
type="textarea"
class="base-textarea"
placeholder="请填写翻译标题"
>
</textarea>
</div>
<div class="item basic">
<div class="label">
<span>正文</span>
<div class="translate-item" v-if="editArticle.useTranslateType === TranslateType.network">
<el-progress :percentage="progress"
:duration="30"
:striped="progress !== 100"
:striped-flow="progress !== 100"
:stroke-width="8"
:show-text="true"/>
<el-select v-model="networkTranslateEngine"
style="width: 80rem;"
>
<el-option
v-for="item in TranslateEngineOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<BaseButton
size="small"
@click="startNetworkTranslate"
:loading="progress!==0 && progress !== 100"
>开始翻译
</BaseButton>
</div>
</div>
<textarea
v-if="editArticle.useTranslateType === TranslateType.custom"
v-model="editArticle.textCustomTranslate"
@input="renewSections"
@blur="onBlur"
@focus="onFocus"
type="textarea"
class="base-textarea"
placeholder="请填写翻译正文"
ref="textareaRef"
>
</textarea>
<textarea
v-if="editArticle.useTranslateType === TranslateType.network"
v-model="editArticle.textNetworkTranslate"
:readonly="![100,0].includes(progress)"
@input="renewSections"
@blur="onBlur"
@focus="onFocus"
type="textarea"
class="base-textarea"
placeholder="等待网络翻译中..."
ref="textareaRef"
>
</textarea>
<Empty
text="不需要翻译~"
v-if="editArticle.useTranslateType === TranslateType.none"
/>
</div>
</div>
<div class="row">
<div class="title">③译文对照</div>
<template v-if="editArticle.sections.length">
<div class="article-translate">
<div class="section" v-for="(item,indexI) in editArticle.sections">
<div class="sentence" v-for="(sentence,indexJ) in item">
<EditAbleText
:value="sentence.text"
@save="(e:string) => saveSentenceText(sentence,e)"
/>
<EditAbleText
:value="sentence.translate"
@save="(e:string) => saveSentenceTranslate(sentence,e)"
/>
</div>
</div>
</div>
<div class="options" v-if="editArticle.text.trim()">
<div class="status">
<span>状态</span>
<div class="warning" v-if="failCount && editArticle.useTranslateType !== TranslateType.none">
<Icon icon="typcn:warning-outline"/>
共有{{ failCount }}句没有翻译
</div>
<div class="success" v-else>
<Icon icon="mdi:success-circle-outline"/>
翻译完成
</div>
</div>
<div class="left">
<BaseButton @click="save('save')">保存</BaseButton>
<BaseButton v-if="type === 'batch'" @click="save('saveAndNext')">保存并添加下一篇</BaseButton>
</div>
</div>
</template>
<Empty v-else text="没有译文对照~"/>
</div>
</div>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
.content {
color: var(--color-font-1);
flex: 1;
display: flex;
gap: var(--space);
padding: var(--space);
padding-top: 10rem;
}
.row {
flex: 10;
width: 33%;
//height: 100%;
display: flex;
flex-direction: column;
//opacity: 0;
.basic {
flex: 1;
display: flex;
flex-direction: column;
}
&:nth-child(1) {
flex: 7;
}
.title {
font-size: 22rem;
text-align: center;
}
.item {
width: 100%;
//margin-bottom: 10rem;
.label {
height: 45rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16rem;
}
}
.translate-item {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: calc(var(--space) / 2);
}
.el-progress {
flex: 1;
}
.article-translate {
margin-top: 10rem;
margin-bottom: 20rem;
flex: 1;
overflow: auto;
border-radius: 8rem;
.section {
background: var(--color-textarea-bg);
margin-bottom: 20rem;
padding: var(--space);
border-radius: 8rem;
&:last-child {
margin-bottom: 0;
}
.sentence {
margin-bottom: 20rem;
&:last-child {
margin-bottom: 0;
}
.text {
font-size: 18rem;
}
}
}
}
.options {
display: flex;
align-items: center;
justify-content: space-between;
.status {
display: flex;
align-items: center;
}
.warning {
display: flex;
align-items: center;
font-size: 20rem;
color: red;
}
.success {
display: flex;
align-items: center;
font-size: 20rem;
color: #67C23A;
}
.left {
gap: var(--space);
display: flex;
}
}
}
</style>

View File

@@ -0,0 +1,295 @@
<script setup lang="ts">
import {onMounted, onUnmounted} from "vue";
import {Article, DefaultArticle} from "@/types.ts";
import BaseButton from "@/components/BaseButton.vue";
import {cloneDeep} from "lodash-es";
import {useBaseStore} from "@/stores/base.ts";
import {$ref} from "vue/macros";
import List from "@/pages/pc/components/list/List.vue";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import EditArticle from "@/pages/pc/components/article/EditArticle.vue";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {useDisableEventListener, useWindowClick} from "@/hooks/event.ts";
import {MessageBox} from "@/utils/MessageBox.tsx";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {nanoid} from "nanoid";
import {syncMyDictList} from "@/hooks/dict.ts";
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
const emit = defineEmits<{
importData: [val: Event]
exportData: [val: string]
}>()
const base = useBaseStore()
const runtimeStore = useRuntimeStore()
let article = $ref<Article>(cloneDeep(DefaultArticle))
let show = $ref(false)
let editArticleRef: any = $ref()
let listEl: any = $ref()
onMounted(() => {
emitter.on(EventKey.openArticleListModal, (val: Article) => {
console.log('val', val)
show = true
if (val) {
article = cloneDeep(val)
}
})
})
onUnmounted(() => {
emitter.off(EventKey.openArticleListModal)
})
useDisableEventListener(() => show)
async function selectArticle(item: Article) {
let r = await checkDataChange()
if (r) {
article = cloneDeep(item)
}
}
function checkDataChange() {
return new Promise(resolve => {
let editArticle: Article = editArticleRef.getEditArticle()
if (editArticle.id !== '-1') {
editArticle.title = editArticle.title.trim()
editArticle.titleTranslate = editArticle.titleTranslate.trim()
editArticle.text = editArticle.text.trim()
editArticle.textCustomTranslate = editArticle.textCustomTranslate.trim()
editArticle.textNetworkTranslate = editArticle.textNetworkTranslate.trim()
if (
editArticle.title !== article.title ||
editArticle.titleTranslate !== article.titleTranslate ||
editArticle.text !== article.text ||
editArticle.textCustomTranslate !== article.textCustomTranslate ||
editArticle.textNetworkTranslate !== article.textNetworkTranslate ||
editArticle.useTranslateType !== article.useTranslateType
) {
return MessageBox.confirm(
'检测到数据有变动,是否保存?',
'提示',
async () => {
let r = await editArticleRef.save('save')
if (r) resolve(true)
},
() => resolve(true),
)
}
} else {
if (editArticle.title.trim() && editArticle.text.trim()) {
return MessageBox.confirm(
'检测到数据有变动,是否保存?',
'提示',
async () => {
let r = await editArticleRef.save('save')
if (r) resolve(true)
},
() => resolve(true),
)
}
}
resolve(true)
})
}
async function add() {
let r = await checkDataChange()
if (r) {
article = cloneDeep(DefaultArticle)
}
}
function saveArticle(val: Article): boolean {
console.log('saveArticle', val)
if (val.id) {
let rIndex = runtimeStore.editDict.articles.findIndex(v => v.id === val.id)
if (rIndex > -1) {
runtimeStore.editDict.articles[rIndex] = cloneDeep(val)
}
} else {
let has = runtimeStore.editDict.articles.find((item: Article) => item.title === val.title)
if (has) {
ElMessage.error('已存在同名文章!')
return false
}
val.id = nanoid(6)
runtimeStore.editDict.articles.push(val)
setTimeout(() => {
listEl.scrollBottom()
})
}
article = cloneDeep(val)
//TODO 保存完成后滚动到对应位置
ElMessage.success('保存成功!')
syncMyDictList(runtimeStore.editDict)
return true
}
function saveAndNext(val: Article) {
if (saveArticle(val)) {
add()
}
}
let showExport = $ref(false)
useWindowClick(() => showExport = false)
</script>
<template>
<Dialog
v-model="show"
:full-screen="true"
:header="false"
>
<div class="add-article">
<div class="slide">
<header>
<div class="dict-name">{{ runtimeStore.editDict.name }}</div>
</header>
<List
ref="listEl"
v-model:list="runtimeStore.editDict.articles"
:select-item="article"
@del-select-item="article = cloneDeep(DefaultArticle)"
@select-item="selectArticle"
>
<template v-slot="{item,index}">
<div class="name"> {{ `${index + 1}. ${item.title}` }}</div>
<div class="translate-name"> {{ ` ${item.titleTranslate}` }}</div>
</template>
</List>
<div class="add" v-if="!article.title">
正在添加新文章...
</div>
<div class="footer">
<div class="import">
<BaseButton size="small">导入</BaseButton>
<input type="file"
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
@change="e => emit('importData',e)">
</div>
<div class="export"
style="position: relative"
@click.stop="null">
<BaseButton size="small" @click="showExport = true">导出</BaseButton>
<MiniDialog
v-model="showExport"
style="width: 80rem;bottom: calc(100% + 10rem);top:unset;"
>
<div class="mini-row-title">
导出选项
</div>
<div class="mini-row">
<BaseButton size="small" @click="emit('exportData',{type:'all',data:[]})">全部文章</BaseButton>
</div>
<div class="mini-row">
<BaseButton size="small" @click="emit('exportData',{type:'chapter',data:article})">当前章节</BaseButton>
</div>
</MiniDialog>
</div>
<BaseButton size="small" @click="add">新增</BaseButton>
</div>
</div>
<EditArticle
ref="editArticleRef"
type="batch"
@save="saveArticle"
@saveAndNext="saveAndNext"
:article="article"/>
</div>
</Dialog>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
.add-article {
//position: fixed;
position: relative;
left: 0;
top: 0;
z-index: 9;
width: 100%;
height: 100%;
box-sizing: border-box;
color: var(--color-font-1);
background: var(--color-second-bg);
display: flex;
.close {
position: absolute;
right: 20rem;
top: 20rem;
}
.slide {
width: 14vw;
height: 100%;
padding: 0 10rem;
display: flex;
flex-direction: column;
$height: 60rem;
header {
height: $height;
display: flex;
justify-content: space-between;
align-items: center;
//opacity: 0;
.dict-name {
font-size: 30rem;
color: var(--color-font-1);
}
}
.name {
font-size: 18rem;
}
.translate-name {
font-size: 16rem;
}
.add {
width: 260rem;
box-sizing: border-box;
border-radius: 8rem;
margin-bottom: 10rem;
padding: 10rem;
display: flex;
justify-content: space-between;
transition: all .3s;
color: var(--color-font-1);
background: var(--color-item-active);
}
.footer {
height: $height;
display: flex;
gap: 10rem;
align-items: center;
justify-content: flex-end;
.import {
display: inline-flex;
position: relative;
input {
position: absolute;
height: 100%;
width: 100%;
opacity: 0;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import {Article, DefaultArticle} from "@/types.ts";
import {cloneDeep} from "lodash-es";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import EditArticle from "@/pages/pc/components/article/EditArticle.vue";
import {useDisableEventListener} from "@/hooks/event.ts";
interface IProps {
article?: Article
modelValue: boolean
}
const props = withDefaults(defineProps<IProps>(), {
article: () => cloneDeep(DefaultArticle),
modelValue: false
})
const emit = defineEmits<{
save: [val: Article]
'update:modelValue': [val: boolean]
}>()
useDisableEventListener(() => props.modelValue)
</script>
<template>
<Dialog
:header="false"
:model-value="props.modelValue"
@close="emit('update:modelValue',false)"
:full-screen="true"
>
<div class="wrapper">
<EditArticle
:article="article"
@save="val => emit('save',val)"
/>
</div>
</Dialog>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
.wrapper {
width: 100%;
height: 100%;
display: flex;
background: var(--color-main-bg);
}
</style>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import {$ref} from "vue/macros";
import {onMounted, onUnmounted, watch} from "vue";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import WordList from "@/pages/pc/components/list/WordList.vue";
import {Article, DefaultArticle} from "@/types.ts";
import {cloneDeep} from "lodash-es";
import Empty from "@/components/Empty.vue";
import {getTranslateText} from "@/hooks/article.ts";
let show = $ref(false)
let loading = $ref(false)
const runtimeStore = useRuntimeStore()
let article: Article = $ref(cloneDeep(DefaultArticle))
onMounted(() => {
emitter.on(EventKey.openArticleContentModal, (val: any) => {
show = true
article = cloneDeep(val)
})
})
onUnmounted(() => {
emitter.off(EventKey.openArticleContentModal)
})
</script>
<template>
<Dialog
:header="false"
v-model="show">
<div class="content">
<div class="article-content">
<div class="title">
<div>{{ article.title }}</div>
</div>
<div class="text" v-if="article.text">
<div class="sentence" v-for="t in article.text.split('\n')">{{ t }}</div>
</div>
<Empty v-else/>
</div>
<div class="article-content">
<div class="title">
<div>{{ article.titleTranslate }}</div>
</div>
<div class="text" v-if="getTranslateText(article).length">
<div class="sentence" v-for="t in getTranslateText(article)">{{ t }}</div>
</div>
<Empty v-else/>
</div>
</div>
</Dialog>
</template>
<style lang="scss" scoped>
@import "@/assets/css/style";
.content {
width: 70vw;
height: 75vh;
display: flex;
gap: var(--space);
padding: var(--space);
color: var(--color-font-1);
.article-content {
flex: 1;
overflow: hidden;
font-size: 20rem;
display: flex;
flex-direction: column;
.title {
text-align: center;
margin-bottom: var(--space);
font-size: 24rem;
}
.text {
text-indent: 1.5em;
line-height: 35rem;
overflow: auto;
padding-right: 10rem;
padding-bottom: 50rem;
.sentence {
margin-bottom: 30rem;
}
}
}
}
</style>

View File

@@ -0,0 +1,372 @@
<script setup lang="ts">
import {onMounted, onUnmounted, watch} from "vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
import {Icon} from '@iconify/vue';
import {useEventListener} from "@/hooks/event.ts";
import {$ref} from "vue/macros";
import BaseButton from "@/components/BaseButton.vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
export interface ModalProps {
modelValue?: boolean,
showClose?: boolean,
title?: string,
content?: string,
fullScreen?: boolean;
padding?: boolean
footer?: boolean
header?: boolean
confirmButtonText?: string
cancelButtonText?: string,
keyboard?: boolean,
confirm?: any
beforeClose?: any
}
const props = withDefaults(defineProps<ModalProps>(), {
modelValue: undefined,
showClose: 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(() => {
// console.log('props.modelValue', props.modelValue)
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="close"></div>
<div class="modal"
ref="modalRef"
:class="[
fullScreen?'full':'window'
]"
>
<Tooltip title="关闭">
<Icon @click="close"
v-if="showClose"
class="close hvr-grow pointer"
width="24" color="#929596"
icon="ion:close-outline"/>
</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">{{ content }}</div>
</div>
<div class="modal-footer" v-if="footer">
<div class="left">
</div>
<div class="right">
<BaseButton type="link" @click="cancel">{{ cancelButtonText }}</BaseButton>
<BaseButton
:loading="confirmButtonLoading"
@click="ok">{{ confirmButtonText }}
</BaseButton>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped lang="scss">
@import "@/assets/css/variable";
$modal-mask-bg: rgba(#000, .45);
$radius: 24rem;
$time: 0.3s;
$header-height: 60rem;
@keyframes bounce-in {
0% {
opacity: 0;
transform: scale(0);
}
50% {
transform: scale(1.15);
}
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-bg);
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform $time, opacity $time;
.close {
position: absolute;
right: 20rem;
top: 20rem;
z-index: 999;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rem 24rem 16rem;
border-radius: $radius $radius 0 0;
.title {
color: var(--color-font-1);
font-weight: bold;
font-size: 24rem;
line-height: 33rem;
}
}
.modal-body {
box-sizing: border-box;
color: rgba(255, 255, 255, 0.8);
font-weight: 400;
font-size: 18rem;
line-height: 27rem;
width: 100%;
flex: 1;
overflow: hidden;
display: flex;
&.padding {
padding: 4rem 24rem 24rem;
}
.content {
width: 350rem;
color: var(--color-font-1);
padding: 4rem 24rem 24rem;
}
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rem 24rem;
color: #fff;
font-size: 18rem;
background: rgba(0, 0, 0, .2);
border-radius: 0 0 24rem 24rem;
.left {
display: flex;
align-items: center;
height: 100%;
.text {
color: white;
font-size: 16rem;
cursor: pointer;
}
&.active {
.text {
color: white;
}
}
}
.right {
display: flex;
flex: 1;
align-items: center;
justify-content: flex-end;
height: 100%;
gap: var(--space);
}
}
}
}
</style>

View File

@@ -0,0 +1,575 @@
<script setup lang="ts">
import {useBaseStore} from "@/stores/base.ts"
import {onMounted} from "vue"
import {DefaultDict, Dict, DictResource, DictType, Sort, Word} from "@/types.ts"
import {chunk, cloneDeep, reverse, shuffle} from "lodash-es";
import {$computed, $ref} from "vue/macros";
import BaseButton from "@/components/BaseButton.vue";
import {Icon} from '@iconify/vue';
import "vue-activity-calendar/style.css";
import WordListDialog from "@/pages/pc/components/dialog/WordListDialog.vue";
import {isArticle} from "@/hooks/article.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import Slide from "@/pages/pc/components/Slide.vue";
import Empty from "@/components/Empty.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import EditBatchArticleModal from "@/pages/pc/components/article/EditBatchArticleModal.vue";
import {nanoid} from "nanoid";
import DictListPanel from "@/pages/pc/components/DictListPanel.vue";
import {useRouter} from "vue-router";
import ArticleList from "@/pages/pc/components/list/ArticleList.vue";
import BaseList from "@/pages/pc/components/list/BaseList.vue";
import {MessageBox} from "@/utils/MessageBox.tsx";
import {ArchiveReader, libarchiveWasm} from 'libarchive-wasm';
import {getDictFile} from "@/utils";
const store = useBaseStore()
const settingStore = useSettingStore()
const runtimeStore = useRuntimeStore()
let router = useRouter()
let step = $ref(1)
let loading = $ref(false)
let show = $ref(false)
let chapterList2 = $ref([])
let chapterWordNumber = $ref(0)
let toggleLoading = $ref(false)
const activeId = $computed(() => {
if (dictIsArticle) {
return runtimeStore.editDict.articles?.[runtimeStore.editDict.chapterIndex].id ?? ''
}
return ''
})
async function selectDict(val: { dict: DictResource | Dict, index: number }) {
let item = val.dict
// console.log('item', item)
step = 1
loading = true
let find: Dict = store.myDictList.find((v: Dict) => v.id === item.id)
if (find) {
runtimeStore.editDict = cloneDeep(find)
} else {
runtimeStore.editDict = cloneDeep({
...cloneDeep(DefaultDict),
...item,
})
runtimeStore.editDict.id = nanoid(6)
//设置默认章节单词数
runtimeStore.editDict.chapterWordNumber = settingStore.chapterWordNumber
}
if ([DictType.collect, DictType.simple, DictType.wrong].includes(runtimeStore.editDict.type)) {
} else {
//如果不是自定义词典并且有url地址才去下载
if (!runtimeStore.editDict.isCustom && runtimeStore.editDict.url) {
let url = `./dicts/${runtimeStore.editDict.language}/${runtimeStore.editDict.type}/${runtimeStore.editDict.translateLanguage}/${runtimeStore.editDict.url}`;
if (runtimeStore.editDict.type === DictType.word) {
if (!runtimeStore.editDict.originWords.length) {
let v = await getDictFile(url)
v.map(s => {
s.id = nanoid(6)
})
runtimeStore.editDict.originWords = cloneDeep(v)
changeSort(runtimeStore.editDict.sort, false)
} else {
runtimeStore.editDict.length = runtimeStore.editDict.words.length
}
}
if (runtimeStore.editDict.type === DictType.article) {
if (!runtimeStore.editDict.articles.length) {
let v = await getDictFile(url)
v.map(s => {
s.id = nanoid(6)
})
runtimeStore.editDict.articles = cloneDeep(v)
} else {
runtimeStore.editDict.length = runtimeStore.editDict.articles.length
}
}
}
}
chapterWordNumber = runtimeStore.editDict.chapterWordNumber
chapterList2 = runtimeStore.editDict.chapterWords.map((v, i) => ({id: i}))
loading = false
}
function close() {
show = false
}
//TODO 切大词典太卡了
function changeDict() {
close()
store.changeDict(runtimeStore.editDict)
setTimeout(() => {
runtimeStore.editDict = cloneDeep(DefaultDict)
})
ElMessage.success('切换成功')
}
const dictIsArticle = $computed(() => {
return isArticle(runtimeStore.editDict.type)
})
function showAllWordModal() {
emitter.emit(EventKey.openWordListModal, {
title: runtimeStore.editDict.name,
translateLanguage: runtimeStore.editDict.translateLanguage,
list: runtimeStore.editDict.words
})
}
function resetChapterList(v: number) {
const temp = () => {
runtimeStore.editDict.chapterWordNumber = v
runtimeStore.editDict.chapterWords = chunk(runtimeStore.editDict.words, runtimeStore.editDict.chapterWordNumber)
chapterList2 = runtimeStore.editDict.chapterWords.map((v, i) => ({id: i}))
}
if (runtimeStore.editDict.isCustom) {
MessageBox.confirm(
'检测到您已对这本词典自定义修改,修改“每章单词数”将会导致所有章节被重新分配,原有章节内容将被清除且不可恢复,是否继续?',
'提示',
() => temp(),
() => {
chapterWordNumber = runtimeStore.editDict.chapterWordNumber
}
)
} else {
temp()
}
}
function changeSort(v: Sort, notice: boolean = true) {
const temp = () => {
runtimeStore.editDict.sort = v
if (v === Sort.normal) {
runtimeStore.editDict.words = cloneDeep(runtimeStore.editDict.originWords)
} else if (v === Sort.random) {
runtimeStore.editDict.words = shuffle(cloneDeep(runtimeStore.editDict.originWords))
} else {
runtimeStore.editDict.words = reverse(cloneDeep(runtimeStore.editDict.originWords))
}
resetChapterList(runtimeStore.editDict.chapterWordNumber)
notice && ElMessage.success('已重新排序')
}
if (runtimeStore.editDict.isCustom) {
MessageBox.confirm(
'检测到您已对这本词典自定义修改,修改“单词排序”将会导致所有章节被重新分配,原有章节内容将被清除且不可恢复,是否继续?',
'提示',
() => temp()
)
} else {
temp()
}
}
function option(type: string) {
show = false
setTimeout(() => {
router.push({path: '/pc/dict', query: {type: type}})
}, 500)
}
onMounted(() => {
emitter.on(EventKey.openDictModal, (type: 'detail' | 'list' | 'my') => {
if (type === "detail") {
selectDict({dict: store.currentDict, index: 0})
}
if (type === "list") {
// currentLanguage = 'en'
step = 0
}
if (type === "my") {
// currentLanguage = 'my'
step = 0
}
show = true
})
})
function showWordListModal(val: { item: Word, index: number }) {
emitter.emit(EventKey.openWordListModal, {
title: `${val.index + 1}`,
translateLanguage: runtimeStore.editDict.translateLanguage,
list: runtimeStore.editDict.chapterWords[val.index]
})
}
function handleChangeArticleChapterIndex(val: any) {
let rIndex = runtimeStore.editDict.articles.findIndex(v => v.id === val.item.id)
if (rIndex > -1) {
runtimeStore.editDict.chapterIndex = rIndex
}
}
</script>
<template>
<Dialog
:header="false"
v-model="show"
:show-close="false">
<div id="DictDialog">
<Slide :slide-count="2" :step="step">
<DictListPanel
@add="option('addDict')"
@select-dict="selectDict"
/>
<div class="dict-detail-page">
<header>
<div class="left" @click.stop="step = 0">
<Icon icon="octicon:arrow-left-24" class="go" width="20"/>
<div class="title">
词典详情
</div>
</div>
<Icon @click="close"
class="hvr-grow pointer"
width="20" color="#929596"
icon="ion:close-outline"/>
</header>
<div class="detail">
<div class="page-content">
<div class="left-column">
<BaseIcon
v-if="![DictType.collect,DictType.wrong,DictType.simple].includes(runtimeStore.editDict.type)"
class="edit-icon"
title="编辑词典"
icon="tabler:edit"
@click='option("editDict")'
/>
<div class="name">{{ runtimeStore.editDict.name }}</div>
<div class="desc">{{ runtimeStore.editDict.description }}</div>
<div class="text flex align-center gap10">
<div v-if="dictIsArticle">总文章{{ runtimeStore.editDict.articles.length }}
</div>
<div v-else>总词汇
<span class="count" @click="showAllWordModal">{{
runtimeStore.editDict.originWords.length
}}</span>
</div>
<BaseIcon icon="mi:add"
@click='option("addWordOrArticle")'
:title="`添加${dictIsArticle?'文章':'单词'}`"
/>
</div>
<template v-if="false">
<div class="text">开始日期-</div>
<div class="text">花费时间-</div>
<div class="text">累积错误-</div>
<div class="text">进度
<el-progress :percentage="0"
:stroke-width="8"
:show-text="false"/>
</div>
</template>
</div>
<div class="center-column">
<div class="common-title">学习设置</div>
<div class="setting">
<template v-if="!dictIsArticle">
<div class="row">
<div class="label">每章单词数</div>
<el-slider
class="my-slider"
:min="10"
:step="10"
:max="runtimeStore.editDict.words.length < 10 ? 10 : runtimeStore.editDict.words.length"
show-input
:show-input-controls="false"
size="small"
v-model="chapterWordNumber"
@change="resetChapterList"
/>
</div>
<div class="notice">
<span class="text">最小:10</span>
<span class="text">最大:{{ runtimeStore.editDict.words.length }}</span>
</div>
<div class="row">
<div class="label">单词顺序</div>
<div class="option">
<el-radio-group :model-value="runtimeStore.editDict.sort"
@change="changeSort"
>
<el-radio :label="Sort.normal" size="large">默认</el-radio>
<el-radio :label="Sort.random" size="large">随机</el-radio>
<el-radio :label="Sort.reverse" size="large">反转</el-radio>
</el-radio-group>
</div>
</div>
</template>
<div class="row">
<div class="label">学习模式</div>
<div class="option">
<el-radio-group v-model="settingStore.dictation">
<el-radio :label="false" size="large">再认</el-radio>
<el-radio :label="true" size="large">拼写</el-radio>
</el-radio-group>
</div>
</div>
<div class="row">
<div class="label">{{ dictIsArticle ? '句子' : '单词' }}发音</div>
<div class="option">
<el-radio-group v-model="settingStore.wordSoundType">
<el-radio label="us" size="large">美音</el-radio>
<el-radio label="uk" size="large">英音</el-radio>
</el-radio-group>
</div>
</div>
<div class="row">
<div class="label">{{ dictIsArticle ? '句子' : '单词' }}自动发音</div>
<div class="option">
<el-switch v-model="settingStore.wordSound"
inline-prompt
active-text=""
inactive-text=""
/>
</div>
</div>
<div class="row">
<div class="label">是否显示翻译</div>
<div class="option">
<el-switch v-model="settingStore.translate"
inline-prompt
active-text=""
inactive-text=""
/>
</div>
</div>
<div class="row">
<div class="label">忽略大小写</div>
<div class="option">
<el-switch v-model="settingStore.ignoreCase"
inline-prompt
active-text=""
inactive-text=""
/>
</div>
</div>
</div>
</div>
<div class="right-column">
<div class="common-title">
<span>{{ dictIsArticle ? '文章' : '章节' }}列表</span>
<BaseIcon
icon="fluent:notepad-edit-20-regular"
@click='option("detail")'
style="position: absolute;right: 20rem;"
:title="`管理${dictIsArticle?'文章':'章节'}`"
/>
</div>
<div class="list-content">
<template v-if="dictIsArticle">
<ArticleList
v-if="runtimeStore.editDict.articles.length"
:isActive="false"
v-loading="loading"
:show-border="true"
@title="(val:any) => emitter.emit(EventKey.openArticleContentModal,val.item)"
@click="handleChangeArticleChapterIndex"
:active-id="activeId"
:list="runtimeStore.editDict.articles">
<template v-slot:prefix="{item,index}">
<input type="radio" :checked="activeId === item.id">
</template>
</ArticleList>
<Empty v-else/>
</template>
<template v-else>
<BaseList
ref="chapterListRef"
v-if="chapterList2.length"
:list="chapterList2"
:show-border="true"
@click="(val:any) => runtimeStore.editDict.chapterIndex = val.index"
:active-index="runtimeStore.editDict.chapterIndex"
>
<template v-slot:prefix="{ item, index }">
<input type="radio" :checked="runtimeStore.editDict.chapterIndex === item.id">
</template>
<template v-slot="{ item, index }">
<div class="item-title" @click.stop="showWordListModal({item,index})">
<span>{{ item.id + 1 }}</span>&nbsp;&nbsp;&nbsp;
<span>{{ runtimeStore.editDict.chapterWords[item.id]?.length }}</span>
</div>
</template>
</BaseList>
<Empty v-else/>
</template>
</div>
<div class="footer">
<!-- <BaseButton @click="step = 0">导出</BaseButton>-->
<BaseButton @click="close">关闭</BaseButton>
<BaseButton :loading="toggleLoading" @click="changeDict">切换</BaseButton>
</div>
</div>
</div>
</div>
</div>
</Slide>
</div>
</Dialog>
<WordListDialog/>
<EditBatchArticleModal/>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
$header-height: 60rem;
#DictDialog {
//position: fixed;
//left: 50%;
//top: 50%;
//transform: translate(-50%, -50%);
background: var(--color-second-bg);
z-index: 99999;
width: 1030rem;
height: 75vh;
}
.dict-detail-page {
width: 50%;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
header {
cursor: pointer;
width: 100%;
display: flex;
box-sizing: border-box;
height: $header-height;
align-items: center;
justify-content: space-between;
color: var(--color-font-3);
padding: 0 var(--space);
.left {
display: flex;
gap: 10rem;
align-items: center;
}
}
.detail {
padding-left: var(--space);
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.page-content {
flex: 1;
overflow: hidden;
display: flex;
position: relative;
.column {
background: var(--color-second-bg);
color: var(--color-font-1);
display: flex;
flex-direction: column;
}
.left-column {
flex: 5;
gap: 10rem;
position: relative;
font-size: 14rem;
padding-right: var(--space);
@extend .column;
.name {
font-size: 24rem;
width: 95%;
}
.desc {
font-size: 16rem;
margin-bottom: 20rem;
}
.count {
cursor: pointer;
border-bottom: 2px solid var(--color-item-active);
}
:deep(.edit-icon) {
position: absolute;
top: 0;
right: 0;
}
}
.center-column {
overflow: auto;
flex: 7;
@extend .column;
.setting {
.row {
display: flex;
align-items: center;
justify-content: space-between;
height: 34rem;
word-break: keep-all;
gap: 10rem;
.el-radio {
margin-right: 10rem;
}
}
.my-slider {
:deep(.el-slider__input) {
width: 55px;
}
}
.notice {
display: flex;
justify-content: space-between;
transform: translate3d(0, -5rem, 0);
padding-left: 100rem;
font-size: 13rem;
}
}
}
.right-column {
flex: 7;
@extend .column;
.list-content {
flex: 1;
overflow: hidden;
display: flex;
}
}
}
.footer {
box-sizing: content-box;
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: var(--space);
padding-right: var(--space);
margin: var(--space) 0;
}
}
}
</style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
interface IProps {
modelValue?: boolean,
width?: string
}
withDefaults(defineProps<IProps>(), {
modelValue: true,
width: '180rem'
})
</script>
<template>
<Transition name="fade">
<div v-if="modelValue" class="mini-modal" :style="{width}">
<slot></slot>
</div>
</Transition>
</template>
<style lang="scss">
@import "@/assets/css/style";
.mini-row-title {
min-height: 35rem;
text-align: center;
font-size: 16rem;
font-weight: bold;
color: var(--color-font-1);
}
.mini-row {
min-height: 35rem;
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: 180rem;
background: var(--color-second-bg);
border-radius: 8rem;
box-shadow: 0 0 8px 2px var(--color-item-border);
padding: 10rem var(--space);
top: 40rem;
left: 50%;
transform: translate3d(-50%, 0, 0);
//margin-top: 10rem;
}
</style>

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import Dialog from "@/pages/pc/components/dialog/Dialog.vue"
import {Icon} from '@iconify/vue';
import {ref} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
import {getAudioFileUrl, useChangeAllSound, usePlayAudio, useWatchAllSound} from "@/hooks/sound.ts";
import {getShortcutKey, useDisableEventListener, useEventListener} from "@/hooks/event.ts";
import {$computed, $ref} from "vue/macros";
import {cloneDeep} from "lodash-es";
import {DefaultShortcutKeyMap, ShortcutKey} from "@/types.ts";
import BaseButton from "@/components/BaseButton.vue";
import {SoundFileOptions} from "@/utils/const.ts";
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
import Setting from "@/pages/pc/components/Setting.vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
const runtimeStore = useRuntimeStore()
let disabledDialogEscKey = $ref(true)
</script>
<template>
<Dialog
v-model="runtimeStore.showSettingModal"
:keyboard="disabledDialogEscKey"
title="设置">
<Setting @toggle-disabled-dialog-esc-key="e => disabledDialogEscKey = !e"/>
</Dialog>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
.setting-modal {
width: 40vw;
height: 70vh;
display: flex;
color: var(--color-font-1);
.left {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
.tabs {
padding: 10rem 20rem;
display: flex;
flex-direction: column;
//align-items: center;
//justify-content: center;
gap: 10rem;
.tab {
cursor: pointer;
padding: 10rem 15rem;
border-radius: 8rem;
display: flex;
align-items: center;
gap: 10rem;
&.active {
background: var(--color-item-bg);
}
}
}
.git-log {
font-size: 10rem;
color: gray;
margin-bottom: 5rem;
}
}
.content {
background: var(--color-header-bg);
flex: 1;
height: 100%;
overflow: auto;
padding: 0 var(--space);
.row {
height: 40rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: calc(var(--space) * 5);
.wrapper {
height: 30rem;
flex: 1;
display: flex;
justify-content: flex-end;
gap: var(--space);
span {
text-align: right;
//width: 30rem;
font-size: 12rem;
color: gray;
}
.set-key {
align-items: center;
input {
width: 150rem;
box-sizing: border-box;
margin-right: 10rem;
height: 28rem;
outline: none;
font-size: 16rem;
border: 1px solid gray;
border-radius: 3rem;
padding: 0 5rem;
background: var(--color-second-bg);
color: var(--color-font-1);
}
}
}
.main-title {
font-size: 18rem;
font-weight: bold;
}
.item-title {
font-size: 16rem;
}
.sub-title {
font-size: 14rem;
}
}
.body {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.scroll {
flex: 1;
padding-right: 10rem;
overflow: auto;
}
.footer {
margin-bottom: 20rem;
}
.desc {
margin-bottom: 10rem;
font-size: 12rem;
}
.line {
border-bottom: 1px solid #c4c3c3;
}
}
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import {$ref} from "vue/macros";
import {onMounted, onUnmounted, watch} from "vue";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import WordList from "@/pages/pc/components/list/WordList.vue";
import Empty from "@/components/Empty.vue";
let show = $ref(false)
let list = $ref([])
let title = $ref('')
onMounted(() => {
emitter.on(EventKey.openWordListModal, (val: any) => {
show = true
list = val.list
title = val.title + `(${list.length}词)`
})
})
watch(() => show, v => {
if (!v) {
list = []
}
})
onUnmounted(() => {
emitter.off(EventKey.openWordListModal)
})
</script>
<template>
<Dialog
:title="title"
v-model="show">
<div class="all-word">
<WordList
v-if="list.length"
class="word-list"
:list="list">
</WordList>
<Empty v-else/>
</div>
</Dialog>
</template>
<style lang="scss" scoped>
@import "@/assets/css/style";
.all-word {
padding-bottom: var(--space);
padding-top: 0;
width: 400rem;
height: 75vh;
display: flex;
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import Input from "@/pages/pc/components/Input.vue";
import {$computed, $ref} from "vue/macros";
import {Article} from "@/types.ts";
import BaseList from "@/pages/pc/components/list/BaseList.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) {
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
}
})
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 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" @click.stop="emit('title',{item,index})">
<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: 15rem;
flex: 1;
overflow: hidden;
.search {
box-sizing: border-box;
width: 100%;
padding: 0 var(--space);
}
.translate {
font-size: 16rem;
}
}
</style>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import {useSettingStore} from "@/stores/setting.ts";
import {nextTick, watch} from 'vue'
import {$computed} from "vue/macros";
const props = withDefaults(defineProps<{
list?: any[],
activeIndex?: number,
activeId?: string,
isActive?: boolean
showBorder?: boolean
static?: boolean
}>(), {
list: [],
activeIndex: -1,
activeId: '',
isActive: false,
showBorder: 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 (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),
border:showBorder
}"
@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 list"
:key="item.id"
>
<div class="common-list-item"
:class="{
active:itemIsActive(item,index),
border:showBorder
}"
@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>
@import "@/assets/css/style";
.scroller {
flex: 1;
padding: 0 var(--space);
}
</style>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import {$computed, $ref} from "vue/macros";
import {watch} from "vue";
import {DictResource} from "@/types.ts";
import DictItem from "@/pages/pc/components/list/DictItem.vue";
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 class="dict-group">
<div class="category">{{ 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>
<DictList
@selectDict="e => emit('selectDict',e)"
:list="list"
:select-id="selectId"/>
</div>
</template>
<style scoped lang="scss">
.dict-group {
color: var(--color-font-1);
margin-bottom: 40rem;
//border-bottom: 1px dashed gray;
.category {
font-size: 24rem;
padding-bottom: 10rem;
border-bottom: 1px dashed gray;
}
}
.tags {
display: flex;
flex-wrap: wrap;
margin: 10rem 0;
.tag {
color: var(--color-font-1);
cursor: pointer;
padding: 5rem 10rem;
border-radius: 20rem;
&.active {
color: var(--color-font-active-1);
background: gray;
}
}
}
</style>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import {Dict, DictType} from "@/types.ts";
import {Icon} from "@iconify/vue";
import {$computed} from "vue/macros";
const props = defineProps<{
dict?: Dict,
active?: boolean
}>()
const emit = defineEmits<{
selectDict: [val: { dict: Dict, index: number }]
add: []
}>()
let length = $computed(() => {
let isWord = [DictType.word,DictType.collect,DictType.simple,DictType.wrong].includes(props.dict.type)
let len: any = ''
if (props.dict.length) {
len = props.dict.length
len += (isWord ? '词' : '篇')
} else {
if (isWord) {
len = props.dict.originWords.length + '词'
} else {
len = props.dict.articles.length + '篇'
}
}
return len
})
</script>
<template>
<div
class="dict-item anim"
:class="active && 'active'"
>
<template v-if="dict.id">
<div class="top">
<div class="name">{{ dict.name }}</div>
<div class="desc">{{ dict.description }}</div>
</div>
<div class="bottom">
<div class="num">{{ length }}</div>
</div>
<div class="pin" v-if="dict.type === DictType.article">文章</div>
</template>
<div v-else class="add" @click.stop="emit('add')">
<Icon icon="fluent:add-20-filled" width="38" color="#929596"/>
</div>
</div>
</template>
<style scoped lang="scss">
.dict-item {
cursor: pointer;
box-sizing: border-box;
padding: 10rem;
width: 125rem;
height: 165rem;
border-radius: 10rem;
position: relative;
background: var(--color-third-bg);
border: 1px solid var(--color-item-border);
color: var(--color-font-1);
font-size: 14rem;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
.name {
font-size: 16rem;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box; //作为弹性伸缩盒子模型显示。
-webkit-box-orient: vertical; //设置伸缩盒子的子元素排列方式--从上到下垂直排列
-webkit-line-clamp: 2; //显示的行
}
.desc {
color: var(--color-font-2);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box; //作为弹性伸缩盒子模型显示。
-webkit-box-orient: vertical; //设置伸缩盒子的子元素排列方式--从上到下垂直排列
-webkit-line-clamp: 2; //显示的行
}
.num {
text-align: right;
color: var(--color-font-2);
//font-weight: bold;
}
.go {
position: absolute;
right: 10rem;
bottom: 15rem;
}
&.active {
background: var(--color-item-active);
}
&:hover {
background: var(--color-item-active);
}
.add {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.pin {
position: absolute;
bottom: 0;
left: 0;
height: 55rem;
width: 55rem;
color: white;
//background-color: skyblue;
background-color: var(--color-main-active);
clip-path: polygon(0 10%, 0% 100%, 100% 100%);
font-size: 12rem;
display: flex;
justify-content: flex-start;
align-items: flex-end;
padding: 4rem;
box-sizing: border-box;
}
}
</style>

View File

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

View File

@@ -0,0 +1,207 @@
<script setup lang="ts" generic="T extends {id:string}">
import BaseIcon from "@/components/BaseIcon.vue";
import Input from "@/pages/pc/components/Input.vue";
import {$computed, $ref} from "vue/macros";
import {cloneDeep, throttle} from "lodash-es";
import {Article} from "@/types.ts";
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)
}
}
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 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="delItem(item)"
title="删除" icon="fluent:delete-24-regular"/>
<div
@mousedown="draggable = true"
@mouseup="draggable = false"
>
<BaseIcon
icon="carbon:move"/>
</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: 5rem;
.search {
margin: 10rem 0;
}
.list {
.item {
box-sizing: border-box;
background: var(--color-item-bg);
color: var(--color-font-1);
border-radius: 8rem;
margin-bottom: 10rem;
padding: 10rem;
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

@@ -0,0 +1,67 @@
<script setup lang="ts">
import {$ref} from "vue/macros";
import {Word} from "@/types.ts";
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
import BaseList from "@/pages/pc/components/list/BaseList.vue";
import {usePlayWordAudio} from "@/hooks/sound.ts";
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 && 'text-shadow'">{{ item.word }}</span>
<span class="phonetic">{{ item.phonetic0 }}</span>
<VolumeIcon class="volume" @click="playWordAudio(item.word)"></VolumeIcon>
</div>
<div class="item-sub-title" v-if="item.trans.length && showTranslate">
<div v-for="v in item.trans">{{ (v.pos ? v.pos + '.' : '') + v.cn }}</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

@@ -0,0 +1,39 @@
<script setup lang="ts">
import {Icon} from "@iconify/vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
import {$ref} from "vue/macros";
let show = $ref(false)
function toggle() {
show = !show
// emitter.emit(EventKey.openArticleListModal)
}
</script>
<template>
<div class="setting" @click.stop="null">
<Tooltip title="添加">
<IconWrapper>
<Icon icon="ic:outline-cloud-upload"
@click="toggle"
/>
</IconWrapper>
</Tooltip>
</div>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
.wrapper {
position: relative;
}
.setting {
position: relative;
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import {useBaseStore} from "@/stores/base.ts";
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import {useWindowClick} from "@/hooks/event.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {nextTick, watch} from "vue";
const store = useBaseStore()
let timer = 0
let show = $ref(false)
useWindowClick(() => show = false)
function toggle(val) {
clearTimeout(timer)
if (val) {
emitter.emit(EventKey.closeOther)
show = val
} else {
timer = setTimeout(() => {
show = val
}, 100)
}
}
function clickJumpSpecifiedChapter(index: number) {
emitter.emit(EventKey.jumpSpecifiedChapter, index)
}
const listRef: HTMLElement = $ref(null as any)
watch(() => show, n => {
if (n){
nextTick(()=>{
listRef?.children[store.currentDict.chapterIndex]?.scrollIntoView({block: 'center'})
})
}
})
</script>
<template>
<div class="ChapterName" @click.stop="null">
<div class="info hvr-grow"
@mouseenter="toggle(true)"
@mouseleave="toggle(false)"
>
{{ store.chapterName }}
</div>
<MiniDialog
v-model="show"
@mouseenter="toggle(true)"
@mouseleave="toggle(false)"
style="width: 230rem;"
>
<div class="chapter-list" ref="listRef">
<div class="chapter-list-item"
:class="store.currentDict.chapterIndex === index && 'active'"
v-for="(item,index) in store.currentDict.chapterWords"
@click="clickJumpSpecifiedChapter(index)">
<input type="radio" :checked="store.currentDict.chapterIndex === index">
<div class="title">{{ index + 1 }}&nbsp;&nbsp;&nbsp;{{ item.length }}</div>
</div>
</div>
</MiniDialog>
</div>
</template>
<style scoped lang="scss">
.ChapterName {
position: relative;
}
.chapter-list {
max-height: 250rem;
overflow: auto;
padding-right: 5rem;
.chapter-list-item {
margin-bottom: 7rem;
height: 36rem;
display: flex;
align-items: center;
gap: 10rem;
width: 100%;
box-sizing: border-box;
background: var(--color-item-bg);
color: var(--color-font-1);
font-size: 18rem;
border-radius: 8rem;
transition: all .3s;
padding: 10rem;
border: 1px solid var(--color-item-border);
&:hover {
background: var(--color-item-hover);
}
&.active {
background: var(--color-item-active);
}
}
}
.el-radio-group {
display: flex;
flex-direction: column;
align-items: flex-start;
}
</style>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import Dialog from "@/pages/pc/components/dialog/Dialog.vue"
import BaseButton from "@/components/BaseButton.vue";
import {GITHUB} from "@/config/ENV.ts";
const emit = defineEmits([
'close',
])
</script>
<template>
<Dialog
@close="emit('close')"
title="反馈">
<div class="feedback-modal">
<div>
给我发Email<a href="mailto:zyronon@163.com">zyronon@163.com</a>
</div>
<p>or</p>
<div class="github">
<span><a :href="GITHUB" target="_blank">Github</a>上给我提一个
<a :href="`${GITHUB}/issues`" target="_blank">Issue</a>
</span>
<div class="options">
<BaseButton>
<a :href="`${GITHUB}/issues/new?assignees=&labels=&projects=&template=%E5%8D%95%E8%AF%8D%E9%94%99%E8%AF%AF---word-error.md&title=%E5%8D%95%E8%AF%8D%E9%94%99%E8%AF%AF+%7C+Word+error`"
target="_blank">词典错误</a>
</BaseButton>
<BaseButton>
<a :href="`${GITHUB}/issues/new?assignees=&labels=&projects=&template=问题报告---bug-report-.md&title=问题报告+%7C+Bug+report+`"
target="_blank">反馈BUG</a>
</BaseButton>
<BaseButton>
<a :href="`${GITHUB}/issues/new?assignees=&labels=&projects=&template=功能请求---feature-request.md&title=功能请求+%7C+Feature+request`"
target="_blank">功能请求</a>
</BaseButton>
</div>
</div>
</div>
</Dialog>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
.feedback-modal {
width: 500rem;
//height: 80vh;
display: flex;
flex-direction: column;
background: var(--color-second-bg);
align-items: center;
padding: var(--space);
//justify-content: center;
color: var(--color-font-1);
p {
font-size: 30rem;
}
.github {
display: flex;
align-items: center;
gap: var(--space);
.options {
display: flex;
flex-direction: column;
gap: 10rem;
}
}
}
</style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import {Icon} from "@iconify/vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
import {useBaseStore} from "@/stores/base.ts";
import {useWindowClick} from "@/hooks/event.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {onMounted} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
const store = useBaseStore()
const settingStore = useSettingStore()
let show = $ref(false)
useWindowClick(() => show = false)
let timer = 0
function toggle(val) {
clearTimeout(timer)
if (val) {
emitter.emit(EventKey.closeOther)
show = val
} else {
timer = setTimeout(() => {
show = val
}, 100)
}
}
onMounted(() => {
})
</script>
<template>
<div class="setting" @click.stop="null">
<IconWrapper>
<Icon icon="tabler:repeat"
@mouseenter="toggle(true)"
@mouseleave="toggle(false)"
/>
</IconWrapper>
<MiniDialog
v-model="show"
@mouseenter="toggle(true)"
@mouseleave="toggle(false)"
style="width: 230rem;"
>
<div class="mini-row-title">
单词循环设置
</div>
<el-radio-group v-model="settingStore.repeatCount">
<el-radio :label="1" size="default">1</el-radio>
<el-radio :label="2" size="default">2</el-radio>
<el-radio :label="3" size="default">3</el-radio>
<el-radio :label="5" size="default">5</el-radio>
<el-radio :label="100" size="default">自定义</el-radio>
</el-radio-group>
<div class="mini-row" v-if="settingStore.repeatCount === 100">
<label class="item-title">自定义循环次数</label>
<el-input-number v-model="settingStore.repeatCustomCount"
:min="6"
:max="15"
type="number"
/>
</div>
</MiniDialog>
</div>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
.setting {
position: relative;
.title {
color: black;
}
.el-radio-group {
display: flex;
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import {Icon} from "@iconify/vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
import {useBaseStore} from "@/stores/base.ts";
import {useWindowClick} from "@/hooks/event.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import BaseButton from "@/components/BaseButton.vue";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import {useSettingStore} from "@/stores/setting.ts";
import {ShortcutKey} from "@/types.ts";
const store = useBaseStore()
const settingStore = useSettingStore()
let show = $ref(false)
let showCustomTranslateModal = $ref(false)
useWindowClick(() => show = false)
function toggle() {
// if (!show) emitter.emit(EventKey.closeOther)
// show = !show
settingStore.translate = !settingStore.translate
}
let translateType = $ref(0)
let networkTranslateEngine = $ref('baidu')
const TranslateEngine = [
{value: 'baidu', label: '百度'},
{value: 'youdao', label: '有道'},
]
function save() {
}
</script>
<template>
<div class="setting" @click.stop="null">
<Tooltip
:title="`开关释义显示(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.ToggleShowTranslate]})`"
>
<IconWrapper>
<Icon v-if="settingStore.translate" icon="mdi:translate"
@click="toggle"
/>
<Icon v-else icon="mdi:translate-off"
@click="toggle"
/>
</IconWrapper>
</Tooltip>
<MiniDialog v-model="show"
style="width: 230rem;"
>
<div class="mini-row">
<label class="item-title">显示翻译</label>
<div class="wrapper">
<el-switch v-model="settingStore.translate"
inline-prompt
active-text=""
inactive-text=""
/>
</div>
</div>
<div class="mini-row">
<label class="item-title">翻译类型</label>
<el-radio-group v-model="translateType" size="small">
<el-radio-button :label="1">本地翻译</el-radio-button>
<el-radio-button :label="0">网络翻译</el-radio-button>
</el-radio-group>
</div>
<div class="mini-row" v-if="translateType">
<label class="item-title">本地翻译</label>
<div class="wrapper">
<Tooltip title="开关释义显示">
<IconWrapper>
<Icon icon="mingcute:edit-line"
@click="toggle"
/>
</IconWrapper>
</Tooltip>
</div>
</div>
<div class="mini-row" v-else>
<label class="item-title">网络翻译</label>
<div class="wrapper">
<el-select v-model="networkTranslateEngine" class="m-2" placeholder="Select" size="small">
<el-option
v-for="item in TranslateEngine"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
</div>
<div class="footer">
<BaseButton size="small" @click="show = false">取消</BaseButton>
<BaseButton size="small" @click="save">确定</BaseButton>
</div>
</MiniDialog>
</div>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
.setting {
position: relative;
}
.footer {
margin-top: 10rem;
display: flex;
justify-content: flex-end;
gap: 10rem;
}
</style>

View File

@@ -0,0 +1,177 @@
<script setup lang="ts">
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import {Icon} from "@iconify/vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
import {useWindowClick} from "@/hooks/event.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {useSettingStore} from "@/stores/setting.ts";
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
import {getAudioFileUrl, useChangeAllSound, usePlayAudio, useWatchAllSound} from "@/hooks/sound.ts";
import {SoundFileOptions} from "@/utils/const.ts";
const settingStore = useSettingStore()
let show = $ref(false)
useWindowClick(() => show = false)
useWatchAllSound()
let timer = 0
function toggle(val: boolean) {
clearTimeout(timer)
if (val) {
emitter.emit(EventKey.closeOther)
show = val
} else {
timer = setTimeout(() => {
show = val
}, 100)
}
}
function toggle2() {
if (!show){
emitter.emit(EventKey.closeOther)
}
show = !show
}
</script>
<template>
<div class="setting"
@click.stop="null"
>
<Tooltip title="音效设置">
<IconWrapper>
<Icon v-if="settingStore.allSound" icon="icon-park-outline:volume-notice"
@click="toggle2()"
/>
<Icon v-else icon="icon-park-outline:volume-mute"
@click="toggle2()"
/>
</IconWrapper>
</Tooltip>
<MiniDialog
width="250rem"
v-model="show">
<div class="mini-row-title">
音效设置
</div>
<div class="mini-row">
<label class="item-title">所有音效</label>
<div class="wrapper">
<el-switch v-model="settingStore.allSound"
@change="useChangeAllSound"
inline-prompt
active-text=""
inactive-text=""
/>
</div>
</div>
<div class="mini-row">
<label class="item-title">单词/句子自动发音</label>
<div class="wrapper">
<el-switch v-model="settingStore.wordSound"
inline-prompt
active-text=""
inactive-text=""
/>
</div>
</div>
<div class="mini-row">
<label class="item-title">单词/句子发音口音</label>
<div class="wrapper">
<el-select v-model="settingStore.wordSoundType"
placeholder="请选择"
size="small">
<el-option label="美音" value="us"/>
<el-option label="英音" value="uk"/>
</el-select>
</div>
</div>
<!-- <div class="mini-row">-->
<!-- <label class="item-title">释义发音</label>-->
<!-- <div class="wrapper">-->
<!-- <el-switch v-model="settingStore.translateSound"-->
<!-- inline-prompt-->
<!-- active-text="开"-->
<!-- inactive-text="关"-->
<!-- />-->
<!-- </div>-->
<!-- </div>-->
<div class="mini-row">
<label class="item-title">按键音</label>
<div class="wrapper">
<el-switch v-model="settingStore.keyboardSound"
inline-prompt
active-text="开"
inactive-text="关"
/>
</div>
</div>
<div class="mini-row">
<label class="item-title">按键音效</label>
<div class="wrapper">
<el-select v-model="settingStore.keyboardSoundFile"
placeholder="请选择"
size="small">
<el-option
v-for="item in SoundFileOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>
<div class="el-option-row">
<span>{{ item.label }}</span>
<VolumeIcon
:time="100"
@click="usePlayAudio(getAudioFileUrl(item.value)[0])"/>
</div>
</el-option>
</el-select>
</div>
</div>
<div class="mini-row">
<label class="item-title">效果音</label>
<div class="wrapper">
<el-switch v-model="settingStore.effectSound"
inline-prompt
active-text="开"
inactive-text="关"
/>
</div>
</div>
</MiniDialog>
</div>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
.wrapper {
width: 100rem;
position: relative;
text-align: right;
}
.setting {
position: relative;
}
.el-option-row {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
.icon-wrapper {
transform: translateX(10rem);
}
}
</style>

View File

@@ -0,0 +1,258 @@
<script setup lang="ts">
import Tooltip from "@/pages/pc/components/Tooltip.vue"
import useTheme from "@/hooks/theme.ts"
import {useBaseStore} from "@/stores/base.ts"
import FeedbackModal from "@/pages/pc/components/toolbar/FeedbackModal.vue"
import {Icon} from '@iconify/vue';
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import {watch} from "vue"
import VolumeSetting from "@/pages/pc/components/toolbar/VolumeSetting.vue";
import RepeatSetting from "@/pages/pc/components/toolbar/RepeatSetting.vue";
import TranslateSetting from "@/pages/pc/components/toolbar/TranslateSetting.vue";
import {useSettingStore} from "@/stores/setting.ts";
import {usePracticeStore} from "@/stores/practice.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {$ref} from "vue/macros";
import {DictType, ShortcutKey} from "@/types.ts";
import ChapterName from "@/pages/pc/components/toolbar/ChapterName.vue";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import BaseIcon from "@/components/BaseIcon.vue";
const {toggleTheme} = useTheme()
const store = useBaseStore()
const settingStore = useSettingStore()
const runtimeStore = useRuntimeStore()
const practiceStore = usePracticeStore()
const showFeedbackModal = $ref(false)
const headerRef = $ref<HTMLDivElement>(null)
const moreOptionsRef = $ref<HTMLDivElement>(null)
watch([() => settingStore.showToolbar, () => headerRef], n => {
if (n[1]) {
if (n[0]) {
n[1].style.marginTop = '10rem'
} else {
let rect = n[1].getBoundingClientRect()
n[1].style.marginTop = `-${rect.height}px`
}
}
})
function toggle() {
if (settingStore.collapse) {
setTimeout(() => {
moreOptionsRef.style.overflow = 'unset'
}, 300)
} else {
moreOptionsRef.style.overflow = 'hidden'
}
settingStore.collapse = !settingStore.collapse
}
watch(() => store.load, n => {
if (!settingStore.collapse) {
moreOptionsRef.style.overflow = 'unset'
}
})
</script>
<template>
<header ref="headerRef">
<div class="content">
<div class="dict-name">
<Tooltip
:title="`词典详情(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.OpenDictDetail]})`">
<div class="info hvr-grow" @click="emitter.emit(EventKey.openDictModal,'detail')">
{{ store.currentDict.name }} {{ practiceStore.repeatNumber ? ' 复习错词' : '' }}
</div>
</Tooltip>
<ChapterName v-if="store.currentDict.type === DictType.word"/>
<div class="info-text" v-if="practiceStore.repeatNumber">
复习错词
</div>
</div>
<div class="options" ref="moreOptionsRef">
<div class="more" :class="settingStore.collapse && 'hide'">
<Tooltip title="收起图标">
<IconWrapper>
<Icon :icon="`system-uicons:window-collapse-${settingStore.collapse?'left':'right'}`"
@click="toggle"/>
</IconWrapper>
</Tooltip>
<Tooltip
:title="`开关默写模式(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.ToggleDictation]})`"
>
<IconWrapper>
<Icon icon="majesticons:eye-off-line"
v-if="settingStore.dictation"
@click="settingStore.dictation = false"/>
<Icon icon="mdi:eye-outline"
v-else
@click="settingStore.dictation = true"/>
</IconWrapper>
</Tooltip>
<TranslateSetting/>
<VolumeSetting/>
<RepeatSetting/>
<!-- <Add/>-->
<BaseIcon
@click="emitter.emit(EventKey.openDictModal,'my')"
title="添加"
icon="ic:outline-cloud-upload"/>
<BaseIcon
@click="showFeedbackModal = true"
title="反馈"
icon="ph:bug-beetle"/>
</div>
<div class="with-bg anim">
<BaseIcon
@click="runtimeStore.showSettingModal = true"
:title="`设置(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.OpenSetting]})`"
icon="uil:setting"/>
<BaseIcon
@click="settingStore.showPanel = !settingStore.showPanel"
:title="`单词本(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`"
icon="tdesign:menu-unfold"/>
</div>
</div>
</div>
<Tooltip :title="settingStore.showToolbar?'收起':'展开'">
<Icon icon="icon-park-outline:down"
@click="settingStore.showToolbar = !settingStore.showToolbar"
class="arrow"
:class="!settingStore.showToolbar && 'down'"
width="24"
color="#999"/>
</Tooltip>
</header>
<FeedbackModal v-if="showFeedbackModal" @close="showFeedbackModal = false"/>
</template>
<style lang="scss">
.info {
border-radius: 6rem;
color: var(--color-font-1);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all .3s;
padding: 6rem 8rem;
&:hover {
background: var(--color-main-active);
color: white;
}
}
.info-text {
@extend .info;
cursor: unset;
&:hover {
background: unset;
}
}
</style>
<style scoped lang="scss">
@import "@/assets/css/style";
.test-enter-active,
.test-leave-active {
transition: all 0.3s;
}
.test-enter-from,
.test-leave-to {
width: 0;
}
header {
width: var(--toolbar-width);
margin-top: 10rem;
background: var(--color-second-bg);
border-radius: 8rem;
margin-bottom: 30rem;
position: relative;
z-index: 2;
padding: 4rem var(--space);
box-sizing: border-box;
gap: 10rem;
border: 1px solid var(--color-item-border);
transition: all var(--anim-time);
box-shadow: var(--shadow);
.content {
min-height: var(--toolbar-height);
display: flex;
align-items: center;
justify-content: space-between;
.dict-name {
display: flex;
max-width: 45%;
font-size: 17rem;
position: relative;
}
.hide {
transform: translateX(calc(100% - 36rem));
}
.options {
display: flex;
align-items: center;
overflow: hidden;
.icon-wrapper {
margin-left: 10rem;
}
:deep(.icon-wrapper) {
margin-left: 10rem;
}
.more {
display: flex;
align-items: center;
transition: all .3s;
}
.with-bg {
display: flex;
align-items: center;
position: relative;
background: var(--color-second-bg);
}
}
}
.arrow {
position: absolute;
bottom: 0;
left: 50%;
cursor: pointer;
transition: all .5s;
transform: translate3d(-50%, 100%, 0) rotate(180deg);
padding: 5rem;
&.down {
transform: translate3d(-50%, 100%, 0) rotate(0);
}
}
}
</style>

View File

@@ -7,10 +7,10 @@ import "vue-activity-calendar/style.css";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import Slide from "@/components/Slide.vue";
import Slide from "@/pages/pc/components/Slide.vue";
import ArticleDictDetail from "@/pages/pc/dict/components/ArticleDictDetail.vue";
import WordDictDetail from "@/pages/pc/dict/components/WordDictDetail.vue";
import DictListPanel from "@/components/DictListPanel.vue";
import DictListPanel from "@/pages/pc/components/DictListPanel.vue";
import EditDict from "@/pages/pc/dict/components/EditDict.vue";
import {useRoute} from "vue-router";

View File

@@ -8,19 +8,19 @@ import {$computed, $ref} from "vue/macros";
import {cloneDeep} from "lodash-es";
import {Article, DefaultArticle, DefaultDict, Dict, DictResource, DictType, Sort, TranslateType} from "@/types.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import EditBatchArticleModal from "@/components/article/EditBatchArticleModal.vue";
import EditBatchArticleModal from "@/pages/pc/components/article/EditBatchArticleModal.vue";
import {no} from "@/utils";
import {Icon} from "@iconify/vue";
import EditDict from "@/pages/pc/dict/components/EditDict.vue";
import {nanoid} from "nanoid";
import {useBaseStore} from "@/stores/base.ts";
import {useSettingStore} from "@/stores/setting.ts";
import MiniDialog from "@/components/dialog/MiniDialog.vue";
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import * as XLSX from "xlsx";
import {MessageBox} from "@/utils/MessageBox.tsx";
import {syncMyDictList} from "@/hooks/dict.ts";
import {useWindowClick} from "@/hooks/event.ts";
import ArticleList from "@/components/list/ArticleList.vue";
import ArticleList from "@/pages/pc/components/list/ArticleList.vue";
import * as copy from "copy-to-clipboard";
import {getTranslateText} from "@/hooks/article.ts";

View File

@@ -5,12 +5,12 @@ import BaseIcon from "@/components/BaseIcon.vue";
import Empty from "@/components/Empty.vue";
import {$computed, $ref} from "vue/macros";
import {nextTick, watch} from "vue";
import MiniDialog from "@/components/dialog/MiniDialog.vue";
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import BaseButton from "@/components/BaseButton.vue";
import {useWindowClick} from "@/hooks/event.ts";
import {cloneDeep, reverse, shuffle} from "lodash-es";
import {usePlayWordAudio} from "@/hooks/sound.ts";
import WordList from '@/components/list/WordList.vue'
import WordList from '@/pages/pc/components/list/WordList.vue'
const props = defineProps<{
title: string,

View File

@@ -12,17 +12,17 @@ import {reactive, watch} from "vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {useBaseStore} from "@/stores/base.ts";
import {useSettingStore} from "@/stores/setting.ts";
import Dialog from "@/components/dialog/Dialog.vue";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import {MessageBox} from "@/utils/MessageBox.tsx";
import * as XLSX from "xlsx";
import WordListDialog from "@/components/dialog/WordListDialog.vue";
import WordListDialog from "@/pages/pc/components/dialog/WordListDialog.vue";
import {no} from "@/utils";
import {Icon} from "@iconify/vue";
import EditDict from "@/pages/pc/dict/components/EditDict.vue";
import {syncMyDictList} from "@/hooks/dict.ts";
import MiniDialog from "@/components/dialog/MiniDialog.vue";
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import {useWindowClick} from "@/hooks/event.ts";
import BaseList from "@/components/list/BaseList.vue";
import BaseList from "@/pages/pc/components/list/BaseList.vue";
const emit = defineEmits<{
back: []

View File

@@ -4,8 +4,8 @@ import DictManage from "@/pages/pc/dict/DictManage.vue";
import {onMounted} from "vue";
import {useRoute} from "vue-router";
import {useRuntimeStore} from "@/stores/runtime.ts";
import RightTopBar from "@/components/RightTopBar.vue";
import Logo from "@/components/Logo.vue";
import RightTopBar from "@/pages/pc/components/RightTopBar.vue";
import Logo from "@/pages/pc/components/Logo.vue";
const router = useRoute()
const runtimeStore = useRuntimeStore()

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import Tooltip from "@/components/Tooltip.vue";
import IconWrapper from "@/components/IconWrapper.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import {Icon} from "@iconify/vue";
import BaseIcon from "@/components/BaseIcon.vue";
import {useWordOptions} from "@/hooks/dict.ts";

View File

@@ -4,23 +4,23 @@ import {useBaseStore} from "@/stores/base.ts"
import {$computed, $ref} from "vue/macros"
import {computed, onMounted, onUnmounted, provide, watch} from "vue"
import {Dict, DictType, ShortcutKey} from "@/types.ts"
import PopConfirm from "@/components/PopConfirm.vue"
import PopConfirm from "@/pages/pc/components/PopConfirm.vue"
import BaseButton from "@/components/BaseButton.vue";
import {useSettingStore} from "@/stores/setting.ts";
import Close from "@/components/icon/Close.vue";
import Empty from "@/components/Empty.vue";
import {useArticleOptions, useWordOptions} from "@/hooks/dict.ts";
import {Icon} from "@iconify/vue";
import Tooltip from "@/components/Tooltip.vue";
import IconWrapper from "@/components/IconWrapper.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {useRouter} from "vue-router";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {cloneDeep} from "lodash-es";
import WordList from "@/components/list/WordList.vue";
import ArticleList from "@/components/list/ArticleList.vue";
import Slide from "@/components/Slide.vue";
import WordList from "@/pages/pc/components/list/WordList.vue";
import ArticleList from "@/pages/pc/components/list/ArticleList.vue";
import Slide from "@/pages/pc/components/Slide.vue";
const router = useRouter()
const store = useBaseStore()

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import Dialog from "@/components/dialog/Dialog.vue";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import {useBaseStore} from "@/stores/base.ts";
import Ring from "@/components/Ring.vue";
import Tooltip from "@/components/Tooltip.vue";
import Fireworks from "@/components/Fireworks.vue";
import Ring from "@/pages/pc/components/Ring.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
import Fireworks from "@/pages/pc/components/Fireworks.vue";
import BaseButton from "@/components/BaseButton.vue";
import {DefaultDisplayStatistics, DisplayStatistics, ShortcutKey} from "@/types.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import Toolbar from "@/components/toolbar/index.vue"
import Toolbar from "@/pages/pc/components/toolbar/index.vue"
import {onMounted, onUnmounted, watch} from "vue";
import {usePracticeStore} from "@/stores/practice.ts";
import Footer from "@/pages/pc/practice/Footer.vue";
@@ -14,11 +14,11 @@ import {MessageBox} from "@/utils/MessageBox.tsx";
import PracticeArticle from "@/pages/pc/practice/practice-article/index.vue";
import PracticeWord from "@/pages/pc/practice/practice-word/index.vue";
import {ShortcutKey} from "@/types.ts";
import DictModal from "@/components/dialog/DictDiglog.vue";
import DictModal from "@/pages/pc/components/dialog/DictDiglog.vue";
import {useStartKeyboardEventListener} from "@/hooks/event.ts";
import useTheme from "@/hooks/theme.ts";
import RightTopBar from "@/components/RightTopBar.vue";
import Logo from "@/components/Logo.vue";
import RightTopBar from "@/pages/pc/components/RightTopBar.vue";
import Logo from "@/pages/pc/components/Logo.vue";
const practiceStore = usePracticeStore()
const store = useBaseStore()

View File

@@ -11,8 +11,8 @@ import {cloneDeep} from "lodash-es";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import Options from "@/pages/pc/practice/Options.vue";
import {Icon} from "@iconify/vue";
import IconWrapper from "@/components/IconWrapper.vue";
import Tooltip from "@/components/Tooltip.vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import {useArticleOptions} from "@/hooks/dict.ts";

View File

@@ -18,17 +18,17 @@ import {onMounted, onUnmounted, watch} from "vue";
import {renewSectionTexts, renewSectionTranslates} from "@/hooks/translate.ts";
import {MessageBox} from "@/utils/MessageBox.tsx";
import {useBaseStore} from "@/stores/base.ts";
import EditSingleArticleModal from "@/components/article/EditSingleArticleModal.vue";
import EditSingleArticleModal from "@/pages/pc/components/article/EditSingleArticleModal.vue";
import {usePracticeStore} from "@/stores/practice.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import IconWrapper from "@/components/IconWrapper.vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import {Icon} from "@iconify/vue";
import Tooltip from "@/components/Tooltip.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {useSettingStore} from "@/stores/setting.ts";
import BaseIcon from "@/components/BaseIcon.vue";
import {syncMyDictList, useArticleOptions} from "@/hooks/dict.ts";
import ArticleList from "@/components/list/ArticleList.vue";
import ArticleList from "@/pages/pc/components/list/ArticleList.vue";
import {useOnKeyboardEventListener} from "@/hooks/event.ts";
const store = useBaseStore()

View File

@@ -9,7 +9,7 @@ import {usePlayBeep, usePlayCorrect, usePlayKeyboardAudio, usePlayWordAudio, use
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {cloneDeep} from "lodash-es";
import {onUnmounted, watch, onMounted} from "vue";
import Tooltip from "@/components/Tooltip.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
interface IProps {
word: Word,

View File

@@ -9,17 +9,17 @@ import {usePracticeStore} from "@/stores/practice.ts"
import {useSettingStore} from "@/stores/setting.ts";
import {useOnKeyboardEventListener, useWindowClick} from "@/hooks/event.ts";
import {Icon} from "@iconify/vue";
import Tooltip from "@/components/Tooltip.vue";
import Tooltip from "@/pages/pc/components/Tooltip.vue";
import Options from "@/pages/pc/practice/Options.vue";
import Typing from "@/pages/pc/practice/practice-word/Typing.vue";
import Panel from "@/pages/pc/practice/Panel.vue";
import IconWrapper from "@/components/IconWrapper.vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {syncMyDictList, useWordOptions} from "@/hooks/dict.ts";
import BaseIcon from "@/components/BaseIcon.vue";
import WordList from "@/components/list/WordList.vue";
import WordList from "@/pages/pc/components/list/WordList.vue";
import Empty from "@/components/Empty.vue";
import MiniDialog from "@/components/dialog/MiniDialog.vue";
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import BaseButton from "@/components/BaseButton.vue";
interface IProps {