fix:color

This commit is contained in:
Zyronon
2025-12-28 03:41:20 +08:00
parent e09db8c22a
commit 0237d2e127
18 changed files with 807 additions and 918 deletions

2
components.d.ts vendored
View File

@@ -10,6 +10,7 @@ declare module 'vue' {
export interface GlobalComponents {
About: typeof import('./src/components/About.vue')['default']
ArticleList: typeof import('./src/components/list/ArticleList.vue')['default']
ArticleSetting: typeof import('./src/components/setting/ArticleSetting.vue')['default']
ArticleSettting: typeof import('./src/components/setting/ArticleSettting.vue')['default']
Audio: typeof import('./src/components/base/Audio.vue')['default']
BackIcon: typeof import('./src/components/BackIcon.vue')['default']
@@ -50,6 +51,7 @@ declare module 'vue' {
IconFluentArrowClockwise20Regular: typeof import('~icons/fluent/arrow-clockwise20-regular')['default']
IconFluentArrowDownload20Regular: typeof import('~icons/fluent/arrow-download20-regular')['default']
IconFluentArrowLeft16Regular: typeof import('~icons/fluent/arrow-left16-regular')['default']
IconFluentArrowMove20Regular: typeof import('~icons/fluent/arrow-move20-regular')['default']
IconFluentArrowRepeatAll20Regular: typeof import('~icons/fluent/arrow-repeat-all20-regular')['default']
IconFluentArrowRight16Regular: typeof import('~icons/fluent/arrow-right16-regular')['default']
IconFluentArrowShuffle16Regular: typeof import('~icons/fluent/arrow-shuffle16-regular')['default']

View File

@@ -1,14 +1,10 @@
@use "anim" as *;
@use 'anim' as *;
@use 'shepherd.css';
:root {
--color-reverse-white: white;
--color-reverse-black: black;
--bg-history: white;
--color-item-bg: rgb(228, 230, 232);
--color-item-hover: white;
//--color-item-active: rgb(75, 110, 175);
--color-item-active: rgb(253, 246, 236);
--color-item-border: rgb(226, 226, 226);
--color-tooltip-bg: white;
@@ -31,7 +27,6 @@
--modal-padding: 1.3rem;
--space: 0.9rem;
--stat-gap: 1rem;
--shadow: rgba(0, 0, 0, 0.08) 0px 4px 12px;
--word-panel-margin-left: calc(50% + var(--toolbar-width) / 2 + 1rem);
--anim-time: 0.5s;
@@ -48,66 +43,53 @@
--font-family: -apple-system, sans-serif;
--word-font-family: ui-monospace, sans-serif;
--en-article-family: Georgia, sans-serif;
--zh-article-family: "Songti SC", "SimSun", "Noto Serif CJK SC", serif;
--zh-article-family: 'Songti SC', 'SimSun', 'Noto Serif CJK SC', serif;
--color-primary: #E6E8EB;
--color-primary: #e6e8eb;
--color-second: rgb(247, 247, 247);
--color-third: rgb(226 232 240 / 1);
--color-fourth: rgb(193, 193, 193);
--color-third: rgb(228, 230, 232);
--color-fourth: rgb(218, 220, 222);
--color-fifth: rgb(253, 246, 236);
//--color-card-active: #FED7AA;
--color-card-active: rgb(253, 246, 236);
--color-list-item-active: rgb(253, 246, 236);
--color-icon-hightlight: rgb(12, 140, 233);
//--color-icon-hightlight: rgb(12, 140, 233);
--color-sub-text: gray;
--color-main-text: rgb(91, 91, 91);
--color-select-bg: rgb(12, 140, 233);
--color-select-text: white;
--color-notice-bg: rgb(247, 247, 247);
//修改的进度条底色
--color-progress-bar: #d1d5df !important;
--color-label-bg: whitesmoke;
--color-link: #2563EB;
--color-link: #2563eb;
--color-card-bg: white;
--bg-card-primary: white;
--bg-card-secend: rgb(247, 247, 247);
}
.footer {
&.hide {
--color-progress-bar: #dbdbdb !important;
}
--bg-book: rgb(226 232 240);
}
html.dark {
--color-reverse-white: black;
--color-reverse-black: white;
--color-primary: #0E1217;
--color-second: rgb(30, 31, 34);
--color-third: rgb(43, 45, 48);
--color-primary: #202124;
--color-second: #292a2d;
--color-third: #35373a;
--color-fourth: rgb(70, 70, 70);
--color-fifth: rgb(84, 84, 84);
--color-card-active: rgb(84, 84, 84);
--color-list-item-active: rgb(84, 84, 84);
--color-icon-hightlight: rgb(147, 173, 227);
--color-sub-text: #b8b8b8;
--color-main-text: rgba(249, 250, 251, 0.8);
--color-select-bg: rgb(147, 173, 227);
--color-select-text: black;
--color-notice-bg: rgb(43, 45, 48);
--bg-history: rgb(43, 45, 48);
--color-item-bg: rgb(43, 45, 48);
--color-item-hover: rgb(67, 69, 74);
--color-item-active: rgb(84, 84, 84);
--color-item-border: rgb(41, 41, 41);
--color-tooltip-bg: #252525;
@@ -119,8 +101,7 @@ html.dark {
--color-scrollbar: rgb(92, 93, 94);
--color-input-color: white;
--color-input-bg: rgba(14, 18, 23, 1);
--color-input-icon: #383737;
--color-input-bg: var(--color-third);
--color-textarea-bg: rgb(43, 45, 48);
--color-article: white;
@@ -134,11 +115,8 @@ html.dark {
--bg-card-primary: rgb(30, 31, 34);
--bg-card-secend: rgb(43, 45, 48);
.footer {
&.hide {
--color-progress-bar: var(--color-third) !important;
}
}
--bg-book: #35373a;
}
@media (max-width: 1720px) {
@@ -186,7 +164,10 @@ html.dark {
}
.anim {
transition: background var(--anim-time), color var(--anim-time), border var(--anim-time), opacity var(--anim-time);
transition: background var(--anim-time),
color var(--anim-time),
border var(--anim-time),
opacity var(--anim-time);
}
.en-article-family {
@@ -200,9 +181,7 @@ html.dark {
html,
body {
//font-size: 1px;
padding: 0;
margin: 0;
overflow-x: hidden;
@apply p-0 m-0 overflow-x-hidden;
color: var(--color-main-text);
font-family: var(--font-family);
background: var(--color-primary);
@@ -211,42 +190,14 @@ body {
}
.page {
position: relative;
z-index: 1;
height: 100%;
width: 100%;
font-size: 1rem;
display: flex;
flex-direction: column;
}
.mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.3);
transition: all .3s;
@apply relative z-1 h-full w-full flex flex-col;
}
.mobile-page {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
overflow: auto;
font-size: 18rem;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
@apply fixed left-0 right-0 bottom-0 top-0 overflow-auto font-size-18 w-full h-full flex flex-col;
& > .page-content {
padding: 10rem;
box-sizing: border-box;
overflow: auto;
@apply p-10 box-border overflow-auto;
}
}
@@ -271,38 +222,33 @@ a {
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {
width: .5rem;
height: .6rem;
width: 0.5rem;
height: 0.6rem;
}
::-webkit-scrollbar-track {
background: transparent;
border-radius: .1rem;
border-radius: 0.1rem;
}
::-webkit-scrollbar-thumb {
background: var(--color-scrollbar);
border-radius: .6rem;
border-radius: 0.6rem;
}
}
.panel-page-item {
display: flex;
flex-direction: column;
height: 100%;
@apply flex flex-col h-full box-border;
padding-bottom: var(--space);
box-sizing: border-box;
}
.scroll {
padding: 0 var(--space);
flex: 1;
overflow: auto;
@apply flex-1 overflow-auto;
}
.virtual-list {
overflow: overlay;
height: 100%;
@apply overflow-overlay h-full;
padding: 0 var(--space);
}
@@ -311,45 +257,27 @@ a {
}
.common-list-item {
cursor: pointer;
width: 100%;
box-sizing: border-box;
background: var(--color-item-bg);
@apply cursor-pointer w-full box-border bg-third rounded-lg flex justify-between gap-1 transition-all duration-300 p-2;
color: var(--color-main-text);
font-size: 1.1rem;
border-radius: .5rem;
display: flex;
justify-content: space-between;
transition: all .3s;
padding: .6rem;
gap: .3rem;
border: 1px solid var(--color-item-border);
.left {
display: flex;
gap: .6rem;
@apply flex gap-1;
.title-wrapper {
display: flex;
flex-direction: column;
gap: .2rem;
word-break: break-word;
@apply flex flex-col gap-0.5 word-break-break-word;
}
}
.right {
display: flex;
flex-direction: column;
gap: .1rem;
transition: all .3s;
@apply flex flex-col gap-0.5 transition-all duration-300;
}
svg {
opacity: 0;
@apply opacity-0;
}
&.active {
background: var(--color-list-item-active);
@apply bg-fifth;
.item-sub-title {
color: var(--color-sub-text);
@@ -364,6 +292,7 @@ a {
&:hover {
@extend .active;
background: var(--color-fourth);
svg {
opacity: 1;
@@ -373,7 +302,7 @@ a {
.item-title {
display: flex;
align-items: center;
gap: .5rem;
gap: 0.5rem;
color: var(--color-main-text);
flex-wrap: wrap;
@@ -386,7 +315,7 @@ a {
}
.phonetic {
font-size: .9rem;
font-size: 0.9rem;
color: gray;
}
}
@@ -399,7 +328,7 @@ a {
.word-shadow {
color: transparent !important;
text-shadow: #b0b0b0 0 0 .5rem;
text-shadow: #b0b0b0 0 0 0.5rem;
user-select: none;
}
@@ -415,14 +344,14 @@ a {
.slide {
flex: 1;
width: 100%;
transition: height .3s;
transition: height 0.3s;
position: relative;
overflow: hidden;
.slide-infinite {
z-index: 1;
margin-top: 0;
transition: all .3s;
transition: all 0.3s;
}
.slide-list {
@@ -466,7 +395,8 @@ a {
.book {
@extend .anim;
@apply p-3 rounded-md relative cursor-pointer bg-third hover:bg-card-active flex flex-col justify-between shrink-0;
@apply p-3 rounded-md relative cursor-pointer hover:bg-fifth flex flex-col justify-between shrink-0;
background: var(--bg-book);
$w: 7rem;
width: $w;
height: calc($w * 1.4);
@@ -476,7 +406,6 @@ a {
width: 100%;
border-bottom: 1px solid var(--color-item-border);
@apply hover:text-blue-700;
}
.line-white {
@@ -501,31 +430,19 @@ a {
}
@keyframes underline {
0%,
100% {
border-left: .1rem solid var(--color-article);
border-left: 0.1rem solid var(--color-article);
}
50% {
border-left: .1rem solid transparent;
border-left: 0.1rem solid transparent;
}
}
#typing-listener {
position: fixed;
left: -9999px;
top: -9999px;
width: 1px;
height: 1px;
opacity: 0.01;
z-index: -1;
pointer-events: none;
border: none;
outline: none;
background: transparent;
@apply fixed left-[-9999px] top-[-9999px] w-1 h-1 opacity-0.01 z-[-1] pointer-events-none border-none outline-none bg-transparent text-transparent;
font-size: 16px; // 防止iOS缩放
color: transparent; // 文字透明
}
.btn-no-margin {
@@ -536,5 +453,5 @@ a {
.target-number {
@apply text-3xl!;
color: rgb(176, 116, 211)!important;
color: rgb(176, 116, 211) !important;
}

View File

@@ -33,14 +33,9 @@ provide('tabIndex', computed(() => tabIndex))
<style scoped lang="scss">
.panel {
border-radius: .5rem;
width: var(--panel-width);
background: var(--color-second);
height: 100%;
display: flex;
flex-direction: column;
border: 1px solid var(--color-item-border);
box-shadow: var(--shadow);
@apply shadow-lg flex flex-col h-full rounded-xl;
}
// 移动端适配

View File

@@ -1,22 +1,20 @@
<template>
<div class="inline-flex w-full relative"
:class="[disabled && 'disabled']"
>
<div class="inline-flex w-full relative" :class="[disabled && 'disabled']">
<textarea
ref="textareaRef"
v-model="innerValue"
:placeholder="placeholder"
:maxlength="maxlength"
:rows="rows"
:disabled="disabled"
:style="textareaStyle"
class="w-full px-3 py-2 border border-gray-300 rounded-md outline-none resize-none transition-colors duration-200 box-border"
@input="handleInput"
ref="textareaRef"
v-model="innerValue"
:placeholder="placeholder"
:maxlength="maxlength"
:rows="rows"
:disabled="disabled"
:style="textareaStyle"
class="w-full px-3 py-2 border border-gray-300 rounded-md outline-none resize-none transition-colors duration-200 box-border"
@input="handleInput"
/>
<!-- 字数统计 -->
<span
v-if="showWordLimit && maxlength"
class="absolute bottom-1 right-2 text-xs text-gray-400 select-none"
v-if="showWordLimit && maxlength"
class="absolute bottom-1 right-2 text-xs text-gray-400 select-none"
>
{{ innerValue.length }} / {{ maxlength }}
</span>
@@ -24,36 +22,38 @@
</template>
<script setup lang="ts">
import {ref, watch, computed, nextTick} from "vue"
import { ref, watch, computed, nextTick } from 'vue'
const props = defineProps<{
modelValue: string,
placeholder?: string,
maxlength?: number,
rows?: number,
modelValue: string
placeholder?: string
maxlength?: number
rows?: number
autosize: boolean | { minRows?: number; maxRows?: number }
showWordLimit?: boolean
disabled?: boolean
}>()
const emit = defineEmits(["update:modelValue"])
const emit = defineEmits(['update:modelValue'])
const innerValue = ref(props.modelValue ?? "")
watch(() => props.modelValue, v => (innerValue.value = v ?? ""))
const innerValue = ref(props.modelValue ?? '')
watch(
() => props.modelValue,
v => (innerValue.value = v ?? '')
)
const textareaRef = ref<HTMLTextAreaElement>()
// 样式(用于控制高度)
const textareaStyle = computed(() => {
return props.autosize ? {height: "auto"} : {}
return props.autosize ? { height: 'auto' } : {}
})
// 输入处理
const handleInput = (e: Event) => {
const val = (e.target as HTMLTextAreaElement).value
innerValue.value = val
emit("update:modelValue", val)
emit('update:modelValue', val)
if (props.autosize) nextTick(resizeTextarea)
}
@@ -61,36 +61,38 @@ const handleInput = (e: Event) => {
const resizeTextarea = () => {
if (!textareaRef.value) return
const el = textareaRef.value
el.style.height = "auto"
el.style.height = 'auto'
let height = el.scrollHeight
let overflow = "hidden"
let overflow = 'hidden'
if (typeof props.autosize === "object") {
const {minRows, maxRows} = props.autosize
if (typeof props.autosize === 'object') {
const { minRows, maxRows } = props.autosize
const lineHeight = 24 // 行高约等于 24px
if (minRows) height = Math.max(height, minRows * lineHeight)
if (maxRows) {
const maxHeight = maxRows * lineHeight
if (height > maxHeight) {
height = maxHeight
overflow = "auto" // 超出时允许滚动
overflow = 'auto' // 超出时允许滚动
}
}
}
el.style.height = height + "px"
el.style.height = height + 'px'
el.style.overflowY = overflow
}
watch(innerValue, () => {
if (props.autosize) nextTick(resizeTextarea)
}, {immediate: true})
watch(
innerValue,
() => {
if (props.autosize) nextTick(resizeTextarea)
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.disabled {
opacity: 0.5;
textarea {
cursor: not-allowed !important;
}

View File

@@ -13,7 +13,7 @@ const selectHandler = inject('selectHandler', null);
// 计算当前选项是否被选中
const isSelected = computed(() => {
return selectValue === props.value;
return selectValue.value === props.value;
});
// 点击选项时调用ElSelect提供的方法
@@ -45,20 +45,16 @@ watch(() => props.value, () => {}, { immediate: true });
<style scoped lang="scss">
.option {
display: flex;
align-items: center;
padding: 0.2rem 1rem;
cursor: pointer;
transition: background-color 0.3s;
@apply flex items-center px-2 py-1 cursor-pointer transition-all duration-300;
&:hover {
background-color: var(--color-third);
background-color: var(--color-fourth);
}
&.is-selected {
color: var(--color-select-bg);
font-weight: bold;
background-color: var(--color-third);
background-color: var(--color-fifth);
}
&.is-disabled {

View File

@@ -1,197 +1,185 @@
<script setup lang="ts">
import {computed, nextTick, onBeforeUnmount, onMounted, provide, ref, useAttrs, useSlots, VNode, watch} from 'vue';
import {useWindowClick} from "@/hooks/event.ts";
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
provide,
ref,
useAttrs,
useSlots,
VNode,
watch,
} from 'vue'
import { useWindowClick } from '@/hooks/event.ts'
interface Option {
label: string;
value: any;
disabled?: boolean;
label: string
value: any
disabled?: boolean
}
const props = defineProps<{
modelValue: any;
placeholder?: string;
disabled?: boolean;
options?: Option[];
}>();
modelValue: any
placeholder?: string
disabled?: boolean
options?: Option[]
}>()
const emit = defineEmits(['update:modelValue']);
const attrs = useAttrs();
const emit = defineEmits(['update:modelValue'])
const attrs = useAttrs()
const isOpen = ref(false);
const isReverse = ref(false);
const dropdownStyle = ref({}); // Teleport 用的样式
const selectedOption = ref<Option | null>(null);
const selectRef = ref<HTMLDivElement | null>(null);
const dropdownRef = ref<HTMLDivElement | null>(null);
const slots = useSlots();
const isOpen = ref(false)
const isReverse = ref(false)
const dropdownStyle = ref({}) // Teleport 用的样式
const selectedOption = ref<Option | null>(null)
const selectRef = ref<HTMLDivElement | null>(null)
const dropdownRef = ref<HTMLDivElement | null>(null)
const slots = useSlots()
const displayValue = computed(() => {
return selectedOption.value
? selectedOption.value.label
: props.placeholder || '请选择';
});
return selectedOption.value ? selectedOption.value.label : props.placeholder || '请选择'
})
const updateDropdownPosition = async () => {
if (!selectRef.value || !dropdownRef.value) return;
if (!selectRef.value || !dropdownRef.value) return
// 等待 DOM 完全渲染(尤其是下拉框高度)
await nextTick();
await new Promise(requestAnimationFrame);
await nextTick()
await new Promise(requestAnimationFrame)
const rect = selectRef.value.getBoundingClientRect();
const dropdownHeight = dropdownRef.value.offsetHeight;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const rect = selectRef.value.getBoundingClientRect()
const dropdownHeight = dropdownRef.value.offsetHeight
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
isReverse.value = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
isReverse.value = spaceBelow < dropdownHeight && spaceAbove > spaceBelow
dropdownStyle.value = {
position: 'fixed',
left: rect.left + 'px',
width: rect.width + 'px',
top: !isReverse.value
? rect.bottom + 5 + 'px'
: 'auto',
bottom: isReverse.value
? window.innerHeight - rect.top + 5 + 'px'
: 'auto',
zIndex: 9999
};
};
top: !isReverse.value ? rect.bottom + 5 + 'px' : 'auto',
bottom: isReverse.value ? window.innerHeight - rect.top + 5 + 'px' : 'auto',
zIndex: 9999,
}
}
const toggleDropdown = async () => {
if (props.disabled) return;
if (props.disabled) return
isOpen.value = !isOpen.value;
isOpen.value = !isOpen.value
if (isOpen.value) {
await nextTick();
await new Promise(requestAnimationFrame);
await updateDropdownPosition();
await nextTick()
await new Promise(requestAnimationFrame)
await updateDropdownPosition()
}
};
}
const selectOption = (value: any, label: string) => {
selectedOption.value = {value, label};
emit('update:modelValue', value);
isOpen.value = false;
};
selectedOption.value = { value, label }
emit('update:modelValue', value)
isOpen.value = false
}
let selectValue = $ref(props.modelValue);
let selectValue = ref(props.modelValue)
provide('selectValue', selectValue);
provide('selectHandler', selectOption);
provide('selectValue', selectValue)
provide('selectHandler', selectOption)
useWindowClick((e: PointerEvent) => {
if (!e) return;
if (!e) return
if (
selectRef.value &&
!selectRef.value.contains(e.target as Node) &&
dropdownRef.value &&
!dropdownRef.value.contains(e.target as Node)
selectRef.value &&
!selectRef.value.contains(e.target as Node) &&
dropdownRef.value &&
!dropdownRef.value.contains(e.target as Node)
) {
isOpen.value = false;
isOpen.value = false
}
});
})
watch(() => props.modelValue, (newValue) => {
selectValue = newValue;
if (slots.default) {
let slot = slots.default();
let list = [];
if (slot.length === 1) {
list = Array.from(slot[0].children as Array<VNode>);
} else {
list = slot;
watch(
() => props.modelValue,
newValue => {
selectValue.value = newValue
if (slots.default) {
let slot = slots.default()
let list = []
if (slot.length === 1) {
list = Array.from(slot[0].children as Array<VNode>)
} else {
list = slot
}
const option = list.find(opt => opt.props.value === newValue)
if (option) {
selectedOption.value = option.props
}
return
}
const option = list.find(opt => opt.props.value === newValue);
if (option) {
selectedOption.value = option.props;
if (props.options) {
const option = props.options.find(opt => opt.value === newValue)
if (option) {
selectedOption.value = option
}
}
return;
}
if (props.options) {
const option = props.options.find(opt => opt.value === newValue);
if (option) {
selectedOption.value = option;
}
}
}, {immediate: true});
},
{ immediate: true }
)
watch(() => props.options, (newOptions) => {
if (newOptions && props.modelValue) {
const option = newOptions.find(opt => opt.value === props.modelValue);
if (option) {
selectedOption.value = option;
watch(
() => props.options,
newOptions => {
if (newOptions && props.modelValue) {
const option = newOptions.find(opt => opt.value === props.modelValue)
if (option) {
selectedOption.value = option
}
}
}
}, {immediate: true});
const handleOptionClick = (option: Option) => {
if (option.disabled) return;
selectOption(option.value, option.label);
};
},
{ immediate: true }
)
const onScrollOrResize = () => {
if (isOpen.value) updateDropdownPosition();
};
if (isOpen.value) updateDropdownPosition()
}
onMounted(() => {
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
});
window.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize);
});
window.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
})
</script>
<template>
<div
class="select"
v-bind="attrs"
:class="{ 'is-disabled': disabled, 'is-active': isOpen, 'is-reverse': isReverse }"
ref="selectRef"
>
<div class="select__wrapper" @click="toggleDropdown">
<div class="select" ref="selectRef">
<div
class="select__wrapper"
:class="{ disabled: disabled, active: isOpen }"
@click="toggleDropdown"
>
<div class="select__label" :class="{ 'is-placeholder': !selectedOption }">
{{ displayValue }}
</div>
<div class="select__suffix">
<IconFluentChevronLeft20Filled
class="arrow"
:class="{ 'is-reverse': isOpen }"
width="16"
/>
</div>
<IconFluentChevronLeft20Filled
class="select__arrow"
:class="{ 'is-reverse': isOpen }"
width="16"
/>
</div>
<teleport to="body">
<transition :name="isReverse ? 'zoom-in-bottom' : 'zoom-in-top'" :key="isReverse ? 'bottom' : 'top'">
<div
class="select__dropdown"
v-if="isOpen"
ref="dropdownRef"
:style="dropdownStyle"
>
<ul class="select__options">
<li
v-if="options"
v-for="(option, index) in options"
:key="index"
class="select__option"
:class="{
'is-selected': option.value === modelValue,
'is-disabled': option.disabled
}"
@click="handleOptionClick(option)"
>
{{ option.label }}
</li>
<slot v-else></slot>
</ul>
<transition
:name="isReverse ? 'zoom-in-bottom' : 'zoom-in-top'"
:key="isReverse ? 'bottom' : 'top'"
>
<div class="select__dropdown" v-if="isOpen" ref="dropdownRef" :style="dropdownStyle">
<slot></slot>
</div>
</transition>
</teleport>
@@ -200,24 +188,27 @@ onBeforeUnmount(() => {
<style scoped lang="scss">
.select {
position: relative;
width: 100%;
font-size: 1rem;
@apply relative w-full;
&__wrapper {
display: flex;
align-items: center;
justify-content: space-between;
@apply flex items-center justify-between rounded-md cursor-pointer transition-all duration-300;
height: 2rem;
padding: 0 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 0.25rem;
background-color: var(--color-input-bg, #fff);
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: var(--color-select-bg);
&:not(.disabled):hover {
border-color: #3c89e8;
}
&.active {
border-color: #1668dc !important;
box-shadow: 0 0 2px 1px #166ce4;
}
&.disabled {
background: var(--color-fourth);
opacity: 0.5;
cursor: not-allowed;
}
}
@@ -232,17 +223,12 @@ onBeforeUnmount(() => {
}
}
&__suffix {
display: flex;
align-items: center;
&__arrow {
color: #999;
transform: rotate(-90deg);
transition: transform 0.3s;
.arrow {
transform: rotate(-90deg);
transition: transform 0.3s;
}
.is-reverse {
&.is-reverse {
transform: rotate(90deg);
}
}
@@ -251,44 +237,17 @@ onBeforeUnmount(() => {
.select__dropdown {
max-height: 200px;
overflow-y: auto;
background-color: var(--color-input-bg);
border: 1px solid var(--color-input-border);
background-color: var(--color-third);
border-radius: 0.25rem;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.select__options {
margin: 0;
padding: 0;
list-style: none;
}
.select__option {
padding: 0.5rem;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: var(--color-item-hover);
}
&.is-selected {
color: var(--color-select-bg);
font-weight: bold;
background-color: var(--color-item-active);
}
}
.is-disabled {
opacity: 0.7;
cursor: not-allowed;
@apply shadow-lg;
}
/* 往下展开的动画 */
.zoom-in-top-enter-active,
.zoom-in-top-leave-active {
transition: transform 0.3s cubic-bezier(0.23, 1, 0.32, 1),
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
transition:
transform 0.3s cubic-bezier(0.23, 1, 0.32, 1),
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
transform-origin: center top;
}
@@ -301,8 +260,9 @@ onBeforeUnmount(() => {
/* 往上展开的动画 */
.zoom-in-bottom-enter-active,
.zoom-in-bottom-leave-active {
transition: transform 0.3s cubic-bezier(0.23, 1, 0.32, 1),
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
transition:
transform 0.3s cubic-bezier(0.23, 1, 0.32, 1),
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
transform-origin: center bottom;
}

View File

@@ -1,24 +1,24 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from "vue";
import Tooltip from "@/components/base/Tooltip.vue";
import { useEventListener } from "@/hooks/event.ts";
import { onMounted, onUnmounted, watch } from 'vue'
import Tooltip from '@/components/base/Tooltip.vue'
import { useEventListener } from '@/hooks/event.ts'
import BaseButton from "@/components/BaseButton.vue";
import { useRuntimeStore } from "@/stores/runtime.ts";
import BaseButton from '@/components/BaseButton.vue'
import { useRuntimeStore } from '@/stores/runtime.ts'
export interface ModalProps {
modelValue?: boolean,
showClose?: boolean,
title?: string,
content?: string,
fullScreen?: boolean;
modelValue?: boolean
showClose?: boolean
title?: string
content?: string
fullScreen?: boolean
padding?: boolean
footer?: boolean
header?: boolean
confirmButtonText?: string
cancelButtonText?: string,
keyboard?: boolean,
closeOnClickBg?: boolean,
cancelButtonText?: string
keyboard?: boolean
closeOnClickBg?: boolean
confirm?: any
beforeClose?: any
}
@@ -32,15 +32,10 @@ const props = withDefaults(defineProps<ModalProps>(), {
header: true,
confirmButtonText: '确认',
cancelButtonText: '取消',
keyboard: true
keyboard: true,
})
const emit = defineEmits([
'update:modelValue',
'close',
'ok',
'cancel',
])
const emit = defineEmits(['update:modelValue', 'close', 'ok', 'cancel'])
let confirmButtonLoading = $ref(false)
let zIndex = $ref(999)
@@ -56,21 +51,21 @@ async function close() {
return
}
if (props.beforeClose) {
if (!await props.beforeClose()) {
if (!(await props.beforeClose())) {
return
}
}
//记录停留时间,避免时间太短,弹框闪烁
let stayTime = Date.now() - openTime;
let closeTime = 300;
let stayTime = Date.now() - openTime
let closeTime = 300
if (stayTime < 500) {
closeTime += 500 - stayTime;
closeTime += 500 - stayTime
}
return new Promise((resolve) => {
return new Promise(resolve => {
setTimeout(() => {
maskRef?.classList.toggle('bounce-out');
modalRef?.classList.toggle('bounce-out');
}, 500 - stayTime);
maskRef?.classList.toggle('bounce-out')
modalRef?.classList.toggle('bounce-out')
}, 500 - stayTime)
setTimeout(() => {
emit('update:modelValue', false)
@@ -82,20 +77,23 @@ async function close() {
runtimeStore.modalList.splice(rIndex, 1)
}
}, closeTime)
});
})
}
watch(() => props.modelValue, n => {
// console.log('n', n)
if (n) {
id = Date.now()
runtimeStore.modalList.push({ id, close })
zIndex = 999 + runtimeStore.modalList.length
visible = true
} else {
close()
watch(
() => props.modelValue,
n => {
// console.log('n', n)
if (n) {
id = Date.now()
runtimeStore.modalList.push({ id, close })
zIndex = 999 + runtimeStore.modalList.length
visible = true
} else {
close()
}
}
})
)
onMounted(() => {
if (props.modelValue === undefined) {
@@ -139,32 +137,30 @@ async function cancel() {
emit('cancel')
await close()
}
</script>
<template>
<Teleport to="body">
<div class="modal-root" :style="{'z-index': zIndex}" v-if="visible">
<div class="modal-mask"
ref="maskRef"
v-if="!fullScreen"
@click.stop="closeOnClickBg && close()"></div>
<div class="modal"
ref="modalRef"
:class="[
fullScreen?'full':'window'
]"
>
<div class="modal-root" :style="{ 'z-index': zIndex }" v-if="visible">
<div
class="modal-mask"
ref="maskRef"
v-if="!fullScreen"
@click.stop="closeOnClickBg && close()"
></div>
<div class="modal" ref="modalRef" :class="[fullScreen ? 'full' : 'window']">
<Tooltip title="关闭">
<IconFluentDismiss20Regular @click="close"
v-if="showClose"
class="close cursor-pointer"
width="24"/>
<IconFluentDismiss20Regular
@click="close"
v-if="showClose"
class="close cursor-pointer"
width="24"
/>
</Tooltip>
<div class="modal-header" v-if="header">
<div class="title">{{ props.title }}</div>
</div>
<div class="modal-body" :class="{padding}">
<div class="modal-body" :class="{ padding }">
<slot></slot>
<div v-if="content" class="content max-h-60vh">{{ content }}</div>
</div>
@@ -174,10 +170,8 @@ async function cancel() {
</div>
<div class="right">
<BaseButton type="info" @click="cancel">{{ cancelButtonText }}</BaseButton>
<BaseButton
id="dialog-ok"
:loading="confirmButtonLoading"
@click="ok">{{ confirmButtonText }}
<BaseButton id="dialog-ok" :loading="confirmButtonLoading" @click="ok"
>{{ confirmButtonText }}
</BaseButton>
</div>
</div>
@@ -187,12 +181,7 @@ async function cancel() {
</template>
<style scoped lang="scss">
$modal-mask-bg: rgba(#000, .6);
$radius: .5rem;
$time: 0.3s;
$header-height: 4rem;
@keyframes bounce-in {
0% {
@@ -224,38 +213,21 @@ $header-height: 4rem;
}
.modal-root {
position: fixed;
top: 0;
left: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
overflow: hidden;
@apply fixed top-0 left-0 z-999 flex items-center justify-center w-full h-full overflow-hidden;
.modal-mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: $modal-mask-bg;
transition: background 0.3s;
@apply fixed top-0 left-0 w-full h-full transition-all duration-300;
background: rgba(#000, 0.6);
animation: fade-in $time;
&.bounce-out {
background: transparent;
@apply bg-transparent;
}
}
.window {
//width: 75vw;
//height: 70vh;
box-shadow: var(--shadow);
border-radius: $radius;
animation: bounce-in $time ease-out;
@apply shadow-lg rounded-lg;
&.bounce-out {
opacity: 0;
@@ -263,8 +235,7 @@ $header-height: 4rem;
}
.full {
width: 100vw;
height: 100vh;
@apply w-full h-full;
animation: bounce-in-full $time ease-out;
&.bounce-out {
@@ -274,61 +245,34 @@ $header-height: 4rem;
}
.modal {
position: relative;
background: var(--color-second);
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform $time, opacity $time;
@apply relative bg-second overflow-hidden flex flex-col transition-all duration-300;
.close {
position: absolute;
right: 1.2rem;
top: 1.2rem;
z-index: 999;
@apply absolute right-1.2rem top-1.2rem z-999;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--modal-padding);
padding-bottom: 0;
border-radius: $radius $radius 0 0;
@apply flex justify-between items-center p-5 pb-0 rounded-t-lg;
.title {
color: var(--color-font-1);
font-weight: bold;
font-size: 1.3rem;
line-height: 1.8rem;
@apply font-bold text-xl;
}
}
.modal-body {
box-sizing: border-box;
color: var(--color-main-text);
font-weight: 400;
font-size: 1.1rem;
line-height: 1.7rem;
width: 100%;
flex: 1;
overflow: hidden;
display: flex;
@apply box-border text-main-text font-normal text-base leading-6 w-full flex-1 overflow-hidden flex;
&.padding {
padding: .2rem var(--modal-padding);
@apply p-1 px-5;
}
.content {
width: 25rem;
padding: .2rem 1.6rem 1.6rem;
@apply w-64 p-2 px-4 pb-4;
}
}
.modal-footer {
display: flex;
justify-content: space-between;
padding: var(--modal-padding);
@apply flex justify-between p-5;
}
}
}

View File

@@ -182,7 +182,7 @@ defineExpose({scrollBottom})
.list {
.item {
box-sizing: border-box;
background: var(--color-item-bg);
background: var(--color-second);
color: var(--color-font-1);
border-radius: .5rem;
margin-bottom: .6rem;
@@ -199,13 +199,14 @@ defineExpose({scrollBottom})
}
&:hover {
background: var(--color-third);
.right {
opacity: 1;
}
}
&.active {
background: var(--color-item-active);
background: var(--color-fourth);
color: var(--color-font-1);
}

View File

@@ -1,16 +1,15 @@
<script setup lang="ts">
import { ShortcutKey } from "@/types/types.ts";
import { SoundFileOptions } from "@/config/env.ts";
import { getAudioFileUrl, usePlayAudio } from "@/hooks/sound.ts";
import Switch from "@/components/base/Switch.vue";
import { Option, Select } from "@/components/base/select";
import Textarea from "@/components/base/Textarea.vue";
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
import Slider from "@/components/base/Slider.vue";
import SettingItem from "@/pages/setting/SettingItem.vue";
import { useSettingStore } from "@/stores/setting.ts";
import { useBaseStore } from "@/stores/base.ts";
import { ShortcutKey } from '@/types/types.ts'
import { SoundFileOptions } from '@/config/env.ts'
import { getAudioFileUrl, usePlayAudio } from '@/hooks/sound.ts'
import Switch from '@/components/base/Switch.vue'
import { Option, Select } from '@/components/base/select'
import Textarea from '@/components/base/Textarea.vue'
import VolumeIcon from '@/components/icon/VolumeIcon.vue'
import Slider from '@/components/base/Slider.vue'
import SettingItem from '@/pages/setting/SettingItem.vue'
import { useSettingStore } from '@/stores/setting.ts'
import { useBaseStore } from '@/stores/base.ts'
const settingStore = useSettingStore()
const store = useBaseStore()
@@ -19,11 +18,9 @@ const simpleWords = $computed({
get: () => store.simpleWords.join(','),
set: v => {
try {
store.simpleWords = v.split(',');
} catch (e) {
}
}
store.simpleWords = v.split(',')
} catch (e) {}
},
})
</script>
@@ -32,81 +29,71 @@ const simpleWords = $computed({
<!-- 通用练习设置-->
<!-- 通用练习设置-->
<div>
<SettingItem title="忽略大小写"
desc="开启后输入时不区分大小写如输入“hello”和“Hello”都会被认为是正确的"
<SettingItem
title="忽略大小写"
desc="开启后输入时不区分大小写如输入“hello”和“Hello”都会被认为是正确的"
>
<Switch v-model="settingStore.ignoreCase"/>
<Switch v-model="settingStore.ignoreCase" />
</SettingItem>
<SettingItem title="允许默写模式下显示提示"
:desc="`开启后,可以通过将鼠标移动到单词上或者按快捷键 ${settingStore.shortcutKeyMap[ShortcutKey.ShowWord]} 显示正确答案`"
<SettingItem
title="允许默写模式下显示提示"
:desc="`开启后,可以通过将鼠标移动到单词上或者按快捷键 ${settingStore.shortcutKeyMap[ShortcutKey.ShowWord]} 显示正确答案`"
>
<Switch v-model="settingStore.allowWordTip"/>
<Switch v-model="settingStore.allowWordTip" />
</SettingItem>
<div class="line"></div>
<SettingItem title="简单词过滤"
desc="开启后,练习的单词中不会包含简单词;文章统计的总词数中不会包含简单词"
<SettingItem
title="简单词过滤"
desc="开启后,练习的单词中不会包含简单词;文章统计的总词数中不会包含简单词"
>
<Switch v-model="settingStore.ignoreSimpleWord"/>
<Switch v-model="settingStore.ignoreSimpleWord" />
</SettingItem>
<SettingItem title="简单词列表"
class="items-start!"
v-if="settingStore.ignoreSimpleWord"
>
<Textarea
placeholder="多个单词用英文逗号隔号"
v-model="simpleWords" :autosize="{minRows: 6, maxRows: 10}"/>
<SettingItem title="简单词列表" class="items-start!" v-if="settingStore.ignoreSimpleWord">
<Textarea
placeholder="多个单词用英文逗号隔号"
v-model="simpleWords"
:autosize="{ minRows: 6, maxRows: 10 }"
/>
</SettingItem>
<!-- 音效-->
<!-- 音效-->
<!-- 音效-->
<div class="line"></div>
<SettingItem main-title="音效"/>
<SettingItem title="单词/句子发音口音"
desc="仅单词生效,文章固定美音"
>
<Select v-model="settingStore.soundType"
placeholder="请选择"
class="w-50!"
>
<Option label="美音" value="us"/>
<Option label="英音" value="uk"/>
<SettingItem main-title="音效" />
<SettingItem title="单词/句子发音口音" desc="仅单词生效,文章固定美音">
<Select v-model="settingStore.soundType" placeholder="请选择" class="w-50!">
<Option label="美音" value="us" />
<Option label="英音" value="uk" />
</Select>
</SettingItem>
<div class="line"></div>
<SettingItem title="按键音">
<Switch v-model="settingStore.keyboardSound"/>
<Switch v-model="settingStore.keyboardSound" />
</SettingItem>
<SettingItem title="按键音效">
<Select v-model="settingStore.keyboardSoundFile"
placeholder="请选择"
class="w-50!"
>
<Select v-model="settingStore.keyboardSoundFile" placeholder="请选择" class="w-50!">
<Option
v-for="item in SoundFileOptions"
:key="item.value"
:label="item.label"
:value="item.value"
v-for="item in SoundFileOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>
<div class="flex justify-between items-center w-full">
<span>{{ item.label }}</span>
<VolumeIcon
:time="100"
@click="usePlayAudio(getAudioFileUrl(item.value)[0])"/>
<VolumeIcon :time="100" @click="usePlayAudio(getAudioFileUrl(item.value)[0])" />
</div>
</Option>
</Select>
</SettingItem>
<SettingItem title="音量">
<Slider v-model="settingStore.keyboardSoundVolume" showText showValue unit="%"/>
<Slider v-model="settingStore.keyboardSoundVolume" showText showValue unit="%" />
</SettingItem>
</div>
</template>
<style scoped lang="scss">
</style>
<style scoped lang="scss"></style>

View File

@@ -3,7 +3,7 @@ import { defineAsyncComponent } from "vue";
import BaseIcon from "@/components/BaseIcon.vue";
import CommonSetting from "@/components/setting/CommonSetting.vue";
import WordSetting from "@/components/setting/WordSetting.vue";
import ArticleSettting from "@/components/setting/ArticleSettting.vue";
import ArticleSetting from "@/components/setting/ArticleSetting.vue";
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
@@ -39,7 +39,7 @@ let show = $ref(false)
<div class="content">
<CommonSetting v-if="tabIndex === 0"/>
<WordSetting v-if="tabIndex === 1"/>
<ArticleSettting v-if="tabIndex === 2"/>
<ArticleSetting v-if="tabIndex === 2"/>
</div>
</div>
</div>

View File

@@ -309,7 +309,6 @@ function updateList(e) {
height: 100vh;
box-sizing: border-box;
color: var(--color-font-1);
background: var(--color-second);
display: flex;
.close {

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, provide, watch } from "vue";
import { useBaseStore } from "@/stores/base.ts";
import { emitter, EventKey, useEvents } from "@/utils/eventBus.ts";
import { useSettingStore } from "@/stores/setting.ts";
import { computed, onMounted, onUnmounted, provide, watch } from 'vue'
import { useBaseStore } from '@/stores/base.ts'
import { emitter, EventKey, useEvents } from '@/utils/eventBus.ts'
import { useSettingStore } from '@/stores/setting.ts'
import {
Article,
ArticleItem,
@@ -13,37 +12,50 @@ import {
PracticeArticleWordType,
ShortcutKey,
Statistics,
Word
} from "@/types/types.ts";
import { useDisableEventListener, useOnKeyboardEventListener, useStartKeyboardEventListener } from "@/hooks/event.ts";
import useTheme from "@/hooks/theme.ts";
Word,
} from '@/types/types.ts'
import {
useDisableEventListener,
useOnKeyboardEventListener,
useStartKeyboardEventListener,
} from '@/hooks/event.ts'
import useTheme from '@/hooks/theme.ts'
import Toast from '@/components/base/toast/Toast.ts'
import { _getDictDataByUrl, _nextTick, cloneDeep, isMobile, loadJsLib, msToMinute, resourceWrap, total } from "@/utils";
import { usePracticeStore } from "@/stores/practice.ts";
import { useArticleOptions } from "@/hooks/dict.ts";
import { genArticleSectionData, usePlaySentenceAudio } from "@/hooks/article.ts";
import { getDefaultArticle, getDefaultDict, getDefaultWord } from "@/types/func.ts";
import TypingArticle from "@/pages/article/components/TypingArticle.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import Panel from "@/components/Panel.vue";
import ArticleList from "@/components/list/ArticleList.vue";
import EditSingleArticleModal from "@/pages/article/components/EditSingleArticleModal.vue";
import Tooltip from "@/components/base/Tooltip.vue";
import ConflictNotice from "@/components/ConflictNotice.vue";
import { useRoute, useRouter } from "vue-router";
import PracticeLayout from "@/components/PracticeLayout.vue";
import ArticleAudio from "@/pages/article/components/ArticleAudio.vue";
import { AppEnv, DICT_LIST, LIB_JS_URL, TourConfig } from "@/config/env.ts";
import { addStat, setUserDictProp } from "@/apis";
import { useRuntimeStore } from "@/stores/runtime.ts";
import SettingDialog from "@/components/setting/SettingDialog.vue";
import {PRACTICE_ARTICLE_CACHE} from "@/utils/cache.ts";
import {
_getDictDataByUrl,
_nextTick,
cloneDeep,
isMobile,
loadJsLib,
msToMinute,
resourceWrap,
total,
} from '@/utils'
import { usePracticeStore } from '@/stores/practice.ts'
import { useArticleOptions } from '@/hooks/dict.ts'
import { genArticleSectionData, usePlaySentenceAudio } from '@/hooks/article.ts'
import { getDefaultArticle, getDefaultDict, getDefaultWord } from '@/types/func.ts'
import TypingArticle from '@/pages/article/components/TypingArticle.vue'
import BaseIcon from '@/components/BaseIcon.vue'
import Panel from '@/components/Panel.vue'
import ArticleList from '@/components/list/ArticleList.vue'
import EditSingleArticleModal from '@/pages/article/components/EditSingleArticleModal.vue'
import Tooltip from '@/components/base/Tooltip.vue'
import ConflictNotice from '@/components/ConflictNotice.vue'
import { useRoute, useRouter } from 'vue-router'
import PracticeLayout from '@/components/PracticeLayout.vue'
import ArticleAudio from '@/pages/article/components/ArticleAudio.vue'
import { AppEnv, DICT_LIST, LIB_JS_URL, TourConfig } from '@/config/env.ts'
import { addStat, setUserDictProp } from '@/apis'
import { useRuntimeStore } from '@/stores/runtime.ts'
import SettingDialog from '@/components/setting/SettingDialog.vue'
import { PRACTICE_ARTICLE_CACHE } from '@/utils/cache.ts'
const store = useBaseStore()
const runtimeStore = useRuntimeStore()
const settingStore = useSettingStore()
const statStore = usePracticeStore()
const {toggleTheme} = useTheme()
const { toggleTheme } = useTheme()
let articleData = $ref({
list: [],
@@ -82,9 +94,9 @@ function prev() {
}
}
const toggleShowTranslate = () => settingStore.translate = !settingStore.translate
const toggleDictation = () => settingStore.dictation = !settingStore.dictation
const togglePanel = () => settingStore.showPanel = !settingStore.showPanel
const toggleShowTranslate = () => (settingStore.translate = !settingStore.translate)
const toggleDictation = () => (settingStore.dictation = !settingStore.dictation)
const togglePanel = () => (settingStore.showPanel = !settingStore.showPanel)
const skip = () => typingArticleRef?.nextSentence()
const collect = () => toggleArticleCollect(articleData.article)
const shortcutKeyEdit = () => edit()
@@ -144,59 +156,70 @@ const initAudio = () => {
const handleVolumeUpdate = (volume: number) => {
settingStore.setState({
articleSoundVolume: volume
articleSoundVolume: volume,
})
}
const handleSpeedUpdate = (speed: number) => {
settingStore.setState({
articleSoundSpeed: speed
articleSoundSpeed: speed,
})
}
watch([() => store.load, () => loading], ([a, b]) => {
if (a && b) init()
}, {immediate: true})
watch(
[() => store.load, () => loading],
([a, b]) => {
if (a && b) init()
},
{ immediate: true }
)
watch(() => articleData?.article?.id, id => {
if (id) {
_nextTick(async () => {
const Shepherd = await loadJsLib('Shepherd', LIB_JS_URL.SHEPHERD);
const tour = new Shepherd.Tour(TourConfig);
tour.on('cancel', () => {
localStorage.setItem('tour-guide', '1');
});
tour.addStep({
id: 'step8',
text: '这里可以练习文章,只需要按下键盘上对应的按键即可,没有输入框!',
attachTo: {
element: '#article-content',
on: 'auto'
},
buttons: [
{
text: `关闭`,
action() {
settingStore.first = false
tour.next()
setTimeout(() => {
showConflictNotice = true
}, 1500)
}
}
]
});
const r = localStorage.getItem('tour-guide');
if (settingStore.first && !r && !isMobile()) {
tour.start();
}
}, 500)
watch(
() => articleData?.article?.id,
id => {
if (id) {
_nextTick(async () => {
const Shepherd = await loadJsLib('Shepherd', LIB_JS_URL.SHEPHERD)
const tour = new Shepherd.Tour(TourConfig)
tour.on('cancel', () => {
localStorage.setItem('tour-guide', '1')
})
tour.addStep({
id: 'step8',
text: '这里可以练习文章,只需要按下键盘上对应的按键即可,没有输入框!',
attachTo: {
element: '#article-content',
on: 'auto',
},
buttons: [
{
text: `关闭`,
action() {
settingStore.first = false
tour.next()
setTimeout(() => {
showConflictNotice = true
}, 1500)
},
},
],
})
const r = localStorage.getItem('tour-guide')
if (settingStore.first && !r && !isMobile()) {
tour.start()
}
}, 500)
}
}
})
)
watch(() => settingStore.$state, (n) => {
initAudio()
}, {immediate: true, deep: true})
watch(
() => settingStore.$state,
n => {
initAudio()
},
{ immediate: true, deep: true }
)
onMounted(() => {
if (store.sbook?.articles?.length) {
@@ -251,19 +274,22 @@ function savePracticeData(init = true, regenerate = true) {
regenerate && savePracticeData()
}
} else {
localStorage.setItem(PRACTICE_ARTICLE_CACHE.key, JSON.stringify({
version: PRACTICE_ARTICLE_CACHE.version,
val: {
practiceData: {
sectionIndex: 0,
sentenceIndex: 0,
wordIndex: 0,
stringIndex: 0,
id: articleData.article.id
localStorage.setItem(
PRACTICE_ARTICLE_CACHE.key,
JSON.stringify({
version: PRACTICE_ARTICLE_CACHE.version,
val: {
practiceData: {
sectionIndex: 0,
sentenceIndex: 0,
wordIndex: 0,
stringIndex: 0,
id: articleData.article.id,
},
statStoreData: statStore.$state,
},
statStoreData: statStore.$state,
}
}))
})
)
}
}
@@ -277,7 +303,7 @@ function setArticle(val: Article) {
articleData.article = val
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
articleData.article.sections.map((v, i) => {
v.map((w) => {
v.map(w => {
w.words.map(s => {
if (!ignoreList.includes(s.word.toLowerCase()) && s.type === PracticeArticleWordType.Word) {
statStore.total++
@@ -305,7 +331,7 @@ async function complete() {
}, 1500)
//todo 有空了改成实时保存
let data: Partial<Statistics> & { title: string, articleId: number } = {
let data: Partial<Statistics> & { title: string; articleId: number } = {
articleId: articleData.article.id,
title: articleData.article.title,
spend: statStore.spend,
@@ -321,7 +347,7 @@ async function complete() {
complete: store.sbook.complete,
title: articleData.article.title,
spend: Number(statStore.spend / 1000 / 60).toFixed(1),
s: ''
s: '',
}
reportData.s = `name:${store.sbook.name},title:${store.sbook.lastLearnIndex}.${data.title},spend:${Number(statStore.spend / 1000 / 60).toFixed(1)}`
window.umami?.track('endStudyArticle', reportData)
@@ -331,7 +357,8 @@ async function complete() {
}
if (AppEnv.CAN_REQUEST) {
let res = await addStat({
...data, type: 'article',
...data,
type: 'article',
complete: store.sdict.complete,
})
if (!res.success) {
@@ -379,7 +406,7 @@ function edit(val: Article = articleData.article) {
}
function wrong(word: Word) {
let temp = word.word.toLowerCase();
let temp = word.word.toLowerCase()
//过滤简单词
if (settingStore.ignoreSimpleWord) {
if (store.simpleWords.includes(temp)) return
@@ -396,7 +423,10 @@ function wrong(word: Word) {
}
function nextWord(word: ArticleWord) {
if (!store.allIgnoreWords.includes(word.word.toLowerCase()) && word.type === PracticeArticleWordType.Word) {
if (
!store.allIgnoreWords.includes(word.word.toLowerCase()) &&
word.type === PracticeArticleWordType.Word
) {
statStore.inputWordNumber++
}
}
@@ -425,10 +455,7 @@ const handlePlayNext = (nextArticle: Article) => {
}
}
const {
isArticleCollect,
toggleArticleCollect
} = useArticleOptions()
const { isArticleCollect, toggleArticleCollect } = useArticleOptions()
function play() {
typingArticleRef?.play()
@@ -473,7 +500,6 @@ useEvents([
[ShortcutKey.EditArticle, shortcutKeyEdit],
])
onMounted(() => {
document.addEventListener('visibilitychange', () => {
isFocus = !document.hidden
@@ -484,7 +510,7 @@ onUnmounted(() => {
timer && clearInterval(timer)
})
const {playSentenceAudio} = usePlaySentenceAudio()
const { playSentenceAudio } = usePlaySentenceAudio()
function play2(e) {
_nextTick(() => {
@@ -504,9 +530,7 @@ const currentPractice = computed(() => {
provide('currentPractice', currentPractice)
</script>
<template>
<PracticeLayout
v-loading="loading"
panelLeft="var(--article-panel-margin-left)">
<PracticeLayout v-loading="loading" panelLeft="var(--article-panel-margin-left)">
<template v-slot:practice>
<TypingArticle
ref="typingArticleRef"
@@ -520,11 +544,12 @@ provide('currentPractice', currentPractice)
/>
</template>
<template v-slot:panel>
<Panel :style="{width:'var(--article-panel-width)'}">
<Panel :style="{ width: 'var(--article-panel-width)' }">
<template v-slot:title>
<span>{{
store.sbook.name
}} ({{ store.sbook.lastLearnIndex + 1 }} / {{ articleData.list.length }})</span>
<span
>{{ store.sbook.name }} ({{ store.sbook.lastLearnIndex + 1 }} /
{{ articleData.list.length }})</span
>
</template>
<div class="panel-page-item pl-4">
<ArticleList
@@ -532,26 +557,30 @@ provide('currentPractice', currentPractice)
:static="false"
:show-translate="settingStore.translate"
@click="changeArticle"
:active-id="articleData.article.id??''"
:list="articleData.list ">
:active-id="articleData.article.id ?? ''"
:list="articleData.list"
>
</ArticleList>
</div>
</Panel>
</template>
<template v-slot:footer>
<div class="footer">
<Tooltip :title="settingStore.showToolbar?'收起':'展开'">
<Tooltip :title="settingStore.showToolbar ? '收起' : '展开'">
<IconFluentChevronLeft20Filled
@click="settingStore.showToolbar = !settingStore.showToolbar"
class="arrow"
:class="!settingStore.showToolbar && 'down'"
color="#999"/>
color="#999"
/>
</Tooltip>
<div class="bottom">
<div class="flex justify-between items-center gap-2">
<div class="stat">
<div class="row">
<div class="num">{{ currentPractice.length }}/{{ msToMinute(total(currentPractice, 'spend')) }}</div>
<div class="num">
{{ currentPractice.length }}/{{ msToMinute(total(currentPractice, 'spend')) }}
</div>
<div class="line"></div>
<div class="name">记录</div>
</div>
@@ -564,10 +593,12 @@ provide('currentPractice', currentPractice)
<div class="num center gap-1">
{{ statStore.total }}
<Tooltip>
<IconFluentQuestionCircle20Regular width="18"/>
<IconFluentQuestionCircle20Regular width="18" />
<template #reference>
<div>
统计词数{{ settingStore.ignoreSimpleWord ? '不包含' : '包含' }}简单词不包含已掌握
统计词数{{
settingStore.ignoreSimpleWord ? '不包含' : '包含'
}}简单词不包含已掌握
<div>简单词可在设置 -> 练习设置 -> 简单词过滤中修改</div>
</div>
</template>
@@ -587,31 +618,34 @@ provide('currentPractice', currentPractice)
></ArticleAudio>
<div class="flex flex-col items-center justify-center gap-1">
<div class="flex gap-2 center">
<SettingDialog type="article"/>
<SettingDialog type="article" />
<BaseIcon
:title="`下一句(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
@click="skip">
<IconFluentArrowBounce20Regular class="transform-rotate-180"/>
@click="skip"
>
<IconFluentArrowBounce20Regular class="transform-rotate-180" />
</BaseIcon>
<BaseIcon
:title="`播放当前句子(${settingStore.shortcutKeyMap[ShortcutKey.PlayWordPronunciation]})`"
@click="play">
<IconFluentReplay20Regular/>
@click="play"
>
<IconFluentReplay20Regular />
</BaseIcon>
<BaseIcon
@click="settingStore.dictation = !settingStore.dictation"
:title="`开关默写模式(${settingStore.shortcutKeyMap[ShortcutKey.ToggleDictation]})`"
>
<IconFluentEyeOff16Regular v-if="settingStore.dictation"/>
<IconFluentEye16Regular v-else/>
<IconFluentEyeOff16Regular v-if="settingStore.dictation" />
<IconFluentEye16Regular v-else />
</BaseIcon>
<BaseIcon
:title="`开关释义显示(${settingStore.shortcutKeyMap[ShortcutKey.ToggleShowTranslate]})`"
@click="settingStore.translate = !settingStore.translate">
<IconFluentTranslate16Regular v-if="settingStore.translate"/>
<IconFluentTranslateOff16Regular v-else/>
@click="settingStore.translate = !settingStore.translate"
>
<IconFluentTranslate16Regular v-if="settingStore.translate" />
<IconFluentTranslateOff16Regular v-else />
</BaseIcon>
<!-- <BaseIcon-->
@@ -621,8 +655,9 @@ provide('currentPractice', currentPractice)
<!-- />-->
<BaseIcon
@click="settingStore.showPanel = !settingStore.showPanel"
:title="`面板(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`">
<IconFluentTextListAbcUppercaseLtr20Regular/>
:title="`面板(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`"
>
<IconFluentTextListAbcUppercaseLtr20Regular />
</BaseIcon>
</div>
</div>
@@ -632,47 +667,33 @@ provide('currentPractice', currentPractice)
</template>
</PracticeLayout>
<EditSingleArticleModal
v-model="showEditArticle"
:article="editArticle"
@save="saveArticle"
/>
<EditSingleArticleModal v-model="showEditArticle" :article="editArticle" @save="saveArticle" />
<ConflictNotice v-if="showConflictNotice"/>
<ConflictNotice v-if="showConflictNotice" />
</template>
<style scoped lang="scss">
.footer {
width: var(--article-toolbar-width);
.bottom {
position: relative;
width: 100%;
box-sizing: border-box;
border-radius: .6rem;
background: var(--color-second);
padding: .5rem var(--space);
z-index: 2;
@apply relative w-full box-border rounded-lg bg-second shadow-lg z-2;
padding: 0.5rem var(--space);
border: 1px solid var(--color-item-border);
box-shadow: var(--shadow);
.stat {
margin-top: .5rem;
margin-top: 0.5rem;
display: flex;
justify-content: space-around;
gap: var(--stat-gap);
.row {
display: flex;
flex-direction: column;
align-items: center;
gap: .3rem;
color: gray;
@apply flex flex-col items-center gap-1 text-gray-500;
.num, .name {
.num,
.name {
word-break: keep-all;
padding: 0 .4rem;
padding: 0 0.4rem;
}
.line {
@@ -689,9 +710,9 @@ provide('currentPractice', currentPractice)
top: -40%;
left: 50%;
cursor: pointer;
transition: all .5s;
transition: all 0.5s;
transform: rotate(-90deg);
padding: .5rem;
padding: 0.5rem;
font-size: 1.2rem;
&.down {

View File

@@ -1,43 +1,51 @@
<script setup lang="ts">
import {Article, Sentence, TranslateEngine} from "@/types/types.ts";
import BaseButton from "@/components/BaseButton.vue";
import EditAbleText from "@/components/EditAbleText.vue";
import {getNetworkTranslate, getSentenceAllText, getSentenceAllTranslateText} from "@/hooks/translate.ts";
import {genArticleSectionData, splitCNArticle2, splitEnArticle2, usePlaySentenceAudio} from "@/hooks/article.ts";
import {_nextTick, _parseLRC, cloneDeep, last} from "@/utils";
import {defineAsyncComponent, watch} from "vue";
import Empty from "@/components/Empty.vue";
import { Article, Sentence, TranslateEngine } from '@/types/types.ts'
import BaseButton from '@/components/BaseButton.vue'
import EditAbleText from '@/components/EditAbleText.vue'
import {
getNetworkTranslate,
getSentenceAllText,
getSentenceAllTranslateText,
} from '@/hooks/translate.ts'
import {
genArticleSectionData,
splitCNArticle2,
splitEnArticle2,
usePlaySentenceAudio,
} from '@/hooks/article.ts'
import { _nextTick, _parseLRC, cloneDeep, last } from '@/utils'
import { defineAsyncComponent, watch } from 'vue'
import Empty from '@/components/Empty.vue'
import Toast from '@/components/base/toast/Toast.ts'
import * as Comparison from "string-comparison"
import BaseIcon from "@/components/BaseIcon.vue";
import {getDefaultArticle} from "@/types/func.ts";
import copy from "copy-to-clipboard";
import {Option, Select} from "@/components/base/select";
import Tooltip from "@/components/base/Tooltip.vue";
import InputNumber from "@/components/base/InputNumber.vue";
import {nanoid} from "nanoid";
import {update} from "idb-keyval";
import ArticleAudio from "@/pages/article/components/ArticleAudio.vue";
import BaseInput from "@/components/base/BaseInput.vue";
import Textarea from "@/components/base/Textarea.vue";
import {LOCAL_FILE_KEY} from "@/config/env.ts";
import PopConfirm from "@/components/PopConfirm.vue";
import * as Comparison from 'string-comparison'
import BaseIcon from '@/components/BaseIcon.vue'
import { getDefaultArticle } from '@/types/func.ts'
import copy from 'copy-to-clipboard'
import { Option, Select } from '@/components/base/select'
import Tooltip from '@/components/base/Tooltip.vue'
import InputNumber from '@/components/base/InputNumber.vue'
import { nanoid } from 'nanoid'
import { update } from 'idb-keyval'
import ArticleAudio from '@/pages/article/components/ArticleAudio.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import Textarea from '@/components/base/Textarea.vue'
import { LOCAL_FILE_KEY } from '@/config/env.ts'
import PopConfirm from '@/components/PopConfirm.vue'
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
interface IProps {
article?: Article,
article?: Article
type?: 'single' | 'batch'
}
const props = withDefaults(defineProps<IProps>(), {
article: () => getDefaultArticle(),
type: 'single'
type: 'single',
})
const emit = defineEmits<{
save: [val: Article],
save: [val: Article]
saveAndNext: [val: Article]
}>()
@@ -47,23 +55,30 @@ let failCount = $ref(0)
let textareaRef = $ref<HTMLTextAreaElement>()
const TranslateEngineOptions = [
// {value: 'youdao', label: '有道'},
{value: 'baidu', label: '百度'},
{ value: 'baidu', label: '百度' },
]
let editArticle = $ref<Article>(getDefaultArticle())
watch(() => props.article, val => {
editArticle = getDefaultArticle(val)
progress = 0
failCount = 0
apply(false)
}, {immediate: true})
watch(
() => props.article,
val => {
editArticle = getDefaultArticle(val)
progress = 0
failCount = 0
apply(false)
},
{ immediate: true }
)
watch(() => editArticle.text, (s) => {
if (!s.trim()) {
editArticle.sections = []
watch(
() => editArticle.text,
s => {
if (!s.trim()) {
editArticle.sections = []
}
}
})
)
function apply(isHandle: boolean = true) {
let text = editArticle.text.trim()
@@ -141,11 +156,13 @@ function save(option: 'save' | 'saveAndNext') {
return resolve(false)
}
editArticle.lrcPosition = editArticle.sections.map(v => {
return v.map((w, j) => {
return w.audioPosition ?? []
editArticle.lrcPosition = editArticle.sections
.map(v => {
return v.map((w, j) => {
return w.audioPosition ?? []
})
})
}).flat()
.flat()
console.log(editArticle)
@@ -161,7 +178,7 @@ function save(option: 'save' | 'saveAndNext') {
}
//不知道为什么直接用editArticle取到是空的默认值
defineExpose({save, getEditArticle: () => cloneDeep(editArticle)})
defineExpose({ save, getEditArticle: () => cloneDeep(editArticle) })
// 处理音频文件上传
async function handleAudioChange(e: any) {
@@ -172,7 +189,7 @@ async function handleAudioChange(e: any) {
file: uploadFile,
}
//把文件存到indexDB
await update(LOCAL_FILE_KEY, (val) => {
await update(LOCAL_FILE_KEY, val => {
if (val) val.push(data)
else val = [data]
return val
@@ -192,32 +209,34 @@ function handleChange(e: any) {
if (!uploadFile) return
// 读取文件内容
let reader = new FileReader();
reader.readAsText(uploadFile, 'UTF-8');
let reader = new FileReader()
reader.readAsText(uploadFile, 'UTF-8')
reader.onload = function (e) {
let lrc: string = e.target.result as string;
let lrc: string = e.target.result as string
console.log(lrc)
if (lrc.trim()) {
let lrcList = _parseLRC(lrc)
console.log('lrcList', lrcList)
if (lrcList.length) {
editArticle.lrcPosition = editArticle.sections.map((v, i) => {
return v.map((w, j) => {
for (let k = 0; k < lrcList.length; k++) {
let s = lrcList[k]
let d = Comparison.default.cosine.similarity(w.text, s.text)
d = Comparison.default.levenshtein.similarity(w.text, s.text)
d = Comparison.default.longestCommonSubsequence.similarity(w.text, s.text)
// d = Comparison.default.metricLcs.similarity(w.text, s.text)
// console.log(w.text, s.text, d)
if (d >= 0.8) {
w.audioPosition = [s.start, s.end ?? -1]
break
editArticle.lrcPosition = editArticle.sections
.map((v, i) => {
return v.map((w, j) => {
for (let k = 0; k < lrcList.length; k++) {
let s = lrcList[k]
let d = Comparison.default.cosine.similarity(w.text, s.text)
d = Comparison.default.levenshtein.similarity(w.text, s.text)
d = Comparison.default.longestCommonSubsequence.similarity(w.text, s.text)
// d = Comparison.default.metricLcs.similarity(w.text, s.text)
// console.log(w.text, s.text, d)
if (d >= 0.8) {
w.audioPosition = [s.start, s.end ?? -1]
break
}
}
}
return w.audioPosition ?? []
return w.audioPosition ?? []
})
})
}).flat()
.flat()
Toast.success('LRC文件解析成功')
}
@@ -238,12 +257,15 @@ let sentenceAudioRef = $ref<HTMLAudioElement>()
let audioRef = $ref<HTMLAudioElement>()
let nameListRef = $ref<string[]>([])
watch(() => showNameDialog, (v) => {
if (v) {
nameListRef = cloneDeep(Array.isArray(editArticle.nameList) ? editArticle.nameList : [])
nameListRef.push('')
watch(
() => showNameDialog,
v => {
if (v) {
nameListRef = cloneDeep(Array.isArray(editArticle.nameList) ? editArticle.nameList : [])
nameListRef.push('')
}
}
})
)
function addName() {
nameListRef.push('')
@@ -287,7 +309,10 @@ function recordStart() {
sentenceAudioRef.play()
}
editSentence.audioPosition[0] = Number(sentenceAudioRef.currentTime.toFixed(2))
if (editSentence.audioPosition[0] > editSentence.audioPosition[1] && editSentence.audioPosition[1] !== 0) {
if (
editSentence.audioPosition[0] > editSentence.audioPosition[1] &&
editSentence.audioPosition[1] !== 0
) {
editSentence.audioPosition[1] = editSentence.audioPosition[0]
}
}
@@ -299,12 +324,14 @@ function recordEnd() {
editSentence.audioPosition[1] = Number(sentenceAudioRef.currentTime.toFixed(2))
}
const {playSentenceAudio} = usePlaySentenceAudio()
const { playSentenceAudio } = usePlaySentenceAudio()
function saveLrcPosition() {
// showEditAudioDialog = false
currentSentence.audioPosition = cloneDeep(editSentence.audioPosition)
editArticle.lrcPosition = editArticle.sections.map((v, i) => v.map((w, j) => (w.audioPosition ?? []))).flat()
editArticle.lrcPosition = editArticle.sections
.map((v, i) => v.map((w, j) => w.audioPosition ?? []))
.flat()
}
function jumpAudio(time: number) {
@@ -347,7 +374,6 @@ function minusStartTime(val: Sentence) {
if (val.audioPosition[0] <= 0) return
val.audioPosition[0] = Number((val.audioPosition[0] - 0.3).toFixed(2))
}
</script>
<template>
@@ -358,7 +384,7 @@ function minusStartTime(val: Sentence) {
<div class="shrink-0">标题</div>
<BaseInput
v-model="editArticle.title"
:disabled="![100,0].includes(progress)"
:disabled="![100, 0].includes(progress)"
type="text"
placeholder="请填写原文标题"
/>
@@ -368,28 +394,36 @@ function minusStartTime(val: Sentence) {
<Tooltip title="配置人名之后,在练习时自动忽略(可选,默认开启)">
<div @click="showNameDialog = true" class="center gap-1 cp">
<span>人名配置</span>
<IconFluentSettings20Regular/>
<IconFluentSettings20Regular />
</div>
</Tooltip>
</div>
<Textarea v-model="editArticle.text"
class="h-full"
:disabled="![100,0].includes(progress)"
placeholder="请复制原文"
:autosize="false"/>
<Textarea
v-model="editArticle.text"
class="h-full"
:disabled="![100, 0].includes(progress)"
placeholder="请复制原文"
:autosize="false"
/>
<div class="justify-end items-center flex">
<Tooltip>
<IconFluentQuestionCircle20Regular class="mr-3" width="20"/>
<IconFluentQuestionCircle20Regular class="mr-3" width="20" />
<template #reference>
<div>
<div class="mb-2">使用方法</div>
<ol class="py-0 pl-5 my-0 text-base color-main">
<li>复制原文然后分句</li>
<li>点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span
class="color-red font-bold"> </span> 手动编辑分句
<li>
点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span
class="color-red font-bold"
>
</span
>
手动编辑分句
</li>
<li>分句规则一行一句段落间空一行</li>
<li>修改完成后点击 <span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏
<li>
修改完成后点击 <span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏
</li>
</ol>
</div>
@@ -405,24 +439,25 @@ function minusStartTime(val: Sentence) {
<div class="shrink-0">标题</div>
<BaseInput
v-model="editArticle.titleTranslate"
:disabled="![100,0].includes(progress)"
:disabled="![100, 0].includes(progress)"
type="text"
placeholder="请填写翻译标题"
/>
</div>
<div class="">正文<span class="text-sm color-gray">一行一句段落间空一行</span></div>
<Textarea v-model="editArticle.textTranslate"
class="h-full"
:disabled="![100,0].includes(progress)"
placeholder="请填写翻译"
:autosize="false"/>
<Textarea
v-model="editArticle.textTranslate"
class="h-full"
:disabled="![100, 0].includes(progress)"
placeholder="请填写翻译"
:autosize="false"
/>
<div class="justify-between items-center flex">
<div class="flex gap-space items-center w-50">
<BaseButton @click="startNetworkTranslate"
:loading="progress!==0 && progress !== 100">翻译
<BaseButton @click="startNetworkTranslate" :loading="progress !== 0 && progress !== 100"
>翻译
</BaseButton>
<Select v-model="networkTranslateEngine"
>
<Select v-model="networkTranslateEngine">
<Option
v-for="item in TranslateEngineOptions"
:key="item.value"
@@ -434,18 +469,26 @@ function minusStartTime(val: Sentence) {
</div>
<div class="flex items-center">
<Tooltip>
<IconFluentQuestionCircle20Regular class="mr-3" width="20"/>
<IconFluentQuestionCircle20Regular class="mr-3" width="20" />
<template #reference>
<div>
<div class="mb-2">使用方法</div>
<ol class="py-0 pl-5 my-0 text-base color-black/60">
<li>复制译文如果没有请点击 <span class="color-red font-bold">翻译</span> 按钮</li>
<li>点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span
class="color-red font-bold"> </span>
<li>
复制译文如果没有请点击 <span class="color-red font-bold">翻译</span> 按钮
</li>
<li>
点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span
class="color-red font-bold"
>
</span
>
手动编辑分句
</li>
<li>分句规则一行一句段落间空一行</li>
<li>修改完成后点击 <span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏
<li>
修改完成后点击
<span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏
</li>
</ol>
</div>
@@ -460,38 +503,40 @@ function minusStartTime(val: Sentence) {
<div class="flex gap-2">
<div class="title">结果</div>
<div class="flex gap-2 flex-1 justify-end">
<ArticleAudio ref="audioRef" :article="editArticle" :autoplay="false"/>
<ArticleAudio ref="audioRef" :article="editArticle" :autoplay="false" />
</div>
</div>
<template v-if="editArticle?.sections?.length">
<div class="flex-1 overflow-auto flex flex-col">
<div class="flex justify-between bg-black/10 py-2 rounded-lt-md rounded-rt-md">
<div class="center flex-[7]">内容
<span class="text-sm color-black/70">均可编辑编辑后点击应用按钮会自动同步</span></div>
<div class="center flex-[7]">
内容(
<span class="text-sm color-gray-500">均可编辑编辑后点击应用按钮会自动同步</span>)
</div>
<div>|</div>
<div class="center flex-[3] gap-2">
<span>音频</span>
<BaseIcon title="音频管理" @click="showAudioDialog = true">
<IconIconParkOutlineAddMusic/>
<IconIconParkOutlineAddMusic />
</BaseIcon>
</div>
</div>
<div class="article-translate">
<div class="section rounded-md " v-for="(item,indexI) in editArticle.sections">
<div class="section rounded-md" v-for="(item, indexI) in editArticle.sections">
<div class="section-title text-lg font-bold">{{ indexI + 1 }}</div>
<div class="sentence" v-for="(sentence,indexJ) in item">
<div class="sentence" v-for="(sentence, indexJ) in item">
<div class="flex-[7]">
<EditAbleText
:disabled="![100,0].includes(progress)"
:disabled="![100, 0].includes(progress)"
:value="sentence.text"
@save="(e:string) => saveSentenceText(sentence,e)"
@save="(e: string) => saveSentenceText(sentence, e)"
/>
<EditAbleText
class="text-lg!"
v-if="sentence.translate"
:disabled="![100,0].includes(progress)"
:disabled="![100, 0].includes(progress)"
:value="sentence.translate"
@save="(e:string) => saveSentenceTranslate(sentence,e)"
@save="(e: string) => saveSentenceTranslate(sentence, e)"
/>
</div>
<div class="flex-[2] flex justify-end gap-1 items-center">
@@ -500,45 +545,49 @@ function minusStartTime(val: Sentence) {
<div>{{ sentence.audioPosition?.[0] ?? 0 }}s</div>
<div class="flex gap-1">
<BaseIcon
@click="setStartTime(sentence,indexI,indexJ)"
:title="indexI === 0 && indexJ === 0 ?'设置开始时间':'使用前一句的结束时间'"
@click="setStartTime(sentence, indexI, indexJ)"
:title="
indexI === 0 && indexJ === 0 ? '设置开始时间' : '使用前一句的结束时间'
"
>
<IconFluentMyLocation20Regular v-if="indexI === 0 && indexJ === 0"/>
<IconFluentPaddingLeft20Regular v-else/>
<IconFluentMyLocation20Regular v-if="indexI === 0 && indexJ === 0" />
<IconFluentPaddingLeft20Regular v-else />
</BaseIcon>
<BaseIcon
@click="minusStartTime(sentence)"
title="减 0.3 秒"
>
<BaseIcon @click="minusStartTime(sentence)" title="减 0.3 秒">
-.3s
</BaseIcon>
</div>
</div>
<div>-</div>
<div class="flex flex-col items-center justify-center">
<div v-if="sentence.audioPosition?.[1] !== -1">{{ sentence.audioPosition?.[1] ?? 0 }}s</div>
<div v-else> 结束</div>
<BaseIcon
@click="setEndTime(sentence,indexI,indexJ)"
title="设置结束时间"
>
<IconFluentMyLocation20Regular/>
<div v-if="sentence.audioPosition?.[1] !== -1">
{{ sentence.audioPosition?.[1] ?? 0 }}s
</div>
<div v-else>结束</div>
<BaseIcon @click="setEndTime(sentence, indexI, indexJ)" title="设置结束时间">
<IconFluentMyLocation20Regular />
</BaseIcon>
</div>
</div>
<div class="flex flex-col">
<BaseIcon :icon="sentence.audioPosition?.length ? 'basil:edit-outline' : 'basil:add-outline'"
title="编辑音频对齐"
@click="handleShowEditAudioDialog(sentence,indexI,indexJ)">
<BaseIcon
:icon="
sentence.audioPosition?.length ? 'basil:edit-outline' : 'basil:add-outline'
"
title="编辑音频对齐"
@click="handleShowEditAudioDialog(sentence, indexI, indexJ)"
>
<IconFluentSpeakerEdit20Regular
v-if="sentence.audioPosition?.length && sentence.audioPosition[1]"/>
<IconFluentAddSquare20Regular v-else/>
v-if="sentence.audioPosition?.length && sentence.audioPosition[1]"
/>
<IconFluentAddSquare20Regular v-else />
</BaseIcon>
<BaseIcon
title="播放"
v-if="sentence.audioPosition?.length"
@click="playSentenceAudio(sentence,audioRef)">
<IconFluentPlay20Regular/>
@click="playSentenceAudio(sentence, audioRef)"
>
<IconFluentPlay20Regular />
</BaseIcon>
</div>
</div>
@@ -550,49 +599,58 @@ function minusStartTime(val: Sentence) {
<div class="status">
<span>状态</span>
<div class="warning" v-if="failCount">
<IconFluentShieldQuestion20Regular/>
<IconFluentShieldQuestion20Regular />
共有{{ failCount }}句没有翻译
</div>
<div class="success" v-else>
<IconFluentCheckmarkCircle16Regular/>
<IconFluentCheckmarkCircle16Regular />
翻译完成
</div>
</div>
<div>
<BaseButton @click="save('save')">保存</BaseButton>
<BaseButton v-if="type === 'batch'" @click="save('saveAndNext')">保存并添加下一篇</BaseButton>
<BaseButton v-if="type === 'batch'" @click="save('saveAndNext')"
>保存并添加下一篇</BaseButton
>
</div>
</div>
</template>
<Empty v-else text="没有译文对照~"/>
<Empty v-else text="没有译文对照~" />
</div>
<Dialog title="调整音频时间轴"
v-model="showEditAudioDialog"
:footer="true"
@close="showEditAudioDialog = false"
@ok="saveLrcPosition"
<Dialog
title="调整音频时间轴"
v-model="showEditAudioDialog"
:footer="true"
@close="showEditAudioDialog = false"
@ok="saveLrcPosition"
>
<div class="p-4 pt-0 color-main w-150 flex flex-col gap-2">
<div class="">
教程点击音频播放按钮当播放到句子开始时点击开始时间的 <span class="color-red">记录</span>
按钮当播放到句子结束时点击结束时间的 <span class="color-red">记录</span> 按钮最后再试听是否正确
教程点击音频播放按钮当播放到句子开始时点击开始时间的
<span class="color-red">记录</span> 按钮当播放到句子结束时点击结束时间的
<span class="color-red">记录</span> 按钮最后再试听是否正确
</div>
<ArticleAudio ref="sentenceAudioRef"
:article="editArticle"
:autoplay="false"
class="w-full"/>
<div class="flex items-center gap-2 justify-between mb-2" v-if="editSentence.audioPosition?.length">
<ArticleAudio
ref="sentenceAudioRef"
:article="editArticle"
:autoplay="false"
class="w-full"
/>
<div
class="flex items-center gap-2 justify-between mb-2"
v-if="editSentence.audioPosition?.length"
>
<div>{{ editSentence.text }}</div>
<div class="flex items-center gap-2 shrink-0">
<div>
<span>{{ editSentence.audioPosition?.[0] }}s</span>
<span v-if="editSentence.audioPosition?.[1] !== -1"> - {{ editSentence.audioPosition?.[1] }}s</span>
<span v-if="editSentence.audioPosition?.[1] !== -1">
- {{ editSentence.audioPosition?.[1] }}s</span
>
<span v-else> - 结束</span>
</div>
<BaseIcon
title="播放"
@click="playSentenceAudio(editSentence,sentenceAudioRef)">
<IconFluentPlay20Regular/>
<BaseIcon title="播放" @click="playSentenceAudio(editSentence, sentenceAudioRef)">
<IconFluentPlay20Regular />
</BaseIcon>
</div>
</div>
@@ -601,28 +659,36 @@ function minusStartTime(val: Sentence) {
<div>开始时间</div>
<div class="flex justify-between flex-1">
<div class="flex items-center gap-2">
<InputNumber v-model="editSentence.audioPosition[0]" :precision="2" :step="0.1"/>
<InputNumber v-model="editSentence.audioPosition[0]" :precision="2" :step="0.1" />
<BaseIcon
@click="jumpAudio(editSentence.audioPosition[0])"
:title='`跳转至${editSentence.audioPosition[0]}秒`'
:title="`跳转至${editSentence.audioPosition[0]}秒`"
>
<IconFluentMyLocation20Regular/>
<IconFluentMyLocation20Regular />
</BaseIcon>
<BaseIcon
v-if="preSentence"
@click="setPreEndTimeToCurrentStartTime"
:title="`使用前一句的结束时间:${preSentence?.audioPosition?.[1]||0}秒`"
:title="`使用前一句的结束时间:${preSentence?.audioPosition?.[1] || 0}秒`"
>
<IconFluentPaddingLeft20Regular/>
<IconFluentPaddingLeft20Regular />
</BaseIcon>
<BaseIcon
@click="editSentence.audioPosition[0] = Number((editSentence.audioPosition[0] - 0.3).toFixed(2))"
@click="
editSentence.audioPosition[0] = Number(
(editSentence.audioPosition[0] - 0.3).toFixed(2)
)
"
title="减少 0.3 秒"
>
-.3s
</BaseIcon>
<BaseIcon
@click="editSentence.audioPosition[0] = Number((editSentence.audioPosition[0] + 0.3).toFixed(2))"
@click="
editSentence.audioPosition[0] = Number(
(editSentence.audioPosition[0] + 0.3).toFixed(2)
)
"
title="增加 0.3 秒"
>
+.3s
@@ -635,9 +701,11 @@ function minusStartTime(val: Sentence) {
<div>结束时间</div>
<div class="flex justify-between flex-1">
<div class="flex items-center gap-2">
<InputNumber v-model="editSentence.audioPosition[1]" :precision="2" :step="0.1"/>
<InputNumber v-model="editSentence.audioPosition[1]" :precision="2" :step="0.1" />
<span></span>
<BaseButton size="small" @click="editSentence.audioPosition[1] = -1">结束</BaseButton>
<BaseButton size="small" @click="editSentence.audioPosition[1] = -1"
>结束</BaseButton
>
</div>
<BaseButton @click="recordEnd">记录</BaseButton>
</div>
@@ -646,40 +714,46 @@ function minusStartTime(val: Sentence) {
</div>
</Dialog>
<Dialog title="音频管理"
v-model="showAudioDialog"
:footer="false"
@close="showAudioDialog = false"
<Dialog
title="音频管理"
v-model="showAudioDialog"
:footer="false"
@close="showAudioDialog = false"
>
<div class="p-4 pt-0 color-main w-150 flex flex-col gap-2">
<div class="">
1上传的文件保存在本地电脑上更换电脑数据将丢失请及时备份数据
<br>
<br />
2LRC 文件用于解析句子对应音频的位置不一定准确后续可自行修改
</div>
<!-- <ArticleAudio ref="sentenceAudioRef" :article="editArticle" class="w-full"/>-->
<div class="upload relative">
<BaseButton>上传音频</BaseButton>
<input type="file"
accept="audio/*"
@change="handleAudioChange"
class="w-full h-full absolute left-0 top-0 opacity-0"/>
<input
type="file"
accept="audio/*"
@change="handleAudioChange"
class="w-full h-full absolute left-0 top-0 opacity-0"
/>
</div>
<div class="upload relative">
<BaseButton>上传 LRC 文件</BaseButton>
<input type="file"
accept=".lrc"
@change="handleChange"
class="w-full h-full absolute left-0 top-0 opacity-0"/>
<input
type="file"
accept=".lrc"
@change="handleChange"
class="w-full h-full absolute left-0 top-0 opacity-0"
/>
</div>
</div>
</Dialog>
<Dialog title="人名管理"
v-model="showNameDialog"
:footer="true"
@close="showNameDialog = false"
@ok="saveNameList"
<Dialog
title="人名管理"
v-model="showNameDialog"
:footer="true"
@close="showNameDialog = false"
@ok="saveNameList"
>
<div class="p-4 pt-0 color-main w-150 flex flex-col gap-3">
<div class="flex justify-between items-center">
@@ -688,17 +762,18 @@ function minusStartTime(val: Sentence) {
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2" v-for="(name,i) in nameListRef" :key="i">
<BaseInput v-model="nameListRef[i]"
placeholder="输入名称"
size="large"
:autofocus="i===nameListRef.length-1"/>
<div class="flex items-center gap-2" v-for="(name, i) in nameListRef" :key="i">
<BaseInput
v-model="nameListRef[i]"
placeholder="输入名称"
size="large"
:autofocus="i === nameListRef.length - 1"
/>
<BaseButton type="info" @click="removeName(i)">删除</BaseButton>
</div>
</div>
</div>
</Dialog>
</div>
</template>
@@ -782,7 +857,7 @@ function minusStartTime(val: Sentence) {
display: flex;
align-items: center;
font-size: 1.2rem;
color: #67C23A;
color: #67c23a;
}
}
}
@@ -807,7 +882,8 @@ function minusStartTime(val: Sentence) {
}
// 表单元素优化
.base-input, .base-textarea {
.base-input,
.base-textarea {
width: 100%;
font-size: 16px; // 防止iOS自动缩放
}
@@ -872,7 +948,8 @@ function minusStartTime(val: Sentence) {
font-size: 0.9rem;
}
.warning, .success {
.warning,
.success {
font-size: 1rem;
}
}

View File

@@ -22,7 +22,7 @@ import MigrateDialog from '@/components/MigrateDialog.vue'
import Log from '@/pages/setting/Log.vue'
import About from '@/components/About.vue'
import CommonSetting from '@/components/setting/CommonSetting.vue'
import ArticleSettting from '@/components/setting/ArticleSettting.vue'
import ArticleSetting from '@/components/setting/ArticleSetting.vue'
import WordSetting from '@/components/setting/WordSetting.vue'
import { PRACTICE_ARTICLE_CACHE, PRACTICE_WORD_CACHE } from '@/utils/cache.ts'
@@ -357,7 +357,7 @@ function transferOk() {
<div class="flex-1 overflow-y-auto overflow-x-hidden pr-4 content">
<CommonSetting v-if="tabIndex === 0" />
<WordSetting v-if="tabIndex === 1" />
<ArticleSettting v-if="tabIndex === 2" />
<ArticleSetting v-if="tabIndex === 2" />
<div class="body" v-if="tabIndex === 3">
<div class="row">

View File

@@ -188,28 +188,15 @@ const progress = $computed(() => {
}
.bottom {
position: relative;
width: 100%;
box-sizing: border-box;
border-radius: 0.6rem;
background: var(--color-second);
@apply relative w-full box-border rounded-xl bg-second shadow-lg z-10;
padding: 0.2rem var(--space) calc(0.4rem + env(safe-area-inset-bottom, 0px)) var(--space);
border: 1px solid var(--color-item-border);
box-shadow: var(--shadow);
z-index: 10;
.stat {
margin-top: 0.5rem;
display: flex;
justify-content: space-around;
gap: var(--stat-gap);
@apply flex justify-around gap-[var(--stat-gap)] mt-2;
.row {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
color: gray;
@apply flex flex-col items-center gap-1 text-gray;
.line {
height: 1px;

View File

@@ -723,7 +723,7 @@ useEvents([
<style scoped lang="scss">
.dictation {
border-bottom: 2px solid black;
border-bottom: 2px solid gray;
}
.typing-word {

View File

@@ -7,6 +7,7 @@ export default defineConfig({
'bg-second': 'bg-[var(--color-second)]',
'bg-third': 'bg-[var(--color-third)]',
'bg-fourth': 'bg-[var(--color-fourth)]',
'bg-fifth': 'bg-[var(--color-fifth)]',
'bg-card-active': 'bg-[var(--color-card-active)]',
'bg-item': 'bg-[var(--color-item-bg)]',
'bg-reverse-white': 'bg-[var(--color-reverse-white)]',