Merge remote-tracking branch 'upstream/master'

This commit is contained in:
SMGDev
2025-11-21 15:32:40 +00:00
91 changed files with 10890 additions and 7146 deletions

3
.env
View File

@@ -1,3 +1,2 @@
VITE_ROUTE_BASE=/TypeWords/
VITE_ROUTE_BASE=/

View File

@@ -34,7 +34,7 @@ jobs:
run: pnpm install
- name: Build
run: pnpm run build
run: pnpm run build-oss
- name: Deploy to OSS + Refresh CDN
run: pnpm run deploy-oss

View File

@@ -33,7 +33,7 @@ jobs:
run: pnpm install
- name: Build
run: pnpm run build-nocdn
run: pnpm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3

View File

@@ -65,4 +65,7 @@ I found this note on my car: 'Sir, we welcome you to our city. This is a 'No Par
Food and talk
A new play is coming to "The Globe"soon, I said. Will you be seeing it?
26的 of curse
26的 of curse
1、例句可以选中单词并添加到收藏
2、ABC页面太墨迹不简洁进度复杂本周学习记录改成日历有个标记+激励分享功能,满足炫耀欲望

View File

@@ -18,14 +18,14 @@
</p>
<div align=center>
<a href="https://trendshift.io/repositories/14139" target="_blank" class="trendshift-badge"><img src="https://trendshift.io/api/badge/repositories/14139" alt="TypeWords | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/15226" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15226" alt="zyronon%2FTypeWords | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
<p align="center">
<br/>
<a href="https://skywork.ai/p/GrXQb4"><img src="/public/skywork-ai.png" alt="License"></a>
Skywork.AI:<a href="https://skywork.ai/p/GrXQb4" target="_blank">10 tasks in 1 hour, not 10 hours →Limited free spots: 127 left</a>
<br/>
<br>
<a href="https://skywork.ai/p/GrXQb4"><img src="/public/skywork-ai.png" alt="License" style="width: 650px;"></a>
<br>
赞助: <a href="https://skywork.ai/p/GrXQb4" target="_blank">Skywork.AI: 10 tasks in 1 hour, not 10 hours →Limited free spots: 127 left</a>
<br/>
<br/>
</p>
@@ -35,14 +35,14 @@
## 在线访问
中国: [https://2study.top](https://2study.top)
其他: [https://vercel.2study.top](https://vercel.2study.top) or [https://tw.2study.top](https://tw.2study.top)
中国: [https://typewords.cc](https://typewords.cc)
其他: [https://vercel.typewords.cc](https://vercel.typewords.cc) or [https://tw.typewords.cc](https://tw.typewords.cc)
## 功能列表
### 单词练习
- 种输入模式:跟打 / 复习 / 默写
- 种输入模式:跟打 / 辨认 / 复习 / 默写
- 智能模式:记忆曲线自动计算学习单词,并通过默写加深记忆
- 自由模式:不受限制,自行规划
- 提供音标、发音(美音、英音)、例句、短语、近义词、同根词、词源、错误统计等功能
@@ -87,7 +87,7 @@
3. 在项目根目录下,打开命令行,运行`npm install`来下载依赖。
4. 执行`npm run dev`来启动项目,项目默认地址为[`http://localhost:3000`](http://localhost:3000)
5. 在浏览器中打开[`http://localhost:3000`](http://localhost:3000) 来访问项目。
6. 执行`npm run build-nocdn`打包项目文件
6. 执行`npm run build`打包项目文件
## 功能与建议

27
components.d.ts vendored
View File

@@ -20,7 +20,6 @@ declare module 'vue' {
Book: typeof import('./src/components/Book.vue')['default']
Checkbox: typeof import('./src/components/base/checkbox/Checkbox.vue')['default']
Close: typeof import('./src/components/icon/Close.vue')['default']
CollectNotice: typeof import('./src/components/CollectNotice.vue')['default']
ConflictNotice: typeof import('./src/components/ConflictNotice.vue')['default']
DeleteIcon: typeof import('./src/components/icon/DeleteIcon.vue')['default']
Dialog: typeof import('./src/components/dialog/Dialog.vue')['default']
@@ -30,41 +29,58 @@ declare module 'vue' {
Empty: typeof import('./src/components/Empty.vue')['default']
Form: typeof import('./src/components/base/form/Form.vue')['default']
FormItem: typeof import('./src/components/base/form/FormItem.vue')['default']
Header: typeof import('./src/components/Header.vue')['default']
IconBxVolume: typeof import('~icons/bx/volume')['default']
IconBxVolumeFull: typeof import('~icons/bx/volume-full')['default']
IconBxVolumeLow: typeof import('~icons/bx/volume-low')['default']
IconBxVolumeMute: typeof import('~icons/bx/volume-mute')['default']
IconEosIconsLoading: typeof import('~icons/eos-icons/loading')['default']
IconFluentAdd16Regular: typeof import('~icons/fluent/add16-regular')['default']
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']
IconFluentArrowBounce20Regular: typeof import('~icons/fluent/arrow-bounce20-regular')['default']
IconFluentArrowCircleRight16Regular: typeof import('~icons/fluent/arrow-circle-right16-regular')['default']
IconFluentArrowClockwise20Regular: typeof import('~icons/fluent/arrow-clockwise20-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']
IconFluentArrowShuffle20Filled: typeof import('~icons/fluent/arrow-shuffle20-filled')['default']
IconFluentArrowSort20Regular: typeof import('~icons/fluent/arrow-sort20-regular')['default']
IconFluentArrowSwap20Regular: typeof import('~icons/fluent/arrow-swap20-regular')['default']
IconFluentBookLetter20Regular: typeof import('~icons/fluent/book-letter20-regular')['default']
IconFluentBookNumber20Filled: typeof import('~icons/fluent/book-number20-filled')['default']
IconFluentCalendarDate20Regular: typeof import('~icons/fluent/calendar-date20-regular')['default']
IconFluentCheckmark20Regular: typeof import('~icons/fluent/checkmark20-regular')['default']
IconFluentCheckmarkCircle16Filled: typeof import('~icons/fluent/checkmark-circle16-filled')['default']
IconFluentCheckmarkCircle16Regular: typeof import('~icons/fluent/checkmark-circle16-regular')['default']
IconFluentCheckmarkCircle20Filled: typeof import('~icons/fluent/checkmark-circle20-filled')['default']
IconFluentCheckmarkCircle20Regular: typeof import('~icons/fluent/checkmark-circle20-regular')['default']
IconFluentChevronDown20Regular: typeof import('~icons/fluent/chevron-down20-regular')['default']
IconFluentChevronLeft20Filled: typeof import('~icons/fluent/chevron-left20-filled')['default']
IconFluentChevronLeft28Filled: typeof import('~icons/fluent/chevron-left28-filled')['default']
IconFluentCrown20Regular: typeof import('~icons/fluent/crown20-regular')['default']
IconFluentDatabasePerson20Regular: typeof import('~icons/fluent/database-person20-regular')['default']
IconFluentDelete20Regular: typeof import('~icons/fluent/delete20-regular')['default']
IconFluentDismiss20Regular: typeof import('~icons/fluent/dismiss20-regular')['default']
IconFluentDismissCircle16Regular: typeof import('~icons/fluent/dismiss-circle16-regular')['default']
IconFluentDismissCircle20Filled: typeof import('~icons/fluent/dismiss-circle20-filled')['default']
IconFluentErrorCircle20Filled: typeof import('~icons/fluent/error-circle20-filled')['default']
IconFluentErrorCircle20Regular: typeof import('~icons/fluent/error-circle20-regular')['default']
IconFluentEye16Regular: typeof import('~icons/fluent/eye16-regular')['default']
IconFluentEyeOff16Regular: typeof import('~icons/fluent/eye-off16-regular')['default']
IconFluentHandWave20Regular: typeof import('~icons/fluent/hand-wave20-regular')['default']
IconFluentHome20Regular: typeof import('~icons/fluent/home20-regular')['default']
IconFluentKeyboardLayoutFloat20Regular: typeof import('~icons/fluent/keyboard-layout-float20-regular')['default']
IconFluentLockClosed20Regular: typeof import('~icons/fluent/lock-closed20-regular')['default']
IconFluentMail20Regular: typeof import('~icons/fluent/mail20-regular')['default']
IconFluentMyLocation20Regular: typeof import('~icons/fluent/my-location20-regular')['default']
IconFluentNumberSymbol20Regular: typeof import('~icons/fluent/number-symbol20-regular')['default']
IconFluentPaddingLeft20Regular: typeof import('~icons/fluent/padding-left20-regular')['default']
IconFluentPayment20Regular: typeof import('~icons/fluent/payment20-regular')['default']
IconFluentPerson20Regular: typeof import('~icons/fluent/person20-regular')['default']
IconFluentPhone20Regular: typeof import('~icons/fluent/phone20-regular')['default']
IconFluentPlay20Regular: typeof import('~icons/fluent/play20-regular')['default']
IconFluentQuestionCircle20Regular: typeof import('~icons/fluent/question-circle20-regular')['default']
IconFluentReplay20Regular: typeof import('~icons/fluent/replay20-regular')['default']
@@ -72,9 +88,9 @@ declare module 'vue' {
IconFluentSearch24Regular: typeof import('~icons/fluent/search24-regular')['default']
IconFluentSettings20Regular: typeof import('~icons/fluent/settings20-regular')['default']
IconFluentShieldQuestion20Regular: typeof import('~icons/fluent/shield-question20-regular')['default']
IconFluentSlideTextTitleEdit20Regular: typeof import('~icons/fluent/slide-text-title-edit20-regular')['default']
IconFluentSpeakerEdit20Regular: typeof import('~icons/fluent/speaker-edit20-regular')['default']
IconFluentSpeakerSettings20Regular: typeof import('~icons/fluent/speaker-settings20-regular')['default']
IconFluentStar12Regular: typeof import('~icons/fluent/star12-regular')['default']
IconFluentStar16Filled: typeof import('~icons/fluent/star16-filled')['default']
IconFluentStar16Regular: typeof import('~icons/fluent/star16-regular')['default']
IconFluentStar20Filled: typeof import('~icons/fluent/star20-filled')['default']
@@ -88,12 +104,9 @@ 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']
IconMaterialSymbolsMail: typeof import('~icons/material-symbols/mail')['default']
IconIxWechatLogo: typeof import('~icons/ix/wechat-logo')['default']
IconPhExportLight: typeof import('~icons/ph/export-light')['default']
IconRiTwitterFill: typeof import('~icons/ri/twitter-fill')['default']
IconSimpleIconsGithub: typeof import('~icons/simple-icons/github')['default']
IconSimpleIconsWechat: typeof import('~icons/simple-icons/wechat')['default']
IconSimpleIconsXiaohongshu: typeof import('~icons/simple-icons/xiaohongshu')['default']
IconSystemUiconsImport: typeof import('~icons/system-uicons/import')['default']
InputNumber: typeof import('./src/components/base/InputNumber.vue')['default']
List: typeof import('./src/components/list/List.vue')['default']

View File

@@ -26,21 +26,21 @@ Practice English, one strike, one step forward
<p align="center">
<br/>
<a href="https://skywork.ai/p/GrXQb4"><img src="/public/skywork-ai.png" alt="License"></a>
Skywork.AI:<a href="https://skywork.ai/p/GrXQb4" target="_blank">10 tasks in 1 hour, not 10 hours →Limited free spots: 127 left</a>
<br>
<a href="https://skywork.ai/p/GrXQb4"><img src="/public/skywork-ai.png" alt="License" style="width: 650px;"></a>
<br>
Sponsor: <a href="https://skywork.ai/p/GrXQb4" target="_blank">Skywork.AI: 10 tasks in 1 hour, not 10 hours →Limited free spots: 127 left</a>
<br/>
<br/>
<br/>
</p>
</p>
<img width="1920" height="1440" alt="295shots_so" src="https://github.com/user-attachments/assets/383ed437-856e-48fe-92b0-9619babb49be" />
<img width="1920" height="1440" alt="922shots_so" src="https://github.com/user-attachments/assets/5b5fa13f-747c-4368-ae21-3c9d7d30fbc7" />
## Online visit
China:<https://2study.top>
other:<https://vercel.2study.top> or <https://tw.2study.top>
China:<https://typewords.cc>
other:<https://vercel.typewords.cc> or <https://tw.typewords.cc>
## Feature list

View File

@@ -2,43 +2,52 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/svg+xml" href="/favicon.png"/>
<link rel="manifest" href="/manifest.json">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Type Words - 词文记 | 单词跟打 · 文章跟打</title>
<title>Type Words 官网 - 词文记 | 单词跟打 · 文章跟打</title>
<!-- 搜索引擎描述 -->
<meta name="description"
content="Type Words在线英语练习平台,支持单词、文章跟打练习,提升打字与语言能力。Practice English, one keystroke at a time.">
content="Type Words 官方网站 - 在线英语练习平台,支持单词、文章跟打练习,提升英语学习效率。Practice English, one strike, one step forward">
<!-- 关键词(可选,搜索引擎基本不用,但能补充信息) -->
<meta name="keywords"
content="Type Words, Typing Word, 英语打字练习, 单词跟打, 文章跟打, 键盘练习, 英语学习, 文章学习">
content="Type Words, Typing Word, Type Words 官网, 官方网站, 英语打字练习, 单词跟打, 文章跟打, 键盘练习, 英语学习, 文章学习, 打字练习软件, 单词记忆工具, 英语学习软件, 背单词神器, 英语肌肉记忆, 键盘工作者, 免费英语学习, 音标发音, 默写练习, 在线学英语, CET-4, CET-6, TOEFL, IELTS, GRE, GMAT, SAT, 考研英语, 专四专八, 程序员英语, JavaScript API, Node.js API, Java API, Linux命令, 编程词汇, 技术英语, VSCode插件, 开源项目, GitHub趋势榜, V2EX热搜, Gitee GVP, 少数派推荐, 英语打字训练, WPM统计, 准确率分析, 商务英语, BEC, 雅思听力, 日语学习, 多语言学习, 英语口语练习, 单词拼写训练">
<meta name="author" content="zyronon"/>
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"/>
<link rel="canonical" href="https://typewords.cc/"/>
<!-- Open Graph用于社交媒体分享微信/QQ/知乎/Facebook 等) -->
<meta property="og:title" content="Type Words - 英语打字练习平台">
<meta property="og:title" content="Type Words 官网 - 英语打字练习平台">
<meta property="og:description"
content="在线英语打字练习平台,支持单词跟打与文章跟打,帮助提升打字速度与英语学习效率。">
content="Type Words 官方网站 - 在线英语练习平台,支持单词文章跟打练习提升英语学习效率。Practice English, one strike, one step forward">
<meta property="og:type" content="website">
<meta property="og:url" content="https://2study.top/">
<meta property="og:image" content="https://2study.top/favicon.png">
<meta property="og:url" content="https://typewords.cc/">
<meta property="og:image" content="https://typewords.cc/favicon.png">
<!-- Twitter Card用于 Twitter 分享) -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Type Words - 英语打字练习平台">
<meta name="twitter:title" content="Type Words 官网 - 英语打字练习平台">
<meta name="twitter:description"
content="Type Words在线英语练习平台,支持单词跟打、文章练习,提升打字速度与英语水平。">
<meta name="twitter:image" content="https://2study.top/favicon.png">
content="Type Words 官方网站 - 在线英语练习平台,支持单词、文章跟打练习,提升英语学习效率。Practice English, one strike, one step forward">
<meta name="twitter:image" content="https://typewords.cc/favicon.png">
<!--用于百度站长验证 -->
<meta name="baidu-site-verification" content="codeva-Kw33xFT3p2"/>
<link rel="icon" type="image/svg+xml" href="/favicon.png"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<!-- 苹果设备iOS Safari在用户添加到主屏时显示的图标-->
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.png"/>
<!-- 设置浏览器地址栏颜色(在 Android Chrome 特别明显)。-->
<meta name="theme-color" content="#818CF8"/>
<link rel="manifest" href="/manifest.json">
<!-- 阻止 iOS 自动把数字识别为电话号码。-->
<!-- HandheldFriendly 和 MobileOptimized 是旧手机浏览器的优化提示(现在作用不大)。-->
<meta name="format-detection" content="telephone=no"/>
<meta name="HandheldFriendly" content="True"/>
<meta name="MobileOptimized" content="320"/>
<!-- referrer 控制请求来源信息-->
<meta name="referrer" content="origin-when-cross-origin"/>
<!-- color-scheme 告诉浏览器支持亮/暗模式-->
<meta name="color-scheme" content="light dark"/>
<script>
;(function () {
var src = '//cdn.jsdelivr.net/npm/eruda';
if (!/eruda=true/.test(window.location) && localStorage.getItem('active-eruda') != 'true') return;
document.write('<scr' + 'ipt src="' + src + '"></scr' + 'ipt>');
document.write('<scr' + 'ipt>eruda.init();</scr' + 'ipt>');
})();
</script>
<script>
if (!location.href.includes('localhost')
&& !location.href.includes('192.168')
@@ -53,21 +62,9 @@
s.parentNode.insertBefore(hm, s);
})();
(function () {
var umami = document.createElement("script");
umami.src = 'https://2study.top/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.2study.top/script.js'
umami2.src = 'https://stat.typewords.cc/script.js'
umami2.setAttribute("data-website-id", "4d728ae3-5393-4efe-81dc-30dcb4f33c00");
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(umami2, s);
@@ -76,18 +73,11 @@
</script>
</head>
<body>
<noscript>
<div>You need to enable JavaScript to run Type Words.</div>
<div>你需要启用 JavaScript 来运行 Type Words.</div>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js').then(registration => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}).catch(error => {
console.log('ServiceWorker registration failed: ', error);
});
});
}
</script>
</body>
</html>

View File

@@ -1,5 +0,0 @@
[[redirects]]
from = "/baidu"
to = "https://api.fanyi.baidu.com/api/trans/vip/translate"
status = 200
force = true

1707
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,10 +6,11 @@
"start": "vite",
"dev": "vite",
"test": "",
"build": "vite build && node scripts/generate-sitemap.js",
"build-nocdn": "vite build",
"build": "vite build",
"build-oss": "vite build && node scripts/do.js",
"build-tsc": "vue-tsc && vite build",
"report": "vite build",
"report-oss": "vite build",
"preview": "vite preview",
"commit": "git-cz",
"prepare": "husky install",
@@ -41,13 +42,16 @@
"@iconify-json/fluent": "^1.2.28",
"@iconify-json/icon-park-outline": "^1.2.4",
"@iconify-json/icon-park-solid": "^1.2.4",
"@iconify-json/ix": "^1.2.10",
"@iconify-json/material-symbols": "^1.2.33",
"@iconify-json/oui": "^1.2.6",
"@iconify-json/ph": "^1.2.2",
"@iconify-json/qlementine-icons": "^1.2.11",
"@iconify-json/ri": "^1.2.5",
"@iconify-json/simple-icons": "^1.2.48",
"@iconify-json/streamline": "^1.2.5",
"@iconify-json/system-uicons": "^1.2.4",
"@iconify-json/uiw": "^1.2.3",
"@types/file-saver": "^2.0.7",
"@types/lodash-es": "^4.17.12",
"@types/md5": "^2.1.33",

9510
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

View File

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

86
public/migrate.html Normal file
View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>TypeWords 数据迁移(旧域名)</title>
</head>
<body>
<h2>等待新域名发送迁移指令...</h2>
<pre id="log"></pre>
<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);
});
}
loadIDBKeyval(); // 确保 idb-keyval 已经加载
// 2⃣ 读取 IndexedDB
async function readAllStorageForMigration(db) {
// localStorage 数据
const localStorageData = {
PracticeSaveWord: localStorage.getItem('PracticeSaveWord'),
PracticeSaveArticle: localStorage.getItem('PracticeSaveArticle')
};
// 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>

100
public/privacy-policy.html Normal file
View File

@@ -0,0 +1,100 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>隐私政策</title>
</head>
<body
style="display:flex;justify-content:center">
<div class="privacy-page"
style="width: 60vw;"
>
<h1 style="text-align: center">隐私政策</h1>
<div class="content">
<section>
<h2>一、引言</h2>
<p>
我们非常重视您的隐私保护。本隐私政策说明了我们如何收集、使用、存储和保护您的个人信息。在使用本应用之前,请您仔细阅读本隐私政策。</p>
</section>
<section>
<h2>二、信息收集</h2>
<p>我们可能收集以下信息:</p>
<p><strong>1. 账户信息:</strong>当您注册账户时,我们会收集您的手机号、邮箱地址、密码等信息。</p>
<p><strong>2. 学习数据:</strong>我们会记录您的学习进度、学习记录、练习数据等信息,以便为您提供个性化的学习服务。
</p>
<p><strong>3. 设备信息:</strong>我们可能收集您的设备型号、操作系统版本、唯一设备标识符等信息,用于改善服务质量和安全性。
</p>
<p><strong>4. 日志信息:</strong>当您使用本应用时我们可能自动收集某些信息包括IP地址、访问时间、访问页面等。
</p>
</section>
<section>
<h2>三、信息使用</h2>
<p>我们使用收集的信息用于以下目的:</p>
<p>1. 提供、维护和改进我们的服务;</p>
<p>2. 处理您的注册、登录、学习记录等请求;</p>
<p>3. 向您发送服务通知、更新和安全提醒;</p>
<p>4. 进行数据分析,以改善用户体验和服务质量;</p>
<p>5. 检测、预防和解决技术问题;</p>
<p>6. 遵守法律法规要求。</p>
</section>
<section>
<h2>四、信息存储</h2>
<p>1. 我们采用行业标准的安全措施来保护您的个人信息,防止未经授权的访问、使用或泄露。</p>
<p>2. 您的个人信息将存储在安全的服务器上,我们会对数据进行加密处理。</p>
<p>3. 我们仅在为实现本隐私政策所述目的所必需的期间内保留您的个人信息。</p>
</section>
<section>
<h2>五、信息共享</h2>
<p>我们不会向第三方出售、交易或转让您的个人信息,除非:</p>
<p>1. 获得您的明确同意;</p>
<p>2. 法律法规要求或司法机关、行政机关依法要求提供;</p>
<p>3. 为履行我们的服务协议或本隐私政策,我们可能需要与我们的服务提供商共享某些信息。</p>
</section>
<section>
<h2>六、Cookie和类似技术</h2>
<p>
我们可能使用Cookie和类似技术来收集信息、改善用户体验和分析服务使用情况。您可以通过浏览器设置管理Cookie但这可能影响某些功能的正常使用。</p>
</section>
<section>
<h2>七、您的权利</h2>
<p>根据相关法律法规,您对自己的个人信息享有以下权利:</p>
<p>1. <strong>访问权:</strong>您有权访问我们持有的关于您的个人信息;</p>
<p>2. <strong>更正权:</strong>您有权要求更正不准确的个人信息;</p>
<p>3. <strong>删除权:</strong>在特定情况下,您有权要求删除您的个人信息;</p>
<p>4. <strong>撤回同意:</strong>您有权随时撤回您之前给予的同意;</p>
<p>5. <strong>投诉权:</strong>如果您认为我们对您个人信息的处理违反了相关法律法规,您有权向相关监管部门投诉。
</p>
</section>
<section>
<h2>八、未成年人保护</h2>
<p>
我们非常重视未成年人的个人信息保护。如果您是未成年人,建议您请您的父母或监护人仔细阅读本隐私政策,并在征得您的父母或监护人同意的前提下使用我们的服务。</p>
</section>
<section>
<h2>九、隐私政策更新</h2>
<p>
我们可能会不时更新本隐私政策。我们会在本页面上发布新的隐私政策,并通过适当方式通知您。如果您不同意更新后的隐私政策,您可以选择停止使用我们的服务。</p>
</section>
<section>
<h2>十、联系我们</h2>
<p>如果您对本隐私政策有任何疑问、意见或建议,或需要行使您的相关权利,请通过以下方式联系我们:</p>
<p>邮箱zyronon@163.com</p>
</section>
<div class="update-time">
<p>最后更新时间2025年11月11日</p>
</div>
</div>
</div>
</body>
</html>

BIN
public/qq.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View File

@@ -1,4 +1,4 @@
User-agent: *
Disallow:
Sitemap: https://2study.top/sitemap.xml
Sitemap: https://typewords.cc/sitemap.xml

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)}();

569
public/static-home.html Normal file
View File

@@ -0,0 +1,569 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<title>Type Words 官网 - 词文记 | 单词跟打 · 文章跟打</title>
<!-- 搜索引擎描述 -->
<meta name="description"
content="Type Words 官方网站 - 在线英语练习平台支持单词、文章跟打练习提升英语学习效率。Practice English, one strike, one step forward">
<!-- 关键词(可选,搜索引擎基本不用,但能补充信息) -->
<meta name="keywords"
content="Type Words, Typing Word, Type Words 官网, 官方网站, 英语打字练习, 单词跟打, 文章跟打, 键盘练习, 英语学习, 文章学习, 打字练习软件, 单词记忆工具, 英语学习软件, 背单词神器, 英语肌肉记忆, 键盘工作者, 免费英语学习, 音标发音, 默写练习, 在线学英语, CET-4, CET-6, TOEFL, IELTS, GRE, GMAT, SAT, 考研英语, 专四专八, 程序员英语, JavaScript API, Node.js API, Java API, Linux命令, 编程词汇, 技术英语, VSCode插件, 开源项目, GitHub趋势榜, V2EX热搜, Gitee GVP, 少数派推荐, 英语打字训练, WPM统计, 准确率分析, 商务英语, BEC, 雅思听力, 日语学习, 多语言学习, 英语口语练习, 单词拼写训练">
<meta name="author" content="zyronon"/>
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"/>
<link rel="canonical" href="https://typewords.cc/"/>
<!-- Open Graph用于社交媒体分享微信/QQ/知乎/Facebook 等) -->
<meta property="og:title" content="Type Words 官网 - 英语打字练习平台">
<meta property="og:description"
content="Type Words 官方网站 - 在线英语练习平台支持单词、文章跟打练习提升英语学习效率。Practice English, one strike, one step forward">
<meta property="og:type" content="website">
<meta property="og:url" content="https://typewords.cc/">
<meta property="og:image" content="https://typewords.cc/favicon.png">
<!-- Twitter Card用于 Twitter 分享) -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Type Words 官网 - 英语打字练习平台">
<meta name="twitter:description"
content="Type Words 官方网站 - 在线英语练习平台支持单词、文章跟打练习提升英语学习效率。Practice English, one strike, one step forward">
<meta name="twitter:image" content="https://typewords.cc/favicon.png">
<link rel="icon" type="image/svg+xml" href="/favicon.png"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<!-- 苹果设备iOS Safari在用户添加到主屏时显示的图标-->
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.png"/>
<!-- 设置浏览器地址栏颜色(在 Android Chrome 特别明显)。-->
<meta name="theme-color" content="#818CF8"/>
<link rel="manifest" href="/manifest.json">
<!-- 阻止 iOS 自动把数字识别为电话号码。-->
<!-- HandheldFriendly 和 MobileOptimized 是旧手机浏览器的优化提示(现在作用不大)。-->
<meta name="format-detection" content="telephone=no"/>
<meta name="HandheldFriendly" content="True"/>
<meta name="MobileOptimized" content="320"/>
<!-- referrer 控制请求来源信息-->
<meta name="referrer" content="origin-when-cross-origin"/>
<!-- color-scheme 告诉浏览器支持亮/暗模式-->
<meta name="color-scheme" content="light dark"/>
<style>
body {
background: rgb(231, 232, 235);
}
h1 {
font-size: 4.8rem !important;
background: linear-gradient(120deg, #bd34fe 30%, #41d1ff);
-webkit-text-fill-color: transparent;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
margin: 0;
font-weight: bold !important;
}
h2 {
font-size: 1.4rem !important;
font-weight: normal !important;
color: rgb(91, 91, 91);
margin: 0;
}
.card {
position: relative;
border-radius: 1rem;
padding: 1rem;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.6rem;
margin-bottom: 0;
width: 25%;
background: rgb(247, 247, 247);
.emoji {
display: inline-block;
background: rgb(226 232 240 / 1);
padding: 0.3rem .6rem;
border-radius: 0.4rem;
font-size: 1.5rem;
}
.title {
font-weight: bold;
}
ul {
margin: 0;
padding-left: 1.2rem;
}
}
a {
color: dodgerblue !important;
}
.base-button {
cursor: pointer;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
outline: none;
text-align: center;
transition: 0.1s;
user-select: none;
vertical-align: middle;
white-space: nowrap;
border-radius: 0.3rem;
color: white;
background: rgb(12, 140, 233);
padding: 0 1.3rem;
height: 2.5rem;
font-size: 0.9rem;
}
.base-button + .base-button {
margin-left: 2rem;
}
.base-button:hover {
opacity: 0.8;
}
.icon {
cursor: pointer;
width: 2rem;
height: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: .3rem;
background: transparent;
transition: all .3s;
color: dimgray;
}
.icon:hover {
background: rgb(12, 140, 233);
color: white;
}
.mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.3);
display: none;
opacity: 0;
transition: all .3s;
}
.dialog {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: rgb(247, 247, 247);
width: 30rem;
border-radius: 1rem;
display: none;
opacity: 0;
transition: all .3s;
}
.dialog header {
padding: 1rem;
display: flex;
justify-content: space-between;
}
.dialog header .title {
font-size: 1.4rem;
}
.dialog-body {
padding: 0 1.2rem 1.2rem 1.2rem;
}
.wrapper {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 100vh;
}
.content {
margin-top: 6rem;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 2rem;
}
.text-center {
text-align: center;
}
.sky {
margin-top: 3rem;
border-top: 1px solid #cecece;
border-bottom: 1px solid #cecece;
padding: 1.2rem 0;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 0.4rem;
width: 100%;
margin-bottom: 1rem;
}
.w {
width: 60vw;
}
.sky-img {
width: 100%;
border-radius: 0.5rem;
}
.card-wrap {
display: flex;
margin-bottom: 1.2rem;
gap: 1rem;
}
.bottom {
display: flex;
gap: 1rem;
margin: 1rem 0 2rem 0;
width: 100%;
padding-top: 1.5rem;
border-top: 1px solid #c4c4c4;
justify-content: center;
align-items: center;
}
.gap-1 {
gap: 1rem;
}
.center {
display: flex;
justify-content: center;
align-items: center;
}
.img {
width: 16rem;
border-radius: 1rem;
margin-top: 1.2rem;
}
.cursor-pointer{
cursor: pointer;
}
</style>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js').then(registration => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}).catch(error => {
console.log('ServiceWorker registration failed: ', error);
});
});
}
</script>
<script>
function nav(url) {
window.location.href = url;
// history.pushState(null, "", url);
}
function toggleEl(val, close = false) {
let le = document.querySelector(val)
if (le) {
if (['none', ''].includes(le.style.display) && !close) {
le.style.display = 'block';
setTimeout(function () {
le.style.opacity = 1;
}, 10)
} else {
le.style.opacity = 0;
setTimeout(function () {
le.style.display = 'none';
}, 300)
}
}
}
function toggleWechatDialog() {
toggleEl('.mask')
toggleEl('#wechatDialog')
}
function toggleQQDialog() {
toggleEl('.mask')
toggleEl('#qqDialog')
}
function toggleXhsDialog() {
toggleEl('.mask')
toggleEl('#xhsDialog')
}
function closeDialog() {
toggleEl('.mask')
toggleEl('#wechatDialog', true)
toggleEl('#xhsDialog', true)
toggleEl('#qqDialog', true)
}
</script>
</head>
<body>
<div class="wrapper">
<div class="content">
<h1>Type Words</h1>
<div class="text-center">
<h2>学习英语,一次敲击,一点进步,开源单词与文章练习工具</h2>
</div>
<div class="">
<div class="base-button" onclick="nav('/words')">单词练习</div>
<div class="base-button" onclick="nav('/articles')">文章练习</div>
</div>
<div class="sky">
<a href="https://skywork.ai/p/GrXQb4" style="width: 40%;" target="_blank">
<img src="https://typewords.cc/skywork-ai.png"
alt="Skywork.AI"
class="sky-img"></a>
<span>赞助:<a href="https://skywork.ai/p/GrXQb4" class="color-blue!" target="_blank">Skywork.AI: 10 tasks in 1 hour, not 10 hours →Limited free spots: 127 left</a></span>
</div>
<div class="w">
<div class="card-wrap">
<div class="card">
<div class="emoji">📚</div>
<div class="title">单词练习</div>
<div class="desc">
<ul>
<li>三种输入模式:跟打 / 复习 / 默写</li>
<li>智能模式:智能规划复习与默写</li>
<li>自由模式:不受限制,自行规划</li>
</ul>
</div>
</div>
<div class="card">
<div class="emoji">✍️</div>
<div class="title">文章练习</div>
<div class="desc">
<ul>
<li>内置常见书籍,也可自行添加文章</li>
<li>跟打 + 默写双模式,让背诵更高效</li>
<li>支持边听边默写,强化记忆</li>
</ul>
</div>
</div>
<div class="card">
<div class="emoji">📕</div>
<div class="title">收藏、错词本、已掌握</div>
<div class="desc">
<ul>
<li>输入错误自动添加到错词本</li>
<li>主动添加到已掌握,后续自动跳过</li>
<li>主动添加到收藏中,以便巩固复习</li>
</ul>
</div>
</div>
<div class="card">
<div class="emoji">🌐</div>
<div class="title">海量词库</div>
<div class="desc">
内置小学、初中、高中、四六级、考研、雅思、托福、GRE、GMAT、SAT、BEC、专四、专八等词库
</div>
</div>
</div>
<div class="card-wrap">
<div class="card">
<div class="emoji">🆓</div>
<div class="title">免费开源</div>
<div class="desc">
<ul>
<li>完全开源,可审查、可修改</li>
<li>免费使用</li>
<li>私有部署</li>
</ul>
</div>
</div>
<div class="card">
<div class="emoji">⚙️</div>
<div class="title">高度自由</div>
<div class="desc">
<ul>
<li>丰富的键盘音效</li>
<li>可自定义快捷键</li>
<li>高度定制化的设置选项</li>
</ul>
</div>
</div>
<div class="card">
<div class="emoji">🎨</div>
<div class="title">简洁高效</div>
<div class="desc">
<ul>
<li>简洁设计现代化UI</li>
<li>界面清爽,操作简单</li>
<li>不强制关注任何平台</li>
</ul>
</div>
</div>
<div class="card">
<div class="emoji">🎯</div>
<div class="title">个性学习</div>
<div class="desc">
<ul>
<li>自由添加词典与文章</li>
<li>定制个性学习计划</li>
<li>多种学习复习策略</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="bottom">
<div class="center gap-1">
<a
href="https://github.com/zyronon/TypeWords"
target="_blank"
rel="noreferrer"
aria-label="Github Address">
<div class="icon">
<svg viewBox="0 0 24 24" width="1.4em" height="1.4em">
<path fill="currentColor"
d="M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
</svg>
</div>
</a>
<div class="icon" onclick="toggleWechatDialog()">
<svg viewBox="0 0 24 24" width="1.4em" height="1.4em">
<path fill="currentColor"
d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213c0 .163.13.295.29.295a.33.33 0 0 0 .167-.054l1.903-1.114a.86.86 0 0 1 .717-.098a10.2 10.2 0 0 0 2.837.403c.276 0 .543-.027.811-.05c-.857-2.578.157-4.972 1.932-6.446c1.703-1.415 3.882-1.98 5.853-1.838c-.576-3.583-4.196-6.348-8.596-6.348M5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178a1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18m5.34 2.867c-1.797-.052-3.746.512-5.28 1.786c-1.72 1.428-2.687 3.72-1.78 6.22c.942 2.453 3.666 4.229 6.884 4.229c.826 0 1.622-.12 2.361-.336a.72.72 0 0 1 .598.082l1.584.926a.3.3 0 0 0 .14.047c.134 0 .24-.111.24-.247c0-.06-.023-.12-.038-.177l-.327-1.233a.6.6 0 0 1-.023-.156a.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983a.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983a.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982"></path>
</svg>
</div>
<div class="icon" onclick="toggleQQDialog()">
<svg viewBox="0 0 24 24" width="1.4em" height="1.4em">
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"/>
<path fill="currentColor"
d="M12 2a6.285 6.285 0 0 0-6.276 5.937l-.146 2.63a28 28 0 0 0-.615 1.41c-1.24 3.073-1.728 5.773-1.088 6.032c.335.135.913-.426 1.566-1.432a6.67 6.67 0 0 0 1.968 3.593c-1.027.35-1.91.828-1.91 1.33c0 .509 2.48.503 4.239.5h.001c.549-.002 1.01-.008 1.38-.057a6.7 6.7 0 0 0 1.76 0c.37.05.833.055 1.382.056c1.76.004 4.239.01 4.239-.499c0-.502-.883-.979-1.909-1.33a6.67 6.67 0 0 0 1.967-3.586c.65 1.002 1.227 1.56 1.56 1.425c.64-.259.154-2.96-1.088-6.032a28 28 0 0 0-.607-1.395l-.147-2.645A6.285 6.285 0 0 0 12 2"/>
</g>
</svg>
</div>
<div class="icon" onclick="toggleXhsDialog()">
<svg viewBox="0 0 24 24" width="1.4em" height="1.4em">
<path fill="currentColor"
d="M22.405 9.879c.002.016.01.02.07.019h.725a.797.797 0 0 0 .78-.972a.794.794 0 0 0-.884-.618a.795.795 0 0 0-.692.794c0 .101-.002.666.001.777m-11.509 4.808c-.203.001-1.353.004-1.685.003a2.5 2.5 0 0 1-.766-.126a.025.025 0 0 0-.03.014L7.7 16.127a.025.025 0 0 0 .01.032c.111.06.336.124.495.124c.66.01 1.32.002 1.981 0q.017 0 .023-.015l.712-1.545a.025.025 0 0 0-.024-.036zM.477 9.91c-.071 0-.076.002-.076.01l-.01.08c-.027.397-.038.495-.234 3.06c-.012.24-.034.389-.135.607c-.026.057-.033.042.003.112c.046.092.681 1.523.787 1.74c.008.015.011.02.017.02c.008 0 .033-.026.047-.044q.219-.282.371-.606c.306-.635.44-1.325.486-1.706c.014-.11.021-.22.03-.33l.204-2.616l.022-.293c.003-.029 0-.033-.03-.034zm7.203 3.757a1.4 1.4 0 0 1-.135-.607c-.004-.084-.031-.39-.235-3.06a.4.4 0 0 0-.01-.082c-.004-.011-.052-.008-.076-.008h-1.48c-.03.001-.034.005-.03.034l.021.293q.114 1.473.233 2.946c.05.4.186 1.085.487 1.706c.103.215.223.419.37.606c.015.018.037.051.048.049c.02-.003.742-1.642.804-1.765c.036-.07.03-.055.003-.112m3.861-.913h-.872a.126.126 0 0 1-.116-.178l1.178-2.625a.025.025 0 0 0-.023-.035l-1.318-.003a.148.148 0 0 1-.135-.21l.876-1.954a.025.025 0 0 0-.023-.035h-1.56q-.017 0-.024.015l-.926 2.068c-.085.169-.314.634-.399.938a.5.5 0 0 0-.02.191a.46.46 0 0 0 .23.378a1 1 0 0 0 .46.119h.59c.041 0-.688 1.482-.834 1.972a.5.5 0 0 0-.023.172a.47.47 0 0 0 .23.398c.15.092.342.12.475.12l1.66-.001q.017 0 .023-.015l.575-1.28a.025.025 0 0 0-.024-.035m-6.93-4.937H3.1a.032.032 0 0 0-.034.033c0 1.048-.01 2.795-.01 6.829c0 .288-.269.262-.28.262h-.74c-.04.001-.044.004-.04.047c.001.037.465 1.064.555 1.263c.01.02.03.033.051.033c.157.003.767.009.938-.014c.153-.02.3-.06.438-.132c.3-.156.49-.419.595-.765c.052-.172.075-.353.075-.533q.003-3.495-.007-6.991a.03.03 0 0 0-.032-.032zm11.784 6.896q-.002-.02-.024-.022h-1.465c-.048-.001-.049-.002-.05-.049v-4.66c0-.072-.005-.07.07-.07h.863c.08 0 .075.004.075-.074V8.393c0-.082.006-.076-.08-.076h-3.5c-.064 0-.075-.006-.075.073v1.445c0 .083-.006.077.08.077h.854c.075 0 .07-.004.07.07v4.624c0 .095.008.084-.085.084c-.37 0-1.11-.002-1.304 0c-.048.001-.06.03-.06.03l-.697 1.519s-.014.025-.008.036s.013.008.058.008q2.622.003 5.243.002c.03-.001.034-.006.035-.033zm4.177-3.43q0 .021-.02.024c-.346.006-.692.004-1.037.004q-.021-.003-.022-.024q-.006-.651-.01-1.303c0-.072-.006-.071.07-.07l.733-.003c.041 0 .081.002.12.015c.093.025.16.107.165.204c.006.431.002 1.153.001 1.153m2.67.244a1.95 1.95 0 0 0-.883-.222h-.18c-.04-.001-.04-.003-.042-.04V10.21q.001-.198-.025-.394a1.8 1.8 0 0 0-.153-.53a1.53 1.53 0 0 0-.677-.71a2.2 2.2 0 0 0-1-.258c-.153-.003-.567 0-.72 0c-.07 0-.068.004-.068-.065V7.76c0-.031-.01-.041-.046-.039H17.93s-.016 0-.023.007q-.008.008-.008.023v.546c-.008.036-.057.015-.082.022h-.95c-.022.002-.028.008-.03.032v1.481c0 .09-.004.082.082.082h.913c.082 0 .072.128.072.128v1.148s.003.117-.06.117h-1.482c-.068 0-.06.082-.06.082v1.445s-.01.068.064.068h1.457c.082 0 .076-.006.076.079v3.225c0 .088-.007.081.082.081h1.43c.09 0 .082.007.082-.08v-3.27c0-.029.006-.035.033-.035l2.323-.003a.7.7 0 0 1 .28.061a.46.46 0 0 1 .274.407c.008.395.003.79.003 1.185c0 .259-.107.367-.33.367h-1.218c-.023.002-.029.008-.028.033q.276.655.57 1.303a.05.05 0 0 0 .04.026c.17.005.34.002.51.003c.15-.002.517.004.666-.01a2 2 0 0 0 .408-.075c.59-.18.975-.698.976-1.313v-1.981q.001-.191-.034-.38c0 .078-.029-.641-.724-.998"></path>
</svg>
</div>
<a
href="https://x.com/typewords2"
target="_blank"
rel="noreferrer"
aria-label="关注我的 X 账户 typewords2">
<div class="icon">
<svg viewBox="0 0 24 24" width="1.4em" height="1.4em">
<path fill="currentColor"
d="M22.213 5.656a8.4 8.4 0 0 1-2.402.658A4.2 4.2 0 0 0 21.649 4c-.82.488-1.719.83-2.655 1.015a4.182 4.182 0 0 0-7.126 3.814a11.87 11.87 0 0 1-8.621-4.37a4.17 4.17 0 0 0-.566 2.103c0 1.45.739 2.731 1.86 3.481a4.2 4.2 0 0 1-1.894-.523v.051a4.185 4.185 0 0 0 3.355 4.102a4.2 4.2 0 0 1-1.89.072A4.185 4.185 0 0 0 8.02 16.65a8.4 8.4 0 0 1-6.192 1.732a11.83 11.83 0 0 0 6.41 1.88c7.694 0 11.9-6.373 11.9-11.9q0-.271-.012-.541a8.5 8.5 0 0 0 2.086-2.164"></path>
</svg>
</div>
</a>
<a
href="mailto:zyronon@163.com"
target="_blank"
rel="noreferrer"
aria-label="发送邮件到 zyronon@163.com">
<div class="icon">
<svg viewBox="0 0 24 24" width="1.4em" height="1.4em">
<path fill="currentColor"
d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8-7l8-5V6l-8 5l-8-5v2z"></path>
</svg>
</div>
</a>
</div>
<div><a href="https://beian.mps.gov.cn/#/query/webSearch?code=51015602001426" target="_blank">川公网安备51015602001426号 </a></div>
<div><a href="https://beian.miit.gov.cn/" target="_blank">蜀ICP备2025157466号-2</a></div>
</div>
<div class="mask" onclick="closeDialog()"></div>
<div class="dialog" id="wechatDialog">
<header>
<div class="title">微信群</div>
<svg
onclick="toggleWechatDialog()"
viewBox="0 0 20 20" width="24" height="1.2em" class="cursor-pointer">
<path fill="currentColor"
d="m4.089 4.216l.057-.07a.5.5 0 0 1 .638-.057l.07.057L10 9.293l5.146-5.147a.5.5 0 0 1 .638-.057l.07.057a.5.5 0 0 1 .057.638l-.057.07L10.707 10l5.147 5.146a.5.5 0 0 1 .057.638l-.057.07a.5.5 0 0 1-.638.057l-.07-.057L10 10.707l-5.146 5.147a.5.5 0 0 1-.638.057l-.07-.057a.5.5 0 0 1-.057-.638l.057-.07L9.293 10L4.146 4.854a.5.5 0 0 1-.057-.638l.057-.07z"></path>
</svg>
</header>
<div class="dialog-body">
<span>加入我们的用户社群后,您可以与我们的开发团队进行沟通,分享您的使用体验和建议,帮助我们改进产品,同时也能够及时了解我们的最新动态和更新内容。</span>
<div class="center">
<img src="/wechat.png" alt="微信群二维码" class="img">
</div>
</div>
</div>
<div class="dialog" id="xhsDialog">
<header>
<div class="title">小红书</div>
<svg
onclick="toggleXhsDialog()"
viewBox="0 0 20 20" width="24" height="1.2em" class="cursor-pointer">
<path fill="currentColor"
d="m4.089 4.216l.057-.07a.5.5 0 0 1 .638-.057l.07.057L10 9.293l5.146-5.147a.5.5 0 0 1 .638-.057l.07.057a.5.5 0 0 1 .057.638l-.057.07L10.707 10l5.147 5.146a.5.5 0 0 1 .057.638l-.057.07a.5.5 0 0 1-.638.057l-.07-.057L10 10.707l-5.146 5.147a.5.5 0 0 1-.638.057l-.07-.057a.5.5 0 0 1-.057-.638l.057-.07L9.293 10L4.146 4.854a.5.5 0 0 1-.057-.638l.057-.07z"></path>
</svg>
</header>
<div class="dialog-body">
<span>关注小红书后,您可以获得开发团队的最新动态和更新内容,反馈您的使用体验和建议,帮助我们改进产品,同时也能够及时了解我们的最新动态和更新内容。</span>
<div class="center">
<img src="/xhs.png" alt="小红书二维码" class="img">
</div>
</div>
</div>
<div class="dialog" id="qqDialog">
<header>
<div class="title">QQ群</div>
<svg
onclick="toggleQQDialog()"
viewBox="0 0 20 20" width="24" height="1.2em" class="cursor-pointer">
<path fill="currentColor"
d="m4.089 4.216l.057-.07a.5.5 0 0 1 .638-.057l.07.057L10 9.293l5.146-5.147a.5.5 0 0 1 .638-.057l.07.057a.5.5 0 0 1 .057.638l-.057.07L10.707 10l5.147 5.146a.5.5 0 0 1 .057.638l-.057.07a.5.5 0 0 1-.638.057l-.07-.057L10 10.707l-5.146 5.147a.5.5 0 0 1-.638.057l-.07-.057a.5.5 0 0 1-.057-.638l.057-.07L9.293 10L4.146 4.854a.5.5 0 0 1-.057-.638l.057-.07z"></path>
</svg>
</header>
<div class="dialog-body">
<span>加入我们的用户社群后,您可以与我们的开发团队进行沟通,分享您的使用体验和建议,帮助我们改进产品,同时也能够及时了解我们的最新动态和更新内容。</span>
<div class="center">
<img src="/qq.jpg" alt="QQ群二维码" class="img">
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>用户协议</title>
</head>
<body
style="display:flex;justify-content:center">
<div class="privacy-page"
style="width: 60vw;"
>
<h1 style="text-align: center">用户协议</h1>
<div class="content">
<section>
<h2>一、总则</h2>
<p>欢迎使用本应用!在使用本应用之前,请您仔细阅读本用户协议(以下简称"本协议")。当您注册、登录、使用(以下统称"使用")本应用时,即表示您已阅读、理解并同意接受本协议的全部内容。</p>
</section>
<section>
<h2>二、服务内容</h2>
<p>本应用为用户提供单词学习、文章阅读等在线教育服务。我们保留随时修改或中断服务而不需通知用户的权利,我们行使修改或中断服务的权利,不需对用户或第三方负责。</p>
</section>
<section>
<h2>三、用户账户</h2>
<p>1. 用户在使用本应用前需要注册一个账户。用户应当使用真实、准确、完整的信息注册账户。</p>
<p>2. 用户有责任维护账户信息的安全,对账户下的所有活动负责。</p>
<p>3. 用户不得将账户转让、出售或以其他方式提供给第三方使用。</p>
</section>
<section>
<h2>四、用户行为规范</h2>
<p>用户在使用本应用时,应当遵守相关法律法规,不得从事以下行为:</p>
<p>1. 发布、传播违法、有害、威胁、辱骂、骚扰、侵权、诽谤、淫秽、暴力或其他不当内容;</p>
<p>2. 侵犯他人知识产权、隐私权或其他合法权益;</p>
<p>3. 干扰或破坏本应用的正常运行;</p>
<p>4. 使用自动化工具或脚本进行数据采集、批量操作等;</p>
<p>5. 其他违反法律法规或本协议的行为。</p>
</section>
<section>
<h2>五、知识产权</h2>
<p>1. 本应用的所有内容,包括但不限于文字、图片、音频、视频、软件、程序、版面设计等,均受知识产权法保护。</p>
<p>2. 未经我们书面许可,用户不得复制、传播、展示、镜像、上传、下载本应用的任何内容。</p>
</section>
<section>
<h2>六、隐私保护</h2>
<p>我们重视用户的隐私保护。关于我们如何收集、使用、存储和保护您的个人信息,请详见《隐私政策》。</p>
</section>
<section>
<h2>七、免责声明</h2>
<p>1. 用户明确同意使用本应用的风险由用户个人承担。</p>
<p>2. 我们不对因不可抗力或非我们原因造成的服务中断或终止承担责任。</p>
<p>3. 我们不对用户在使用本应用过程中产生的任何直接、间接、偶然、特殊及后续的损害承担责任。</p>
</section>
<section>
<h2>八、协议修改</h2>
<p>我们有权随时修改本协议的任何条款。一旦本协议的内容发生变动,我们将会通过适当方式向用户提示修改内容。如果用户不同意我们对本协议相关条款所做的修改,用户有权停止使用本应用。如果用户继续使用本应用,则视为用户接受我们对本协议相关条款所做的修改。</p>
</section>
<section>
<h2>九、法律适用与争议解决</h2>
<p>1. 本协议的订立、执行和解释及争议的解决均应适用中华人民共和国法律。</p>
<p>2. 如双方就本协议内容或其执行发生任何争议,双方应尽量友好协商解决;协商不成时,任何一方均可向我们所在地的人民法院提起诉讼。</p>
</section>
<section>
<h2>十、其他</h2>
<p>1. 本协议构成双方对本协议之约定事项及其他有关事宜的完整协议,除本协议规定的之外,未赋予本协议各方其他权利。</p>
<p>2. 如本协议中的任何条款无论因何种原因完全或部分无效或不具有执行力,本协议的其余条款仍应有效并且有约束力。</p>
</section>
<div class="update-time">
<p>最后更新时间2025年11月11日</p>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

View File

@@ -8,10 +8,9 @@ const {
OSS_KEY_ID,
OSS_KEY_SECRET,
OSS_BUCKET,
CDN_DOMAIN
} = process.env
if (!OSS_REGION || !OSS_KEY_ID || !OSS_KEY_SECRET || !OSS_BUCKET || !CDN_DOMAIN) {
if (!OSS_REGION || !OSS_KEY_ID || !OSS_KEY_SECRET || !OSS_BUCKET) {
console.error('❌ 缺少必要的环境变量,请检查 GitHub Secrets 配置')
process.exit(1)
}
@@ -116,10 +115,10 @@ async function uploadFilesWithClean(files, localBase = './dist', ignoreDirs = []
// 刷新 CDN
async function refreshCDN() {
console.log('🔄 刷新 CDN 缓存...')
async function refreshCDN(domain) {
console.log(`🔄 刷新 ${domain} CDN 缓存...`)
const params = {
ObjectPath: `https://${CDN_DOMAIN}/`,
ObjectPath: `https://${domain}/`,
ObjectType: 'Directory'
}
const requestOption = {method: 'POST'}
@@ -132,7 +131,8 @@ async function main() {
console.log(`📁 共找到 ${files.length} 个文件,开始上传...`)
await uploadFilesWithClean(files, './dist', ['dicts', 'sound', 'libs'])
// await uploadFilesWithClean(files, './dist', ['libs'])
await refreshCDN()
await refreshCDN('2study.top')
await refreshCDN('typewords.cc')
}
main().catch(err => {

50
scripts/do.js Normal file
View File

@@ -0,0 +1,50 @@
const {SitemapStream, streamToPromise} = require('sitemap')
const {createWriteStream} = require('fs')
const {resolve} = require('path')
const fs = require('fs')
async function generateSitemap() {
const bookList = require('../public/list/article.json')
const dictList = require('../public/list/word.json')
const SITE_URL = 'https://typewords.cc'
// 静态路由(首页、练习页等)
const staticPages = [
{url: '/index.html', changefreq: 'monthly', priority: 1.0},
{url: '/', changefreq: 'daily', priority: 1.0},
{url: '/words', changefreq: 'daily', priority: 0.9},
{url: '/articles', changefreq: 'daily', priority: 0.9},
{url: '/setting', changefreq: 'monthly', priority: 0.3},
]
// 动态页面示例(假设你有文章或单词数据)
const dynamicPages = bookList.flat().map(book => {
return {url: '/practice-articles/' + book.id, changefreq: 'weekly', priority: 0.8}
}).concat(dictList.flat().map(book => {
return {url: '/practice-words/' + book.id, changefreq: 'weekly', priority: 0.8}
}))
const sitemap = new SitemapStream({hostname: SITE_URL})
const writeStream = createWriteStream(resolve(__dirname, '../dist/sitemap.xml'))
sitemap.pipe(writeStream)
// 添加静态页
staticPages.forEach(page => sitemap.write(page))
// 添加动态页
dynamicPages.forEach(page => sitemap.write(page))
sitemap.end()
await streamToPromise(sitemap)
console.log('✅ sitemap.xml 已生成在 dist 目录')
}
function renameHtml() {
//首页为了seo被剥离出去了现在是一个静态页面用nginx 重定向控制对应的跳转
fs.renameSync('dist/index.html', 'dist/app.html')
fs.renameSync('dist/static-home.html', 'dist/index.html')
}
generateSitemap()
renameHtml()

View File

@@ -1,42 +0,0 @@
const {SitemapStream, streamToPromise} = require('sitemap')
const {createWriteStream} = require('fs')
const {resolve} = require('path')
const bookList = require('../public/list/article.json')
const dictList = require('../public/list/word.json')
// 你的网站域名
const SITE_URL = 'https://2study.top'
// 静态路由(首页、练习页等)
const staticPages = [
{url: '/', changefreq: 'daily', priority: 1.0},
{url: '/words', changefreq: 'daily', priority: 0.9},
{url: '/articles', changefreq: 'daily', priority: 0.9},
{url: '/setting', changefreq: 'monthly', priority: 0.3},
]
// 动态页面示例(假设你有文章或单词数据)
const dynamicPages = bookList.flat().map(book => {
return {url: '/practice-articles/' + book.id, changefreq: 'weekly', priority: 0.8}
}).concat(dictList.flat().map(book => {
return {url: '/practice-words/' + book.id, changefreq: 'weekly', priority: 0.8}
}))
async function generateSitemap() {
const sitemap = new SitemapStream({hostname: SITE_URL})
const writeStream = createWriteStream(resolve(__dirname, '../dist/sitemap.xml'))
sitemap.pipe(writeStream)
// 添加静态页
staticPages.forEach(page => sitemap.write(page))
// 添加动态页
dynamicPages.forEach(page => sitemap.write(page))
sitemap.end()
await streamToPromise(sitemap)
console.log('✅ sitemap.xml 已生成在 dist 目录')
}
generateSitemap()

View File

@@ -4,7 +4,7 @@ const dictList = require('../public/list/word.json')
async function pushUrls() {
// 配置区:改成你的
const site = "https://2study.top"; // 必须和百度站长平台注册的域名一致
const site = "https://typewords.cc"; // 必须和百度站长平台注册的域名一致
const token = ""; // 在百度站长平台获取
// 读取 urls.txt每行一个 URL

View File

@@ -1,21 +1,23 @@
<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 { routes } from "@/router.ts";
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, CAN_REQUEST, LOCAL_FILE_KEY, SAVE_DICT_KEY, SAVE_SETTING_KEY } from "@/config/env.ts";
import { syncSetting } from "@/apis";
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()
let lastAudioFileIdList = []
@@ -49,20 +51,24 @@ watch(store.$state, (n: BaseState) => {
}
})
watch(settingStore.$state, (n) => {
watch(() => settingStore.$state, (n) => {
set(SAVE_SETTING_KEY.key, JSON.stringify({val: n, version: SAVE_SETTING_KEY.version}))
if (CAN_REQUEST) {
if (AppEnv.CAN_REQUEST) {
syncSetting(null, settingStore.$state)
}
})
}, {deep: true})
async function init() {
await userStore.init()
await store.init()
await settingStore.init()
store.load = true
setTheme(settingStore.theme)
if (!settingStore.first) {
if (settingStore.first) {
set(APP_VERSION.key, APP_VERSION.version)
} else {
get(APP_VERSION.key).then(r => {
runtimeStore.isNew = r ? (APP_VERSION.version > Number(r)) : true
})
@@ -72,27 +78,38 @@ async function init() {
onMounted(init)
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 = ''
//迁移数据
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)
}
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)
})
// 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)
// })
</script>
<template>
@@ -104,8 +121,8 @@ watch(() => route.path, (to, from) => {
<!-- </transition>-->
<!-- </router-view>-->
<router-view></router-view>
</template>
<style scoped lang="scss">
</style>
<MigrateDialog
v-model="showTransfer"
@ok="init"
/>
</template>

View File

@@ -48,7 +48,7 @@ export function addDict(params?, data?) {
return http<Dict>('dict/addDict', remove(data), remove(params), 'post')
}
export function uploadImportData(data,onUploadProgress) {
export function uploadImportData(data, onUploadProgress) {
return axiosInstance({
url: 'dict/uploadImportData',
method: 'post',
@@ -59,3 +59,7 @@ export function uploadImportData(data,onUploadProgress) {
onUploadProgress
})
}
export function getProgress() {
return http<{ status: number; reason: string }>('dict/getProgress', null, null, 'get')
}

69
src/apis/member.ts Normal file
View File

@@ -0,0 +1,69 @@
import http from '@/utils/http.ts'
export type LevelBenefits = {
"level": {
"id": number,
"name": string,
"code": string,
"level": number,
"price": string,
"price_auto": string,
"yearly_price": string,
"description": string,
"color": string,
"icon": string,
"is_active": number,
"created_at": string,
"updated_at": string
},
"benefits": {
"code": string,
"name": string,
"type": boolean,
"unit": null,
"value": string
}[]
}
export type CouponInfo = {
"id": number,
"code": string,
"name": string,
"type": string,
"value"?: string,
"min_amount"?: string,
"max_discount"?: string,
"applicable_levels": {
code: string,
name: string,
level: string,
}[]
"usage_limit": number,
"total_usage": number,
"start_date": string
"end_date": string
"is_active": number,
"created_at": string
"updated_at": string
"is_valid": boolean,
}
export function levelBenefits(params) {
return http<LevelBenefits>('member/levelBenefits', null, params, 'get')
}
export function orderCreate(params) {
return http<{ orderNo: string }>('/member/orderCreate', params, null, 'post')
}
export function orderStatus(params) {
return http('/member/orderStatus', null, params, 'get')
}
export function couponInfo(params) {
return http<CouponInfo>('/member/couponInfo', null, params, 'get')
}
export function setAutoRenewApi(params) {
return http('/member/setAutoRenew', params, null, 'post')
}

116
src/apis/user.ts Normal file
View File

@@ -0,0 +1,116 @@
import http from '@/utils/http.ts'
import { CodeType } from "@/types/types.ts";
// 用户登录接口
export interface LoginParams {
account?: string
password?: string
phone?: string
code?: string
type: 'code' | 'pwd'
}
export interface User {
id: string
email?: string
phone?: string
username?: string
avatar?: string,
hasPwd?: boolean,
member: {
levelDesc: string,
status: string,
active: boolean,
endDate: number,
autoRenew: boolean,
plan: string,
planDesc: string,
}
}
// 用户注册接口
export interface RegisterParams {
account: string
password: string
code: string
}
export interface RegisterResponse {
token: string
user: {
id: string
email?: string
phone: string
nickname?: string
avatar?: string
}
}
// 发送验证码接口
export interface SendCodeParams {
val: string
type: CodeType
}
// 重置密码接口
export interface ResetPasswordParams {
account: string
code: string
newPassword: string
}
// 微信登录接口
export interface WechatLoginParams {
code: string
state?: string
}
export function loginApi(params: LoginParams) {
return http<{ token:string }>('user/login', params, null, 'post')
}
export function registerApi(params: RegisterParams) {
return http<RegisterResponse>('user/register', params, null, 'post')
}
export function sendCode(params: SendCodeParams) {
return http<boolean>('user/sendCode', null, params, 'get')
}
export function resetPasswordApi(params: ResetPasswordParams) {
return http<boolean>('user/resetPassword', params, null, 'post')
}
export function wechatLogin(params: WechatLoginParams) {
return http<User>('user/wechatLogin', params, null, 'post')
}
export function refreshToken() {
return http<{ token: string }>('user/refreshToken', null, null, 'post')
}
// 获取用户信息
export function getUserInfo() {
return http<User>('user/userInfo', null, null, 'get')
}
// 设置密码
export function setPassword(data) {
return http('user/setPassword', data, null, 'post')
}
// 修改邮箱
export function changeEmailApi(data) {
return http('user/changeEmail', data, null, 'post')
}
// 修改手机号
export function changePhoneApi(data) {
return http('user/changePhone', data, null, 'post')
}
// 修改用户信息
export function updateUserInfoApi(data) {
return http('user/updateUserInfo', data, null, 'post')
}

View File

@@ -27,6 +27,7 @@
}
@keyframes shake {
10%,
90% {

View File

@@ -15,7 +15,8 @@
--color-font-2: rgb(46, 46, 46);
--color-font-3: rgb(75, 85, 99);
--color-font-active-1: white;
--color-scrollbar: rgb(147, 173, 227);
--color-scrollbar: #c1c1c1;
--color-sub-gray: #c0bfbf;
--article-width: 50vw;
@@ -69,6 +70,11 @@
//修改的进度条底色
--color-progress-bar: #d1d5df !important;
--color-label-bg: whitesmoke;
--color-link: #2563EB;
--color-card-bg: white;
}
.footer {
@@ -118,6 +124,10 @@ html.dark {
--color-progress-bar: rgb(73, 77, 82) !important;
--color-label-bg: rgb(10, 10, 10);
--color-card-bg: rgb(30, 31, 34);
.footer {
&.hide {
--color-progress-bar: var(--color-third) !important;
@@ -178,7 +188,7 @@ html, body {
z-index: 1;
height: 100%;
width: 100%;
font-size: .9rem;
font-size: 1rem;
display: flex;
flex-direction: column;
}
@@ -209,11 +219,18 @@ html, body {
}
a {
$main: rgb(64, 158, 255);
color: $main;
color: var(--color-link);
text-decoration: none;
}
.link {
color: var(--color-link);
@apply hover:opacity-80;
}
.cp {
@apply cursor-pointer;
}
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {
@@ -386,10 +403,15 @@ a {
}
.card {
@apply rounded-xl p-4 mb-5 box-border relative;
@apply rounded-xl p-4 mb-8 shadow-lg box-border relative;
background: var(--color-second);
}
.card-white {
@extend .card;
background: var(--color-card-bg);
}
.inline-center {
@apply inline-flex justify-center items-center;
}
@@ -409,6 +431,8 @@ a {
.line {
width: 100%;
border-bottom: 1px solid var(--color-item-border);
@apply hover:text-blue-700;
}
.line-white {

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import BaseIcon from "@/components/BaseIcon.vue";
import {useAttrs} from "vue";
import router from "@/router.ts";
import { useAttrs } from "vue";
import { useNav } from "@/utils";
const attrs = useAttrs()
const router = useNav()
function onClick() {
if (!attrs.onClick) {

View File

@@ -7,7 +7,7 @@ interface IProps {
disabled?: boolean
loading?: boolean
size?: 'small' | 'normal' | 'large',
type?: 'primary' | 'link' | 'info'
type?: 'primary' | 'link' | 'info' | 'orange'
}
withDefaults(defineProps<IProps>(), {
@@ -62,7 +62,7 @@ defineEmits(['click'])
color: white;
& + .base-button {
margin-left: var(--space);
margin-left: 1rem;
}
.loading {
@@ -76,8 +76,8 @@ defineEmits(['click'])
}
&.small {
border-radius: 0.2rem;
padding: 0 0.8rem;
border-radius: 0.3rem;
padding: 0 0.6rem;
height: 1.6rem;
font-size: .8rem;
}
@@ -86,6 +86,7 @@ defineEmits(['click'])
padding: 0 1.3rem;
height: 2.4rem;
font-size: 0.9rem;
border-radius: .5rem;
}
& > span {
@@ -97,19 +98,19 @@ defineEmits(['click'])
}
}
&:hover {
opacity: .8;
}
&.primary {
background: var(--btn-primary);
&:hover:not(.disabled) {
opacity: 0.6;
}
}
&.link {
border-radius: 0;
border-bottom: 2px solid transparent;
&:hover {
&:hover:not(.disabled) {
border-bottom: 2px solid var(--color-font-2);
}
}
@@ -118,6 +119,20 @@ defineEmits(['click'])
background: var(--btn-info);
border: 1px solid var(--color-main-text);
color: var(--color-main-text);
&:hover:not(.disabled) {
opacity: 0.6;
}
}
&.orange {
background: #FACC15;
color: black;
&:hover:not(.disabled) {
background: #fbe27e;
color: rgba(0, 0, 0, 0.6);
}
}
&.active {

View File

@@ -14,6 +14,7 @@ import Checkbox from "@/components/base/checkbox/Checkbox.vue";
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
import Dialog from "@/components/dialog/Dialog.vue";
import BaseInput from "@/components/base/BaseInput.vue";
import {Host} from "@/config/env.ts";
let list = defineModel('list')
@@ -283,7 +284,7 @@ defineRender(
<div>短语一行原文一行译文多个请换<span class="color-red"></span></div>
<div>同义词同根词词源请前往官方字典然后编辑其中某个单词参考其格式</div>
<div class="mt-6">
模板下载地址<a href="https://2study.top/libs/单词导入模板.xlsx">单词导入模板</a>
模板下载地址<a href={`https://${Host}/libs/单词导入模板.xlsx`}>单词导入模板</a>
</div>
<div class="mt-4">
<BaseButton

View File

@@ -65,7 +65,7 @@ const studyProgress = $computed(() => {
top: 4px;
right: -22px;
padding: 1px 20px;
background: whitesmoke;
background: var(--color-label-bg);
font-size: 11px;
transform: rotate(45deg);
}

View File

@@ -1,189 +0,0 @@
<script setup lang="ts">
import Close from "@/components/icon/Close.vue";
import BaseButton from "@/components/BaseButton.vue";
import {watch} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
import {isMobile} from "@/utils";
import {ProjectName, Host} from "@/config/env.ts";
let settingStore = useSettingStore()
let showNotice = $ref(false)
let show = $ref(false)
let num = $ref(5)
let timer = -1
let mobile = $ref(isMobile())
const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
function toggleNotice() {
showNotice = true
settingStore.first = false
timer = setInterval(() => {
num--
if (num <= 0) close()
}, 1000)
}
function close() {
clearInterval(timer)
show = settingStore.first = false
}
watch(() => settingStore.load, (n) => {
if (n && settingStore.first) {
setTimeout(() => {
show = true
}, 1000)
}
}, {immediate: true})
</script>
<template>
<transition name="right">
<div class="CollectNotice"
:class="{mobile}"
v-if="show">
<div class="notice">
坚持练习提高外语能力
<span class="active">{{ ProjectName }}</span>
保存为书签永不迷失
</div>
<div class="wrapper">
<transition name="fade">
<div class="collect" v-if="showNotice">
<div class="href-wrapper">
<div class="round">
<div class="href">{{ Host }}</div>
<IconFluentStar12Regular width="22"/>
</div>
<div class="right">
👈
<IconFluentStar20Filled class="star" width="22"/>
点亮它!
</div>
</div>
<div class="collect-keyboard" v-if="!mobile">或使用收藏快捷键<span
class="active">{{ isMac ? 'Command' : 'Ctrl' }} + D</span></div>
</div>
<BaseButton v-else size="large" @click="toggleNotice">我想收藏</BaseButton>
</transition>
</div>
<div class="close-wrapper">
<span v-show="showNotice"><span class="active">{{ num }}s</span> 后自动关闭</span>
<Close @click="close" title="关闭"/>
</div>
</div>
</transition>
</template>
<style scoped lang="scss">
.right-enter-active,
.right-leave-active {
transition: all .5s ease;
}
.right-enter-from,
.right-leave-to {
transform: translateX(110%);
}
.CollectNotice {
position: fixed;
right: var(--space);
top: var(--space);
z-index: 2;
font-size: 1.2rem;
display: flex;
flex-direction: column;
align-items: center;
background: var(--color-notice-bg);
padding: 1.8rem;
border-radius: 0.7rem;
width: 30rem;
gap: 2.4rem;
color: var(--color-font-1);
line-height: 1.5;
border: 1px solid var(--color-item-border);
box-shadow: var(--shadow);
box-sizing: border-box;
&.mobile {
width: 95%;
padding: 0.6rem;
}
.notice {
margin-top: 2.4rem;
}
.active {
color: var(--color-select-bg);
}
.wrapper {
.collect {
display: flex;
flex-direction: column;
align-items: center;
.href-wrapper {
display: flex;
font-size: 1rem;
align-items: center;
gap: 0.6rem;
.round {
color: var(--color-font-1);
border-radius: 3rem;
padding: 0.6rem 0.6rem;
padding-left: 1.2rem;
gap: 2rem;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--color-primary);
.href {
font-size: 0.9rem;
}
}
.star {
color: var(--color-select-bg);
}
.right {
display: flex;
align-items: center;
}
}
.collect-keyboard {
margin-top: 1.2rem;
font-size: 1rem;
span {
margin-left: 0.6rem;
}
}
}
}
.close-wrapper {
right: var(--space);
top: var(--space);
position: absolute;
font-size: 0.9rem;
display: flex;
justify-content: flex-end;
align-items: center;
color: var(--color-font-1);
gap: 0.6rem;
}
}
</style>

29
src/components/Header.vue Normal file
View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import BackIcon from "@/components/BackIcon.vue";
import { useAttrs } from "vue";
interface IProps {
title: string;
showBackIcon?: boolean;
}
withDefaults(defineProps<IProps>(), {
title: '',
showBackIcon: true,
})
const attrs = useAttrs()
</script>
<template>
<div class="mb-3 text-xl font-bold relative min-h-8">
<BackIcon class="z-2 relative" v-bind="attrs" v-if="showBackIcon" />
<span class="absolute text-center w-full left-0" @click.stop>{{ title }}</span>
</div>
</template>
<style scoped lang="scss">
</style>

View File

@@ -1,17 +1,31 @@
<script lang="jsx">
import {Teleport, Transition} from 'vue'
import BaseButton from "@/components/BaseButton.vue";
export default {
name: "PopConfirm",
components: {
Teleport,
Transition
Transition,
BaseButton
},
props: {
title: {
type: String,
type: [String, Array],
default() {
return ''
},
validator(value) {
// Validate that array items have the correct structure
if (Array.isArray(value)) {
return value.every(item =>
typeof item === 'object' &&
item !== null &&
typeof item.text === 'string' &&
['normal', 'bold', 'red', 'redBold'].includes(item.type)
)
}
return typeof value === 'string'
}
},
disabled: {
@@ -21,6 +35,17 @@ export default {
}
}
},
computed: {
titleItems() {
if (typeof this.title === 'string') {
return [{ text: this.title, type: 'normal' }]
}
if (Array.isArray(this.title)) {
return this.title
}
return []
}
},
data() {
return {
show: false
@@ -35,6 +60,27 @@ export default {
})
},
methods: {
getTextStyle(type) {
const styles = {
normal: {
fontWeight: 'normal',
color: 'inherit'
},
bold: {
fontWeight: 'bold',
color: 'inherit'
},
red: {
fontWeight: 'normal',
color: 'red'
},
redBold: {
fontWeight: 'bold',
color: 'red'
}
}
return styles[type] || styles.normal
},
showPop(e) {
if (this.disabled) return this.$emit('confirm')
e?.stopPropagation()
@@ -60,18 +106,26 @@ export default {
render() {
let Vnode = this.$slots.default()[0]
return (
<div class="pop-confirm">
<div class="pop-confirm leading-none">
<Teleport to="body">
<Transition>
<Transition name="fade">
{
this.show && (
<div ref="tip" class="pop-confirm-content">
<div class="text">
{this.title}
<div ref="tip" class="pop-confirm-content shadow-2xl">
<div class="w-52 title-content">
{this.titleItems.map((item, index) => (
<div
key={index}
style={this.getTextStyle(item.type)}
class="title-item"
>
{item.text}
</div>
))}
</div>
<div class="options">
<div onClick={() => this.show = false}>取消</div>
<div class="main" onClick={() => this.confirm()}>确认</div>
<BaseButton type="info" size="small" onClick={() => this.show = false}>取消</BaseButton>
<BaseButton size="small" onClick={() => this.confirm()}>确认</BaseButton>
</div>
</div>
)
@@ -85,43 +139,27 @@ export default {
}
</script>
<style lang="scss" scoped>
$bg-color: rgb(226, 226, 226);
.pop-confirm-content {
position: fixed;
background: var(--color-tooltip-bg);
padding: 1rem;
border-radius: .3rem;
border-radius: .6rem;
transform: translate(-50%, calc(-100% - .6rem));
box-shadow: 0 0 6px 1px var(--color-tooltip-shadow);
z-index: 999;
.text {
color: var(--color-font-1);
text-align: start;
font-size: 1rem;
width: 9rem;
min-width: 9rem;
.title-content {
.title-item {
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
}
.options {
margin-top: .9rem;
display: flex;
justify-content: flex-end;
align-items: center;
gap: .7rem;
font-size: .9rem;
div {
cursor: pointer;
}
.main {
color: gray;
background: $bg-color;
padding: .2rem .6rem;
border-radius: .24rem;
}
text-align: right;
}
}
</style>

View File

@@ -9,6 +9,7 @@ interface IProps {
currentTime?: number;
playbackRate?: number;
disabled?: boolean;
}
const props = withDefaults(defineProps<IProps>(), {
@@ -17,11 +18,13 @@ const props = withDefaults(defineProps<IProps>(), {
volume: 1,
currentTime: 0,
playbackRate: 1,
disabled: false
disabled: false,
});
const emit = defineEmits<{
ended: []
(e: 'ended'): [],
(e: 'update-volume', volume: number): void,
(e: 'update-speed', volume: number): void
}>();
const attrs = useAttrs();
@@ -30,17 +33,20 @@ const attrs = useAttrs();
const audioRef = ref<HTMLAudioElement>();
const progressBarRef = ref<HTMLDivElement>();
const volumeBarRef = ref<HTMLDivElement>();
const volumeFillRef = ref<HTMLElement>();
// 状态管理
const isPlaying = ref(false);
const isLoading = ref(false);
const duration = ref(0);
const currentTime = ref(0);
// const volume = ref(props.volume);
const volume = ref(props.volume);
const playbackRate = ref(props.playbackRate);
const isDragging = ref(false);
const isVolumeDragging = ref(false);
const isVolumeHovering = ref(false); // 添加音量控制hover状态变量
const volumePosition = ref('top') // 音量控制位置,'top'或'down'
const error = ref('');
// 计算属性
@@ -85,17 +91,18 @@ const toggleMute = () => {
volume.value = 1;
audioRef.value.volume = 1;
}
emit('update-volume', Math.floor(volume.value * 100));
};
const changePlaybackRate = () => {
if (!audioRef.value || props.disabled) return;
const rates = [0.5, 0.75, 1, 1.25, 1.5, 2];
const currentIndex = rates.indexOf(playbackRate.value);
const nextIndex = (currentIndex + 1) % rates.length;
playbackRate.value = rates[nextIndex];
audioRef.value.playbackRate = playbackRate.value;
// 提交更新播放速度事件
emit('update-speed', playbackRate.value);
};
// 事件处理
@@ -108,6 +115,10 @@ const handleLoadedData = () => {
};
const handleLoadedMetadata = () => {
if (audioRef.value) {
audioRef.value.volume = volume.value;
}
duration.value = audioRef.value?.duration || 0;
};
@@ -250,26 +261,18 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
const startX = event.clientX;
const startY = event.clientY;
let hasMoved = false;
let lastVolume = 0; // 记录最后音量
const moveThreshold = 3; // 移动阈值,超过这个距离才认为是拖拽
let lastVolume = 0; // 记录最后音量
const moveThreshold = 3; // 超过这个距离才认为是拖拽
// 获取DOM元素引用
const volumeFill = volumeBarRef.value.querySelector('.volume-fill') as HTMLElement;
const volumeThumb = volumeBarRef.value.querySelector('.volume-thumb') as HTMLElement;
const volumeFill = volumeFillRef.value;
// 立即跳转到点击位置
// 计算点击位置对应音量百分比(最上 100%,最下 0%
const clickY = event.clientY - rect.top;
// 计算百分比最上面是0%最下面是100%
const percentage = Math.max(0, Math.min(1, clickY / rect.height));
const percentage = 1 - Math.max(0, Math.min(1, clickY / rect.height));
// 直接更新DOM样式
if (volumeFill && volumeThumb) {
// 更新 UI 与音量
if (volumeFill) {
volumeFill.style.height = `${percentage * 100}%`;
// 设置top而不是bottom
volumeThumb.style.top = `${percentage * 100}%`;
// 重置left样式
volumeThumb.style.left = '50%';
}
volume.value = percentage;
@@ -277,6 +280,7 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
lastVolume = percentage;
isVolumeDragging.value = true;
// 鼠标移动时调整音量
const handleMouseMove = (e: MouseEvent) => {
const deltaX = Math.abs(e.clientX - startX);
const deltaY = Math.abs(e.clientY - startY);
@@ -286,46 +290,41 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
}
if (!hasMoved) return;
// 禁用过渡动画
if (volumeFill && volumeThumb) {
if (volumeFill) {
volumeFill.style.transition = 'none';
volumeThumb.style.transition = 'none';
}
const rect = volumeBarRef.value!.getBoundingClientRect();
const clickY = e.clientY - rect.top;
// 计算百分比最上面是0%最下面是100%
const percentage = Math.max(0, Math.min(1, clickY / rect.height));
const moveY = e.clientY - rect.top;
const percentage = 1 - Math.max(0, Math.min(1, moveY / rect.height));
// 直接更新DOM样式不使用响应式变量
if (volumeFill && volumeThumb) {
if (volumeFill) {
volumeFill.style.height = `${percentage * 100}%`;
// 设置top而不是bottom
volumeThumb.style.top = `${percentage * 100}%`;
}
// 更新响应式变量和音频音量
volume.value = percentage;
lastVolume = percentage;
// 实时更新音频音量
if (audioRef.value) {
audioRef.value.volume = percentage;
}
};
// 鼠标释放时结束拖动
const handleMouseUp = () => {
isVolumeDragging.value = false;
// 恢复过渡动画
if (volumeFill && volumeThumb) {
if (volumeFill) {
volumeFill.style.transition = '';
volumeThumb.style.transition = '';
}
// 如果是拖拽在结束时更新audio元素到最终音量
if (hasMoved && audioRef.value) {
audioRef.value.volume = lastVolume;
}
// 提交更新音量事件
emit('update-volume', Math.floor(volume.value * 100));
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
@@ -335,6 +334,20 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
document.addEventListener('mouseup', handleMouseUp);
};
// 音量控制鼠标移入事件,自动调整音量控制条位置
const onVolumeSectionEnter = (e: MouseEvent) => {
isVolumeHovering.value = true;
const section = e.target as HTMLElement
const top = section.getBoundingClientRect().top + window.scrollY
const dropdownH = section.querySelector('.volume-dropdown').clientHeight
if (top < dropdownH * 1.25) {
volumePosition.value = 'down'
} else {
volumePosition.value = 'top'
}
}
// 监听属性变化
watch(() => props.src, (newSrc) => {
if (audioRef.value) {
@@ -377,52 +390,29 @@ watch(() => props.playbackRate, (newRate) => {
}
});
defineExpose({audioRef})
defineExpose({ audioRef })
</script>
<template>
<div
class="custom-audio"
:class="{ 'disabled': disabled||error, 'has-error': error }"
v-bind="attrs"
>
<div class="custom-audio" :class="{ 'disabled': disabled || error, 'has-error': error }" v-bind="attrs">
<!-- 隐藏的原生audio元素 -->
<audio
ref="audioRef"
:src="src"
preload="auto"
:autoplay="autoplay"
:loop="loop"
:controls="false"
@loadstart="handleLoadStart"
@loadeddata="handleLoadedData"
@loadedmetadata="handleLoadedMetadata"
@canplaythrough="handleCanPlayThrough"
@play="handlePlay"
@pause="handlePause"
@ended="handleEnded"
@error="handleError"
@timeupdate="handleTimeUpdate"
@volumechange="handleVolumeChange"
@ratechange="handleRateChange"
/>
<audio ref="audioRef" :src="src" preload="auto" :autoplay="autoplay" :loop="loop" :controls="false"
@loadstart="handleLoadStart" @loadeddata="handleLoadedData" @loadedmetadata="handleLoadedMetadata"
@canplaythrough="handleCanPlayThrough" @play="handlePlay" @pause="handlePause" @ended="handleEnded"
@error="handleError" @timeupdate="handleTimeUpdate" @volumechange="handleVolumeChange"
@ratechange="handleRateChange" />
<!-- 自定义控制界面 -->
<div class="audio-container">
<!-- 播放/暂停按钮 -->
<button
class="play-button"
:class="{ 'loading': isLoading }"
@click="togglePlay"
:disabled="disabled"
:aria-label="isPlaying ? '暂停' : '播放'"
>
<button class="play-button" :class="{ 'loading': isLoading }" @click="togglePlay" :disabled="disabled"
:aria-label="isPlaying ? '暂停' : '播放'">
<div v-if="isLoading" class="loading-spinner"></div>
<svg v-else-if="isPlaying" class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
</button>
@@ -431,70 +421,40 @@ defineExpose({audioRef})
<!-- 时间显示 -->
<span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
<!-- 进度条 -->
<div
class="progress-container"
@mousedown="handleProgressMouseDown"
ref="progressBarRef"
>
<div class="progress-container" @mousedown="handleProgressMouseDown" ref="progressBarRef">
<div class="progress-track">
<div
class="progress-fill"
:style="{ width: progress + '%' }"
></div>
<div
class="progress-thumb"
:style="{ left: progress + '%' }"
></div>
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
<div class="progress-thumb" :style="{ left: progress + '%' }"></div>
</div>
</div>
</div>
<!-- 音量控制 -->
<div
class="volume-section"
@mouseenter="isVolumeHovering = true"
@mouseleave="isVolumeHovering = false"
>
<button
class="volume-button"
@click="toggleMute"
:disabled="disabled"
:aria-label="volume > 0 ? '静音' : '取消静音'"
>
<div class="volume-section" @mouseenter="onVolumeSectionEnter" @mouseleave="isVolumeHovering = false">
<button class="volume-button" tabindex="-1" @click="toggleMute" :disabled="disabled"
:aria-label="volume > 0 ? '静音' : '取消静音'">
<IconBxVolumeMute v-if="volume === 0" class="icon"></IconBxVolumeMute>
<IconBxVolumeLow v-else-if="volume < 0.5" class="icon"></IconBxVolumeLow>
<IconBxVolumeFull v-else class="icon"></IconBxVolumeFull>
</button>
<!-- 音量下拉控制条 -->
<div class="volume-dropdown" :class="{ 'active': isVolumeHovering || isVolumeDragging }">
<div
class="volume-container"
@mousedown="handleVolumeMouseDown"
ref="volumeBarRef"
>
<div class="volume-dropdown" :class="[{ 'active': isVolumeHovering || isVolumeDragging }, volumePosition]">
<div class="volume-container" @mousedown="handleVolumeMouseDown" ref="volumeBarRef">
<div class="volume-track">
<div
class="volume-fill"
:style="{ height: volumeProgress + '%', top: 0 }"
></div>
<div
class="volume-thumb"
:style="{ top: volumeProgress + '%' }"
></div>
<div class="volume-fill" ref="volumeFillRef" :style="{ height: volumeProgress + '%', bottom: 0 }"></div>
</div>
<div class="volume-num">
<span>{{ Math.floor(volumeProgress) }}%</span>
</div>
</div>
</div>
</div>
<!-- 播放速度控制 -->
<button
class="speed-button"
@click="changePlaybackRate"
:disabled="disabled"
:aria-label="`播放速度: ${playbackRate}x`"
>
<button class="speed-button" @click="changePlaybackRate" :disabled="disabled"
:aria-label="`播放速度: ${playbackRate}x`">
{{ playbackRate }}x
</button>
</div>
@@ -641,6 +601,7 @@ defineExpose({audioRef})
.volume-section {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-shrink: 0;
position: relative;
@@ -671,13 +632,9 @@ defineExpose({audioRef})
.volume-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--color-primary);
border-radius: 4px;
border-radius: 8px;
padding: 8px;
margin-top: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
opacity: 0;
visibility: hidden;
@@ -688,6 +645,14 @@ defineExpose({audioRef})
opacity: 1;
visibility: visible;
}
&.top {
bottom: 42px;
}
&.down {
top: 42px;
}
}
.volume-container {
@@ -705,35 +670,41 @@ defineExpose({audioRef})
width: 6px;
height: 100%;
background: var(--color-second);
border-radius: 2px;
overflow: hidden;
border-radius: 6px;
// overflow: hidden;
}
.volume-num {
display: flex;
position: absolute;
bottom: 0;
font-size: 12px;
color: #333;
transform: scale(0.85);
line-height: normal;
}
.volume-fill {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: var(--fill-height);
background: var(--color-fourth);
border-radius: 2px;
}
border-radius: 6px;
display: flex;
justify-content: center;
.volume-thumb {
position: absolute;
left: 50%;
top: var(--thumb-top);
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
background: var(--color-fourth);
border-radius: 50%;
box-shadow: var(--audio-volume-thumb-shadow);
cursor: grab;
opacity: 1;
transition: all 0.2s ease;
&:active {
cursor: grabbing;
&::before {
content: "";
position: absolute;
top: 0;
width: 10px;
height: 10px;
border-radius: 100%;
background: var(--color-fourth);
transform: translateY(-50%);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
cursor: grab;
}
}
@@ -772,6 +743,7 @@ defineExpose({audioRef})
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}

View File

@@ -1,13 +1,18 @@
<script setup lang="ts">
import { ref, useAttrs, watch } from 'vue';
import {defineComponent, ref, useAttrs, watch, computed} from 'vue';
import Close from "@/components/icon/Close.vue";
import { useDisableEventListener } from "@/hooks/event.ts";
import {useDisableEventListener} from "@/hooks/event.ts";
defineOptions({
name: "BaseInput",
})
const props = defineProps({
modelValue: [String, Number],
placeholder: String,
disabled: Boolean,
autofocus: Boolean,
error: Boolean,
type: {
type: String,
default: 'text',
@@ -21,40 +26,42 @@ const props = defineProps({
default: false,
},
maxLength: Number,
size: {
type: String,
default: 'normal',
validator: (value: string) => ['normal', 'large'].includes(value)
},
});
const emit = defineEmits(['update:modelValue', 'input', 'change', 'focus', 'blur', 'validation']);
const emit = defineEmits(['update:modelValue', 'input', 'change', 'focus', 'blur', 'validation', 'enter']);
const attrs = useAttrs();
const inputValue = ref(props.modelValue);
const errorMsg = ref('');
let focus = $ref(false)
let inputEl = $ref<HTMLDivElement>()
const passwordVisible = ref(false)
const inputType = computed(() => {
if (props.type === 'password') {
return passwordVisible.value ? 'text' : 'password'
}
return props.type
})
const togglePasswordVisibility = () => {
passwordVisible.value = !passwordVisible.value
}
watch(() => props.modelValue, (val) => {
inputValue.value = val;
validate(val);
});
const validate = (val: string | number | null | undefined) => {
let err = '';
const strVal = val == null ? '' : String(val);
if (props.required && !strVal.trim()) {
err = '不能为空';
} else if (props.maxLength && strVal.length > props.maxLength) {
err = `长度不能超过 ${props.maxLength} 个字符`;
}
errorMsg.value = err;
emit('validation', err === '', err);
return err === '';
};
const onInput = (e: Event) => {
const target = e.target as HTMLInputElement;
inputValue.value = target.value;
validate(target.value);
emit('update:modelValue', target.value);
emit('input', e);
emit('change', e);
};
const onChange = (e: Event) => {
@@ -68,14 +75,15 @@ const onFocus = (e: FocusEvent) => {
const onBlur = (e: FocusEvent) => {
focus = false
validate(inputValue.value);
emit('blur', e);
};
const clearInput = () => {
const onEnter = (e: KeyboardEvent) => {
emit('enter', e);
};
const clearInput = () => {
inputValue.value = '';
validate('');
emit('update:modelValue', '');
};
@@ -94,60 +102,97 @@ const vFocus = {
</script>
<template>
<div class="base-input2"
<div class="base-input"
ref="inputEl"
:class="{ 'is-disabled': disabled, 'has-error': errorMsg,focus }">
:class="{ 'is-disabled': disabled, 'error': props.error, focus, [`base-input--${size}`]: true }">
<slot name="subfix"></slot>
<!-- PreIcon slot -->
<div v-if="$slots.preIcon" class="pre-icon">
<slot name="preIcon"></slot>
</div>
<IconFluentLockClosed20Regular class="pre-icon" v-if="type === 'password'"/>
<IconFluentMail20Regular class="pre-icon" v-if="type === 'email'"/>
<IconFluentPhone20Regular class="pre-icon" v-if="type === 'tel'"/>
<IconFluentNumberSymbol20Regular class="pre-icon" v-if="type === 'code'"/>
<input
v-bind="attrs"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:value="inputValue"
@input="onInput"
@change="onChange"
@focus="onFocus"
@blur="onBlur"
class="inner"
v-focus="autofocus"
:maxlength="maxLength"
v-bind="attrs"
:type="inputType"
:placeholder="placeholder"
:disabled="disabled"
:value="inputValue"
@input="onInput"
@change="onChange"
@focus="onFocus"
@blur="onBlur"
@keydown.enter="onEnter"
class="inner"
v-focus="autofocus"
:maxlength="maxLength"
/>
<slot name="prefix"></slot>
<Close
v-if="clearable && inputValue && !disabled"
@click="clearInput"/>
<div v-if="errorMsg" class="base-input2__error">{{ errorMsg }}</div>
v-if="clearable && inputValue && !disabled"
@click="clearInput"/>
<!-- Password visibility toggle -->
<div
v-if="type === 'password' && !disabled"
class="password-toggle"
@click="togglePasswordVisibility"
:title="passwordVisible ? '隐藏密码' : '显示密码'">
<IconFluentEye16Regular v-if="!passwordVisible"/>
<IconFluentEyeOff16Regular v-else/>
</div>
</div>
</template>
<style scoped lang="scss">
.base-input2 {
.base-input {
position: relative;
display: inline-flex;
box-sizing: border-box;
width: 100%;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: 6px;
overflow: hidden;
padding: .2rem .3rem;
transition: all .3s;
align-items: center;
background: var(--color-input-bg);
::placeholder {
font-size: 0.9rem;
color: darkgray;
}
// normal size (default)
&--normal {
padding: .2rem .3rem;
.inner {
height: 1.5rem;
font-size: 1rem;
}
}
// large size
&--large {
padding: .4rem .6rem;
border-radius: .5rem;
.inner {
height: 2rem;
font-size: 1.125rem;
}
}
&.is-disabled {
opacity: 0.6;
}
&.has-error {
.base-input2__inner {
border-color: #f56c6c;
}
.base-input2__error {
color: #f56c6c;
font-size: 0.85rem;
margin-top: 0.25rem;
}
&.error {
border-color: #f56c6c;
background: rgba(245, 108, 108, 0.07);
}
&.focus {
@@ -159,8 +204,22 @@ const vFocus = {
cursor: not-allowed;
}
&__error {
padding-left: 0.5rem;
// PreIcon styling
&.has-preicon {
.inner {
padding-left: 2rem;
}
}
.pre-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-input-color);
opacity: 0.6;
z-index: 1;
pointer-events: none;
margin-right: 0.2rem;
}
.inner {
@@ -173,6 +232,24 @@ const vFocus = {
height: 1.5rem;
color: var(--color-input-color);
background: transparent;
width: 100%;
}
.password-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-left: 4px;
cursor: pointer;
color: var(--color-input-color);
opacity: 0.6;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
</style>

View File

@@ -8,14 +8,16 @@ interface IProps {
strokeWidth?: number;
color?: string;
format?: (percentage: number) => string;
size?: 'normal' | 'large';
}
const props = withDefaults(defineProps<IProps>(), {
showText: true,
textInside: false,
strokeWidth: 6,
color: '#93ADE3',
color: '#409eff',
format: (percentage) => `${percentage}%`,
size: 'normal',
});
const barStyle = computed(() => {
@@ -26,13 +28,15 @@ const barStyle = computed(() => {
});
const trackStyle = computed(() => {
const height = props.size === 'large' ? props.strokeWidth * 2.5 : props.strokeWidth;
return {
height: `${props.strokeWidth}px`,
height: `${height}px`,
};
});
const progressTextSize = computed(() => {
return props.strokeWidth * 0.83 + 6;
const baseSize = props.strokeWidth * 0.83 + 6;
return props.size === 'large' ? baseSize * 1.2 : baseSize;
});
const content = computed(() => {

View File

@@ -5,7 +5,8 @@
</template>
<script setup lang="ts">
import {ref, provide, watch, toRef} from 'vue'
import {provide, ref, toRef} from 'vue'
import type {FormField, FormModel, FormRules} from './types'
interface Field {
prop: string
@@ -14,8 +15,8 @@ interface Field {
}
const props = defineProps({
model: Object,
rules: Object // { word: [{required:true,...}, ...], name: [...] }
model: Object as () => FormModel,
rules: Object as () => FormRules
})
const fields = ref<Field[]>([])
@@ -25,7 +26,7 @@ const registerField = (field: Field) => {
}
// 校验整个表单
const validate = (cb): boolean => {
function validate(cb) {
let valid = true
fields.value.forEach(f => {
const fieldRules = props.rules?.[f.prop] || []
@@ -35,10 +36,23 @@ const validate = (cb): boolean => {
cb(valid)
}
// 校验指定字段
function validateField(fieldName: string, cb?: (valid: boolean) => void): boolean {
const field = fields.value.find(f => f.prop === fieldName)
if (field) {
const fieldRules = props.rules?.[fieldName] || []
const valid = field.validate(fieldRules)
if (cb) cb(valid)
return valid
}
if (cb) cb(true)
return true
}
provide('registerField', registerField)
provide('formModel', toRef(props, 'model'))
provide('formValidate', validate)
provide('formRules', props.rules)
defineExpose({validate})
defineExpose({validate, validateField})
</script>

View File

@@ -11,7 +11,7 @@ let error = $ref('')
// 拿到 form 的 model 和注册函数
const formModel = inject<ref>('formModel')
const registerField = inject('registerField')
const registerField = inject<Function>('registerField')
const formRules = inject('formRules', {})
const myRules = $computed(() => {
@@ -19,9 +19,13 @@ const myRules = $computed(() => {
})
// 校验函数
const validate = (rules) => {
const validate = (rules, isBlur = false) => {
error = ''
const val = formModel.value[props.prop]
//为空并且是非主动触发检验的情况下,不检验
if (isBlur && val.trim() === '') {
return true
}
for (const rule of rules) {
if (rule.required && (!val || !val.toString().trim())) {
error = rule.message
@@ -31,43 +35,93 @@ const validate = (rules) => {
error = rule.message
return false
}
if (rule.min && val && val.toString().length < rule.min) {
error = rule.message
return false
}
if (rule.max && val && val.toString().length > rule.max) {
error = rule.message
return false
}
if (rule.validator) {
try {
rule.validator(rule, val)
} catch (e) {
error = e.message
return false
}
}
}
return true
}
// 自动触发 blur 校验
const handleBlur = () => {
function handleBlur() {
const blurRules = myRules.filter((r) => r.trigger === 'blur')
if (blurRules.length) validate(blurRules)
if (blurRules.length) validate(blurRules, true)
}
function handChange() {
error = ''
}
// 注册到 Form
onMounted(() => {
registerField && registerField({prop: props.prop, modelValue: value, validate})
})
let slot = useSlots()
function patchVNode(vnode, patchFn) {
if (!vnode) return vnode
// 如果当前节点就是我们要找的 BaseInput
if (vnode.type && vnode.type.name) {
return patchFn(vnode)
}
// 如果有子节点,则递归修改
if (Array.isArray(vnode.children)) {
vnode.children = vnode.children.map(child => patchVNode(child, patchFn))
}
return vnode
}
defineRender(() => {
let DefaultNode = slot.default()[0]
return <div class="form-item mb-6 flex gap-space">
let DefaultNode: any = slot.default()[0]
// 对 DefaultNode 深度查找 BaseInput 并加上 onBlur / error
DefaultNode = patchVNode(DefaultNode, vnode => {
return {
...vnode,
props: {
...vnode.props,
error: !!error,
onBlur: handleBlur,
onChange: handChange
},
}
})
return <div class="form-item flex gap-space">
{props.label &&
<label class="w-20 flex items-start mt-1 justify-end">
{myRules.length ? <span class="form-error">*</span> : null} {props.label}
</label>}
<label class="w-20 flex items-start mt-1 justify-end">
{myRules.length ? <span class="form-error">*</span> : null} {props.label}
</label>}
<div class="flex-1 relative">
<DefaultNode onBlur={handleBlur}/>
<div class="form-error absolute top-[100%] anim" style={{opacity: error ? 1 : 0}}>{error}</div>
<DefaultNode/>
<div class="form-error my-0.5 anim" style={{opacity: error ? 1 : 0}}>{error} &nbsp;</div>
</div>
</div>
})
</script>
<style scoped lang="scss">
.form-item {
.form-error {
color: #f56c6c;
font-size: 0.8rem;
}
.form-error {
color: #f56c6c;
font-size: 0.8rem;
}
</style>

View File

@@ -0,0 +1,65 @@
// Form 组件的 TypeScript 类型定义
// 表单字段接口
export interface FormField {
prop: string
modelValue: any
validate: (rules: FormRule[]) => boolean
}
// 表单规则接口
export interface FormRule {
required?: boolean
message?: string
pattern?: RegExp
validator?: (rule: FormRule, value: any, callback: (error?: Error) => void) => void
min?: number
max?: number
len?: number
type?: string
}
// 表单规则对象类型
export type FormRules = Record<string, FormRule[]>
// 表单模型对象类型
export type FormModel = Record<string, any>
// Form 组件的 Props 接口
export interface FormProps {
model?: FormModel
rules?: FormRules
}
// Form 组件的实例接口
export interface FormInstance {
/**
* 校验整个表单
* @param callback 校验完成后的回调函数,接收校验结果
*/
validate: (callback: (valid: boolean) => void) => void
/**
* 校验指定字段
* @param fieldName 要校验的字段名称
* @param callback 可选的回调函数,接收校验结果
* @returns 校验是否通过
*/
validateField: (fieldName: string, callback?: (valid: boolean) => void) => boolean
}
// 注入的上下文类型
export interface FormContext {
registerField: (field: FormField) => void
formModel: FormModel
formValidate: (callback: (valid: boolean) => void) => void
formRules: FormRules
}
// 验证状态枚举
export enum ValidateStatus {
Success = 'success',
Error = 'error',
Validating = 'validating',
Pending = 'pending'
}

View File

@@ -188,7 +188,7 @@ async function cancel() {
<style scoped lang="scss">
$modal-mask-bg: rgba(#000, .45);
$modal-mask-bg: rgba(#000, .6);
$radius: .5rem;
$time: 0.3s;
$header-height: 4rem;
@@ -196,11 +196,9 @@ $header-height: 4rem;
@keyframes bounce-in {
0% {
opacity: 0;
transform: scale(0);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@@ -259,7 +257,6 @@ $header-height: 4rem;
animation: bounce-in $time ease-out;
&.bounce-out {
transform: scale(0);
opacity: 0;
}
}

View File

@@ -4,11 +4,13 @@ import { Article } from "@/types/types.ts";
import BaseList from "@/components/list/BaseList.vue";
import BaseInput from "@/components/base/BaseInput.vue";
const props = withDefaults(defineProps<{
list: Article[],
showTranslate?: boolean
}>(), {
list: [],
interface IProps {
list: Article[];
showTranslate?: boolean;
}
const props = withDefaults(defineProps<IProps>(), {
list: () => [] as Article[],
showTranslate: true,
})
@@ -62,27 +64,23 @@ function scrollToItem(index: number) {
listRef?.scrollToItem(index)
}
defineExpose({scrollToBottom, scrollToItem})
defineExpose({ scrollToBottom, scrollToItem })
</script>
<template>
<div class="list">
<div class="search">
<BaseInput
clearable
v-model="searchKey"
>
<BaseInput clearable v-model="searchKey">
<template #subfix>
<IconFluentSearch24Regular class="text-lg text-gray"/>
<IconFluentSearch24Regular class="text-lg text-gray" />
</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>
@@ -91,7 +89,7 @@ defineExpose({scrollToBottom, scrollToItem})
<div class="name"> {{ `${searchKey ? '' : (index + 1) + '. '}${item.title}` }}</div>
</div>
<div class="item-sub-title" v-if="item.titleTranslate && showTranslate">
<div class="item-translate"> {{ ` ${item.titleTranslate}` }}</div>
<div class="item-translate"> {{ ` ${item.titleTranslate}` }}</div>
</div>
</template>
<template v-slot:suffix="{ item, index }">

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
}

52
src/config/auth.ts Normal file
View File

@@ -0,0 +1,52 @@
// 微信登录配置
export const WECHAT_CONFIG = {
// 微信开放平台AppID需要在微信开放平台申请
appId: 'your_wechat_app_id',
// 微信授权回调地址
redirectUri: `${window.location.origin}/wechat/callback`,
// 授权作用域
scope: 'snsapi_userinfo',
// 授权状态参数
state: 'wechat_login'
}
// 获取微信授权URL
export function getWechatAuthUrl(state?: string): string {
const {appId, redirectUri, scope} = WECHAT_CONFIG
const authState = state || Math.random().toString(36).substr(2, 15)
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${scope}&state=${authState}#wechat_redirect`
}
// 手机号验证配置
export const PHONE_CONFIG = {
// 验证码长度
codeLength: 6,
// 验证码发送间隔(秒)
sendInterval: 60,
// 手机号正则表达式(中国大陆)
phoneRegex: /^1[2-9]\d{9}$/
}
// 邮箱配置
export const EMAIL_CONFIG = {
// 邮箱正则表达式
emailRegex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
// 邮箱验证码长度
codeLength: 6
}
// 密码配置
export const PASSWORD_CONFIG = {
// 密码最小长度
minLength: 9,
// 密码最大长度
maxLength: 20
}

View File

@@ -1,8 +1,6 @@
import { useBaseStore } from "@/stores/base.ts";
export const GITHUB = 'https://github.com/zyronon/TypeWords'
export const ProjectName = 'Type Words'
export const Host = '2study.top'
export const Host = 'typewords.cc'
export const EMAIL = 'zyronon@163.com'
export const Origin = `https://${Host}`
export const APP_NAME = 'Type Words'
@@ -16,11 +14,18 @@ const map = {
}
export const ENV = Object.assign(map['DEV'], common)
// export const IS_OFFICIAL = import.meta.env.DEV
// export let IS_LOGIN = true
export const IS_OFFICIAL = false
export let IS_LOGIN = false
export const CAN_REQUEST = IS_LOGIN && IS_OFFICIAL
export let AppEnv = {
TOKEN: localStorage.getItem('token') ?? '',
IS_OFFICIAL: false,
IS_LOGIN: false,
CAN_REQUEST: false
}
AppEnv.IS_LOGIN = !!AppEnv.TOKEN
AppEnv.CAN_REQUEST = AppEnv.IS_LOGIN && AppEnv.IS_OFFICIAL
// console.log('AppEnv.CAN_REQUEST',AppEnv.CAN_REQUEST)
export const RESOURCE_PATH = ENV.API + 'static'
export const DICT_LIST = {
@@ -51,13 +56,14 @@ export const SAVE_DICT_KEY = {
}
export const SAVE_SETTING_KEY = {
key: 'typing-word-setting',
version: 16
version: 17
}
export const EXPORT_DATA_KEY = {
key: 'typing-word-export',
version: 4
}
export const LOCAL_FILE_KEY = 'typing-word-files'
export const PracticeSaveWordKey = {
key: 'PracticeSaveWord',
version: 1

View File

@@ -1,126 +1,131 @@
import { Article, DictId, PracticeArticleWordType, Sentence } from "@/types/types.ts";
import { _nextTick, cloneDeep } from "@/utils";
import { usePlayWordAudio } from "@/hooks/sound.ts";
import { getSentenceAllText, getSentenceAllTranslateText } from "@/hooks/translate.ts";
import { getDefaultArticleWord } from "@/types/func.ts";
import { useSettingStore } from "@/stores/setting.ts";
import { useBaseStore } from "@/stores/base.ts";
import { useRuntimeStore } from "@/stores/runtime.ts";
import { Article, DictId, PracticeArticleWordType, Sentence } from "@/types/types.ts"
import { _nextTick, cloneDeep } from "@/utils"
import { usePlayWordAudio } from "@/hooks/sound.ts"
import { getSentenceAllText, getSentenceAllTranslateText } from "@/hooks/translate.ts"
import { getDefaultArticleWord } from "@/types/func.ts"
import { useSettingStore } from "@/stores/setting.ts"
import { useBaseStore } from "@/stores/base.ts"
import { useRuntimeStore } from "@/stores/runtime.ts"
function parseSentence(sentence: string) {
// 先统一一些常见的“智能引号” -> 直引号,避免匹配问题
sentence = sentence
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // 各种单引号 → '
.replace(/[\u201C\u201D\u201E\u201F]/g, '"'); // 各种双引号 → "
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // 各种双引号 → "
const len = sentence.length;
const tokens = [];
let i = 0;
const len = sentence.length
const tokens = []
let i = 0
while (i < len) {
const ch = sentence[i];
const ch = sentence[i]
// 跳过空白(但不把空白作为 token
if (/\s/.test(ch)) {
i++;
continue;
i++
continue
}
const rest = sentence.slice(i);
const rest = sentence.slice(i)
// 1) 货币 + 数字($1,000.50 或 ¥200 或 €100.5
let m = rest.match(/^[\$¥€£]\d{1,3}(?:,\d{3})*(?:\.\d+)?%?/);
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});
i += m[0].length;
continue;
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number })
i += m[0].length
continue
}
// 2) 数字/小数/百分比100% 3.14 1,000.00
m = rest.match(/^\d{1,3}(?:,\d{3})*(?:\.\d+)?%?/);
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});
i += m[0].length;
continue;
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number })
i += m[0].length
continue
}
// 3) 带点缩写或多段缩写U.S. U.S.A. e.g. i.e. Ph.D.
m = rest.match(/^[A-Za-z]+(?:\.[A-Za-z]+)+\.?/);
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});
i += m[0].length;
continue;
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word })
i += m[0].length
continue
}
// 4) 单词(包含撇号/连字符,如 it's, o'clock, we'll, mother-in-law
m = rest.match(/^[A-Za-z0-9]+(?:[\'\-][A-Za-z0-9]+)*/);
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});
i += m[0].length;
continue;
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word })
i += m[0].length
continue
}
// 5) 其它可视符号(标点)——单字符处理(连续标点会被循环拆为单字符)
// 包括:.,!?;:"'()-[]{}<>/\\@#%^&*~`等非单词非空白字符
if (/[^\w\s]/.test(ch)) {
tokens.push({word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol});
i += 1;
continue;
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});
i += 1;
tokens.push({ word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol })
i += 1
}
// 计算 nextSpace查看当前 token 的 end 到下一个 token 的 start 之间是否含空白
const result = tokens.map((t, idx) => {
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});
});
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 result;
return result
}
//生成文章段落数据
export function genArticleSectionData(article: Article): number {
let text = article.text.trim()
let sections: Sentence[][] = []
text.split('\n\n').filter(Boolean).map((sectionText, i) => {
let section: Sentence[] = []
sections.push(section)
sectionText.trim().split('\n').filter(Boolean).map((item, i, arr) => {
item = item.trim()
//如果没有空格,导致修改一行一行的数据时,汇总时全没有空格了,库无法正常断句
//所以要保证最后一个是空格但防止用户打N个空格就去掉再加上一个空格只需要一个即可
//2025/10/1:最后一句不需要空格
if (i < arr.length - 1) item += ' '
let sentence: Sentence = cloneDeep({
text: item,
translate: '',
words: parseSentence(item),
audioPosition: [0, 0],
})
section.push(sentence)
text
.split("\n\n")
.filter(Boolean)
.map((sectionText, i) => {
let section: Sentence[] = []
sections.push(section)
sectionText
.trim()
.split("\n")
.filter(Boolean)
.map((item, i, arr) => {
item = item.trim()
//如果没有空格,导致修改一行一行的数据时,汇总时全没有空格了,库无法正常断句
//所以要保证最后一个是空格但防止用户打N个空格就去掉再加上一个空格只需要一个即可
//2025/10/1:最后一句不需要空格
if (i < arr.length - 1) item += " "
let sentence: Sentence = cloneDeep({
text: item,
translate: "",
words: parseSentence(item),
audioPosition: [0, 0]
})
section.push(sentence)
})
})
})
sections = sections.filter(v => v.length)
sections = sections.filter((v) => v.length)
article.sections = sections
let failCount = 0
let translateList = article.textTranslate?.split('\n\n') || []
let translateList = article.textTranslate?.split("\n\n") || []
for (let i = 0; i < article.sections.length; i++) {
let v = article.sections[i]
let sList = []
try {
let s = translateList[i]
sList = s.split('\n')
} catch (e) {
}
sList = s.split("\n")
} catch (e) {}
for (let j = 0; j < v.length; j++) {
let sentence = v[j]
@@ -159,167 +164,167 @@ export function genArticleSectionData(article: Article): number {
export function splitEnArticle2(text: string): string {
text = text.trim()
if (!text && false) {
// text = `It was Sunday. I never get up early on Sundays. I sometimes stay in bed until lunchtime. Last Sunday I got up very late. I looked out of the window. It was dark outside. 'What a day!' I thought. 'It's raining again. ' Just then, the telephone rang. It was my aunt Lucy. 'I've just arrived by train, ' she said. 'I'm coming to see you. '
//
// 'But I'm still having breakfast, ' I said.
// 'What are you doing?' she asked.
// 'I'm having breakfast, ' I repeated.
// 'Dear me,$3.000' she said. 'Do you always get up so late? It's one o'clock!'`
// text = `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.`
// text = `It was Sunday. I never get up early on Sundays. I sometimes stay in bed until lunchtime. Last Sunday I got up very late. I looked out of the window. It was dark outside. 'What a day!' I thought. 'It's raining again. ' Just then, the telephone rang. It was my aunt Lucy. 'I've just arrived by train, ' she said. 'I'm coming to see you. '
//
// 'But I'm still having breakfast, ' I said.
// 'What are you doing?' she asked.
// 'I'm having breakfast, ' I repeated.
// 'Dear me,$3.000' she said. 'Do you always get up so late? It's one o'clock!'`
// text = `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.`
// text = "It was Sunday. I never get up early on Sundays. I sometimes stay in bed until lunchtime. Last Sunday I got up very late. I looked out of the window. It was dark outside. 'What a day!' I thought. 'It's raining again.' Just then, the telephone rang. It was my aunt Lucy. 'I've just arrived by train,' she said. 'I'm coming to see you.'\n\n 'But I'm still having breakfast,' I said.\n\n 'What are you doing?' she asked.\n\n 'I'm having breakfast,' I repeated.\n\n 'Dear me,' she said. 'Do you always get up so late? It's one o'clock!'"
}
if (!text) return '';
if (!text) return ""
const abbreviations = [
'Mr', 'Mrs', 'Ms', 'Dr', 'Prof', 'Sr', 'Jr',
'St', 'Co', 'Ltd', 'Inc', 'e.g', 'i.e', 'U.S.A', 'U.S', 'U.K', 'etc'
];
const abbreviations = ["Mr", "Mrs", "Ms", "Dr", "Prof", "Sr", "Jr", "St", "Co", "Ltd", "Inc", "e.g", "i.e", "U.S.A", "U.S", "U.K", "etc"]
function isSentenceEnd(text, idx) {
const before = text.slice(0, idx + 1);
const after = text.slice(idx + 1);
const before = text.slice(0, idx + 1)
const after = text.slice(idx + 1)
const abbrevPattern = new RegExp('\\b(' + abbreviations.join('|') + ')\\.$', 'i');
if (abbrevPattern.test(before)) return false;
if (/\d+\.$/.test(before)) return false;
if (/\d+\.\d/.test(text.slice(idx - 1, idx + 2))) return false;
if (/%/.test(after)) return false;
if (/[\$¥€]\d/.test(before + after)) return false;
const abbrevPattern = new RegExp("\\b(" + abbreviations.join("|") + ")\\.$", "i")
if (abbrevPattern.test(before)) return false
if (/\d+\.$/.test(before)) return false
if (/\d+\.\d/.test(text.slice(idx - 1, idx + 2))) return false
if (/%/.test(after)) return false
if (/[\$¥€]\d/.test(before + after)) return false
return true;
return true
}
function normalizeQuotes(text) {
const isWord = ch => /\w/.test(ch);
let res = [];
let singleOpen = false;
let doubleOpen = false;
const isWord = (ch) => /\w/.test(ch)
let res = []
let singleOpen = false
let doubleOpen = false
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const ch = text[i]
if (ch === "'") {
const prev = i > 0 ? text[i - 1] : '';
const nxt = i + 1 < text.length ? text[i + 1] : '';
const prev = i > 0 ? text[i - 1] : ""
const nxt = i + 1 < text.length ? text[i + 1] : ""
if (isWord(prev) && isWord(nxt)) {
res.push("'");
continue;
res.push("'")
continue
}
if (singleOpen) {
if (res.length && res[res.length - 1] === ' ') res.pop();
res.push("'");
singleOpen = false;
if (res.length && res[res.length - 1] === " ") res.pop()
res.push("'")
singleOpen = false
} else {
res.push("'");
singleOpen = true;
res.push("'")
singleOpen = true
}
} else if (ch === '"') {
if (doubleOpen) {
if (res.length && res[res.length - 1] === ' ') res.pop();
res.push('"');
doubleOpen = false;
if (res.length && res[res.length - 1] === " ") res.pop()
res.push('"')
doubleOpen = false
} else {
res.push('"');
doubleOpen = true;
res.push('"')
doubleOpen = true
}
} else {
res.push(ch);
res.push(ch)
}
}
return res.join('');
return res.join("")
}
let rawParagraphs = text.replaceAll('\n\n', '`^`').replaceAll('\n', '').split('`^`')
let rawParagraphs = text.replaceAll("\n\n", "`^`").replaceAll("\n", "").split("`^`")
const formattedParagraphs = rawParagraphs.map(p => {
p = p.trim();
if (!p) return '';
const formattedParagraphs = rawParagraphs.map((p) => {
p = p.trim()
if (!p) return ""
p = p.replace(/\n/g, ' ');
p = normalizeQuotes(p);
p = p.replace(/\n/g, " ")
p = normalizeQuotes(p)
const tentative: string[] = p.match(/[^.!?。!?]+[.!?。!?'"”’)]*/g) || [];
const tentative: string[] = p.match(/[^.!?。!?]+[.!?。!?'"”’)]*/g) || []
const sentences = [];
tentative.forEach(segment => {
segment = segment.trim();
if (!segment) return;
const sentences = []
tentative.forEach((segment) => {
segment = segment.trim()
if (!segment) return
const lastCharIdx = segment.length - 1;
const lastCharIdx = segment.length - 1
if (/[.!?。!?]/.test(segment[lastCharIdx])) {
const globalIdx = p.indexOf(segment);
const globalIdx = p.indexOf(segment)
if (!isSentenceEnd(p, globalIdx + segment.length - 1)) {
if (sentences.length > 0) {
sentences[sentences.length - 1] += ' ' + segment;
sentences[sentences.length - 1] += " " + segment
} else {
sentences.push(segment);
sentences.push(segment)
}
return;
return
}
}
sentences.push(segment);
});
sentences.push(segment)
})
const finalSentences = [];
let i = 0;
const finalSentences = []
let i = 0
while (i < sentences.length) {
let cur = sentences[i];
let cur = sentences[i]
if (i + 1 < sentences.length) {
const nxt = sentences[i + 1];
const nxt = sentences[i + 1]
if (/['"”’)\]]$/.test(cur) && /^[a-z]|^(I|You|She|He|They|We)\b/i.test(nxt)) {
finalSentences.push(cur + ' ' + nxt);
i += 2;
continue;
finalSentences.push(cur + " " + nxt)
i += 2
continue
}
}
finalSentences.push(cur);
i += 1;
finalSentences.push(cur)
i += 1
}
return finalSentences.join('\n');
});
return finalSentences.join("\n")
})
return formattedParagraphs.filter(p => p).join('\n\n');
return formattedParagraphs.filter((p) => p).join("\n\n")
}
export function splitCNArticle2(text: string): string {
if (!text && false) {
// text = "飞机误点了,侦探们在机场等了整整一上午。他们正期待从南非来的一个装着钻石的贵重包裹。数小时以前,有人向警方报告,说有人企图偷走这些钻石。当飞机到达时,一些侦探等候在主楼内,另一些侦探则守候在停机坪上。有两个人把包裹拿下飞机,进了海关。这时两个侦探把住门口,另外两个侦探打开了包裹。令他们吃惊的是,那珍贵的包裹里面装的全是石头和沙子!"
// text = `那是个星期天,而在星期天我是从来不早起的,有时我要一直躺到吃午饭的时候。上个星期天,我起得很晚。我望望窗外,外面一片昏暗。“鬼天气!”我想,“又下雨了。”正在这时,电话铃响了。是我姑母露西打来的。“我刚下火车,”她说,“我这就来看你。”
// “但我还在吃早饭,”我说。
// “你在干什么?”她问道。
// “我正在吃早饭,”我又说了一遍。
// “天啊”她说“你总是起得这么晚吗现在已经1点钟了”`
// text = `上星期我去看戏。我的座位很好,戏很有意思,但我却无法欣赏。一青年男子与一青年女子坐在我的身后,大声地说着话。我非常生气,因为我听不见演员在说什么。我回过头去怒视着那一男一女,他们却毫不理会。最后,我忍不住了,又一次回过头去,生气地说:“我一个字也听不见了!”
// “不关你的事,”那男的毫不客气地说,“这是私人间的谈话!”`
// text = `那是个星期天,而在星期天我是从来不早起的,有时我要一直躺到吃午饭的时候。上个星期天,我起得很晚。我望望窗外,外面一片昏暗。“鬼天气!”我想,“又下雨了。”正在这时,电话铃响了。是我姑母露西打来的。“我刚下火车,”她说,“我这就来看你。”
// “但我还在吃早饭,”我说。
// “你在干什么?”她问道。
// “我正在吃早饭,”我又说了一遍。
// “天啊”她说“你总是起得这么晚吗现在已经1点钟了”`
// 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('`^`')
let sectionTextList = text.replaceAll("\n\n", "`^`").replaceAll("\n", "").split("`^`")
let s = sectionTextList.filter(v => v).map((rowSection, i) => {
const segments = segmenterJa.segment(rowSection);
let ss = ''
Array.from(segments).map(sentenceRow => {
let row = sentenceRow.segment
if (row) {
//这个库总是会把反引号给断句到上一行末尾
//而 sentence-splitter 这个库总是会把反引号给断句到下一行开头
if (row[row.length - 1] === "“") {
row = row.substring(0, row.length - 1)
ss += (row + '\n') + '“'
} else {
ss += (row + '\n')
let s = sectionTextList
.filter((v) => v)
.map((rowSection, i) => {
const segments = segmenterJa.segment(rowSection)
let ss = ""
Array.from(segments).map((sentenceRow) => {
let row = sentenceRow.segment
if (row) {
//这个库总是会把反引号给断句到上一行末尾
//而 sentence-splitter 这个库总是会把反引号给断句到下一行开头
if (row[row.length - 1] === "“") {
row = row.substring(0, row.length - 1)
ss += row + "\n" + "“"
} else {
ss += row + "\n"
}
}
}
})
return ss
})
return ss
}).join('\n').trim()
.join("\n")
.trim()
return s
}
export function getTranslateText(article: Article) {
return article.textTranslate
.split('\n\n').filter(v => v)
return article.textTranslate.split("\n\n").filter((v) => v)
}
export function usePlaySentenceAudio() {
@@ -327,14 +332,14 @@ export function usePlaySentenceAudio() {
const settingStore = useSettingStore()
let timer = $ref(0)
function playSentenceAudio(sentence: Sentence, ref?: HTMLAudioElement,) {
function playSentenceAudio(sentence: Sentence, ref?: HTMLAudioElement) {
if (sentence.audioPosition?.length && ref && ref.src) {
clearTimeout(timer)
if (ref.played) {
ref.pause()
}
let start = sentence.audioPosition[0];
ref.volume = settingStore.wordSoundVolume / 100
let start = sentence.audioPosition[0]
// ref.volume = settingStore.wordSoundVolume / 100
ref.currentTime = start
ref.play()
let end = sentence.audioPosition?.[1]
@@ -342,9 +347,9 @@ export function usePlaySentenceAudio() {
if (end && end !== -1) {
timer = setTimeout(() => {
console.log('停')
console.log("停")
ref.pause()
}, (end - start) / ref.playbackRate * 1000)
}, ((end - start) / ref.playbackRate) * 1000)
}
} else {
playWordAudio(sentence.text)
@@ -361,8 +366,8 @@ export function syncBookInMyStudyList(study = false) {
_nextTick(() => {
const base = useBaseStore()
const runtimeStore = useRuntimeStore()
let rIndex = base.article.bookList.findIndex(v => v.id === runtimeStore.editDict.id)
let temp = cloneDeep(runtimeStore.editDict);
let rIndex = base.article.bookList.findIndex((v) => v.id === runtimeStore.editDict.id)
let temp = cloneDeep(runtimeStore.editDict)
if (!temp.custom && temp.id !== DictId.articleCollect) {
temp.custom = true
}
@@ -375,4 +380,4 @@ export function syncBookInMyStudyList(study = false) {
if (study) base.article.studyIndex = base.article.bookList.length - 1
}
}, 100)
}
}

View File

@@ -1,4 +1,4 @@
import {Article, TaskWords, Word, WordPracticeMode} from "@/types/types.ts";
import { Article, TaskWords, Word, WordPracticeMode } from "@/types/types.ts";
import { useBaseStore } from "@/stores/base.ts";
import { useSettingStore } from "@/stores/setting.ts";
import { getDefaultWord } from "@/types/func.ts";
@@ -87,7 +87,7 @@ export function useArticleOptions() {
export function getCurrentStudyWord(): TaskWords {
const store = useBaseStore()
let data = {new: [], review: [], write: []}
let data = {new: [], review: [], write: [], shuffle: []}
let dict = store.sdict;
let isTest = false
let words = dict.words.slice()

View File

@@ -1,8 +1,8 @@
import { createApp } from 'vue'
import {createApp} from 'vue'
import './assets/css/style.scss'
import 'virtual:uno.css';
import App from './App.vue'
import { createPinia } from "pinia"
import {createPinia} from "pinia"
import router from "@/router.ts";
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
@@ -13,6 +13,7 @@ import loadingDirective from './directives/loading.tsx'
const pinia = createPinia()
const app = createApp(App)
app.use(VueVirtualScroller)
app.use(pinia)
app.use(router)
@@ -21,5 +22,17 @@ app.directive('opacity', (el, binding) => {
el.style.opacity = binding.value ? 1 : 0
})
app.directive('loading', loadingDirective)
app.mount('#app')
// 注册Service Worker(pwa支持)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(err => {
console.log('ServiceWorker registration failed: ', err);
});
});
}

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

@@ -18,7 +18,7 @@ import dayjs from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import isoWeek from 'dayjs/plugin/isoWeek'
import { useFetch } from "@vueuse/core";
import { CAN_REQUEST, DICT_LIST, PracticeSaveArticleKey } from "@/config/env.ts";
import { AppEnv, DICT_LIST, PracticeSaveArticleKey } from "@/config/env.ts";
import { myDictList } from "@/apis";
dayjs.extend(isoWeek)
@@ -36,7 +36,7 @@ watch(() => store.load, n => {
}, {immediate: true})
async function init() {
if (CAN_REQUEST) {
if (AppEnv.CAN_REQUEST) {
let res = await myDictList({type: "article"})
if (res.success) {
store.setState(Object.assign(store.$state, res.data))
@@ -215,7 +215,7 @@ const {data: recommendBookList, isFetching} = useFetch(resourceWrap(DICT_LIST.AR
</div>
<div class="flex flex-col justify-between items-end">
<div class="flex gap-4 items-center" v-opacity="base.sbook.id">
<div class="color-blue cursor-pointer" @click="router.push('/book-list')">更换</div>
<div class="color-link cursor-pointer" @click="router.push('/book-list')">更换</div>
</div>
<BaseButton size="large"
@click="startStudy"
@@ -238,10 +238,10 @@ const {data: recommendBookList, isFetching} = useFetch(resourceWrap(DICT_LIST.AR
</BaseIcon>
</PopConfirm>
<div class="color-blue cursor-pointer" v-if="base.article.bookList.length > 1"
<div class="color-link cursor-pointer" v-if="base.article.bookList.length > 1"
@click="isMultiple = !isMultiple; selectIds = []">{{ isMultiple ? '取消' : '管理书籍' }}
</div>
<div class="color-blue cursor-pointer" @click="nav('book-detail', { isAdd: true })">创建个人书籍</div>
<div class="color-link cursor-pointer" @click="nav('book-detail', { isAdd: true })">创建个人书籍</div>
</div>
</div>
<div class="flex gap-4 flex-wrap mt-4">
@@ -262,7 +262,7 @@ const {data: recommendBookList, isFetching} = useFetch(resourceWrap(DICT_LIST.AR
<div class="flex justify-between">
<div class="title">推荐</div>
<div class="flex gap-4 items-center">
<div class="color-blue cursor-pointer" @click="router.push('/book-list')">更多</div>
<div class="color-link cursor-pointer" @click="router.push('/book-list')">更多</div>
</div>
</div>
@@ -278,8 +278,7 @@ const {data: recommendBookList, isFetching} = useFetch(resourceWrap(DICT_LIST.AR
<style scoped lang="scss">
.stat {
@apply rounded-xl p-4 box-border relative flex-1;
background: white;
@apply rounded-xl p-4 box-border relative flex-1 bg-[var(--bg-history)];
border: 1px solid gainsboro;
.num {

View File

@@ -20,7 +20,7 @@ import ArticleAudio from "@/pages/article/components/ArticleAudio.vue";
import { MessageBox } from "@/utils/MessageBox.tsx";
import { useSettingStore } from "@/stores/setting.ts";
import { useFetch } from "@vueuse/core";
import { CAN_REQUEST, DICT_LIST } from "@/config/env.ts";
import { AppEnv, DICT_LIST } from "@/config/env.ts";
import { detail } from "@/apis";
const runtimeStore = useRuntimeStore()
@@ -93,7 +93,7 @@ async function init() {
}
if (base.article.bookList.find(book => book.id === runtimeStore.editDict.id)) {
if (CAN_REQUEST) {
if (AppEnv.CAN_REQUEST) {
let res = await detail({id: runtimeStore.editDict.id})
if (res.success) {
runtimeStore.editDict.statistics = res.data.statistics
@@ -225,7 +225,7 @@ function next() {
<div
class="item border border-item border-solid mt-2 p-2 bg-[var(--bg-history)] rounded-md flex justify-between"
v-for="i in currentPractice">
<span class="color-gray">{{ _dateFormat(i.startDate, 'YYYY/MM/DD HH:mm') }}</span>
<span class="color-gray">{{ _dateFormat(i.startDate) }}</span>
<span>{{ msToHourMinute(i.spend) }}</span>
</div>
</div>

View File

@@ -34,7 +34,7 @@ import { useRoute, useRouter } from "vue-router";
import PracticeLayout from "@/components/PracticeLayout.vue";
import ArticleAudio from "@/pages/article/components/ArticleAudio.vue";
import VolumeSetting from "@/pages/article/components/VolumeSetting.vue";
import { CAN_REQUEST, DICT_LIST, PracticeSaveArticleKey } from "@/config/env.ts";
import { AppEnv, DICT_LIST, PracticeSaveArticleKey } from "@/config/env.ts";
import { addStat, setDictProp } from "@/apis";
import { useRuntimeStore } from "@/stores/runtime.ts";
@@ -53,6 +53,7 @@ let typingArticleRef = $ref<any>()
let loading = $ref<boolean>(false)
let allWrongWords = new Set()
let editArticle = $ref<Article>(getDefaultArticle())
let audioRef = $ref<HTMLAudioElement>()
let timer = $ref(0)
let isFocus = true
@@ -132,10 +133,34 @@ async function init() {
}
}
const initAudio = () => {
_nextTick(() => {
audioRef.volume = settingStore.articleSoundVolume / 100
audioRef.playbackRate = settingStore.articleSoundSpeed
})
}
const handleVolumeUpdate = (volume: number) => {
settingStore.setState({
articleSoundVolume: volume
})
}
const handleSpeedUpdate = (speed: number) => {
settingStore.setState({
articleSoundSpeed: speed
})
}
watch(() => store.load, (n) => {
if (n && loading) init()
}, {immediate: true})
watch(() => settingStore.$state, (n) => {
initAudio()
}, {immediate: true, deep: true})
onMounted(() => {
if (store.sbook?.articles?.length) {
articleData.list = cloneDeep(store.sbook.articles)
@@ -238,6 +263,10 @@ function setArticle(val: Article) {
})
}
watch(() => articleData.article.id, n => {
console.log('articleData.article.id', n)
})
async function complete() {
clearInterval(timer)
setTimeout(() => {
@@ -254,7 +283,7 @@ async function complete() {
wrong: statStore.wrong,
}
if (CAN_REQUEST) {
if (AppEnv.CAN_REQUEST) {
let res = await addStat({...data, type: 'article'})
if (!res.success) {
Toast.error(res.msg)
@@ -337,13 +366,14 @@ async function changeArticle(val: ArticleItem) {
store.sbook.lastLearnIndex = rIndex
getCurrentPractice()
if (CAN_REQUEST) {
if (AppEnv.CAN_REQUEST) {
let res = await setDictProp(null, store.sbook)
if (!res.success) {
Toast.error(res.msg)
}
}
}
initAudio()
}
const handlePlayNext = (nextArticle: Article) => {
@@ -372,7 +402,6 @@ function onKeyUp() {
}
async function onKeyDown(e: KeyboardEvent) {
// console.log('e', e)
switch (e.key) {
case 'Backspace':
typingArticleRef.del()
@@ -414,13 +443,14 @@ onUnmounted(() => {
timer && clearInterval(timer)
})
let audioRef = $ref<HTMLAudioElement>()
const {playSentenceAudio} = usePlaySentenceAudio()
function play2(e) {
if (settingStore.articleSound || e.handle) {
playSentenceAudio(e.sentence, audioRef)
}
_nextTick(() => {
if (settingStore.articleSound || e.handle) {
playSentenceAudio(e.sentence, audioRef)
}
})
}
const currentPractice = computed(() => {
@@ -461,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
@@ -519,7 +549,10 @@ provide('currentPractice', currentPractice)
ref="audioRef"
:article="articleData.article"
:autoplay="settingStore.articleAutoPlayNext"
@ended="settingStore.articleAutoPlayNext && next()"></ArticleAudio>
@ended="settingStore.articleAutoPlayNext && next()"
@update-speed="handleSpeedUpdate"
@update-volume="handleVolumeUpdate"
></ArticleAudio>
<div class="flex flex-col items-center justify-center gap-1">
<div class="flex gap-2 center">
<VolumeSetting/>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Article } from "@/types/types.ts";
import { watch } from "vue";
import { ref, watch, nextTick } from "vue";
import { get } from "idb-keyval";
import Audio from "@/components/base/Audio.vue";
import { LOCAL_FILE_KEY } from "@/config/env.ts";
@@ -10,12 +10,43 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
ended: []
(e: 'ended'): [],
(e: 'update-volume', volume: number): void,
(e: 'update-speed', volume: number): void
}>();
let file = $ref(null)
let instance = $ref<{ audioRef: HTMLAudioElement }>({audioRef: null})
let instance = $ref<{ audioRef: HTMLAudioElement }>({ audioRef: null })
const pendingUpdates = ref({})
const handleVolumeUpdate = (volume: number) => {
emit('update-volume', volume)
}
const handleSpeedUpdate = (speed: number) => {
emit('update-speed', speed)
}
const setAudioRefValue = (key: string, value: any) => {
if (instance?.audioRef) {
switch (key) {
case 'currentTime':
instance.audioRef.currentTime = value;
break;
case 'volume':
instance.audioRef.volume = value;
break;
case 'playbackRate':
instance.audioRef.playbackRate = value;
break;
default:
break
}
} else {
// 如果audioRef还未初始化先存起来等初始化后再设置 => watch监听instance变化
pendingUpdates.value[key] = value
}
}
watch(() => props.article.audioFileId, async () => {
if (!props.article.audioSrc && props.article.audioFileId) {
@@ -29,7 +60,15 @@ watch(() => props.article.audioFileId, async () => {
} else {
file = null
}
}, {immediate: true})
}, { immediate: true })
// 监听instance变化设置之前pending的值
watch(() => instance, (newVal) => {
Object.entries(pendingUpdates.value).forEach(([key, value]) => {
setAudioRefValue(key, value)
});
pendingUpdates.value = {};
}, { immediate: true })
//转发一遍这里Proxy的默认值不能为{}可能是vue做了什么
defineExpose(new Proxy({
@@ -52,21 +91,18 @@ defineExpose(new Proxy({
return target[key]
},
set(_, key, value) {
if (key === 'currentTime') instance.audioRef.currentTime = value
if (key === 'volume') return instance.audioRef.volume = value
setAudioRefValue(key as string, value)
return true
}
}))
</script>
<template>
<Audio v-bind="$attrs" ref="instance"
v-if="props.article.audioSrc"
:src="props.article.audioSrc"
@ended="emit('ended')"/>
<Audio v-bind="$attrs" ref="instance"
v-else-if="file"
:src="file"
@ended="emit('ended')"
/>
<Audio v-bind="$attrs" ref="instance" v-if="props.article.audioSrc" :src="props.article.audioSrc"
@ended="emit('ended')" @update-volume="handleVolumeUpdate" @update-speed="handleSpeedUpdate" />
<Audio v-bind="$attrs" ref="instance" v-else-if="file" :src="file" @ended="emit('ended')"
@update-volume="handleVolumeUpdate" @update-speed="handleSpeedUpdate" />
</template>

View File

@@ -12,8 +12,8 @@ import { Option, Select } from "@/components/base/select";
import BaseInput from "@/components/base/BaseInput.vue";
import Form from "@/components/base/form/Form.vue";
import FormItem from "@/components/base/form/FormItem.vue";
import { CAN_REQUEST } from "@/config/env.ts";
import { addDict } from "@/apis";
import { AppEnv } from "@/config/env.ts";
const props = defineProps<{
isAdd: boolean,
@@ -58,7 +58,7 @@ async function onSubmit() {
Toast.warning('已有相同名称!')
return
} else {
if (CAN_REQUEST) {
if (AppEnv.CAN_REQUEST) {
loading = true
let res = await addDict(null, data)
loading = false

View File

@@ -1,25 +1,24 @@
<script setup lang="ts">
import { inject, onMounted, onUnmounted, watch } from "vue"
import {inject, onMounted, onUnmounted, watch} from "vue"
import {Article, ArticleWord, PracticeArticleWordType, Sentence, ShortcutKey, Word} from "@/types/types.ts";
import { useBaseStore } from "@/stores/base.ts";
import { useSettingStore } from "@/stores/setting.ts";
import { usePlayBeep, usePlayCorrect, usePlayKeyboardAudio } from "@/hooks/sound.ts";
import {useBaseStore} from "@/stores/base.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {usePlayBeep, usePlayCorrect, usePlayKeyboardAudio} from "@/hooks/sound.ts";
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
import { _dateFormat, _nextTick, msToHourMinute, msToMinute, total } from "@/utils";
import {_dateFormat, _nextTick, msToHourMinute, total} from "@/utils";
import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css'
import ContextMenu from '@imengyu/vue3-context-menu'
import { getTranslateText } from "@/hooks/article.ts";
import BaseButton from "@/components/BaseButton.vue";
import QuestionForm from "@/pages/article/components/QuestionForm.vue";
import { getDefaultArticle, getDefaultWord } from "@/types/func.ts";
import {getDefaultArticle, getDefaultWord} from "@/types/func.ts";
import Toast from '@/components/base/toast/Toast.ts'
import TypingWord from "@/pages/article/components/TypingWord.vue";
import Space from "@/pages/article/components/Space.vue";
import { useWordOptions } from "@/hooks/dict.ts";
import {useWordOptions} from "@/hooks/dict.ts";
import nlp from "compromise/three";
import { nanoid } from "nanoid";
import { usePracticeStore } from "@/stores/practice.ts";
import { PracticeSaveArticleKey } from "@/config/env.ts";
import {nanoid} from "nanoid";
import {usePracticeStore} from "@/stores/practice.ts";
import {PracticeSaveArticleKey} from "@/config/env.ts";
interface IProps {
article: Article,
@@ -246,6 +245,7 @@ function nextSentence() {
}
function onTyping(e: KeyboardEvent) {
debugger
if (!props.article.sections.length) return
if (isTyping || isEnd) return;
isTyping = true;
@@ -263,7 +263,12 @@ function onTyping(e: KeyboardEvent) {
// 检查下一个单词是否存在
if (wordIndex + 1 < currentSentence.words.length) {
wordIndex++;
emit('nextWord', currentWord);
currentWord = currentSentence.words[wordIndex]
if ([PracticeArticleWordType.Symbol,PracticeArticleWordType.Number].includes(currentWord.type) && settingStore.ignoreSymbol){
next()
}else {
emit('nextWord', currentWord);
}
} else {
nextSentence()
}
@@ -273,12 +278,16 @@ function onTyping(e: KeyboardEvent) {
if (e.code === 'Space') {
next()
} else {
wrong = ' '
playBeep()
setTimeout(() => {
wrong = ''
wrong = input = ''
}, 500)
// 如果在第一个单词的最后一位上, 不按空格的直接输入下一个字母的话
next()
isTyping = false
onTyping(e)
// wrong = ' '
// playBeep()
// setTimeout(() => {
// wrong = ''
// wrong = input = ''
// }, 500)
}
} else {
//如果是首句首词
@@ -427,8 +436,8 @@ function onContextMenu(e: MouseEvent, sentence: Sentence, i, j, w) {
label: "收藏单词",
onClick: () => {
let word = props.article.sections[i][j].words[w]
let doc = nlp(word.word)
let text = word.word
let doc = nlp(text)
// 优先判断是不是动词
if (doc.verbs().found) {
text = doc.verbs().toInfinitive().text()
@@ -636,7 +645,7 @@ const currentPractice = inject('currentPractice', [])
<span :class="i === currentPractice.length-1 ? 'color-red':'color-gray'"
>{{
i === currentPractice.length - 1 ? '当前' : i + 1
}}.&nbsp;&nbsp;{{ _dateFormat(item.startDate, 'YYYY/MM/DD HH:mm') }}</span>
}}.&nbsp;&nbsp;{{ _dateFormat(item.startDate) }}</span>
<span>{{ msToHourMinute(item.spend) }}</span>
</div>
</div>

View File

@@ -1,239 +0,0 @@
<script setup lang="ts">
import {GITHUB, ProjectName} from "@/config/env.ts";
import BaseButton from "@/components/BaseButton.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import {defineAsyncComponent} from "vue";
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
let showWechatDialog = $ref(false)
let showXhsDialog = $ref(false)
</script>
<template>
<div class="flex flex-col justify-between min-h-screen">
<div class="center flex-col gap-8">
<h1>{{ ProjectName }}</h1>
<div class="text-center -mt-10">
<h2>学习英语一次敲击一点进步</h2>
<h2>记忆不再盲目学习更高效开源单词与文章练习工具</h2>
</div>
<div class="flex">
<BaseButton size="large" @click="$router.push('/words')">单词练习</BaseButton>
<BaseButton size="large" @click="$router.push('/articles')">文章练习</BaseButton>
</div>
<div class="center justify-center flex-col gap-2 w-full mb-4">
<a href="https://skywork.ai/p/GrXQb4" class="w-60vw" target="_blank"><img src="/skywork-ai.png" alt="Skywork.AI" class="w-full rounded-lg"></a>
<span>Skywork.AI:<a href="https://skywork.ai/p/GrXQb4" class="color-blue!" target="_blank">10 tasks in 1 hour, not 10 hours Limited free spots: 127 left</a></span>
</div>
<div class="w-60vw">
<div class="flex mb-5 gap-space">
<div class="card">
<div class="emoji">📚</div>
<div class="title">单词练习</div>
<div class="desc">
<ul>
<li>三种输入模式跟打 / 复习 / 默写</li>
<li>智能模式智能规划复习与默写</li>
<li>自由模式不受限制自行规划</li>
</ul>
</div>
</div>
<div class="card">
<div class="emoji"></div>
<div class="title">文章练习</div>
<div class="desc">
<ul>
<li>内置常见书籍也可自行添加文章</li>
<li>跟打 + 默写双模式让背诵更高效</li>
<li>支持边听边默写强化记忆</li>
</ul>
</div>
</div>
<div class="card">
<div class="emoji">📕</div>
<div class="title">收藏错词本已掌握</div>
<div class="desc">
<ul>
<li>输入错误自动添加到错词本</li>
<li>主动添加到已掌握后续自动跳过</li>
<li>主动添加到收藏中以便巩固复习</li>
</ul>
</div>
</div>
<div class="card">
<div class="emoji">🌐</div>
<div class="title">海量词库</div>
<div class="desc">
内置小学初中高中四六级考研雅思托福GREGMATSATBEC专四专八等词库
</div>
</div>
</div>
<div class="flex gap-space">
<div class="card">
<div class="emoji">🆓</div>
<div class="title">免费开源</div>
<div class="desc">
<ul>
<li>完全开源可审查可修改</li>
<li>免费使用</li>
<li>私有部署</li>
</ul>
</div>
</div>
<div class="card">
<div class="emoji"></div>
<div class="title">高度自由</div>
<div class="desc">
<ul>
<li>丰富的键盘音效</li>
<li>可自定义快捷键</li>
<li>高度定制化的设置选项</li>
</ul>
</div>
</div>
<div class="card">
<div class="emoji">🎨</div>
<div class="title">简洁高效</div>
<div class="desc">
<ul>
<li>简洁设计现代化UI无广告</li>
<li>界面清爽操作简单</li>
<li>不强制关注任何平台</li>
</ul>
</div>
</div>
<div class="card">
<div class="emoji">🎯</div>
<div class="title">个性学习</div>
<div class="desc">
<ul>
<li>自由添加词典与文章</li>
<li>定制个性学习计划</li>
<li>多种学习复习策略</li>
</ul>
</div>
</div>
</div>
<div class="w-60vw text-center" v-if="false">
<h3 class="text-4xl">单词练习</h3>
<img src="/word.png" alt="word.png" class="w-full rounded-xl">
<h3 class="text-4xl">文章练习</h3>
<img src="/article.png" alt="article.png" class="w-full rounded-xl">
</div>
</div>
</div>
<div class="center gap-space my-10 bottom">
<div class="center gap-1">
<a
:href="GITHUB"
target="_blank"
rel="noreferrer"
aria-label="GITHUB 项目地址">
<BaseIcon>
<IconSimpleIconsGithub/>
</BaseIcon>
</a>
<BaseIcon @click="showWechatDialog = true">
<IconSimpleIconsWechat/>
</BaseIcon>
<BaseIcon @click="showXhsDialog = true" >
<IconSimpleIconsXiaohongshu/>
</BaseIcon>
<a
href="https://x.com/typewords2"
target="_blank"
rel="noreferrer"
aria-label="关注我的 X 账户 typewords2">
<BaseIcon>
<IconRiTwitterFill/>
</BaseIcon>
</a>
<a
href="mailto:zyronon@163.com"
target="_blank"
rel="noreferrer"
aria-label="发送邮件到 zyronon@163.com">
<BaseIcon>
<IconMaterialSymbolsMail/>
</BaseIcon>
</a>
</div>
<div>蜀ICP备2025157466号</div>
</div>
<Dialog v-model="showWechatDialog" title="Type Words 交流群">
<div class="w-120 p-6 pt-0">
<div class="mb-4">
加入我们的用户社群后您可以与我们的开发团队进行沟通分享您的使用体验和建议帮助我们改进产品同时也能够及时了解我们的最新动态和更新内容
</div>
<div class="text-center">
<img src="/wechat.png" alt="微信群二维码" class="w-60 rounded-lg">
</div>
</div>
</Dialog>
<Dialog v-model="showXhsDialog" title="小红书">
<div class="w-120 p-6 pt-0">
<div class="mb-4">
关注小红书后您可以获得开发团队的最新动态和更新内容反馈您的使用体验和建议帮助我们改进产品同时也能够及时了解我们的最新动态和更新内容
</div>
<div class="text-center">
<img src="/xhs.png" alt="小红书二维码" class="w-60 rounded-lg">
</div>
</div>
</Dialog>
</div>
</template>
<style scoped lang="scss">
h1 {
font-size: 5rem;
background: linear-gradient(120deg, #bd34fe 30%, #41d1ff);
-webkit-text-fill-color: transparent;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
margin: 2rem;
}
h2 {
margin: 0;
}
h3:first-child {
margin-top: 0;
}
.card {
@apply flex flex-col items-start gap-2 mb-0 w-25%;
.emoji {
display: inline-block;
background: var(--color-third);
padding: .6rem;
border-radius: 0.4rem;
font-size: 1.5rem;
}
.title {
font-weight: bold;
}
ul {
margin: 0;
padding-left: 1.2rem;
}
}
.bottom {
width: 100%;
padding-top: 2rem;
border-top: 1px solid #c4c4c4;
}
a {
color: unset;
}
</style>

View File

@@ -8,13 +8,15 @@ import useTheme from "@/hooks/theme.ts";
import BaseIcon from "@/components/BaseIcon.vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
const settingStore = useSettingStore()
const runtimeStore = useRuntimeStore()
const router = useRouter()
const {toggleTheme,getTheme} = useTheme()
const {toggleTheme, getTheme} = useTheme()
//seonginx
function goHome() {
window.location.href = '/';
}
</script>
<template>
@@ -24,7 +26,7 @@ const {toggleTheme,getTheme} = useTheme()
<div class="aside anim fixed" :class="{'expand':settingStore.sideExpand}">
<div class="top">
<Logo v-if="settingStore.sideExpand"/>
<div class="row" @click="router.push('/')">
<div class="row" @click="goHome">
<IconFluentHome20Regular/>
<span v-if="settingStore.sideExpand">主页</span>
</div>
@@ -49,14 +51,14 @@ const {toggleTheme,getTheme} = useTheme()
</div>
<div class="bottom flex justify-evenly ">
<BaseIcon
@click="settingStore.sideExpand = !settingStore.sideExpand">
@click="settingStore.sideExpand = !settingStore.sideExpand">
<IconFluentChevronLeft20Filled v-if="settingStore.sideExpand"/>
<IconFluentChevronLeft20Filled class="transform-rotate-180" v-else/>
</BaseIcon>
<BaseIcon
v-if="settingStore.sideExpand"
:title="`切换主题(${settingStore.shortcutKeyMap[ShortcutKey.ToggleTheme]})`"
@click="toggleTheme"
v-if="settingStore.sideExpand"
:title="`切换主题(${settingStore.shortcutKeyMap[ShortcutKey.ToggleTheme]})`"
@click="toggleTheme"
>
<IconFluentWeatherMoon16Regular v-if="getTheme() === 'light'"/>
<IconFluentWeatherSunny16Regular v-else/>

View File

@@ -1,17 +1,17 @@
<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 {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,
EXPORT_DATA_KEY,
APP_NAME, APP_VERSION, EMAIL,
EXPORT_DATA_KEY, GITHUB,
LOCAL_FILE_KEY,
Origin,
PracticeSaveArticleKey,
@@ -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,8 +29,9 @@ 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 {get, set} from "idb-keyval";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {useUserStore} from "@/stores/auth.ts";
const emit = defineEmits<{
toggleDisabledDialogEscKey: [val: boolean]
@@ -40,6 +41,8 @@ const tabIndex = $ref(0)
const settingStore = useSettingStore()
const runtimeStore = useRuntimeStore()
const store = useBaseStore()
const userStore = useUserStore()
//@ts-ignore
const gitLastCommitHash = ref(LATEST_COMMIT_HASH);
const simpleWords = $computed({
@@ -95,7 +98,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;
}
@@ -424,8 +427,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>
<!-- 音效-->
@@ -453,16 +456,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>
@@ -554,7 +557,7 @@ function importOldData() {
<div class="line"></div>
<SettingItem mainTitle="自动切换"/>
<SettingItem title="自动切换下一个单词"
desc="未开启自动切换时,当输入完成后请使用 **空格键** 切换下一个"
desc="仅在 **跟写** 时生效,听写、辨认、默写均不会自动切换,需要手动按 **空格键** 切换"
>
<Switch v-model="settingStore.autoNextWord"/>
</SettingItem>
@@ -580,16 +583,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>
@@ -618,8 +621,12 @@ function importOldData() {
<Slider v-model="settingStore.articleSoundSpeed" :step="0.1" :min="0.5" :max="3"/>
<span class="w-10 pl-5">{{ settingStore.articleSoundSpeed }}</span>
</SettingItem>
</div>
<div class="line"></div>
<SettingItem title="输入时忽略符号/数字">
<Switch v-model="settingStore.ignoreSymbol"/>
</SettingItem>
</div>
<div class="body" v-if="tabIndex === 3">
<div class="row">
@@ -634,7 +641,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>
@@ -671,25 +678,73 @@ function importOldData() {
@change="importData">
</div>
<PopConfirm
title="导入老版本数据前,请先备份当前数据,确定要导入老版本数据吗?"
@confirm="importOldData">
title="导入老版本数据前,请先备份当前数据,确定要导入老版本数据吗?"
@confirm="importOldData">
<BaseButton>老版本数据导入</BaseButton>
</PopConfirm>
</div>
</div>
<div v-if="tabIndex === 5">
<div class="item p-2">
<div class="log-item">
<div class="mb-2">
<div>
<div>更新日期2025/10/26</div>
<div>更新内容进一步完善单词练习解决复习数量太多的问题</div>
<div>日期2025/11/16</div>
<div>内容辨认单词时不认识单词可以直接输入自动标识为错误单词无需按2</div>
</div>
</div>
</div>
<div class="log-item">
<div class="mb-2">
<div>
<div>日期2025/11/15</div>
<div>内容练习单词时底部工具栏新增跳到下一阶段按钮</div>
</div>
</div>
</div>
<div class="log-item">
<div class="mb-2">
<div>
<div>日期2025/11/14</div>
<div>内容新增文章练习时可跳过空格如果在单词的最后一位上不按空格直接输入下一个字母的话自动跳下一个单词 按空格也自动跳下一个单词</div>
</div>
</div>
</div>
<div class="log-item">
<div class="mb-2">
<div>
<div>日期2025/11/13</div>
<div>内容新增文章练习时输入时忽略符号/数字选项</div>
</div>
</div>
</div>
<div class="log-item">
<div class="mb-2">
<div>
<div>日期2025/11/6</div>
<div>内容新增随机复习功能</div>
</div>
</div>
</div>
<div class="log-item">
<div class="mb-2">
<div>
<div>日期2025/10/30</div>
<div>内容集成PWA基础配置支持用户以类App形式打开项目</div>
</div>
</div>
</div>
<div class="log-item">
<div class="mb-2">
<div>
<div>日期2025/10/26</div>
<div>内容进一步完善单词练习解决复习数量太多的问题</div>
</div>
<div class="text-base mt-1">
<ol>
<li>
<div class="title"><b>智能模式优化</b></div>
<div class="desc">练习时新增四种练习模式学习复习听写默写</div>
<div class="desc">练习时新增四种练习模式学习辨认听写默写</div>
</li>
<li>
<div class="title"><b>学习模式</b></div>
@@ -702,7 +757,7 @@ function importOldData() {
</div>
</li>
<li>
<div class="title"><b>复习模式新增</b></div>
<div class="title"><b>辨认模式新增</b></div>
<div class="desc">
<ul>
<li>仅在复习已学单词时出现</li>
@@ -730,13 +785,20 @@ function importOldData() {
<div>通过引入复习默写两种模式使复习流程更加灵活高效</div>
</div>
</div>
<div class="line"></div>
</div>
<div class="item p-2">
<div class="log-item">
<div class="mb-2">
<div>
<div>更新日期2025/9/14</div>
<div>更新内容完善文章编辑导入导出等功能</div>
<div>日期2025/10/8</div>
<div>内容文章支持自动播放下一篇</div>
</div>
</div>
</div>
<div class="log-item">
<div class="mb-2">
<div>
<div>日期2025/9/14</div>
<div>内容完善文章编辑导入导出等功能</div>
</div>
<div class="text-base mt-1">
<div>1文章的音频管理功能目前已可添加音频设置句子与音频的对应位置</div>
@@ -744,24 +806,61 @@ function importOldData() {
<div>3单词可导入导出</div>
</div>
</div>
<div class="line"></div>
</div>
<div class="log-item">
<div class="mb-2">
<div>
<div>日期2025/8/10</div>
<div>内容2.0版本发布全新UI全新逻辑新增短语例句近义词等功能</div>
</div>
</div>
</div>
<div class="log-item">
<div class="mb-2">
<div>
<div>日期2025/7/19</div>
<div>内容1.0版本发布</div>
</div>
</div>
</div>
</div>
<div v-if="tabIndex === 6" class="center flex-col">
<h1>Type Words</h1>
<!-- 用户信息部分 -->
<div v-if="userStore.isLoggedIn && userStore.user" class="user-info-section mb-6">
<div class="user-avatar mb-4">
<img v-if="userStore.user.avatar" :src="userStore.user.avatar" alt="头像" class="avatar-img"/>
<div v-else class="avatar-placeholder">
{{ userStore.user.nickname?.charAt(0) || 'U' }}
</div>
</div>
<h3 class="mb-2">{{ userStore.user.nickname || '用户' }}</h3>
<p v-if="userStore.user.email" class="text-sm color-gray mb-1">{{ userStore.user.email }}</p>
<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"
>
退出登录
</BaseButton>
</div>
<p class="w-100 text-xl">
感谢使用本项目本项目是开源项目如果觉得有帮助请在 GitHub 点个 Star您的支持是我持续改进的动力
</p>
<p>
GitHub地址<a href="https://github.com/zyronon/TypeWords" target="_blank">https://github.com/zyronon/TypeWords</a>
GitHub地址<a :href="GITHUB" target="_blank">{{ GITHUB }}</a>
</p>
<p>
反馈<a
href="https://github.com/zyronon/TypeWords/issues" target="_blank">https://github.com/zyronon/TypeWords/issues</a>
反馈<a :href="`${GITHUB}/issues`" target="_blank">{{ GITHUB }}/issues</a>
</p>
<p>
作者邮箱<a href="mailto:zyronon@163.com">zyronon@163.com</a>
作者邮箱<a :href="`mailto:${EMAIL}`">{{ EMAIL }}</a>
</p>
<div class="text-md color-gray mt-10">
Build {{ gitLastCommitHash }}
@@ -775,6 +874,80 @@ function importOldData() {
<style scoped lang="scss">
.log-item {
border-bottom: 1px solid var(--color-input-border);
margin-bottom: 1rem;
}
// 用户信息样式
.user-info-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
border: 1px solid var(--color-input-border);
border-radius: 8px;
background: var(--color-bg);
width: 100%;
max-width: 400px;
.user-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
border: 3px solid var(--color-select-bg);
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
font-weight: bold;
}
}
h3 {
margin: 0;
color: var(--color-font-1);
}
.text-sm {
font-size: 0.9rem;
margin: 0.25rem 0;
}
.color-gray {
color: #666;
}
.mb-1 {
margin-bottom: 0.25rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mt-4 {
margin-top: 1rem;
}
}
.setting {
@apply text-lg;
display: flex;

66
src/pages/user/Code.vue Normal file
View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import {CodeType} from "@/types/types.ts";
import BaseButton from "@/components/BaseButton.vue";
import {sendCode} from "@/apis/user.ts";
import {PHONE_CONFIG} from "@/config/auth.ts";
import Toast from "@/components/base/toast/Toast.ts";
let isSendingCode = $ref(false)
let codeCountdown = $ref(0)
interface IProps {
validateField: Function,
type: CodeType
val: any
size?: any
}
const props = withDefaults(defineProps<IProps>(), {
size: 'large',
})
// 发送验证码
async function sendVerificationCode() {
let res = props.validateField()
if (res) {
try {
isSendingCode = true
const res = await sendCode({val: props.val, type: props.type})
if (res.success) {
codeCountdown = PHONE_CONFIG.sendInterval
const timer = setInterval(() => {
codeCountdown--
if (codeCountdown <= 0) {
clearInterval(timer)
}
}, 1000)
} else {
Toast.error(res.msg || '发送失败')
}
} catch (error) {
console.error('Send code error:', error)
Toast.error('发送验证码失败')
} finally {
isSendingCode = false
}
}
}
</script>
<template>
<BaseButton
@click="sendVerificationCode"
:disabled="isSendingCode || codeCountdown > 0"
type="info"
:size="props.size"
style="border: 1px solid var(--color-input-border)"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : (isSendingCode ? '发送中' : '发送验证码') }}
</BaseButton>
</template>
<style scoped lang="scss">
</style>

15
src/pages/user/Notice.vue Normal file
View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
</script>
<template>
<div class="h-12 text-xs text-gray-400">
<span>
继续操作即表示你阅读并同意我们的
<a href="/user-agreement.html" target="_blank" class="link">用户协议</a>
<a href="/privacy-policy.html" target="_blank" class="link">隐私政策</a>
</span>
<slot/>
</div>
</template>

633
src/pages/user/User.vue Normal file
View File

@@ -0,0 +1,633 @@
<script setup lang="ts">
import {onMounted} from 'vue'
import {useUserStore} from '@/stores/auth.ts'
import {useRouter} from 'vue-router'
import BaseInput from '@/components/base/BaseInput.vue'
import BasePage from "@/components/BasePage.vue";
import {APP_NAME, EMAIL, GITHUB} from "@/config/env.ts";
import BaseButton from "@/components/BaseButton.vue";
import {PASSWORD_CONFIG, PHONE_CONFIG} from "@/config/auth.ts";
import {changeEmailApi, changePhoneApi, setPassword, updateUserInfoApi, User} from "@/apis/user.ts";
import BaseIcon from "@/components/BaseIcon.vue";
import {CodeType} from "@/types/types.ts";
import FormItem from "@/components/base/form/FormItem.vue";
import Form from "@/components/base/form/Form.vue";
import {FormInstance} from "@/components/base/form/types.ts";
import {codeRules, emailRules, passwordRules, phoneRules} from "@/utils/validation.ts";
import {_dateFormat, cloneDeep} from "@/utils";
import Toast from "@/components/base/toast/Toast.ts";
import Code from "@/pages/user/Code.vue";
import {MessageBox} from "@/utils/MessageBox.tsx";
const userStore = useUserStore()
const router = useRouter()
let showChangePwd = $ref(false)
let showChangeEmail = $ref(false)
let showChangeUsername = $ref(false)
let showChangePhone = $ref(false)
let loading = $ref(false)
const handleLogout = () => {
userStore.logout()
router.push('/login')
}
const contactSupport = () => {
console.log('Contact support')
}
const goIssues = () => {
window.open(GITHUB + '/issues', '_blank')
}
onMounted(() => {
userStore.fetchUserInfo()
})
// 修改手机号
// 修改手机号
// 修改手机号
let changePhoneFormRef = $ref<FormInstance>()
let defaultFrom = {oldCode: '', phone: '', code: '', pwd: '',}
let changePhoneForm = $ref(cloneDeep(defaultFrom))
let changePhoneFormRules = {
oldCode: codeRules,
phone: [...phoneRules, {
validator: (rule: any, value: any) => {
if (userStore.user?.phone && value === userStore.user?.phone) {
throw new Error('新手机号与原手机号一致')
}
}, trigger: 'blur'
},],
code: codeRules,
pwd: passwordRules
}
function showChangePhoneForm() {
showChangePhone = showChangeUsername = showChangeEmail = showChangePwd = false
showChangePhone = true
changePhoneForm = cloneDeep(defaultFrom)
}
function changePhone() {
changePhoneFormRef.validate(async valid => {
if (valid) {
try {
loading = true
const res = await changePhoneApi(changePhoneForm)
if (res.success) {
Toast.success('修改成功')
await userStore.fetchUserInfo()
showChangePhone = false
} else {
Toast.error(res.msg || '修改失败')
}
} catch (error) {
Toast.error(error || '修改失败,请重试')
} finally {
loading = false
}
}
})
}
// 修改用户名
// 修改用户名
// 修改用户名
let changeUsernameFormRef = $ref<FormInstance>()
let changeUsernameForm = $ref({username: ''})
let changeUsernameFormRules = {
username: [{required: true, message: '请输入用户名', trigger: 'blur'}],
}
function showChangeUsernameForm() {
showChangePhone = showChangeUsername = showChangeEmail = showChangePwd = false
showChangeUsername = true
changeUsernameForm = cloneDeep({username: userStore.user?.username ?? '',})
}
function changeUsername() {
changeUsernameFormRef.validate(async valid => {
if (valid) {
try {
loading = true
const res = await updateUserInfoApi(changeUsernameForm)
if (res.success) {
Toast.success('修改成功')
await userStore.fetchUserInfo()
showChangeUsername = false
} else {
Toast.error(res.msg || '修改失败')
}
} catch (error) {
Toast.error(error || '修改失败,请重试')
} finally {
loading = false
}
}
})
}
// 修改邮箱
// 修改邮箱
// 修改邮箱
let changeEmailFormRef = $ref<FormInstance>()
let changeEmailForm = $ref({
email: '',
pwd: '',
code: '',
})
let changeEmailFormRules = {
email: [
...emailRules, {
validator: (rule: any, value: any) => {
if (userStore.user?.email && value === userStore.user?.email) {
throw new Error('该邮箱与当前一致')
}
}, trigger: 'blur'
}
],
pwd: passwordRules,
code: codeRules,
}
function showChangeEmailForm() {
showChangePhone = showChangeUsername = showChangeEmail = showChangePwd = false
showChangeEmail = true
changeEmailForm = cloneDeep({email: userStore.user?.email ?? '', pwd: '', code: '',})
}
function changeEmail() {
changeEmailFormRef.validate(async valid => {
if (valid) {
try {
loading = true
const res = await changeEmailApi(changeEmailForm)
if (res.success) {
Toast.success('修改成功')
await userStore.fetchUserInfo()
showChangeEmail = false
} else {
Toast.error(res.msg || '修改失败')
}
} catch (error) {
Toast.error(error || '修改失败,请重试')
} finally {
loading = false
}
}
})
}
// 修改密码
// 修改密码
// 修改密码
let changePwdFormRef = $ref<FormInstance>()
const defaultChangePwdForm = {
oldPwd: '',
newPwd: '',
confirmPwd: '',
}
let changePwdForm = $ref(cloneDeep(defaultChangePwdForm))
let changePwdFormRules = {
oldPwd: passwordRules,
newPwd: passwordRules,
confirmPwd: [
{required: true, message: '请再次输入新密码', trigger: 'blur'},
{
validator: (rule: any, value: any) => {
if (value !== changePwdForm.newPwd) {
throw new Error('两次密码输入不一致')
}
}, trigger: 'blur'
},
],
}
function showChangePwdForm() {
showChangePhone = showChangeUsername = showChangeEmail = showChangePwd = false
showChangePwd = true
changePwdForm = cloneDeep(defaultChangePwdForm)
}
function changePwd() {
changePwdFormRef.validate(async valid => {
if (valid) {
try {
loading = true
const res = await setPassword(changePwdForm)
if (res.success) {
Toast.success('密码设置成功,请重新登录')
showChangePwd = false
userStore.logout()
} else {
Toast.error(res.msg || '设置失败')
}
} catch (error) {
Toast.error(error || '设置密码失败,请重试')
} finally {
loading = false
}
}
})
}
const member = $computed<User['member']>(() => userStore.user?.member ?? {} as any)
const memberEndDate = $computed(() => {
if (member?.endDate === null) return '永久'
return member?.endDate
})
function subscribe() {
router.push('/vip')
}
function onFileChange(e) {
console.log('e', e)
}
</script>
<template>
<BasePage>
<!-- Unauthenticated View -->
<div v-if="!userStore.isLogin" class="center h-screen">
<div class="card-white text-center flex-col gap-6 w-110">
<div class="w-20 h-20 bg-blue-100 rounded-full center mx-auto">
<IconFluentPerson20Regular class="text-3xl text-blue-600"/>
</div>
<h1 class="text-2xl font-bold">
<IconFluentHandWave20Regular class="text-xl translate-y-1 mr-2 shrink-0"/>
<span>欢迎使用</span>
</h1>
<p class="">登录开启您的学习之旅</p>
<div>保存进度同步数据解锁个性化内容</div>
<BaseButton
@click="router.push('/login')"
size="large"
class="w-full mt-4"
>
登录
</BaseButton>
<p class="text-sm text-gray-500">
还没有账户
<router-link to="/login?register=1" class="line">立即注册</router-link>
</p>
</div>
</div>
<!-- Authenticated View -->
<div v-else class="w-full flex gap-4">
<!-- Main Account Settings -->
<div class="card-white flex-1 flex flex-col gap-2 px-6">
<h1 class="text-2xl font-bold mt-0">帐户</h1>
<!-- 用户名-->
<div class="item">
<div class="flex-1">
<div class="mb-2">用户名</div>
<div class="flex items-center gap-2" v-if="userStore.user?.username">
<IconFluentPerson20Regular class="text-base"/>
<span>{{ userStore.user?.username }}</span>
</div>
<div v-else class="text-xs">在此设置用户名</div>
</div>
<BaseIcon @click="showChangeUsernameForm">
<IconFluentTextEditStyle20Regular/>
</BaseIcon>
</div>
<div v-if="showChangeUsername">
<Form
ref="changeUsernameFormRef"
:rules="changeUsernameFormRules"
:model="changeUsernameForm">
<FormItem prop="username">
<BaseInput
v-model="changeUsernameForm.username"
type="text"
size="large"
placeholder="请输入用户名"
autofocus
>
<template #preIcon>
<IconFluentPerson20Regular class="text-base"/>
</template>
</BaseInput>
</FormItem>
</Form>
<div class="text-align-end mb-2">
<BaseButton type="info" @click="showChangeUsername = false">取消</BaseButton>
<BaseButton @click="changeUsername">保存</BaseButton>
</div>
</div>
<div class="line"></div>
<!-- 手机号-->
<div class="item">
<div class="flex-1">
<div class="mb-2">手机号</div>
<div class="flex items-center gap-2" v-if="userStore.user?.phone">
<IconFluentMail20Regular class="text-base"/>
<span>{{ userStore.user?.phone }}</span>
</div>
<div v-else class="text-xs">在此设置手机号</div>
</div>
<BaseIcon @click="showChangePhoneForm">
<IconFluentTextEditStyle20Regular/>
</BaseIcon>
</div>
<div v-if="showChangePhone">
<Form
ref="changePhoneFormRef"
:rules="changePhoneFormRules"
:model="changePhoneForm">
<FormItem prop="oldCode" v-if="userStore.user?.phone">
<div class="flex gap-2">
<BaseInput
v-model="changePhoneForm.oldCode"
type="code"
autofocus
placeholder="请输入原手机号验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<Code :validate-field="() => true"
:type="CodeType.ChangePhoneOld"
:val="userStore.user.phone"/>
</div>
</FormItem>
<FormItem prop="phone">
<BaseInput
v-model="changePhoneForm.phone"
type="tel"
size="large"
placeholder="请输入新手机号"
/>
</FormItem>
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="changePhoneForm.code"
type="code"
placeholder="请输入新手机号验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<Code :validate-field="() => changePhoneFormRef.validateField('phone')"
:type="CodeType.ChangePhoneNew"
:val="changePhoneForm.phone"/>
</div>
</FormItem>
<FormItem prop="pwd" v-if="!userStore.user?.phone">
<BaseInput
v-model="changePhoneForm.pwd"
type="password"
size="large"
placeholder="请输入原密码"
/>
</FormItem>
</Form>
<div class="flex justify-between items-end mb-2">
<span class="link text-sm cp"
@click="MessageBox.notice(`请提供证明信息发送邮件到 ${EMAIL} 进行申诉`,'人工申诉')"
v-if="userStore.user?.phone">原手机号不可用,点此申诉</span>
<span v-else></span>
<div>
<BaseButton type="info" @click="showChangePhone = false">取消</BaseButton>
<BaseButton @click="changePhone">保存</BaseButton>
</div>
</div>
</div>
<div class="line"></div>
<!-- Email Section -->
<div class="item">
<div class="flex-1">
<div class="mb-2">电子邮箱</div>
<div class="flex items-center gap-2" v-if="userStore.user?.email">
<IconFluentMail20Regular class="text-base"/>
<span>{{ userStore.user?.email }}</span>
</div>
<div v-else class="text-xs">在此设置邮箱</div>
</div>
<BaseIcon @click="showChangeEmailForm">
<IconFluentTextEditStyle20Regular/>
</BaseIcon>
</div>
<div v-if="showChangeEmail">
<Form
ref="changeEmailFormRef"
:rules="changeEmailFormRules"
:model="changeEmailForm">
<FormItem prop="email">
<BaseInput
v-model="changeEmailForm.email"
type="email"
size="large"
placeholder="请输入邮箱地址"
autofocus
/>
</FormItem>
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="changeEmailForm.code"
type="code"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<Code :validate-field="() => changeEmailFormRef.validateField('email')"
:type="CodeType.ChangeEmail"
:val="changeEmailForm.email"/>
</div>
</FormItem>
<FormItem prop="pwd" v-if="userStore.user?.hasPwd">
<BaseInput
v-model="changePwdForm.pwd"
type="password"
size="large"
placeholder="请输入密码"
/>
</FormItem>
</Form>
<div class="text-align-end mb-2">
<BaseButton type="info" @click="showChangeEmail = false">取消</BaseButton>
<BaseButton @click="changeEmail">保存</BaseButton>
</div>
</div>
<div class="line"></div>
<!-- Password Section -->
<div class="item">
<div class="flex-1">
<div class="mb-2">设置密码</div>
<div class="text-xs">在此输入密码</div>
</div>
<BaseIcon @click="showChangePwdForm">
<IconFluentTextEditStyle20Regular/>
</BaseIcon>
</div>
<div v-if="showChangePwd">
<Form
ref="changePwdFormRef"
:rules="changePwdFormRules"
:model="changePwdForm">
<FormItem prop="oldPwd" v-if="userStore.user.hasPwd">
<BaseInput
v-model="changePwdForm.oldPwd"
placeholder="旧密码"
type="password"
size="large"
autofocus
/>
</FormItem>
<FormItem prop="newPwd">
<BaseInput
v-model="changePwdForm.newPwd"
type="password"
size="large"
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength}位)`"
:min="PASSWORD_CONFIG.minLength"
:max="PASSWORD_CONFIG.maxLength"
autofocus
/>
</FormItem>
<FormItem prop="confirmPwd">
<BaseInput
v-model="changePwdForm.confirmPwd"
type="password"
size="large"
placeholder="请再次输入新密码"
:min="PASSWORD_CONFIG.minLength"
:max="PASSWORD_CONFIG.maxLength"
/>
</FormItem>
</Form>
<div class="text-align-end mb-2">
<BaseButton type="info" @click="showChangePwd = false">取消</BaseButton>
<BaseButton :loading="loading" @click="changePwd">保存</BaseButton>
</div>
</div>
<div class="line"></div>
<!-- Contact Support -->
<div class="item cp"
v-if="false"
@click="contactSupport">
<div class="flex-1">
联系 {{ APP_NAME }} 客服
</div>
<IconFluentChevronLeft28Filled class="rotate-180"/>
</div>
<!-- <div class="line"></div>-->
<!-- 同步进度-->
<div class="item cp relative">
<div class="flex-1">
<div class="">同步进度</div>
<!-- <div class="text-xs mt-2">在此输入密码</div>-->
</div>
<IconFluentChevronLeft28Filled class="rotate-180"/>
<input type="file" accept=".json,.zip,application/json,application/zip"
@change="onFileChange"
class="absolute left-0 top-0 w-full h-full bg-red cp opacity-0"/>
</div>
<div class="line"></div>
<!-- 去github issue-->
<div class="item cp"
@click="goIssues">
<div class="flex-1">
给 {{ APP_NAME }} 提交意见
</div>
<IconFluentChevronLeft28Filled class="rotate-180"/>
</div>
<div class="line"></div>
<!-- Logout Button -->
<div class="center w-full mt-4">
<BaseButton
@click="handleLogout"
size="large"
class="w-[80%]"
>
登出
</BaseButton>
</div>
<div class="text-xs text-center mt-2">
<a href="/user-agreement.html" target="_blank" class="text-gray-500 hover:text-gray-700">用户协议</a>
<a href="/privacy-policy.html" target="_blank" class="text-gray-500 hover:text-gray-700">隐私政策</a>
</div>
</div>
<!-- Subscription Information -->
<div class="card-white w-80">
<div class="flex items-center gap-3 mb-4">
<IconFluentCrown20Regular class="text-2xl text-yellow-500"/>
<div class="text-lg font-bold">订阅信息</div>
</div>
<div class="space-y-4">
<template v-if="userStore.user?.member">
<div>
<div class="mb-1">当前计划</div>
<div class="text-base font-bold">{{ member?.planDesc }}</div>
</div>
<div>
<div class="mb-1">状态</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :class="member?.active ?'bg-green-500':'bg-red-500'"></div>
<span class="text-base font-medium" :class="member?.active ?'text-green-700':'text-red-700'">
{{ member?.status }}
</span>
</div>
</div>
<div>
<div class="mb-1">到期时间</div>
<div class="flex items-center gap-2">
<IconFluentCalendarDate20Regular class="text-lg"/>
<span class="text-base font-medium">{{ memberEndDate }}</span>
</div>
</div>
<div>
<div class="mb-1">自动续费</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full"
:class="member?.autoRenew ? 'bg-blue-500' : 'bg-gray-400'"
></div>
<span class="text-base font-medium"
:class="member?.autoRenew ? 'text-blue-700' : 'text-gray-600'">
{{ member?.autoRenew ? '已开启' : '已关闭' }}
</span>
</div>
</div>
</template>
<div class="text-base" v-else>当前无订阅</div>
<BaseButton class="w-full" size="large" @click="subscribe">{{
userStore.user?.member ? '管理订阅' : '会员介绍'
}}
</BaseButton>
</div>
</div>
</div>
</BasePage>
</template>
<style scoped lang="scss">
.item {
@apply flex items-center justify-between min-h-14;
}
</style>

518
src/pages/user/VipIntro.vue Normal file
View File

@@ -0,0 +1,518 @@
<script setup lang="ts">
import BasePage from '@/components/BasePage.vue'
import BaseButton from '@/components/BaseButton.vue'
import {useRouter} from 'vue-router'
import {useUserStore} from '@/stores/auth.ts'
import {User} from "@/apis/user.ts";
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
import Header from "@/components/Header.vue";
import {
CouponInfo,
couponInfo,
LevelBenefits,
levelBenefits,
orderCreate,
orderStatus,
setAutoRenewApi
} from "@/apis/member.ts";
import Radio from "@/components/base/radio/Radio.vue";
import RadioGroup from "@/components/base/radio/RadioGroup.vue";
import {APP_NAME} from "@/config/env.ts";
import Toast from "@/components/base/toast/Toast.ts";
import {_dateFormat, _nextTick} from "@/utils";
import InputNumber from "@/components/base/InputNumber.vue";
import dayjs from "dayjs";
import BaseInput from "@/components/base/BaseInput.vue";
import PopConfirm from "@/components/PopConfirm.vue";
const router = useRouter()
const userStore = useUserStore()
interface Plan {
id: string
name: string
price: number
unit: '月' | '年'
highlight?: string
autoRenew?: boolean
}
let loading = $ref(false);
let selectedPaymentMethod = $ref('wechat')
let selectedPlanId = $ref('')
let duration = $ref(1)
const member = $computed<User['member']>(() => userStore.user?.member ?? {} as any)
const memberEndDate = $computed(() => {
if (member?.endDate === null) return '永久'
return member?.endDate
})
let data = $ref<LevelBenefits>({} as any)
const plans: Plan[] = $computed(() => {
let list = []
if (data?.level) {
list.push({
id: 'month',
name: '月付',
price: data.level.price,
unit: '月',
},)
list.push({
id: 'month_auto',
name: '连续包月',
price: data.level.price_auto,
unit: '月',
highlight: '性价比更高',
autoRenew: true,
},)
list.push({
id: 'year',
name: '年度会员',
price: data.level.yearly_price,
unit: '年',
highlight: '年度优惠',
},)
}
return list
})
// Payment methods - WeChat and Alipay
const paymentMethods = [
{
id: 'wechat',
name: '微信支付',
description: '使用微信支付'
},
{
id: 'alipay',
name: '支付宝',
description: '使用支付宝支付'
}
]
const currentPlan = $computed(() => {
return plans.find(v => v.id === member?.plan) ?? null
})
const selectPlan = $computed(() => {
return plans.find(v => v.id === selectedPlanId) ?? null
})
// Calculate original price based on plan type
const originalPrice = $computed(() => {
return selectPlan?.id === 'month_auto' ? Number(selectPlan?.price) : Number(duration) * Number(selectPlan?.price)
})
// check Is it enough for a discount
const enoughDiscount = $computed(() => {
if (coupon.is_valid) {
if (coupon.min_amount) {
const minAmount = Number(coupon.min_amount)
return originalPrice > minAmount
}
return true
}
return false
})
const endPrice = $computed(() => {
if (!coupon.is_valid) {
return Number(originalPrice.toFixed(2))
}
if (coupon.type === 'free_trial') return 0
if (!enoughDiscount) {
return Number(originalPrice.toFixed(2))
}
let discountAmount = 0
if (coupon.type === 'discount') {
// Discount coupon: e.g., 0.8 means 20% off
const discountRate = Number(coupon.value)
discountAmount = originalPrice * (1 - discountRate)
// Apply max_discount limit if available
if (coupon.max_discount) {
const maxDiscount = Number(coupon.max_discount)
discountAmount = Math.min(discountAmount, maxDiscount)
}
} else if (coupon.type === 'amount') {
// Amount coupon: fixed amount off
discountAmount = Number(coupon.value)
}
const finalPrice = Math.max(originalPrice - discountAmount, 0)
return finalPrice.toFixed(2)
}
)
const startDate = $computed(() => {
if (member?.active) {
return member.endDate
} else {
return _dateFormat(Date.now())
}
})
onMounted(async () => {
let res = await levelBenefits({levelCode: 'basic'})
if (res.success) {
data = res.data
}
})
let loading2 = $ref(false);
async function toggleAutoRenew() {
if (loading2) return
loading2 = true
let res = await setAutoRenewApi({autoRenew: false})
if (res.success) {
Toast.success('取消成功')
userStore.init()
} else {
Toast.error(res.msg || '取消失败')
}
loading2 = false
}
// Get button text based on current plan
function getPlanButtonText(plan: Plan) {
if (plan.id === selectedPlanId) return '已选中'
if (plan.id === currentPlan?.id) return '当前计划'
return '选择'
}
function goPurchase(plan: Plan) {
if (!userStore.isLogin) {
router.push({path: '/login', query: {redirect: '/vip'}})
return
}
selectedPlanId = plan.id
_nextTick(() => {
let el = document.getElementById('pay')
el.scrollIntoView({behavior: "smooth"})
})
}
let startLoop = $ref(false)
let orderNo = $ref('')
let timer: number = $ref()
let showCouponInput = $ref(false)
let coupon = $ref<CouponInfo>({code: ''} as CouponInfo)
watch(() => startLoop, (n) => {
if (n) {
clearInterval(timer)
timer = setInterval(() => {
orderStatus({orderNo}).then(res => {
if (res?.success) {
if (res.data?.payment_status === 'paid') {
Toast.success('付款成功')
userStore.init()
startLoop = false
selectedPlanId = undefined
}
} else {
startLoop = false
Toast.error(res.msg || '付款失败')
}
})
}, 1000)
} else {
clearInterval(timer)
}
})
onUnmounted(() => {
startLoop = false
clearInterval(timer)
})
async function handlePayment() {
if (loading) return
loading = true
let data = {
plan: selectedPlanId,
duration: Number(duration),
payment_method: selectedPaymentMethod,
couponCode: coupon.is_valid ? coupon.code : undefined
}
let res = await orderCreate(data)
if (res.success) {
orderNo = res.data.orderNo
startLoop = true
} else {
Toast.error(res.msg || '付款失败')
}
loading = false
}
let couponLoading = $ref(false)
async function getCouponInfo() {
if (showCouponInput) {
if (!coupon.code) return
if (couponLoading) return
couponLoading = true
let res = await couponInfo(coupon)
if (res.success) {
if (res.data.is_valid) {
coupon = res.data
} else {
coupon = {code: coupon.code} as CouponInfo
Toast.info('优惠券已失效')
}
} else {
coupon = {code: coupon.code} as CouponInfo
Toast.error(res.msg || '优惠券无效')
}
couponLoading = false
} else {
showCouponInput = true
}
}
</script>
<template>
<BasePage>
<div class="space-y-6">
<div class="card-white">
<Header title="会员介绍"></Header>
<div class="grid grid-cols-3 grid-rows-3 gap-3">
<div class="text-lg items-center" v-for="f in data.benefits" :key="f.name">
<IconFluentCheckmarkCircle20Regular class="mr-2 text-green-600"/>
<span>
<span>{{ f.name }}</span>
<span v-if="f.value !== 'true'">{{ `(${f.value}${f.unit ?? ''})` }}</span>
</span>
</div>
</div>
</div>
<div v-if="member?.active" class="card-white bg-green-50 dark:bg-item border border-green-200 mt-3 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<IconFluentCheckmarkCircle20Regular class="mr-2 text-green-600"/>
<div>
<div class="font-semibold text-green-800">当前计划{{ currentPlan?.name }}</div>
<div class="text-sm text-green-600">
到期时间{{ memberEndDate }}
</div>
</div>
</div>
<div class="text-align-end space-y-2">
<div v-if="member.autoRenew" class="flex items-center gap-space">
<div class="flex items-center text-sm text-gray-600">
<IconFluentArrowRepeatAll20Regular class="mr-1"/>
<span>自动续费已开启</span>
</div>
<PopConfirm
title="确认取消?"
@confirm="toggleAutoRenew"
>
<BaseButton size="small" type="info" :loading="loading2">关闭</BaseButton>
</PopConfirm>
</div>
</div>
</div>
</div>
<div class="flex justify-between">
<div class="title">选择适合您的套餐</div>
<div class="subtitle">三种方案按需选择</div>
</div>
<div class="plans">
<div v-for="p in plans" :key="p.id"
class="card-white p-0 overflow-hidden flex flex-col">
<div class="text-2xl font-bold bg-gray-300 dark:bg-third px-6 py-4">{{ p.name }}</div>
<div class="p-6 flex flex-col justify-between flex-1">
<div class="plan-head">
<div class="price">
<span class="amount">¥{{ p.price }}</span>
<span class="unit">/ {{ p.unit }}</span>
</div>
<div v-if="p.highlight" class="tag">{{ p.highlight }}</div>
</div>
<div v-if="p.autoRenew" class="text-sm flex items-center mt-4">
<IconFluentArrowRepeatAll20Regular class="mr-2"/>
开启自动续费可随时关闭
</div>
<BaseButton class="w-full mt-4" size="large"
:type="(p.id === currentPlan?.id || p.id === selectedPlanId) ? 'primary' : 'info'"
:disabled="p.id === currentPlan?.id" @click="goPurchase(p)">
{{ getPlanButtonText(p) }}
</BaseButton>
</div>
</div>
</div>
</div>
<div id="pay" class="mb-50" v-if="selectedPlanId">
<!-- Page Header -->
<div class="text-center mb-6">
<h1 class="text-xl font-semibold mb-2">安全支付</h1>
<p class="">选择支付方式完成订单</p>
</div>
<div class="center">
<div class="card-white w-7/10">
<div class="flex items-center justify-between gap-6 ">
<div class="center gap-2" v-if="!showCouponInput">
<IconStreamlineDiscountPercentCoupon/>
<span>有优惠券</span>
</div>
<BaseInput v-else v-model="coupon.code"
placeholder="请输入优惠券"
autofocus
@enter="getCouponInfo"
/>
<BaseButton size="large"
:loading="couponLoading"
@click="getCouponInfo">{{ showCouponInput ? '确定' : '在此兑换!' }}
</BaseButton>
</div>
<div class="bg-green-50 border border-green-200 rounded-lg px-4 py-3 mt-4"
v-if="coupon.is_valid">
<div class="font-medium">优惠券: {{ coupon.name }}</div>
<div class="flex justify-between w-full mt-2">
<span v-if="coupon.type === 'discount'">折扣券{{ (Number(coupon.value) * 10).toFixed(1) }}</span>
<span v-else-if="coupon.type === 'amount'">立减券{{ Number(coupon.value).toFixed(2) }}</span>
<span v-else-if="coupon.type === 'free_trial'">折扣: -100%</span>
<!-- Coupon restrictions -->
<div v-if="coupon.min_amount || coupon.max_discount">
<span v-if="coupon.min_amount">{{ Number(coupon.min_amount).toFixed(2) }}元可用</span>
<span v-if="coupon.max_discount && coupon.type === 'discount'">
· 最高减{{ Number(coupon.max_discount).toFixed(2) }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left Card: Payment Method Selection -->
<div class="card-white">
<div class="text-lg font-medium mb-4">选择支付方式</div>
<RadioGroup v-model="selectedPaymentMethod">
<div class="space-y-3 w-full">
<div v-for="method in paymentMethods" :key="method.id"
@click=" selectedPaymentMethod = method.id"
class="flex p-4 border rounded-lg cp transition-all duration-200 hover:bg-item"
:class="selectedPaymentMethod === method.id && 'bg-item'">
<div class="flex items-center flex-1 gap-4">
<IconSimpleIconsWechat class="text-xl color-green-500" v-if="method.id === 'wechat'"/>
<IconUiwAlipay class="text-xl color-blue" v-else/>
<div>
<div class="font-medium color-main">{{ method.name }}</div>
<div class="text-sm text-gray-500">{{ method.description }}</div>
</div>
</div>
<Radio :value="method.id" label=""></Radio>
</div>
</div>
</RadioGroup>
</div>
<!-- Right Card: Order Summary -->
<div class="card-white">
<div class="text-lg font-semibold mb-4">订单概要</div>
<!-- Plan Info -->
<div class="mb-4">
<div class="text-purple-600 text-sm mb-2">付费方案{{ selectPlan?.name }}订阅</div>
<div class="mb-4"> {{ startDate }} 开始:</div>
</div>
<div class="flex justify-between items-center mb-4">
<!-- Price -->
<div class="flex items-baseline">
<span class="font-semibold"
:class="selectPlan?.id === 'month_auto' ? 'text-3xl' : 'text-xl'">
{{ selectPlan?.price }}
</span>
<span class="ml-2">/ {{ selectPlan?.unit }}</span>
</div>
<div v-if="selectPlan?.id !== 'month_auto'">
<InputNumber :min="1" v-model="duration"/>
</div>
</div>
<div v-if="coupon.is_valid" class="mb-4">
<div class="flex items-baseline text-gray-500 line-through" v-if="enoughDiscount">
<span class="text-lg">原价{{ Number(originalPrice).toFixed(2) }}</span>
<span class="ml-2">/ {{ selectPlan?.unit }}</span>
</div>
<div class="text-sm">
<div v-if="enoughDiscount" class="text-green-600 flex items-center">
<IconStreamlineDiscountPercentCoupon class="mr-2"/>
<span>已优惠{{ (Number(originalPrice) - Number(endPrice)).toFixed(2) }}</span>
</div>
<span v-else>优惠券不可用未满足条件</span>
</div>
</div>
<!-- Final Price -->
<div class="flex items-baseline mb-4">
<span class="text-2xl font-semibold">总计</span>
<span class="text-3xl font-semibold">{{ endPrice }}</span>
</div>
<div class="bg-second text-sm px-4 py-3 rounded-lg mb-4 text-gray-600">
会员属于虚拟服务一经购买激活后不支持退款请在购买前仔细阅读权益说明确认符合您的需求再进行支付
</div>
<!-- Payment Button -->
<BaseButton class="w-full" size="large" :loading="loading || startLoop"
:type="!!selectedPaymentMethod ? 'primary' : 'info'" :disabled="!selectedPaymentMethod"
@click="handlePayment">
付款
</BaseButton>
</div>
</div>
</div>
</BasePage>
</template>
<style scoped lang="scss">
.plans {
display: grid;
gap: 3rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.plan-head {
@apply flex flex-col gap-2;
}
.price {
@apply flex items-end gap-1;
}
.amount {
@apply text-4xl font-500;
}
.unit {
@apply text-base text-gray-500;
}
.desc {
@apply text-sm text-gray-600;
}
.tag {
@apply text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded w-fit;
}
</style>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { IS_LOGIN } from "@/config/env.ts";
import router from "@/router.ts";
onMounted(() => {
if (!IS_LOGIN) {
}
router.push({path: "/login"});
})
</script>
<template>
<div class="flex flex-col justify-between min-h-screen">
<div class="center flex-col gap-8">
onMounted(() => {
if (!IS_LOGIN) {
router.push({path: "/login"});
}
})
</div>
</div>
</template>
<style scoped lang="scss">
</style>

View File

@@ -1,66 +1,627 @@
<script setup lang="ts">
<script setup lang="tsx">
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 { uploadImportData } from "@/apis";
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 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 Code from "@/pages/user/Code.vue";
import {isNewUser, useNav} from "@/utils";
import Header from "@/components/Header.vue";
import PopConfirm from "@/components/PopConfirm.vue";
function sync() {
// 状态管理
const userStore = useUserStore()
const route = useRoute()
const router = useNav()
// 页面状态
let currentMode = $ref<'login' | 'register' | 'forgot'>('login')
let loginType = $ref<'code' | 'password'>('code') // 默认验证码登录
let loading = $ref(false)
let showWechatQR = $ref(true)
let wechatQRUrl = $ref('https://open.weixin.qq.com/connect/qrcode/041GmMJM2wfM0w3D')
// 微信二维码状态idle-正常/等待扫码scanned-已扫码待确认expired-已过期cancelled-已取消
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分钟过期
let phoneLoginForm = $ref({phone: '', code: ''})
let phoneLoginFormRef = $ref<FormInstance>()
let phoneLoginFormRules = {
phone: phoneRules,
code: codeRules
}
async function handleAudioChange(e) {
let uploadFile = e.target?.files?.[0]
if (!uploadFile) return
let data = new FormData();
data.append("file", uploadFile);
let res = await uploadImportData(data, e => {
console.log('e', e)
let loginForm2 = $ref({account: '', password: ''})
let loginForm2Ref = $ref<FormInstance>()
let loginForm2Rules = {
account: accountRules,
password: passwordRules,
}
const registerForm = $ref({
account: '',
password: '',
confirmPassword: '',
code: ''
})
let registerFormRef = $ref<FormInstance>()
// 注册表单规则和引用
let registerFormRules = {
account: accountRules,
code: codeRules,
password: passwordRules,
confirmPassword: [
{required: true, message: '请再次输入密码', trigger: 'blur'},
{
validator: (rule: any, value: any) => {
if (value !== registerForm.password) {
throw new Error('两次密码输入不一致')
}
}, trigger: 'blur'
},
],
}
const forgotForm = $ref({
account: '',
code: '',
newPassword: '',
confirmPassword: ''
})
let forgotFormRef = $ref<FormInstance>()
// 忘记密码表单规则和引用
let forgotFormRules = {
account: accountRules,
code: codeRules,
newPassword: passwordRules,
confirmPassword: [
{required: true, message: '请再次输入新密码', trigger: 'blur'},
{
validator: (rule: any, value: any) => {
if (value !== forgotForm.newPassword) {
throw new Error('两次密码输入不一致')
}
}, trigger: 'blur'
},
],
}
const currentFormRef = $computed<FormInstance>(() => {
if (currentMode === 'login') {
if (loginType == 'code') return phoneLoginFormRef
else return loginForm2Ref
} else if (currentMode === 'register') return registerFormRef
else return forgotFormRef
})
// 统一登录处理
async function handleLogin() {
currentFormRef.validate(async (valid) => {
if (!valid) return;
try {
loading = true
let data = {}
//手机号登录
if (loginType === 'code') {
data = {...phoneLoginForm, type: 'code'}
} else {
//密码登录
data = {...loginForm2, type: 'pwd'}
}
let res = await loginApi(data as LoginParams)
if (res.success) {
userStore.setToken(res.data.token)
Toast.success('登录成功')
router.back()
} else {
Toast.error(res.msg || '登录失败')
if (res.code === 499) {
loginType = 'code'
}
}
} catch (error) {
Toast.error('登录失败,请重试')
} finally {
loading = false
}
})
console.log('res', res)
console.log(uploadFile)
e.target.value = ''
}
async function s() {
const taskId = await fetch('/startImport').then(r => r.json()).then(d => d.taskId);
const timer = setInterval(async () => {
const res = await fetch(`/getProgress/${taskId}`).then(r => r.json());
console.log(`当前进度: ${res.progress}%`);
if (res.progress >= 100) clearInterval(timer);
}, 1000);
// 注册
async function handleRegister() {
registerFormRef.validate(async (valid) => {
if (!valid) return
try {
loading = true
let res = await registerApi(registerForm)
if (res.success) {
userStore.setToken(res.data.token)
userStore.setUser(res.data.user as any)
Toast.success('注册成功')
// 跳转到首页或用户中心
router.push('/')
} else {
Toast.error(res.msg || '注册失败')
}
} catch (error) {
Toast.error('注册失败,请重试')
} finally {
loading = false
}
})
}
// 忘记密码
async function handleForgotPassword() {
forgotFormRef.validate(async (valid) => {
if (!valid) return
try {
loading = true
const res = await resetPasswordApi(forgotForm)
if (res.success) {
Toast.success('密码重置成功,请重新登录')
switchMode('login')
} else {
Toast.error(res.msg || '重置失败')
}
} catch (error) {
Toast.error(error || '重置密码失败,请重试')
} finally {
loading = false
}
})
}
// 清除二维码相关定时器
function clearQRTimers() {
if (qrExpireTimer) {
clearTimeout(qrExpireTimer)
qrExpireTimer = null
}
if (qrCheckInterval) {
clearInterval(qrCheckInterval)
qrCheckInterval = null
}
}
// 刷新二维码
async function refreshQRCode() {
clearQRTimers()
qrStatus = 'idle'
await handleWechatLogin()
}
// 微信登录 - 显示二维码
async function handleWechatLogin() {
try {
showWechatQR = true
qrStatus = 'idle'
// 这里应该调用后端获取二维码
// const response = await getWechatQR()
// wechatQRUrl = response.qrUrl
// 暂时使用占位二维码
wechatQRUrl = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2Y1ZjVmNSIvPgogIDx0ZXh0IHg9IjUwJSIgeT0iNTAlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OTk5OSI+55So5o6l566h55CG6L295Lit6K+B77yBPC90ZXh0Pgo8L3N2Zz4K'
// 模拟轮询检查扫码状态
qrCheckInterval = setInterval(async () => {
// 这里应该轮询后端检查扫码状态
// const result = await checkWechatLoginStatus()
// if (result.scanned) qrStatus = 'scanned'
// if (result.success) {
// clearQRTimers()
// showWechatQR = false
// qrStatus = 'idle'
// // 登录成功处理
// }
}, 2000)
// 设置二维码过期
qrExpireTimer = setTimeout(() => {
qrStatus = 'expired'
clearInterval(qrCheckInterval!)
qrCheckInterval = null
Toast.info('二维码已过期,请点击刷新')
}, QR_EXPIRE_TIME)
} catch (error) {
console.error('Wechat login error:', error)
Toast.error('微信登录失败')
}
}
// 切换模式
function switchMode(mode: 'login' | 'register' | 'forgot') {
currentMode = mode
// 切换到注册或忘记密码模式时,隐藏微信扫码
if (mode === 'register' || mode === 'forgot') {
if (showWechatQR) {
clearQRTimers()
showWechatQR = false
qrStatus = 'idle'
}
}
}
// 用户主动取消登录(示例:可在需要的地方调用)
function cancelWechatLogin() {
qrStatus = 'cancelled'
qrStatus = 'cancelled'
qrStatus = 'cancelled'
}
// 初始化页面
onMounted(() => {
console.log('route.query', route.query)
if (route.query?.register) {
currentMode = 'register'
}
})
// 组件卸载时清理定时器
onBeforeUnmount(() => {
clearQRTimers()
})
</script>
<template>
<div class="center h-screen">
<div class=" flex flex-col gap-6 w-100">
<h1 class="mb-0 text-align-center">{{ APP_NAME }}</h1>
<div class="flex center">
<span class="shrink-0">账户</span>
<BaseInput type="text"/>
</div>
<div class="flex center">
<span class="shrink-0">密码</span>
<BaseInput type="password"/>
</div>
<BaseButton class="w-full">登录</BaseButton>
<BaseButton class="w-full" @click="sync">同步</BaseButton>
<div class="upload relative">
<BaseButton>上传</BaseButton>
<input type="file"
accept=".zip,.json"
@change="handleAudioChange"
class="w-full h-full absolute left-0 top-0 opacity-0"/>
</div>
<div class="center min-h-screen">
<div class="card-white p-2" v-if="!waitForImportConfirmation">
<!-- 登录区域容器 - 弹框形式 -->
<div class="flex gap-2">
<!-- 左侧登录区域 -->
<div class="flex-1 w-80 p-3">
<!-- 登录选项 -->
<div v-if="currentMode === 'login'">
<div class="mb-6 text-center text-2xl font-bold">{{ APP_NAME }}</div>
<div class="w-full flex justify-end gap-4">
<div>注册</div>
<div>忘记密码</div>
<!-- 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'"
>
<div>
<span>验证码登录</span>
<div
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'"
>
<div>
<span>密码登录</span>
<div
v-opacity="loginType === 'password'"
class="mt-1 h-0.5 bg-blue-600"
></div>
</div>
</div>
</div>
<!-- 验证码登录表单 -->
<Form
v-if="loginType === 'code'"
ref="phoneLoginFormRef"
:rules="phoneLoginFormRules"
:model="phoneLoginForm">
<FormItem prop="phone">
<BaseInput v-model="phoneLoginForm.phone"
type="tel"
name="username"
autocomplete="tel"
size="large"
placeholder="请输入手机号"
/>
</FormItem>
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="phoneLoginForm.code"
type="code"
size="large"
:max-length="PHONE_CONFIG.codeLength"
placeholder="请输入验证码"
/>
<Code :validate-field="() => phoneLoginFormRef.validateField('phone')"
:type="CodeType.Login"
:val="phoneLoginForm.phone"/>
</div>
</FormItem>
</Form>
<!-- 密码登录表单 -->
<Form
v-else
ref="loginForm2Ref"
:rules="loginForm2Rules"
:model="loginForm2">
<FormItem prop="account">
<BaseInput v-model="loginForm2.account"
type="email"
name="username"
autocomplete="email"
size="large"
placeholder="请输入手机号/邮箱地址"
/>
</FormItem>
<FormItem prop="password">
<div class="flex gap-2">
<BaseInput
v-model="loginForm2.password"
type="password"
name="password"
autocomplete="current-password"
size="large"
placeholder="请输入密码"
/>
</div>
</FormItem>
</Form>
<Notice>
<span v-if="loginType === 'code'">,未注册的手机号将自动注册</span>
</Notice>
<BaseButton
class="w-full"
size="large"
:loading="loading"
@click="handleLogin"
>
登录
</BaseButton>
<!-- 底部操作链接 - 只在密码登录时显示 -->
<div class="mt-4 flex justify-between text-sm" v-opacity="loginType !== 'code'">
<div class="link cp" @click="switchMode('forgot')">忘记密码?</div>
<div class="link cp" @click="switchMode('register')">注册账号</div>
</div>
</div>
<!-- 注册模式 -->
<div v-else-if="currentMode === 'register'">
<Header @click="switchMode('login')" title="注册新账号"/>
<Form
ref="registerFormRef"
:rules="registerFormRules"
:model="registerForm">
<FormItem prop="account">
<BaseInput
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"
/>
<Code :validate-field="() => registerFormRef.validateField('account')"
:type="CodeType.Register"
:val="registerForm.account"/>
</div>
</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} 位)`"
/>
</FormItem>
<FormItem prop="confirmPassword">
<BaseInput
v-model="registerForm.confirmPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
placeholder="请再次输入密码"
/>
</FormItem>
</Form>
<Notice/>
<BaseButton
class="w-full"
size="large"
:loading="loading"
@click="handleRegister"
>
注册
</BaseButton>
</div>
<!-- 忘记密码模式 -->
<div v-else-if="currentMode === 'forgot'">
<Header @click="switchMode('login')" title="重置密码"/>
<Form
ref="forgotFormRef"
:rules="forgotFormRules"
:model="forgotForm">
<FormItem prop="account">
<BaseInput
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"
/>
<Code :validate-field="() => forgotFormRef.validateField('account')"
:type="CodeType.ResetPwd"
:val="forgotForm.account"/>
</div>
</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} 位)`"
/>
</FormItem>
<FormItem prop="confirmPassword">
<BaseInput
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"
>
重置密码
</BaseButton>
</div>
</div>
<!-- 右侧微信二维码 - 只在登录模式时显示 -->
<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' }"
/>
<!-- 扫描成功蒙层 -->
<div
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>
<div class="text-xs text-gray-600">微信中轻触允许即可登录</div>
</div>
<!-- 取消登录蒙层 -->
<div
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>
<div class="text-xs text-gray-600">你可<span class="color-link" @click="refreshQRCode">再次登录</span>,或关闭窗口
</div>
</div>
<!-- 过期蒙层 -->
<div
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"/>
</div>
</div>
<p class="mt-4 center gap-space">
<IconIxWechatLogo class="text-xl color-green"/>
<span class="text-sm text-gray-600">微信扫码登录</span>
</p>
</div>
</div>
</div>
<div v-else class="card-white p-6 w-100">
<div class="title">同步数据确认</div>
<div class="flex flex-col justify-between h-60">
<div v-if="!isImporting">
<h2>检测到您本地存在使用记录</h2>
<h3>是否需要同步到账户中?</h3>
</div>
<div>
<h3 class="text-align-center">正在导入中</h3>
<ol class="pl-4">
<li>
您的用户数据已自动下载到您的电脑中
</li>
<li>
随后将开始数据同步
</li>
<li>
如果您的数据量很大,这将是一个耗时操作
</li>
<li class="color-red-5 font-bold">
请耐心等待,请勿关闭此页面
</li>
</ol>
</div>
<div class="flex gap-space justify-end">
<PopConfirm :title="[
{text:'您的用户数据将以压缩包自动下载到您的电脑中',type:'normal'},
{text:'随后用户数据将被移除',type:'redBold'},
{text:'是否确认继续?',type:'normal'},
]">
<BaseButton type="info">放弃数据</BaseButton>
</PopConfirm>
<BaseButton>确认同步</BaseButton>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
<style scoped lang="scss">
</style>

View File

@@ -26,7 +26,7 @@ import { getCurrentStudyWord } from "@/hooks/dict.ts";
import PracticeSettingDialog from "@/pages/word/components/PracticeSettingDialog.vue";
import { useSettingStore } from "@/stores/setting.ts";
import { MessageBox } from "@/utils/MessageBox.tsx";
import { CAN_REQUEST, Origin, PracticeSaveWordKey } from "@/config/env.ts";
import { AppEnv, Origin, PracticeSaveWordKey } from "@/config/env.ts";
import { detail } from "@/apis";
const runtimeStore = useRuntimeStore()
@@ -196,7 +196,7 @@ onMounted(async () => {
}
if (base.word.bookList.find(book => book.id === runtimeStore.editDict.id)) {
if (CAN_REQUEST) {
if (AppEnv.CAN_REQUEST) {
let res = await detail({id: runtimeStore.editDict.id})
if (res.success) {
runtimeStore.editDict.statistics = res.data.statistics
@@ -237,7 +237,7 @@ async function startPractice() {
wordPracticeMode: settingStore.wordPracticeMode
})
let currentStudy = getCurrentStudyWord()
nav('practice-words/' + store.sdict.id, {}, currentStudy)
nav('practice-words/' + store.sdict.id, {}, {taskWords:currentStudy})
}
async function addMyStudyList() {

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import {onMounted, provide, ref, watch} from "vue";
import {onMounted, provide, ref, toRef, watch} from "vue";
import Statistics from "@/pages/word/Statistics.vue";
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
@@ -50,6 +49,7 @@ let taskWords = $ref<TaskWords>({
new: [],
review: [],
write: [],
shuffle: [],
})
let data = $ref<PracticeData>({
@@ -60,10 +60,9 @@ let data = $ref<PracticeData>({
})
let isTypingWrongWord = ref(false)
let practiceMode = ref(WordPracticeType.FollowWrite)
provide('isTypingWrongWord', isTypingWrongWord)
provide('practiceData', data)
provide('practiceMode', practiceMode)
provide('practiceTaskWords', taskWords)
async function loadDict() {
// console.log('load好了开始加载')
@@ -100,7 +99,7 @@ watch(() => store.load, (n) => {
onMounted(() => {
//如果是从单词学习主页过来的,就直接使用;否则等待加载
if (runtimeStore.routeData) {
initData(runtimeStore.routeData, true)
initData(runtimeStore.routeData.taskWords, true)
} else {
loading = true
}
@@ -124,27 +123,45 @@ function initData(initVal: TaskWords, init: boolean = false) {
initData(initVal, true)
}
} else {
taskWords = initVal
if (taskWords.new.length === 0) {
if (taskWords.review.length) {
settingStore.wordPracticeType = WordPracticeType.Identify
statStore.step = 3
data.words = taskWords.review
} else {
if (taskWords.write.length) {
// taskWords = initVal
//不能直接赋值,会导致 inject 的数据为默认值
taskWords = Object.assign(taskWords, initVal)
//如果 shuffle 数组不为空,就说明是复习
if (taskWords.shuffle.length === 0) {
if (taskWords.new.length === 0) {
if (taskWords.review.length) {
settingStore.wordPracticeType = WordPracticeType.Identify
data.words = taskWords.write
statStore.step = 6
statStore.step = 3
data.words = taskWords.review
} else {
Toast.warning('没有可学习的单词!')
router.push('/word')
if (taskWords.write.length) {
settingStore.wordPracticeType = WordPracticeType.Identify
data.words = taskWords.write
statStore.step = 6
} else {
Toast.warning('没有可学习的单词!')
router.push('/word')
}
}
} else {
settingStore.wordPracticeType = WordPracticeType.FollowWrite
data.words = taskWords.new
statStore.step = 0
}
statStore.total = taskWords.review.length + taskWords.new.length + taskWords.write.length
statStore.newWordNumber = taskWords.new.length
statStore.reviewWordNumber = taskWords.review.length
statStore.writeWordNumber = taskWords.write.length
} else {
settingStore.wordPracticeType = WordPracticeType.FollowWrite
data.words = taskWords.new
statStore.step = 0
settingStore.wordPracticeType = WordPracticeType.Dictation
data.words = taskWords.shuffle
statStore.step = 10
statStore.total = taskWords.shuffle.length
statStore.newWordNumber = 0
statStore.reviewWordNumber = 0
statStore.writeWordNumber = statStore.total
}
data.index = 0
data.wrongWords = []
data.excludeWords = []
@@ -152,11 +169,6 @@ function initData(initVal: TaskWords, init: boolean = false) {
statStore.startDate = Date.now()
statStore.inputWordNumber = 0
statStore.wrong = 0
statStore.total = taskWords.review.length + taskWords.new.length + taskWords.write.length
statStore.newWordNumber = taskWords.new.length
statStore.reviewWordNumber = taskWords.review.length
statStore.writeWordNumber = taskWords.write.length
statStore.index = 0
isTypingWrongWord.value = false
}
}
@@ -194,22 +206,29 @@ watch(() => settingStore.wordPracticeType, (n) => {
}
}, {immediate: true})
const groupSize = 7
function wordLoop() {
// return data.index++
let d = Math.floor(data.index / 6) - 1
if (data.index > 0 && data.index % 6 === (d < 0 ? 0 : d)) {
if (settingStore.wordPracticeType === WordPracticeType.FollowWrite) {
// 学习模式
if (settingStore.wordPracticeType === WordPracticeType.FollowWrite) {
data.index++
// 到达一个组末尾,就切换到拼写模式
if (data.index % groupSize === 0) {
settingStore.wordPracticeType = WordPracticeType.Spell
data.index -= 6
} else {
settingStore.wordPracticeType = WordPracticeType.FollowWrite
data.index++
data.index -= groupSize // 回到刚学单词开头
}
} else {
// 拼写模式
data.index++
// 拼写走完一组,切回跟写模式
if (data.index % groupSize === 0) {
settingStore.wordPracticeType = WordPracticeType.FollowWrite
}
}
}
let toastInstance: ToastInstance = null
function goNextStep(originList, mode, msg) {
//每次都判断,因为每次都可能新增已掌握的单词
let list = originList.filter(v => (!data.excludeWords.includes(v.word)))
@@ -228,12 +247,8 @@ function goNextStep(originList, mode, msg) {
}
}
let toastInstance: ToastInstance = null
async function next(isTyping: boolean = true) {
if (isTyping) {
statStore.inputWordNumber++
}
if (isTyping) statStore.inputWordNumber++
if (settingStore.wordPracticeMode === WordPracticeMode.Free) {
if (data.index === data.words.length - 1) {
data.wrongWords = data.wrongWords.filter(v => (!data.excludeWords.includes(v.word)))
@@ -244,7 +259,7 @@ async function next(isTyping: boolean = true) {
data.words = shuffle(cloneDeep(data.wrongWords))
data.index = 0
data.wrongWords = []
}else {
} else {
console.log('自由模式,全完学完了')
showStatDialog = true
localStorage.removeItem(PracticeSaveWordKey.key)
@@ -256,19 +271,8 @@ async function next(isTyping: boolean = true) {
if (data.index === data.words.length - 1) {
if (statStore.step === 0 || isTypingWrongWord.value) {
if (settingStore.wordPracticeType !== WordPracticeType.Spell) {
let i = data.index
i--
let d = Math.floor(i / 6) - 1
while (i % 6 !== (d < 0 ? 0 : d)) {
i--
d = Math.floor(i / 6) - 1
}
console.log('i', i)
if (i <= 0) i = -1
if (i + 1 == data.index) {
data.index = 0
}
data.index = i + 1
//回到最后一组的开始位置
data.index = Math.floor(data.index / groupSize) * groupSize
emitter.emit(EventKey.resetWord)
settingStore.wordPracticeType = WordPracticeType.Spell
return
@@ -304,9 +308,9 @@ async function next(isTyping: boolean = true) {
return goNextStep(shuffle(taskWords.write), WordPracticeType.Listen, '开始听写之前')
}
//开始复写之前
//开始辨认之前
if (statStore.step === 5) {
return goNextStep(taskWords.write, WordPracticeType.Identify, '开始复写之前')
return goNextStep(taskWords.write, WordPracticeType.Identify, '开始辨认之前')
}
//开始默写上次
@@ -319,9 +323,9 @@ async function next(isTyping: boolean = true) {
return goNextStep(shuffle(taskWords.review), WordPracticeType.Listen, '开始听写上次')
}
//开始复写昨日
//开始辨认昨日
if (statStore.step === 2) {
return goNextStep(taskWords.review, WordPracticeType.Identify, '开始复写昨日')
return goNextStep(taskWords.review, WordPracticeType.Identify, '开始辨认昨日')
}
//开始默写新词
@@ -346,6 +350,13 @@ async function next(isTyping: boolean = true) {
savePracticeData()
}
function skipStep(){
data.index = data.words.length - 1
settingStore.wordPracticeType = WordPracticeType.Spell
data.wrongWords = []
next(false)
}
function onWordKnow() {
//标记模式时,用户认识的单词加入到排除里面,后续不再复习
let rIndex = data.excludeWords.findIndex(v => v === word.word)
@@ -388,7 +399,7 @@ function onKeyUp(e: KeyboardEvent) {
typingRef.hideWord()
}
async function onKeyDown(e: KeyboardEvent) {
function onKeyDown(e: KeyboardEvent) {
// console.log('onKeyDown', e)
switch (e.key) {
case 'Backspace':
@@ -401,21 +412,27 @@ useOnKeyboardEventListener(onKeyDown, onKeyUp)
function repeat() {
console.log('重学一遍')
if (settingStore.wordPracticeMode === WordPracticeMode.System) settingStore.dictation = false
if (store.sdict.lastLearnIndex === 0 && store.sdict.complete) {
//如果是刚刚完成那么学习进度要从length减回去因为lastLearnIndex为0了同时改complete为false
store.sdict.lastLearnIndex = store.sdict.length - statStore.newWordNumber
store.sdict.complete = false
let temp = cloneDeep(taskWords)
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
//随机练习单独处理
if (taskWords.shuffle.length) {
temp.shuffle = shuffle(temp.shuffle.filter(v => !ignoreList.includes(v.word)))
} else {
//将学习进度减回去
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex - statStore.newWordNumber
if (settingStore.wordPracticeMode === WordPracticeMode.System) settingStore.dictation = false
if (store.sdict.lastLearnIndex === 0 && store.sdict.complete) {
//如果是刚刚完成那么学习进度要从length减回去因为lastLearnIndex为0了同时改complete为false
store.sdict.lastLearnIndex = store.sdict.length - statStore.newWordNumber
store.sdict.complete = false
} else {
//将学习进度减回去
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex - statStore.newWordNumber
}
//排除已掌握单词
temp.new = temp.new.filter(v => !ignoreList.includes(v.word))
temp.review = temp.review.filter(v => !ignoreList.includes(v.word))
temp.write = temp.write.filter(v => !ignoreList.includes(v.word))
}
emitter.emit(EventKey.resetWord)
let temp = cloneDeep(taskWords)
//排除已掌握单词
temp.new = temp.new.filter(v => !store.knownWords.includes(v.word))
temp.review = temp.review.filter(v => !store.knownWords.includes(v.word))
temp.write = temp.write.filter(v => !store.knownWords.includes(v.word))
initData(temp)
}
@@ -477,16 +494,26 @@ function togglePanel() {
}
function continueStudy() {
if (settingStore.wordPracticeMode === WordPracticeMode.System) settingStore.dictation = false
//这里判断是否显示结算弹框,如果显示了结算弹框的话,就不用加进度了
if (!showStatDialog) {
console.log('没学完,强行跳过')
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex + statStore.newWordNumber
let temp = cloneDeep(taskWords)
//随机练习单独处理
if (taskWords.shuffle.length) {
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
temp.shuffle = shuffle(store.sdict.words.filter(v => !ignoreList.includes(v.word))).slice(0, runtimeStore.routeData.total)
if (showStatDialog) showStatDialog = false
} else {
console.log('学完了,正常下一组')
showStatDialog = false
if (settingStore.wordPracticeMode === WordPracticeMode.System) settingStore.dictation = false
//这里判断是否显示结算弹框,如果显示了结算弹框的话,就不用加进度了
if (!showStatDialog) {
console.log('没学完,强行跳过')
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex + statStore.newWordNumber
} else {
console.log('学完了,正常下一组')
showStatDialog = false
}
temp = getCurrentStudyWord()
}
initData(getCurrentStudyWord())
emitter.emit(EventKey.resetWord)
initData(temp)
}
function randomWrite() {
@@ -533,8 +560,8 @@ useEvents([
<template>
<PracticeLayout
v-loading="loading"
panelLeft="var(--word-panel-margin-left)">
v-loading="loading"
panelLeft="var(--word-panel-margin-left)">
<template v-slot:practice>
<div class="practice-word">
<div class="absolute z-1 top-4 w-full" v-if="settingStore.showNearWord">
@@ -543,16 +570,16 @@ useEvents([
v-if="prevWord">
<IconFluentArrowLeft16Regular class="arrow" width="22"/>
<Tooltip
:title="`上一个(${settingStore.shortcutKeyMap[ShortcutKey.Previous]})`"
:title="`上一个(${settingStore.shortcutKeyMap[ShortcutKey.Previous]})`"
>
<div class="word">{{ prevWord.word }}</div>
</Tooltip>
</div>
<div class="center gap-2 cursor-pointer float-right "
<div class="center gap-2 cursor-pointer float-right mr-3"
@click="next(false)"
v-if="nextWord">
<Tooltip
:title="`下一个(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
:title="`下一个(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
>
<div class="word" :class="settingStore.dictation && 'word-shadow'">{{ nextWord.word }}</div>
</Tooltip>
@@ -560,11 +587,11 @@ useEvents([
</div>
</div>
<TypeWord
ref="typingRef"
:word="word"
@wrong="onTypeWrong"
@complete="next"
@know="onWordKnow"
ref="typingRef"
:word="word"
@wrong="onTypeWrong"
@complete="next"
@know="onWordKnow"
/>
</div>
</template>
@@ -576,41 +603,41 @@ useEvents([
<span>{{ store.sdict.name }} ({{ store.sdict.lastLearnIndex }} / {{ store.sdict.length }})</span>
<BaseIcon
@click="continueStudy"
:title="`下一组(${settingStore.shortcutKeyMap[ShortcutKey.NextChapter]})`">
@click="continueStudy"
:title="`下一组(${settingStore.shortcutKeyMap[ShortcutKey.NextChapter]})`">
<IconFluentArrowRight16Regular class="arrow" width="22"/>
</BaseIcon>
<BaseIcon
@click="randomWrite"
:title="`随机默写(${settingStore.shortcutKeyMap[ShortcutKey.RandomWrite]})`">
@click="randomWrite"
:title="`随机默写(${settingStore.shortcutKeyMap[ShortcutKey.RandomWrite]})`">
<IconFluentArrowShuffle16Regular class="arrow" width="22"/>
</BaseIcon>
</div>
</template>
<div class="panel-page-item pl-4">
<WordList
v-if="data.words.length"
:is-active="settingStore.showPanel"
:static="false"
:show-word="!settingStore.dictation"
:show-translate="settingStore.translate"
:list="data.words"
:activeIndex="data.index"
@click="(val:any) => data.index = val.index"
v-if="data.words.length"
:is-active="settingStore.showPanel"
:static="false"
:show-word="!settingStore.dictation"
:show-translate="settingStore.translate"
:list="data.words"
:activeIndex="data.index"
@click="(val:any) => data.index = val.index"
>
<template v-slot:suffix="{item,index}">
<BaseIcon
:class="!isWordCollect(item)?'collect':'fill'"
@click.stop="toggleWordCollect(item)"
:title="!isWordCollect(item) ? '收藏' : '取消收藏'">
:class="!isWordCollect(item)?'collect':'fill'"
@click.stop="toggleWordCollect(item)"
:title="!isWordCollect(item) ? '收藏' : '取消收藏'">
<IconFluentStar16Regular v-if="!isWordCollect(item)"/>
<IconFluentStar16Filled v-else/>
</BaseIcon>
<BaseIcon
:class="!isWordSimple(item)?'collect':'fill'"
@click.stop="toggleWordSimple(item)"
:title="!isWordSimple(item) ? '标记为已掌握' : '取消标记已掌握'">
:class="!isWordSimple(item)?'collect':'fill'"
@click.stop="toggleWordSimple(item)"
:title="!isWordSimple(item) ? '标记为已掌握' : '取消标记已掌握'">
<IconFluentCheckmarkCircle16Regular v-if="!isWordSimple(item)"/>
<IconFluentCheckmarkCircle16Filled v-else/>
</BaseIcon>
@@ -622,11 +649,12 @@ useEvents([
</template>
<template v-slot:footer>
<Footer
:is-simple="isWordSimple(word)"
@toggle-simple="toggleWordSimpleWrapper"
:is-collect="isWordCollect(word)"
@toggle-collect="toggleWordCollect(word)"
@skip="next(false)"
:is-simple="isWordSimple(word)"
@toggle-simple="toggleWordSimpleWrapper"
:is-collect="isWordCollect(word)"
@toggle-collect="toggleWordCollect(word)"
@skip="next(false)"
@skipStep="skipStep"
/>
</template>
</PracticeLayout>

View File

@@ -1,26 +1,27 @@
<script setup lang="ts">
import {useBaseStore} from "@/stores/base.ts";
import BaseButton from "@/components/BaseButton.vue";
import {ShortcutKey, Statistics} from "@/types/types.ts";
import {PracticeData, ShortcutKey, Statistics, TaskWords, WordPracticeMode} from "@/types/types.ts";
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {usePracticeStore} from "@/stores/practice.ts";
import dayjs from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import {defineAsyncComponent, watch} from "vue";
import {defineAsyncComponent, inject, watch} from "vue";
import isoWeek from 'dayjs/plugin/isoWeek'
import {msToHourMinute, msToMinute} from "@/utils";
dayjs.extend(isoWeek)
dayjs.extend(isBetween);
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
const store = useBaseStore()
const settingStore = useSettingStore()
const statStore = usePracticeStore()
const model = defineModel({default: false})
let list = $ref([])
let dictIsEnd = $ref(false)
let practiceTaskWords = inject<TaskWords>('practiceTaskWords')
function calcWeekList() {
// 获取本周的起止时间
@@ -68,12 +69,16 @@ watch(model, (newVal) => {
complete: store.sdict.complete,
str: `name:${store.sdict.name},per:${store.sdict.perDayStudyNumber},spend:${Number(statStore.spend / 1000 / 60).toFixed(1)},index:${store.sdict.lastLearnIndex}`
})
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex + statStore.newWordNumber
if (store.sdict.lastLearnIndex >= store.sdict.length) {
dictIsEnd = true;
store.sdict.complete = true
store.sdict.lastLearnIndex = 0
//如果 shuffle 数组不为空,就说明是复习,不用修改 lastLearnIndex
if (!practiceTaskWords.shuffle.length) {
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex + statStore.newWordNumber
if (store.sdict.lastLearnIndex >= store.sdict.length) {
dictIsEnd = true;
store.sdict.complete = true
store.sdict.lastLearnIndex = 0
}
}
store.sdict.statistics.push(data as any)
calcWeekList(); // 新增:计算本周学习记录
}
@@ -97,33 +102,41 @@ function options(emitType: string) {
<template>
<Dialog
:close-on-click-bg="false"
:header="false"
:keyboard="false"
:show-close="false"
v-model="model">
:close-on-click-bg="false"
:header="false"
:keyboard="false"
:show-close="false"
v-model="model">
<div class="w-140 bg-white color-black p-6 relative flex flex-col gap-6">
<div class="w-full flex flex-col justify-evenly">
<div class="center text-2xl mb-2">已完成今日任务</div>
<div class="center text-2xl mb-2">已完成{{ practiceTaskWords.shuffle.length ? '随机复习' : '今日任务' }}</div>
<div class="flex">
<div class="flex-1 flex flex-col items-center">
<div class="text-sm color-gray">新词数</div>
<div class="text-4xl font-bold">{{ statStore.newWordNumber }}</div>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="text-sm color-gray">复习上次</div>
<div class="text-4xl font-bold">{{ statStore.reviewWordNumber }}</div>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="text-sm color-gray">复习之前</div>
<div class="text-4xl font-bold">{{ statStore.writeWordNumber }}</div>
<div v-if="practiceTaskWords.shuffle.length"
class="flex-1 flex flex-col items-center">
<div class="text-sm color-gray">随机复习</div>
<div class="text-4xl font-bold">{{ practiceTaskWords.shuffle.length }}</div>
</div>
<template v-else>
<div class="flex-1 flex flex-col items-center">
<div class="text-sm color-gray">新词数</div>
<div class="text-4xl font-bold">{{ statStore.newWordNumber }}</div>
</div>
<template v-if="settingStore.wordPracticeMode !== WordPracticeMode.Free">
<div class="flex-1 flex flex-col items-center">
<div class="text-sm color-gray">复习上次</div>
<div class="text-4xl font-bold">{{ statStore.reviewWordNumber }}</div>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="text-sm color-gray">复习之前</div>
<div class="text-4xl font-bold">{{ statStore.writeWordNumber }}</div>
</div>
</template>
</template>
</div>
</div>
<div class="text-xl text-center flex flex-col justify-around">
<div>非常棒! 坚持了 <span class="color-emerald-500 font-bold text-2xl">
{{ dayjs().diff(statStore.startDate, 'm') }}</span>分钟
<div>非常棒! 坚持了 <span class="color-emerald-500 font-bold text-2xl">{{msToHourMinute(statStore.spend) }}</span>
</div>
</div>
<div class="flex justify-center gap-10">
@@ -149,29 +162,29 @@ function options(emitType: string) {
<div class="title text-align-center mb-2">本周学习记录</div>
<div class="flex gap-4 color-gray">
<div
class="w-8 h-8 rounded-md center"
:class="item ? 'bg-emerald-500 color-white' : 'bg-gray-200'"
v-for="(item, i) in list"
:key="i"
class="w-8 h-8 rounded-md center"
:class="item ? 'bg-emerald-500 color-white' : 'bg-gray-200'"
v-for="(item, i) in list"
:key="i"
>{{ i + 1 }}
</div>
</div>
</div>
<div class="flex justify-center gap-4 ">
<BaseButton
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.RepeatChapter]"
@click="options(EventKey.repeatStudy)">
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.RepeatChapter]"
@click="options(EventKey.repeatStudy)">
重学一遍
</BaseButton>
<BaseButton
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextChapter]"
@click="options(EventKey.continueStudy)">
{{ dictIsEnd ? '重新练习' : '再来一组' }}
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextChapter]"
@click="options(EventKey.continueStudy)">
{{ dictIsEnd ? '从头开始练习' : '再来一组' }}
</BaseButton>
<BaseButton
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextRandomWrite]"
@click="options(EventKey.randomWrite)">
继续默写
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextRandomWrite]"
@click="options(EventKey.randomWrite)">
继续默写
</BaseButton>
<BaseButton @click="$router.back">
返回主页
@@ -182,7 +195,4 @@ function options(emitType: string) {
</div>
</div>
</Dialog>
</template>
<style scoped lang="scss">
</style>
</template>

View File

@@ -2,9 +2,9 @@
import { useBaseStore } from "@/stores/base.ts";
import { useRouter } from "vue-router";
import BaseIcon from "@/components/BaseIcon.vue";
import { _getAccomplishDate, _getDictDataByUrl, resourceWrap, useNav } from "@/utils";
import { _getAccomplishDate, _getDictDataByUrl, resourceWrap, shuffle, useNav } from "@/utils";
import BasePage from "@/components/BasePage.vue";
import {DictResource, WordPracticeMode} from "@/types/types.ts";
import { DictResource, WordPracticeMode } from "@/types/types.ts";
import { watch } from "vue";
import { getCurrentStudyWord } from "@/hooks/dict.ts";
import { useRuntimeStore } from "@/stores/runtime.ts";
@@ -18,11 +18,11 @@ import DeleteIcon from "@/components/icon/DeleteIcon.vue";
import PracticeSettingDialog from "@/pages/word/components/PracticeSettingDialog.vue";
import ChangeLastPracticeIndexDialog from "@/pages/word/components/ChangeLastPracticeIndexDialog.vue";
import { useSettingStore } from "@/stores/setting.ts";
import CollectNotice from "@/components/CollectNotice.vue";
import { useFetch } from "@vueuse/core";
import { CAN_REQUEST, DICT_LIST, PracticeSaveWordKey } from "@/config/env.ts";
import { AppEnv, DICT_LIST, PracticeSaveWordKey } from "@/config/env.ts";
import { myDictList } from "@/apis";
import PracticeWordListDialog from "@/pages/word/components/PracticeWordListDialog.vue";
import ShufflePracticeSettingDialog from "@/pages/word/components/ShufflePracticeSettingDialog.vue";
const store = useBaseStore()
@@ -35,7 +35,8 @@ let isSaveData = $ref(false)
let currentStudy = $ref({
new: [],
review: [],
write: []
write: [],
shuffle: [],
})
watch(() => store.load, n => {
@@ -43,7 +44,7 @@ watch(() => store.load, n => {
}, {immediate: true})
async function init() {
if (CAN_REQUEST) {
if (AppEnv.CAN_REQUEST) {
let res = await myDictList({type: "word"})
if (res.success) {
store.setState(Object.assign(store.$state, res.data))
@@ -85,7 +86,9 @@ function startPractice() {
complete: store.sdict.complete,
wordPracticeMode: settingStore.wordPracticeMode
})
nav('practice-words/' + store.sdict.id, {}, currentStudy)
//把是否是第一次设置为false
settingStore.first = false
nav('practice-words/' + store.sdict.id, {}, {taskWords: currentStudy})
} else {
window.umami?.track('no-dict')
Toast.warning('请先选择一本词典')
@@ -93,15 +96,17 @@ function startPractice() {
}
let showPracticeSettingDialog = $ref(false)
let showShufflePracticeSettingDialog = $ref(false)
let showChangeLastPracticeIndexDialog = $ref(false)
let showPracticeWordListDialog = $ref(false)
async function goDictDetail(val: DictResource) {
if (!val.id) return nav('dict-list')
runtimeStore.editDict = getDefaultDict(val)
nav('dict-detail', {})
}
let isMultiple = $ref(false)
let isManageDict = $ref(false)
let selectIds = $ref([])
function handleBatchDel() {
@@ -156,6 +161,26 @@ async function savePracticeSetting() {
currentStudy = getCurrentStudyWord()
}
async function onShufflePracticeSettingOk(total) {
window.umami?.track('startShuffleStudyWord', {
name: store.sdict.name,
index: store.sdict.lastLearnIndex,
perDayStudyNumber: store.sdict.perDayStudyNumber,
total,
custom: store.sdict.custom,
complete: store.sdict.complete,
})
isSaveData = false
localStorage.removeItem(PracticeSaveWordKey.key)
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
currentStudy.shuffle = shuffle(store.sdict.words.slice(0, store.sdict.lastLearnIndex).filter(v => !ignoreList.includes(v.word))).slice(0, total)
nav('practice-words/' + store.sdict.id, {}, {
taskWords: currentStudy,
total //用于再来一组时,随机出正确的长度,因为练习中可能会点击已掌握,导致重学一遍之后长度变少,如果再来一组,此时长度就不正确
})
}
async function saveLastPracticeIndex(e) {
Toast.success('修改成功')
runtimeStore.editDict.lastLearnIndex = e
@@ -171,97 +196,195 @@ const {
isFetching
} = useFetch(resourceWrap(DICT_LIST.WORD.RECOMMENDED)).json()
</script>
<template>
<BasePage>
<div class="card flex gap-10">
<div class="flex-1 flex flex-col gap-2">
<div class="flex">
<div class="bg-third px-3 h-14 rounded-md flex items-center ">
<span @click="goDictDetail(store.sdict)"
class="text-lg font-bold cursor-pointer">{{ store.sdict.name || '请选择词典开始学习' }}</span>
<BaseIcon title="切换词典"
class="ml-4"
@click="router.push('/dict-list')"
>
<IconFluentArrowSort20Regular v-if="store.sdict.name"/>
<IconFluentAdd20Filled v-else/>
</BaseIcon>
<div class="card flex gap-8">
<div class="flex-1 flex flex-col justify-between">
<div class="flex gap-3">
<div class="p-1 center rounded-full bg-white">
<IconFluentBookNumber20Filled class="text-xl color-link"/>
</div>
<div
@click="goDictDetail(store.sdict)"
class="text-2xl font-bold cursor-pointer">
{{ store.sdict.name || '当前无正在学习的词典' }}
</div>
</div>
<div class="flex items-end gap-space">
<div class="flex-1">
<div class="text-sm flex justify-between">
<span>{{ progressTextLeft }}</span>
<span>{{ progressTextRight }} / {{ store.sdict.words.length }}</span>
</div>
<Progress class="mt-1" :percentage="store.currentStudyProgress" :show-text="false"></Progress>
</div>
<PopConfirm
:disabled="!isSaveData"
title="当前存在未完成的学习任务,修改会重新生成学习任务,是否继续?"
@confirm="check(()=>showChangeLastPracticeIndexDialog = true)">
<div class="color-blue cursor-pointer">更改</div>
</PopConfirm>
</div>
<div class="text-sm text-align-end">
预计完成日期{{ _getAccomplishDate(store.sdict.words.length, store.sdict.perDayStudyNumber) }}
<template v-if="store.sdict.id">
<div class="mt-4 flex flex-col gap-2">
<div class="">当前进度{{ progressTextLeft }}</div>
<Progress size="large" :percentage="store.currentStudyProgress" :show-text="false"></Progress>
<div class="text-sm flex justify-between">
<span>已完成 {{ progressTextRight }} / {{ store.sdict.words.length }} </span>
<span v-if="store.sdict.id">
预计完成日期{{ _getAccomplishDate(store.sdict.words.length, store.sdict.perDayStudyNumber) }}
</span>
</div>
</div>
<div class="flex items-center mt-4 gap-4">
<BaseButton type="info"
size="small"
@click="router.push('/dict-list')">
<div class="center gap-1">
<IconFluentArrowSwap20Regular/>
<span>选择词典</span>
</div>
</BaseButton>
<PopConfirm
:disabled="!isSaveData"
title="当前存在未完成的学习任务,修改会重新生成学习任务,是否继续?"
@confirm="check(()=>showChangeLastPracticeIndexDialog = true)">
<BaseButton type="info"
size="small"
v-if="store.sdict.id"
>
<div class="center gap-1">
<IconFluentSlideTextTitleEdit20Regular/>
<span>更改进度</span>
</div>
</BaseButton>
</PopConfirm>
</div>
</template>
<div class="flex items-center gap-4 mt-2 flex-1" v-else>
<div class="title">请选择一本词典开始学习</div>
<BaseButton type="primary" size="large" @click="router.push('/dict-list')">
<div class="center gap-1">
<IconFluentAdd16Regular/>
<span>选择词典</span>
</div>
</BaseButton>
</div>
</div>
<div class="w-3/10 flex flex-col justify-evenly">
<div class="center gap-2">
<span class="text-xl">{{ isSaveData ? '上次学习任务' : '今日任务' }}</span>
<span class="color-blue cursor-pointer" @click="showPracticeWordListDialog = true">词表</span>
<div class="flex-1" :class="!store.sdict.id && 'opacity-30 cursor-not-allowed'">
<div class="flex justify-between">
<div class="flex items-center gap-2">
<div class="p-2 center rounded-full bg-white ">
<IconFluentStar20Filled class="text-lg color-amber"/>
</div>
<div class="text-xl font-bold">
{{ isSaveData ? '上次任务' : '今日任务' }}
</div>
<span class="color-link cursor-pointer"
v-if="store.sdict.id"
@click="showPracticeWordListDialog = true">词表</span>
</div>
<div class="flex gap-1 items-center"
v-if="store.sdict.id"
>
每日目标
<div style="color:#ac6ed1;"
class="bg-third px-2 h-10 flex center text-2xl rounded">
{{ store.sdict.id ? store.sdict.perDayStudyNumber : 0 }}
</div>
个单词
<PopConfirm
:disabled="!isSaveData"
title="当前存在未完成的学习任务,修改会重新生成学习任务,是否继续?"
@confirm="check(()=>showPracticeSettingDialog = true)">
<BaseButton
type="info" size="small">更改
</BaseButton>
</PopConfirm>
</div>
</div>
<div class="flex">
<div class="flex-1 flex flex-col items-center">
<div class="text-4xl font-bold">{{ currentStudy.new.length }}</div>
<div class="text">新词</div>
<div class="flex mt-4 justify-between">
<div class="stat">
<div class="num">{{ currentStudy.new.length }}</div>
<div class="txt">新词</div>
</div>
<template v-if="settingStore.wordPracticeMode === WordPracticeMode.System">
<div class="flex-1 flex flex-col items-center">
<div class="text-4xl font-bold">{{ currentStudy.review.length }}</div>
<div class="text">复习上次</div>
<div class="stat">
<div class="num">{{ currentStudy.review.length }}</div>
<div class="txt">复习上次</div>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="text-4xl font-bold">{{ currentStudy.write.length }}
</div>
<div class="text">复习之前</div>
<div class="stat">
<div class="num">{{ currentStudy.write.length }}</div>
<div class="txt">复习之前</div>
</div>
</template>
</div>
</div>
<div class="flex items-end mt-4">
<BaseButton size="large"
class="flex-1"
:disabled="!store.sdict.id"
:loading="loading"
@click="startPractice">
<div class="flex items-center gap-2">
<span class="line-height-[2]">{{ isSaveData ? '继续学习' : '开始学习' }}</span>
<IconFluentArrowCircleRight16Regular class="text-xl"/>
</div>
</BaseButton>
<div class="flex flex-col items-end justify-around ">
<div class="flex gap-1 items-center">
每日目标
<div style="color:#ac6ed1;"
class="bg-third px-2 h-10 flex center text-2xl rounded">
{{ store.sdict.id ? store.sdict.perDayStudyNumber : 0 }}
<div
v-if="false"
class="w-full flex box-border cp color-white">
<div
@click="startPractice"
class="flex-1 rounded-l-lg center gap-2 py-1 bg-[var(--btn-primary)] hover:opacity-50">
<span class="line-height-[2]">{{ isSaveData ? '继续学习' : '开始学习' }}</span>
<IconFluentArrowCircleRight16Regular class="text-xl"/>
</div>
<div class="relative group">
<div
class="w-10 rounded-r-lg h-full center bg-[var(--btn-primary)] hover:bg-gray border-solid border-2 border-l-gray border-transparent box-border">
<IconFluentChevronDown20Regular/>
</div>
<div
class="space-y-2 pt-2 absolute z-2 right-0 border rounded opacity-0 scale-95
group-hover:opacity-100 group-hover:scale-100
transition-all duration-150 pointer-events-none group-hover:pointer-events-auto"
>
<div>
<BaseButton
size="large" type="orange"
:loading="loading"
@click="check(()=>showShufflePracticeSettingDialog = true)">
<div class="flex items-center gap-2">
<span class="line-height-[2]">随机复习</span>
<IconFluentArrowShuffle20Filled class="text-xl"/>
</div>
</BaseButton>
</div>
<div>
<BaseButton
size="large" type="orange"
:loading="loading"
@click="check(()=>showShufflePracticeSettingDialog = true)">
<div class="flex items-center gap-2">
<span class="line-height-[2]">重新学习</span>
<IconFluentArrowShuffle20Filled class="text-xl"/>
</div>
</BaseButton>
</div>
</div>
</div>
</div>
个单词
<PopConfirm
:disabled="!isSaveData"
title="当前存在未完成的学习任务,修改会重新生成学习任务,是否继续?"
@confirm="check(()=>showPracticeSettingDialog = true)">
<span class="color-blue cursor-pointer">更改</span>
</PopConfirm>
<BaseButton
v-if="store.sdict.id && store.sdict.lastLearnIndex"
size="large" type="orange"
:loading="loading"
@click="check(()=>showShufflePracticeSettingDialog = true)">
<div class="flex items-center gap-2">
<span class="line-height-[2]">随机复习</span>
<IconFluentArrowShuffle20Filled class="text-xl"/>
</div>
</BaseButton>
</div>
<BaseButton size="large" :disabled="!store.sdict.name"
:loading="loading"
@click="startPractice">
<div class="flex items-center gap-2">
<span class="line-height-[2]">{{ isSaveData ? '继续学习' : '开始学习' }}</span>
<IconFluentArrowCircleRight16Regular class="text-xl"/>
</div>
</BaseButton>
</div>
</div>
<div class="card flex flex-col">
<div class="card flex flex-col">
<div class="flex justify-between">
<div class="title">我的词典</div>
<div class="flex gap-4 items-center">
@@ -271,25 +394,25 @@ const {
</BaseIcon>
</PopConfirm>
<div class="color-blue cursor-pointer" v-if="store.word.bookList.length > 3"
@click="isMultiple = !isMultiple; selectIds = []">{{ isMultiple ? '取消' : '管理词典' }}
<div class="color-link cursor-pointer" v-if="store.word.bookList.length > 3"
@click="isManageDict = !isManageDict; selectIds = []">{{ isManageDict ? '取消' : '管理词典' }}
</div>
<div class="color-blue cursor-pointer" @click="nav('dict-detail', { isAdd: true })">创建个人词典</div>
<div class="color-link cursor-pointer" @click="nav('dict-detail', { isAdd: true })">创建个人词典</div>
</div>
</div>
<div class="flex gap-4 flex-wrap mt-4">
<Book :is-add="false" quantifier="个词" :item="item" :checked="selectIds.includes(item.id)"
@check="() => toggleSelect(item)" :show-checkbox="isMultiple && j >= 3"
@check="() => toggleSelect(item)" :show-checkbox="isManageDict && j >= 3"
v-for="(item, j) in store.word.bookList" @click="goDictDetail(item)"/>
<Book :is-add="true" @click="router.push('/dict-list')"/>
</div>
</div>
<div class="card flex flex-col overflow-hidden" v-loading="isFetching">
<div class="card flex flex-col overflow-hidden" v-loading="isFetching">
<div class="flex justify-between">
<div class="title">推荐</div>
<div class="flex gap-4 items-center">
<div class="color-blue cursor-pointer" @click="router.push('/dict-list')">更多</div>
<div class="color-link cursor-pointer" @click="router.push('/dict-list')">更多</div>
</div>
</div>
@@ -317,8 +440,23 @@ const {
v-model="showPracticeWordListDialog"
/>
<CollectNotice/>
<ShufflePracticeSettingDialog
v-model="showShufflePracticeSettingDialog"
@ok="onShufflePracticeSettingOk"/>
</template>
<style scoped lang="scss">
.stat {
@apply w-31% box-border flex flex-col items-center justify-center rounded-xl p-2 bg-[var(--bg-history)];
border: 1px solid gainsboro;
.num {
@apply color-[#409eff] text-4xl font-bold;
}
.txt {
@apply color-gray-500;
}
}
</style>

View File

@@ -3,12 +3,12 @@
import { inject, Ref, watch } from "vue"
import { usePracticeStore } from "@/stores/practice.ts";
import { useSettingStore } from "@/stores/setting.ts";
import { PracticeData, WordPracticeType, ShortcutKey } from "@/types/types.ts";
import { PracticeData, WordPracticeType, ShortcutKey, TaskWords } from "@/types/types.ts";
import BaseIcon from "@/components/BaseIcon.vue";
import Tooltip from "@/components/base/Tooltip.vue";
import Progress from '@/components/base/Progress.vue'
const statisticsStore = usePracticeStore()
const statStore = usePracticeStore()
const settingStore = useSettingStore()
defineProps<{
@@ -22,6 +22,7 @@ const emit = defineEmits<{
toggleSimple: [],
edit: [],
skip: [],
skipStep:[]
}>()
let practiceData = inject<PracticeData>('practiceData')
@@ -33,8 +34,12 @@ function format(val: number, suffix: string = '', check: number = -1) {
const status = $computed(() => {
if (isTypingWrongWord.value) return '复习错词'
return getStepStr(statStore.step)
})
function getStepStr(step: number) {
let str = ''
switch (statisticsStore.step) {
switch (step) {
case 0:
str += `学习新词`
break
@@ -45,7 +50,7 @@ const status = $computed(() => {
str += `默写新词`
break
case 3:
str += `复习上次学习`
str += `辨认上次学习`
break
case 4:
str += '听写上次学习'
@@ -54,7 +59,7 @@ const status = $computed(() => {
str += '默写上次学习'
break
case 6:
str += '复习之前学习'
str += '辨认之前学习'
break
case 7:
str += '听写之前学习'
@@ -62,9 +67,15 @@ const status = $computed(() => {
case 8:
str += '默写之前学习'
break
case 9:
str += '学习完成'
break
case 10:
str += '随机复习'
break
}
return str
})
}
const progress = $computed(() => {
if (!practiceData.words.length) return 0
@@ -96,22 +107,29 @@ const progress = $computed(() => {
<div class="name">{{ status }}</div>
</div>
<div class="row">
<div class="num">{{ statisticsStore.total }}</div>
<div class="num">{{ statStore.total }}</div>
<div class="line"></div>
<div class="name">单词总数</div>
</div>
<div class="row">
<div class="num">{{ format(statisticsStore.inputWordNumber, '', 0) }}</div>
<div class="num">{{ format(statStore.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="num">{{ format(statStore.wrong, '', 0) }}</div>
<div class="line"></div>
<div class="name">总错误数</div>
</div>
</div>
<div class="flex gap-2 justify-center items-center">
<div class="flex gap-2 justify-center items-center">
<BaseIcon
v-if="statStore.step < 9"
@click="emit('skipStep')"
:title="`跳到下一阶段:${getStepStr(statStore.step+1)}`">
<IconFluentArrowRight16Regular/>
</BaseIcon>
<BaseIcon
:class="!isSimple?'collect':'fill'"
@click="$emit('toggleSimple')"
@@ -129,7 +147,7 @@ const progress = $computed(() => {
</BaseIcon>
<BaseIcon
@click="emit('skip')"
:title="`跳过(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`">
:title="`跳过当前单词(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`">
<IconFluentArrowBounce20Regular class="transform-rotate-180"/>
</BaseIcon>
@@ -204,7 +222,6 @@ const progress = $computed(() => {
flex-direction: column;
align-items: center;
gap: .3rem;
width: 6rem;
color: gray;
.line {

View File

@@ -1,18 +1,15 @@
<script setup lang="ts">
import {_getAccomplishDays} from "@/utils";
import Radio from "@/components/base/radio/Radio.vue";
import RadioGroup from "@/components/base/radio/RadioGroup.vue";
import { _getAccomplishDays } from "@/utils";
import BaseButton from "@/components/BaseButton.vue";
import Checkbox from "@/components/base/checkbox/Checkbox.vue";
import Slider from "@/components/base/Slider.vue";
import {useBaseStore} from "@/stores/base.ts";
import {defineAsyncComponent, watch} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
import { defineAsyncComponent, watch } from "vue";
import { useSettingStore } from "@/stores/setting.ts";
import Toast from "@/components/base/toast/Toast.ts";
import ChangeLastPracticeIndexDialog from "@/pages/word/components/ChangeLastPracticeIndexDialog.vue";
import Tooltip from "@/components/base/Tooltip.vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
import { useRuntimeStore } from "@/stores/runtime.ts";
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import Slider from "@/components/base/Slider.vue";
import {defineAsyncComponent, watch} from "vue";
import {useBaseStore} from "@/stores/base.ts";
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
const store = useBaseStore()
const model = defineModel()
const emit = defineEmits<{
ok: [val: number];
}>()
let num = $ref(0)
let min = $ref(0)
watch(() => model.value, (n) => {
if (n) {
num = Math.floor(store.sdict.lastLearnIndex / 3)
num = num > 50 ? 50 : num
min = num < 10 ? num : 10
}
})
</script>
<template>
<Dialog v-model="model" title="随机复习设置"
:footer="true"
@ok="emit('ok',num)">
<div class="target-modal color-main">
<div class="flex gap-4 items-end mb-2">
<span>随机复习<span class="font-bold">{{ store.sdict.name }}</span></span>
<span class="text-3xl mx-2 lh">{{ num }}</span>个单词
</div>
<div class="flex gap-space">
<span class="shrink-0">随机数量</span>
<Slider :min="min"
:step="10"
show-text
class="mt-1"
:max="store.sdict.lastLearnIndex"
v-model="num"/>
</div>
</div>
</Dialog>
</template>
<style scoped lang="scss">
.target-modal {
width: 30rem;
padding: 0 var(--space);
.lh {
color: rgb(176, 116, 211)
}
.mode-item {
@apply w-50% border border-blue border-solid p-2 rounded-lg cursor-pointer;
}
.active {
@apply bg-blue color-white;
}
}
</style>

View File

@@ -1,18 +1,17 @@
<script setup lang="ts">
import {WordPracticeType, ShortcutKey, Word, WordPracticeMode} from "@/types/types.ts";
import {ShortcutKey, Word, WordPracticeType} from "@/types/types.ts";
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
import {useSettingStore} from "@/stores/setting.ts";
import {usePlayBeep, usePlayCorrect, usePlayKeyboardAudio, usePlayWordAudio} from "@/hooks/sound.ts";
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
import {inject, onMounted, onUnmounted, Ref, watch} from "vue";
import {onMounted, onUnmounted, watch} from "vue";
import SentenceHightLightWord from "@/pages/word/components/SentenceHightLightWord.vue";
import {usePracticeStore} from "@/stores/practice.ts";
import {getDefaultWord} from "@/types/func.ts";
import {_nextTick, last, sleep} from "@/utils";
import {_nextTick, last} from "@/utils";
import BaseButton from "@/components/BaseButton.vue";
import Space from "@/pages/article/components/Space.vue";
import Toast from "@/components/base/toast/Toast.ts";
import Tooltip from "@/components/base/Tooltip.vue";
interface IProps {
word: Word,
@@ -104,9 +103,7 @@ function repeat() {
wordRepeatCount++
inputLock = false
if (settingStore.wordSound) {
volumeIconRef?.play()
}
if (settingStore.wordSound) volumeIconRef?.play()
}, settingStore.waitTimeForChangeWord)
}
@@ -144,6 +141,7 @@ function unknown(e) {
if (!showWordResult) {
showWordResult = true
emit('wrong')
if (settingStore.wordSound) volumeIconRef?.play()
return
}
}
@@ -153,25 +151,45 @@ function unknown(e) {
async function onTyping(e: KeyboardEvent) {
debugger
let word = props.word.word
// 输入完成会锁死不能再输入
if (inputLock) {
// 因为输入完成会锁死不能再输入,所以在这里判断空格键切换到下一个单词
if (e.code === 'Space' && input.toLowerCase() === word.toLowerCase()) {
showWordResult = inputLock = false
emit('complete')
} else {
//当显示单词时,提示用户正确按键
if (showWordResult) {
pressNumber++
if (pressNumber >= 3) {
Toast.info(right ? '请按空格键切换' : '请按删除键重新输入', {duration: 2000})
pressNumber = 0
//判断是否是空格键以便切换到下一个单词
if (e.code === 'Space') {
//正确时就切换到下一个单词
if (right) {
showWordResult = inputLock = false
emit('complete')
} else {
if (showWordResult) {
// 错误时,提示用户按删除键,仅默写需要提示
pressNumber++
if (pressNumber >= 3) {
Toast.info('请按删除键重新输入', {duration: 2000})
pressNumber = 0
}
}
}
} else {
//当正确时,提醒用户按空格键切下一个
if (right) {
pressNumber++
if (pressNumber >= 3) {
Toast.info('请按空格键继续', {duration: 2000})
pressNumber = 0
}
} else {
//当错误时,按任意键重新输入
showWordResult = inputLock = false
input = wrong = ''
onTyping(e)
}
}
return
}
inputLock = true
let letter = e.key
console.log('letter',letter)
//默写特殊逻辑
if (settingStore.wordPracticeType === WordPracticeType.Dictation) {
if (e.code === 'Space') {
@@ -186,12 +204,12 @@ async function onTyping(e: KeyboardEvent) {
} else {
//未显示单词,则播放正确音乐,并在后面设置为 showWordResult 为 true 来显示单词
playCorrect()
volumeIconRef?.play()
if (settingStore.wordSound) volumeIconRef?.play()
}
} else {
//错误处理
playBeep()
volumeIconRef?.play()
if (settingStore.wordSound) volumeIconRef?.play()
emit('wrong')
}
showWordResult = true
@@ -204,12 +222,19 @@ async function onTyping(e: KeyboardEvent) {
playKeyboardAudio()
updateCurrentWordInfo();
inputLock = false
} else if (settingStore.wordPracticeType === WordPracticeType.Identify && !showWordResult) {
//当辨认模式下按1和2会单独处理如果按其他键则自动默认为不认识
showWordResult = true
emit('wrong')
if (settingStore.wordSound) volumeIconRef?.play()
inputLock = false
onTyping(e)
} else {
let right = false
if (settingStore.ignoreCase) {
right = letter.toLowerCase() === word[input.length].toLowerCase()
} else {
right = letter === props.word.word[input.length]
right = letter === word[input.length]
}
if (right) {
input += letter
@@ -219,10 +244,11 @@ async function onTyping(e: KeyboardEvent) {
emit('wrong')
wrong = letter
playBeep()
volumeIconRef?.play()
await sleep(500)
if (settingStore.inputWrongClear) input = ''
wrong = ''
if (settingStore.wordSound) volumeIconRef?.play()
setTimeout(() => {
if (settingStore.inputWrongClear) input = ''
wrong = ''
}, 500)
}
// 更新当前单词信息
updateCurrentWordInfo();
@@ -274,15 +300,22 @@ function del() {
function showWord() {
if (settingStore.allowWordTip) {
showFullWord = true
}
//系统设定的默认模式情况下,如果看了单词统计到错词里面去
switch (statStore.step) {
case 1:
case 3:
case 4:
if (settingStore.wordPracticeType === WordPracticeType.Dictation || settingStore.dictation) {
emit('wrong')
break
}
showFullWord = true
//系统设定的默认模式情况下,如果看了单词统计到错词里面去
switch (statStore.step) {
case 1:
case 2:
case 4:
case 5:
case 7:
case 8:
case 10:
emit('wrong')
break
}
}
}
@@ -291,6 +324,9 @@ function hideWord() {
}
function play() {
if (settingStore.wordPracticeType === WordPracticeType.Dictation || settingStore.dictation) {
emit('wrong')
}
volumeIconRef?.play()
}

View File

@@ -1,7 +1,7 @@
import * as VueRouter from 'vue-router'
import {RouteRecordRaw} from 'vue-router'
import WordsPage from "@/pages/word/WordsPage.vue";
import PC from "@/pages/index.vue";
import Layout from "@/pages/layout.vue";
import ArticlesPage from "@/pages/article/ArticlesPage.vue";
import PracticeArticles from "@/pages/article/PracticeArticles.vue";
import DictDetail from "@/pages/word/DictDetail.vue";
@@ -10,17 +10,17 @@ import BookDetail from "@/pages/article/BookDetail.vue";
import DictList from "@/pages/word/DictList.vue";
import BookList from "@/pages/article/BookList.vue";
import Setting from "@/pages/setting/Setting.vue";
import Home from "@/pages/home/index.vue";
import Login from "@/pages/user/login.vue";
import User from "@/pages/user/index.vue";
import User from "@/pages/user/User.vue";
import VipIntro from "@/pages/user/VipIntro.vue";
// import { useAuthStore } from "@/stores/auth.ts";
export const routes: RouteRecordRaw[] = [
{
path: '/',
component: PC,
redirect: '/',
component: Layout,
children: [
{path: '/', component: Home},
{path: '/', redirect: '/words'},
{path: 'words', component: WordsPage},
{path: 'word', redirect: '/words'},
{path: 'practice-words/:id', component: PracticeWords},
@@ -37,11 +37,12 @@ export const routes: RouteRecordRaw[] = [
{path: 'setting', component: Setting},
{path: 'login', component: Login},
{path: 'user', component: User},
{path: 'vip', component: VipIntro},
]
},
{path: '/batch-edit-article', component: () => import("@/pages/article/BatchEditArticlePage.vue")},
{path: '/test', component: () => import("@/pages/test/test.vue")},
{path: '/:pathMatch(.*)*', redirect: '/word'},
{path: '/:pathMatch(.*)*', redirect: '/words'},
]
const router = VueRouter.createRouter({
@@ -58,8 +59,30 @@ const router = VueRouter.createRouter({
},
})
router.beforeEach((to: any, from: any) => {
// 路由守卫
router.beforeEach(async (to: any, from: any) => {
return true
// const userStore = useAuthStore()
//
// // 公共路由,不需要登录验证
// const publicRoutes = ['/login', '/wechat/callback', '/user-agreement', '/privacy-policy']
//
// // 如果目标路由是公共路由,直接放行
// if (publicRoutes.includes(to.path)) {
// return true
// }
//
// // 如果用户未登录,跳转到登录页
// if (!userStore.isLoggedIn) {
// // 尝试初始化认证状态
// const isInitialized = await userStore.initAuth()
// if (!isInitialized) {
// return {path: '/login', query: {redirect: to.fullPath}}
// }
// }
//
// return true
// console.log('beforeEach-to',to.path)
// console.log('beforeEach-from',from.path)
// const runtimeStore = useRuntimeStore()

78
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
import {defineStore} from 'pinia'
import {ref} from 'vue'
import {getUserInfo, User} from '@/apis/user.ts'
import {AppEnv} from "@/config/env.ts";
import Toast from "@/components/base/toast/Toast.ts";
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const isLogin = ref<boolean>(false)
// 设置token
const setToken = (newToken: string) => {
isLogin.value = true
AppEnv.TOKEN = newToken
AppEnv.IS_LOGIN = !!AppEnv.TOKEN
AppEnv.CAN_REQUEST = AppEnv.IS_LOGIN && AppEnv.IS_OFFICIAL
localStorage.setItem('token', newToken)
}
// 清除token
const clearToken = () => {
AppEnv.IS_LOGIN = AppEnv.CAN_REQUEST = false
AppEnv.TOKEN = ''
localStorage.removeItem('token')
isLogin.value = false
user.value = null
}
// 设置用户信息
const setUser = (userInfo: User) => {
user.value = userInfo
isLogin.value = true
}
// 登出
function logout() {
clearToken()
Toast.success('已退出登录')
//这行会引起hrm失效
// router.push('/')
}
// 获取用户信息
async function fetchUserInfo() {
if (!AppEnv.CAN_REQUEST) return false
try {
const res = await getUserInfo()
if (res.success) {
setUser(res.data)
return true
}
return false
} catch (error) {
console.error('Get user info error:', error)
return false
}
}
// 初始化用户状态
async function init() {
const success = await fetchUserInfo()
if (!success) {
clearToken()
}
}
return {
user,
isLogin,
setToken,
clearToken,
setUser,
logout,
fetchUserInfo,
init
}
})

View File

@@ -4,7 +4,7 @@ import { _getStudyProgress, checkAndUpgradeSaveDict, shakeCommonDict } from "@/u
import { shallowReactive } from "vue";
import { getDefaultDict } from "@/types/func.ts";
import { get, set } from 'idb-keyval'
import { CAN_REQUEST, IS_LOGIN, IS_OFFICIAL, SAVE_DICT_KEY } from "@/config/env.ts";
import { AppEnv, SAVE_DICT_KEY } from "@/config/env.ts";
import { add2MyDict, dictListVersion, myDictList } from "@/apis";
import Toast from "@/components/base/toast/Toast.ts";
@@ -22,7 +22,7 @@ export interface BaseState {
dictListVersion: number
}
export const DefaultBaseState = (): BaseState => ({
export const getDefaultBaseState = (): BaseState => ({
simpleWords: [
'a', 'an',
'i', 'my', 'me', 'you', 'your', 'he', 'his', 'she', 'her', 'it',
@@ -51,7 +51,7 @@ export const DefaultBaseState = (): BaseState => ({
export const useBaseStore = defineStore('base', {
state: (): BaseState => {
return DefaultBaseState()
return getDefaultBaseState()
},
getters: {
collectWord(): Dict {
@@ -125,13 +125,13 @@ export const useBaseStore = defineStore('base', {
try {
let configStr: string = await get(SAVE_DICT_KEY.key)
let data = checkAndUpgradeSaveDict(configStr)
if (IS_OFFICIAL) {
if (AppEnv.IS_OFFICIAL) {
let r = await dictListVersion()
if (r.success) {
data.dictListVersion = r.data
}
}
if (CAN_REQUEST) {
if (AppEnv.CAN_REQUEST) {
let res = await myDictList()
if (res.success) {
Object.assign(data, res.data)
@@ -147,7 +147,7 @@ export const useBaseStore = defineStore('base', {
},
//改变词典
async changeDict(val: Dict) {
if (CAN_REQUEST) {
if (AppEnv.CAN_REQUEST) {
let r = await add2MyDict(val)
if (!r.success) {
return Toast.error(r.msg)
@@ -175,7 +175,7 @@ export const useBaseStore = defineStore('base', {
},
//改变书籍
async changeBook(val: Dict) {
if (CAN_REQUEST) {
if (AppEnv.CAN_REQUEST) {
let r = await add2MyDict(val)
if (!r.success) {
return Toast.error(r.msg)

View File

@@ -5,14 +5,11 @@ export interface PracticeState {
startDate: number,
spend: number,
total: number,
index: number,//当前输入的第几个用于和total计算进度
newWordNumber: number,
reviewWordNumber: number,
writeWordNumber: number,
inputWordNumber: number,//当前总输入了多少个单词(不包含跳过)
wrong: number,
startIndex: number,
endIndex: number,
}
export const usePracticeStore = defineStore('practice', {
@@ -22,9 +19,6 @@ export const usePracticeStore = defineStore('practice', {
spend: 0,
startDate: Date.now(),
total: 0,
index: 0,
startIndex: 0,
endIndex: 0,
newWordNumber: 0,
reviewWordNumber: 0,
writeWordNumber: 0,

View File

@@ -1,8 +1,8 @@
import { defineStore } from "pinia"
import { checkAndUpgradeSaveSetting, cloneDeep } from "@/utils";
import {DefaultShortcutKeyMap, WordPracticeMode, WordPracticeType} from "@/types/types.ts";
import { DefaultShortcutKeyMap, WordPracticeMode, WordPracticeType } from "@/types/types.ts";
import { get } from "idb-keyval";
import { CAN_REQUEST, SAVE_SETTING_KEY } from "@/config/env.ts";
import { AppEnv, SAVE_SETTING_KEY } from "@/config/env.ts";
import { getSetting } from "@/apis";
export interface SettingState {
@@ -53,6 +53,7 @@ export interface SettingState {
disableShowPracticeSettingDialog: boolean // 不默认显示练习设置弹框
autoNextWord: boolean //自动切换下一个单词
inputWrongClear: boolean //单词输入错误,清空已输入内容
ignoreSymbol: boolean //过滤符号
}
export const getDefaultSettingState = (): SettingState => ({
@@ -69,7 +70,7 @@ export const getDefaultSettingState = (): SettingState => ({
keyboardSound: true,
keyboardSoundVolume: 100,
keyboardSoundFile: '机械键盘2',
keyboardSoundFile: '笔记本键盘',
effectSound: true,
effectSoundVolume: 100,
@@ -103,6 +104,7 @@ export const getDefaultSettingState = (): SettingState => ({
disableShowPracticeSettingDialog: false,
autoNextWord: true,
inputWrongClear: false,
ignoreSymbol: true
})
export const useSettingStore = defineStore('setting', {
@@ -115,15 +117,9 @@ export const useSettingStore = defineStore('setting', {
},
init() {
return new Promise(async resolve => {
//TODO 后面记得删除了
let configStr = localStorage.getItem(SAVE_SETTING_KEY.key)
let configStr2 = await get(SAVE_SETTING_KEY.key)
if (configStr2) {
//兼容localStorage.getItem
configStr = configStr2
}
let configStr = await get(SAVE_SETTING_KEY.key)
let data = checkAndUpgradeSaveSetting(configStr)
if (CAN_REQUEST) {
if (AppEnv.CAN_REQUEST) {
let res = await getSetting()
if (res.success) {
Object.assign(data, res.data)

View File

@@ -200,6 +200,7 @@ export interface TaskWords {
new: Word[],
review: Word[],
write: Word[],
shuffle: Word[],
}
export class DictId {
@@ -228,4 +229,13 @@ export enum WordPracticeType {
Identify,
Listen,
Dictation
}
}
export enum CodeType {
Login = 0,
Register = 1,
ResetPwd = 2,
ChangeEmail = 3,
ChangePhoneNew = 4,
ChangePhoneOld = 5
}

View File

@@ -1,6 +1,7 @@
import axios, { AxiosInstance } from 'axios'
import { ENV } from "@/config/env.ts";
import axios, {AxiosInstance} from 'axios'
import {AppEnv, ENV} from "@/config/env.ts";
import Toast from "@/components/base/toast/Toast.ts";
import App from "@/App.vue";
export const axiosInstance: AxiosInstance = axios.create({
baseURL: ENV.API,
@@ -9,10 +10,7 @@ export const axiosInstance: AxiosInstance = axios.create({
axiosInstance.interceptors.request.use(
(config) => {
// console.log('config', config)
// if (config.url === 'https://api.fanyi.baidu.com/api/trans/vip/translate') {
// config.url = '/baidu'
// }
if (AppEnv.CAN_REQUEST) config.headers.token = AppEnv.TOKEN
return config
},
error => Promise.reject(error),

View File

@@ -1,11 +1,10 @@
import {BaseState, DefaultBaseState, useBaseStore} from "@/stores/base.ts";
import {BaseState, getDefaultBaseState, useBaseStore} from "@/stores/base.ts";
import {getDefaultSettingState, SettingState} from "@/stores/setting.ts";
import {Dict, DictId, DictResource, DictType} from "@/types/types.ts";
import {useRouter} from "vue-router";
import {useRuntimeStore} from "@/stores/runtime.ts";
import dayjs from 'dayjs'
import axios from "axios";
import {ENV, IS_OFFICIAL, RESOURCE_PATH, SAVE_DICT_KEY, SAVE_SETTING_KEY} from "@/config/env.ts";
import {AppEnv, RESOURCE_PATH, SAVE_DICT_KEY, SAVE_SETTING_KEY} from "@/config/env.ts";
import {nextTick} from "vue";
import Toast from '@/components/base/toast/Toast.ts'
import {getDefaultDict, getDefaultWord} from "@/types/func.ts";
@@ -29,7 +28,7 @@ export function checkAndUpgradeSaveDict(val: any) {
// console.log(configStr)
// console.log('s', new Blob([val]).size)
// val = ''
let defaultState = DefaultBaseState()
let defaultState = getDefaultBaseState()
if (val) {
try {
let data: any
@@ -138,10 +137,10 @@ export function useNav() {
router.push({path, query})
}
return {nav, back: router.back}
return {nav, push: nav, back: router.back}
}
export function _dateFormat(val: any, format?: string): string {
export function _dateFormat(val: any, format: string = 'YYYY/MM/DD HH:mm'): string {
if (!val) return
if (String(val).length === 10) {
val = val * 1000
@@ -243,7 +242,7 @@ export function convertToWord(raw: any) {
// 1. trans
const trans = safeSplit(raw.trans, '\n').map(line => {
const match = line.match(/^([^\s.]+\.?)\s*(.*)$/);
const match = safeString(line).match(/^([^\s.]+\.?)\s*(.*)$/);
if (match) {
let pos = safeString(match[1]);
let cn = safeString(match[2]);
@@ -440,7 +439,7 @@ export function total(arr, key) {
}
export function resourceWrap(resource: string, version?: number) {
if (IS_OFFICIAL) {
if (AppEnv.IS_OFFICIAL) {
if (resource.includes('.json')) resource = resource.replace('.json', '');
if (!resource.includes('http')) resource = RESOURCE_PATH + resource
if (version === undefined) {
@@ -450,4 +449,13 @@ export function resourceWrap(resource: string, version?: number) {
return `${resource}_v${version}.json`
}
return resource;
}
// check if it is a new user
export async function isNewUser() {
let isNew = false
let base = useBaseStore()
console.log(JSON.stringify(base.$state))
console.log(JSON.stringify(getDefaultBaseState()))
return JSON.stringify(base.$state) === JSON.stringify({...getDefaultBaseState(), ...{load: true}})
}

54
src/utils/validation.ts Normal file
View File

@@ -0,0 +1,54 @@
// 邮箱验证
import {EMAIL_CONFIG, PASSWORD_CONFIG, PHONE_CONFIG} from "@/config/auth.ts";
export const validateEmail = (email: string): boolean => {
return EMAIL_CONFIG.emailRegex.test(email)
}
// 手机号验证(中国大陆)
export const validatePhone = (phone: string): boolean => {
return PHONE_CONFIG.phoneRegex.test(phone)
}
export const codeRules = [
{required: true, message: '请输入验证码', trigger: 'blur'},
{min: PHONE_CONFIG.codeLength, message: `请输入 ${PHONE_CONFIG.codeLength} 位验证码`, trigger: 'blur'},
]
export const accountRules = [
{required: true, message: '请输入手机号/邮箱地址', trigger: 'blur'},
{
validator: (rule: any, value: any) => {
if (!validatePhone(value) && !validateEmail(value)) {
throw new Error('请输入有效的手机号或邮箱地址')
}
}, trigger: 'blur'
},
]
export const emailRules = [
{required: true, message: '请输入邮箱地址', trigger: 'blur'},
{
validator: (rule: any, value: any) => {
if (!validateEmail(value)) {
throw new Error('请输入有效的邮箱地址')
}
}, trigger: 'blur'
},
]
export const phoneRules = [
{required: true, message: '请输入手机号', trigger: 'blur'},
{
validator: (rule: any, value: any) => {
if (!validatePhone(value)) {
throw new Error('请输入有效的手机号')
}
}, trigger: 'blur'
},
]
export const passwordRules = [
{required: true, message: '请输入密码', trigger: 'blur'},
{
min: PASSWORD_CONFIG.minLength,
max: PASSWORD_CONFIG.maxLength,
message: `密码长度为 ${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength}`,
trigger: 'blur'
},
]

View File

@@ -6,9 +6,13 @@ export default defineConfig({
'bg-primary': 'bg-[var(--color-primary)]',
'bg-second': 'bg-[var(--color-second)]',
'bg-third': 'bg-[var(--color-third)]',
'bg-fourth': 'bg-[var(--color-fourth)]',
'bg-card-active': 'bg-[var(--color-card-active)]',
'bg-item': 'bg-[var(--color-item-bg)]',
'bg-reverse-white': 'bg-[var(--color-reverse-white)]',
'bg-reverse-black': 'bg-[var(--color-reverse-black)]',
'color-main': 'color-[var(--color-main-text)]',
'color-link': 'color-[var(--color-link)]',
'gap-space': 'gap-[var(--space)]',
'p-space': 'p-[var(--space)]',
'px-space': 'px-[var(--space)]',

View File

@@ -1,23 +1,24 @@
import { defineConfig } from 'vite'
import {defineConfig} from 'vite'
import Vue from '@vitejs/plugin-vue'
import VueJsx from "@vitejs/plugin-vue-jsx";
import { resolve } from 'path'
import { visualizer } from "rollup-plugin-visualizer";
import {resolve} from 'path'
import {visualizer} from "rollup-plugin-visualizer";
import SlidePlugin from './src/components/slide/data.js';
import { getLastCommit } from "git-last-commit";
import {getLastCommit} from "git-last-commit";
import UnoCSS from 'unocss/vite'
import VueMacros from 'unplugin-vue-macros/vite'
import Icons from 'unplugin-icons/vite'
import Components from 'unplugin-vue-components/vite'
import IconsResolver from 'unplugin-icons/resolver'
import { viteExternalsPlugin } from 'vite-plugin-externals'
import {viteExternalsPlugin} from 'vite-plugin-externals'
function pathResolve(dir: string) {
return resolve(__dirname, ".", dir)
}
const lifecycle = process.env.npm_lifecycle_event;
let isCdnBuild = ['build', 'report'].includes(lifecycle)
let isCdnBuild = ['build-oss', 'report-oss'].includes(lifecycle)
let isAnalyseBuild = ['report-oss', 'report'].includes(lifecycle)
// https://vitejs.dev/config/
export default defineConfig(() => {
@@ -47,7 +48,7 @@ export default defineConfig(() => {
},
}),
UnoCSS(),
lifecycle === 'report' ?
isAnalyseBuild ?
visualizer({
gzipSize: true,
brotliSize: true,
@@ -125,9 +126,6 @@ export default defineConfig(() => {
port: 3000,
open: false,
host: '0.0.0.0',
fs: {
strict: false,
},
proxy: {
'/baidu': 'https://api.fanyi.baidu.com/api/trans/vip/translate'
}