Merge branch 'master' into dev

# Conflicts:
#	components.d.ts
This commit is contained in:
zyronon
2025-08-20 00:24:50 +08:00
125 changed files with 10366 additions and 404989 deletions

46
.github/workflows/deploy-aliyun-oss.yml vendored Normal file
View 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 }}

View File

@@ -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

View File

@@ -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>
## 📸 在线访问
![image](/docs/word.png)
![image](/docs/article.png)
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非常感谢你对我们的支持

View File

4
components.d.ts vendored
View File

@@ -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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

BIN
docs/word.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

View File

@@ -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>

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -676,8 +676,6 @@
}
]
},
{
"id": "F4wm63",
"title": "A wet night",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

1
public/s.js Normal file
View 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)}();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

140
scripts/deploy-oss.js Normal file
View 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)
})

View 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()

View File

@@ -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
View 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"
}
]
]

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View File

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

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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)

View 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
}
}

View File

@@ -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(() => {

View File

@@ -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
}

View File

@@ -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
})
}

View File

@@ -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

View File

@@ -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
View 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('&');
}
}

View File

@@ -0,0 +1 @@
复制这个库是因为他引入了franc-min这个包太大了50多k我用不到

145
src/libs/translate/baidu.ts Normal file
View 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;

View File

@@ -0,0 +1,2 @@
export * from "./languages";
export * from "./locales";

View 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;

View File

@@ -0,0 +1,3 @@
import { Language } from "./languages";
export type Locale = { [key in Language]: string };

View File

@@ -0,0 +1,3 @@
export * from "../languages";
export * from "./type";
export * from "./translator";

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

View 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">;

View File

@@ -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')

View File

@@ -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">
开启后输入时不区分大小写如输入helloHello都会被认为是正确的
</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>

View File

@@ -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"

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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/>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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)

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
import Select from './Select.vue';
import Option from './Option.vue';
export {Select, Option};
export default Select;

View 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

View 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>

View 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
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

Some files were not shown because too many files have changed in this diff Show More