Merge branch 'master' into dev
# Conflicts: # components.d.ts
46
.github/workflows/deploy-aliyun-oss.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Deploy to Aliyun OSS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'master' ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build-path: dist
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Deploy to OSS + Refresh CDN
|
||||
run: pnpm run deploy-oss
|
||||
env:
|
||||
OSS_KEY_ID: ${{ secrets.OSS_KEY_ID }}
|
||||
OSS_KEY_SECRET: ${{ secrets.OSS_KEY_SECRET }}
|
||||
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
|
||||
OSS_REGION: ${{ secrets.OSS_REGION }}
|
||||
CDN_DOMAIN: ${{ secrets.CDN_DOMAIN }}
|
||||
28
.github/workflows/deploy-pages.yml
vendored
@@ -1,32 +1,20 @@
|
||||
# 将静态内容部署到 GitHub Pages 的简易工作流程
|
||||
name: Deploy static content to Pages
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
# 仅在推送到默认分支时运行。
|
||||
push:
|
||||
branches: [ 'master' ]
|
||||
|
||||
# 这个选项可以使你手动在 Action tab 页面触发工作流
|
||||
workflow_dispatch:
|
||||
|
||||
# 设置 GITHUB_TOKEN 的权限,以允许部署到 GitHub Pages。
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# 允许一个并发的部署
|
||||
concurrency:
|
||||
group: 'pages'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# 单次部署的工作描述
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build-path: dist
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -39,17 +27,14 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
run: pnpm run build-nocdn
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
@@ -60,4 +45,3 @@ jobs:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
|
||||
40
README.md
@@ -3,7 +3,7 @@
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
可在网页上使用的背单词软件
|
||||
一个可以在网页上背单词、背文章的网站
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -17,27 +17,28 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
## 📸 在线访问
|
||||

|
||||

|
||||
|
||||
Github Pages: <https://2study.top>
|
||||
## 在线访问
|
||||
|
||||
## 🛠 功能列表
|
||||
中国: <https://2study.top>
|
||||
其他: <https://vercel.2study.top> or <https://tw.2study.top>
|
||||
|
||||
## 功能列表
|
||||
|
||||
### 背单词
|
||||
可以选择记忆或默写单词,提供了音标显示、发音功能(均可选美音、英音)、错误统计
|
||||
根据记忆曲线自动计算学习单词,并通过默写加深记忆;提供了音标、发音(美音、英音)、例句、短语、近义词、同根词、词源、错误统计等功能
|
||||
|
||||
### 背文章
|
||||
内置经典教材书籍,可以练习和背诵文章,逐句输入,自动发音。也可以自行添加、导入文章,提供一键翻译、译文对照功能
|
||||
内置经典教材书籍,练习和背诵文章,逐句输入,自动发音。可以自行添加、导入文章,提供一键翻译、译文对照功能
|
||||
|
||||
### 生词本、错词本、已掌握
|
||||
默写单词时输入错误会自动添加到错词本,以便后续复习。也可以添加到已掌握,之后再遇到这个词便会自动跳过,同时也可以将其添加到生词本中,以便巩固复习
|
||||
|
||||
### 默写模式
|
||||
在用户完成一个章节的练习后,如果有错误词,那么会重复练习错误词,直到没有错误词为止。完成之后弹出选项可选择默写本章、重复本章、下一章
|
||||
### 收藏、错词本、已掌握
|
||||
学习单词时输入错误会自动添加到错词本,方便后续复习。也可以添加到已掌握,以后再遇到这个词会自动跳过,同时也可以将其添加到收藏中,以便巩固复习
|
||||
|
||||
### 词库
|
||||
内置了常用的 CET-4 、CET-6 、GMAT 、GRE 、IELTS 、SAT 、TOEFL 、考研英语、专业四级英语、专业八级英语,也有程序员常见英语单词以及多种编程语言
|
||||
API 等词库。 尽可能满足大部分用户对背单词的需求,也非常欢迎社区贡献更多的词库。
|
||||
内置了常用的 CET-4 、CET-6 、GMAT 、GRE 、IELTS 、SAT 、TOEFL 、考研英语、专业四级英语、专业八级英语等词库。 尽可能满足大部分用户对背单词的需求,也非常欢迎社区贡献更多的词库。
|
||||
|
||||
|
||||
## 运行项目
|
||||
|
||||
@@ -46,21 +47,12 @@ API 等词库。 尽可能满足大部分用户对背单词的需求,也非常
|
||||
### 手动安装
|
||||
|
||||
1. 安装 NodeJS,参考[官方文档](https://nodejs.org/en/download)
|
||||
2. 使用 `git clone` 下载项目到本地, 不使用 git 可能因为缺少依赖而无法运行
|
||||
2. 本项目只能使用 `git clone` 命令下载项目到本地,直接下载 Github 提供 Download ZIP 功能是无法运行的
|
||||
3. 打开命令行,在项目根目录下,运行`npm install`来下载依赖。
|
||||
4. 执行`npm start`来启动项目,项目默认地址为[`http://localhost:3000`](http://localhost:3000)
|
||||
5. 在浏览器中打开[`http://localhost:3000`](http://localhost:3000) 来访问项目。
|
||||
|
||||
## 📕 词库列表
|
||||
|
||||
- CET-4、CET-6、GMAT、GRE、IELTS、SAT、TOEFL、BEC
|
||||
- 考研英语、专业四级英语、专业八级英语、商务英语
|
||||
- Coder Dict 程序员常用词
|
||||
- 高考、中考、人教版英语 3-9 年级
|
||||
- 王陆雅思王听力语料库
|
||||
- 日语常见词、N1 ~ N5
|
||||
|
||||
## 🎙 功能与建议
|
||||
## 功能与建议
|
||||
|
||||
目前项目处于开发初期,新功能正在持续添加中,如果你对软件有任何功能与建议,欢迎在 Issues 中提出
|
||||
如果你也喜欢本软件的设计思想,欢迎提交 pr,非常感谢你对我们的支持!
|
||||
|
||||
4
components.d.ts
vendored
@@ -8,13 +8,11 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
BackIcon: typeof import('./src/components/icon/BackIcon.vue')['default']
|
||||
BaseButton: typeof import('./src/components/BaseButton.vue')['default']
|
||||
BaseIcon: typeof import('./src/components/BaseIcon.vue')['default']
|
||||
Close: typeof import('./src/components/icon/Close.vue')['default']
|
||||
DeleteIcon: typeof import('./src/components/icon/DeleteIcon.vue')['default']
|
||||
Empty: typeof import('./src/components/Empty.vue')['default']
|
||||
HostNotice: typeof import('./src/components/HostNotice.vue')['default']
|
||||
IconBasilAddOutline: typeof import('~icons/basil/add-outline')['default']
|
||||
IconBasilEditOutline: typeof import('~icons/basil/edit-outline')['default']
|
||||
IconBiArrowLeft: typeof import('~icons/bi/arrow-left')['default']
|
||||
@@ -25,9 +23,11 @@ declare module 'vue' {
|
||||
IconBxVolumeFull: typeof import('~icons/bx/volume-full')['default']
|
||||
IconBxVolumeLow: typeof import('~icons/bx/volume-low')['default']
|
||||
IconCarbonCloseOutline: typeof import('~icons/carbon/close-outline')['default']
|
||||
IconCarbonMove: typeof import('~icons/carbon/move')['default']
|
||||
IconEosIconsLoading: typeof import('~icons/eos-icons/loading')['default']
|
||||
IconEpMoon: typeof import('~icons/ep/moon')['default']
|
||||
IconFluentAdd20Filled: typeof import('~icons/fluent/add20-filled')['default']
|
||||
IconFluentDelete24Regular: typeof import('~icons/fluent/delete24-regular')['default']
|
||||
IconFluentReplay16Filled: typeof import('~icons/fluent/replay16-filled')['default']
|
||||
IconFluentSearch24Regular: typeof import('~icons/fluent/search24-regular')['default']
|
||||
IconFormkitLeft: typeof import('~icons/formkit/left')['default']
|
||||
|
||||
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
version: "2"
|
||||
services:
|
||||
typeword:
|
||||
image: "node:latest"
|
||||
#environment: #按需配置,主要为了科学上网解决依赖安装网络问题
|
||||
# - HTTP_PROXY=http://127.0.0.1:80
|
||||
# HTTPS_PROXY=http://127.0.0.1:80
|
||||
working_dir: /home/node/app
|
||||
volumes:#将代码目录直接映射到容器,节省打包拷贝时间
|
||||
- ./:/home/node/app
|
||||
expose:
|
||||
- "3000"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- |
|
||||
npm install
|
||||
npm start
|
||||
BIN
docs/article.png
Normal file
|
After Width: | Height: | Size: 960 KiB |
BIN
docs/word.png
Normal file
|
After Width: | Height: | Size: 819 KiB |
86
index.html
@@ -1,33 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.jpg"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Type Words</title>
|
||||
<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')
|
||||
&& !location.href.includes('172.16')
|
||||
&& !location.href.includes('10.0')
|
||||
) {
|
||||
var _hmt = _hmt || [];
|
||||
(function () {
|
||||
var hm = document.createElement("script");
|
||||
hm.src = "https://hm.baidu.com/hm.js?3dae52fcd5375a19905462e4ad3eb54e";
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(hm, s);
|
||||
<meta charset="UTF-8"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.png"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Type Words - 英语打字练习平台 | 单词跟打 · 文章跟打</title>
|
||||
<!-- 搜索引擎描述 -->
|
||||
<meta name="description"
|
||||
content="Type Words:在线英语练习平台,支持单词、文章跟打练习,提升打字与语言能力。Practice English, one keystroke at a time.">
|
||||
<!-- 关键词(可选,搜索引擎基本不用,但能补充信息) -->
|
||||
<meta name="keywords"
|
||||
content="Type Words, Typing Word, 英语打字练习, 单词跟打, 文章跟打, 键盘练习, 英语学习, 文章学习">
|
||||
|
||||
<!-- Open Graph(用于社交媒体分享,微信/QQ/知乎/Facebook 等) -->
|
||||
<meta property="og:title" content="Type Words - 英语打字练习平台">
|
||||
<meta property="og:description"
|
||||
content="在线英语打字练习平台,支持单词跟打与文章跟打,帮助提升打字速度与英语学习效率。">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://2study.top/">
|
||||
<meta property="og:image" content="https://2study.top/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:在线英语练习平台,支持单词跟打、文章练习,提升打字速度与英语水平。">
|
||||
<meta name="twitter:image" content="https://2study.top/favicon.png">
|
||||
|
||||
<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>
|
||||
<script>
|
||||
if (!location.href.includes('localhost')
|
||||
&& !location.href.includes('192.168')
|
||||
&& !location.href.includes('172.16')
|
||||
&& !location.href.includes('10.0')
|
||||
) {
|
||||
var _hmt = _hmt || [];
|
||||
(function () {
|
||||
var hm = document.createElement("script");
|
||||
hm.src = "https://hm.baidu.com/hm.js?3dae52fcd5375a19905462e4ad3eb54e";
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(hm, s);
|
||||
})();
|
||||
|
||||
(function () {
|
||||
var umami = document.createElement("script");
|
||||
umami.src = './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);
|
||||
})();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
62
package.json
@@ -3,52 +3,80 @@
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"test": "",
|
||||
"build": "vite build",
|
||||
"build": "vite build && node scripts/generate-sitemap.js",
|
||||
"build-nocdn": "vite build",
|
||||
"build-tsc": "vue-tsc && vite build",
|
||||
"report": "vite build",
|
||||
"preview": "vite preview",
|
||||
"commit": "git-cz",
|
||||
"prepare": "husky install",
|
||||
"i18n:write": "gulp i18nwrite"
|
||||
"i18n:write": "gulp i18nwrite",
|
||||
"deploy-oss": "node scripts/deploy-oss.js",
|
||||
"deploy-2": "node scripts/generate-sitemap.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@imengyu/vue3-context-menu": "^1.5.1",
|
||||
"@opentranslate/baidu": "^1.4.2",
|
||||
"@opentranslate/translator": "^1.4.2",
|
||||
"axios": "^1.10.0",
|
||||
"compromise": "^14.14.4",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"element-plus": "^2.10.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"git-last-commit": "^1.0.1",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"libarchive-wasm": "^1.2.0",
|
||||
"localforage": "^1.10.0",
|
||||
"md5": "^2.2.1",
|
||||
"mitt": "^3.0.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"pinia": "^3.0.3",
|
||||
"sentence-splitter": "^4.4.1",
|
||||
"string-comparison": "^1.3.0",
|
||||
"tesseract.js": "^4.1.4",
|
||||
"unplugin-element-plus": "^0.10.0",
|
||||
"vue": "^3.5.17",
|
||||
"vue-activity-calendar": "^1.2.2",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-virtual-scroller": "2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@alicloud/pop-core": "^1.8.0",
|
||||
"@iconify-json/basil": "^1.2.4",
|
||||
"@iconify-json/bi": "^1.2.6",
|
||||
"@iconify-json/bx": "^1.2.2",
|
||||
"@iconify-json/carbon": "^1.2.13",
|
||||
"@iconify-json/eos-icons": "^1.2.4",
|
||||
"@iconify-json/ep": "^1.2.3",
|
||||
"@iconify-json/fluent": "^1.2.28",
|
||||
"@iconify-json/formkit": "^1.2.2",
|
||||
"@iconify-json/gg": "^1.2.2",
|
||||
"@iconify-json/hugeicons": "^1.2.10",
|
||||
"@iconify-json/ic": "^1.2.4",
|
||||
"@iconify-json/icon-park-outline": "^1.2.4",
|
||||
"@iconify-json/iconamoon": "^1.2.2",
|
||||
"@iconify-json/icons8": "^1.2.1",
|
||||
"@iconify-json/ion": "^1.2.6",
|
||||
"@iconify-json/majesticons": "^1.2.4",
|
||||
"@iconify-json/material-symbols": "^1.2.32",
|
||||
"@iconify-json/material-symbols-light": "^1.2.32",
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
"@iconify-json/mingcute": "^1.2.5",
|
||||
"@iconify-json/ph": "^1.2.2",
|
||||
"@iconify-json/ri": "^1.2.5",
|
||||
"@iconify-json/solar": "^1.2.4",
|
||||
"@iconify-json/tabler": "^1.2.22",
|
||||
"@iconify-json/tdesign": "^1.2.8",
|
||||
"@iconify-json/twemoji": "^1.2.4",
|
||||
"@iconify-json/typcn": "^1.2.2",
|
||||
"@iconify-json/uil": "^1.2.3",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/md5": "^2.1.33",
|
||||
"@unocss/postcss": "^66.4.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vitejs/plugin-vue-jsx": "^5.0.1",
|
||||
"@vue/compiler-sfc": "^3.5.17",
|
||||
"ali-oss": "^6.23.0",
|
||||
"commitizen": "^4.3.1",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"esm": "^3.2.25",
|
||||
"git-last-commit": "^1.0.1",
|
||||
"gulp": "^4.0.2",
|
||||
"husky": "^8.0.3",
|
||||
"rollup-plugin-visualizer": "^5.14.0",
|
||||
@@ -56,16 +84,18 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.8.3",
|
||||
"unocss": "^66.4.0",
|
||||
"unplugin-icons": "^22.2.0",
|
||||
"unplugin-vue-components": "^29.0.0",
|
||||
"unplugin-vue-macros": "^2.14.5",
|
||||
"vite": "^7.0.3",
|
||||
"vite-plugin-cdn-import": "^1.0.1",
|
||||
"vite-plugin-externals": "^0.6.2",
|
||||
"vue-tsc": "^3.0.1",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "^0.18.5",
|
||||
"sitemap": "^8.0.0"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "./node_modules/cz-conventional-changelog"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@9.1.4+sha512.9df9cf27c91715646c7d675d1c9c8e41f6fce88246f1318c1aa6a1ed1aeb3c4f032fcdf4ba63cc69c4fe6d634279176b5358727d8f2cc1e65b65f43ce2f8bfb0"
|
||||
}
|
||||
}
|
||||
|
||||
3088
pnpm-lock.yaml
generated
@@ -676,8 +676,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"id": "F4wm63",
|
||||
"title": "A wet night",
|
||||
|
||||
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
public/logo.jpg
|
Before Width: | Height: | Size: 24 KiB |
1
public/s.js
Normal file
@@ -0,0 +1 @@
|
||||
!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)}();
|
||||
140
scripts/deploy-oss.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import OSS from 'ali-oss'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import Core from '@alicloud/pop-core'
|
||||
|
||||
const {
|
||||
OSS_REGION,
|
||||
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) {
|
||||
console.error('❌ 缺少必要的环境变量,请检查 GitHub Secrets 配置')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const client = new OSS({
|
||||
region: OSS_REGION,
|
||||
accessKeyId: OSS_KEY_ID,
|
||||
accessKeySecret: OSS_KEY_SECRET,
|
||||
bucket: OSS_BUCKET
|
||||
})
|
||||
|
||||
const cdnClient = new Core({
|
||||
accessKeyId: OSS_KEY_ID,
|
||||
accessKeySecret: OSS_KEY_SECRET,
|
||||
endpoint: 'https://cdn.aliyuncs.com',
|
||||
apiVersion: '2018-05-10'
|
||||
})
|
||||
|
||||
// 遍历 dist 目录,统计文件
|
||||
function getAllFiles(dir, fileList = []) {
|
||||
const files = fs.readdirSync(dir)
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file)
|
||||
const stat = fs.statSync(filePath)
|
||||
if (stat.isDirectory()) {
|
||||
getAllFiles(filePath, fileList)
|
||||
} else {
|
||||
fileList.push(filePath)
|
||||
}
|
||||
}
|
||||
return fileList
|
||||
}
|
||||
|
||||
// 上传文件,显示进度,可跳过指定目录
|
||||
/**
|
||||
* 上传文件并清理远端多余文件
|
||||
* @param files 本地文件完整路径列表
|
||||
* @param localBase 本地基准路径
|
||||
* @param ignoreDirs 相对 localBase 的目录名数组,上传时跳过,删除远端时保留
|
||||
*/
|
||||
async function uploadFilesWithClean(files, localBase = './dist', ignoreDirs = []) {
|
||||
// 1️⃣ 过滤掉忽略的目录
|
||||
const filteredFiles = files.filter(file => {
|
||||
const relativePath = path.relative(localBase, file)
|
||||
const topDir = relativePath.split(path.sep)[0]
|
||||
return !ignoreDirs.includes(topDir)
|
||||
})
|
||||
|
||||
// 2️⃣ 获取远端已有文件列表
|
||||
console.log('📄 获取远端文件列表...')
|
||||
let remoteFiles = []
|
||||
let marker = ''
|
||||
do {
|
||||
const result = await client.list({
|
||||
prefix: '',
|
||||
'max-keys': 1000,
|
||||
marker,
|
||||
})
|
||||
|
||||
if (result.objects) {
|
||||
remoteFiles.push(...result.objects.map(f => f.name))
|
||||
}
|
||||
|
||||
marker = result.nextMarker || ''
|
||||
} while (marker)
|
||||
|
||||
// 3️⃣ 上传文件
|
||||
const total = filteredFiles.length
|
||||
let count = 0
|
||||
const uploadedFiles = []
|
||||
|
||||
for (const file of filteredFiles) {
|
||||
const relativePath = path.relative(localBase, file)
|
||||
const remotePath = relativePath.split(path.sep).join('/') // POSIX 路径
|
||||
await client.put(remotePath, file)
|
||||
uploadedFiles.push(remotePath)
|
||||
count++
|
||||
const percent = ((count / total) * 100).toFixed(1)
|
||||
process.stdout.write(`\r📤 上传进度: ${count}/${total} (${percent}%) ${remotePath} `)
|
||||
}
|
||||
console.log('\n✅ 文件上传完成')
|
||||
|
||||
// 4️⃣ 删除远端多余文件(远端存在但本地未上传),同时保留 ignoreDirs
|
||||
const toDelete = remoteFiles.filter(f => {
|
||||
const topDir = f.split('/')[0]
|
||||
return !uploadedFiles.includes(f) && !ignoreDirs.includes(topDir)
|
||||
})
|
||||
|
||||
if (toDelete.length) {
|
||||
console.log('🗑 删除远端多余文件:', toDelete)
|
||||
// 分批删除,防止数量过多
|
||||
const batchSize = 1000
|
||||
for (let i = 0; i < toDelete.length; i += batchSize) {
|
||||
const batch = toDelete.slice(i, i + batchSize)
|
||||
await client.deleteMulti(batch)
|
||||
}
|
||||
console.log('✅ 多余文件删除完成')
|
||||
} else {
|
||||
console.log('ℹ️ 无需删除远端文件')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 刷新 CDN
|
||||
async function refreshCDN() {
|
||||
console.log('🔄 刷新 CDN 缓存...')
|
||||
const params = {
|
||||
ObjectPath: `https://${CDN_DOMAIN}/`,
|
||||
ObjectType: 'Directory'
|
||||
}
|
||||
const requestOption = {method: 'POST'}
|
||||
const result = await cdnClient.request('RefreshObjectCaches', params, requestOption)
|
||||
console.log('✅ CDN 刷新完成:', result)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files = getAllFiles('./dist')
|
||||
console.log(`📁 共找到 ${files.length} 个文件,开始上传...`)
|
||||
await uploadFilesWithClean(files, './dist', ['dicts', 'sound', 'libs'])
|
||||
await refreshCDN()
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('❌ 部署失败:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
42
scripts/generate-sitemap.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const {SitemapStream, streamToPromise} = require('sitemap')
|
||||
const {createWriteStream} = require('fs')
|
||||
const {resolve} = require('path')
|
||||
const bookList = require('../src/assets/book-list.json')
|
||||
const dictList = require('../src/assets/dict-list.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()
|
||||
26
src/App.vue
@@ -4,11 +4,11 @@ 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 * as localforage from "localforage";
|
||||
import CollectNotice from "@/pages/pc/components/CollectNotice.vue";
|
||||
import {SAVE_DICT_KEY, SAVE_SETTING_KEY} from "@/utils/const.ts";
|
||||
import {isMobile, shakeCommonDict} from "@/utils";
|
||||
import router, {routes} from "@/router.ts";
|
||||
import {shakeCommonDict} from "@/utils";
|
||||
import {routes} from "@/router.ts";
|
||||
import {set} from 'idb-keyval'
|
||||
|
||||
import {useRoute} from "vue-router";
|
||||
|
||||
@@ -18,32 +18,22 @@ const settingStore = useSettingStore()
|
||||
const {setTheme} = useTheme()
|
||||
|
||||
watch(store.$state, (n: BaseState) => {
|
||||
localforage.setItem(SAVE_DICT_KEY.key, JSON.stringify({val: shakeCommonDict(n), version: SAVE_DICT_KEY.version}))
|
||||
set(SAVE_DICT_KEY.key, JSON.stringify({val: shakeCommonDict(n), version: SAVE_DICT_KEY.version}))
|
||||
})
|
||||
|
||||
watch(settingStore.$state, (n) => {
|
||||
localStorage.setItem(SAVE_SETTING_KEY.key, JSON.stringify({val: n, version: SAVE_SETTING_KEY.version}))
|
||||
set(SAVE_SETTING_KEY.key, JSON.stringify({val: n, version: SAVE_SETTING_KEY.version}))
|
||||
})
|
||||
|
||||
async function init() {
|
||||
// console.time()
|
||||
store.init().then(() => {
|
||||
store.load = true
|
||||
// console.timeEnd()
|
||||
})
|
||||
await store.init()
|
||||
await settingStore.init()
|
||||
store.load = true
|
||||
setTheme(settingStore.theme)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
onMounted(init)
|
||||
|
||||
if (isMobile()) {
|
||||
// 当前设备是移动设备
|
||||
console.log('当前设备是移动设备')
|
||||
router.replace('/mobile')
|
||||
}
|
||||
})
|
||||
let transitionName = $ref('go')
|
||||
const route = useRoute()
|
||||
watch(() => route.path, (to, from) => {
|
||||
|
||||
43
src/assets/book-list.json
Normal file
@@ -0,0 +1,43 @@
|
||||
[
|
||||
[
|
||||
{
|
||||
"id": "article_nce2",
|
||||
"name": "新概念英语2-课文",
|
||||
"description": "新概念英语2-课文",
|
||||
"category": "文章学习",
|
||||
"tags": [
|
||||
"新概念英语"
|
||||
],
|
||||
"url": "NCE_2.json",
|
||||
"length": 96,
|
||||
"translateLanguage": "common",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"id": "article_nce3",
|
||||
"name": "新概念英语3-课文",
|
||||
"description": "新概念英语3-课文",
|
||||
"category": "文章学习",
|
||||
"tags": [
|
||||
"新概念英语"
|
||||
],
|
||||
"url": "NCE_3.json",
|
||||
"length": 3,
|
||||
"translateLanguage": "common",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"id": "article_nce4",
|
||||
"name": "新概念英语4-课文",
|
||||
"description": "新概念英语4-课文",
|
||||
"category": "文章学习",
|
||||
"tags": [
|
||||
"新概念英语"
|
||||
],
|
||||
"url": "NCE_4.json",
|
||||
"length": 1,
|
||||
"translateLanguage": "common",
|
||||
"language": "en"
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -17,15 +17,17 @@
|
||||
|
||||
--practice-wrapper-translateX: 1px;
|
||||
--article-width: 50vw;
|
||||
--article-toolbar-width: 50rem;
|
||||
--toolbar-width: 50rem;
|
||||
--panel-width: 24rem;
|
||||
--space: 1rem;
|
||||
--stat-gap: 2rem;
|
||||
--stat-gap: 1rem;
|
||||
--shadow: rgba(0, 0, 0, 0.08) 0px 4px 12px;
|
||||
--panel-margin-left: calc(50% + var(--toolbar-width) / 2 + 1rem);
|
||||
--article-panel-margin-left: calc(50% + var(--article-width) / 2 + 1rem);
|
||||
--anim-time: 0.3s;
|
||||
--anim-time: 0.5s;
|
||||
|
||||
--color-input-color: black;
|
||||
--color-input-bg: white;
|
||||
--color-input-border: #bfbfbf;
|
||||
--color-input-icon: #d3d4d7;
|
||||
@@ -60,12 +62,12 @@
|
||||
|
||||
|
||||
//修改element-ui的进度条底色
|
||||
--el-border-color-lighter: #d1d5df !important;
|
||||
--color-progress-bar: #d1d5df !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
&.hide {
|
||||
--el-border-color-lighter: #dbdbdb !important;
|
||||
--color-progress-bar: #dbdbdb !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,22 +99,23 @@ html.dark {
|
||||
|
||||
--btn-info: transparent;
|
||||
|
||||
--color-input-color:white;
|
||||
--color-input-bg: rgba(14, 18, 23, 1);
|
||||
--color-input-icon: #383737;
|
||||
|
||||
--color-textarea-bg: rgb(43, 45, 48);
|
||||
--color-article: white;
|
||||
|
||||
--el-border-color-lighter: rgb(73, 77, 82) !important;
|
||||
--color-progress-bar: rgb(73, 77, 82) !important;
|
||||
|
||||
.footer {
|
||||
&.hide {
|
||||
--el-border-color-lighter: var(--color-third) !important;
|
||||
--color-progress-bar: var(--color-third) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1680px) {
|
||||
@media (max-width: 1720px) {
|
||||
:root {
|
||||
--toolbar-width: 50vw;
|
||||
--article-width: 50vw;
|
||||
@@ -161,6 +164,7 @@ html, body {
|
||||
overflow-x: hidden;
|
||||
color: var(--color-main-text);
|
||||
font-family: var(--font-family);
|
||||
background: var(--color-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -406,8 +410,8 @@ a {
|
||||
background: var(--color-second);
|
||||
}
|
||||
|
||||
.center {
|
||||
@apply flex justify-center items-center;
|
||||
.inline-center {
|
||||
@apply inline-flex justify-center items-center;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -416,7 +420,10 @@ a {
|
||||
|
||||
.book {
|
||||
@extend .anim;
|
||||
@apply p-4 rounded-md relative cursor-pointer h-40 bg-third hover:bg-card-active flex flex-col justify-between;
|
||||
@apply p-4 rounded-md relative cursor-pointer bg-third hover:bg-card-active flex flex-col justify-between shrink-0;
|
||||
$w: 6rem;
|
||||
width: $w;
|
||||
height: calc($w * 1.4);
|
||||
}
|
||||
|
||||
.line {
|
||||
|
||||
3297
src/assets/dict-list.json
Normal file
@@ -1,460 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8"/>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.city {
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
bottom: 0px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.city img {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<title>放烟花模拟</title>
|
||||
</head>
|
||||
<body onselectstart="return false">
|
||||
<canvas id="cas" style="background-color: rgba(0, 5, 24, 1)">浏览器不支持canvas</canvas>
|
||||
<img src="moon.png" alt="" id="moon" style="visibility: hidden"/>
|
||||
<div style="display: none">
|
||||
<div class="shape">新年快乐</div>
|
||||
<div class="shape">合家幸福</div>
|
||||
<div class="shape">HAPPY</div>
|
||||
</div>
|
||||
<audio src="boom.mp3" preload="auto"></audio>
|
||||
<audio src="boom.mp3" preload="auto"></audio>
|
||||
<audio src="boom.mp3" preload="auto"></audio>
|
||||
<audio src="boom.mp3" preload="auto"></audio>
|
||||
<audio src="boom.mp3" preload="auto"></audio>
|
||||
<audio src="boom.mp3" preload="auto"></audio>
|
||||
<audio src="shotfire.mp3" preload="auto"></audio>
|
||||
<audio src="shotfire.mp3" preload="auto"></audio>
|
||||
<audio src="shotfire.mp3" preload="auto"></audio>
|
||||
<script>
|
||||
let canvas = document.getElementById("cas");
|
||||
let ocas = document.createElement("canvas");
|
||||
let octx = ocas.getContext("2d");
|
||||
let ctx = canvas.getContext("2d");
|
||||
ocas.width = canvas.width = window.innerWidth;
|
||||
ocas.height = canvas.height = window.innerHeight;
|
||||
let bigbooms = [];
|
||||
|
||||
window.onload = function () {
|
||||
initAnimate();
|
||||
};
|
||||
|
||||
function initAnimate() {
|
||||
drawBg();
|
||||
|
||||
lastTime = new Date();
|
||||
animate();
|
||||
}
|
||||
|
||||
let lastTime;
|
||||
|
||||
function animate() {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.globalAlpha = 0.1;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.restore();
|
||||
|
||||
let newTime = new Date();
|
||||
if (newTime - lastTime > 200 + (window.innerHeight - 767) / 2) {
|
||||
let random = Math.random() * 100 > 2 ? true : false;
|
||||
let x = getRandom(canvas.width / 5, (canvas.width * 4) / 5);
|
||||
let y = getRandom(50, 200);
|
||||
if (random) {
|
||||
let bigboom = new Boom(
|
||||
getRandom(canvas.width / 3, (canvas.width * 2) / 3),
|
||||
2,
|
||||
"#FFF",
|
||||
{x: x, y: y}
|
||||
);
|
||||
bigbooms.push(bigboom);
|
||||
} else {
|
||||
let bigboom = new Boom(
|
||||
getRandom(canvas.width / 3, (canvas.width * 2) / 3),
|
||||
2,
|
||||
"#FFF",
|
||||
{
|
||||
x: canvas.width / 2,
|
||||
y: 200,
|
||||
},
|
||||
document.querySelectorAll(".shape")[
|
||||
parseInt(
|
||||
getRandom(0, document.querySelectorAll(".shape").length)
|
||||
)
|
||||
]
|
||||
);
|
||||
bigbooms.push(bigboom);
|
||||
}
|
||||
lastTime = newTime;
|
||||
}
|
||||
|
||||
stars.foreach(function () {
|
||||
this.paint();
|
||||
});
|
||||
|
||||
drawMoon();
|
||||
|
||||
bigbooms.foreach(function (index) {
|
||||
let that = this;
|
||||
if (!this.dead) {
|
||||
this._move();
|
||||
this._drawLight();
|
||||
} else {
|
||||
this.booms.foreach(function (index) {
|
||||
if (!this.dead) {
|
||||
this.moveTo(index);
|
||||
} else if (index === that.booms.length - 1) {
|
||||
bigbooms.splice(bigbooms.indexOf(that), 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
raf(animate);
|
||||
}
|
||||
|
||||
function drawMoon() {
|
||||
let moon = document.getElementById("moon");
|
||||
let centerX = canvas.width - 200,
|
||||
centerY = 100,
|
||||
width = 80;
|
||||
if (moon.complete) {
|
||||
ctx.drawImage(moon, centerX, centerY, width, width);
|
||||
} else {
|
||||
moon.onload = function () {
|
||||
ctx.drawImage(moon, centerX, centerY, width, width);
|
||||
};
|
||||
}
|
||||
let index = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
centerX + width / 2,
|
||||
centerY + width / 2,
|
||||
width / 2 + index,
|
||||
0,
|
||||
2 * Math.PI
|
||||
);
|
||||
ctx.fillStyle = "rgba(240,219,120,0.005)";
|
||||
index += 2;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
Array.prototype.foreach = function (callback) {
|
||||
for (let i = 0; i < this.length; i++) {
|
||||
if (this[i] !== null) callback.apply(this[i], [i]);
|
||||
}
|
||||
};
|
||||
|
||||
let raf =
|
||||
window.requestAnimationFrame ||
|
||||
window.webkitRequestAnimationFrame ||
|
||||
window.mozRequestAnimationFrame ||
|
||||
window.oRequestAnimationFrame ||
|
||||
window.msRequestAnimationFrame ||
|
||||
function (callback) {
|
||||
window.setTimeout(callback, 1000 / 60);
|
||||
};
|
||||
|
||||
canvas.onclick = function () {
|
||||
let x = event.clientX;
|
||||
let y = event.clientY;
|
||||
let bigboom = new Boom(
|
||||
getRandom(canvas.width / 3, (canvas.width * 2) / 3),
|
||||
2,
|
||||
"#FFF",
|
||||
{x: x, y: y}
|
||||
);
|
||||
bigbooms.push(bigboom);
|
||||
};
|
||||
|
||||
let Boom = function (x, r, c, boomArea, shape) {
|
||||
this.booms = [];
|
||||
this.x = x;
|
||||
this.y = canvas.height + r;
|
||||
this.r = r;
|
||||
this.c = c;
|
||||
this.shape = shape || false;
|
||||
this.boomArea = boomArea;
|
||||
this.theta = 0;
|
||||
this.dead = false;
|
||||
this.ba = parseInt(getRandom(80, 200));
|
||||
|
||||
let audio = document.getElementsByTagName("audio");
|
||||
for (let i = 0; i < audio.length; i++) {
|
||||
if (
|
||||
audio[i].src.indexOf("shotfire") >= 0 &&
|
||||
(audio[i].paused || audio[i].ended)
|
||||
) {
|
||||
audio[i].play();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
Boom.prototype = {
|
||||
_paint: function () {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = this.c;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
_move: function () {
|
||||
let dx = this.boomArea.x - this.x,
|
||||
dy = this.boomArea.y - this.y;
|
||||
this.x = this.x + dx * 0.01;
|
||||
this.y = this.y + dy * 0.01;
|
||||
|
||||
if (Math.abs(dx) <= this.ba && Math.abs(dy) <= this.ba) {
|
||||
if (this.shape) {
|
||||
this._shapBoom();
|
||||
} else this._boom();
|
||||
this.dead = true;
|
||||
} else {
|
||||
this._paint();
|
||||
}
|
||||
},
|
||||
_drawLight: function () {
|
||||
ctx.save();
|
||||
ctx.fillStyle = "rgba(255,228,150,0.3)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
this.x,
|
||||
this.y,
|
||||
this.r + 3 * Math.random() + 1,
|
||||
0,
|
||||
2 * Math.PI
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
_boom: function () {
|
||||
let fragNum = getRandom(100, 300);
|
||||
let style = getRandom(0, 10) >= 5 ? 1 : 2;
|
||||
let color;
|
||||
if (style === 1) {
|
||||
color = {
|
||||
a: parseInt(getRandom(128, 255)),
|
||||
b: parseInt(getRandom(128, 255)),
|
||||
c: parseInt(getRandom(128, 255)),
|
||||
};
|
||||
}
|
||||
|
||||
let fanwei = fragNum;
|
||||
let audio = document.getElementsByTagName("audio");
|
||||
for (let i = 0; i < audio.length; i++) {
|
||||
if (
|
||||
audio[i].src.indexOf("boom") >= 0 &&
|
||||
(audio[i].paused || audio[i].ended)
|
||||
) {
|
||||
audio[i].play();
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < fragNum; i++) {
|
||||
if (style === 2) {
|
||||
color = {
|
||||
a: parseInt(getRandom(128, 255)),
|
||||
b: parseInt(getRandom(128, 255)),
|
||||
c: parseInt(getRandom(128, 255)),
|
||||
};
|
||||
}
|
||||
let a = getRandom(-Math.PI, Math.PI);
|
||||
let x = getRandom(0, fanwei) * Math.cos(a) + this.x;
|
||||
let y = getRandom(0, fanwei) * Math.sin(a) + this.y;
|
||||
let radius = getRandom(0, 2);
|
||||
let frag = new Frag(this.x, this.y, radius, color, x, y);
|
||||
this.booms.push(frag);
|
||||
}
|
||||
},
|
||||
_shapBoom: function () {
|
||||
let that = this;
|
||||
putValue(ocas, octx, this.shape, 5, function (dots) {
|
||||
let dx = canvas.width / 2 - that.x;
|
||||
let dy = canvas.height / 2 - that.y;
|
||||
for (let i = 0; i < dots.length; i++) {
|
||||
color = {a: dots[i].a, b: dots[i].b, c: dots[i].c};
|
||||
let x = dots[i].x;
|
||||
let y = dots[i].y;
|
||||
let radius = 1;
|
||||
let frag = new Frag(
|
||||
that.x,
|
||||
that.y,
|
||||
radius,
|
||||
color,
|
||||
x - dx,
|
||||
y - dy
|
||||
);
|
||||
that.booms.push(frag);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function putValue(canvas, context, ele, dr, callback) {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
let img = new Image();
|
||||
if (ele.innerHTML.indexOf("img") >= 0) {
|
||||
img.src = ele.getElementsByTagName("img")[0].src;
|
||||
imgload(img, function () {
|
||||
context.drawImage(
|
||||
img,
|
||||
canvas.width / 2 - img.width / 2,
|
||||
canvas.height / 2 - img.width / 2
|
||||
);
|
||||
dots = getimgData(canvas, context, dr);
|
||||
callback(dots);
|
||||
});
|
||||
} else {
|
||||
let text = ele.innerHTML;
|
||||
context.save();
|
||||
let fontSize = 200;
|
||||
context.font = fontSize + "px 宋体 bold";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillStyle =
|
||||
"rgba(" +
|
||||
parseInt(getRandom(128, 255)) +
|
||||
"," +
|
||||
parseInt(getRandom(128, 255)) +
|
||||
"," +
|
||||
parseInt(getRandom(128, 255)) +
|
||||
" , 1)";
|
||||
context.fillText(text, canvas.width / 2, canvas.height / 2);
|
||||
context.restore();
|
||||
dots = getimgData(canvas, context, dr);
|
||||
callback(dots);
|
||||
}
|
||||
}
|
||||
|
||||
function imgload(img, callback) {
|
||||
if (img.complete) {
|
||||
callback.call(img);
|
||||
} else {
|
||||
img.onload = function () {
|
||||
callback.call(this);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getimgData(canvas, context, dr) {
|
||||
let imgData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
let dots = [];
|
||||
for (let x = 0; x < imgData.width; x += dr) {
|
||||
for (let y = 0; y < imgData.height; y += dr) {
|
||||
let i = (y * imgData.width + x) * 4;
|
||||
if (imgData.data[i + 3] > 128) {
|
||||
let dot = {
|
||||
x: x,
|
||||
y: y,
|
||||
a: imgData.data[i],
|
||||
b: imgData.data[i + 1],
|
||||
c: imgData.data[i + 2],
|
||||
};
|
||||
dots.push(dot);
|
||||
}
|
||||
}
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
function getRandom(a, b) {
|
||||
return Math.random() * (b - a) + a;
|
||||
}
|
||||
|
||||
let maxRadius = 1,
|
||||
stars = [];
|
||||
|
||||
function drawBg() {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
let r = Math.random() * maxRadius;
|
||||
let x = Math.random() * canvas.width;
|
||||
let y = Math.random() * 2 * canvas.height - canvas.height;
|
||||
let star = new Star(x, y, r);
|
||||
stars.push(star);
|
||||
star.paint();
|
||||
}
|
||||
}
|
||||
|
||||
let Star = function (x, y, r) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.r = r;
|
||||
};
|
||||
Star.prototype = {
|
||||
paint: function () {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = "rgba(255,255,255," + this.r + ")";
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
|
||||
let focallength = 250;
|
||||
let Frag = function (centerX, centerY, radius, color, tx, ty) {
|
||||
this.tx = tx;
|
||||
this.ty = ty;
|
||||
this.x = centerX;
|
||||
this.y = centerY;
|
||||
this.dead = false;
|
||||
this.centerX = centerX;
|
||||
this.centerY = centerY;
|
||||
this.radius = radius;
|
||||
this.color = color;
|
||||
};
|
||||
|
||||
Frag.prototype = {
|
||||
paint: function () {
|
||||
// ctx.beginPath();
|
||||
// ctx.arc(this.x , this.y , this.radius , 0 , 2*Math.PI);
|
||||
ctx.fillStyle =
|
||||
"rgba(" +
|
||||
this.color.a +
|
||||
"," +
|
||||
this.color.b +
|
||||
"," +
|
||||
this.color.c +
|
||||
",1)";
|
||||
ctx.fillRect(
|
||||
this.x - this.radius,
|
||||
this.y - this.radius,
|
||||
this.radius * 2,
|
||||
this.radius * 2
|
||||
);
|
||||
},
|
||||
moveTo: function (index) {
|
||||
this.ty = this.ty + 0.3;
|
||||
let dx = this.tx - this.x,
|
||||
dy = this.ty - this.y;
|
||||
this.x = Math.abs(dx) < 0.1 ? this.tx : this.x + dx * 0.1;
|
||||
this.y = Math.abs(dy) < 0.1 ? this.ty : this.y + dy * 0.1;
|
||||
if (dx === 0 && Math.abs(dy) <= 80) {
|
||||
this.dead = true;
|
||||
}
|
||||
this.paint();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import Tooltip from "@/pages/pc/components/Tooltip.vue";
|
||||
import {Icon} from "@iconify/vue";
|
||||
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
|
||||
|
||||
interface IProps {
|
||||
keyboard?: string,
|
||||
@@ -32,14 +31,14 @@ defineEmits(['click'])
|
||||
(disabled||loading) && 'disabled',
|
||||
]">
|
||||
<span :style="{opacity:loading?0:1}"><slot></slot></span>
|
||||
<Icon v-if="loading"
|
||||
class="loading"
|
||||
icon="eos-icons:loading"
|
||||
width="18"
|
||||
color="#ffffff"
|
||||
<IconEosIconsLoading
|
||||
v-if="loading"
|
||||
class="loading"
|
||||
width="18"
|
||||
:color="type === 'info'?'#000000':'#ffffff'"
|
||||
/>
|
||||
<div class="key-notice" v-if="keyboard">
|
||||
<Icon icon="bi:keyboard" width="14" color="#ffffff"/>
|
||||
<IconBiKeyboard width="14" color="#ffffff"/>
|
||||
<span class="key">{{ keyboard }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,7 +120,7 @@ defineEmits(['click'])
|
||||
|
||||
&.info {
|
||||
background: var(--btn-info);
|
||||
border: 1px solid var(--color-main-text);
|
||||
border: 1px solid var(--color-main-text);
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import Tooltip from "@/pages/pc/components/Tooltip.vue";
|
||||
import IconWrapper from "@/pages/pc/components/IconWrapper.vue";
|
||||
import {Icon} from "@iconify/vue";
|
||||
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
|
||||
|
||||
defineProps<{
|
||||
title?: string,
|
||||
icon: string,
|
||||
disabled?: boolean,
|
||||
noBg?: boolean,
|
||||
}>()
|
||||
@@ -23,7 +20,7 @@ const emit = defineEmits(['click'])
|
||||
class="icon-wrapper"
|
||||
:class="{disabled,noBg}"
|
||||
>
|
||||
<Icon :icon="icon"/>
|
||||
<slot/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
@@ -46,7 +43,7 @@ $w: 1.4rem;
|
||||
&:hover:not(.disabled,.noBg) {
|
||||
background: var(--color-icon-hightlight);
|
||||
|
||||
svg {
|
||||
:deep(svg) {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import {watch} from "vue";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
|
||||
let settingStore = useSettingStore()
|
||||
let show = $ref(false)
|
||||
|
||||
function toggleNotice() {
|
||||
show = false
|
||||
}
|
||||
|
||||
watch(() => settingStore.load, (n) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('from') === 'redirect') {
|
||||
show = true
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="right">
|
||||
<div class="HostNotice" v-if="show">
|
||||
<div class="notice">
|
||||
<div>检查到您是通过老域名 typing-word.ttentau.top 访问的本网站,特此弹窗提示!</div>
|
||||
<p>老域名已不再续费,7天后过期将无法访问,请更换为新域名 <span class="active"><a href="https://2study.top">2study.top</a></span>
|
||||
访问</p>
|
||||
</div>
|
||||
<div class="wrapper">
|
||||
<BaseButton size="large" @click="toggleNotice">关闭</BaseButton>
|
||||
</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%);
|
||||
}
|
||||
|
||||
.HostNotice {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
font-size: 20rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--color-second-bg);
|
||||
padding: 30rem;
|
||||
border-radius: 12rem;
|
||||
width: 500rem;
|
||||
color: var(--color-font-1);
|
||||
line-height: 1.5;
|
||||
border: 1px solid var(--color-item-border);
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
.notice {
|
||||
margin-top: 30rem;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--color-main-active);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
.collect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.href-wrapper {
|
||||
display: flex;
|
||||
font-size: 16rem;
|
||||
align-items: center;
|
||||
gap: 10rem;
|
||||
|
||||
.round {
|
||||
color: var(--color-font-1);
|
||||
border-radius: 50rem;
|
||||
padding: 10rem 10rem;
|
||||
padding-left: 20rem;
|
||||
gap: 30rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--color-main-bg);
|
||||
|
||||
.href {
|
||||
font-size: 14rem;
|
||||
}
|
||||
}
|
||||
|
||||
.star {
|
||||
color: var(--color-main-active);
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.collect-keyboard {
|
||||
margin-top: 20rem;
|
||||
font-size: 16rem;
|
||||
|
||||
span {
|
||||
margin-left: 10rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-wrapper {
|
||||
right: var(--space);
|
||||
top: var(--space);
|
||||
position: absolute;
|
||||
font-size: 14rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
color: var(--color-font-1);
|
||||
gap: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {Icon} from "@iconify/vue";
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon
|
||||
class="back-icon"
|
||||
icon="octicon:arrow-left-24" width="22"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {Icon} from "@iconify/vue";
|
||||
import Tooltip from "@/pages/pc/components/Tooltip.vue";
|
||||
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
|
||||
|
||||
defineEmits(['click'])
|
||||
defineProps<{
|
||||
@@ -13,8 +12,7 @@ defineProps<{
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<Tooltip :title="title">
|
||||
<Icon icon="carbon:close-outline"
|
||||
/>
|
||||
<IconCarbonCloseOutline/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
@@ -27,4 +25,4 @@ defineProps<{
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {Icon} from "@iconify/vue";
|
||||
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<Icon icon="solar:trash-bin-minimalistic-linear" width="20"/>
|
||||
<IconSolarTrashBinMinimalisticLinear/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
@@ -49,11 +49,23 @@ defineExpose({play})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseIcon @click.stop="click"
|
||||
v-if="props.simple"
|
||||
no-bg
|
||||
:icon="iconList[step]"/>
|
||||
<BaseIcon @click.stop="click" v-else :icon="iconList[step]"/>
|
||||
<template v-if="props.simple">
|
||||
<BaseIcon @click.stop="click"
|
||||
no-bg
|
||||
>
|
||||
<IconBxVolume v-if="step === 0"/>
|
||||
<IconBxVolumeLow v-if="step === 1"/>
|
||||
<IconBxVolumeFull v-if="step === 2"/>
|
||||
</BaseIcon>
|
||||
</template>
|
||||
<template v-else>
|
||||
<BaseIcon @click.stop="click"
|
||||
>
|
||||
<IconBxVolume v-if="step === 0"/>
|
||||
<IconBxVolumeLow v-if="step === 1"/>
|
||||
<IconBxVolumeFull v-if="step === 2"/>
|
||||
</BaseIcon>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const GITHUB = 'https://github.com/zyronon/bbword'
|
||||
export const GITHUB = 'https://github.com/zyronon/TypeWords'
|
||||
|
||||
const common = {
|
||||
word_dict_list_version: 1
|
||||
@@ -8,4 +8,4 @@ const map = {
|
||||
api: 'http://localhost/index.php',
|
||||
}
|
||||
}
|
||||
export const env = Object.assign(map['dev'], common)
|
||||
export const env = Object.assign(map['dev'], common)
|
||||
|
||||
61
src/directives/loading.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// src/directives/loading.js
|
||||
import {createApp, h} from 'vue'
|
||||
import IconEosIconsLoading from '~icons/eos-icons/loading'
|
||||
|
||||
// 创建一个 Loading 组件
|
||||
const LoadingComponent = {
|
||||
name: 'LoadingComponent',
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: 'rgba(255, 255, 255, 0.7)',
|
||||
zIndex: 9999
|
||||
}}
|
||||
>
|
||||
<IconEosIconsLoading class="text-3xl"/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义指令
|
||||
export default {
|
||||
mounted(el, binding) {
|
||||
const position = getComputedStyle(el).position
|
||||
if (position === 'static' || !position) {
|
||||
el.style.position = 'relative' // 保证 loading 居中
|
||||
}
|
||||
|
||||
const app = createApp(LoadingComponent)
|
||||
const instance = app.mount(document.createElement('div'))
|
||||
el.__loadingInstance = instance
|
||||
|
||||
if (binding.value) {
|
||||
el.appendChild(instance.$el)
|
||||
}
|
||||
},
|
||||
updated(el, binding) {
|
||||
const instance = el.__loadingInstance
|
||||
if (binding.value && !el.contains(instance.$el)) {
|
||||
el.appendChild(instance.$el)
|
||||
} else if (!binding.value && el.contains(instance.$el)) {
|
||||
el.removeChild(instance.$el)
|
||||
}
|
||||
},
|
||||
unmounted(el) {
|
||||
const instance = el.__loadingInstance
|
||||
if (instance && instance.$el.parentNode) {
|
||||
instance.$el.parentNode.removeChild(instance.$el)
|
||||
}
|
||||
delete el.__loadingInstance
|
||||
}
|
||||
}
|
||||
@@ -546,7 +546,7 @@ export function usePlaySentenceAudio() {
|
||||
ref.currentTime = start
|
||||
ref.play()
|
||||
let end = sentence.audioPosition?.[1]
|
||||
console.log(sentence.audioPosition,(end - start) * 1000)
|
||||
// console.log(sentence.audioPosition,(end - start) * 1000)
|
||||
|
||||
if (end && end !== -1) {
|
||||
timer = setTimeout(() => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {Article, Word} from "@/types/types.ts";
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
import {nanoid} from "nanoid";
|
||||
import {getDefaultArticle} from "@/types/func.ts";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import {getDefaultWord} from "@/types/func.ts";
|
||||
import {getRandomN, splitIntoN} from "@/utils";
|
||||
|
||||
export function useWordOptions() {
|
||||
const store = useBaseStore()
|
||||
@@ -64,7 +65,7 @@ export function useArticleOptions() {
|
||||
const store = useBaseStore()
|
||||
|
||||
function isArticleCollect(val: Article) {
|
||||
return !!store.collectArticle.articles.find(v => v.id === val.id)
|
||||
return !!store.collectArticle?.articles?.find(v => v.id === val.id)
|
||||
}
|
||||
|
||||
//todo 这里先收藏,再修改。收藏里面的未同步。单词也是一样的
|
||||
@@ -85,105 +86,98 @@ export function useArticleOptions() {
|
||||
}
|
||||
|
||||
export function getCurrentStudyWord() {
|
||||
// console.time()
|
||||
console.log('getCurrentStudyWord')
|
||||
const store = useBaseStore()
|
||||
let data = {new: [], review: [], write: []}
|
||||
let dict = store.sdict;
|
||||
if (dict.words?.length) {
|
||||
const getList = (startIndex: number, endIndex: number) => dict.words.slice(startIndex, endIndex)
|
||||
|
||||
const perDay = store.sdict.perDayStudyNumber;
|
||||
const totalNeed = perDay * 3;
|
||||
let isTest = false
|
||||
let words = dict.words.slice()
|
||||
if (isTest) {
|
||||
words = Array.from({length: 10}).map((v, i) => {
|
||||
return getDefaultWord({word: String(i)})
|
||||
})
|
||||
}
|
||||
if (words?.length) {
|
||||
const settingStore = useSettingStore()
|
||||
//忽略时是否加上自定义的简单词
|
||||
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
|
||||
const perDay = dict.perDayStudyNumber;
|
||||
let start = dict.lastLearnIndex;
|
||||
let end = start + dict.perDayStudyNumber
|
||||
|
||||
if (dict.complete) {
|
||||
let complete = dict.complete;
|
||||
if (isTest) {
|
||||
start = 1
|
||||
complete = true
|
||||
}
|
||||
let end = start
|
||||
let list = dict.words.slice(start)
|
||||
if (complete) {
|
||||
//如果是已完成,那么把应该学的新词放到复习词组里面
|
||||
dict.words.slice(start, end).map(item => {
|
||||
if (!store.knownWords.includes(item.word)) {
|
||||
data.review.push(item)
|
||||
for (let item of list) {
|
||||
if (!ignoreList.includes(item.word.toLowerCase())) {
|
||||
if (data.review.length < perDay) {
|
||||
data.review.push(item)
|
||||
} else break
|
||||
}
|
||||
})
|
||||
//如果起点index 减去总默写不足的话,那就直接从最后面取
|
||||
//todo 这里有空了,优化成往前滚动取值
|
||||
if (start - totalNeed < 0) {
|
||||
end = dict.length
|
||||
} else {
|
||||
end = start
|
||||
end++
|
||||
}
|
||||
} else {
|
||||
dict.words.slice(start, end).map(item => {
|
||||
if (!store.knownWords.includes(item.word)) {
|
||||
data.new.push(item)
|
||||
//从start往后取perDay个单词,作为当前练习单词
|
||||
for (let item of list) {
|
||||
if (!ignoreList.includes(item.word.toLowerCase())) {
|
||||
if (data.new.length < perDay) {
|
||||
data.new.push(item)
|
||||
} else break
|
||||
}
|
||||
})
|
||||
end = start
|
||||
start = start - dict.perDayStudyNumber
|
||||
if (start < 0) start = 0
|
||||
//取上一次学习的单词用于复习
|
||||
let list = getList(start, end)
|
||||
list.map(item => {
|
||||
if (!store.knownWords.includes(item.word)) {
|
||||
data.review.push(item)
|
||||
end++
|
||||
}
|
||||
//从start往前取perDay个单词,作为当前复习单词,取到0为止
|
||||
list = dict.words.slice(0, start).toReversed()
|
||||
for (let item of list) {
|
||||
if (!ignoreList.includes(item.word.toLowerCase())) {
|
||||
if (data.review.length < perDay) {
|
||||
data.review.push(item)
|
||||
} else break
|
||||
}
|
||||
})
|
||||
|
||||
end = start
|
||||
start--
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(start,end)
|
||||
// end = start
|
||||
// start = start - dict.perDayStudyNumber * 3
|
||||
// //在上次学习再往前取前3次学习的单词用于默写
|
||||
// list = getList(start, end)
|
||||
// list.map(item => {
|
||||
// if (!store.knownWords.includes(item.word)) {
|
||||
// data.write.push(item)
|
||||
// }
|
||||
// })
|
||||
|
||||
//write数组放的是上上次之前的单词,总的数量为perDayStudyNumber * 3,取单词的规则为:从后往前取6个perDayStudyNumber的,越靠前的取的单词越多。
|
||||
|
||||
// 上上次更早的单词
|
||||
if (end > 0) {
|
||||
const allWords = dict.words;
|
||||
const candidateWords = allWords.slice(0, end).filter(w => !store.knownWords.includes(w.word));
|
||||
//默认只取start之前的单词
|
||||
let candidateWords = dict.words.slice(0, start).toReversed()
|
||||
//但如果已完成,则滚动取值
|
||||
if (complete) candidateWords = candidateWords.concat(dict.words.slice(end).toReversed())
|
||||
candidateWords = candidateWords.filter(w => !ignoreList.includes(w.word.toLowerCase()));
|
||||
// console.log(candidateWords.map(v => v.word))
|
||||
//最终要获取的单词数量
|
||||
const totalNeed = perDay * 3;
|
||||
if (candidateWords.length <= totalNeed) {
|
||||
data.write = candidateWords
|
||||
} else {
|
||||
//write数组放的是上上次之前的单词,总的数量为perDayStudyNumber * 3,取单词的规则为:从后往前取6个perDayStudyNumber的,越靠前的取的单词越多。
|
||||
let days = 6
|
||||
// 分6组,每组最多 perDay 个
|
||||
const groups: Word[][] = splitIntoN(candidateWords.slice(0, days * perDay), 6)
|
||||
// console.log('groups', groups)
|
||||
|
||||
// 分6组,每组 perDay 个
|
||||
const groupCount = 6;
|
||||
const groupSize = perDay;
|
||||
const groups: Word[][] = [];
|
||||
for (let i = 0; i < groupCount; i++) {
|
||||
const start = candidateWords.length - (i + 1) * groupSize;
|
||||
const end = candidateWords.length - i * groupSize;
|
||||
if (start < 0 && end <= 0) break;
|
||||
groups.unshift(candidateWords.slice(Math.max(0, start), Math.max(0, end)));
|
||||
}
|
||||
|
||||
// 分配数量,靠前组多,靠后组少
|
||||
// 例如分配比例 [1,2,3,4,5,6]
|
||||
const ratio = [1, 2, 3, 4, 5, 6];
|
||||
// 分配数量,靠前组多,靠后组少,例如分配比例 [6,5,4,3,2,1]
|
||||
const ratio = Array.from({length: days}, (_, i) => i + 1).reverse();
|
||||
const ratioSum = ratio.reduce((a, b) => a + b, 0);
|
||||
const realRatio = ratio.map(r => Math.round(r * totalNeed / ratioSum));
|
||||
const realRatio = ratio.map(r => Math.round(r / ratioSum * totalNeed));
|
||||
// console.log(ratio, ratioSum, realRatio, realRatio.reduce((a, b) => a + b, 0))
|
||||
|
||||
// 按比例从每组取单词
|
||||
// 按比例从每组随机取单词
|
||||
let writeWords: Word[] = [];
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
writeWords = writeWords.concat(groups[i].slice(-realRatio[i]));
|
||||
}
|
||||
// 如果数量不够,补足
|
||||
if (writeWords.length < totalNeed) {
|
||||
const extra = candidateWords.filter(w => !writeWords.includes(w)).slice(-(totalNeed - writeWords.length));
|
||||
writeWords = writeWords.concat(extra);
|
||||
}
|
||||
// 最终数量截断
|
||||
writeWords = writeWords.slice(-totalNeed);
|
||||
|
||||
//这里需要反转一下,越靠近今天的单词,越先练习
|
||||
data.write = writeWords.reverse();
|
||||
groups.map((v, i) => {
|
||||
writeWords = writeWords.concat(getRandomN(v, realRatio[i]))
|
||||
})
|
||||
// console.log('writeWords', writeWords)
|
||||
data.write = writeWords;
|
||||
}
|
||||
}
|
||||
// console.timeEnd()
|
||||
// console.log('data', data)
|
||||
// console.log('data-new', data.new.map(v => v.word))
|
||||
// console.log('data-review', data.review.map(v => v.word))
|
||||
// console.log('data-write', data.write.map(v => v.word))
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -112,20 +112,10 @@ export function useOnKeyboardEventListener(onKeyDown: (e: KeyboardEvent) => void
|
||||
})
|
||||
}
|
||||
|
||||
//因为如果用useStartKeyboardEventListener局部变量控制,当出现多个hooks时就不行了,所以用全局变量来控制
|
||||
export function useDisableEventListener(watchVal: any) {
|
||||
const runtimeStore = useRuntimeStore()
|
||||
watch(watchVal, (n: any) => {
|
||||
if (n === true) runtimeStore.disableEventListener = true
|
||||
if (n === false) runtimeStore.disableEventListener = false
|
||||
})
|
||||
onMounted(() => {
|
||||
if (watchVal() === undefined) {
|
||||
runtimeStore.disableEventListener = true
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (watchVal() === undefined) {
|
||||
runtimeStore.disableEventListener = false
|
||||
}
|
||||
runtimeStore.disableEventListener = n
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ export function useSound(audioSrcList?: string[], audioFileLength?: number) {
|
||||
}
|
||||
|
||||
function play(volume: number = 100) {
|
||||
console.log('play')
|
||||
index++
|
||||
if (audioList.length > 1 && audioList.length !== audioLength) {
|
||||
audioList[index % audioList.length].volume = volume / 100
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {Article, Sentence, TranslateEngine} from "@/types/types.ts";
|
||||
import Baidu from "@opentranslate/baidu";
|
||||
import {Translator} from "@opentranslate/translator/src/translator.ts";
|
||||
import Baidu from "@/libs/translate/baidu";
|
||||
import {Translator} from "@/libs/translate/translator/index.ts";
|
||||
|
||||
export function getSentenceAllTranslateText(article: Article) {
|
||||
return article.sections.map(v => v.map(s => s.translate.trim()).filter(v=>v).join(' \n')).filter(v=>v).join(' \n\n');
|
||||
return article.sections.map(v => v.map(s => s.translate.trim()).filter(v => v).join(' \n')).filter(v => v).join(' \n\n');
|
||||
}
|
||||
|
||||
export function getSentenceAllText(article: Article) {
|
||||
return article.sections.map(v => v.map(s => s.text.trim()).filter(v=>v).join(' \n')).filter(v=>v).join(' \n\n');
|
||||
return article.sections.map(v => v.map(s => s.text.trim()).filter(v => v).join(' \n')).filter(v => v).join(' \n\n');
|
||||
}
|
||||
|
||||
/***
|
||||
@@ -48,6 +48,8 @@ export async function getNetworkTranslate(
|
||||
const translate = async (sentence: Sentence) => {
|
||||
try {
|
||||
let r = await translator.translate(sentence.text, 'en', 'zh-CN')
|
||||
console.log(r)
|
||||
|
||||
if (r) {
|
||||
const cb = () => {
|
||||
sentence.translate = r.trans.paragraphs[0]
|
||||
|
||||
7
src/libs/qs.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
stringify: (params: Record<string, any>): string => {
|
||||
return Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
}
|
||||
}
|
||||
1
src/libs/translate/README.md
Normal file
@@ -0,0 +1 @@
|
||||
复制这个库是因为他引入了franc-min这个包,太大了50多k,我用不到
|
||||
145
src/libs/translate/baidu.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Language,
|
||||
Translator,
|
||||
TranslateError,
|
||||
TranslateQueryResult
|
||||
} from "./translator";
|
||||
import md5 from "md5";
|
||||
import qs from "../qs";
|
||||
|
||||
const langMap: [Language, string][] = [
|
||||
["auto", "auto"],
|
||||
["zh-CN", "zh"],
|
||||
["en", "en"],
|
||||
["yue", "yue"],
|
||||
["wyw", "wyw"],
|
||||
["ja", "jp"],
|
||||
["ko", "kor"],
|
||||
["fr", "fra"],
|
||||
["es", "spa"],
|
||||
["th", "th"],
|
||||
["ar", "ara"],
|
||||
["ru", "ru"],
|
||||
["pt", "pt"],
|
||||
["de", "de"],
|
||||
["it", "it"],
|
||||
["el", "el"],
|
||||
["nl", "nl"],
|
||||
["pl", "pl"],
|
||||
["bg", "bul"],
|
||||
["et", "est"],
|
||||
["da", "dan"],
|
||||
["fi", "fin"],
|
||||
["cs", "cs"],
|
||||
["ro", "rom"],
|
||||
["sl", "slo"],
|
||||
["sv", "swe"],
|
||||
["hu", "hu"],
|
||||
["zh-TW", "cht"],
|
||||
["vi", "vie"]
|
||||
];
|
||||
|
||||
export interface BaiduConfig {
|
||||
placeholder?: string;
|
||||
appid: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export class Baidu extends Translator<BaiduConfig> {
|
||||
readonly name = "baidu";
|
||||
|
||||
readonly endpoint = "https://api.fanyi.baidu.com/api/trans/vip/translate";
|
||||
|
||||
protected async query(
|
||||
text: string,
|
||||
from: Language,
|
||||
to: Language,
|
||||
config: BaiduConfig
|
||||
): Promise<TranslateQueryResult> {
|
||||
type BaiduTranslateError = {
|
||||
error_code: "54001" | string;
|
||||
error_msg: "Invalid Sign" | string;
|
||||
};
|
||||
|
||||
type BaiduTranslateResult = {
|
||||
from: string;
|
||||
to: string;
|
||||
trans_result: Array<{
|
||||
dst: string;
|
||||
src: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const salt = Date.now();
|
||||
const {endpoint} = this;
|
||||
const {appid, key} = config;
|
||||
|
||||
const res = await this.request<BaiduTranslateResult | BaiduTranslateError>(
|
||||
endpoint,
|
||||
{
|
||||
params: {
|
||||
from: Baidu.langMap.get(from),
|
||||
to: Baidu.langMap.get(to),
|
||||
q: text,
|
||||
salt,
|
||||
appid,
|
||||
sign: md5(appid + text + salt + key)
|
||||
}
|
||||
}
|
||||
).catch(() => {
|
||||
throw new TranslateError("NETWORK_ERROR");
|
||||
});
|
||||
|
||||
const {data} = res;
|
||||
|
||||
if ((data as BaiduTranslateError).error_code) {
|
||||
console.error(
|
||||
new Error("[Baidu service]" + (data as BaiduTranslateError).error_msg)
|
||||
);
|
||||
throw new TranslateError("API_SERVER_ERROR");
|
||||
}
|
||||
|
||||
const {
|
||||
trans_result: transResult,
|
||||
from: langDetected
|
||||
} = data as BaiduTranslateResult;
|
||||
const transParagraphs = transResult.map(({dst}) => dst);
|
||||
const detectedFrom = Baidu.langMapReverse.get(langDetected) as Language;
|
||||
|
||||
return {
|
||||
text,
|
||||
from: detectedFrom,
|
||||
to,
|
||||
origin: {
|
||||
paragraphs: transResult.map(({src}) => src),
|
||||
tts: await this.textToSpeech(text, detectedFrom)
|
||||
},
|
||||
trans: {
|
||||
paragraphs: transParagraphs,
|
||||
tts: await this.textToSpeech(transParagraphs.join(" "), to)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Translator lang to custom lang */
|
||||
private static readonly langMap = new Map(langMap);
|
||||
|
||||
/** Custom lang to translator lang */
|
||||
private static readonly langMapReverse = new Map(
|
||||
langMap.map(([translatorLang, lang]) => [lang, translatorLang])
|
||||
);
|
||||
|
||||
getSupportLanguages(): Language[] {
|
||||
return [...Baidu.langMap.keys()];
|
||||
}
|
||||
|
||||
async textToSpeech(text: string, lang: Language): Promise<string> {
|
||||
return `https://fanyi.baidu.com/gettts?${qs.stringify({
|
||||
lan: Baidu.langMap.get(lang !== "auto" ? lang : "zh-CN") || "zh",
|
||||
text,
|
||||
spd: 5,
|
||||
})}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default Baidu;
|
||||
2
src/libs/translate/languages/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./languages";
|
||||
export * from "./locales";
|
||||
123
src/libs/translate/languages/languages.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
export type Language = (typeof languages)[number];
|
||||
|
||||
export const languages = [
|
||||
"af",
|
||||
"am",
|
||||
"ar",
|
||||
"auto",
|
||||
"az",
|
||||
"be",
|
||||
"bg",
|
||||
"bn",
|
||||
"bs",
|
||||
"ca",
|
||||
"ceb",
|
||||
"co",
|
||||
"cs",
|
||||
"cy",
|
||||
"da",
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"eo",
|
||||
"es",
|
||||
"et",
|
||||
"eu",
|
||||
"fa",
|
||||
"fi",
|
||||
"fil",
|
||||
"fj",
|
||||
"fr",
|
||||
"fy",
|
||||
"ga",
|
||||
"gd",
|
||||
"gl",
|
||||
"gu",
|
||||
"ha",
|
||||
"haw",
|
||||
"he",
|
||||
"hi",
|
||||
"hmn",
|
||||
"hr",
|
||||
"ht",
|
||||
"hu",
|
||||
"hy",
|
||||
"id",
|
||||
"ig",
|
||||
"is",
|
||||
"it",
|
||||
"ja",
|
||||
"jw",
|
||||
"ka",
|
||||
"kk",
|
||||
"km",
|
||||
"kn",
|
||||
"ko",
|
||||
"ku",
|
||||
"ky",
|
||||
"la",
|
||||
"lb",
|
||||
"lo",
|
||||
"lt",
|
||||
"lv",
|
||||
"mg",
|
||||
"mi",
|
||||
"mk",
|
||||
"ml",
|
||||
"mn",
|
||||
"mr",
|
||||
"ms",
|
||||
"mt",
|
||||
"mww",
|
||||
"my",
|
||||
"ne",
|
||||
"nl",
|
||||
"no",
|
||||
"ny",
|
||||
"otq",
|
||||
"pa",
|
||||
"pl",
|
||||
"ps",
|
||||
"pt",
|
||||
"ro",
|
||||
"ru",
|
||||
"sd",
|
||||
"si",
|
||||
"sk",
|
||||
"sl",
|
||||
"sm",
|
||||
"sn",
|
||||
"so",
|
||||
"sq",
|
||||
"sr",
|
||||
"sr-Cyrl",
|
||||
"sr-Latn",
|
||||
"st",
|
||||
"su",
|
||||
"sv",
|
||||
"sw",
|
||||
"ta",
|
||||
"te",
|
||||
"tg",
|
||||
"th",
|
||||
"tlh",
|
||||
"tlh-Qaak",
|
||||
"to",
|
||||
"tr",
|
||||
"ty",
|
||||
"ug",
|
||||
"uk",
|
||||
"ur",
|
||||
"uz",
|
||||
"vi",
|
||||
"wyw",
|
||||
"xh",
|
||||
"yi",
|
||||
"yo",
|
||||
"yua",
|
||||
"yue",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"zu"
|
||||
] as const;
|
||||
3
src/libs/translate/languages/locales.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Language } from "./languages";
|
||||
|
||||
export type Locale = { [key in Language]: string };
|
||||
3
src/libs/translate/translator/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "../languages";
|
||||
export * from "./type";
|
||||
export * from "./translator";
|
||||
100
src/libs/translate/translator/translator.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Languages,
|
||||
TranslatorEnv,
|
||||
TranslatorInit,
|
||||
TranslateResult,
|
||||
TranslateQueryResult
|
||||
} from "./type";
|
||||
import {Language} from "../languages";
|
||||
import Axios, {AxiosInstance, AxiosRequestConfig, AxiosPromise} from "axios";
|
||||
|
||||
export abstract class Translator<Config extends {} = {}> {
|
||||
axios: AxiosInstance;
|
||||
|
||||
protected readonly env: TranslatorEnv;
|
||||
|
||||
/**
|
||||
* 自定义选项
|
||||
*/
|
||||
config: Config;
|
||||
|
||||
/**
|
||||
* 翻译源标识符
|
||||
*/
|
||||
abstract readonly name: string;
|
||||
|
||||
/**
|
||||
* 可选的axios实例
|
||||
*/
|
||||
constructor(init: TranslatorInit<Config> = {}) {
|
||||
this.env = init.env || "node";
|
||||
this.axios = init.axios || Axios;
|
||||
this.config = init.config || ({} as Config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取翻译器所支持的语言列表: 语言标识符数组
|
||||
*/
|
||||
abstract getSupportLanguages(): Languages;
|
||||
|
||||
/**
|
||||
* 下游应用调用的接口
|
||||
*/
|
||||
async translate(
|
||||
text: string,
|
||||
from: Language,
|
||||
to: Language,
|
||||
config = {} as Config
|
||||
): Promise<TranslateResult> {
|
||||
const queryResult = await this.query(text, from, to, {
|
||||
...this.config,
|
||||
...config
|
||||
});
|
||||
|
||||
return {
|
||||
...queryResult,
|
||||
engine: this.name
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 token 的方法
|
||||
*/
|
||||
updateToken?(): Promise<void>;
|
||||
|
||||
/**
|
||||
* 翻译源需要实现的方法
|
||||
*/
|
||||
protected abstract query(
|
||||
text: string,
|
||||
from: Language,
|
||||
to: Language,
|
||||
config: Config
|
||||
): Promise<TranslateQueryResult>;
|
||||
|
||||
protected request<R = {}>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): AxiosPromise<R> {
|
||||
return this.axios(url, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果翻译源提供了单独的检测语言的功能,请实现此接口
|
||||
*/
|
||||
async detect(text: string): Promise<Language> {
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本转换为语音
|
||||
* @returns {Promise<string|null>} 语言文件地址
|
||||
*/
|
||||
textToSpeech(
|
||||
text: string,
|
||||
lang: Language,
|
||||
meta?: any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
): Promise<string | null> {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
45
src/libs/translate/translator/type.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {Language} from "../languages";
|
||||
import {AxiosInstance} from "axios";
|
||||
|
||||
export type Languages = Array<Language>;
|
||||
|
||||
export type TranslatorEnv = "node" | "ext";
|
||||
|
||||
export interface TranslatorInit<Config extends {}> {
|
||||
env?: TranslatorEnv;
|
||||
axios?: AxiosInstance;
|
||||
config?: Config;
|
||||
}
|
||||
|
||||
export type TranslateErrorType =
|
||||
| "NETWORK_ERROR"
|
||||
| "NETWORK_TIMEOUT"
|
||||
| "API_SERVER_ERROR"
|
||||
| "UNSUPPORTED_LANG"
|
||||
| "UNKNOWN";
|
||||
|
||||
export class TranslateError extends Error {
|
||||
constructor(message: TranslateErrorType) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一的查询结果的数据结构 */
|
||||
export interface TranslateResult {
|
||||
engine: string;
|
||||
text: string;
|
||||
from: Language;
|
||||
to: Language;
|
||||
/** 原文 */
|
||||
origin: {
|
||||
paragraphs: string[];
|
||||
tts?: string;
|
||||
};
|
||||
/** 译文 */
|
||||
trans: {
|
||||
paragraphs: string[];
|
||||
tts?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type TranslateQueryResult = Omit<TranslateResult, "engine">;
|
||||
@@ -6,18 +6,19 @@ import {createPinia} from "pinia"
|
||||
import router from "@/router.ts";
|
||||
import VueVirtualScroller from 'vue-virtual-scroller'
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
import './global.d.ts'
|
||||
import './types/global.d.ts'
|
||||
import loadingDirective from './directives/loading.tsx'
|
||||
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(VueVirtualScroller)
|
||||
// app.use(ElementPlus)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
app.directive('opacity', (el, binding) => {
|
||||
el.style.opacity = binding.value ? 1 : 0
|
||||
})
|
||||
app.directive('loading', loadingDirective)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,651 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {Icon} from '@iconify/vue';
|
||||
import {ref, watch} from "vue";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import {getAudioFileUrl, useChangeAllSound, usePlayAudio, useWatchAllSound} from "@/hooks/sound.ts";
|
||||
import {getShortcutKey, useDisableEventListener, useEventListener} from "@/hooks/event.ts";
|
||||
import {cloneDeep} from "@/utils";
|
||||
import {DefaultShortcutKeyMap, ShortcutKey} from "@/types/types.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import {APP_NAME, EXPORT_DATA_KEY, SAVE_DICT_KEY, SAVE_SETTING_KEY, SoundFileOptions} from "@/utils/const.ts";
|
||||
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
import {saveAs} from "file-saver";
|
||||
import {checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting, shakeCommonDict} from "@/utils";
|
||||
import {GITHUB} from "@/config/ENV.ts";
|
||||
import dayjs from "dayjs";
|
||||
import BasePage from "@/pages/pc/components/BasePage.vue";
|
||||
import {ElSwitch, ElSelect, ElOption, ElSlider, ElRadioGroup, ElRadio, ElInputNumber} from 'element-plus'
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleDisabledDialogEscKey: [val: boolean]
|
||||
}>()
|
||||
|
||||
const tabIndex = $ref(0)
|
||||
const settingStore = useSettingStore()
|
||||
const store = useBaseStore()
|
||||
//@ts-ignore
|
||||
const gitLastCommitHash = ref(LATEST_COMMIT_HASH);
|
||||
|
||||
useDisableEventListener(() => undefined)
|
||||
useWatchAllSound()
|
||||
|
||||
let editShortcutKey = $ref('')
|
||||
|
||||
const disabledDefaultKeyboardEvent = $computed(() => {
|
||||
return editShortcutKey && tabIndex === 2
|
||||
})
|
||||
|
||||
watch(() => disabledDefaultKeyboardEvent, v => {
|
||||
emit('toggleDisabledDialogEscKey', !!v)
|
||||
})
|
||||
|
||||
useEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (!disabledDefaultKeyboardEvent) return
|
||||
e.preventDefault()
|
||||
|
||||
let shortcutKey = getShortcutKey(e)
|
||||
// console.log('e', e, e.keyCode, e.ctrlKey, e.altKey, e.shiftKey)
|
||||
// console.log('key', shortcutKey)
|
||||
|
||||
// if (shortcutKey[shortcutKey.length-1] === '+') {
|
||||
// settingStore.shortcutKeyMap[editShortcutKey] = DefaultShortcutKeyMap[editShortcutKey]
|
||||
// return ElMessage.warning('设备失败!')
|
||||
// }
|
||||
|
||||
if (editShortcutKey) {
|
||||
if (shortcutKey === 'Delete') {
|
||||
settingStore.shortcutKeyMap[editShortcutKey] = ''
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(settingStore.shortcutKeyMap)) {
|
||||
if (v === shortcutKey && k !== editShortcutKey) {
|
||||
settingStore.shortcutKeyMap[editShortcutKey] = DefaultShortcutKeyMap[editShortcutKey]
|
||||
return ElMessage.warning('快捷键重复!')
|
||||
}
|
||||
}
|
||||
settingStore.shortcutKeyMap[editShortcutKey] = shortcutKey
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function resetShortcutKeyMap() {
|
||||
editShortcutKey = ''
|
||||
settingStore.shortcutKeyMap = cloneDeep(DefaultShortcutKeyMap)
|
||||
ElMessage.success('恢复成功')
|
||||
}
|
||||
|
||||
function exportData() {
|
||||
let data = {
|
||||
version: EXPORT_DATA_KEY.version,
|
||||
val: {
|
||||
setting: {
|
||||
version: SAVE_SETTING_KEY.version,
|
||||
val: settingStore.$state
|
||||
},
|
||||
dict: {
|
||||
version: SAVE_DICT_KEY.version,
|
||||
val: shakeCommonDict(store.$state)
|
||||
}
|
||||
}
|
||||
}
|
||||
let blob = new Blob([JSON.stringify(data)], {type: "text/plain;charset=utf-8"});
|
||||
saveAs(blob, `${APP_NAME}-User-Data-${dayjs().format('YYYY-MM-DD HH-mm-ss')}.json`);
|
||||
ElMessage.success('导出成功!')
|
||||
}
|
||||
|
||||
function importData(e) {
|
||||
let file = e.target.files[0]
|
||||
if (!file) return
|
||||
// no()
|
||||
let reader = new FileReader();
|
||||
reader.onload = function (v) {
|
||||
let str: any = v.target.result;
|
||||
if (str) {
|
||||
let obj = {
|
||||
version: -1,
|
||||
val: {
|
||||
setting: {},
|
||||
dict: {},
|
||||
}
|
||||
}
|
||||
try {
|
||||
obj = JSON.parse(str)
|
||||
let data = obj.val
|
||||
let settingState = checkAndUpgradeSaveSetting(data.setting)
|
||||
settingStore.setState(settingState)
|
||||
let baseState = checkAndUpgradeSaveDict(data.dict)
|
||||
store.setState(baseState)
|
||||
ElMessage.success('导入成功!')
|
||||
} catch (err) {
|
||||
return ElMessage.error('导入失败!')
|
||||
}
|
||||
}
|
||||
}
|
||||
reader.readAsText(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasePage>
|
||||
<div class="setting text-md">
|
||||
<div class="left mt-10">
|
||||
<div class="tabs">
|
||||
<div class="tab" :class="tabIndex === 0 && 'active'" @click="tabIndex = 0">
|
||||
<Icon icon="bx:headphone" width="20"/>
|
||||
<span>音效设置</span>
|
||||
</div>
|
||||
<div class="tab" :class="tabIndex === 1 && 'active'" @click="tabIndex = 1">
|
||||
<Icon icon="icon-park-outline:setting-config" width="20"/>
|
||||
<span>练习设置</span>
|
||||
</div>
|
||||
<div class="tab" :class="tabIndex === 2 && 'active'" @click="tabIndex = 2">
|
||||
<Icon icon="material-symbols:keyboard-outline" width="20"/>
|
||||
<span>快捷键设置</span>
|
||||
</div>
|
||||
<div class="tab" :class="tabIndex === 3 && 'active'" @click="tabIndex = 3">
|
||||
<Icon icon="mdi:database-cog-outline" width="20"/>
|
||||
<span>数据管理</span>
|
||||
</div>
|
||||
<div class="tab" :class="tabIndex === 4 && 'active'" @click="tabIndex = 4">
|
||||
<Icon icon="mingcute:service-fill" width="20"/>
|
||||
<span>反馈</span>
|
||||
</div>
|
||||
<div class="tab" :class="tabIndex === 5 && 'active'" @click="tabIndex = 5">
|
||||
<Icon icon="mdi:about-circle-outline" width="20"/>
|
||||
<span>关于</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="page-title text-align-center">设置</div>
|
||||
<div v-if="tabIndex === 0">
|
||||
<div class="row">
|
||||
<label class="main-title">所有音效</label>
|
||||
<div class="wrapper">
|
||||
<ElSwitch v-model="settingStore.allSound"
|
||||
@change="useChangeAllSound"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="row">
|
||||
<label class="item-title">单词/句子自动发音</label>
|
||||
<div class="wrapper">
|
||||
<ElSwitch v-model="settingStore.wordSound"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub-title">单词/句子发音口音</label>
|
||||
<div class="wrapper">
|
||||
<ElSelect v-model="settingStore.wordSoundType"
|
||||
placeholder="请选择"
|
||||
>
|
||||
<ElOption label="美音" value="us"/>
|
||||
<ElOption label="英音" value="uk"/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub-title">音量</label>
|
||||
<div class="wrapper">
|
||||
<ElSlider v-model="settingStore.wordSoundVolume"/>
|
||||
<span>{{ settingStore.wordSoundVolume }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub-title">倍速</label>
|
||||
<div class="wrapper">
|
||||
<ElSlider v-model="settingStore.wordSoundSpeed" :step="0.1" :min="0.5" :max="3"/>
|
||||
<span>{{ settingStore.wordSoundSpeed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="row">
|
||||
<label class="item-title">按键音</label>
|
||||
<div class="wrapper">
|
||||
<ElSwitch v-model="settingStore.keyboardSound"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="item-title">按键音效</label>
|
||||
<div class="wrapper">
|
||||
<ElSelect v-model="settingStore.keyboardSoundFile"
|
||||
placeholder="请选择"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in SoundFileOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
>
|
||||
<div class="el-option-row">
|
||||
<span>{{ item.label }}</span>
|
||||
<VolumeIcon
|
||||
:time="100"
|
||||
@click="usePlayAudio(getAudioFileUrl(item.value)[0])"/>
|
||||
</div>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub-title">音量</label>
|
||||
<div class="wrapper">
|
||||
<ElSlider v-model="settingStore.keyboardSoundVolume"/>
|
||||
<span>{{ settingStore.keyboardSoundVolume }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="row">
|
||||
<label class="item-title">效果音(输入错误、完成时的音效)</label>
|
||||
<div class="wrapper">
|
||||
<ElSwitch v-model="settingStore.effectSound"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub-title">音量</label>
|
||||
<div class="wrapper">
|
||||
<ElSlider v-model="settingStore.effectSoundVolume"/>
|
||||
<span>{{ settingStore.effectSoundVolume }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tabIndex === 1">
|
||||
<div class="row">
|
||||
<label class="item-title">单词循环设置</label>
|
||||
<div class="wrapper">
|
||||
<ElRadioGroup v-model="settingStore.repeatCount">
|
||||
<ElRadio :value="1" size="default">1</ElRadio>
|
||||
<ElRadio :value="2" size="default">2</ElRadio>
|
||||
<ElRadio :value="3" size="default">3</ElRadio>
|
||||
<ElRadio :value="5" size="default">5</ElRadio>
|
||||
<ElRadio :value="100" size="default">自定义</ElRadio>
|
||||
</ElRadioGroup>
|
||||
<div class="mini-row" v-if="settingStore.repeatCount === 100">
|
||||
<label class="item-title">循环次数</label>
|
||||
<ElInputNumber v-model="settingStore.repeatCustomCount"
|
||||
:min="6"
|
||||
:max="15"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="item-title">显示上一个/下一个单词</label>
|
||||
<div class="wrapper">
|
||||
<ElSwitch v-model="settingStore.showNearWord"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="desc">
|
||||
开启后,练习中会在上方显示上一个/下一个单词
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="row">
|
||||
<label class="item-title">忽略大小写</label>
|
||||
<div class="wrapper">
|
||||
<ElSwitch v-model="settingStore.ignoreCase"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="desc">
|
||||
开启后,输入时不区分大小写,如输入“hello”和“Hello”都会被认为是正确的
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="row">
|
||||
<label class="item-title">允许默写模式下显示提示</label>
|
||||
<div class="wrapper">
|
||||
<ElSwitch v-model="settingStore.allowWordTip"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="desc">
|
||||
开启后,可以通过鼠标 hover 单词或者按 {{ settingStore.shortcutKeyMap[ShortcutKey.ShowWord] }} 显示正确答案
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="row">
|
||||
<label class="item-title">字体设置(仅可调整单词练习)</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub-title">外语字体</label>
|
||||
<div class="wrapper">
|
||||
<ElSlider
|
||||
:min="10"
|
||||
:max="100"
|
||||
v-model="settingStore.fontSize.wordForeignFontSize"/>
|
||||
<span>{{ settingStore.fontSize.wordForeignFontSize }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub-title">中文字体</label>
|
||||
<div class="wrapper">
|
||||
<ElSlider
|
||||
:min="10"
|
||||
:max="100"
|
||||
v-model="settingStore.fontSize.wordTranslateFontSize"/>
|
||||
<span>{{ settingStore.fontSize.wordTranslateFontSize }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="line"></div>
|
||||
<div class="row">
|
||||
<label class="item-title">其他设置</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub-title">切换下一个单词时间</label>
|
||||
<div class="wrapper">
|
||||
<ElInputNumber v-model="settingStore.waitTimeForChangeWord"
|
||||
:min="6"
|
||||
:max="100"
|
||||
type="number"
|
||||
/>
|
||||
<span>毫秒</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body" v-if="tabIndex === 2">
|
||||
<div class="row">
|
||||
<label class="main-title">功能</label>
|
||||
<div class="wrapper">快捷键(点击可修改)</div>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<div class="row" v-for="item of Object.entries(settingStore.shortcutKeyMap)">
|
||||
<label class="item-title">{{ item[0] }}</label>
|
||||
<div class="wrapper" @click="editShortcutKey = item[0]">
|
||||
<div class="set-key" v-if="editShortcutKey === item[0]">
|
||||
<input :value="item[1]?item[1]:'未设置快捷键'" readonly type="text" @blur="editShortcutKey = ''">
|
||||
<span @click.stop="editShortcutKey = ''">直接按键盘进行设置</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="item[1]">{{ item[1] }}</div>
|
||||
<span v-else>未设置快捷键</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="item-title"></label>
|
||||
<div class="wrapper">
|
||||
<BaseButton @click="resetShortcutKeyMap">恢复默认</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tabIndex === 3">
|
||||
<div class="row">
|
||||
<div class="main-title">数据导出</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub-title">
|
||||
目前用户的所有数据(自定义设置、自定义词典、练习进度等)
|
||||
<b>仅保存在本地</b>
|
||||
。如果您需要在不同的设备、浏览器或者其他非官方部署上使用 {{ APP_NAME }}, 您需要手动进行数据同步和保存。
|
||||
</label>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<BaseButton @click="exportData">数据导出</BaseButton>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="main-title">数据导入</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub-title">
|
||||
请注意,导入数据将
|
||||
<b style="color: red"> 完全覆盖 </b>
|
||||
当前数据。请谨慎操作。
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="import hvr-grow">
|
||||
<BaseButton>数据导入</BaseButton>
|
||||
<input type="file"
|
||||
accept="application/json"
|
||||
@change="importData">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tabIndex === 4" class="feedback-modal">
|
||||
<div>
|
||||
给我发Email:<a href="mailto:zyronon@163.com">zyronon@163.com</a>
|
||||
</div>
|
||||
<p>or</p>
|
||||
<div class="github">
|
||||
<span>在<a :href="GITHUB" target="_blank">Github</a>上给我提一个
|
||||
<a :href="`${GITHUB}/issues`" target="_blank">Issue</a>
|
||||
</span>
|
||||
<div class="options">
|
||||
<BaseButton>
|
||||
<a :href="`${GITHUB}/issues/new?assignees=&labels=&projects=&template=%E5%8D%95%E8%AF%8D%E9%94%99%E8%AF%AF---word-error.md&title=%E5%8D%95%E8%AF%8D%E9%94%99%E8%AF%AF+%7C+Word+error`"
|
||||
target="_blank">词典错误?</a>
|
||||
</BaseButton>
|
||||
<BaseButton>
|
||||
<a :href="`${GITHUB}/issues/new?assignees=&labels=&projects=&template=问题报告---bug-report-.md&title=问题报告+%7C+Bug+report+`"
|
||||
target="_blank">反馈BUG?</a>
|
||||
</BaseButton>
|
||||
<BaseButton>
|
||||
<a :href="`${GITHUB}/issues/new?assignees=&labels=&projects=&template=功能请求---feature-request.md&title=功能请求+%7C+Feature+request`"
|
||||
target="_blank">功能请求?</a>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tabIndex === 5" class="center flex-col">
|
||||
<h1>Type Words</h1>
|
||||
<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>
|
||||
</p>
|
||||
<p>
|
||||
反馈:<a
|
||||
href="https://github.com/zyronon/TypeWords/issues" target="_blank">https://github.com/zyronon/TypeWords/issues</a>
|
||||
</p>
|
||||
<div class="text-md color-gray">
|
||||
Build {{ gitLastCommitHash }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.setting {
|
||||
display: flex;
|
||||
color: var(--color-font-1);
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-right: 2px solid gainsboro;
|
||||
|
||||
.tabs {
|
||||
padding: .6rem 1.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .6rem;
|
||||
//color: #0C8CE9;
|
||||
|
||||
.tab {
|
||||
cursor: pointer;
|
||||
padding: .6rem .9rem;
|
||||
border-radius: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
|
||||
&.active {
|
||||
background: var(--color-select-bg);
|
||||
color: var(--color-select-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 0 2.6rem;
|
||||
|
||||
.row {
|
||||
min-height: 2.6rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: calc(var(--space) * 5);
|
||||
|
||||
.wrapper {
|
||||
height: 2rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space);
|
||||
|
||||
span {
|
||||
text-align: right;
|
||||
//width: 30rem;
|
||||
font-size: .7rem;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.set-key {
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
width: 9rem;
|
||||
box-sizing: border-box;
|
||||
margin-right: .6rem;
|
||||
height: 1.8rem;
|
||||
outline: none;
|
||||
font-size: 1rem;
|
||||
border: 1px solid gray;
|
||||
border-radius: .2rem;
|
||||
padding: 0 .3rem;
|
||||
background: var(--color-second);
|
||||
color: var(--color-font-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: .9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
padding-right: .6rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin-bottom: .6rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.line {
|
||||
border-bottom: 1px solid #c4c3c3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-option-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.icon-wrapper {
|
||||
transform: translateX(10rem);
|
||||
}
|
||||
}
|
||||
|
||||
.import {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-modal {
|
||||
//height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space);
|
||||
//justify-content: center;
|
||||
color: var(--color-font-1);
|
||||
|
||||
p {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
|
||||
.github {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space);
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.about {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
import {Icon} from '@iconify/vue'
|
||||
import "vue-activity-calendar/style.css";
|
||||
import {useRouter} from "vue-router";
|
||||
import BasePage from "@/pages/pc/components/BasePage.vue";
|
||||
import {_getDictDataByUrl, useNav} from "@/utils";
|
||||
@@ -9,11 +7,13 @@ import {DictResource, DictType} from "@/types/types.ts";
|
||||
import {useRuntimeStore} from "@/stores/runtime.ts";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import Book from "@/pages/pc/components/Book.vue";
|
||||
import {ElMessage, ElProgress} from 'element-plus';
|
||||
import Progress from '@/pages/pc/components/base/Progress.vue';
|
||||
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
|
||||
import {onMounted, watch} from "vue";
|
||||
import {getDefaultDict} from "@/types/func.ts";
|
||||
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
|
||||
|
||||
const {nav} = useNav()
|
||||
const base = useBaseStore()
|
||||
@@ -35,11 +35,18 @@ async function init() {
|
||||
function startStudy() {
|
||||
if (base.sbook.id) {
|
||||
if (!base.sbook.articles.length) {
|
||||
return ElMessage.warning('没有文章可学习!')
|
||||
return Toast.warning('没有文章可学习!')
|
||||
}
|
||||
nav('/study-article')
|
||||
window.umami?.track('startStudyArticle', {
|
||||
name: base.sbook.name,
|
||||
index: base.sbook.lastLearnIndex,
|
||||
custom: base.sbook.custom,
|
||||
complete: base.sbook.complete,
|
||||
})
|
||||
nav('/practice-articles/' + store.sbook.id)
|
||||
} else {
|
||||
ElMessage.warning('请先选择一本书籍')
|
||||
window.umami?.track('no-book')
|
||||
Toast.warning('请先选择一本书籍')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +67,7 @@ function handleBatchDel() {
|
||||
}
|
||||
})
|
||||
selectIds = []
|
||||
ElMessage.success("删除成功!")
|
||||
Toast.success("删除成功!")
|
||||
}
|
||||
|
||||
function toggleSelect(item) {
|
||||
@@ -88,8 +95,10 @@ async function goBookDetail(val: DictResource) {
|
||||
@click="goBookDetail(base.currentBook)">{{
|
||||
base.currentBook.name || '请选择书籍开始学习'
|
||||
}}</span>
|
||||
<BaseIcon @click="router.push('/book-list')"
|
||||
:icon="base.currentBook.name ? 'gg:arrows-exchange':'fluent:add-20-filled'"/>
|
||||
<BaseIcon @click="router.push('/book-list')">
|
||||
<IconGgArrowsExchange v-if="base.currentBook.name"/>
|
||||
<IconFluentAdd20Filled v-else/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
<BaseButton
|
||||
size="large"
|
||||
@@ -98,12 +107,12 @@ async function goBookDetail(val: DictResource) {
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>开始学习</span>
|
||||
<Icon icon="icons8:right-round" class="text-2xl"/>
|
||||
<IconIcons8RightRound class="text-2xl"/>
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="mt-5 text-sm">已学习{{ base.currentBook.lastLearnIndex }}篇文章</div>
|
||||
<ElProgress class="mt-1" :percentage="base.currentBookProgress" :show-text="false"></ElProgress>
|
||||
<Progress class="mt-1" :percentage="base.currentBookProgress" :show-text="false"></Progress>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col">
|
||||
@@ -111,16 +120,18 @@ async function goBookDetail(val: DictResource) {
|
||||
<div class="title">我的书籍</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<PopConfirm title="确认删除所有选中书籍?" @confirm="handleBatchDel" v-if="selectIds.length">
|
||||
<BaseIcon class="del" title="删除" icon="solar:trash-bin-minimalistic-linear"/>
|
||||
<BaseIcon class="del" title="删除">
|
||||
<DeleteIcon/>
|
||||
</BaseIcon>
|
||||
</PopConfirm>
|
||||
|
||||
<div class="color-blue cursor-pointer" v-if="base.article.bookList.length > 1"
|
||||
@click="isMultiple = !isMultiple; selectIds = []">{{ isMultiple ? '取消' : '管理书籍' }}
|
||||
</div>
|
||||
<div class="color-blue cursor-pointer" @click="nav('dict-detail', { isAdd: true })">创建个人书籍</div>
|
||||
<div class="color-blue cursor-pointer" @click="nav('book-detail', { isAdd: true })">创建个人书籍</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-6 gap-4 mt-4">
|
||||
<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 >= 1"
|
||||
|
||||
@@ -7,14 +7,14 @@ import {useBaseStore} from "@/stores/base.ts";
|
||||
|
||||
import List from "@/pages/pc/components/list/List.vue";
|
||||
import {emitter, EventKey} from "@/utils/eventBus.ts";
|
||||
import {useDisableEventListener, useWindowClick} from "@/hooks/event.ts";
|
||||
import {useWindowClick} from "@/hooks/event.ts";
|
||||
import {MessageBox} from "@/utils/MessageBox.tsx";
|
||||
import {useRuntimeStore} from "@/stores/runtime.ts";
|
||||
import {nanoid} from "nanoid";
|
||||
import EditArticle from "@/pages/pc/article/components/EditArticle.vue";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import {ElMessage} from "element-plus";
|
||||
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
|
||||
import {getDefaultArticle} from "@/types/func.ts";
|
||||
import BackIcon from "@/pages/pc/components/BackIcon.vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
importData: [val: Event]
|
||||
@@ -42,8 +42,6 @@ onUnmounted(() => {
|
||||
emitter.off(EventKey.openArticleListModal)
|
||||
})
|
||||
|
||||
useDisableEventListener(() => show)
|
||||
|
||||
async function selectArticle(item: Article) {
|
||||
let r = await checkDataChange()
|
||||
if (r) {
|
||||
@@ -111,7 +109,7 @@ function saveArticle(val: Article): boolean {
|
||||
} else {
|
||||
let has = runtimeStore.editDict.articles.find((item: Article) => item.title === val.title)
|
||||
if (has) {
|
||||
ElMessage.error('已存在同名文章!')
|
||||
Toast.error('已存在同名文章!')
|
||||
return false
|
||||
}
|
||||
val.id = nanoid(6)
|
||||
@@ -122,7 +120,7 @@ function saveArticle(val: Article): boolean {
|
||||
}
|
||||
article = cloneDeep(val)
|
||||
//TODO 保存完成后滚动到对应位置
|
||||
ElMessage.success('保存成功!')
|
||||
Toast.success('保存成功!')
|
||||
syncBookInMyStudyList()
|
||||
return true
|
||||
}
|
||||
@@ -161,10 +159,7 @@ useWindowClick(() => showExport = false)
|
||||
<div class="add-article">
|
||||
<div class="aslide">
|
||||
<header class="flex justify-between items-center">
|
||||
<BaseIcon
|
||||
title="返回"
|
||||
@click="$router.back"
|
||||
icon="formkit:left"/>
|
||||
<BackIcon/>
|
||||
<div class="text-xl">{{ runtimeStore.editDict.name }}</div>
|
||||
</header>
|
||||
<List
|
||||
|
||||
@@ -52,10 +52,10 @@ async function init() {
|
||||
runtimeStore.editDict = getDefaultDict()
|
||||
} else {
|
||||
if (!runtimeStore.editDict.id) {
|
||||
await router.push("/article")
|
||||
await router.push("/articles")
|
||||
} else {
|
||||
if (!runtimeStore.editDict.articles.length
|
||||
&& !runtimeStore.editDict.custom
|
||||
if (!runtimeStore.editDict?.articles?.length
|
||||
&& !runtimeStore.editDict?.custom
|
||||
&& ![DictId.articleCollect].includes(runtimeStore.editDict.id)
|
||||
) {
|
||||
loading = true
|
||||
@@ -66,6 +66,7 @@ async function init() {
|
||||
if (runtimeStore.editDict.articles.length) {
|
||||
selectArticle = runtimeStore.editDict.articles[0]
|
||||
}
|
||||
console.log('runtimeStore.editDict',runtimeStore.editDict)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,9 +92,9 @@ const {
|
||||
<BackIcon class="z-2" @click="$router.back"/>
|
||||
<div class="absolute text-2xl text-align-center w-full">{{ runtimeStore.editDict.name }}</div>
|
||||
<div class="flex">
|
||||
<BaseButton type="info" @click="isEdit = true">编辑</BaseButton>
|
||||
<BaseButton :loading="studyLoading||loading" type="info" @click="isEdit = true">编辑</BaseButton>
|
||||
<BaseButton type="info" @click="router.push('batch-edit-article')">文章管理</BaseButton>
|
||||
<BaseButton :loading="studyLoading" @click="addMyStudyList">学习</BaseButton>
|
||||
<BaseButton :loading="studyLoading||loading" @click="addMyStudyList">学习</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg ">介绍:{{ runtimeStore.editDict.description }}</div>
|
||||
@@ -109,15 +110,12 @@ const {
|
||||
:active-id="selectArticle.id">
|
||||
<template v-slot:suffix="{item,index}">
|
||||
<BaseIcon
|
||||
v-if="!isArticleCollect(item)"
|
||||
class="collect"
|
||||
@click="toggleArticleCollect(item)"
|
||||
title="收藏" icon="ph:star"/>
|
||||
<BaseIcon
|
||||
v-else
|
||||
class="fill"
|
||||
@click="toggleArticleCollect(item)"
|
||||
title="取消收藏" icon="ph:star-fill"/>
|
||||
:class="!isArticleCollect(item)?'collect':'fill'"
|
||||
@click.stop="toggleArticleCollect(item)"
|
||||
:title="!isArticleCollect(item) ? '收藏' : '取消收藏'">
|
||||
<IconPhStar v-if="!isArticleCollect(item)"/>
|
||||
<IconPhStarFill v-else/>
|
||||
</BaseIcon>
|
||||
</template>
|
||||
</ArticleList>
|
||||
<Empty v-else/>
|
||||
@@ -148,7 +146,7 @@ const {
|
||||
|
||||
<div class="card mb-0 h-[95vh]" v-else>
|
||||
<div class="flex justify-between items-center relative">
|
||||
<BackIcon class="z-2" @click="isAdd ? $router.back():(isEdit = false)"/>
|
||||
<BackIcon class="z-2" @click="isAdd ? $router.back:(isEdit = false)"/>
|
||||
<div class="absolute text-2xl text-align-center w-full">{{ runtimeStore.editDict.id ? '修改' : '创建' }}书籍
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import "vue-activity-calendar/style.css";
|
||||
import {useNav} from "@/utils";
|
||||
import BasePage from "@/pages/pc/components/BasePage.vue";
|
||||
import {DictResource} from "@/types/types.ts";
|
||||
@@ -11,7 +10,7 @@ import BaseButton from "@/components/BaseButton.vue";
|
||||
import DictList from "@/pages/pc/components/list/DictList.vue";
|
||||
import BackIcon from "@/pages/pc/components/BackIcon.vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import {enArticle} from "@/assets/dictionary.ts";
|
||||
import book_list from "@/assets/book-list.json";
|
||||
import {computed} from "vue";
|
||||
import {getDefaultDict} from "@/types/func.ts";
|
||||
|
||||
@@ -35,7 +34,7 @@ let searchKey = $ref('')
|
||||
const searchList = computed<any[]>(() => {
|
||||
if (searchKey) {
|
||||
let s = searchKey.toLowerCase()
|
||||
return enArticle.filter((item) => {
|
||||
return book_list.flat().filter((item) => {
|
||||
return item.id.toLowerCase().includes(s)
|
||||
|| item.name.toLowerCase().includes(s)
|
||||
|| item.category.toLowerCase().includes(s)
|
||||
@@ -52,16 +51,17 @@ const searchList = computed<any[]>(() => {
|
||||
<BasePage>
|
||||
<div class="card">
|
||||
<div class="flex items-center relative gap-2">
|
||||
<BackIcon class="z-2" @Click='router.back()'/>
|
||||
<BackIcon class="z-2" @Click='router.back'/>
|
||||
<div class="flex flex-1 gap-4" v-if="showSearchInput">
|
||||
<Input placeholder="请输入书籍名称/缩写/类别" v-model="searchKey" class="flex-1" autofocus/>
|
||||
<Input prefix-icon placeholder="请输入书籍名称/缩写/类别" v-model="searchKey" class="flex-1" autofocus/>
|
||||
<BaseButton @click="showSearchInput = false, searchKey = ''">取消</BaseButton>
|
||||
</div>
|
||||
<div class="py-1 flex flex-1 justify-end" v-else>
|
||||
<span class="page-title absolute w-full center">书籍列表</span>
|
||||
<BaseIcon @click="showSearchInput = true"
|
||||
class="z-1"
|
||||
icon="fluent:search-24-regular"/>
|
||||
class="z-1">
|
||||
<IconFluentSearch24Regular/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4" v-if="searchKey">
|
||||
@@ -75,9 +75,9 @@ const searchList = computed<any[]>(() => {
|
||||
</div>
|
||||
<div class="w-full mt-2" v-else>
|
||||
<DictList
|
||||
v-if="enArticle.length "
|
||||
v-if="book_list.flat().length "
|
||||
@selectDict="selectDict"
|
||||
:list="enArticle"
|
||||
:list="book_list.flat()"
|
||||
quantifier="篇"
|
||||
:select-id="'-1'"/>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import EditArticle from "@/pages/pc/article/components/EditArticle.vue";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import BackIcon from "@/pages/pc/components/BackIcon.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen">
|
||||
<BaseIcon
|
||||
title="返回"
|
||||
@click="$router.back"
|
||||
icon="formkit:left"/>
|
||||
<BackIcon/>
|
||||
<EditArticle class="vue"></EditArticle>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, onUnmounted} from "vue";
|
||||
import {onMounted, onUnmounted, watch} from "vue";
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
|
||||
import Statistics from "@/pages/pc/word/Statistics.vue";
|
||||
import {emitter, EventKey} from "@/utils/eventBus.ts";
|
||||
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import {useRuntimeStore} from "@/stores/runtime.ts";
|
||||
import PracticeArticle from "@/pages/pc/article/practice-article/index.vue";
|
||||
import {ShortcutKey} from "@/types/types.ts";
|
||||
import {useStartKeyboardEventListener} from "@/hooks/event.ts";
|
||||
import {Article, ArticleItem, ArticleWord, Dict, DictType, ShortcutKey, Word} from "@/types/types.ts";
|
||||
import {useDisableEventListener, useOnKeyboardEventListener, useStartKeyboardEventListener} from "@/hooks/event.ts";
|
||||
import useTheme from "@/hooks/theme.ts";
|
||||
import {ElMessage} from "element-plus";
|
||||
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
|
||||
import {_getDictDataByUrl, cloneDeep} from "@/utils";
|
||||
import {usePracticeStore} from "@/stores/practice.ts";
|
||||
import {useArticleOptions} from "@/hooks/dict.ts";
|
||||
import {genArticleSectionData, usePlaySentenceAudio} from "@/hooks/article.ts";
|
||||
import {getDefaultArticle, getDefaultDict} from "@/types/func.ts";
|
||||
import TypingArticle from "@/pages/pc/article/components/TypingArticle.vue";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import Panel from "@/pages/pc/components/Panel.vue";
|
||||
import ArticleList from "@/pages/pc/components/list/ArticleList.vue";
|
||||
import EditSingleArticleModal from "@/pages/pc/article/components/EditSingleArticleModal.vue";
|
||||
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
|
||||
import ConflictNotice from "@/pages/pc/components/ConflictNotice.vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import book_list from "@/assets/book-list.json";
|
||||
|
||||
const store = useBaseStore()
|
||||
const settingStore = useSettingStore()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
const statisticsStore = usePracticeStore()
|
||||
const {toggleTheme} = useTheme()
|
||||
const practiceRef: any = $ref()
|
||||
|
||||
let articleData = $ref({
|
||||
list: [],
|
||||
article: getDefaultArticle(),
|
||||
sectionIndex: 0,
|
||||
sentenceIndex: 0,
|
||||
wordIndex: 0,
|
||||
stringIndex: 0,
|
||||
})
|
||||
let showEditArticle = $ref(false)
|
||||
let typingArticleRef = $ref<any>()
|
||||
let loading = $ref<boolean>(false)
|
||||
let editArticle = $ref<Article>(getDefaultArticle())
|
||||
|
||||
function write() {
|
||||
// console.log('write')
|
||||
@@ -27,81 +48,467 @@ function write() {
|
||||
}
|
||||
|
||||
//TODO 需要判断是否已忽略
|
||||
//todo 使用场景是?
|
||||
function repeat() {
|
||||
// console.log('repeat')
|
||||
emitter.emit(EventKey.resetWord)
|
||||
practiceRef.getCurrentPractice()
|
||||
getCurrentPractice()
|
||||
}
|
||||
|
||||
function prev() {
|
||||
// console.log('next')
|
||||
if (store.currentBook.chapterIndex === 0) {
|
||||
ElMessage.warning('已经在第一章了~')
|
||||
if (store.sbook.lastLearnIndex === 0) {
|
||||
Toast.warning('已经在第一章了~')
|
||||
} else {
|
||||
store.currentBook.chapterIndex--
|
||||
repeat()
|
||||
store.sbook.lastLearnIndex--
|
||||
getCurrentPractice()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowTranslate() {
|
||||
settingStore.translate = !settingStore.translate
|
||||
}
|
||||
|
||||
function toggleDictation() {
|
||||
settingStore.dictation = !settingStore.dictation
|
||||
}
|
||||
const toggleShowTranslate = () => settingStore.translate = !settingStore.translate
|
||||
const toggleDictation = () => settingStore.dictation = !settingStore.dictation
|
||||
const togglePanel = () => settingStore.showPanel = !settingStore.showPanel
|
||||
const skip = () => typingArticleRef?.nextSentence()
|
||||
const collect = () => toggleArticleCollect(articleData.article)
|
||||
const shortcutKeyEdit = () => edit()
|
||||
|
||||
function toggleConciseMode() {
|
||||
settingStore.showToolbar = !settingStore.showToolbar
|
||||
settingStore.showPanel = settingStore.showToolbar
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
settingStore.showPanel = !settingStore.showPanel
|
||||
function next() {
|
||||
if (store.sbook.lastLearnIndex >= articleData.list.length - 1) {
|
||||
store.sbook.lastLearnIndex = 0
|
||||
//todo 这里应该弹窗
|
||||
} else store.sbook.lastLearnIndex++
|
||||
getCurrentPractice()
|
||||
}
|
||||
|
||||
function jumpSpecifiedChapter(val: number) {
|
||||
store.currentBook.chapterIndex = val
|
||||
repeat()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
async function init() {
|
||||
console.log('load好了开始加载')
|
||||
let dict = getDefaultDict()
|
||||
let dictId = route.params.id
|
||||
if (dictId) {
|
||||
//先在自己的词典列表里面找,如果没有再在资源列表里面找
|
||||
dict = store.article.bookList.find(v => v.id === dictId)
|
||||
if (!dict) dict = book_list.flat().find(v => v.id === dictId) as Dict
|
||||
if (dict && dict.id) {
|
||||
//如果是不是自定义词典,就请求数据
|
||||
if (!dict.custom) dict = await _getDictDataByUrl(dict, DictType.article)
|
||||
if (!dict.articles.length) {
|
||||
router.push('/articles')
|
||||
return Toast.warning('没有文章可学习!')
|
||||
}
|
||||
store.changeBook(dict)
|
||||
articleData.list = cloneDeep(store.sbook.articles)
|
||||
getCurrentPractice()
|
||||
loading = false
|
||||
} else {
|
||||
router.push('/articles')
|
||||
}
|
||||
} else {
|
||||
router.push('/articles')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => store.load, (n) => {
|
||||
if (n && loading) init()
|
||||
}, {immediate: true})
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on(EventKey.write, write)
|
||||
emitter.on(EventKey.repeatStudy, repeat)
|
||||
emitter.on(EventKey.jumpSpecifiedChapter, jumpSpecifiedChapter)
|
||||
if (store.sbook?.articles?.length) {
|
||||
articleData.list = cloneDeep(store.sbook.articles)
|
||||
getCurrentPractice()
|
||||
} else {
|
||||
loading = true
|
||||
}
|
||||
})
|
||||
|
||||
emitter.on(ShortcutKey.PreviousChapter, prev)
|
||||
emitter.on(ShortcutKey.RepeatChapter, repeat)
|
||||
emitter.on(ShortcutKey.DictationChapter, write)
|
||||
emitter.on(ShortcutKey.ToggleShowTranslate, toggleShowTranslate)
|
||||
emitter.on(ShortcutKey.ToggleDictation, toggleDictation)
|
||||
emitter.on(ShortcutKey.ToggleTheme, toggleTheme)
|
||||
emitter.on(ShortcutKey.ToggleConciseMode, toggleConciseMode)
|
||||
emitter.on(ShortcutKey.TogglePanel, togglePanel)
|
||||
|
||||
useStartKeyboardEventListener()
|
||||
useDisableEventListener(() => loading)
|
||||
|
||||
function setArticle(val: Article) {
|
||||
statisticsStore.inputWordNumber = 0
|
||||
statisticsStore.wrong = 0
|
||||
statisticsStore.total = 0
|
||||
statisticsStore.startDate = Date.now()
|
||||
|
||||
articleData.list[store.sbook.lastLearnIndex] = val
|
||||
articleData.article = val
|
||||
articleData.sectionIndex = 0
|
||||
articleData.sentenceIndex = 0
|
||||
articleData.wordIndex = 0
|
||||
articleData.stringIndex = 0
|
||||
articleData.article.sections.map((v, i) => {
|
||||
v.map((w, j) => {
|
||||
w.words.map(s => {
|
||||
if (!store.allIgnoreWords.includes(s.word.toLowerCase()) && !s.isSymbol) {
|
||||
statisticsStore.total++
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getCurrentPractice() {
|
||||
emitter.emit(EventKey.resetWord)
|
||||
let currentArticle = articleData.list[store.sbook.lastLearnIndex]
|
||||
let article = getDefaultArticle(currentArticle)
|
||||
// console.log('article', article)
|
||||
if (article.sections.length) {
|
||||
setArticle(article)
|
||||
} else {
|
||||
genArticleSectionData(article)
|
||||
setArticle(article)
|
||||
}
|
||||
}
|
||||
|
||||
function saveArticle(val: Article) {
|
||||
console.log('saveArticle', val, JSON.stringify(val.lrcPosition))
|
||||
console.log('saveArticle', val.textTranslate)
|
||||
showEditArticle = false
|
||||
let rIndex = store.sbook.articles.findIndex(v => v.id === val.id)
|
||||
if (rIndex > -1) {
|
||||
store.sbook.articles[rIndex] = cloneDeep(val)
|
||||
}
|
||||
setArticle(val)
|
||||
}
|
||||
|
||||
function edit(val: Article = articleData.article) {
|
||||
editArticle = val
|
||||
showEditArticle = true
|
||||
}
|
||||
|
||||
function wrong(word: Word) {
|
||||
let lowerName = word.word.toLowerCase();
|
||||
if (!store.wrong.words.find((v: Word) => v.word.toLowerCase() === lowerName)) {
|
||||
store.wrong.words.push(word)
|
||||
}
|
||||
if (!store.allIgnoreWords.includes(lowerName)) {
|
||||
//todo
|
||||
}
|
||||
}
|
||||
|
||||
function nextWord(word: ArticleWord) {
|
||||
if (!store.allIgnoreWords.includes(word.word.toLowerCase()) && !word.isSymbol) {
|
||||
statisticsStore.inputWordNumber++
|
||||
}
|
||||
}
|
||||
|
||||
function changeArticle(val: ArticleItem) {
|
||||
let rIndex = articleData.list.findIndex(v => v.id === val.item.id)
|
||||
if (rIndex > -1) {
|
||||
store.sbook.lastLearnIndex = rIndex
|
||||
getCurrentPractice()
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
isArticleCollect,
|
||||
toggleArticleCollect
|
||||
} = useArticleOptions()
|
||||
|
||||
function play() {
|
||||
typingArticleRef?.play()
|
||||
}
|
||||
|
||||
function show() {
|
||||
typingArticleRef?.showSentence()
|
||||
}
|
||||
|
||||
function onKeyUp() {
|
||||
typingArticleRef.hideSentence()
|
||||
}
|
||||
|
||||
async function onKeyDown(e: KeyboardEvent) {
|
||||
// console.log('e', e)
|
||||
switch (e.key) {
|
||||
case 'Backspace':
|
||||
typingArticleRef.del()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
useOnKeyboardEventListener(onKeyDown, onKeyUp)
|
||||
|
||||
useEvents([
|
||||
[EventKey.write, write],
|
||||
[EventKey.repeatStudy, repeat],
|
||||
[EventKey.continueStudy, next],
|
||||
|
||||
[ShortcutKey.PreviousChapter, prev],
|
||||
[ShortcutKey.RepeatChapter, repeat],
|
||||
[ShortcutKey.DictationChapter, write],
|
||||
[ShortcutKey.ToggleShowTranslate, toggleShowTranslate],
|
||||
[ShortcutKey.ToggleDictation, toggleDictation],
|
||||
[ShortcutKey.ToggleTheme, toggleTheme],
|
||||
[ShortcutKey.ToggleConciseMode, toggleConciseMode],
|
||||
[ShortcutKey.TogglePanel, togglePanel],
|
||||
[ShortcutKey.NextChapter, next],
|
||||
[ShortcutKey.PlayWordPronunciation, play],
|
||||
[ShortcutKey.ShowWord, show],
|
||||
[ShortcutKey.Next, skip],
|
||||
[ShortcutKey.ToggleCollect, collect],
|
||||
[ShortcutKey.EditArticle, shortcutKeyEdit],
|
||||
])
|
||||
|
||||
let speedMinute = $ref(0)
|
||||
let timer = $ref(0)
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
speedMinute = Math.floor((Date.now() - statisticsStore.startDate) / 1000 / 60)
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(EventKey.write, write)
|
||||
emitter.off(EventKey.repeatStudy, repeat)
|
||||
emitter.off(EventKey.jumpSpecifiedChapter, jumpSpecifiedChapter)
|
||||
|
||||
emitter.off(ShortcutKey.PreviousChapter, prev)
|
||||
emitter.off(ShortcutKey.RepeatChapter, repeat)
|
||||
emitter.off(ShortcutKey.DictationChapter, write)
|
||||
emitter.off(ShortcutKey.ToggleShowTranslate, toggleShowTranslate)
|
||||
emitter.off(ShortcutKey.ToggleDictation, toggleDictation)
|
||||
emitter.off(ShortcutKey.ToggleTheme, toggleTheme)
|
||||
emitter.off(ShortcutKey.ToggleConciseMode, toggleConciseMode)
|
||||
emitter.off(ShortcutKey.TogglePanel, togglePanel)
|
||||
timer && clearInterval(timer)
|
||||
})
|
||||
|
||||
useStartKeyboardEventListener()
|
||||
let audioRef = $ref<HTMLAudioElement>()
|
||||
const {playSentenceAudio} = usePlaySentenceAudio()
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<PracticeArticle ref="practiceRef"/>
|
||||
<Statistics/>
|
||||
<div class="practice-wrapper" v-loading="loading">
|
||||
<div class="practice-article">
|
||||
<TypingArticle
|
||||
ref="typingArticleRef"
|
||||
@edit="edit"
|
||||
@wrong="wrong"
|
||||
@next="next"
|
||||
@nextWord="nextWord"
|
||||
@play="e => playSentenceAudio(e,audioRef,articleData.article)"
|
||||
:article="articleData.article"
|
||||
/>
|
||||
|
||||
<div class="panel-wrapper">
|
||||
<Panel>
|
||||
<template v-slot:title>
|
||||
<span>{{
|
||||
store.sbook.name
|
||||
}} ({{ store.sbook.lastLearnIndex + 1 }} / {{ articleData.list.length }})</span>
|
||||
</template>
|
||||
<div class="panel-page-item pl-4">
|
||||
<ArticleList
|
||||
:isActive="true"
|
||||
:static="false"
|
||||
:show-translate="settingStore.translate"
|
||||
@click="changeArticle"
|
||||
:active-id="articleData.article.id"
|
||||
:list="articleData.list ">
|
||||
<template v-slot:suffix="{item,index}">
|
||||
<BaseIcon
|
||||
:class="!isArticleCollect(item) ? 'collect' : 'fill'"
|
||||
@click.stop="toggleArticleCollect(item)"
|
||||
:title="!isArticleCollect(item) ? '收藏' : '取消收藏'">
|
||||
<IconPhStar v-if="!isArticleCollect(item)"/>
|
||||
<IconPhStarFill v-else/>
|
||||
</BaseIcon>
|
||||
</template>
|
||||
</ArticleList>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<EditSingleArticleModal
|
||||
v-model="showEditArticle"
|
||||
:article="editArticle"
|
||||
@save="saveArticle"
|
||||
/>
|
||||
</div>
|
||||
<div class="footer" :class="!settingStore.showToolbar && 'hide'">
|
||||
<Tooltip :title="settingStore.showToolbar?'收起':'展开'">
|
||||
<IconIconParkOutlineDown
|
||||
@click="settingStore.showToolbar = !settingStore.showToolbar"
|
||||
class="arrow"
|
||||
:class="!settingStore.showToolbar && 'down'"
|
||||
width="24"
|
||||
color="#999"/>
|
||||
</Tooltip>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="stat">
|
||||
<div class="row">
|
||||
<div class="num">{{ speedMinute }}分钟</div>
|
||||
<div class="line"></div>
|
||||
<div class="name">时间</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="num">{{ statisticsStore.total }}</div>
|
||||
<div class="line"></div>
|
||||
<div class="name">单词总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<audio ref="audioRef" v-if="articleData.article.audioSrc" :src="articleData.article.audioSrc"
|
||||
controls></audio>
|
||||
<div class="flex flex-col items-center justify-center gap-1">
|
||||
<div class="flex gap-2 center">
|
||||
<BaseIcon
|
||||
:title="`下一句(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
|
||||
@click="skip">
|
||||
<IconIconParkOutlineGoAhead/>
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
:title="`重听(${settingStore.shortcutKeyMap[ShortcutKey.PlayWordPronunciation]})`"
|
||||
@click="play">
|
||||
<IconFluentReplay16Filled/>
|
||||
</BaseIcon>
|
||||
|
||||
<BaseIcon
|
||||
@click="settingStore.dictation = !settingStore.dictation"
|
||||
:title="`开关默写模式(${settingStore.shortcutKeyMap[ShortcutKey.ToggleDictation]})`"
|
||||
>
|
||||
<IconMajesticonsEyeOffLine v-if="settingStore.dictation"/>
|
||||
<IconMdiEyeOutline v-else/>
|
||||
</BaseIcon>
|
||||
|
||||
<BaseIcon
|
||||
:title="`开关释义显示(${settingStore.shortcutKeyMap[ShortcutKey.ToggleShowTranslate]})`"
|
||||
@click="settingStore.translate = !settingStore.translate">
|
||||
<IconMdiTranslate v-if="settingStore.translate"/>
|
||||
<IconMdiTranslateOff v-else/>
|
||||
</BaseIcon>
|
||||
|
||||
<!-- <BaseIcon-->
|
||||
<!-- :title="`编辑(${settingStore.shortcutKeyMap[ShortcutKey.EditArticle]})`"-->
|
||||
<!-- icon="tabler:edit"-->
|
||||
<!-- @click="emitter.emit(ShortcutKey.EditArticle)"-->
|
||||
<!-- />-->
|
||||
<BaseIcon
|
||||
@click="settingStore.showPanel = !settingStore.showPanel"
|
||||
:title="`面板(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`">
|
||||
<IconTdesignMenuUnfold/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConflictNotice/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.practice-wrapper {
|
||||
font-size: 0.9rem;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.swiper-wrapper {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.swiper-list {
|
||||
transition: transform .3s;
|
||||
height: 200%;
|
||||
|
||||
.swiper-item {
|
||||
height: 50%;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.step1 {
|
||||
transform: translate3d(0, -50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.practice-article {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
width: var(--article-width);
|
||||
}
|
||||
|
||||
.typing-word-wrapper {
|
||||
width: var(--toolbar-width);
|
||||
}
|
||||
|
||||
.panel-wrapper {
|
||||
position: absolute;
|
||||
left: var(--article-panel-margin-left);
|
||||
//left: 0;
|
||||
top: .8rem;
|
||||
z-index: 1;
|
||||
height: calc(100% - 1.5rem);
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: var(--article-toolbar-width);
|
||||
margin-bottom: .8rem;
|
||||
transition: all var(--anim-time);
|
||||
position: relative;
|
||||
margin-top: 1rem;
|
||||
|
||||
&.hide {
|
||||
margin-bottom: -6rem;
|
||||
margin-top: 3rem;
|
||||
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: .6rem;
|
||||
background: var(--color-second);
|
||||
padding: .5rem var(--space);
|
||||
z-index: 2;
|
||||
border: 1px solid var(--color-item-border);
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
.stat {
|
||||
margin-top: .5rem;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: var(--stat-gap);
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .3rem;
|
||||
width: 5rem;
|
||||
color: gray;
|
||||
|
||||
.line {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--color-sub-gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: 50%;
|
||||
cursor: pointer;
|
||||
transition: all .5s;
|
||||
transform: rotate(0);
|
||||
padding: .5rem;
|
||||
|
||||
&.down {
|
||||
top: -90%;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -3,18 +3,20 @@
|
||||
import {Article, Sentence, TranslateEngine} from "@/types/types.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import EditAbleText from "@/pages/pc/components/EditAbleText.vue";
|
||||
import {Icon} from "@iconify/vue";
|
||||
import {getNetworkTranslate, getSentenceAllText, getSentenceAllTranslateText} from "@/hooks/translate.ts";
|
||||
import {genArticleSectionData, splitCNArticle2, splitEnArticle2, usePlaySentenceAudio} from "@/hooks/article.ts";
|
||||
import {_nextTick, _parseLRC, cloneDeep, last} from "@/utils";
|
||||
import {watch} from "vue";
|
||||
import {defineAsyncComponent, watch} from "vue";
|
||||
import Empty from "@/components/Empty.vue";
|
||||
import {ElInputNumber, ElMessage, ElOption, ElPopover, ElSelect, ElUpload, UploadProps} from "element-plus";
|
||||
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
|
||||
import * as Comparison from "string-comparison"
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
|
||||
import {getDefaultArticle} from "@/types/func.ts";
|
||||
import copy from "copy-to-clipboard";
|
||||
import {Option, Select} from "@/pages/pc/components/base/select";
|
||||
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
|
||||
import InputNumber from "@/pages/pc/components/base/InputNumber.vue";
|
||||
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
|
||||
|
||||
interface IProps {
|
||||
article?: Article,
|
||||
@@ -36,8 +38,8 @@ let progress = $ref(0)
|
||||
let failCount = $ref(0)
|
||||
let textareaRef = $ref<HTMLTextAreaElement>()
|
||||
const TranslateEngineOptions = [
|
||||
{value: 'baidu', label: '百度'},
|
||||
{value: 'youdao', label: '有道'},
|
||||
{value: 'baidu', label: '百度'},
|
||||
]
|
||||
|
||||
let editArticle = $ref<Article>(getDefaultArticle())
|
||||
@@ -62,7 +64,7 @@ function apply(isHandle: boolean = true) {
|
||||
// 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!'"
|
||||
editArticle.sections = []
|
||||
ElMessage.error('请填写原文!')
|
||||
Toast.error('请填写原文!')
|
||||
return
|
||||
}
|
||||
failCount = genArticleSectionData(editArticle)
|
||||
@@ -91,10 +93,10 @@ function splitTranslateText() {
|
||||
//TODO
|
||||
async function startNetworkTranslate() {
|
||||
if (!editArticle.title.trim()) {
|
||||
return ElMessage.error('请填写标题!')
|
||||
return Toast.error('请填写标题!')
|
||||
}
|
||||
if (!editArticle.text.trim()) {
|
||||
return ElMessage.error('请填写正文!')
|
||||
return Toast.error('请填写正文!')
|
||||
}
|
||||
apply()
|
||||
//注意!!!
|
||||
@@ -132,11 +134,11 @@ function save(option: 'save' | 'saveAndNext') {
|
||||
editArticle.textTranslate = editArticle.textTranslate.trim()
|
||||
|
||||
if (!editArticle.title) {
|
||||
ElMessage.error('请填写标题!')
|
||||
Toast.error('请填写标题!')
|
||||
return resolve(false)
|
||||
}
|
||||
if (!editArticle.text) {
|
||||
ElMessage.error('请填写正文!')
|
||||
Toast.error('请填写正文!')
|
||||
return resolve(false)
|
||||
}
|
||||
|
||||
@@ -155,10 +157,11 @@ function save(option: 'save' | 'saveAndNext') {
|
||||
//不知道为什么直接用editArticle,取到是空的默认值
|
||||
defineExpose({save, getEditArticle: () => cloneDeep(editArticle)})
|
||||
|
||||
const handleChange: UploadProps['onChange'] = (uploadFile, uploadFiles) => {
|
||||
console.log(uploadFile)
|
||||
function handleChange(e: any) {
|
||||
let uploadFile = e.target?.files?.[0]
|
||||
if (!uploadFile) return
|
||||
let reader = new FileReader();
|
||||
reader.readAsText(uploadFile.raw, 'UTF-8');
|
||||
reader.readAsText(uploadFile, 'UTF-8');
|
||||
reader.onload = function (e) {
|
||||
let lrc: string = e.target.result as string;
|
||||
console.log(lrc)
|
||||
@@ -290,25 +293,23 @@ function setStartTime(val: Sentence, i: number, j: number) {
|
||||
>
|
||||
</textarea>
|
||||
<div class="justify-end items-center flex">
|
||||
<ElPopover
|
||||
class="box-item"
|
||||
title="使用方法"
|
||||
placement="top"
|
||||
:width="400"
|
||||
>
|
||||
<ol class="py-0 pl-5 my-0 text-base color-main">
|
||||
<li>复制原文,然后分句</li>
|
||||
<li>点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span
|
||||
class="color-red font-bold"> 或</span> 手动编辑分句
|
||||
</li>
|
||||
<li>分句规则:一行一句,段落间空一行</li>
|
||||
<li>修改完成后点击 <span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏
|
||||
</li>
|
||||
</ol>
|
||||
<Tooltip>
|
||||
<IconRiQuestionLine class="mr-3" width="20"/>
|
||||
<template #reference>
|
||||
<Icon icon="ri:question-line" class="mr-3" width="20"/>
|
||||
<div>
|
||||
<div class="mb-2">使用方法</div>
|
||||
<ol class="py-0 pl-5 my-0 text-base color-main">
|
||||
<li>复制原文,然后分句</li>
|
||||
<li>点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span
|
||||
class="color-red font-bold"> 或</span> 手动编辑分句
|
||||
</li>
|
||||
<li>分句规则:一行一句,段落间空一行</li>
|
||||
<li>修改完成后点击 <span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</ElPopover>
|
||||
</Tooltip>
|
||||
<BaseButton @click="splitText">分句</BaseButton>
|
||||
<BaseButton @click="apply()">应用</BaseButton>
|
||||
</div>
|
||||
@@ -342,37 +343,36 @@ function setStartTime(val: Sentence, i: number, j: number) {
|
||||
<BaseButton @click="startNetworkTranslate"
|
||||
:loading="progress!==0 && progress !== 100">翻译
|
||||
</BaseButton>
|
||||
<ElSelect v-model="networkTranslateEngine"
|
||||
<Select v-model="networkTranslateEngine"
|
||||
>
|
||||
<ElOption
|
||||
<Option
|
||||
v-for="item in TranslateEngineOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</Select>
|
||||
{{ progress }}%
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<ElPopover
|
||||
class="box-item"
|
||||
title="使用方法"
|
||||
placement="top"
|
||||
:width="400"
|
||||
>
|
||||
<ol class="py-0 pl-5 my-0 text-base color-black/60">
|
||||
<li>复制译文,如果没有请点击 <span class="color-red font-bold">翻译</span> 按钮</li>
|
||||
<li>点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span class="color-red font-bold"> 或</span>
|
||||
手动编辑分句
|
||||
</li>
|
||||
<li>分句规则:一行一句,段落间空一行</li>
|
||||
<li>修改完成后点击 <span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏
|
||||
</li>
|
||||
</ol>
|
||||
<Tooltip>
|
||||
<IconRiQuestionLine class="mr-3" width="20"/>
|
||||
<template #reference>
|
||||
<Icon icon="ri:question-line" class="mr-3" width="20"/>
|
||||
<div>
|
||||
<div class="mb-2">使用方法</div>
|
||||
<ol class="py-0 pl-5 my-0 text-base color-black/60">
|
||||
<li>复制译文,如果没有请点击 <span class="color-red font-bold">翻译</span> 按钮</li>
|
||||
<li>点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span
|
||||
class="color-red font-bold"> 或</span>
|
||||
手动编辑分句
|
||||
</li>
|
||||
<li>分句规则:一行一句,段落间空一行</li>
|
||||
<li>修改完成后点击 <span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</ElPopover>
|
||||
</Tooltip>
|
||||
<BaseButton @click="splitTranslateText">分句</BaseButton>
|
||||
<BaseButton @click="apply(true)">应用</BaseButton>
|
||||
</div>
|
||||
@@ -383,14 +383,12 @@ function setStartTime(val: Sentence, i: number, j: number) {
|
||||
<div class="center">正文、译文与结果均可编辑,编辑后点击应用按钮会自动同步</div>
|
||||
<div class="flex gap-2">
|
||||
<BaseButton>添加音频</BaseButton>
|
||||
<ElUpload
|
||||
class="upload-demo"
|
||||
:limit="1"
|
||||
:on-change="handleChange"
|
||||
:auto-upload="false"
|
||||
>
|
||||
<div class="upload relative">
|
||||
<BaseButton>添加音频LRC文件</BaseButton>
|
||||
</ElUpload>
|
||||
<input type="file"
|
||||
@change="handleChange"
|
||||
class="w-full h-full absolute left-0 top-0 opacity-0"/>
|
||||
</div>
|
||||
<audio ref="audioRef" :src="editArticle.audioSrc" controls></audio>
|
||||
</div>
|
||||
<template v-if="editArticle?.sections?.length">
|
||||
@@ -422,9 +420,11 @@ function setStartTime(val: Sentence, i: number, j: number) {
|
||||
<div>{{ sentence.audioPosition?.[0] ?? 0 }}s</div>
|
||||
<BaseIcon
|
||||
@click="setStartTime(sentence,indexI,indexJ)"
|
||||
:icon="indexI === 0 && indexJ === 0 ?'ic:sharp-my-location':'twemoji:end-arrow'"
|
||||
:title="indexI === 0 && indexJ === 0 ?'设置开始时间':'使用前一句的结束时间'"
|
||||
/>
|
||||
>
|
||||
<IconIcSharpMyLocation v-if="indexI === 0 && indexJ === 0"/>
|
||||
<IconTwemojiEndArrow v-else/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
<div>-</div>
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
@@ -433,15 +433,21 @@ function setStartTime(val: Sentence, i: number, j: number) {
|
||||
<BaseIcon
|
||||
@click="sentence.audioPosition[1] = Number(Number(audioRef.currentTime).toFixed(2))"
|
||||
title="设置结束时间"
|
||||
icon="ic:sharp-my-location"
|
||||
/>
|
||||
>
|
||||
<IconIcSharpMyLocation/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<BaseIcon :icon="sentence.audioPosition?.length ? 'basil:edit-outline' : 'basil:add-outline'"
|
||||
@click="handleShowEditAudioDialog(sentence,indexI,indexJ)"/>
|
||||
<BaseIcon v-if="sentence.audioPosition?.length" icon="hugeicons:play"
|
||||
@click="playSentenceAudio(sentence,audioRef,editArticle)"/>
|
||||
@click="handleShowEditAudioDialog(sentence,indexI,indexJ)">
|
||||
<IconBasilEditOutline v-if="sentence.audioPosition?.length"/>
|
||||
<IconBasilAddOutline v-else/>
|
||||
</BaseIcon>
|
||||
<BaseIcon v-if="sentence.audioPosition?.length"
|
||||
@click="playSentenceAudio(sentence,audioRef,editArticle)">
|
||||
<IconHugeiconsPlay/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -452,11 +458,11 @@ function setStartTime(val: Sentence, i: number, j: number) {
|
||||
<div class="status">
|
||||
<span>状态:</span>
|
||||
<div class="warning" v-if="failCount">
|
||||
<Icon icon="typcn:warning-outline"/>
|
||||
<IconTypcnWarningOutline/>
|
||||
共有{{ failCount }}句没有翻译!
|
||||
</div>
|
||||
<div class="success" v-else>
|
||||
<Icon icon="mdi:success-circle-outline"/>
|
||||
<IconMdiSuccessCircleOutline/>
|
||||
翻译完成!
|
||||
</div>
|
||||
</div>
|
||||
@@ -488,9 +494,11 @@ function setStartTime(val: Sentence, i: number, j: number) {
|
||||
<span v-if="editSentence.audioPosition?.[1] !== -1"> - {{ editSentence.audioPosition?.[1] }}s</span>
|
||||
<span v-else> - 结束</span>
|
||||
</div>
|
||||
<BaseIcon icon="hugeicons:play"
|
||||
<BaseIcon
|
||||
title="试听"
|
||||
@click="playSentenceAudio(editSentence,sentenceAudioRef,editArticle)"/>
|
||||
@click="playSentenceAudio(editSentence,sentenceAudioRef,editArticle)">
|
||||
<IconHugeiconsPlay/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -498,21 +506,19 @@ function setStartTime(val: Sentence, i: number, j: number) {
|
||||
<div>开始时间:</div>
|
||||
<div class="flex justify-between flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<ElInputNumber v-model="editSentence.audioPosition[0]" :precision="2" :step="0.1">
|
||||
<template #suffix>
|
||||
<span>s</span>
|
||||
</template>
|
||||
</ElInputNumber>
|
||||
<InputNumber v-model="editSentence.audioPosition[0]" :precision="2" :step="0.1"/>
|
||||
<BaseIcon
|
||||
@click="jumpAudio(editSentence.audioPosition[0])"
|
||||
title="跳转"
|
||||
icon="ic:sharp-my-location"
|
||||
/>
|
||||
>
|
||||
<IconIcSharpMyLocation/>
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
@click="setPreEndTimeToCurrentStartTime"
|
||||
title="使用前一句的结束时间"
|
||||
icon="twemoji:end-arrow"
|
||||
/>
|
||||
>
|
||||
<IconTwemojiEndArrow/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
<BaseButton @click="recordStart">记录</BaseButton>
|
||||
</div>
|
||||
@@ -521,11 +527,7 @@ function setStartTime(val: Sentence, i: number, j: number) {
|
||||
<div>结束时间:</div>
|
||||
<div class="flex justify-between flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<ElInputNumber v-model="editSentence.audioPosition[1]" :precision="2" :step="0.1">
|
||||
<template #suffix>
|
||||
<span>s</span>
|
||||
</template>
|
||||
</ElInputNumber>
|
||||
<InputNumber v-model="editSentence.audioPosition[1]" :precision="2" :step="0.1"/>
|
||||
<span>或</span>
|
||||
<BaseButton size="small" @click="editSentence.audioPosition[1] = -1">结束</BaseButton>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import {Dict, DictId, DictType} from "@/types/types.ts";
|
||||
import {cloneDeep} from "@/utils";
|
||||
|
||||
import {ElForm, ElFormItem, ElInput, ElSelect, ElOption, FormInstance, FormRules, ElMessage} from "element-plus";
|
||||
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
|
||||
import {onMounted, reactive} from "vue";
|
||||
import {useRuntimeStore} from "@/stores/runtime.ts";
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import {getDefaultDict} from "@/types/func.ts";
|
||||
import {Option, Select} from "@/pages/pc/components/base/select";
|
||||
import BaseInput from "@/pages/pc/components/base/BaseInput.vue";
|
||||
import Form from "@/pages/pc/components/base/form/Form.vue";
|
||||
import FormItem from "@/pages/pc/components/base/form/FormItem.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
isAdd: boolean,
|
||||
@@ -31,8 +34,8 @@ const DefaultDictForm = {
|
||||
type: DictType.article
|
||||
}
|
||||
let dictForm: any = $ref(cloneDeep(DefaultDictForm))
|
||||
const dictFormRef = $ref<FormInstance>()
|
||||
const dictRules = reactive<FormRules>({
|
||||
const dictFormRef = $ref()
|
||||
const dictRules = reactive({
|
||||
name: [
|
||||
{required: true, message: '请输入名称', trigger: 'blur'},
|
||||
{max: 20, message: '名称不能超过20个字符', trigger: 'blur'},
|
||||
@@ -54,13 +57,13 @@ async function onSubmit() {
|
||||
if (props.isAdd) {
|
||||
data.id = 'custom-dict-' + Date.now()
|
||||
if (source.bookList.find(v => v.name === data.name)) {
|
||||
ElMessage.warning('已有相同名称!')
|
||||
Toast.warning('已有相同名称!')
|
||||
return
|
||||
} else {
|
||||
source.bookList.push(cloneDeep(data))
|
||||
runtimeStore.editDict = data
|
||||
emit('submit')
|
||||
ElMessage.success('添加成功')
|
||||
Toast.success('添加成功')
|
||||
}
|
||||
} else {
|
||||
let rIndex = source.bookList.findIndex(v => v.id === data.id)
|
||||
@@ -68,15 +71,15 @@ async function onSubmit() {
|
||||
if (rIndex > -1) {
|
||||
source.bookList[rIndex] = cloneDeep(data)
|
||||
emit('submit')
|
||||
ElMessage.success('修改成功')
|
||||
Toast.success('修改成功')
|
||||
} else {
|
||||
source.bookList.push(cloneDeep(data))
|
||||
ElMessage.success('修改成功并加入我的词典')
|
||||
Toast.success('修改成功并加入我的词典')
|
||||
}
|
||||
}
|
||||
console.log('submit!', data)
|
||||
} else {
|
||||
ElMessage.warning('请填写完整')
|
||||
Toast.warning('请填写完整')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -91,38 +94,38 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="w-120 mt-4">
|
||||
<ElForm
|
||||
<Form
|
||||
ref="dictFormRef"
|
||||
:rules="dictRules"
|
||||
:model="dictForm"
|
||||
label-width="8rem">
|
||||
<ElFormItem label="名称" prop="name">
|
||||
<ElInput v-model="dictForm.name"/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="描述">
|
||||
<ElInput v-model="dictForm.description" type="textarea"/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="原文语言">
|
||||
<ElSelect v-model="dictForm.language" placeholder="请选择选项">
|
||||
<ElOption label="英语" value="en"/>
|
||||
<ElOption label="德语" value="de"/>
|
||||
<ElOption label="日语" value="ja"/>
|
||||
<ElOption label="代码" value="code"/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="译文语言">
|
||||
<ElSelect v-model="dictForm.translateLanguage" placeholder="请选择选项">
|
||||
<ElOption label="中文" value="zh-CN"/>
|
||||
<ElOption label="英语" value="en"/>
|
||||
<ElOption label="德语" value="de"/>
|
||||
<ElOption label="日语" value="ja"/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<FormItem label="名称" prop="name">
|
||||
<BaseInput v-model="dictForm.name"/>
|
||||
</FormItem>
|
||||
<FormItem label="描述">
|
||||
<BaseInput v-model="dictForm.description" textarea/>
|
||||
</FormItem>
|
||||
<FormItem label="原文语言">
|
||||
<Select v-model="dictForm.language" placeholder="请选择选项">
|
||||
<Option label="英语" value="en"/>
|
||||
<Option label="德语" value="de"/>
|
||||
<Option label="日语" value="ja"/>
|
||||
<Option label="代码" value="code"/>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="译文语言">
|
||||
<Select v-model="dictForm.translateLanguage" placeholder="请选择选项">
|
||||
<Option label="中文" value="zh-CN"/>
|
||||
<Option label="英语" value="en"/>
|
||||
<Option label="德语" value="de"/>
|
||||
<Option label="日语" value="ja"/>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<div class="center">
|
||||
<base-button type="info" @click="emit('close')">关闭</base-button>
|
||||
<base-button type="primary" @click="onSubmit">确定</base-button>
|
||||
</div>
|
||||
</ElForm>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {Article} from "@/types/types.ts";
|
||||
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
|
||||
import {useDisableEventListener} from "@/hooks/event.ts";
|
||||
import EditArticle from "@/pages/pc/article/components/EditArticle.vue";
|
||||
import {getDefaultArticle} from "@/types/func.ts";
|
||||
import {defineAsyncComponent} from "vue";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
|
||||
|
||||
interface IProps {
|
||||
article?: Article
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, useTemplateRef} from 'vue'
|
||||
import QuestionItem from './QuestionItem.vue'
|
||||
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
|
||||
|
||||
interface IProps {
|
||||
questions: Array,
|
||||
@@ -90,7 +91,7 @@ const submitAll = () => {
|
||||
const wrongCount = results.length - correctCount
|
||||
|
||||
console.log('最终结果:', results)
|
||||
ElMessage({message: `共 ${results.length} 题,答对 ${correctCount},答错 ${wrongCount}`})
|
||||
Toast.success(`共 ${results.length} 题,答对 ${correctCount},答错 ${wrongCount}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {getTranslateText} from "@/hooks/article.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import QuestionForm from "@/pages/pc/article/components/QuestionForm.vue";
|
||||
import {getDefaultArticle} from "@/types/func.ts";
|
||||
import {ElMessage} from "element-plus";
|
||||
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
|
||||
|
||||
interface IProps {
|
||||
article: Article,
|
||||
@@ -21,7 +21,6 @@ interface IProps {
|
||||
sentenceIndex?: number,
|
||||
wordIndex?: number,
|
||||
stringIndex?: number,
|
||||
active: boolean,
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
@@ -30,7 +29,6 @@ const props = withDefaults(defineProps<IProps>(), {
|
||||
sentenceIndex: 0,
|
||||
wordIndex: 0,
|
||||
stringIndex: 0,
|
||||
active: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -38,11 +36,11 @@ const emit = defineEmits<{
|
||||
wrong: [val: Word],
|
||||
play: [val: Sentence],
|
||||
nextWord: [val: ArticleWord],
|
||||
over: [],
|
||||
complete: [],
|
||||
next: [],
|
||||
edit: [val: Article]
|
||||
}>()
|
||||
|
||||
let isPlay = $ref(false)
|
||||
let typeArticleRef = $ref<HTMLInputElement>(null)
|
||||
let articleWrapperRef = $ref<HTMLInputElement>(null)
|
||||
let sectionIndex = $ref(0)
|
||||
@@ -94,7 +92,6 @@ watch(() => settingStore.translate, () => {
|
||||
checkTranslateLocation().then(() => checkCursorPosition())
|
||||
})
|
||||
|
||||
|
||||
function checkCursorPosition(a = sectionIndex, b = sentenceIndex, c = wordIndex) {
|
||||
// console.log('checkCursorPosition')
|
||||
_nextTick(() => {
|
||||
@@ -144,7 +141,6 @@ function checkTranslateLocation() {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
let lockNextSentence = false
|
||||
|
||||
function nextSentence() {
|
||||
@@ -164,7 +160,7 @@ function nextSentence() {
|
||||
input = wrong = ''
|
||||
|
||||
//todo 计得把略过的单词加上统计里面去
|
||||
// if (!store.knownWordsWithSimpleWords.includes(currentWord.word.toLowerCase()) && !currentWord.isSymbol) {
|
||||
// if (!store.allIgnoreWords.includes(currentWord.word.toLowerCase()) && !currentWord.isSymbol) {
|
||||
// statisticsStore.inputNumber++
|
||||
// }
|
||||
|
||||
@@ -175,7 +171,7 @@ function nextSentence() {
|
||||
if (!props.article.sections[sectionIndex]) {
|
||||
console.log('打完了')
|
||||
isEnd = true
|
||||
emit('over')
|
||||
emit('complete')
|
||||
} else {
|
||||
emit('play', props.article.sections[sectionIndex][0])
|
||||
}
|
||||
@@ -186,7 +182,6 @@ function nextSentence() {
|
||||
}
|
||||
|
||||
function onTyping(e: KeyboardEvent) {
|
||||
if (!props.active) return
|
||||
if (!props.article.sections.length) return
|
||||
// console.log('keyDown', e.key, e.code, e.keyCode)
|
||||
wrong = ''
|
||||
@@ -321,10 +316,7 @@ function onContextMenu(e: MouseEvent, sentence: Sentence, i, j) {
|
||||
label: "复制",
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(sentence.text).then(r => {
|
||||
ElMessage({
|
||||
message: '已复制',
|
||||
type: 'success',
|
||||
})
|
||||
Toast.success('已复制')
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -332,11 +324,7 @@ function onContextMenu(e: MouseEvent, sentence: Sentence, i, j) {
|
||||
label: "语法分析",
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(sentence.text).then(r => {
|
||||
ElMessage({
|
||||
message: '已复制!随后将打开语法分析网站!',
|
||||
type: 'success',
|
||||
duration: 3000
|
||||
})
|
||||
Toast.success('已复制!随后将打开语法分析网站!')
|
||||
setTimeout(() => {
|
||||
window.open('https://enpuz.com/')
|
||||
}, 1000)
|
||||
@@ -458,7 +446,7 @@ let showQuestions = $ref(false)
|
||||
<div class="options flex justify-center" v-if="isEnd">
|
||||
<BaseButton
|
||||
v-if="store.currentBook.lastLearnIndex < store.currentBook.articles.length - 1"
|
||||
@click="emitter.emit(EventKey.continueStudy)">下一章
|
||||
@click="emit('next')">下一章
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
@@ -1,520 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import TypingArticle from "./TypingArticle.vue";
|
||||
import {Article, ArticleItem, ArticleWord, DisplayStatistics, ShortcutKey, Word} from "@/types/types.ts";
|
||||
import {cloneDeep} from "@/utils";
|
||||
import Panel from "../../components/Panel.vue";
|
||||
import {onMounted, onUnmounted} from "vue";
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
import EditSingleArticleModal from "@/pages/pc/article/components/EditSingleArticleModal.vue";
|
||||
import {usePracticeStore} from "@/stores/practice.ts";
|
||||
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
|
||||
import {useRuntimeStore} from "@/stores/runtime.ts";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import {useArticleOptions} from "@/hooks/dict.ts";
|
||||
import ArticleList from "@/pages/pc/components/list/ArticleList.vue";
|
||||
import {useOnKeyboardEventListener} from "@/hooks/event.ts";
|
||||
import {genArticleSectionData, usePlaySentenceAudio} from "@/hooks/article.ts";
|
||||
import {ElProgress} from 'element-plus';
|
||||
import router from "@/router.ts";
|
||||
import {getDefaultArticle} from "@/types/func.ts";
|
||||
|
||||
const store = useBaseStore()
|
||||
const statisticsStore = usePracticeStore()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
|
||||
let tabIndex = $ref(0)
|
||||
let wordData = $ref({
|
||||
words: [],
|
||||
index: -1
|
||||
})
|
||||
let articleData = $ref({
|
||||
articles: [],
|
||||
article: getDefaultArticle(),
|
||||
sectionIndex: 0,
|
||||
sentenceIndex: 0,
|
||||
wordIndex: 0,
|
||||
stringIndex: 0,
|
||||
})
|
||||
let showEditArticle = $ref(false)
|
||||
let typingArticleRef = $ref<any>()
|
||||
let editArticle = $ref<Article>(getDefaultArticle())
|
||||
let articleIsActive = $computed(() => tabIndex === 0)
|
||||
|
||||
function next() {
|
||||
if (!articleIsActive) return
|
||||
if (store.currentBook.lastLearnIndex >= articleData.articles.length - 1) {
|
||||
store.currentBook.lastLearnIndex = 0
|
||||
} else store.currentBook.lastLearnIndex++
|
||||
|
||||
emitter.emit(EventKey.resetWord)
|
||||
getCurrentPractice()
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!store.currentBook?.articles?.length) {
|
||||
router.push('/article')
|
||||
return
|
||||
}
|
||||
articleData.articles = cloneDeep(store.currentBook.articles)
|
||||
getCurrentPractice()
|
||||
console.log('inin', articleData.article)
|
||||
}
|
||||
|
||||
function setArticle(val: Article) {
|
||||
let tempVal = cloneDeep(val)
|
||||
articleData.articles[store.currentBook.lastLearnIndex] = tempVal
|
||||
articleData.article = tempVal
|
||||
statisticsStore.inputWordNumber = 0
|
||||
statisticsStore.wrong = 0
|
||||
statisticsStore.total = 0
|
||||
statisticsStore.startDate = Date.now()
|
||||
articleData.article.sections.map((v, i) => {
|
||||
v.map((w, j) => {
|
||||
w.words.map(s => {
|
||||
if (!store.knownWordsWithSimpleWords.includes(s.word.toLowerCase()) && !s.isSymbol) {
|
||||
statisticsStore.total++
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getCurrentPractice() {
|
||||
// console.log('store.currentBook',store.currentBook)
|
||||
// return
|
||||
tabIndex = 0
|
||||
articleData.article = getDefaultArticle()
|
||||
|
||||
let currentArticle = articleData.articles[store.currentBook.lastLearnIndex]
|
||||
let tempArticle = getDefaultArticle(currentArticle)
|
||||
// console.log('article', tempArticle)
|
||||
if (tempArticle.sections.length) {
|
||||
setArticle(tempArticle)
|
||||
} else {
|
||||
genArticleSectionData(tempArticle)
|
||||
setArticle(tempArticle)
|
||||
}
|
||||
}
|
||||
|
||||
function saveArticle(val: Article) {
|
||||
console.log('saveArticle', val, JSON.stringify(val.lrcPosition))
|
||||
console.log('saveArticle', val.textTranslate)
|
||||
showEditArticle = false
|
||||
let rIndex = store.currentBook.articles.findIndex(v => v.id === val.id)
|
||||
if (rIndex > -1) {
|
||||
store.currentBook.articles[rIndex] = cloneDeep(val)
|
||||
}
|
||||
setArticle(val)
|
||||
}
|
||||
|
||||
function edit(val: Article = articleData.article) {
|
||||
if (!articleIsActive) return
|
||||
// tabIndex = 1
|
||||
// wordData.words = [
|
||||
// {
|
||||
// ...cloneDeep(DefaultWord),
|
||||
// word: 'test'
|
||||
// }
|
||||
// ]
|
||||
// wordData.index = 0
|
||||
// return
|
||||
editArticle = val
|
||||
showEditArticle = true
|
||||
}
|
||||
|
||||
function wrong(word: Word) {
|
||||
let lowerName = word.word.toLowerCase();
|
||||
if (!store.wrong.words.find((v: Word) => v.word.toLowerCase() === lowerName)) {
|
||||
store.wrong.words.push(word)
|
||||
}
|
||||
if (!store.knownWordsWithSimpleWords.includes(lowerName)) {
|
||||
}
|
||||
}
|
||||
|
||||
function over() {
|
||||
if (statisticsStore.wrong === 0) {
|
||||
// if (false) {
|
||||
console.log('这章节完了')
|
||||
let now = Date.now()
|
||||
let stat: DisplayStatistics = {
|
||||
startDate: statisticsStore.startDate,
|
||||
endDate: now,
|
||||
spend: now - statisticsStore.startDate,
|
||||
total: statisticsStore.total,
|
||||
correctRate: -1,
|
||||
wrong: statisticsStore.wrong,
|
||||
}
|
||||
stat.correctRate = 100 - Math.trunc(((stat.wrong) / (stat.total)) * 100)
|
||||
} else {
|
||||
tabIndex = 1
|
||||
wordData.index = 0
|
||||
}
|
||||
}
|
||||
|
||||
function nextWord(word: ArticleWord) {
|
||||
if (!store.knownWordsWithSimpleWords.includes(word.word.toLowerCase()) && !word.isSymbol) {
|
||||
statisticsStore.inputWordNumber++
|
||||
}
|
||||
}
|
||||
|
||||
function handleChangeChapterIndex(val: ArticleItem) {
|
||||
let rIndex = articleData.articles.findIndex(v => v.id === val.item.id)
|
||||
if (rIndex > -1) {
|
||||
store.currentBook.lastLearnIndex = rIndex
|
||||
getCurrentPractice()
|
||||
}
|
||||
}
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const {
|
||||
isArticleCollect,
|
||||
toggleArticleCollect
|
||||
} = useArticleOptions()
|
||||
|
||||
function sort(list: Word[]) {
|
||||
wordData.words = list
|
||||
wordData.index = 0
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (!articleIsActive) return
|
||||
typingArticleRef?.play()
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (!articleIsActive) return
|
||||
typingArticleRef?.showSentence()
|
||||
}
|
||||
|
||||
|
||||
function onKeyUp(e: KeyboardEvent) {
|
||||
typingArticleRef.hideSentence()
|
||||
}
|
||||
|
||||
async function onKeyDown(e: KeyboardEvent) {
|
||||
// console.log('e', e)
|
||||
switch (e.key) {
|
||||
case 'Backspace':
|
||||
typingArticleRef.del()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
useOnKeyboardEventListener(onKeyDown, onKeyUp)
|
||||
|
||||
function skip() {
|
||||
if (!articleIsActive) return
|
||||
typingArticleRef?.nextSentence()
|
||||
}
|
||||
|
||||
function collect(e: KeyboardEvent) {
|
||||
if (!articleIsActive) return
|
||||
toggleArticleCollect(articleData.article)
|
||||
}
|
||||
|
||||
//包装一遍,因为快捷建的默认参数是Event
|
||||
function shortcutKeyEdit() {
|
||||
edit()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
|
||||
useEvents([
|
||||
[EventKey.changeDict, init],
|
||||
[EventKey.continueStudy, next],
|
||||
|
||||
[ShortcutKey.NextChapter, next],
|
||||
[ShortcutKey.PlayWordPronunciation, play],
|
||||
[ShortcutKey.ShowWord, show],
|
||||
[ShortcutKey.Next, skip],
|
||||
[ShortcutKey.ToggleCollect, collect],
|
||||
[ShortcutKey.EditArticle, shortcutKeyEdit],
|
||||
])
|
||||
|
||||
defineExpose({getCurrentPractice})
|
||||
|
||||
const emit = defineEmits<{
|
||||
ignore: [],
|
||||
wrong: [val: Word],
|
||||
nextWord: [val: ArticleWord],
|
||||
over: [],
|
||||
edit: [val: Article]
|
||||
}>()
|
||||
|
||||
function format(val: number, suffix: string = '', check: number = -1) {
|
||||
return val === check ? '-' : (val + suffix)
|
||||
}
|
||||
|
||||
const progress = $computed(() => {
|
||||
if (!statisticsStore.total) return 0
|
||||
if (statisticsStore.index > statisticsStore.total) return 100
|
||||
return ((statisticsStore.index / statisticsStore.total) * 100)
|
||||
})
|
||||
|
||||
let speedMinute = $ref(0)
|
||||
let timer = $ref(0)
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
speedMinute = Math.floor((Date.now() - statisticsStore.startDate) / 1000 / 60)
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
timer && clearInterval(timer)
|
||||
})
|
||||
|
||||
let audioRef = $ref<HTMLAudioElement>()
|
||||
const {playSentenceAudio} = usePlaySentenceAudio()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="practice-wrapper">
|
||||
<div class="practice-article">
|
||||
<TypingArticle
|
||||
ref="typingArticleRef"
|
||||
:active="tabIndex === 0"
|
||||
@edit="edit"
|
||||
@wrong="wrong"
|
||||
@over="skip"
|
||||
@nextWord="nextWord"
|
||||
@play="e => playSentenceAudio(e,audioRef,articleData.article)"
|
||||
:article="articleData.article"
|
||||
/>
|
||||
|
||||
<div class="panel-wrapper">
|
||||
<Panel>
|
||||
<template v-slot:title>
|
||||
<span>{{
|
||||
store.currentBook.name
|
||||
}} ({{ store.currentBook.lastLearnIndex + 1 }} / {{ articleData.articles.length }})</span>
|
||||
</template>
|
||||
<div class="panel-page-item pl-4">
|
||||
<ArticleList
|
||||
:isActive="true"
|
||||
:static="false"
|
||||
:show-translate="settingStore.translate"
|
||||
@click="handleChangeChapterIndex"
|
||||
:active-id="articleData.article.id"
|
||||
:list="articleData.articles ">
|
||||
<template v-slot:suffix="{item,index}">
|
||||
<BaseIcon
|
||||
v-if="!isArticleCollect(item)"
|
||||
class="collect"
|
||||
@click="toggleArticleCollect(item)"
|
||||
title="收藏" icon="ph:star"/>
|
||||
<BaseIcon
|
||||
v-else
|
||||
class="fill"
|
||||
@click="toggleArticleCollect(item)"
|
||||
title="取消收藏" icon="ph:star-fill"/>
|
||||
</template>
|
||||
</ArticleList>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<EditSingleArticleModal
|
||||
v-model="showEditArticle"
|
||||
:article="editArticle"
|
||||
@save="saveArticle"
|
||||
/>
|
||||
</div>
|
||||
<div class="footer" :class="!settingStore.showToolbar && 'hide'">
|
||||
<div class="bottom">
|
||||
<ElProgress
|
||||
class="flex-1"
|
||||
:percentage="progress"
|
||||
:stroke-width="8"
|
||||
:show-text="false"/>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="stat">
|
||||
<div class="row">
|
||||
<div class="num">{{ speedMinute }}分钟</div>
|
||||
<div class="line"></div>
|
||||
<div class="name">时间</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="num">{{ statisticsStore.total }}</div>
|
||||
<div class="line"></div>
|
||||
<div class="name">单词总数</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="num">{{ format(statisticsStore.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="line"></div>
|
||||
<div class="name">错误数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-1">
|
||||
<audio ref="audioRef" v-if="articleData.article.audioSrc" :src="articleData.article.audioSrc"
|
||||
controls></audio>
|
||||
<div class="flex gap-2 center">
|
||||
<BaseIcon
|
||||
:title="`下一句(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
|
||||
icon="icon-park-outline:go-ahead"
|
||||
@click="emit('over')"/>
|
||||
<BaseIcon
|
||||
:title="`重听(${settingStore.shortcutKeyMap[ShortcutKey.PlayWordPronunciation]})`"
|
||||
icon="fluent:replay-16-filled"
|
||||
@click="play"/>
|
||||
|
||||
<BaseIcon
|
||||
@click="settingStore.dictation = !settingStore.dictation"
|
||||
:title="`开关默写模式(${settingStore.shortcutKeyMap[ShortcutKey.ToggleDictation]})`"
|
||||
:icon="['majesticons:eye-off-line','mdi:eye-outline'][settingStore.dictation?0:1]"/>
|
||||
|
||||
<BaseIcon :icon="['mdi:translate','mdi:translate-off'][settingStore.translate?0:1]"
|
||||
:title="`开关释义显示(${settingStore.shortcutKeyMap[ShortcutKey.ToggleShowTranslate]})`"
|
||||
@click="settingStore.translate = !settingStore.translate"/>
|
||||
|
||||
<BaseIcon
|
||||
:title="`编辑(${settingStore.shortcutKeyMap[ShortcutKey.EditArticle]})`"
|
||||
icon="tabler:edit"
|
||||
@click="emitter.emit(ShortcutKey.EditArticle)"
|
||||
/>
|
||||
<BaseIcon
|
||||
@click="settingStore.showPanel = !settingStore.showPanel"
|
||||
:title="`面板(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`"
|
||||
icon="tdesign:menu-unfold"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<ElProgress :percentage="progress"
|
||||
:stroke-width="8"
|
||||
:show-text="false"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.practice-wrapper {
|
||||
font-size: 0.9rem;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.swiper-wrapper {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.swiper-list {
|
||||
transition: transform .3s;
|
||||
height: 200%;
|
||||
|
||||
.swiper-item {
|
||||
height: 50%;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.step1 {
|
||||
transform: translate3d(0, -50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.practice-article {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
width: var(--article-width);
|
||||
}
|
||||
|
||||
.typing-word-wrapper {
|
||||
width: var(--toolbar-width);
|
||||
}
|
||||
|
||||
.panel-wrapper {
|
||||
position: absolute;
|
||||
left: var(--article-panel-margin-left);
|
||||
//left: 0;
|
||||
top: .8rem;
|
||||
z-index: 1;
|
||||
height: calc(100% - 1.5rem);
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: var(--article-width);
|
||||
margin-bottom: .8rem;
|
||||
transition: all var(--anim-time);
|
||||
position: relative;
|
||||
margin-top: 1rem;
|
||||
|
||||
&.hide {
|
||||
margin-bottom: -6rem;
|
||||
margin-top: 3rem;
|
||||
|
||||
.progress {
|
||||
bottom: calc(100% + 1.8rem);
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: .6rem;
|
||||
background: var(--color-second);
|
||||
padding: .5rem var(--space);
|
||||
z-index: 2;
|
||||
border: 1px solid var(--color-item-border);
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
.stat {
|
||||
margin-top: .5rem;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: var(--stat-gap);
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .3rem;
|
||||
width: 5rem;
|
||||
color: gray;
|
||||
|
||||
.line {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--color-sub-gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
transition: all .3s;
|
||||
padding: 0 .6rem;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ElProgress-bar__inner) {
|
||||
background: var(--color-scrollbar);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -6,7 +6,9 @@ import BaseIcon from "@/components/BaseIcon.vue";
|
||||
<template>
|
||||
<BaseIcon
|
||||
title="返回"
|
||||
icon="formkit:left"/>
|
||||
@click="$router.back">
|
||||
<IconFormkitLeft/>
|
||||
</BaseIcon>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -9,8 +9,10 @@ import {cloneDeep, debounce, reverse, shuffle} from "@/utils";
|
||||
import Input from "@/pages/pc/components/Input.vue";
|
||||
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
|
||||
import Empty from "@/components/Empty.vue";
|
||||
import {Icon} from "@iconify/vue";
|
||||
import {ElCheckbox, ElPagination} from 'element-plus'
|
||||
import Pagination from '@/pages/pc/components/base/Pagination.vue'
|
||||
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
|
||||
import Checkbox from "@/pages/pc/components/base/checkbox/Checkbox.vue";
|
||||
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
|
||||
|
||||
let list = defineModel('list')
|
||||
|
||||
@@ -94,11 +96,11 @@ let showSearchInput = $ref(false)
|
||||
|
||||
function sort(type: Sort) {
|
||||
if (type === Sort.reverse) {
|
||||
ElMessage.success('已翻转排序')
|
||||
Toast.success('已翻转排序')
|
||||
list.value = reverse(cloneDeep(list.value))
|
||||
}
|
||||
if (type === Sort.random) {
|
||||
ElMessage.success('已随机排序')
|
||||
Toast.success('已随机排序')
|
||||
list.value = shuffle(cloneDeep(list.value))
|
||||
}
|
||||
showSortDialog = false
|
||||
@@ -118,7 +120,7 @@ const s = useSlots()
|
||||
|
||||
defineRender(
|
||||
() => {
|
||||
const d = (item) => <ElCheckbox
|
||||
const d = (item) => <Checkbox
|
||||
modelValue={selectIds.includes(item.id)}
|
||||
onChange={() => toggleSelect(item)}
|
||||
size="large"/>
|
||||
@@ -132,6 +134,7 @@ defineRender(
|
||||
class="flex gap-4"
|
||||
>
|
||||
<Input
|
||||
prefixIcon
|
||||
modelValue={searchKey}
|
||||
onUpdate:modelValue=
|
||||
{debounce(e => searchKey = e)}
|
||||
@@ -141,9 +144,9 @@ defineRender(
|
||||
) : (
|
||||
<div class="flex justify-between " v-else>
|
||||
<div class="flex gap-2 items-center">
|
||||
<ElCheckbox
|
||||
<Checkbox
|
||||
disabled={!currentList.length}
|
||||
onClick={() => toggleSelectAll()}
|
||||
onChange={() => toggleSelectAll()}
|
||||
modelValue={selectAll}
|
||||
size="large"/>
|
||||
<span>{selectIds.length} / {list.value.length}</span>
|
||||
@@ -158,26 +161,32 @@ defineRender(
|
||||
>
|
||||
<BaseIcon
|
||||
class="del"
|
||||
title="删除"
|
||||
icon="solar:trash-bin-minimalistic-linear"/>
|
||||
title="删除">
|
||||
<DeleteIcon/>
|
||||
</BaseIcon>
|
||||
</PopConfirm>
|
||||
: null
|
||||
}
|
||||
<BaseIcon
|
||||
onClick={props.add}
|
||||
icon="fluent:add-20-filled"
|
||||
title="添加单词"/>
|
||||
title="添加单词">
|
||||
<IconFluentAdd20Filled/>
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
disabled={!currentList.length}
|
||||
title="改变顺序"
|
||||
icon="icon-park-outline:sort-two"
|
||||
onClick={() => showSortDialog = !showSortDialog}
|
||||
/>
|
||||
>
|
||||
<IconIconParkOutlineSortTwo/>
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
disabled={!currentList.length}
|
||||
onClick={() => showSearchInput = !showSearchInput}
|
||||
title="搜索"
|
||||
icon="fluent:search-24-regular"/>
|
||||
title="搜索">
|
||||
<IconFluentSearch24Regular/>
|
||||
</BaseIcon>
|
||||
<MiniDialog
|
||||
modelValue={showSortDialog}
|
||||
onUpdate:modelValue={e => showSortDialog = e}
|
||||
@@ -200,8 +209,7 @@ defineRender(
|
||||
{
|
||||
props.loading ?
|
||||
<div class="h-full w-full center text-4xl">
|
||||
<Icon
|
||||
icon="eos-icons:loading"
|
||||
<IconEosIconsLoading
|
||||
color="gray"
|
||||
/>
|
||||
</div>
|
||||
@@ -220,14 +228,14 @@ defineRender(
|
||||
})}
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<ElPagination background
|
||||
currentPage={pageNo}
|
||||
onUpdate:current-page={handlePageNo}
|
||||
pageSize={pageSize}
|
||||
onUpdate:page-size={(e) => pageSize = e}
|
||||
pageSizes={[20, 50, 100, 200]}
|
||||
layout="prev, pager, next"
|
||||
total={list.value.length}/>
|
||||
<Pagination
|
||||
currentPage={pageNo}
|
||||
onUpdate:current-page={handlePageNo}
|
||||
pageSize={pageSize}
|
||||
onUpdate:page-size={(e) => pageSize = e}
|
||||
pageSizes={[20, 50, 100, 200]}
|
||||
layout="prev, pager, next"
|
||||
total={list.value.length}/>
|
||||
</div>
|
||||
</>
|
||||
) : <Empty/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {Dict, DictResource} from "@/types/types.ts";
|
||||
import {Icon} from "@iconify/vue";
|
||||
import {ElProgress, ElCheckbox} from 'element-plus';
|
||||
import {Dict} from "@/types/types.ts";
|
||||
import Progress from '@/pages/pc/components/base/Progress.vue'
|
||||
import Checkbox from "@/pages/pc/components/base/checkbox/Checkbox.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
item?: Partial<Dict>;
|
||||
@@ -37,20 +37,20 @@ const studyProgress = $computed(() => {
|
||||
<div>{{ studyProgress }}{{ item?.length }}{{ quantifier }}</div>
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-4 right-4">
|
||||
<ElProgress v-if="item?.lastLearnIndex || item.complete" class="mt-1"
|
||||
:percentage="progress"
|
||||
:show-text="false"></ElProgress>
|
||||
<Progress v-if="item?.lastLearnIndex || item.complete" class="mt-1"
|
||||
:percentage="progress"
|
||||
:show-text="false"></Progress>
|
||||
</div>
|
||||
<ElCheckbox v-if="showCheckbox"
|
||||
:model-value="checked"
|
||||
@click.stop="$emit('check')"
|
||||
class="absolute left-0 bottom-0 h-5!"/>
|
||||
<Checkbox v-if="showCheckbox"
|
||||
:model-value="checked"
|
||||
@change="$emit('check')"
|
||||
class="absolute left-4 bottom-4"/>
|
||||
<div class="custom" v-if="item.custom">自定义</div>
|
||||
</template>
|
||||
<div v-else class="center h-full">
|
||||
<Icon
|
||||
<IconFluentAdd20Filled
|
||||
width="40px"
|
||||
icon="fluent:add-20-filled"/>
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {Icon} from "@iconify/vue";
|
||||
import Close from "@/components/icon/Close.vue";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import {watch} from "vue";
|
||||
@@ -54,16 +53,11 @@ watch(() => settingStore.load, (n) => {
|
||||
<div class="href-wrapper">
|
||||
<div class="round">
|
||||
<div class="href">2study.top</div>
|
||||
<Icon
|
||||
width="22"
|
||||
icon="mdi:star-outline"/>
|
||||
<IconMdiStarOutline width="22"/>
|
||||
</div>
|
||||
<div class="right">
|
||||
👈
|
||||
<Icon
|
||||
class="star"
|
||||
width="22"
|
||||
icon="mdi:star"/>
|
||||
<IconMdiStar class="star" width="22"/>
|
||||
点亮它!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
49
src/pages/pc/components/ConflictNotice.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {defineAsyncComponent, onMounted, watch} from "vue";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
|
||||
|
||||
let settingStore = useSettingStore()
|
||||
let show = $ref(false)
|
||||
|
||||
watch(() => settingStore.load, (n) => {
|
||||
if (n && settingStore.conflictNotice) {
|
||||
setTimeout(() => {
|
||||
show = true
|
||||
}, 300)
|
||||
}
|
||||
}, {immediate: true})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model="show"
|
||||
title="提示"
|
||||
footer
|
||||
cancel-button-text="不再提醒"
|
||||
confirm-button-text="关闭"
|
||||
@cancel="settingStore.conflictNotice = false"
|
||||
>
|
||||
<div class="card w-120 center flex-col color-main py-0 mb-0">
|
||||
<div>
|
||||
<div class="text">
|
||||
1、 如果您安装了 <span class="font-bold text-red">“调速” “Vim”</span> 等会接管键盘点击的插件/脚本,将导致本网站无法正常使用
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div>①:在对应插件/脚本的设置里面排除本网站</div>
|
||||
<div>②:临时禁用对应插件/脚本</div>
|
||||
</div>
|
||||
<div class="text mt-2">
|
||||
2、如果您未安装以上插件/脚本,还是无法使用
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div>①:请打开浏览器无痕模式尝试</div>
|
||||
<div>②:无痕模式下无法正常使用,请给<a href="https://github.com/zyronon/TypeWords/issues">作者提 BUG</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import {ElInput} from "element-plus";
|
||||
|
||||
import {watchEffect} from "vue";
|
||||
import Textarea from "@/pages/pc/components/base/Textarea.vue";
|
||||
|
||||
interface IProps {
|
||||
value: string,
|
||||
@@ -31,6 +31,7 @@ function save() {
|
||||
|
||||
function toggle() {
|
||||
edit = !edit
|
||||
editVal = props.value
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -38,15 +39,16 @@ function toggle() {
|
||||
<div
|
||||
v-if="edit"
|
||||
class="edit-text">
|
||||
<ElInput
|
||||
<Textarea
|
||||
v-model="editVal"
|
||||
ref="inputRef"
|
||||
textarea
|
||||
autosize
|
||||
autofocus
|
||||
type="textarea"
|
||||
:input-style="`color: var(--color-font-1);font-size: 1rem;`"
|
||||
/>
|
||||
<div class="options">
|
||||
<div class="flex justify-end mt-2">
|
||||
<BaseButton @click="toggle">取消</BaseButton>
|
||||
<BaseButton @click="save">应用</BaseButton>
|
||||
</div>
|
||||
@@ -63,13 +65,6 @@ function toggle() {
|
||||
.edit-text {
|
||||
margin-top: .6rem;
|
||||
color: var(--color-font-1);
|
||||
|
||||
.options {
|
||||
margin-top: .6rem;
|
||||
gap: .6rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {Icon} from "@iconify/vue";
|
||||
import Close from "@/components/icon/Close.vue";
|
||||
import {useDisableEventListener, useWindowClick} from "@/hooks/event.ts";
|
||||
|
||||
@@ -8,6 +7,7 @@ defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
autofocus?: boolean
|
||||
prefixIcon?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
@@ -36,7 +36,8 @@ const vFocus = {
|
||||
:class="{focus}"
|
||||
ref="inputEl"
|
||||
>
|
||||
<Icon icon="fluent:search-24-regular"
|
||||
<IconFluentSearch24Regular
|
||||
v-if="prefixIcon"
|
||||
width="20"/>
|
||||
<input type="text"
|
||||
:value="modelValue"
|
||||
@@ -61,7 +62,6 @@ const vFocus = {
|
||||
transition: all .3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all .3s;
|
||||
background: var(--color-input-bg);
|
||||
|
||||
:deep(svg) {
|
||||
|
||||
@@ -10,22 +10,15 @@ function goHome() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="logo" @click="goHome">
|
||||
<div class="center mb-2" @click="goHome">
|
||||
<img v-show="settingStore.theme === 'dark'" src="/logo-text-white.png" alt="">
|
||||
<img v-show="settingStore.theme !== 'dark'" src="/logo-text-black.png" alt="">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.logo {
|
||||
//position: fixed;
|
||||
left: var(--space);
|
||||
top: var(--space);
|
||||
z-index: 1;
|
||||
|
||||
img {
|
||||
cursor: pointer;
|
||||
height: 2rem;
|
||||
}
|
||||
img {
|
||||
cursor: pointer;
|
||||
height: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@ import {computed, provide} from "vue"
|
||||
import {ShortcutKey} from "@/types/types.ts"
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import Close from "@/components/icon/Close.vue";
|
||||
import Tooltip from "@/pages/pc/components/Tooltip.vue";
|
||||
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
let tabIndex = $ref(0)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {Word} from "@/types/types.ts";
|
||||
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
|
||||
import {usePlayWordAudio} from "@/hooks/sound.ts";
|
||||
import {ElPopover} from 'element-plus'
|
||||
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
item: Word,
|
||||
@@ -36,16 +36,12 @@ const playWordAudio = usePlayWordAudio()
|
||||
</div>
|
||||
<div class="item-sub-title flex flex-col gap-2" v-if="item.trans.length && showTranslate">
|
||||
<div v-for="v in item.trans">
|
||||
<ElPopover
|
||||
<Tooltip
|
||||
v-if="v.cn.length > 30 && showTransPop"
|
||||
width="300"
|
||||
:content="v.pos + ' ' + v.cn"
|
||||
placement="top"
|
||||
:title="v.pos + ' ' + v.cn"
|
||||
>
|
||||
<template #reference>
|
||||
<span>{{ v.pos + ' ' + v.cn.slice(0, 30) + '...' }}</span>
|
||||
</template>
|
||||
</ElPopover>
|
||||
<span>{{ v.pos + ' ' + v.cn.slice(0, 30) + '...' }}</span>
|
||||
</Tooltip>
|
||||
<span v-else>{{ v.pos + ' ' + v.cn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
172
src/pages/pc/components/base/BaseInput.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, useAttrs, watch} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: [String, Number],
|
||||
placeholder: String,
|
||||
disabled: Boolean,
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxLength: Number,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'input', 'change', 'focus', 'blur', 'validation']);
|
||||
const attrs = useAttrs();
|
||||
|
||||
const inputValue = ref(props.modelValue);
|
||||
const errorMsg = ref('');
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const onChange = (e: Event) => {
|
||||
emit('change', e);
|
||||
};
|
||||
|
||||
const onFocus = (e: FocusEvent) => {
|
||||
emit('focus', e);
|
||||
};
|
||||
|
||||
const onBlur = (e: FocusEvent) => {
|
||||
validate(inputValue.value);
|
||||
emit('blur', e);
|
||||
};
|
||||
|
||||
const clearInput = () => {
|
||||
inputValue.value = '';
|
||||
validate('');
|
||||
emit('update:modelValue', '');
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="custom-input" :class="{ 'is-disabled': disabled, 'has-error': errorMsg }">
|
||||
<input
|
||||
v-bind="attrs"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:value="inputValue"
|
||||
@input="onInput"
|
||||
@change="onChange"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
class="custom-input__inner"
|
||||
:maxlength="maxLength"
|
||||
/>
|
||||
<button
|
||||
v-if="clearable && inputValue && !disabled"
|
||||
type="button"
|
||||
class="custom-input__clear"
|
||||
@click="clearInput"
|
||||
aria-label="Clear input"
|
||||
>×
|
||||
</button>
|
||||
|
||||
<div v-if="errorMsg" class="custom-input__error">{{ errorMsg }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.custom-input {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.has-error {
|
||||
.custom-input__inner {
|
||||
border-color: #f56c6c;
|
||||
}
|
||||
|
||||
.custom-input__error {
|
||||
color: #f56c6c;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__inner {
|
||||
width: 100%;
|
||||
padding: 0.4rem 1.5rem 0.4rem 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
transition: all .3s;
|
||||
color: var(--color-input-color);
|
||||
background: var(--color-input-bg);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 0 3px #409eff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__clear {
|
||||
position: absolute;
|
||||
right: 0.4rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
padding: 0;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
195
src/pages/pc/components/base/InputNumber.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="input-number inline-center select-none anim" :class="{ 'is-disabled': disabled }">
|
||||
<!-- 减号 -->
|
||||
<button
|
||||
class="btn minus-btn inline-center cursor-pointer anim border-none outline-none w-8 h-8"
|
||||
type="button"
|
||||
:disabled="disabled || isMin"
|
||||
@mousedown.prevent="onHold(-1)"
|
||||
@mouseup="onRelease"
|
||||
@mouseleave="onRelease"
|
||||
aria-label="decrease"
|
||||
>-
|
||||
</button>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<input
|
||||
ref="inputRef"
|
||||
class="flex-1 h-8 px-2 text-center border-none outline-none bg-transparent input-inner w-14"
|
||||
:value="displayValue"
|
||||
:disabled="disabled"
|
||||
inputmode="decimal"
|
||||
@input="e => displayValue = e.target.value"
|
||||
@keydown.up.prevent="change(1)"
|
||||
@keydown.down.prevent="change(-1)"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<!-- 加号 -->
|
||||
<button
|
||||
class="btn plus-btn inline-center cursor-pointer anim border-none outline-none w-8 h-8"
|
||||
type="button"
|
||||
:disabled="disabled || isMax"
|
||||
@mousedown.prevent="onHold(1)"
|
||||
@mouseup="onRelease"
|
||||
@mouseleave="onRelease"
|
||||
aria-label="increase"
|
||||
>+
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onBeforeUnmount} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {type: [Number, String], default: null},
|
||||
min: {type: Number, default: -Infinity},
|
||||
max: {type: Number, default: Infinity},
|
||||
step: {type: Number, default: 1},
|
||||
precision: {type: Number},
|
||||
disabled: {type: Boolean, default: false},
|
||||
stepStrictly: {type: Boolean, default: false},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'input', 'change'])
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const inner = ref<number | null>(normalizeToNumber(props.modelValue))
|
||||
let holdTimer: number | null = null
|
||||
let holdInterval: number | null = null
|
||||
|
||||
const displayValue = computed({
|
||||
get: () => inner.value === null ? '' : format(inner.value),
|
||||
set: v => {
|
||||
const n = parseInput(v)
|
||||
if (n === 'editing') return
|
||||
setValue(n)
|
||||
}
|
||||
})
|
||||
|
||||
const isMin = computed(() => inner.value !== null && inner.value <= props.min)
|
||||
const isMax = computed(() => inner.value !== null && inner.value >= props.max)
|
||||
|
||||
function normalizeToNumber(v: any): number | null {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function clamp(n: number | null) {
|
||||
if (n === null) return null
|
||||
if (n < props.min) return props.min
|
||||
if (n > props.max) return props.max
|
||||
return n
|
||||
}
|
||||
|
||||
function format(n: number) {
|
||||
return props.precision != null ? n.toFixed(props.precision) : String(n)
|
||||
}
|
||||
|
||||
function parseInput(s: string): number | 'editing' | null {
|
||||
const trimmed = s.trim()
|
||||
if (['', '-', '+', '.', '-.', '+.'].includes(trimmed)) return 'editing'
|
||||
const n = Number(trimmed)
|
||||
return Number.isFinite(n) ? n : 'editing'
|
||||
}
|
||||
|
||||
function applyStepStrict(n: number | null) {
|
||||
if (n === null) return null
|
||||
if (!props.stepStrictly) return n
|
||||
const base = Number.isFinite(props.min) ? props.min : 0
|
||||
const k = Math.round((n - base) / props.step)
|
||||
return base + k * props.step
|
||||
}
|
||||
|
||||
function toPrecision(n: number) {
|
||||
return props.precision != null ? Number(n.toFixed(props.precision)) : n
|
||||
}
|
||||
|
||||
function setValue(n: number | null) {
|
||||
const v = clamp(toPrecision(applyStepStrict(n)))
|
||||
inner.value = v
|
||||
emit('update:modelValue', v)
|
||||
emit('input', v)
|
||||
emit('change', v)
|
||||
}
|
||||
|
||||
function change(dir: 1 | -1) {
|
||||
if (props.disabled) return
|
||||
const base = inner.value ?? (Number.isFinite(props.min) ? props.min : 0)
|
||||
setValue(base + dir * props.step)
|
||||
}
|
||||
|
||||
function onHold(dir: 1 | -1) {
|
||||
change(dir)
|
||||
holdTimer = window.setTimeout(() => {
|
||||
holdInterval = window.setInterval(() => change(dir), 100)
|
||||
}, 400)
|
||||
}
|
||||
|
||||
function onRelease() {
|
||||
if (holdTimer) {
|
||||
clearTimeout(holdTimer);
|
||||
holdTimer = null
|
||||
}
|
||||
if (holdInterval) {
|
||||
clearInterval(holdInterval);
|
||||
holdInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
const n = parseInput(displayValue.value)
|
||||
setValue(n === 'editing' ? inner.value : n)
|
||||
}
|
||||
|
||||
onBeforeUnmount(onRelease)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.input-number {
|
||||
border: 1px solid var(--color-input-border);
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: var(--color-input-bg);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-select-bg);
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: .7;
|
||||
|
||||
.btn, .input-inner {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.input-inner {
|
||||
color: var(--color-input-color);
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: var(--color-second);
|
||||
color: var(--color-input-color);
|
||||
|
||||
&.minus-btn {
|
||||
border-right: 1px solid var(--color-input-border);
|
||||
}
|
||||
|
||||
&.plus-btn {
|
||||
border-left: 1px solid var(--color-input-border);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-third);
|
||||
color: var(--color-select-bg);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
384
src/pages/pc/components/base/Pagination.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, onUnmounted, ref} from 'vue';
|
||||
|
||||
interface IProps {
|
||||
currentPage?: number;
|
||||
pageSize?: number;
|
||||
pageSizes?: number[];
|
||||
layout?: string;
|
||||
total: number;
|
||||
hideOnSinglePage?: boolean;
|
||||
// background property removed as per requirements
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
pageSizes: () => [10, 20, 30, 40, 50, 100],
|
||||
layout: 'prev, pager, next',
|
||||
hideOnSinglePage: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:currentPage': [val: number];
|
||||
'update:pageSize': [val: number];
|
||||
'size-change': [val: number];
|
||||
'current-change': [val: number];
|
||||
}>();
|
||||
|
||||
const internalCurrentPage = ref(props.currentPage);
|
||||
const internalPageSize = ref(props.pageSize);
|
||||
|
||||
// 计算总页数
|
||||
const pageCount = computed(() => {
|
||||
return Math.max(1, Math.ceil(props.total / internalPageSize.value));
|
||||
});
|
||||
|
||||
// 可用于显示的页码数量,会根据容器宽度动态计算
|
||||
const availablePagerCount = ref(5); // 默认值
|
||||
|
||||
// 计算显示的页码
|
||||
const pagers = computed(() => {
|
||||
const pagerCount = availablePagerCount.value; // 动态计算的页码数量
|
||||
const halfPagerCount = Math.floor(pagerCount / 2);
|
||||
const currentPage = internalCurrentPage.value;
|
||||
const pageCountValue = pageCount.value;
|
||||
|
||||
let showPrevMore = false;
|
||||
let showNextMore = false;
|
||||
|
||||
if (pageCountValue > pagerCount) {
|
||||
if (currentPage > pagerCount - halfPagerCount) {
|
||||
showPrevMore = true;
|
||||
}
|
||||
if (currentPage < pageCountValue - halfPagerCount) {
|
||||
showNextMore = true;
|
||||
}
|
||||
}
|
||||
|
||||
const array = [];
|
||||
if (showPrevMore && !showNextMore) {
|
||||
const startPage = pageCountValue - (pagerCount - 2);
|
||||
for (let i = startPage; i < pageCountValue; i++) {
|
||||
array.push(i);
|
||||
}
|
||||
} else if (!showPrevMore && showNextMore) {
|
||||
for (let i = 2; i < pagerCount; i++) {
|
||||
array.push(i);
|
||||
}
|
||||
} else if (showPrevMore && showNextMore) {
|
||||
const offset = Math.floor(pagerCount / 2) - 1;
|
||||
for (let i = currentPage - offset; i <= currentPage + offset; i++) {
|
||||
array.push(i);
|
||||
}
|
||||
} else {
|
||||
for (let i = 2; i < pageCountValue; i++) {
|
||||
array.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return array;
|
||||
});
|
||||
|
||||
// 是否显示分页
|
||||
const shouldShow = computed(() => {
|
||||
return props.hideOnSinglePage ? pageCount.value > 1 : true;
|
||||
});
|
||||
|
||||
// 处理页码变化
|
||||
function handleCurrentChange(val: number) {
|
||||
internalCurrentPage.value = val;
|
||||
emit('update:currentPage', val);
|
||||
emit('current-change', val);
|
||||
}
|
||||
|
||||
// 处理每页条数变化
|
||||
function handleSizeChange(val: number) {
|
||||
internalPageSize.value = val;
|
||||
emit('update:pageSize', val);
|
||||
emit('size-change', val);
|
||||
|
||||
// 重新计算可用页码数量
|
||||
calculateAvailablePagerCount();
|
||||
|
||||
// 重新计算当前页,确保当前页在有效范围内
|
||||
const newPageCount = Math.ceil(props.total / val);
|
||||
if (internalCurrentPage.value > newPageCount) {
|
||||
internalCurrentPage.value = newPageCount;
|
||||
emit('update:currentPage', newPageCount);
|
||||
emit('current-change', newPageCount);
|
||||
}
|
||||
}
|
||||
|
||||
// 计算可用宽度并更新页码数量
|
||||
function calculateAvailablePagerCount() {
|
||||
// 在下一个渲染周期执行,确保DOM已更新
|
||||
setTimeout(() => {
|
||||
const paginationEl = document.querySelector('.pagination') as HTMLElement;
|
||||
if (!paginationEl) return;
|
||||
|
||||
const containerWidth = paginationEl.offsetWidth;
|
||||
const buttonWidth = 38; // 按钮宽度(包括margin)
|
||||
const availableWidth = containerWidth - 120; // 减去其他元素占用的空间(前后按钮等)
|
||||
|
||||
// 计算可以显示多少个页码按钮
|
||||
const maxPagers = Math.max(3, Math.floor(availableWidth / buttonWidth) - 2); // 减2是因为第一页和最后一页始终显示
|
||||
availablePagerCount.value = maxPagers;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// 监听窗口大小变化
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', calculateAvailablePagerCount);
|
||||
// 初始计算
|
||||
calculateAvailablePagerCount();
|
||||
});
|
||||
|
||||
// 组件卸载时移除监听器
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', calculateAvailablePagerCount);
|
||||
})
|
||||
|
||||
// 上一页
|
||||
function prev() {
|
||||
const newPage = internalCurrentPage.value - 1;
|
||||
if (newPage >= 1) {
|
||||
handleCurrentChange(newPage);
|
||||
}
|
||||
}
|
||||
|
||||
// 下一页
|
||||
function next() {
|
||||
const newPage = internalCurrentPage.value + 1;
|
||||
if (newPage <= pageCount.value) {
|
||||
handleCurrentChange(newPage);
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到指定页
|
||||
function jumpPage(page: number) {
|
||||
if (page !== internalCurrentPage.value) {
|
||||
handleCurrentChange(page);
|
||||
}
|
||||
}
|
||||
|
||||
// 快速向前跳转
|
||||
function quickPrevPage() {
|
||||
const newPage = Math.max(1, internalCurrentPage.value - 5);
|
||||
if (newPage !== internalCurrentPage.value) {
|
||||
handleCurrentChange(newPage);
|
||||
}
|
||||
}
|
||||
|
||||
// 快速向后跳转
|
||||
function quickNextPage() {
|
||||
const newPage = Math.min(pageCount.value, internalCurrentPage.value + 5);
|
||||
if (newPage !== internalCurrentPage.value) {
|
||||
handleCurrentChange(newPage);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pagination" v-if="shouldShow">
|
||||
<div class="pagination-container">
|
||||
<!-- 上一页 -->
|
||||
<button
|
||||
v-if="layout.includes('prev')"
|
||||
class="btn-prev"
|
||||
:disabled="internalCurrentPage <= 1"
|
||||
@click="prev"
|
||||
>
|
||||
<IconMingcuteLeftLine/>
|
||||
</button>
|
||||
|
||||
<!-- 页码 -->
|
||||
<ul v-if="layout.includes('pager')" class="pager">
|
||||
<!-- 第一页 -->
|
||||
<li
|
||||
class="number"
|
||||
:class="{ active: internalCurrentPage === 1 }"
|
||||
@click="jumpPage(1)"
|
||||
>
|
||||
1
|
||||
</li>
|
||||
|
||||
<!-- 快速向前 -->
|
||||
<li
|
||||
v-if="pageCount > availablePagerCount && internalCurrentPage > (availablePagerCount - Math.floor(availablePagerCount / 2))"
|
||||
class="more btn-quickprev"
|
||||
@click="quickPrevPage"
|
||||
>
|
||||
...
|
||||
</li>
|
||||
|
||||
<!-- 中间页码 -->
|
||||
<li
|
||||
v-for="pager in pagers"
|
||||
:key="pager"
|
||||
class="number"
|
||||
:class="{ active: internalCurrentPage === pager }"
|
||||
@click="jumpPage(pager)"
|
||||
>
|
||||
{{ pager }}
|
||||
</li>
|
||||
|
||||
<!-- 快速向后 -->
|
||||
<li
|
||||
v-if="pageCount > availablePagerCount && internalCurrentPage < pageCount - Math.floor(availablePagerCount / 2)"
|
||||
class="more btn-quicknext"
|
||||
@click="quickNextPage"
|
||||
>
|
||||
...
|
||||
</li>
|
||||
|
||||
<!-- 最后一页 -->
|
||||
<li
|
||||
v-if="pageCount > 1"
|
||||
class="number"
|
||||
:class="{ active: internalCurrentPage === pageCount }"
|
||||
@click="jumpPage(pageCount)"
|
||||
>
|
||||
{{ pageCount }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 下一页 -->
|
||||
<button
|
||||
v-if="layout.includes('next')"
|
||||
class="btn-next"
|
||||
:disabled="internalCurrentPage >= pageCount"
|
||||
@click="next"
|
||||
>
|
||||
<IconMingcuteRightLine/>
|
||||
</button>
|
||||
|
||||
<!-- 每页条数选择器 -->
|
||||
<div v-if="layout.includes('sizes')" class="sizes">
|
||||
<select
|
||||
:value="internalPageSize"
|
||||
@change="handleSizeChange(Number($event.target.value))"
|
||||
>
|
||||
<option v-for="item in pageSizes" :key="item" :value="item">
|
||||
{{ item }} 条/页
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 总数 -->
|
||||
<span v-if="layout.includes('total')" class="total">
|
||||
共 {{ total }} 条
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pagination {
|
||||
white-space: normal;
|
||||
color: var(--color-main-text);
|
||||
font-weight: normal;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-prev, .btn-next {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
min-width: 1.9375rem;
|
||||
height: 1.9375rem;
|
||||
border-radius: 0.125rem;
|
||||
cursor: pointer;
|
||||
background-color: var(--color-third);
|
||||
color: #606266;
|
||||
border: none;
|
||||
padding: 0 0.375rem;
|
||||
margin: 0.25rem 0.25rem;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--color-select-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: inline-flex;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-wrap: wrap;
|
||||
|
||||
li {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
min-width: 1.9375rem;
|
||||
height: 1.9375rem;
|
||||
line-height: 1.9375rem;
|
||||
border-radius: 0.125rem;
|
||||
margin: 0.25rem 0.25rem;
|
||||
cursor: pointer;
|
||||
background-color: var(--color-third);
|
||||
border: none;
|
||||
|
||||
&.active {
|
||||
background-color: var(--el-color-primary, #409eff);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.more {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: var(--el-color-primary, #409eff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sizes {
|
||||
margin: 0.25rem 0.5rem;
|
||||
|
||||
select {
|
||||
height: 1.9375rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 0.125rem;
|
||||
border: 1px solid #dcdfe6;
|
||||
background-color: #fff;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--el-color-primary, #409eff);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #f5f7fa;
|
||||
color: #c0c4cc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.total {
|
||||
margin: 0.25rem 0.5rem;
|
||||
font-weight: normal;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
103
src/pages/pc/components/base/Progress.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue';
|
||||
|
||||
interface IProps {
|
||||
percentage: number;
|
||||
showText?: boolean;
|
||||
textInside?: boolean;
|
||||
strokeWidth?: number;
|
||||
color?: string;
|
||||
format?: (percentage: number) => string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
showText: true,
|
||||
textInside: false,
|
||||
strokeWidth: 6,
|
||||
color: '#409eff',
|
||||
format: (percentage) => `${percentage}%`,
|
||||
});
|
||||
|
||||
const barStyle = computed(() => {
|
||||
return {
|
||||
width: `${props.percentage}%`,
|
||||
backgroundColor: props.color,
|
||||
};
|
||||
});
|
||||
|
||||
const trackStyle = computed(() => {
|
||||
return {
|
||||
height: `${props.strokeWidth}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const progressTextSize = computed(() => {
|
||||
return props.strokeWidth * 0.83 + 6;
|
||||
});
|
||||
|
||||
const content = computed(() => {
|
||||
if (typeof props.format === 'function') {
|
||||
return props.format(props.percentage) || '';
|
||||
} else {
|
||||
return `${props.percentage}%`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="progress" role="progressbar" :aria-valuenow="percentage" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress-bar" :style="trackStyle">
|
||||
<div class="progress-bar-inner" :style="barStyle">
|
||||
<div v-if="showText && textInside" class="progress-bar-text" :style="{ fontSize: progressTextSize + 'px' }">
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showText && !textInside" class="progress-bar-text" :style="{ fontSize: progressTextSize + 'px' }">
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.progress {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
border-radius: 100px;
|
||||
background-color: var(--color-progress-bar);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
|
||||
.progress-bar-inner {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
border-radius: 100px;
|
||||
transition: width 0.6s ease;
|
||||
text-align: right;
|
||||
|
||||
.progress-bar-text {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
margin: 0 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar-text {
|
||||
margin-left: 5px;
|
||||
min-width: 50px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
236
src/pages/pc/components/base/Slider.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import {nextTick, onMounted, ref, watch} from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
showText?: boolean;
|
||||
showValue?: boolean; // 是否显示当前值
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const min = props.min ?? 0;
|
||||
const max = props.max ?? 100;
|
||||
const step = props.step ?? 1;
|
||||
|
||||
const sliderRef = ref<HTMLElement | null>(null);
|
||||
const isDragging = ref(false);
|
||||
const sliderLeft = ref(0);
|
||||
const sliderWidth = ref(0);
|
||||
|
||||
const currentValue = ref(props.modelValue);
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
currentValue.value = val;
|
||||
});
|
||||
|
||||
const valueToPercent = (value: number) => ((value - min) / (max - min)) * 100;
|
||||
|
||||
// 计算一个数字的小数位数
|
||||
function countDecimals(value: number) {
|
||||
if (Math.floor(value) === value) return 0;
|
||||
const str = value.toString();
|
||||
if (str.indexOf('e-') >= 0) {
|
||||
// 科学计数法处理
|
||||
const [, trail] = str.split('e-');
|
||||
return parseInt(trail, 10);
|
||||
}
|
||||
return str.split('.')[1]?.length || 0;
|
||||
}
|
||||
|
||||
// 对数值按步长对齐,并控制精度,避免浮点误差
|
||||
function alignToStep(value: number, step: number) {
|
||||
const decimals = countDecimals(step);
|
||||
return Number((Math.round(value / step) * step).toFixed(decimals));
|
||||
}
|
||||
|
||||
const percentToValue = (percent: number) => {
|
||||
let val = min + ((max - min) * percent) / 100;
|
||||
val = alignToStep(val, step);
|
||||
|
||||
if (val < min) val = min;
|
||||
if (val > max) val = max;
|
||||
|
||||
return val;
|
||||
};
|
||||
|
||||
|
||||
const updateSliderRect = () => {
|
||||
if (!sliderRef.value) return;
|
||||
const rect = sliderRef.value.getBoundingClientRect();
|
||||
sliderLeft.value = rect.left;
|
||||
sliderWidth.value = rect.width;
|
||||
};
|
||||
|
||||
const setValueFromPosition = (pageX: number) => {
|
||||
let percent = ((pageX - sliderLeft.value) / sliderWidth.value) * 100;
|
||||
if (percent < 0) percent = 0;
|
||||
if (percent > 100) percent = 100;
|
||||
currentValue.value = percentToValue(percent);
|
||||
emit('update:modelValue', currentValue.value);
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (props.disabled) return;
|
||||
e.preventDefault();
|
||||
updateSliderRect();
|
||||
isDragging.value = true;
|
||||
setValueFromPosition(e.pageX);
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (props.disabled) return;
|
||||
updateSliderRect();
|
||||
isDragging.value = true;
|
||||
setValueFromPosition(e.touches[0].pageX);
|
||||
window.addEventListener('touchmove', onTouchMove);
|
||||
window.addEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
e.preventDefault();
|
||||
setValueFromPosition(e.pageX);
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
setValueFromPosition(e.touches[0].pageX);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (!isDragging.value) return;
|
||||
isDragging.value = false;
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (!isDragging.value) return;
|
||||
isDragging.value = false;
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
window.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
|
||||
const onClickTrack = (e: MouseEvent) => {
|
||||
if (props.disabled) return;
|
||||
updateSliderRect();
|
||||
setValueFromPosition(e.pageX);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
updateSliderRect();
|
||||
window.addEventListener('resize', updateSliderRect);
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div
|
||||
ref="sliderRef"
|
||||
class="custom-slider"
|
||||
:class="{ 'is-disabled': disabled }"
|
||||
@mousedown="onClickTrack"
|
||||
@touchstart.prevent="onClickTrack"
|
||||
>
|
||||
<div class="custom-slider__track"></div>
|
||||
<div
|
||||
class="custom-slider__fill"
|
||||
:style="{ width: valueToPercent(currentValue) + '%' }"
|
||||
></div>
|
||||
<div
|
||||
class="custom-slider__thumb"
|
||||
:style="{ left: valueToPercent(currentValue) + '%' }"
|
||||
@mousedown.stop.prevent="onMouseDown"
|
||||
@touchstart.stop.prevent="onTouchStart"
|
||||
tabindex="0"
|
||||
role="slider"
|
||||
:aria-valuemin="min"
|
||||
:aria-valuemax="max"
|
||||
:aria-valuenow="currentValue"
|
||||
:aria-disabled="disabled"
|
||||
></div>
|
||||
<div v-if="showValue" class="custom-slider__value">{{ currentValue }}</div>
|
||||
</div>
|
||||
<div class="text flex justify-between text-sm color-gray" v-if="showText">
|
||||
<span>{{ min }}</span>
|
||||
<span>{{ max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.custom-slider {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
cursor: pointer;
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&__track {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6px;
|
||||
background-color: #ddd;
|
||||
border-radius: 2px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
&__fill {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
height: 6px;
|
||||
background-color: #409eff;
|
||||
border-radius: 2px 0 0 2px;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #fff;
|
||||
border: 2px solid #409eff;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: grab;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
&__thumb:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 5px #409eff;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&__value {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 4px);
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
115
src/pages/pc/components/base/Switch.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue';
|
||||
|
||||
interface IProps {
|
||||
modelValue: boolean;
|
||||
disabled?: boolean;
|
||||
width?: number; // 开关宽度,默认 40px
|
||||
activeText?: string; // 开启状态显示文字
|
||||
inactiveText?: string;// 关闭状态显示文字
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
activeText: '开',
|
||||
inactiveText: '关',
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change']);
|
||||
|
||||
const isChecked = ref(props.modelValue);
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
isChecked.value = val;
|
||||
});
|
||||
|
||||
const toggle = () => {
|
||||
if (props.disabled) return;
|
||||
isChecked.value = !isChecked.value;
|
||||
emit('update:modelValue', isChecked.value);
|
||||
emit('change', isChecked.value);
|
||||
};
|
||||
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
|
||||
const switchWidth = computed(() => props.width ?? 40);
|
||||
const switchHeight = computed(() => (switchWidth.value / 2) | 0);
|
||||
const ballSize = computed(() => switchHeight.value - 4);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="switch"
|
||||
:class="{ 'checked': isChecked, 'disabled': disabled }"
|
||||
:tabindex="disabled ? -1 : 0"
|
||||
role="switch"
|
||||
:aria-checked="isChecked"
|
||||
@click="toggle"
|
||||
@keydown="onKeydown"
|
||||
:style="{ width: switchWidth + 'px', height: switchHeight + 'px' ,borderRadius: switchHeight + 'px'}"
|
||||
>
|
||||
<transition name="fade">
|
||||
<span class="text left" v-if="isChecked && activeText">{{ activeText }}</span>
|
||||
</transition>
|
||||
<div
|
||||
class="ball"
|
||||
:style="{
|
||||
width: ballSize + 'px',
|
||||
height: ballSize + 'px',
|
||||
transform: isChecked ? 'translateX(' + (switchWidth - ballSize - 2) + 'px)' : 'translateX(2px)'
|
||||
}"
|
||||
></div>
|
||||
<transition name="fade">
|
||||
<span class="text right" v-if="!isChecked && inactiveText">{{ inactiveText }}</span>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
background-color: #DCDFE6;
|
||||
position: relative;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.checked {
|
||||
background-color: #409eff;
|
||||
}
|
||||
|
||||
.ball {
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.2);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.text {
|
||||
position: absolute;
|
||||
font-size: 0.75rem;
|
||||
color: #fff;
|
||||
user-select: none;
|
||||
|
||||
&.left {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
&.right {
|
||||
right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
99
src/pages/pc/components/base/Textarea.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="inline-flex w-full relative">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="innerValue"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="maxlength"
|
||||
:rows="rows"
|
||||
:style="textareaStyle"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md outline-none resize-none transition-colors duration-200 box-border"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<!-- 字数统计 -->
|
||||
<span
|
||||
v-if="showWordLimit && maxlength"
|
||||
class="absolute bottom-1 right-2 text-xs text-gray-400 select-none"
|
||||
>
|
||||
{{ innerValue.length }} / {{ maxlength }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, computed, nextTick} from "vue"
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string,
|
||||
placeholder?: string,
|
||||
maxlength?: number,
|
||||
rows?: number,
|
||||
autosize: boolean | { minRows?: number; maxRows?: number }
|
||||
showWordLimit?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(["update:modelValue"])
|
||||
|
||||
const innerValue = ref(props.modelValue ?? "")
|
||||
watch(() => props.modelValue, v => (innerValue.value = v ?? ""))
|
||||
|
||||
const textareaRef = ref<HTMLTextAreaElement>()
|
||||
|
||||
// 样式(用于控制高度)
|
||||
const textareaStyle = computed(() => {
|
||||
return props.autosize ? {height: "auto"} : {}
|
||||
})
|
||||
|
||||
// 输入处理
|
||||
const handleInput = (e: Event) => {
|
||||
const val = (e.target as HTMLTextAreaElement).value
|
||||
innerValue.value = val
|
||||
emit("update:modelValue", val)
|
||||
if (props.autosize) nextTick(resizeTextarea)
|
||||
}
|
||||
|
||||
// 自动调整高度
|
||||
const resizeTextarea = () => {
|
||||
if (!textareaRef.value) return
|
||||
const el = textareaRef.value
|
||||
el.style.height = "auto"
|
||||
let height = el.scrollHeight
|
||||
let overflow = "hidden"
|
||||
|
||||
if (typeof props.autosize === "object") {
|
||||
const {minRows, maxRows} = props.autosize
|
||||
const lineHeight = 24 // 行高约等于 24px
|
||||
if (minRows) height = Math.max(height, minRows * lineHeight)
|
||||
if (maxRows) {
|
||||
const maxHeight = maxRows * lineHeight
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight
|
||||
overflow = "auto" // 超出时允许滚动
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
el.style.height = height + "px"
|
||||
el.style.overflowY = overflow
|
||||
}
|
||||
|
||||
watch(innerValue, () => {
|
||||
if (props.autosize) nextTick(resizeTextarea)
|
||||
}, {immediate: true})
|
||||
|
||||
</script>
|
||||
<style>
|
||||
textarea {
|
||||
font-family: var(--font-family);
|
||||
color: var(--color-input-color);
|
||||
background: var(--color-input-bg);
|
||||
@apply text-base;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 0 3px #409eff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -29,7 +29,7 @@ export default {
|
||||
methods: {
|
||||
showPop(e) {
|
||||
if (this.disabled) return
|
||||
if (!this.title) return
|
||||
if (!this.title && !this.$slots?.reference) return;
|
||||
e.stopPropagation()
|
||||
let rect = e.target.getBoundingClientRect()
|
||||
this.show = true
|
||||
@@ -49,21 +49,20 @@ export default {
|
||||
},
|
||||
},
|
||||
render() {
|
||||
if (!this.title) return this.$slots.default()
|
||||
let Vnode = this.$slots.default()[0]
|
||||
let DefaultNode = this.$slots.default()[0]
|
||||
let ReferenceNode = this.$slots?.reference?.()?.[0]
|
||||
return <>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
{
|
||||
this.show && (
|
||||
<div ref="tip" class="tip">
|
||||
{this.title}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<Vnode
|
||||
<Transition name="fade">
|
||||
<Teleport to="body">
|
||||
{this.show && (
|
||||
<div ref="tip" class="tip">
|
||||
{ReferenceNode ? <ReferenceNode/> : this.title}
|
||||
</div>
|
||||
)}
|
||||
</Teleport>
|
||||
</Transition>
|
||||
|
||||
<DefaultNode
|
||||
onClick={() => this.show = false}
|
||||
onmouseenter={(e) => this.showPop(e)}
|
||||
onmouseleave={() => this.show = false}
|
||||
@@ -75,13 +74,13 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.tip {
|
||||
position: fixed;
|
||||
font-size: 0.9rem;
|
||||
font-size: 1rem;
|
||||
z-index: 9999;
|
||||
border-radius: .3rem;
|
||||
padding: 0.4rem .8rem;
|
||||
color: var(--color-font-1);
|
||||
background: var(--color-tooltip-bg);
|
||||
//box-shadow: 1px 1px 6px #bbbbbb;
|
||||
max-width: 22rem;
|
||||
box-shadow: 0 0 6px 1px var(--color-tooltip-shadow);
|
||||
}
|
||||
</style>
|
||||
75
src/pages/pc/components/base/checkbox/Checkbox.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<label class="checkbox" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
@change="change"
|
||||
/>
|
||||
<span class="checkbox-box">
|
||||
<span class="checkbox-inner"></span>
|
||||
</span>
|
||||
<span class="checkbox-label"><slot/></span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
modelValue: Boolean
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'click'])
|
||||
|
||||
function change($event) {
|
||||
emit('update:modelValue', $event.target.checked)
|
||||
emit('onChange', $event.target.checked)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkbox-box {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 2px;
|
||||
background-color: #fff;
|
||||
margin-right: 8px;
|
||||
transition: all 0.3s;
|
||||
|
||||
.checkbox-inner {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: #409eff;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + .checkbox-box .checkbox-inner {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover .checkbox-box {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
44
src/pages/pc/components/base/form/Form.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<form @submit.prevent>
|
||||
<slot/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, provide, watch, toRef} from 'vue'
|
||||
|
||||
interface Field {
|
||||
prop: string
|
||||
modelValue: any
|
||||
validate: (rules: any[]) => boolean
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
rules: Object // { word: [{required:true,...}, ...], name: [...] }
|
||||
})
|
||||
|
||||
const fields = ref<Field[]>([])
|
||||
|
||||
const registerField = (field: Field) => {
|
||||
fields.value.push(field)
|
||||
}
|
||||
|
||||
// 校验整个表单
|
||||
const validate = (cb): boolean => {
|
||||
let valid = true
|
||||
fields.value.forEach(f => {
|
||||
const fieldRules = props.rules?.[f.prop] || []
|
||||
const res = f.validate(fieldRules)
|
||||
if (!res) valid = false
|
||||
})
|
||||
cb(valid)
|
||||
}
|
||||
|
||||
provide('registerField', registerField)
|
||||
provide('formModel', toRef(props, 'model'))
|
||||
provide('formValidate', validate)
|
||||
provide('formRules', props.rules)
|
||||
|
||||
defineExpose({validate})
|
||||
</script>
|
||||
73
src/pages/pc/components/base/form/FormItem.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="tsx">
|
||||
import {inject, onMounted, ref, useSlots} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
prop: String,
|
||||
label: String,
|
||||
})
|
||||
|
||||
const value = ref('')
|
||||
let error = $ref('')
|
||||
|
||||
// 拿到 form 的 model 和注册函数
|
||||
const formModel = inject<ref>('formModel')
|
||||
const registerField = inject('registerField')
|
||||
const formRules = inject('formRules', {})
|
||||
|
||||
const myRules = $computed(() => {
|
||||
return formRules?.[props.prop] || []
|
||||
})
|
||||
|
||||
// 校验函数
|
||||
const validate = (rules) => {
|
||||
error = ''
|
||||
const val = formModel.value[props.prop]
|
||||
for (const rule of rules) {
|
||||
if (rule.required && (!val || !val.toString().trim())) {
|
||||
error = rule.message
|
||||
return false
|
||||
}
|
||||
if (rule.max && val && val.toString().length > rule.max) {
|
||||
error = rule.message
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 自动触发 blur 校验
|
||||
const handleBlur = () => {
|
||||
const blurRules = myRules.filter((r) => r.trigger === 'blur')
|
||||
if (blurRules.length) validate(blurRules)
|
||||
}
|
||||
|
||||
// 注册到 Form
|
||||
onMounted(() => {
|
||||
registerField && registerField({prop: props.prop, modelValue: value, validate})
|
||||
})
|
||||
let slot = useSlots()
|
||||
|
||||
defineRender(() => {
|
||||
let DefaultNode = slot.default()[0]
|
||||
return <div class="form-item mb-6 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>}
|
||||
<div class="flex-1 relative">
|
||||
<DefaultNode onBlur={handleBlur}/>
|
||||
<div class="form-error absolute top-[100%] 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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
121
src/pages/pc/components/base/radio/Radio.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<label
|
||||
:class="['radio', sizeClass, { 'is-disabled': isDisabled, 'is-checked': isChecked }]"
|
||||
@click.prevent="onClick"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="hidden"
|
||||
:value="value"
|
||||
:disabled="isDisabled"
|
||||
/>
|
||||
<span class="radio__inner"></span>
|
||||
<span class="radio__label">
|
||||
<slot>{{ label }}</slot>
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {inject, computed} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: [String, Number, Boolean],
|
||||
label: [String, Number, Boolean],
|
||||
disabled: {type: Boolean, default: false}
|
||||
})
|
||||
|
||||
// 注入父组状态
|
||||
const radioGroupValue = inject<any>('radioGroupValue', null)
|
||||
const radioGroupSize = inject('radioGroupSize', 'default')
|
||||
const radioGroupDisabled = inject<boolean>('radioGroupDisabled', false)
|
||||
const updateRadioGroupValue = inject<Function>('updateRadioGroupValue', null)
|
||||
|
||||
const sizeClass = computed(() => `radio--${radioGroupSize}`)
|
||||
|
||||
// 是否禁用
|
||||
const isDisabled = computed(() => props.disabled || radioGroupDisabled)
|
||||
|
||||
// 是否选中
|
||||
const isChecked = computed(() => radioGroupValue?.value === props.value)
|
||||
|
||||
// 选中时通知父组件
|
||||
function onClick() {
|
||||
if (isDisabled.value) return
|
||||
updateRadioGroupValue?.(props.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.radio {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.radio__inner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
background: white;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background-color: #409eff;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.radio__label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
&.is-checked {
|
||||
.radio__inner {
|
||||
background-color: #409eff;
|
||||
}
|
||||
|
||||
.radio__label {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.radio__inner::after {
|
||||
background-color: white;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.radio--small {
|
||||
.radio__inner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.radio--large {
|
||||
.radio__inner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
35
src/pages/pc/components/base/radio/RadioGroup.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="flex gap-5">
|
||||
<slot/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {provide, ref, watch} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: [String, Number, Boolean],
|
||||
disabled: {type: Boolean, default: false},
|
||||
size: {type: String, default: 'default'} // small / default / large
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const groupValue = ref(props.modelValue)
|
||||
|
||||
// 提供给子组件
|
||||
provide('radioGroupSize', props.size)
|
||||
provide('radioGroupValue', groupValue)
|
||||
provide('radioGroupDisabled', props.disabled)
|
||||
provide('updateRadioGroupValue', (val: string | number | boolean) => {
|
||||
if (props.disabled) return
|
||||
groupValue.value = val
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// 外部 v-model 更新同步
|
||||
watch(() => props.modelValue, (val) => {
|
||||
groupValue.value = val
|
||||
})
|
||||
|
||||
</script>
|
||||
75
src/pages/pc/components/base/select/Option.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
label: string;
|
||||
value: any;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
// 通过inject获取ElSelect提供的数据和方法
|
||||
const selectValue = inject('selectValue', null);
|
||||
const selectHandler = inject('selectHandler', null);
|
||||
|
||||
// 计算当前选项是否被选中
|
||||
const isSelected = computed(() => {
|
||||
return selectValue === props.value;
|
||||
});
|
||||
|
||||
// 点击选项时调用ElSelect提供的方法
|
||||
const handleClick = () => {
|
||||
if (props.disabled) return;
|
||||
if (selectHandler) {
|
||||
selectHandler(props.value, props.label);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听props变化,确保在props更新时重新计算isSelected
|
||||
watch(() => props.value, () => {}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="option"
|
||||
:class="{
|
||||
'is-selected': isSelected,
|
||||
'is-disabled': disabled
|
||||
}"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot>
|
||||
<span class="option__label">{{ label }}</span>
|
||||
</slot>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-third);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
color: var(--color-select-bg);
|
||||
font-weight: bold;
|
||||
background-color: var(--color-third);
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
color: #c0c4cc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
309
src/pages/pc/components/base/select/Select.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, provide, ref, useAttrs, useSlots, VNode, watch} from 'vue';
|
||||
import {useWindowClick} from "@/hooks/event.ts";
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: any;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
options?: Option[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const attrs = useAttrs();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const isReverse = ref(false);
|
||||
const dropdownStyle = ref({}); // Teleport 用的样式
|
||||
const selectedOption = ref<Option | null>(null);
|
||||
const selectRef = ref<HTMLDivElement | null>(null);
|
||||
const dropdownRef = ref<HTMLDivElement | null>(null);
|
||||
const slots = useSlots();
|
||||
|
||||
const displayValue = computed(() => {
|
||||
return selectedOption.value
|
||||
? selectedOption.value.label
|
||||
: props.placeholder || '请选择';
|
||||
});
|
||||
|
||||
const updateDropdownPosition = async () => {
|
||||
if (!selectRef.value || !dropdownRef.value) return;
|
||||
|
||||
// 等待 DOM 完全渲染(尤其是下拉框高度)
|
||||
await nextTick();
|
||||
await new Promise(requestAnimationFrame);
|
||||
|
||||
const rect = selectRef.value.getBoundingClientRect();
|
||||
const dropdownHeight = dropdownRef.value.offsetHeight;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
|
||||
isReverse.value = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
|
||||
|
||||
dropdownStyle.value = {
|
||||
position: 'fixed',
|
||||
left: rect.left + 'px',
|
||||
width: rect.width + 'px',
|
||||
top: !isReverse.value
|
||||
? rect.bottom + 5 + 'px'
|
||||
: 'auto',
|
||||
bottom: isReverse.value
|
||||
? window.innerHeight - rect.top + 5 + 'px'
|
||||
: 'auto',
|
||||
zIndex: 9999
|
||||
};
|
||||
};
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
if (props.disabled) return;
|
||||
|
||||
isOpen.value = !isOpen.value;
|
||||
|
||||
if (isOpen.value) {
|
||||
await nextTick();
|
||||
await new Promise(requestAnimationFrame);
|
||||
await updateDropdownPosition();
|
||||
}
|
||||
};
|
||||
|
||||
const selectOption = (value: any, label: string) => {
|
||||
selectedOption.value = {value, label};
|
||||
emit('update:modelValue', value);
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
let selectValue = $ref(props.modelValue);
|
||||
|
||||
provide('selectValue', selectValue);
|
||||
provide('selectHandler', selectOption);
|
||||
|
||||
useWindowClick((e: PointerEvent) => {
|
||||
if (!e) return;
|
||||
if (
|
||||
selectRef.value &&
|
||||
!selectRef.value.contains(e.target as Node) &&
|
||||
dropdownRef.value &&
|
||||
!dropdownRef.value.contains(e.target as Node)
|
||||
) {
|
||||
isOpen.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
selectValue = newValue;
|
||||
if (slots.default) {
|
||||
let slot = slots.default();
|
||||
let list = [];
|
||||
if (slot.length === 1) {
|
||||
list = Array.from(slot[0].children as Array<VNode>);
|
||||
} else {
|
||||
list = slot;
|
||||
}
|
||||
const option = list.find(opt => opt.props.value === newValue);
|
||||
if (option) {
|
||||
selectedOption.value = option.props;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (props.options) {
|
||||
const option = props.options.find(opt => opt.value === newValue);
|
||||
if (option) {
|
||||
selectedOption.value = option;
|
||||
}
|
||||
}
|
||||
}, {immediate: true});
|
||||
|
||||
watch(() => props.options, (newOptions) => {
|
||||
if (newOptions && props.modelValue) {
|
||||
const option = newOptions.find(opt => opt.value === props.modelValue);
|
||||
if (option) {
|
||||
selectedOption.value = option;
|
||||
}
|
||||
}
|
||||
}, {immediate: true});
|
||||
|
||||
const handleOptionClick = (option: Option) => {
|
||||
if (option.disabled) return;
|
||||
selectOption(option.value, option.label);
|
||||
};
|
||||
|
||||
const onScrollOrResize = () => {
|
||||
if (isOpen.value) updateDropdownPosition();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', onScrollOrResize, true);
|
||||
window.addEventListener('resize', onScrollOrResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', onScrollOrResize, true);
|
||||
window.removeEventListener('resize', onScrollOrResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="select"
|
||||
v-bind="attrs"
|
||||
:class="{ 'is-disabled': disabled, 'is-active': isOpen, 'is-reverse': isReverse }"
|
||||
ref="selectRef"
|
||||
>
|
||||
<div class="select__wrapper" @click="toggleDropdown">
|
||||
<div class="select__label" :class="{ 'is-placeholder': !selectedOption }">
|
||||
{{ displayValue }}
|
||||
</div>
|
||||
<div class="select__suffix">
|
||||
<IconMdiChevronDown
|
||||
:class="{ 'is-reverse': isOpen }"
|
||||
width="16"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<teleport to="body">
|
||||
<transition :name="isReverse ? 'zoom-in-bottom' : 'zoom-in-top'" :key="isReverse ? 'bottom' : 'top'">
|
||||
<div
|
||||
class="select__dropdown"
|
||||
v-if="isOpen"
|
||||
ref="dropdownRef"
|
||||
:style="dropdownStyle"
|
||||
>
|
||||
<ul class="select__options">
|
||||
<li
|
||||
v-if="options"
|
||||
v-for="(option, index) in options"
|
||||
:key="index"
|
||||
class="select__option"
|
||||
:class="{
|
||||
'is-selected': option.value === modelValue,
|
||||
'is-disabled': option.disabled
|
||||
}"
|
||||
@click="handleOptionClick(option)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</li>
|
||||
<slot v-else></slot>
|
||||
</ul>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
|
||||
&__wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--color-input-bg, #fff);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-select-bg);
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-placeholder {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
&__suffix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #999;
|
||||
transition: transform 0.3s;
|
||||
|
||||
.is-reverse {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select__dropdown {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.select__options {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.select__option {
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
color: var(--color-select-bg);
|
||||
font-weight: bold;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
}
|
||||
|
||||
.is-disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 往下展开的动画 */
|
||||
.zoom-in-top-enter-active,
|
||||
.zoom-in-top-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.23, 1, 0.32, 1),
|
||||
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
.zoom-in-top-enter-from,
|
||||
.zoom-in-top-leave-to {
|
||||
opacity: 0;
|
||||
transform: scaleY(0);
|
||||
}
|
||||
|
||||
/* 往上展开的动画 */
|
||||
.zoom-in-bottom-enter-active,
|
||||
.zoom-in-bottom-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.23, 1, 0.32, 1),
|
||||
opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.zoom-in-bottom-enter-from,
|
||||
.zoom-in-bottom-leave-to {
|
||||
opacity: 0;
|
||||
transform: scaleY(0);
|
||||
}
|
||||
</style>
|
||||
5
src/pages/pc/components/base/select/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import Select from './Select.vue';
|
||||
import Option from './Option.vue';
|
||||
|
||||
export {Select, Option};
|
||||
export default Select;
|
||||
120
src/pages/pc/components/base/toast/Toast.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import {createVNode, render} from 'vue'
|
||||
import ToastComponent from '@/pages/pc/components/base/toast/Toast.vue'
|
||||
import type {ToastOptions, ToastInstance, ToastService} from '@/pages/pc/components/base/toast/type.ts'
|
||||
|
||||
interface ToastContainer {
|
||||
id: string
|
||||
container: HTMLElement
|
||||
instance: ToastInstance
|
||||
offset: number
|
||||
}
|
||||
|
||||
let toastContainers: ToastContainer[] = []
|
||||
let toastIdCounter = 0
|
||||
|
||||
// 创建Toast容器
|
||||
const createToastContainer = (): HTMLElement => {
|
||||
const container = document.createElement('div')
|
||||
container.className = 'toast-container'
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
`
|
||||
return container
|
||||
}
|
||||
|
||||
// 更新所有Toast的位置
|
||||
const updateToastPositions = () => {
|
||||
toastContainers.forEach((toastContainer, index) => {
|
||||
const offset = index * 70 // 每个Toast之间的间距,从80px减少到50px
|
||||
toastContainer.offset = offset
|
||||
toastContainer.container.style.marginTop = `${offset}px`
|
||||
})
|
||||
}
|
||||
|
||||
// 移除Toast容器
|
||||
const removeToastContainer = (id: string) => {
|
||||
const index = toastContainers.findIndex(container => container.id === id)
|
||||
if (index > -1) {
|
||||
const container = toastContainers[index]
|
||||
// 延迟销毁,等待动画完成
|
||||
setTimeout(() => {
|
||||
render(null, container.container)
|
||||
container.container.remove()
|
||||
const currentIndex = toastContainers.findIndex(c => c.id === id)
|
||||
if (currentIndex > -1) {
|
||||
toastContainers.splice(currentIndex, 1)
|
||||
updateToastPositions()
|
||||
}
|
||||
}, 300) // 等待动画完成(0.3秒)
|
||||
}
|
||||
}
|
||||
|
||||
const Toast: ToastService = (options: ToastOptions | string): ToastInstance => {
|
||||
const toastOptions = typeof options === 'string' ? {message: options} : options
|
||||
const id = `toast-${++toastIdCounter}`
|
||||
|
||||
// 创建Toast容器
|
||||
const container = createToastContainer()
|
||||
document.body.appendChild(container)
|
||||
|
||||
// 创建VNode
|
||||
const vnode = createVNode(ToastComponent, {
|
||||
...toastOptions,
|
||||
onClose: () => {
|
||||
removeToastContainer(id)
|
||||
}
|
||||
})
|
||||
|
||||
// 渲染到容器
|
||||
render(vnode, container)
|
||||
|
||||
// 创建实例
|
||||
const instance: ToastInstance = {
|
||||
close: () => {
|
||||
vnode.component?.exposed?.close?.()
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到容器列表
|
||||
const toastContainer: ToastContainer = {
|
||||
id,
|
||||
container,
|
||||
instance,
|
||||
offset: 0
|
||||
}
|
||||
|
||||
toastContainers.push(toastContainer)
|
||||
updateToastPositions()
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
// 添加类型方法
|
||||
Toast.success = (message: string, options?: Omit<ToastOptions, 'message' | 'type'>) => {
|
||||
return Toast({message, type: 'success', ...options})
|
||||
}
|
||||
|
||||
Toast.warning = (message: string, options?: Omit<ToastOptions, 'message' | 'type'>) => {
|
||||
return Toast({message, type: 'warning', ...options})
|
||||
}
|
||||
|
||||
Toast.info = (message: string, options?: Omit<ToastOptions, 'message' | 'type'>) => {
|
||||
return Toast({message, type: 'info', ...options})
|
||||
}
|
||||
|
||||
Toast.error = (message: string, options?: Omit<ToastOptions, 'message' | 'type'>) => {
|
||||
return Toast({message, type: 'error', ...options})
|
||||
}
|
||||
|
||||
// 关闭所有消息
|
||||
Toast.closeAll = () => {
|
||||
toastContainers.forEach(container => container.instance.close())
|
||||
toastContainers = []
|
||||
}
|
||||
|
||||
export default Toast
|
||||
198
src/pages/pc/components/base/toast/Toast.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<Transition name="message-fade" appear>
|
||||
<div v-if="visible" class="message" :class="type" :style="style" @mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave">
|
||||
<div class="message-content">
|
||||
<IconMdiCheckCircle v-if="props.type === 'success'" class="message-icon"/>
|
||||
<IconMdiAlertCircle v-if="props.type === 'warning'" class="message-icon"/>
|
||||
<IconMdiInformation v-if="props.type === 'info'" class="message-icon"/>
|
||||
<IconMdiCloseCircle v-if="props.type === 'error'" class="message-icon"/>
|
||||
<span class="message-text">{{ message }}</span>
|
||||
<IconMdiClose v-if="showClose" class="message-close" @click="close"/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
|
||||
|
||||
interface Props {
|
||||
message: string
|
||||
type?: 'success' | 'warning' | 'info' | 'error'
|
||||
duration?: number
|
||||
showClose?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'info',
|
||||
duration: 3000,
|
||||
showClose: false
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const visible = ref(false)
|
||||
let timer = null
|
||||
|
||||
const style = computed(() => ({
|
||||
// 移除offset,现在由容器管理位置
|
||||
}))
|
||||
|
||||
const startTimer = () => {
|
||||
if (props.duration > 0) {
|
||||
timer = setTimeout(close, props.duration)
|
||||
}
|
||||
}
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
clearTimer()
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
startTimer()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
visible.value = false
|
||||
// 延迟发出close事件,等待动画完成
|
||||
setTimeout(() => {
|
||||
emit('close')
|
||||
}, 300) // 等待动画完成(0.3秒)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
visible.value = true
|
||||
startTimer()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimer()
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
close,
|
||||
show: () => {
|
||||
visible.value = true
|
||||
startTimer()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message {
|
||||
position: relative;
|
||||
min-width: 16rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 0.2rem;
|
||||
box-shadow: 0 0.2rem 0.9rem rgba(0, 0, 0, 0.15);
|
||||
background: white;
|
||||
border: 1px solid #ebeef5;
|
||||
transition: all 0.3s ease;
|
||||
pointer-events: auto;
|
||||
|
||||
&.success {
|
||||
background: #f0f9ff;
|
||||
border-color: #67c23a;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: #fdf6ec;
|
||||
border-color: #e6a23c;
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
&.info {
|
||||
background: #f4f4f5;
|
||||
border-color: #909399;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: #fef0f0;
|
||||
border-color: #f56c6c;
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
// 深色模式支持
|
||||
html.dark {
|
||||
.message {
|
||||
background: var(--color-second);
|
||||
border-color: var(--color-item-border);
|
||||
color: var(--color-main-text);
|
||||
|
||||
&.success {
|
||||
background: rgba(103, 194, 58, 0.1);
|
||||
border-color: #67c23a;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: rgba(230, 162, 60, 0.1);
|
||||
border-color: #e6a23c;
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
&.info {
|
||||
background: rgba(144, 147, 153, 0.1);
|
||||
border-color: #909399;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: rgba(245, 108, 108, 0.1);
|
||||
border-color: #f56c6c;
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-close {
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.message-fade-enter-active,
|
||||
.message-fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.message-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-40px);
|
||||
}
|
||||
|
||||
.message-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-40px);
|
||||
}
|
||||
</style>
|
||||
26
src/pages/pc/components/base/toast/type.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type ToastType = 'success' | 'warning' | 'info' | 'error'
|
||||
|
||||
export interface ToastOptions {
|
||||
message: string
|
||||
type?: ToastType
|
||||
duration?: number
|
||||
showClose?: boolean
|
||||
}
|
||||
|
||||
export interface ToastInstance {
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export interface ToastService {
|
||||
(options: ToastOptions | string): ToastInstance
|
||||
|
||||
success(message: string, options?: Omit<ToastOptions, 'message' | 'type'>): ToastInstance
|
||||
|
||||
warning(message: string, options?: Omit<ToastOptions, 'message' | 'type'>): ToastInstance
|
||||
|
||||
info(message: string, options?: Omit<ToastOptions, 'message' | 'type'>): ToastInstance
|
||||
|
||||
error(message: string, options?: Omit<ToastOptions, 'message' | 'type'>): ToastInstance
|
||||
|
||||
closeAll(): void
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {onMounted, onUnmounted, watch} from "vue";
|
||||
import Tooltip from "@/pages/pc/components/Tooltip.vue";
|
||||
import {Icon} from '@iconify/vue';
|
||||
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
|
||||
import {useEventListener} from "@/hooks/event.ts";
|
||||
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
@@ -93,13 +92,13 @@ watch(() => props.modelValue, n => {
|
||||
runtimeStore.modalList.push({id, close})
|
||||
zIndex = 999 + runtimeStore.modalList.length
|
||||
visible = true
|
||||
console.log(12)
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// console.log('props.modelValue', props.modelValue)
|
||||
if (props.modelValue === undefined) {
|
||||
visible = true
|
||||
id = Date.now()
|
||||
@@ -158,11 +157,10 @@ async function cancel() {
|
||||
]"
|
||||
>
|
||||
<Tooltip title="关闭">
|
||||
<Icon @click="close"
|
||||
v-if="showClose"
|
||||
class="close hvr-grow cursor-pointer"
|
||||
width="24" color="#929596"
|
||||
icon="ion:close-outline"/>
|
||||
<IconIonCloseOutline @click="close"
|
||||
v-if="showClose"
|
||||
class="close hvr-grow cursor-pointer"
|
||||
width="24" color="#929596"/>
|
||||
</Tooltip>
|
||||
<div class="modal-header" v-if="header">
|
||||
<div class="title">{{ props.title }}</div>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
|
||||
|
||||
import {onMounted, onUnmounted, watch} from "vue";
|
||||
import {emitter, EventKey} from "@/utils/eventBus.ts";
|
||||
import {useRuntimeStore} from "@/stores/runtime.ts";
|
||||
import WordList from "@/pages/pc/components/list/WordList.vue";
|
||||
import Empty from "@/components/Empty.vue";
|
||||
|
||||
let show = $ref(false)
|
||||
let list = $ref([])
|
||||
let title = $ref('')
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on(EventKey.openWordListModal, (val: any) => {
|
||||
show = true
|
||||
list = val.list
|
||||
title = val.title + `(${list.length}词)`
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => show, v => {
|
||||
if (!v) {
|
||||
list = []
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(EventKey.openWordListModal)
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:title="title"
|
||||
v-model="show">
|
||||
<div class="all-word">
|
||||
<WordList
|
||||
v-if="list.length"
|
||||
class="word-list"
|
||||
:list="list">
|
||||
</WordList>
|
||||
<Empty v-else/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
|
||||
.all-word {
|
||||
padding-bottom: var(--space);
|
||||
padding-top: 0;
|
||||
width: 30rem;
|
||||
height: 75vh;
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
@@ -48,7 +48,7 @@ defineExpose({scrollToBottom, scrollToItem})
|
||||
<template>
|
||||
<div class="list">
|
||||
<div class="search">
|
||||
<Input v-model="searchKey"/>
|
||||
<Input prefix-icon v-model="searchKey"/>
|
||||
</div>
|
||||
<BaseList
|
||||
ref="listRef"
|
||||
|
||||