feat: 修改文章编辑器

This commit is contained in:
zyronon
2025-03-23 02:57:59 +08:00
parent 47d4958a6a
commit 082532bca9
22 changed files with 1666 additions and 362 deletions

View File

@@ -11,6 +11,8 @@ import Tooltip from "@/pages/pc/components/Tooltip.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
import {useArticleOptions} from "@/hooks/dict.ts";
import audio from '/public/sound/article/nce2-1/1.mp3'
import {emitter} from "@/utils/eventBus.ts";
const statisticsStore = usePracticeStore()
const settingStore = useSettingStore()
@@ -55,86 +57,90 @@ onUnmounted(() => {
<template>
<div class="footer " :class="!settingStore.showToolbar && 'hide'">
<div class="bottom">
<div class="flex gap-2">
<el-progress
class="flex-1"
:percentage="progress"
:stroke-width="8"
:show-text="false"/>
</div>
<div class="flex justify-between">
<div class="stat">
<div class="row">
<div class="num">{{ speedMinute }}分钟</div>
<div class="line"></div>
<div class="name">时间</div>
</div>
<div class="row">
<div class="num">{{ statisticsStore.total }}</div>
<div class="line"></div>
<div class="name">单词总数</div>
</div>
<div class="row">
<div class="num">{{ format(statisticsStore.inputWordNumber, '', 0) }}</div>
<div class="line"></div>
<div class="name">输入数</div>
</div>
<div class="row">
<div class="num">{{ format(statisticsStore.wrong, '', 0) }}</div>
<div class="line"></div>
<div class="name">错误数</div>
</div>
<div class="row">
<div class="num">{{ format(statisticsStore.correctRate, '%') }}</div>
<div class="line"></div>
<div class="name">正确率</div>
</div>
</div>
<div class="flex gap-3 center">
<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>
<div>
<el-progress
class="flex-1"
:percentage="progress"
:stroke-width="8"
:show-text="false"/>
<div class="stat">
<div class="row">
<div class="num">{{ speedMinute }}分钟</div>
<div class="line"></div>
<div class="name">时间</div>
</div>
<div class="row">
<div class="num">{{ statisticsStore.total }}</div>
<div class="line"></div>
<div class="name">单词总数</div>
</div>
<div class="row">
<div class="num">{{ format(statisticsStore.inputWordNumber, '', 0) }}</div>
<div class="line"></div>
<div class="name">输入数</div>
</div>
<div class="row">
<div class="num">{{ format(statisticsStore.wrong, '', 0) }}</div>
<div class="line"></div>
<div class="name">错误数</div>
</div>
<div class="row">
<div class="num">{{ format(statisticsStore.correctRate, '%') }}</div>
<div class="line"></div>
<div class="name">正确率</div>
</div>
</div>
</div>
<div>
<audio :src="audio" controls></audio>
<div class="flex gap-3 center">
<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/>
<TranslateSetting/>
<VolumeSetting/>
<VolumeSetting/>
<BaseIcon
:title="`编辑(${settingStore.shortcutKeyMap[ShortcutKey.EditArticle]})`"
icon="tabler:edit"
@click="emit('edit',)"
/>
<BaseIcon
:title="`编辑(${settingStore.shortcutKeyMap[ShortcutKey.EditArticle]})`"
icon="tabler:edit"
@click="emitter.emit(ShortcutKey.EditArticle)"
/>
<BaseIcon
v-if="!isArticleCollect()"
class="collect"
@click="toggleArticleCollect()"
:title="`收藏(${settingStore.shortcutKeyMap[ShortcutKey.ToggleCollect]})`"
icon="ph:star"/>
<BaseIcon
v-else
class="fill"
@click="toggleArticleCollect()"
:title="`取消收藏(${settingStore.shortcutKeyMap[ShortcutKey.ToggleCollect]})`"
icon="ph:star-fill"/>
<BaseIcon
:title="`下一句(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
icon="icon-park-outline:go-ahead"
@click="emit('over')"/>
<BaseIcon
v-if="!isArticleCollect()"
class="collect"
@click="toggleArticleCollect()"
:title="`收藏(${settingStore.shortcutKeyMap[ShortcutKey.ToggleCollect]})`"
icon="ph:star"/>
<BaseIcon
v-else
class="fill"
@click="toggleArticleCollect()"
:title="`取消收藏(${settingStore.shortcutKeyMap[ShortcutKey.ToggleCollect]})`"
icon="ph:star-fill"/>
<BaseIcon
:title="`下一句(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
icon="icon-park-outline:go-ahead"
@click="emit('over')"/>
<BaseIcon
@click="settingStore.showPanel = !settingStore.showPanel"
:title="`面板(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`"
icon="tdesign:menu-unfold"/>
<BaseIcon
@click="settingStore.showPanel = !settingStore.showPanel"
:title="`面板(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`"
icon="tdesign:menu-unfold"/>
</div>
</div>
</div>
</div>

View File

@@ -1,14 +1,9 @@
<script setup lang="ts">
import {useBaseStore} from "@/stores/base.ts";
import DictListPanel from "@/pages/pc/components/DictListPanel.vue";
import {Icon} from '@iconify/vue'
import {ActivityCalendar} from "vue-activity-calendar";
import "vue-activity-calendar/style.css";
import {useRouter} from "vue-router";
import BaseIcon from "@/components/BaseIcon.vue";
import DictList from "@/pages/pc/components/list/DictList.vue";
import {enArticle} from "@/assets/dictionary.ts";
import {DictType} from "@/types.ts";
import BasePage from "@/pages/pc/components/BasePage.vue";
const base = useBaseStore()

View File

@@ -53,7 +53,6 @@ function toggle() {
<div
v-else
class="text"
:style="`font-size: 1rem;`"
@click="toggle">
{{ value }}
</div>

View File

@@ -5,15 +5,13 @@ import {useSettingStore} from "@/stores/setting.ts";
import {getAudioFileUrl, useChangeAllSound, usePlayAudio, useWatchAllSound} from "@/hooks/sound.ts";
import {getShortcutKey, useDisableEventListener, useEventListener} from "@/hooks/event.ts";
import {cloneDeep} from "lodash-es";
import {DefaultShortcutKeyMap, Dict, DictType, ShortcutKey} from "@/types.ts";
import {DefaultShortcutKeyMap, 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 {useBaseStore} from "@/stores/base.ts";
import {saveAs} from "file-saver";
import {checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting, shakeCommonDict} from "@/utils";
import {dayjs} from "element-plus";
import {GITHUB} from "@/config/ENV.ts";

View File

@@ -11,13 +11,17 @@ import {
renewSectionTexts,
renewSectionTranslates
} from "@/hooks/translate.ts";
import * as copy from "copy-to-clipboard";
import {MessageBox} from "@/utils/MessageBox.tsx";
import {getSplitTranslateText} from "@/hooks/article.ts";
import {cloneDeep} from "lodash-es";
import {watch} from "vue";
import {watch, ref} from "vue";
import Empty from "@/components/Empty.vue";
import {UploadProps, UploadUserFile} from "element-plus";
import {_copy, _parseLRC} from "@/utils";
import * as Comparison from "string-comparison"
import audio from '/public/sound/article/nce2-1/1.mp3'
import BaseIcon from "@/components/BaseIcon.vue";
interface IProps {
article?: Article,
@@ -70,6 +74,12 @@ watch(() => props.article, val => {
// console.log('ar', article)
}, {immediate: true})
watch(() => editArticle.text, (s) => {
if (!s.trim()) {
editArticle.sections = []
}
})
function renewSections() {
if (editArticle.text.trim()) {
renewSectionTexts(editArticle)
@@ -136,7 +146,6 @@ async function startNetworkTranslate() {
return ElMessage.error('请填写正文!')
}
renewSectionTexts(editArticle)
editArticle.textNetworkTranslate = ''
//注意!!!
//这里需要用异步因为watch了article.networkTranslate改变networkTranslate了之后会重新设置article.sections
//导致getNetworkTranslate里面拿到的article.sections是废弃的值
@@ -145,8 +154,6 @@ async function startNetworkTranslate() {
progress = v
})
failCount = 0
copy(JSON.stringify(editArticle.sections))
})
}
@@ -230,61 +237,147 @@ function save(option: 'save' | 'saveAndNext') {
//不知道为什么直接用editArticle取到是空的默认值
defineExpose({save, getEditArticle: () => cloneDeep(editArticle)})
const fileList = ref<UploadUserFile[]>([])
const handleExceed: UploadProps['onExceed'] = (files, uploadFiles) => {
ElMessage.warning(
`The limit is 3, you selected ${files.length} files this time, add up to ${
files.length + uploadFiles.length
} totally`
)
}
const handleChange: UploadProps['onChange'] = (uploadFile, uploadFiles) => {
console.log(1)
fileList.value.push({
name: uploadFile.name,
url: uploadFile.url,
})
}
function test() {
let lrc = `[00:00.00]Lesson 1 A Private Conversation
[00:04.35]First listen and then answer the question.
[00:09.26]Why did the writer complain to the people behind him?
[00:14.60]Last week I went to the theatre.
[00:19.15]I had a very good seat.
[00:22.03]The play was very interesting.
[00:24.59]I did not enjoy it.
[00:27.26]A young man and a young woman were sitting behind me.
[00:31.65]They were talking loudly.
[00:34.43]I got very angry.
[00:36.98]I could not hear the actors.
[00:40.36]I turned round.I looked at the man and the woman angrily.
[00:46.59]They did not pay any attention.
[00:50.65]In the end,I could not bear it.
[00:54.57]I turned round again 'I can't hear a word!'I said angrily
[01:02.98]'It's none of your business,'the young man said rudely.
[01:08.85]'This is a private conversation!'
`
let lrcList = _parseLRC(lrc)
console.log(lrcList)
editArticle.sections.map((v, i) => {
v.map((w, j) => {
console.log(w)
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]
w.test = s.text
break
}
}
})
})
console.log(editArticle.sections.flat())
// console.log(cosine.similarity('Thanos', 'Rival'))
}
const a = new Audio(audio)
function play(sentence: Sentence) {
if (sentence.audioPosition?.length) {
let start = sentence.audioPosition[0];
a.currentTime = start
a.play()
let end = sentence.audioPosition?.[1]
if (end && end !== -1) {
setTimeout(() => {
a.pause()
}, (end - start) * 1000)
}
}
}
function s() {
}
</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="请填写原文正文"
>
<div class="row flex flex-col gap-2">
<div class="title">原文</div>
<div class="">标题</div>
<input
v-model="editArticle.title"
type="text"
class="base-input"
placeholder="请填写原文标题"
/>
<div class="">正文</div>
<textarea
v-model="editArticle.text"
:readonly="![100,0].includes(progress)"
type="textarea"
class="base-textarea"
placeholder="请复制原文"
>
</textarea>
<div class="justify-between items-center gap-2 flex">
<div class="text-base mb-1 color-white/60">请复制原文然后进行分句一行一句段落间空一行修改完成后点击 <span class="color-red font-bold">应用按钮</span> 同步到左侧结果
</div>
<el-button type="primary" @click="renewSections">应用</el-button>
</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 :value="TranslateType.custom">本地翻译</el-radio-button>
<el-radio-button :value="TranslateType.network">网络翻译</el-radio-button>
<el-radio-button :value="TranslateType.none">不需要翻译</el-radio-button>
</el-radio-group>
</div>
<textarea
v-model="editArticle.titleTranslate"
type="textarea"
class="base-textarea"
placeholder="请填写翻译标题"
>
</textarea>
<div class="row flex flex-col gap-2">
<div class="title">译文</div>
<div class="flex gap-2">
标题
</div>
<div class="item basic">
<div class="label">
<span>正文</span>
<div class="translate-item" v-if="editArticle.useTranslateType === TranslateType.network">
<input
v-model="editArticle.titleTranslate"
type="text"
class="base-input"
placeholder="请填写翻译标题"
/>
<div class="flex">
<span>正文</span>
</div>
<textarea
v-model="editArticle.textCustomTranslate"
:readonly="![100,0].includes(progress)"
@blur="onBlur"
@focus="onFocus"
type="textarea"
class="base-textarea"
placeholder="请填写翻译"
ref="textareaRef"
>
</textarea>
<div class="justify-between items-center gap-2 flex">
<div class="text-base mb-1 color-white/60">
请复制译文如果没有可点击网络翻译按钮进行翻译然后进行分句一行一句段落间空一行修改完成后点击 <span class="color-red font-bold">应用按钮</span> 同步到左侧结果
</div>
<div class="flex flex-col gap-2">
<div class="translate-item">
<el-progress :percentage="progress"
:duration="30"
:striped="progress !== 100"
@@ -292,7 +385,6 @@ defineExpose({save, getEditArticle: () => cloneDeep(editArticle)})
:stroke-width="8"
:show-text="true"/>
<el-select v-model="networkTranslateEngine"
style="width: 80rem;"
>
<el-option
v-for="item in TranslateEngineOptions"
@@ -305,55 +397,57 @@ defineExpose({save, getEditArticle: () => cloneDeep(editArticle)})
size="small"
@click="startNetworkTranslate"
:loading="progress!==0 && progress !== 100"
>开始翻译
>网络翻译
</BaseButton>
</div>
<div class="flex justify-end">
<el-button type="primary" @click="renewSections">应用</el-button>
</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>
<div class="title">结果</div>
<div class="center">正文译文与结果均可编辑修改一处另外两处会自动同步变动</div>
<div class="flex gap-2">
<BaseButton>导入音频</BaseButton>
<BaseButton @click="test">导入音频LRC</BaseButton>
<el-upload
v-model:file-list="fileList"
class="upload-demo"
:limit="1"
:on-change="handleChange"
>
<el-button type="primary">Click to upload</el-button>
<template #tip>
<div class="el-upload__tip">
jpg/png files with a size less than 500KB.
</div>
</template>
</el-upload>
<audio :src="audio" controls></audio>
</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 class="sentence flex justify-between" v-for="(sentence,indexJ) in item">
<div>
<EditAbleText
:value="sentence.text"
@save="(e:string) => saveSentenceText(sentence,e)"
/>
<EditAbleText
v-if="sentence.translate"
:value="sentence.translate"
@save="(e:string) => saveSentenceTranslate(sentence,e)"
/>
</div>
<div class="flex items-center gap-2">
<div class="text-base"> {{ sentence.audioPosition?.[0] }} - {{ sentence.audioPosition?.[1] }}</div>
<div>
<BaseIcon icon="hugeicons:play" @click="play(sentence)"/>
</div>
</div>
</div>
</div>
</div>
@@ -384,7 +478,7 @@ defineExpose({save, getEditArticle: () => cloneDeep(editArticle)})
@import "@/assets/css/style";
.content {
color: var(--color-font-1);
color: var(--color-article);
flex: 1;
display: flex;
gap: var(--space);
@@ -393,7 +487,7 @@ defineExpose({save, getEditArticle: () => cloneDeep(editArticle)})
}
.row {
flex: 10;
flex: 7;
width: 33%;
//height: 100%;
display: flex;
@@ -404,28 +498,21 @@ defineExpose({save, getEditArticle: () => cloneDeep(editArticle)})
flex: 1;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
&:nth-child(1) {
flex: 7;
&:nth-child(3) {
flex: 10;
}
.title {
font-weight: bold;
font-size: 1.4rem;
text-align: center;
}
.item {
width: 100%;
//margin-bottom: 10rem;
.label {
height: 3rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1rem;
}
}
.translate-item {
@@ -458,7 +545,8 @@ defineExpose({save, getEditArticle: () => cloneDeep(editArticle)})
}
.sentence {
margin-bottom: 1.2rem;
margin-bottom: 0.5rem;
line-height: 1.2;
&:last-child {
margin-bottom: 0;

View File

@@ -21,6 +21,7 @@ import {useWindowClick} from "@/hooks/event.ts";
import ArticleList from "@/pages/pc/components/list/ArticleList.vue";
import * as copy from "copy-to-clipboard";
import {getTranslateText} from "@/hooks/article.ts";
import {_copy} from "@/utils";
const emit = defineEmits<{
back: []
@@ -210,7 +211,7 @@ function exportData(val: {
type: string,
data?: Article
}) {
copy(JSON.stringify(cloneDeep(runtimeStore.editDict.articles).map(v => {
_copy(JSON.stringify(cloneDeep(runtimeStore.editDict.articles).map(v => {
delete v.sections
return v
})))

View File

@@ -1,7 +1,24 @@
<script setup lang="ts">
import {splitEnArticle} from "@/hooks/article.ts";
function test() {
let {newText, sections} = splitEnArticle(
`While it is yet to be seen what direction the second Trump administration will take globally in its China policy, VOA traveled to the main island of Mahe in Seychelles to look at how China and the U.S. have impacted the country, and how each is fairing in that competition for influence there.
`)
}
function test2() {
let {newText, sections} = splitEnArticle(
`
Last week I went to the theatre. I had a very good seat. The play was very interesting. I did not enjoy it. A young man and a young woman were sitting behind me. They were talking loudly. I got very angry. I could not hear the actors. I turned round. I looked at the man and the woman angrily. They did not pay any attention. In the end, I could not bear it. I turned round again. I cant hear a word! I said angrily.
Its none of your business, the young man said rudely. This is a private conversation!
`)
}
</script>
<template>
<div class="word flex justify-center ">
<div class="word flex center h-screen ">
<El-Button @click="test">test</El-Button>
<El-Button @click="test2">test2</El-Button>
</div>
</template>

View File

@@ -95,7 +95,7 @@ watch(() => settingStore.translate, () => {
function checkCursorPosition(a = sectionIndex, b = sentenceIndex, c = wordIndex) {
console.log('checkCursorPosition')
// console.log('checkCursorPosition')
_nextTick(() => {
let currentWord = jq(`.section:nth(${a}) .sentence:nth(${b}) .word:nth(${c})`)
// console.log(a, b, c, currentWord)
@@ -115,7 +115,7 @@ function checkCursorPosition(a = sectionIndex, b = sentenceIndex, c = wordIndex)
}
function checkTranslateLocation() {
console.log('checkTranslateLocation')
// console.log('checkTranslateLocation')
return new Promise<void>(resolve => {
_nextTick(() => {
let articleRect = articleWrapperRef.getBoundingClientRect()
@@ -129,7 +129,7 @@ function checkTranslateLocation() {
let translate: HTMLDivElement = document.querySelector(translateClassName)
translate.style.opacity = '1'
translate.style.top = wordRect.top - articleRect.top + 28 + 'px'
translate.style.top = wordRect.top - articleRect.top + 24 + 'px'
// @ts-ignore
translate.firstChild.style.width = wordRect.left - articleRect.left + 'px'
// console.log(word, wordRect.left - articleRect.left)
@@ -469,8 +469,6 @@ defineExpose({showSentence, play, del, hideSentence, nextSentence})
//color: rgb(22, 163, 74);
}
$article-width: 1000px;
.typing-article {
height: 100%;
width: 100%;
@@ -538,18 +536,15 @@ $article-width: 1000px;
}
&.tall {
line-height: 2.5;
line-height: 2.2;
}
.section {
margin-bottom: 1.2rem;
margin-bottom: 1.5rem;
.sentence {
transition: all .3s;
&:first-child {
padding-left: 3rem;
}
}
.word {
@@ -580,8 +575,8 @@ $article-width: 1000px;
left: 0;
height: 100%;
width: 100%;
font-size: 1.1rem;
line-height: 3.5;
font-size: 1.2rem;
line-height: 3.0;
letter-spacing: .2rem;
font-family: var(--zh-article-family);
font-weight: bold;