feat:improve the import and export functions

This commit is contained in:
zyronon
2025-09-14 18:55:43 +08:00
parent 3b5d8d94ed
commit 65bba3ea07
9 changed files with 187 additions and 12791 deletions

3
components.d.ts vendored
View File

@@ -23,7 +23,6 @@ declare module 'vue' {
IconFluentAdd20Filled: typeof import('~icons/fluent/add20-filled')['default']
IconFluentAdd20Regular: typeof import('~icons/fluent/add20-regular')['default']
IconFluentAddSquare20Regular: typeof import('~icons/fluent/add-square20-regular')['default']
IconFluentAppsList24Regular: typeof import('~icons/fluent/apps-list24-regular')['default']
IconFluentArrowBounce20Regular: typeof import('~icons/fluent/arrow-bounce20-regular')['default']
IconFluentArrowCircleRight16Regular: typeof import('~icons/fluent/arrow-circle-right16-regular')['default']
IconFluentArrowLeft16Regular: typeof import('~icons/fluent/arrow-left16-regular')['default']
@@ -45,7 +44,6 @@ declare module 'vue' {
IconFluentErrorCircle20Filled: typeof import('~icons/fluent/error-circle20-filled')['default']
IconFluentEye16Regular: typeof import('~icons/fluent/eye16-regular')['default']
IconFluentEyeOff16Regular: typeof import('~icons/fluent/eye-off16-regular')['default']
IconFluentHeadphones20Regular: typeof import('~icons/fluent/headphones20-regular')['default']
IconFluentHome20Regular: typeof import('~icons/fluent/home20-regular')['default']
IconFluentKeyboardLayoutFloat20Regular: typeof import('~icons/fluent/keyboard-layout-float20-regular')['default']
IconFluentMailEdit20Regular: typeof import('~icons/fluent/mail-edit20-regular')['default']
@@ -73,7 +71,6 @@ declare module 'vue' {
IconFluentWeatherMoon16Regular: typeof import('~icons/fluent/weather-moon16-regular')['default']
IconFluentWeatherSunny16Regular: typeof import('~icons/fluent/weather-sunny16-regular')['default']
IconIconParkOutlineAddMusic: typeof import('~icons/icon-park-outline/add-music')['default']
IconIconParkSolidAddMusic: typeof import('~icons/icon-park-solid/add-music')['default']
IconMaterialSymbolsMail: typeof import('~icons/material-symbols/mail')['default']
IconRiTwitterFill: typeof import('~icons/ri/twitter-fill')['default']
IconSimpleIconsGithub: typeof import('~icons/simple-icons/github')['default']

12721
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,6 @@
"dayjs": "^1.11.13",
"file-saver": "^2.0.5",
"idb-keyval": "^6.2.2",
"libarchive-wasm": "^1.2.0",
"md5": "^2.2.1",
"mitt": "^3.0.1",
"nanoid": "^5.1.5",

43
pnpm-lock.yaml generated
View File

@@ -87,6 +87,9 @@ importers:
'@types/file-saver':
specifier: ^2.0.7
version: 2.0.7
'@types/jszip':
specifier: ^3.4.1
version: 3.4.1
'@types/lodash-es':
specifier: ^4.17.12
version: 4.17.12
@@ -827,6 +830,10 @@ packages:
'@types/file-saver@2.0.7':
resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==}
'@types/jszip@3.4.1':
resolution: {integrity: sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A==}
deprecated: This is a stub types definition. jszip provides its own type definitions, so you do not need this installed.
'@types/lodash-es@4.17.12':
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
@@ -2265,6 +2272,9 @@ packages:
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immutable@5.1.3:
resolution: {integrity: sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==}
@@ -2496,6 +2506,9 @@ packages:
jstoxml@2.2.9:
resolution: {integrity: sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
just-debounce@1.1.0:
resolution: {integrity: sha512-qpcRocdkUmf+UTNBYx5w6dexX5J31AKK1OmPwH630a83DdVVUIngk55RSAiIGpQyoH0dlr872VHfPjnQnK1qDQ==}
@@ -2540,6 +2553,9 @@ packages:
libarchive-wasm@1.2.0:
resolution: {integrity: sha512-aunFn8oL9VwGRj+brRvdOv8BRUD4Ea1WxJW45IdiuXE2Vp/m/X+M1UxSU+yPzXfc1mPPC8AARaflg/CtF11u8g==}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
liftoff@3.1.0:
resolution: {integrity: sha512-DlIPlJUkCV0Ips2zf2pJP0unEoT1kwYhiiPUGF3s/jtxTCjziNLoiVVh+jqWOWeFi6mmwQ5fNxvAUyPad4Dfog==}
engines: {node: '>= 0.8'}
@@ -2841,6 +2857,9 @@ packages:
package-manager-detector@1.3.0:
resolution: {integrity: sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -3162,6 +3181,9 @@ packages:
resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==}
engines: {node: '>=0.10.0'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@@ -4404,6 +4426,10 @@ snapshots:
'@types/file-saver@2.0.7': {}
'@types/jszip@3.4.1':
dependencies:
jszip: 3.10.1
'@types/lodash-es@4.17.12':
dependencies:
'@types/lodash': 4.17.20
@@ -6200,6 +6226,8 @@ snapshots:
ieee754@1.2.1: {}
immediate@3.0.6: {}
immutable@5.1.3: {}
import-fresh@3.3.1:
@@ -6403,6 +6431,13 @@ snapshots:
jstoxml@2.2.9: {}
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
just-debounce@1.1.0: {}
kind-of@3.2.2:
@@ -6440,6 +6475,10 @@ snapshots:
libarchive-wasm@1.2.0: {}
lie@3.3.0:
dependencies:
immediate: 3.0.6
liftoff@3.1.0:
dependencies:
extend: 3.0.2
@@ -6791,6 +6830,8 @@ snapshots:
package-manager-detector@1.3.0: {}
pako@1.0.11: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -7128,6 +7169,8 @@ snapshots:
is-plain-object: 2.0.4
split-string: 3.1.0
setimmediate@1.0.5: {}
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0

View File

@@ -135,7 +135,7 @@ const {
<div class="right flex-[4] shrink-0 pl-4 overflow-auto">
<div v-if="selectArticle.id">
<div class="en-article-family title text-xl">
<div class="text-center text-2xl my-2" v-if="selectArticle.audioSrc">
<div class="text-center text-2xl my-2">
<ArticleAudio :article="selectArticle"></ArticleAudio>
</div>
<div class="text-center text-2xl">{{ selectArticle.title }}</div>

View File

@@ -171,7 +171,7 @@ async function handleAudioChange(e: any) {
let uploadFile = e.target?.files?.[0]
if (!uploadFile) return
let data = {
id: nanoid(6),
id: nanoid(),
file: uploadFile,
}
//把文件存到indexDB

View File

@@ -6,7 +6,14 @@ import {getShortcutKey, useEventListener} from "@/hooks/event.ts";
import {checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting, cloneDeep, shakeCommonDict} from "@/utils";
import {DefaultShortcutKeyMap, ShortcutKey} from "@/types/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 {
APP_NAME,
EXPORT_DATA_KEY,
LOCAL_FILE_KEY,
SAVE_DICT_KEY,
SAVE_SETTING_KEY,
SoundFileOptions
} from "@/utils/const.ts";
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
import {useBaseStore} from "@/stores/base.ts";
import {saveAs} from "file-saver";
@@ -23,6 +30,8 @@ import InputNumber from "@/pages/pc/components/base/InputNumber.vue";
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
import Textarea from "@/pages/pc/components/base/Textarea.vue";
import SettingItem from "@/pages/pc/setting/SettingItem.vue";
// import {ArchiveReader, libarchiveWasm} from "libarchive-wasm";
import {get, set} from "idb-keyval";
const emit = defineEmits<{
toggleDisabledDialogEscKey: [val: boolean]
@@ -66,7 +75,7 @@ watch(() => editShortcutKey, (newVal) => {
useEventListener('keydown', (e: KeyboardEvent) => {
if (!disabledDefaultKeyboardEvent) return
// 确保阻止浏览器默认行为
e.preventDefault()
e.stopPropagation()
@@ -85,11 +94,11 @@ useEventListener('keydown', (e: KeyboardEvent) => {
settingStore.shortcutKeyMap[editShortcutKey] = ''
} else {
// 忽略单独的修饰键
if (shortcutKey === 'Ctrl+' || shortcutKey === 'Alt+' || shortcutKey === 'Shift+' ||
if (shortcutKey === 'Ctrl+' || shortcutKey === 'Alt+' || shortcutKey === 'Shift+' ||
e.key === 'Control' || e.key === 'Alt' || e.key === 'Shift') {
return;
}
for (const [k, v] of Object.entries(settingStore.shortcutKeyMap)) {
if (v === shortcutKey && k !== editShortcutKey) {
settingStore.shortcutKeyMap[editShortcutKey] = DefaultShortcutKeyMap[editShortcutKey]
@@ -101,7 +110,6 @@ useEventListener('keydown', (e: KeyboardEvent) => {
}
})
function handleInputBlur() {
// 输入框失焦时结束编辑状态
editShortcutKey = ''
@@ -144,7 +152,7 @@ function getShortcutKeyName(key: string): string {
'ToggleConciseMode': '切换简洁模式',
'TogglePanel': '切换面板'
}
return shortcutKeyNameMap[key] || key
}
@@ -154,7 +162,24 @@ function resetShortcutKeyMap() {
Toast.success('恢复成功')
}
function exportData(notice = '导出成功!') {
async function loadJSZip() {
if (window.JSZip) return window.JSZip;
return new Promise((resolve, reject) => {
const script = document.createElement("script");
// script.src = "https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js";
script.src = "https://2study.top/libs/jszip.min.js";
script.onload = () => resolve(window.JSZip);
script.onerror = reject;
document.head.appendChild(script);
});
}
let exportLoading = $ref(false)
let importLoading = $ref(false)
async function exportData(notice = '导出成功!') {
exportLoading = true
const JSZip = await loadJSZip();
let data = {
version: EXPORT_DATA_KEY.version,
val: {
@@ -168,42 +193,94 @@ function exportData(notice = '导出成功!') {
}
}
}
let blob = new Blob([JSON.stringify(data)], {type: "text/plain;charset=utf-8"});
saveAs(blob, `${APP_NAME}-User-Data-${dayjs().format('YYYY-MM-DD HH-mm-ss')}.json`);
const zip = new JSZip();
zip.file("data.json", JSON.stringify(data));
const mp3 = zip.folder("mp3");
const allRecords = await get(LOCAL_FILE_KEY);
for (const rec of allRecords) {
mp3.file(rec.id + ".mp3", rec.file);
}
exportLoading = false
zip.generateAsync({type: "blob"}).then(function (content) {
saveAs(content, `${APP_NAME}-User-Data-${dayjs().format('YYYY-MM-DD HH-mm-ss')}.zip`);
});
Toast.success(notice)
}
function importData(e) {
let file = e.target.files[0]
if (!file) return
// no()
let reader = new FileReader();
reader.onload = function (v) {
let str: any = v.target.result;
if (str) {
let obj = {
version: -1,
val: {
setting: {},
dict: {},
}
}
try {
obj = JSON.parse(str)
let data = obj.val
let settingState = checkAndUpgradeSaveSetting(data.setting)
settingState.load = true
settingStore.setState(settingState)
let baseState = checkAndUpgradeSaveDict(data.dict)
baseState.load = true
store.setState(baseState)
Toast.success('导入成功!')
} catch (err) {
return Toast.error('导入失败!')
}
function importJson(str: string, notice: boolean = true) {
let obj = {
version: -1,
val: {
setting: {},
dict: {},
}
}
reader.readAsText(file);
try {
obj = JSON.parse(str)
let data = obj.val
let settingState = checkAndUpgradeSaveSetting(data.setting)
settingState.load = true
settingStore.setState(settingState)
let baseState = checkAndUpgradeSaveDict(data.dict)
baseState.load = true
store.setState(baseState)
notice && Toast.success('导入成功!')
} catch (err) {
return Toast.error('导入失败!')
}
}
async function importData(e) {
let file = e.target.files[0]
if (!file) return
if (file.name.endsWith(".json")) {
let reader = new FileReader();
reader.onload = function (v) {
let str: any = v.target.result;
if (str) {
importJson(str)
}
}
reader.readAsText(file);
} else if (file.name.endsWith(".zip")) {
try {
importLoading = true
const JSZip = await loadJSZip();
const zip = await JSZip.loadAsync(file);
const dataFile = zip.file("data.json");
if (!dataFile) {
return Toast.error("缺少 data.json导入失败");
}
const mp3Folder = zip.folder("mp3");
if (mp3Folder) {
const records: { id: string; file: Blob }[] = [];
for (const filename in zip.files) {
if (filename.startsWith("mp3/") && filename.endsWith(".mp3")) {
const entry = zip.file(filename);
if (!entry) continue;
const blob = await entry.async("blob");
const id = filename.replace(/^mp3\//, "").replace(/\.mp3$/, "");
records.push({ id, file: blob });
}
}
await set(LOCAL_FILE_KEY, records);
}
const str = await dataFile.async("string");
importJson(str, false)
Toast.success("导入成功!");
} catch (e) {
Toast.error("导入失败!");
} finally {
importLoading = false
}
} else {
Toast.error("不支持的文件类型");
}
}
function importOldData() {
@@ -299,9 +376,9 @@ function importOldData() {
v-model="simpleWords" :autosize="{minRows: 6, maxRows: 10}"/>
</SettingItem>
<!-- 音效-->
<!-- 音效-->
<!-- 音效-->
<!-- 音效-->
<!-- 音效-->
<!-- 音效-->
<div class="line"></div>
<SettingItem main-title="音效"/>
<SettingItem title="单词/句子发音口音">
@@ -354,9 +431,9 @@ function importOldData() {
</div>
<!-- 单词练习设置-->
<!-- 单词练习设置-->
<!-- 单词练习设置-->
<!-- 单词练习设置-->
<!-- 单词练习设置-->
<!-- 单词练习设置-->
<div v-if="tabIndex === 1">
<SettingItem title="练习模式">
<RadioGroup v-model="settingStore.wordPracticeMode" class="flex-col gap-0!">
@@ -401,9 +478,9 @@ function importOldData() {
</SettingItem>
<!-- 发音-->
<!-- 发音-->
<!-- 发音-->
<!-- 发音-->
<!-- 发音-->
<!-- 发音-->
<div class="line"></div>
<SettingItem mainTitle="音效"/>
<SettingItem title="自动发音">
@@ -419,9 +496,9 @@ function importOldData() {
</SettingItem>
<!-- 自动切换-->
<!-- 自动切换-->
<!-- 自动切换-->
<!-- 自动切换-->
<!-- 自动切换-->
<!-- 自动切换-->
<div class="line"></div>
<SettingItem mainTitle="自动切换"/>
<SettingItem title="自动切换下一个单词"
@@ -444,9 +521,9 @@ function importOldData() {
</SettingItem>
<!-- 字体设置-->
<!-- 字体设置-->
<!-- 字体设置-->
<!-- 字体设置-->
<!-- 字体设置-->
<!-- 字体设置-->
<div class="line"></div>
<SettingItem mainTitle="字体设置"/>
<SettingItem title="外语字体">
@@ -466,10 +543,9 @@ function importOldData() {
</div>
<!-- 文章练习设置-->
<!-- 文章练习设置-->
<!-- 文章练习设置-->
<!-- 文章练习设置-->
<!-- 文章练习设置-->
<!-- 文章练习设置-->
<div v-if="tabIndex === 2">
<!-- 发音-->
<!-- 发音-->
@@ -492,7 +568,6 @@ function importOldData() {
</div>
<div class="body" v-if="tabIndex === 3">
<div class="row">
<label class="main-title">功能</label>
@@ -503,7 +578,8 @@ function importOldData() {
<label class="item-title">{{ getShortcutKeyName(item[0]) }}</label>
<div class="wrapper" @click="editShortcutKey = item[0]">
<div class="set-key" v-if="editShortcutKey === item[0]">
<input ref="shortcutInput" :value="item[1]?item[1]:'未设置快捷键'" readonly type="text" @blur="handleInputBlur">
<input ref="shortcutInput" :value="item[1]?item[1]:'未设置快捷键'" readonly type="text"
@blur="handleInputBlur">
<span @click.stop="editShortcutKey = ''">按键盘进行设置<span
class="text-red!">设置完成点击这里</span></span>
</div>
@@ -527,7 +603,7 @@ function importOldData() {
<b class="text-red">仅保存在本地</b>如果您需要在不同的设备浏览器或者其他非官方部署上使用 {{ APP_NAME }}
您需要手动进行数据同步和保存
</div>
<BaseButton class="mt-3" @click="exportData()">导出数据</BaseButton>
<BaseButton :loading="exportLoading" class="mt-3" @click="exportData()">导出数据</BaseButton>
<div class="line my-3"></div>
@@ -535,9 +611,9 @@ function importOldData() {
</div>
<div class="flex gap-space mt-3">
<div class="import hvr-grow">
<BaseButton>导入数据</BaseButton>
<BaseButton :loading="importLoading">导入数据</BaseButton>
<input type="file"
accept="application/json"
accept="application/json,.zip,application/zip"
@change="importData">
</div>
<PopConfirm
@@ -656,7 +732,7 @@ function importOldData() {
background: var(--color-second);
color: var(--color-font-1);
}
}
}

View File

@@ -9,6 +9,7 @@ declare global {
umami: {
track(name: string, data?: any): void
},
JSZip: any,
__CURRENT_WORD_INFO__?: {
word: string,
input: string,
@@ -18,6 +19,7 @@ declare global {
}
}
console.json = function (v: any, space = 0) {
const json = JSON.stringify(
v,

View File

@@ -18,7 +18,7 @@ export const SAVE_SETTING_KEY = {
}
export const EXPORT_DATA_KEY = {
key: 'typing-word-export',
version: 1
version: 2
}
export const LOCAL_FILE_KEY = 'typing-word-files'