This commit is contained in:
Zyronon
2025-11-20 01:25:16 +08:00
parent 84ea2154a0
commit f8246c3255
16 changed files with 372 additions and 260 deletions

1
components.d.ts vendored
View File

@@ -109,6 +109,7 @@ declare module 'vue' {
IconSimpleIconsWechat: typeof import('~icons/simple-icons/wechat')['default']
IconStreamlineDiscountPercentCoupon: typeof import('~icons/streamline/discount-percent-coupon')['default']
IconSystemUiconsImport: typeof import('~icons/system-uicons/import')['default']
IconUiwAlipay: typeof import('~icons/uiw/alipay')['default']
InputNumber: typeof import('./src/components/base/InputNumber.vue')['default']
List: typeof import('./src/components/list/List.vue')['default']
Logo: typeof import('./src/components/Logo.vue')['default']

10
pnpm-lock.yaml generated
View File

@@ -102,6 +102,9 @@ importers:
'@iconify-json/system-uicons':
specifier: ^1.2.4
version: 1.2.4
'@iconify-json/uiw':
specifier: ^1.2.3
version: 1.2.3
'@types/file-saver':
specifier: ^2.0.7
version: 2.0.7
@@ -547,6 +550,9 @@ packages:
'@iconify-json/system-uicons@1.2.4':
resolution: {integrity: sha512-9WB9dmEm+TRCXI5Ml2IY8zQAPZES8euKxY0VOaf8D6E6ZaEr7ztO6DChMlGg7qWECs3m3FjFUqNgBx8ZpB+djw==}
'@iconify-json/uiw@1.2.3':
resolution: {integrity: sha512-6ThTlo2tXfgHiN6fOMMENnBLDjJc0H0cDdsjvxSqiFWsB24G+ES0/a+ousVd+/wB0KoUCNAKPNws33CU7m+zzA==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -4193,6 +4199,10 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/uiw@1.2.3':
dependencies:
'@iconify/types': 2.0.0
'@iconify/types@2.0.0': {}
'@iconify/utils@2.3.0':

View File

@@ -18,7 +18,7 @@
function loadIDBKeyval() {
return new Promise((resolve) => {
let script = document.createElement("script");
script.src = 'https://cdn.jsdelivr.net/npm/idb-keyval@6.2.2/dist/umd.js';
script.src = '/libs/idb-keyval.js';
script.onload = function () {
log("idb-keyval 加载完成");
resolve(window.idbKeyval);

View File

@@ -11,7 +11,7 @@ import {useRoute} from "vue-router";
import {DictId} from "@/types/types.ts";
import {APP_VERSION, AppEnv, LOCAL_FILE_KEY, Origin, SAVE_DICT_KEY, SAVE_SETTING_KEY} from "@/config/env.ts";
import {syncSetting} from "@/apis";
import {useUserStore} from "@/stores/auth.ts";
import {useUserStore} from "@/stores/user.ts";
import MigrateDialog from "@/pages/MigrateDialog.vue";
const store = useBaseStore()

View File

@@ -55,6 +55,19 @@ export function uploadImportData(data, onUploadProgress) {
headers: {
contentType: 'formdata',
},
timeout: 1000000000,
data,
onUploadProgress
})
}
export function upload(data, onUploadProgress) {
return axiosInstance({
url: 'file/upload',
method: 'post',
headers: {
contentType: 'formdata',
},
data,
onUploadProgress
})

View File

@@ -31,7 +31,7 @@ function parseSentence(sentence: string) {
// 1) 货币 + 数字($1,000.50 或 ¥200 或 €100.5
let m = rest.match(/^[\$¥€£]\d{1,3}(?:,\d{3})*(?:\.\d+)?%?/)
if (m) {
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number })
tokens.push({word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number})
i += m[0].length
continue
}
@@ -39,7 +39,7 @@ function parseSentence(sentence: string) {
// 2) 数字/小数/百分比100% 3.14 1,000.00
m = rest.match(/^\d{1,3}(?:,\d{3})*(?:\.\d+)?%?/)
if (m) {
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number })
tokens.push({word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number})
i += m[0].length
continue
}
@@ -47,7 +47,7 @@ function parseSentence(sentence: string) {
// 3) 带点缩写或多段缩写U.S. U.S.A. e.g. i.e. Ph.D.
m = rest.match(/^[A-Za-z]+(?:\.[A-Za-z]+)+\.?/)
if (m) {
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word })
tokens.push({word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word})
i += m[0].length
continue
}
@@ -55,7 +55,7 @@ function parseSentence(sentence: string) {
// 4) 单词(包含撇号/连字符,如 it's, o'clock, we'll, mother-in-law
m = rest.match(/^[A-Za-z0-9]+(?:[\'\-][A-Za-z0-9]+)*/)
if (m) {
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word })
tokens.push({word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word})
i += m[0].length
continue
}
@@ -63,13 +63,13 @@ function parseSentence(sentence: string) {
// 5) 其它可视符号(标点)——单字符处理(连续标点会被循环拆为单字符)
// 包括:.,!?;:"'()-[]{}<>/\\@#%^&*~`等非单词非空白字符
if (/[^\w\s]/.test(ch)) {
tokens.push({ word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol })
tokens.push({word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol})
i += 1
continue
}
// 6) 回退方案:把当前字符当作一个 token防止意外丢失
tokens.push({ word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol })
tokens.push({word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol})
i += 1
}
@@ -78,7 +78,7 @@ function parseSentence(sentence: string) {
const next = tokens[idx + 1]
const between = next ? sentence.slice(t.end, next.start) : sentence.slice(t.end)
const nextSpace = /\s/.test(between)
return getDefaultArticleWord({ word: t.word, nextSpace, type: t.type })
return getDefaultArticleWord({word: t.word, nextSpace, type: t.type})
})
return result
@@ -125,7 +125,8 @@ export function genArticleSectionData(article: Article): number {
try {
let s = translateList[i]
sList = s.split("\n")
} catch (e) {}
} catch (e) {
}
for (let j = 0; j < v.length; j++) {
let sentence = v[j]
@@ -294,7 +295,7 @@ export function splitCNArticle2(text: string): string {
// text = `上星期我去看戏。我的座位很好,戏很有意思,但我却无法欣赏。一青年男子与一青年女子坐在我的身后,大声地说着话。我非常生气,因为我听不见演员在说什么。我回过头去怒视着那一男一女,他们却毫不理会。最后,我忍不住了,又一次回过头去,生气地说:“我一个字也听不见了!”
// “不关你的事,”那男的毫不客气地说,“这是私人间的谈话!”`
}
const segmenterJa = new Intl.Segmenter("zh-CN", { granularity: "sentence" })
const segmenterJa = new Intl.Segmenter("zh-CN", {granularity: "sentence"})
let sectionTextList = text.replaceAll("\n\n", "`^`").replaceAll("\n", "").split("`^`")
@@ -323,10 +324,6 @@ export function splitCNArticle2(text: string): string {
return s
}
export function getTranslateText(article: Article) {
return article.textTranslate.split("\n\n").filter((v) => v)
}
export function usePlaySentenceAudio() {
const playWordAudio = usePlayWordAudio()
const settingStore = useSettingStore()

89
src/hooks/export.ts Normal file
View File

@@ -0,0 +1,89 @@
import { loadJsLib, shakeCommonDict } from "@/utils";
import {
APP_NAME,
APP_VERSION,
EXPORT_DATA_KEY,
LOCAL_FILE_KEY,
Origin,
PracticeSaveArticleKey,
PracticeSaveWordKey,
SAVE_DICT_KEY,
SAVE_SETTING_KEY
} from "@/config/env.ts";
import { get } from "idb-keyval";
import { saveAs } from "file-saver";
import dayjs from "dayjs";
import Toast from "@/components/base/toast/Toast.ts";
import { useBaseStore } from "@/stores/base.ts";
import { useSettingStore } from "@/stores/setting.ts";
import { ref } from "vue";
export function useExport() {
const store = useBaseStore()
const settingStore = useSettingStore()
let loading = ref(false)
async function exportData(notice = '导出成功!') {
if (loading.value) return
loading.value = true
const JSZip = await loadJsLib('JSZip', `${Origin}/libs/jszip.min.js`);
let data = {
version: EXPORT_DATA_KEY.version,
val: {
setting: {
version: SAVE_SETTING_KEY.version,
val: settingStore.$state
},
dict: {
version: SAVE_DICT_KEY.version,
val: shakeCommonDict(store.$state)
},
[PracticeSaveWordKey.key]: {
version: PracticeSaveWordKey.version,
val: {}
},
[PracticeSaveArticleKey.key]: {
version: PracticeSaveArticleKey.version,
val: {}
},
[APP_VERSION.key]: -1
}
}
let d = localStorage.getItem(PracticeSaveWordKey.key)
if (d) {
try {
data.val[PracticeSaveWordKey.key] = JSON.parse(d)
} catch (e) {
}
}
let d1 = localStorage.getItem(PracticeSaveArticleKey.key)
if (d1) {
try {
data.val[PracticeSaveArticleKey.key] = JSON.parse(d1)
} catch (e) {
}
}
let r = await get(APP_VERSION.key)
data.val[APP_VERSION.key] = r
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);
}
let content = await zip.generateAsync({type: "blob"})
saveAs(content, `${APP_NAME}-User-Data-${dayjs().format('YYYY-MM-DD HH-mm-ss')}.zip`);
notice && Toast.success(notice)
loading.value = false
return content
}
return {
loading,
exportData,
}
}

View File

@@ -1,12 +1,13 @@
import {useSettingStore} from "@/stores/setting.ts";
import { useSettingStore } from "@/stores/setting.ts";
type Theme = "light" | "dark";
// 获取系统主题
function getSystemTheme(): Theme {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
return 'dark';
} else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
return 'light';
return 'light';
}
return 'light'; // 默认浅色模式
}
@@ -18,18 +19,18 @@ function swapTheme(theme: Theme): Theme {
// 监听系统主题变化
function listenToSystemThemeChange(call: (theme: Theme) => void) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (e.matches) {
// console.log('系统已切换到深色模式');
call('dark');
}
});
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', e => {
if (e.matches) {
// console.log('系统已切换到浅色模式');
call('light');
}
});
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (e.matches) {
// console.log('系统已切换到深色模式');
call('dark');
}
});
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', e => {
if (e.matches) {
// console.log('系统已切换到浅色模式');
call('light');
}
});
}
export default function useTheme() {
@@ -38,10 +39,10 @@ export default function useTheme() {
// 开启监听系统主题变更,后期可以通过用户配置来决定是否开启
listenToSystemThemeChange((theme: Theme) => {
// 如果系统主题变更后和当前的主题一致,则不需要再重新切换
if(settingStore.theme === theme){
return;
if (settingStore.theme === theme) {
return;
}
settingStore.theme = theme;
setTheme(theme);
})
@@ -52,13 +53,13 @@ export default function useTheme() {
setTheme(settingStore.theme);
}
function setTheme(val:string) {
function setTheme(val: string) {
// auto模式下则通过查询系统主题来设置主题名称
document.documentElement.className = val === 'auto' ? getSystemTheme() : val;
}
// 获取当前具体的主题名称
function getTheme():Theme{
function getTheme(): Theme {
// auto模式下则通过查询系统主题来获取当前具体的主题名称
return settingStore.theme === 'auto' ? getSystemTheme() : settingStore.theme as Theme;
}

View File

@@ -1,6 +0,0 @@
export default function useMobile() {
if (/Mobi|Android|iPhone/i.test(navigator.userAgent)) {
return true
}
return false
}

View File

@@ -1,14 +1,14 @@
<script setup lang="ts">
import {nextTick, ref, watch} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
import {getAudioFileUrl, usePlayAudio} from "@/hooks/sound.ts";
import {getShortcutKey, useEventListener} from "@/hooks/event.ts";
import {checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting, cloneDeep, loadJsLib, shakeCommonDict} from "@/utils";
import {DefaultShortcutKeyMap, ShortcutKey, WordPracticeMode} from "@/types/types.ts";
import { nextTick, ref, watch } from "vue";
import { useSettingStore } from "@/stores/setting.ts";
import { getAudioFileUrl, usePlayAudio } from "@/hooks/sound.ts";
import { getShortcutKey, useEventListener } from "@/hooks/event.ts";
import { checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting, cloneDeep, loadJsLib, shakeCommonDict } from "@/utils";
import { DefaultShortcutKeyMap, ShortcutKey, WordPracticeMode } from "@/types/types.ts";
import BaseButton from "@/components/BaseButton.vue";
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
import {useBaseStore} from "@/stores/base.ts";
import {saveAs} from "file-saver";
import { useBaseStore } from "@/stores/base.ts";
import { saveAs } from "file-saver";
import {
APP_NAME, APP_VERSION, EMAIL,
EXPORT_DATA_KEY, GITHUB,
@@ -20,7 +20,7 @@ import {
import dayjs from "dayjs";
import BasePage from "@/components/BasePage.vue";
import Toast from '@/components/base/toast/Toast.ts'
import {Option, Select} from "@/components/base/select";
import { Option, Select } from "@/components/base/select";
import Switch from "@/components/base/Switch.vue";
import Slider from "@/components/base/Slider.vue";
import RadioGroup from "@/components/base/radio/RadioGroup.vue";
@@ -29,9 +29,10 @@ import InputNumber from "@/components/base/InputNumber.vue";
import PopConfirm from "@/components/PopConfirm.vue";
import Textarea from "@/components/base/Textarea.vue";
import SettingItem from "@/pages/setting/SettingItem.vue";
import {get, set} from "idb-keyval";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {useUserStore} from "@/stores/auth.ts";
import { get, set } from "idb-keyval";
import { useRuntimeStore } from "@/stores/runtime.ts";
import { useUserStore } from "@/stores/user.ts";
import { useExport } from "@/hooks/export.ts";
const emit = defineEmits<{
toggleDisabledDialogEscKey: [val: boolean]
@@ -98,7 +99,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
} else {
// 忽略单独的修饰键
if (shortcutKey === 'Ctrl+' || shortcutKey === 'Alt+' || shortcutKey === 'Shift+' ||
e.key === 'Control' || e.key === 'Alt' || e.key === 'Shift') {
e.key === 'Control' || e.key === 'Alt' || e.key === 'Shift') {
return;
}
@@ -168,65 +169,9 @@ function resetShortcutKeyMap() {
Toast.success('恢复成功')
}
let exportLoading = $ref(false)
let importLoading = $ref(false)
async function exportData(notice = '导出成功!') {
exportLoading = true
const JSZip = await loadJsLib('JSZip', `${Origin}/libs/jszip.min.js`);
let data = {
version: EXPORT_DATA_KEY.version,
val: {
setting: {
version: SAVE_SETTING_KEY.version,
val: settingStore.$state
},
dict: {
version: SAVE_DICT_KEY.version,
val: shakeCommonDict(store.$state)
},
[PracticeSaveWordKey.key]: {
version: PracticeSaveWordKey.version,
val: {}
},
[PracticeSaveArticleKey.key]: {
version: PracticeSaveArticleKey.version,
val: {}
},
[APP_VERSION.key]: -1
}
}
let d = localStorage.getItem(PracticeSaveWordKey.key)
if (d) {
try {
data.val[PracticeSaveWordKey.key] = JSON.parse(d)
} catch (e) {
}
}
let d1 = localStorage.getItem(PracticeSaveArticleKey.key)
if (d1) {
try {
data.val[PracticeSaveArticleKey.key] = JSON.parse(d1)
} catch (e) {
}
}
let r = await get(APP_VERSION.key)
data.val[APP_VERSION.key] = r
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)
}
const {loading: exportLoading, exportData} = useExport()
function importJson(str: string, notice: boolean = true) {
let obj = {
@@ -427,8 +372,8 @@ function importOldData() {
v-if="settingStore.ignoreSimpleWord"
>
<Textarea
placeholder="多个单词用英文逗号隔号"
v-model="simpleWords" :autosize="{minRows: 6, maxRows: 10}"/>
placeholder="多个单词用英文逗号隔号"
v-model="simpleWords" :autosize="{minRows: 6, maxRows: 10}"/>
</SettingItem>
<!-- 音效-->
@@ -456,16 +401,16 @@ function importOldData() {
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])"/>
:time="100"
@click="usePlayAudio(getAudioFileUrl(item.value)[0])"/>
</div>
</Option>
</Select>
@@ -583,16 +528,16 @@ function importOldData() {
<SettingItem mainTitle="字体设置"/>
<SettingItem title="外语字体">
<Slider
:min="10"
:max="100"
v-model="settingStore.fontSize.wordForeignFontSize"/>
:min="10"
:max="100"
v-model="settingStore.fontSize.wordForeignFontSize"/>
<span class="w-10 pl-5">{{ settingStore.fontSize.wordForeignFontSize }}px</span>
</SettingItem>
<SettingItem title="中文字体">
<Slider
:min="10"
:max="100"
v-model="settingStore.fontSize.wordTranslateFontSize"/>
:min="10"
:max="100"
v-model="settingStore.fontSize.wordTranslateFontSize"/>
<span class="w-10 pl-5">{{ settingStore.fontSize.wordTranslateFontSize }}px</span>
</SettingItem>
</div>
@@ -641,7 +586,7 @@ function importOldData() {
<input ref="shortcutInput" :value="item[1]?item[1]:'未设置快捷键'" readonly type="text"
@blur="handleInputBlur">
<span @click.stop="editShortcutKey = ''">按键盘进行设置<span
class="text-red!">设置完成点击这里</span></span>
class="text-red!">设置完成点击这里</span></span>
</div>
<div v-else>
<div v-if="item[1]">{{ item[1] }}</div>
@@ -678,8 +623,8 @@ function importOldData() {
@change="importData">
</div>
<PopConfirm
title="导入老版本数据前,请先备份当前数据,确定要导入老版本数据吗?"
@confirm="importOldData">
title="导入老版本数据前,请先备份当前数据,确定要导入老版本数据吗?"
@confirm="importOldData">
<BaseButton>老版本数据导入</BaseButton>
</PopConfirm>
</div>
@@ -706,7 +651,9 @@ function importOldData() {
<div class="mb-2">
<div>
<div>日期2025/11/14</div>
<div>内容新增文章练习时可跳过空格如果在单词的最后一位上不按空格直接输入下一个字母的话自动跳下一个单词 按空格也自动跳下一个单词</div>
<div>内容新增文章练习时可跳过空格如果在单词的最后一位上不按空格直接输入下一个字母的话自动跳下一个单词
按空格也自动跳下一个单词
</div>
</div>
</div>
</div>
@@ -841,10 +788,10 @@ function importOldData() {
<p v-if="userStore.user.phone" class="text-sm color-gray">{{ userStore.user.phone }}</p>
<BaseButton
@click="userStore.logout"
type="info"
class="mt-4"
:loading="userStore.isLoading"
@click="userStore.logout"
type="info"
class="mt-4"
:loading="userStore.isLoading"
>
退出登录
</BaseButton>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import {onMounted} from 'vue'
import {useUserStore} from '@/stores/auth.ts'
import {useUserStore} from '@/stores/user.ts'
import {useRouter} from 'vue-router'
import BaseInput from '@/components/base/BaseInput.vue'
import BasePage from "@/components/BasePage.vue";

View File

@@ -2,7 +2,7 @@
import BasePage from '@/components/BasePage.vue'
import BaseButton from '@/components/BaseButton.vue'
import {useRouter} from 'vue-router'
import {useUserStore} from '@/stores/auth.ts'
import {useUserStore} from '@/stores/user.ts'
import {User} from "@/apis/user.ts";
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
import Header from "@/components/Header.vue";

View File

@@ -1,23 +1,26 @@
<script setup lang="tsx">
import {onBeforeUnmount, onMounted} from 'vue'
import {useRoute} from 'vue-router'
import { onBeforeUnmount, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import BaseInput from "@/components/base/BaseInput.vue";
import BaseButton from "@/components/BaseButton.vue";
import {APP_NAME} from "@/config/env.ts";
import {useUserStore} from "@/stores/auth.ts";
import {loginApi, LoginParams, registerApi, resetPasswordApi} from "@/apis/user.ts";
import {accountRules, codeRules, passwordRules, phoneRules} from "@/utils/validation.ts";
import { APP_NAME } from "@/config/env.ts";
import { useUserStore } from "@/stores/user.ts";
import { loginApi, LoginParams, registerApi, resetPasswordApi } from "@/apis/user.ts";
import { accountRules, codeRules, passwordRules, phoneRules } from "@/utils/validation.ts";
import Toast from "@/components/base/toast/Toast.ts";
import FormItem from "@/components/base/form/FormItem.vue";
import Form from "@/components/base/form/Form.vue";
import Notice from "@/pages/user/Notice.vue";
import {FormInstance} from "@/components/base/form/types.ts";
import {PASSWORD_CONFIG, PHONE_CONFIG} from "@/config/auth.ts";
import {CodeType} from "@/types/types.ts";
import { FormInstance } from "@/components/base/form/types.ts";
import { PASSWORD_CONFIG, PHONE_CONFIG } from "@/config/auth.ts";
import { CodeType, ImportStatus } from "@/types/types.ts";
import Code from "@/pages/user/Code.vue";
import {isNewUser, useNav} from "@/utils";
import { isNewUser, useNav } from "@/utils";
import Header from "@/components/Header.vue";
import PopConfirm from "@/components/PopConfirm.vue";
import { useExport } from "@/hooks/export.ts";
import { getProgress, upload, uploadImportData } from "@/apis";
import { Exception } from "sass";
// 状态管理
const userStore = useUserStore()
@@ -34,8 +37,7 @@ let wechatQRUrl = $ref('https://open.weixin.qq.com/connect/qrcode/041GmMJM2wfM0w
let qrStatus = $ref<'idle' | 'scanned' | 'expired' | 'cancelled'>('idle')
let qrExpireTimer: ReturnType<typeof setTimeout> | null = null
let qrCheckInterval: ReturnType<typeof setInterval> | null = null
let waitForImportConfirmation = $ref(true)
let isImporting = $ref(true)
const QR_EXPIRE_TIME = 5 * 60 * 1000 // 5分钟过期
@@ -271,7 +273,7 @@ function cancelWechatLogin() {
// 初始化页面
onMounted(() => {
console.log('route.query', route.query)
// console.log('route.query', route.query)
if (route.query?.register) {
currentMode = 'register'
}
@@ -280,7 +282,52 @@ onMounted(() => {
// 组件卸载时清理定时器
onBeforeUnmount(() => {
clearQRTimers()
clearInterval(timer)
})
const {exportData} = useExport()
let waitForImportConfirmation = $ref(true)
let isImporting = $ref(false)
let reason = $ref('')
let timer = $ref(-1)
async function startSync() {
isImporting = true
reason = '导出数据中'
let res = await exportData('')
reason = '上传数据中'
let formData = new FormData()
formData.append('file', res, "example.zip")
uploadImportData(formData, progressEvent => {
let percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
reason = `上传进度(${percent}%)`
}).then((result: any) => {
if (result.success) {
reason = `上传完成; 正在解析中`
clearInterval(timer)
timer = setInterval(() => {
getProgress().then(r => {
if (r.success) {
if (r.data.status === ImportStatus.Success) {
reason = '同步完成'
clearInterval(timer)
} else if (r.data.status === ImportStatus.Success) {
reason = '同步失败'
} else {
reason = r.data.reason
}
}
})
}, 2000)
} else {
throw new Error('同步失败')
}
}).catch(error => {
Toast.error(error.message || '同步失败')
reason = error.message || '同步失败'
isImporting = false
});
}
</script>
<template>
@@ -297,28 +344,28 @@ onBeforeUnmount(() => {
<!-- Tab切换 -->
<div class="center gap-8 mb-6">
<div
class="center cp transition-colors"
:class="loginType === 'code' ? 'link font-medium' : 'text-gray-600'"
@click="loginType = 'code'"
class="center cp transition-colors"
:class="loginType === 'code' ? 'link font-medium' : 'text-gray-600'"
@click="loginType = 'code'"
>
<div>
<span>验证码登录</span>
<div
v-opacity="loginType === 'code'"
class="mt-1 h-0.5 bg-blue-600"
v-opacity="loginType === 'code'"
class="mt-1 h-0.5 bg-blue-600"
></div>
</div>
</div>
<div
class="center cp transition-colors"
:class="loginType === 'password' ? 'link font-medium' : 'text-gray-600'"
@click="loginType = 'password'"
class="center cp transition-colors"
:class="loginType === 'password' ? 'link font-medium' : 'text-gray-600'"
@click="loginType = 'password'"
>
<div>
<span>密码登录</span>
<div
v-opacity="loginType === 'password'"
class="mt-1 h-0.5 bg-blue-600"
v-opacity="loginType === 'password'"
class="mt-1 h-0.5 bg-blue-600"
></div>
</div>
</div>
@@ -326,10 +373,10 @@ onBeforeUnmount(() => {
<!-- 验证码登录表单 -->
<Form
v-if="loginType === 'code'"
ref="phoneLoginFormRef"
:rules="phoneLoginFormRules"
:model="phoneLoginForm">
v-if="loginType === 'code'"
ref="phoneLoginFormRef"
:rules="phoneLoginFormRules"
:model="phoneLoginForm">
<FormItem prop="phone">
<BaseInput v-model="phoneLoginForm.phone"
type="tel"
@@ -342,11 +389,11 @@ onBeforeUnmount(() => {
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="phoneLoginForm.code"
type="code"
size="large"
:max-length="PHONE_CONFIG.codeLength"
placeholder="请输入验证码"
v-model="phoneLoginForm.code"
type="code"
size="large"
:max-length="PHONE_CONFIG.codeLength"
placeholder="请输入验证码"
/>
<Code :validate-field="() => phoneLoginFormRef.validateField('phone')"
:type="CodeType.Login"
@@ -357,10 +404,10 @@ onBeforeUnmount(() => {
<!-- 密码登录表单 -->
<Form
v-else
ref="loginForm2Ref"
:rules="loginForm2Rules"
:model="loginForm2">
v-else
ref="loginForm2Ref"
:rules="loginForm2Rules"
:model="loginForm2">
<FormItem prop="account">
<BaseInput v-model="loginForm2.account"
type="email"
@@ -373,12 +420,12 @@ onBeforeUnmount(() => {
<FormItem prop="password">
<div class="flex gap-2">
<BaseInput
v-model="loginForm2.password"
type="password"
name="password"
autocomplete="current-password"
size="large"
placeholder="请输入密码"
v-model="loginForm2.password"
type="password"
name="password"
autocomplete="current-password"
size="large"
placeholder="请输入密码"
/>
</div>
</FormItem>
@@ -389,10 +436,10 @@ onBeforeUnmount(() => {
</Notice>
<BaseButton
class="w-full"
size="large"
:loading="loading"
@click="handleLogin"
class="w-full"
size="large"
:loading="loading"
@click="handleLogin"
>
登录
</BaseButton>
@@ -409,27 +456,27 @@ onBeforeUnmount(() => {
<Header @click="switchMode('login')" title="注册新账号"/>
<Form
ref="registerFormRef"
:rules="registerFormRules"
:model="registerForm">
ref="registerFormRef"
:rules="registerFormRules"
:model="registerForm">
<FormItem prop="account">
<BaseInput
v-model="registerForm.account"
type="tel"
name="username"
autocomplete="username"
size="large"
placeholder="请输入手机号/邮箱地址"
v-model="registerForm.account"
type="tel"
name="username"
autocomplete="username"
size="large"
placeholder="请输入手机号/邮箱地址"
/>
</FormItem>
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="registerForm.code"
type="code"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
v-model="registerForm.code"
type="code"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<Code :validate-field="() => registerFormRef.validateField('account')"
:type="CodeType.Register"
@@ -438,22 +485,22 @@ onBeforeUnmount(() => {
</FormItem>
<FormItem prop="password">
<BaseInput
v-model="registerForm.password"
type="password"
name="password"
autocomplete="current-password"
size="large"
:placeholder="`请设置密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
v-model="registerForm.password"
type="password"
name="password"
autocomplete="current-password"
size="large"
:placeholder="`请设置密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
/>
</FormItem>
<FormItem prop="confirmPassword">
<BaseInput
v-model="registerForm.confirmPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
placeholder="请再次输入密码"
v-model="registerForm.confirmPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
placeholder="请再次输入密码"
/>
</FormItem>
</Form>
@@ -461,10 +508,10 @@ onBeforeUnmount(() => {
<Notice/>
<BaseButton
class="w-full"
size="large"
:loading="loading"
@click="handleRegister"
class="w-full"
size="large"
:loading="loading"
@click="handleRegister"
>
注册
</BaseButton>
@@ -476,27 +523,27 @@ onBeforeUnmount(() => {
<Header @click="switchMode('login')" title="重置密码"/>
<Form
ref="forgotFormRef"
:rules="forgotFormRules"
:model="forgotForm">
ref="forgotFormRef"
:rules="forgotFormRules"
:model="forgotForm">
<FormItem prop="account">
<BaseInput
v-model="forgotForm.account"
type="tel"
name="username"
autocomplete="username"
size="large"
placeholder="请输入手机号/邮箱地址"
v-model="forgotForm.account"
type="tel"
name="username"
autocomplete="username"
size="large"
placeholder="请输入手机号/邮箱地址"
/>
</FormItem>
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="forgotForm.code"
type="code"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
v-model="forgotForm.code"
type="code"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<Code :validate-field="() => forgotFormRef.validateField('account')"
:type="CodeType.ResetPwd"
@@ -505,31 +552,31 @@ onBeforeUnmount(() => {
</FormItem>
<FormItem prop="newPassword">
<BaseInput
v-model="forgotForm.newPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
v-model="forgotForm.newPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
/>
</FormItem>
<FormItem prop="confirmPassword">
<BaseInput
v-model="forgotForm.confirmPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
placeholder="请再次输入新密码"
v-model="forgotForm.confirmPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
placeholder="请再次输入新密码"
/>
</FormItem>
</Form>
<BaseButton
class="w-full mt-2"
size="large"
:loading="loading"
@click="handleForgotPassword"
class="w-full mt-2"
size="large"
:loading="loading"
@click="handleForgotPassword"
>
重置密码
</BaseButton>
@@ -540,16 +587,16 @@ onBeforeUnmount(() => {
<div v-if="currentMode === 'login'" class="center flex-col bg-gray-100 rounded-xl px-12">
<div class="relative w-40 h-40 bg-white rounded-xl overflow-hidden shadow-xl">
<img
v-if="showWechatQR"
:src="wechatQRUrl"
alt="微信登录二维码"
class="w-full h-full"
:class="{ 'opacity-30': qrStatus === 'expired' }"
v-if="showWechatQR"
:src="wechatQRUrl"
alt="微信登录二维码"
class="w-full h-full"
:class="{ 'opacity-30': qrStatus === 'expired' }"
/>
<!-- 扫描成功蒙层 -->
<div
v-if="qrStatus === 'scanned'"
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
v-if="qrStatus === 'scanned'"
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
>
<IconFluentCheckmarkCircle20Filled class="color-green text-4xl"/>
<div class="text-base text-gray-700 font-medium">扫描成功</div>
@@ -557,8 +604,8 @@ onBeforeUnmount(() => {
</div>
<!-- 取消登录蒙层 -->
<div
v-if="qrStatus === 'cancelled'"
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
v-if="qrStatus === 'cancelled'"
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
>
<IconFluentErrorCircle20Regular class="color-red text-4xl"/>
<div class="text-base text-gray-700 font-medium">你已取消此次登录</div>
@@ -567,12 +614,12 @@ onBeforeUnmount(() => {
</div>
<!-- 过期蒙层 -->
<div
v-if=" qrStatus === 'expired'"
class="absolute top-0 left-0 right-0 bottom-0 bg-opacity-95 center backdrop-blur-sm"
v-if=" qrStatus === 'expired'"
class="absolute top-0 left-0 right-0 bottom-0 bg-opacity-95 center backdrop-blur-sm"
>
<IconFluentArrowClockwise20Regular
@click="refreshQRCode"
class="cp text-4xl"/>
@click="refreshQRCode"
class="cp text-4xl"/>
</div>
</div>
<p class="mt-4 center gap-space">
@@ -590,7 +637,7 @@ onBeforeUnmount(() => {
<h2>检测到您本地存在使用记录</h2>
<h3>是否需要同步到账户中?</h3>
</div>
<div>
<div v-else>
<h3 class="text-align-center">正在导入中</h3>
<ol class="pl-4">
<li>
@@ -606,17 +653,24 @@ onBeforeUnmount(() => {
请耐心等待,请勿关闭此页面
</li>
</ol>
<div class="flex items-center justify-between gap-2 mt-10">
<span>当前进度: {{ reason }}</span>
<IconEosIconsLoading class="text-xl"/>
</div>
</div>
<div class="flex gap-space justify-end">
<div class="flex gap-space justify-end" v-if="!isImporting">
<BaseButton type="info" @click="waitForImportConfirmation = false">退出登录</BaseButton>
<PopConfirm :title="[
{text:'您的用户数据将以压缩包自动下载到您的电脑中',type:'normal'},
{text:'随后用户数据将被除',type:'redBold'},
{text:'您的用户数据将以压缩包自动下载到您的电脑中,以便您随时恢复',type:'normal'},
{text:'随后网站的用户数据将被除',type:'redBold'},
{text:'是否确认继续?',type:'normal'},
]">
<BaseButton type="info">放弃数据</BaseButton>
</PopConfirm>
<BaseButton>确认同步</BaseButton>
<BaseButton @click="startSync">确认同步</BaseButton>
</div>
</div>
</div>

View File

@@ -13,7 +13,7 @@ import Setting from "@/pages/setting/Setting.vue";
import Login from "@/pages/user/login.vue";
import User from "@/pages/user/User.vue";
import VipIntro from "@/pages/user/VipIntro.vue";
// import { useAuthStore } from "@/stores/auth.ts";
// import { useAuthStore } from "@/stores/user.ts";
export const routes: RouteRecordRaw[] = [
{

View File

@@ -239,3 +239,9 @@ export enum CodeType {
ChangePhoneNew = 4,
ChangePhoneOld = 5
}
export enum ImportStatus {
Idle = 0,
Success = 1,
Fail = 2
}