Merge pull request #163 from zyronon/master

merge
This commit is contained in:
Zyronon
2025-11-18 16:31:25 +08:00
committed by GitHub
11 changed files with 256 additions and 171 deletions

View File

@@ -42,7 +42,7 @@
### 单词练习
- 种输入模式:跟打 / 复习 / 默写
- 种输入模式:跟打 / 辨认 / 复习 / 默写
- 智能模式:记忆曲线自动计算学习单词,并通过默写加深记忆
- 自由模式:不受限制,自行规划
- 提供音标、发音(美音、英音)、例句、短语、近义词、同根词、词源、错误统计等功能

View File

@@ -62,18 +62,6 @@
s.parentNode.insertBefore(hm, s);
})();
(function () {
var umami = document.createElement("script");
umami.src = 'https://typewords.cc/s.js'
if (location.href.includes('vercel') || location.href.includes('tw')) {
umami.setAttribute("data-website-id", "f630eefc-8b91-4e20-b890-106e6c7bcc10");
} else {
umami.setAttribute("data-website-id", "160308c9-7900-4b1d-a0b1-c3b25a9530f6");
}
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(umami, s);
})();
(function () {
var umami2 = document.createElement("script");
umami2.src = 'https://stat.typewords.cc/script.js'

View File

@@ -1896,9 +1896,6 @@
"tags": [
"通用"
],
"words": [
"private","fuck","add","remove"
],
"url": "GaoKaoZhenTiHeXinGaoPin.json",
"length": 799,
"language": "en",

View File

@@ -2,62 +2,85 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Migrate Data</title>
<title>TypeWords 数据迁移(旧域名)</title>
</head>
<body>
<script>
async function readIndexedDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('type-words', 1); // 你的数据库名
request.onsuccess = function (event) {
const db = event.target.result;
const tx = db.transaction(['typing-word-dict', 'typing-word-setting', 'typing-word-files'], 'readonly');
const result = {};
<h2>等待新域名发送迁移指令...</h2>
<pre id="log"></pre>
let count = 0;
const keys = ['typing-word-dict', 'typing-word-setting', 'typing-word-files'];
keys.forEach((storeName) => {
const store = tx.objectStore(storeName);
const allRequest = store.getAll();
allRequest.onsuccess = function (e) {
result[storeName] = e.target.result;
count++;
if (count === keys.length) {
resolve(result);
}
};
allRequest.onerror = function (e) {
result[storeName] = null;
count++;
if (count === keys.length) resolve(result);
};
});
};
request.onerror = function (e) {
resolve({});
<script>
function log(msg) {
console.log(msg);
document.getElementById('log').textContent += msg + "\n";
}
// 1⃣ 先动态加载 idb-keyval
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.onload = function () {
log("idb-keyval 加载完成");
resolve(window.idbKeyval);
};
document.head.appendChild(script);
});
}
// 监听 postMessage
window.addEventListener('message', async (e) => {
if (e.data && e.data.type === 'requestData') {
const local = {};
['PracticeSaveWord', 'PracticeSaveArticle'].forEach(key => {
local[key] = localStorage.getItem(key) || null;
});
loadIDBKeyval(); // 确保 idb-keyval 已经加载
const indexed = await readIndexedDB();
// 2⃣ 读取 IndexedDB
async function readAllStorageForMigration(db) {
// localStorage 数据
const localStorageData = {
PracticeSaveWord: localStorage.getItem('PracticeSaveWord'),
PracticeSaveArticle: localStorage.getItem('PracticeSaveArticle')
};
// 回复新域名
e.source.postMessage({
type: 'responseData',
localStorageData: local,
indexedDBData: indexed
}, e.origin);
// IndexedDB 数据key 对应你的老项目
const keys = [
'type-words-app-version',
'typing-word-dict',
'typing-word-setting',
'typing-word-files'
];
const indexedDBData = {};
for (let key of keys) {
let res = await db.get(key);
if (res) indexedDBData[key] = res
}
});
return {
localStorage: localStorageData,
indexedDB: indexedDBData
};
}
// 3⃣ 接收新域名指令
window.addEventListener('message', async (event) => {
if (event.data?.type !== 'REQUEST_MIGRATION_DATA') return;
// 安全校验 origin可选
// if (event.origin !== 'https://typewords.cc') return;
log("收到迁移指令,开始读取数据...");
const db = await loadIDBKeyval(); // 确保 idb-keyval 已经加载
const data = await readAllStorageForMigration(db);
log("读取完成,发送数据给新域名");
event.source.postMessage({
type: 'MIGRATION_RESULT',
payload: data
}, event.origin);
log("已发送迁移数据");
// 自动关闭窗口(延迟 500ms
setTimeout(() => {
window.close();
}, 500);
});
</script>
</body>
</html>

View File

@@ -1 +0,0 @@
!function(){"use strict";(t=>{const{screen:{width:e,height:a},navigator:{language:n,doNotTrack:i,msDoNotTrack:r},location:o,document:s,history:c,top:u,doNotTrack:d}=t,{currentScript:l,referrer:h}=s;if(!l)return;const{hostname:f,href:m,origin:p}=o,y=m.startsWith("data:")?void 0:t.localStorage,g="data-",b="true",v=l.getAttribute.bind(l),w=v(g+"website-id"),S=v(g+"host-url"),k=v(g+"before-send"),N=v(g+"tag")||void 0,T="false"!==v(g+"auto-track"),A=v(g+"do-not-track")===b,j=v(g+"exclude-search")===b,x=v(g+"exclude-hash")===b,$=v(g+"domains")||"",E=$.split(",").map(t=>t.trim()),K=`${(S||"https://api-gateway.umami.dev"||l.src.split("/").slice(0,-1).join("/")).replace(/\/$/,"")}/api/send`,L=`${e}x${a}`,O=/data-umami-event-([\w-_]+)/,_=g+"umami-event",D=300,U=()=>({website:w,screen:L,language:n,title:s.title,hostname:f,url:z,referrer:F,tag:N,id:q||void 0}),W=(t,e,a)=>{a&&(F=z,z=new URL(a,o.href),j&&(z.search=""),x&&(z.hash=""),z=z.toString(),z!==F&&setTimeout(J,D))},B=()=>H||!w||y&&y.getItem("umami.disabled")||$&&!E.includes(f)||A&&(()=>{const t=d||i||r;return 1===t||"1"===t||"yes"===t})(),C=async(e,a="event")=>{if(B())return;const n=t[k];if("function"==typeof n&&(e=n(a,e)),e)try{const t=await fetch(K,{keepalive:!0,method:"POST",body:JSON.stringify({type:a,payload:e}),headers:{"Content-Type":"application/json",...void 0!==R&&{"x-umami-cache":R}},credentials:"omit"}),n=await t.json();n&&(H=!!n.disabled,R=n.cache)}catch(t){}},I=()=>{G||(G=!0,J(),(()=>{const t=(t,e,a)=>{const n=t[e];return(...e)=>(a.apply(null,e),n.apply(t,e))};c.pushState=t(c,"pushState",W),c.replaceState=t(c,"replaceState",W)})(),(()=>{const t=async t=>{const e=t.getAttribute(_);if(e){const a={};return t.getAttributeNames().forEach(e=>{const n=e.match(O);n&&(a[n[1]]=t.getAttribute(e))}),J(e,a)}};s.addEventListener("click",async e=>{const a=e.target,n=a.closest("a,button");if(!n)return t(a);const{href:i,target:r}=n;if(n.getAttribute(_)){if("BUTTON"===n.tagName)return t(n);if("A"===n.tagName&&i){const a="_blank"===r||e.ctrlKey||e.shiftKey||e.metaKey||e.button&&1===e.button;return a||e.preventDefault(),t(n).then(()=>{a||(("_top"===r?u.location:o).href=i)})}}},!0)})())},J=(t,e)=>C("string"==typeof t?{...U(),name:t,data:e}:"object"==typeof t?{...t}:"function"==typeof t?t(U()):U()),P=(t,e)=>("string"==typeof t&&(q=t),R="",C({...U(),data:"object"==typeof t?t:e},"identify"));t.umami||(t.umami={track:J,identify:P});let R,q,z=m,F=h.startsWith(p)?"":h,G=!1,H=!1;T&&!B()&&("complete"===s.readyState?I():s.addEventListener("readystatechange",I,!0))})(window)}();

View File

@@ -324,66 +324,6 @@
toggleEl('#qqDialog', true)
}
</script>
<script>
function migrateFromOldDomain() {
return new Promise((resolve) => {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = 'https://2study.top/migrate.html';
document.body.appendChild(iframe);
// 接收数据
window.addEventListener('message', async function handler(e) {
if (e.data && e.data.type === 'responseData') {
// 写入 localStorage
const localData = e.data.localStorageData;
for (const key in localData) {
if (localData[key] !== null) localStorage.setItem(key, localData[key]);
}
// 写入 IndexedDB
const indexedData = e.data.indexedDBData;
const request = indexedDB.open('type-words', 1);
request.onupgradeneeded = function (event) {
const db = event.target.result;
// 建 store
['typing-word-dict', 'typing-word-setting', 'typing-word-files'].forEach(name => {
if (!db.objectStoreNames.contains(name)) {
db.createObjectStore(name, {autoIncrement: true});
}
});
};
request.onsuccess = function (event) {
const db = event.target.result;
const tx = db.transaction(['typing-word-dict', 'typing-word-setting', 'typing-word-files'], 'readwrite');
for (const storeName in indexedData) {
const store = tx.objectStore(storeName);
const items = indexedData[storeName];
if (items) {
items.forEach(item => store.put(item));
}
}
tx.oncomplete = function () {
resolve(true);
window.removeEventListener('message', handler);
iframe.remove();
};
};
// 发送请求
iframe.contentWindow.postMessage({type: 'requestData'}, 'https://2study.top');
}
});
});
}
if (location.href === 'https://typewords.cc/') {
migrateFromOldDomain().then(() => {
console.log('数据迁移完成!');
});
}
</script>
</head>
<body>
<div class="wrapper">

View File

@@ -1,28 +1,29 @@
<script setup lang="ts">
import { onMounted, watch } from "vue";
import { BaseState, useBaseStore } from "@/stores/base.ts";
import { useRuntimeStore } from "@/stores/runtime.ts";
import { useSettingStore } from "@/stores/setting.ts";
import {onMounted, watch} from "vue";
import {BaseState, useBaseStore} from "@/stores/base.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {useSettingStore} from "@/stores/setting.ts";
import useTheme from "@/hooks/theme.ts";
import { shakeCommonDict } from "@/utils";
import { get, set } from 'idb-keyval'
import {loadJsLib, shakeCommonDict} from "@/utils";
import {get, set} from 'idb-keyval'
import { useRoute } from "vue-router";
import { DictId } from "@/types/types.ts";
import { APP_VERSION, AppEnv, LOCAL_FILE_KEY, SAVE_DICT_KEY, SAVE_SETTING_KEY } from "@/config/env.ts";
import { syncSetting } from "@/apis";
import { useUserStore } from "@/stores/auth.ts";
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 MigrateDialog from "@/pages/MigrateDialog.vue";
const store = useBaseStore()
const runtimeStore = useRuntimeStore()
const settingStore = useSettingStore()
const userStore = useUserStore()
const { setTheme } = useTheme()
const {setTheme} = useTheme()
let lastAudioFileIdList = []
watch(store.$state, (n: BaseState) => {
let data = shakeCommonDict(n)
set(SAVE_DICT_KEY.key, JSON.stringify({ val: data, version: SAVE_DICT_KEY.version }))
set(SAVE_DICT_KEY.key, JSON.stringify({val: data, version: SAVE_DICT_KEY.version}))
//筛选自定义和收藏
let bookList = data.article.bookList.filter(v => v.custom || [DictId.articleCollect].includes(v.id))
@@ -51,11 +52,11 @@ watch(store.$state, (n: BaseState) => {
})
watch(() => settingStore.$state, (n) => {
set(SAVE_SETTING_KEY.key, JSON.stringify({ val: n, version: SAVE_SETTING_KEY.version }))
set(SAVE_SETTING_KEY.key, JSON.stringify({val: n, version: SAVE_SETTING_KEY.version}))
if (AppEnv.CAN_REQUEST) {
syncSetting(null, settingStore.$state)
}
}, { deep: true })
}, {deep: true})
async function init() {
await userStore.init()
@@ -66,37 +67,48 @@ async function init() {
setTheme(settingStore.theme)
if (settingStore.first) {
set(APP_VERSION.key,APP_VERSION.version)
}else {
set(APP_VERSION.key, APP_VERSION.version)
} else {
get(APP_VERSION.key).then(r => {
runtimeStore.isNew = r ? (APP_VERSION.version > Number(r)) : true
})
}
window.umami?.track('host', { host: window.location.host })
window.umami?.track('host', {host: window.location.host})
}
onMounted(init)
//迁移数据
let showTransfer = $ref(false)
onMounted(() => {
if (new URLSearchParams(window.location.search).get('from_old_site') === '1' && location.origin === Origin) {
if (localStorage.getItem('__migrated_from_2study_top__')) return;
setTimeout(() => {
showTransfer = true
}, 1000)
}
})
// let transitionName = $ref('go')
// const route = useRoute()
// watch(() => route.path, (to, from) => {
// return transitionName = ''
// console.log('watch', to, from)
// //footer下面的5个按钮对跳不要用动画
// let noAnimation = [
// '/pc/practice',
// '/pc/dict',
// '/mobile',
// '/'
// ]
// if (noAnimation.indexOf(from) !== -1 && noAnimation.indexOf(to) !== -1) {
// return transitionName = ''
// }
//
// const toDepth = routes.findIndex(v => v.path === to)
// const fromDepth = routes.findIndex(v => v.path === from)
// transitionName = toDepth > fromDepth ? 'go' : 'back'
// console.log('transitionName', transitionName, toDepth, fromDepth)
// console.log('watch', to, from)
// //footer下面的5个按钮对跳不要用动画
// let noAnimation = [
// '/pc/practice',
// '/pc/dict',
// '/mobile',
// '/'
// ]
// if (noAnimation.indexOf(from) !== -1 && noAnimation.indexOf(to) !== -1) {
// return transitionName = ''
// }
//
// const toDepth = routes.findIndex(v => v.path === to)
// const fromDepth = routes.findIndex(v => v.path === from)
// transitionName = toDepth > fromDepth ? 'go' : 'back'
// console.log('transitionName', transitionName, toDepth, fromDepth)
// })
</script>
@@ -109,4 +121,8 @@ onMounted(init)
<!-- </transition>-->
<!-- </router-view>-->
<router-view></router-view>
<MigrateDialog
v-model="showTransfer"
@ok="init"
/>
</template>

View File

@@ -7,13 +7,11 @@ import BaseInput from "@/components/base/BaseInput.vue";
interface IProps {
list: Article[];
showTranslate?: boolean;
activeId: string | number;
}
const props = withDefaults(defineProps<IProps>(), {
list: () => [] as Article[],
showTranslate: true,
activeId: ""
})
const emit = defineEmits<{
@@ -79,7 +77,10 @@ defineExpose({ scrollToBottom, scrollToItem })
</template>
</BaseInput>
</div>
<BaseList ref="listRef" @click="(e: any) => emit('click', e)" :list="localList" v-bind="$attrs">
<BaseList ref="listRef"
@click="(e: any) => emit('click', e)"
:list="localList"
v-bind="$attrs">
<template v-slot:prefix="{ item, index }">
<slot name="prefix" :item="item" :index="index"></slot>
</template>

View File

@@ -5,13 +5,13 @@ import { nextTick, watch } from 'vue'
const props = withDefaults(defineProps<{
list?: any[],
activeIndex?: number,
activeId?: number,
activeId?: number | string,
isActive?: boolean
static?: boolean
}>(), {
list: [],
activeIndex: -1,
activeId: null,
activeId: '',
isActive: false,
static: true
})
@@ -94,7 +94,7 @@ function scrollToItem(index: number) {
function itemIsActive(item: any, index: number) {
return props.activeId ?
props.activeId === item.id
props.activeId == item.id
: props.activeIndex === index
}

115
src/pages/MigrateDialog.vue Normal file
View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import {Origin} from "@/config/env.ts";
import BaseButton from "@/components/BaseButton.vue";
import {set} from 'idb-keyval'
import {defineAsyncComponent} from "vue";
import Toast from "@/components/base/toast/Toast.ts";
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
const model = defineModel()
const emit = defineEmits<{ ok: [] }>()
async function migrateFromOldSite() {
return new Promise(async (resolve, reject) => {
// 旧域名地址
var OLD_ORIGIN = 'https://2study.top';
// 需要迁移的 IndexedDB key
var IDB_KEYS = [
'type-words-app-version',
'typing-word-dict',
'typing-word-setting',
'typing-word-files'
];
// 需要迁移的 localStorage key
var LS_KEYS = [
'PracticeSaveWord',
'PracticeSaveArticle'
];
const migrateWin = window.open(`${OLD_ORIGIN}/migrate.html`, '_blank', 'width=400,height=400');
if (!migrateWin) return reject('弹窗被阻止,请在网址输入栏最右边,点击允许弹窗');
async function onMessage(event) {
if (event.origin !== OLD_ORIGIN) return;
if (event.data?.type !== 'MIGRATION_RESULT') return;
const payload = event.data.payload;
console.log('payload', payload);
// 写入 localStorage
LS_KEYS.forEach(key => {
if (payload.localStorage[key] !== undefined) {
localStorage.setItem(key, payload.localStorage[key]);
}
});
// 写入 IndexedDB
for (let key of IDB_KEYS) {
if (payload.indexedDB[key] !== undefined) {
await set(key, payload.indexedDB[key]);
}
}
window.removeEventListener('message', onMessage);
resolve(true);
}
window.addEventListener('message', onMessage);
// 等窗口加载完毕后发请求
const timer = setInterval(() => {
if (!migrateWin || migrateWin.closed) {
clearInterval(timer);
reject('迁移窗口已关闭');
} else {
try {
migrateWin.postMessage({type: 'REQUEST_MIGRATION_DATA'}, OLD_ORIGIN);
} catch (e) {
// 跨域安全错误忽略,等窗口完全加载后再试
}
}
}, 100);
});
}
async function transfer() {
try {
await migrateFromOldSite();
localStorage.setItem('__migrated_from_2study_top__', '1');
console.log('迁移完成');
Toast.success('迁移完成')
model.value = false
emit('ok')
} catch (e) {
Toast.error('迁移失败:' + e)
console.error('迁移失败', e);
}
}
</script>
<template>
<Dialog v-model="model" title="迁移数据">
<div class="px-4 flex-col center text-align-center w-100">
<h2>
本网站已启用新域名 <span class="color-blue">{{ Origin }}</span>
</h2>
<h3>
老域名即将停用由于浏览器安全限制新老网站数据无法互通需要您手动点击转移数据
</h3>
<h3>
<BaseButton
size="large"
@click="transfer">
转移数据
</BaseButton>
</h3>
</div>
</Dialog>
</template>
<style scoped lang="scss">
</style>

View File

@@ -42,7 +42,7 @@ const store = useBaseStore()
const runtimeStore = useRuntimeStore()
const settingStore = useSettingStore()
const statStore = usePracticeStore()
const { toggleTheme } = useTheme()
const {toggleTheme} = useTheme()
let articleData = $ref({
list: [],
@@ -132,6 +132,7 @@ async function init() {
router.push('/articles')
}
}
const initAudio = () => {
_nextTick(() => {
audioRef.volume = settingStore.articleSoundVolume / 100
@@ -154,11 +155,11 @@ const handleSpeedUpdate = (speed: number) => {
watch(() => store.load, (n) => {
if (n && loading) init()
}, { immediate: true })
}, {immediate: true})
watch(() => settingStore.$state, (n) => {
initAudio()
}, { immediate: true, deep: true })
}, {immediate: true, deep: true})
onMounted(() => {
if (store.sbook?.articles?.length) {
@@ -190,9 +191,9 @@ function savePracticeData(init = true, regenerate = true) {
let data = obj.val
//如果全是0说明未进行练习直接重置
if (
data.practiceData.sectionIndex === 0 &&
data.practiceData.sentenceIndex === 0 &&
data.practiceData.wordIndex === 0
data.practiceData.sectionIndex === 0 &&
data.practiceData.sentenceIndex === 0 &&
data.practiceData.wordIndex === 0
) {
throw new Error()
}
@@ -262,6 +263,10 @@ function setArticle(val: Article) {
})
}
watch(() => articleData.article.id, n => {
console.log('articleData.article.id', n)
})
async function complete() {
clearInterval(timer)
setTimeout(() => {
@@ -279,7 +284,7 @@ async function complete() {
}
if (AppEnv.CAN_REQUEST) {
let res = await addStat({ ...data, type: 'article' })
let res = await addStat({...data, type: 'article'})
if (!res.success) {
Toast.error(res.msg)
}
@@ -438,7 +443,8 @@ onUnmounted(() => {
timer && clearInterval(timer)
})
const { playSentenceAudio } = usePlaySentenceAudio()
const {playSentenceAudio} = usePlaySentenceAudio()
function play2(e) {
_nextTick(() => {
if (settingStore.articleSound || e.handle) {
@@ -485,7 +491,7 @@ provide('currentPractice', currentPractice)
:static="false"
:show-translate="settingStore.translate"
@click="changeArticle"
:active-id="articleData.article.id"
:active-id="articleData.article.id??''"
:list="articleData.list ">
<template v-slot:suffix="{item,index}">
<BaseIcon