Merge remote-tracking branch 'upstream/master'
This commit is contained in:
2
.github/workflows/deploy-aliyun-oss.yml
vendored
2
.github/workflows/deploy-aliyun-oss.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/deploy-pages.yml
vendored
2
.github/workflows/deploy-pages.yml
vendored
@@ -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
|
||||
|
||||
5
Note.md
5
Note.md
@@ -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页面太墨迹,不简洁,进度复杂,本周学习记录改成日历,有个标记,+激励分享功能,满足炫耀欲望
|
||||
|
||||
18
README.md
18
README.md
@@ -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
27
components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
84
index.html
84
index.html
@@ -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>
|
||||
|
||||
@@ -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
1707
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
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 |
@@ -1896,9 +1896,6 @@
|
||||
"tags": [
|
||||
"通用"
|
||||
],
|
||||
"words": [
|
||||
"private","fuck","add","remove"
|
||||
],
|
||||
"url": "GaoKaoZhenTiHeXinGaoPin.json",
|
||||
"length": 799,
|
||||
"language": "en",
|
||||
|
||||
86
public/migrate.html
Normal file
86
public/migrate.html
Normal 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
100
public/privacy-policy.html
Normal 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
BIN
public/qq.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
@@ -1,4 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
||||
Sitemap: https://2study.top/sitemap.xml
|
||||
Sitemap: https://typewords.cc/sitemap.xml
|
||||
|
||||
@@ -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
569
public/static-home.html
Normal 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>
|
||||
83
public/user-agreement.html
Normal file
83
public/user-agreement.html
Normal 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 |
BIN
public/word.png
BIN
public/word.png
Binary file not shown.
|
Before Width: | Height: | Size: 166 KiB |
@@ -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
50
scripts/do.js
Normal 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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
95
src/App.vue
95
src/App.vue
@@ -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>
|
||||
@@ -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
69
src/apis/member.ts
Normal 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
116
src/apis/user.ts
Normal 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')
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
@keyframes shake {
|
||||
10%,
|
||||
90% {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
29
src/components/Header.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} </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>
|
||||
|
||||
65
src/components/base/form/types.ts
Normal file
65
src/components/base/form/types.ts
Normal 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'
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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
52
src/config/auth.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
19
src/main.ts
19
src/main.ts
@@ -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
115
src/pages/MigrateDialog.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}}. {{ _dateFormat(item.startDate, 'YYYY/MM/DD HH:mm') }}</span>
|
||||
}}. {{ _dateFormat(item.startDate) }}</span>
|
||||
<span>{{ msToHourMinute(item.spend) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
内置小学、初中、高中、四六级、考研、雅思、托福、GRE、GMAT、SAT、BEC、专四、专八等词库
|
||||
</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>
|
||||
@@ -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()
|
||||
|
||||
//首页为了seo被剥离出去了,现在是一个静态页面,用nginx 重定向控制对应的跳转
|
||||
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/>
|
||||
@@ -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
66
src/pages/user/Code.vue
Normal 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
15
src/pages/user/Notice.vue
Normal 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
633
src/pages/user/User.vue
Normal 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
518
src/pages/user/VipIntro.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
69
src/pages/word/components/ShufflePracticeSettingDialog.vue
Normal file
69
src/pages/word/components/ShufflePracticeSettingDialog.vue
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
78
src/stores/auth.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
54
src/utils/validation.ts
Normal 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'
|
||||
},
|
||||
]
|
||||
@@ -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)]',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user