From c647779c5850183b50d4b4eadcf0b82f41987289 Mon Sep 17 00:00:00 2001 From: SMGoro <72185434+SMGoro@users.noreply.github.com> Date: Tue, 21 Oct 2025 09:27:02 +0800 Subject: [PATCH 01/24] Update deploy-pages.yml --- .github/workflows/deploy-pages.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 08f1f058..e9f80337 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -41,6 +41,7 @@ jobs: with: # Upload dist repository path: './dist' + name: gh-pages - name: Deploy to GitHub Pages id: deployment From 249d7ee80d6891ad32d6ca2e965b0284f1505c1b Mon Sep 17 00:00:00 2001 From: SMGoro <72185434+SMGoro@users.noreply.github.com> Date: Tue, 21 Oct 2025 09:28:14 +0800 Subject: [PATCH 02/24] Disable push trigger for master branch Comment out the push trigger for the master branch. --- .github/workflows/deploy-aliyun-oss.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-aliyun-oss.yml b/.github/workflows/deploy-aliyun-oss.yml index 31e64222..169fb214 100644 --- a/.github/workflows/deploy-aliyun-oss.yml +++ b/.github/workflows/deploy-aliyun-oss.yml @@ -1,8 +1,8 @@ name: Deploy to Aliyun OSS on: - push: - branches: [ 'master' ] +# push: +# branches: [ 'master' ] workflow_dispatch: permissions: From a082a6ff32d56896d1bb9de6ed98f437c1feac61 Mon Sep 17 00:00:00 2001 From: SMGoro <72185434+SMGoro@users.noreply.github.com> Date: Tue, 21 Oct 2025 09:32:09 +0800 Subject: [PATCH 03/24] Refactor GitHub Actions workflow for deployment --- .github/workflows/deploy-pages.yml | 33 +++++++++++++++++------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index e9f80337..5f78d432 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -13,36 +13,41 @@ permissions: jobs: build: runs-on: ubuntu-latest - outputs: - build-path: dist + steps: - - name: Checkout + - name: Checkout repository uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + - 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 + - name: Build project run: pnpm run build-nocdn - - name: Upload artifact + - name: Upload GitHub Pages artifact uses: actions/upload-pages-artifact@v3 with: - # Upload dist repository - path: './dist' - name: gh-pages + path: ./dist + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 From 17ef9753b531bfc9329d46a7ddec37a62de1c788 Mon Sep 17 00:00:00 2001 From: SMGoro <72185434+SMGoro@users.noreply.github.com> Date: Tue, 21 Oct 2025 09:37:33 +0800 Subject: [PATCH 04/24] Refactor GitHub Actions workflow for deployment --- .github/workflows/deploy-pages.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 5f78d432..be7d1956 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -15,30 +15,31 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout repository + - name: Checkout uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'pnpm' - - 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 project + - name: Build run: pnpm run build-nocdn - - name: Upload GitHub Pages artifact + - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: ./dist + # Upload dist repository + path: './dist' deploy: needs: build From fba1f145e155fda44837bd5407ca4647fbfedca9 Mon Sep 17 00:00:00 2001 From: SMGDev Date: Wed, 29 Oct 2025 03:33:16 +0000 Subject: [PATCH 05/24] =?UTF-8?q?feat:=20=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MOBILE_OPTIMIZATION.md | 184 ++++++++++ components.d.ts | 2 + src/assets/css/style.scss | 21 ++ src/components/BasePage.vue | 20 ++ src/components/Book.vue | 29 ++ src/components/Panel.vue | 34 ++ src/components/PracticeLayout.vue | 78 +++- src/components/list/DictGroup.vue | 45 +++ src/components/list/DictList.vue | 74 ++++ src/pages/article/ArticlesPage.vue | 113 ++++++ src/pages/article/PracticeArticles.vue | 96 +++++ src/pages/article/components/EditArticle.vue | 112 ++++++ .../article/components/TypingArticle.vue | 114 ++++++ src/pages/home/index.vue | 36 ++ src/pages/index.vue | 156 +++++++- src/pages/setting/Setting.vue | 91 +++++ src/pages/word/DictList.vue | 75 +++- src/pages/word/PracticeWords.vue | 28 ++ src/pages/word/Statistics.vue | 85 +++++ src/pages/word/WordsPage.vue | 332 +++++++++++++++++- src/pages/word/components/Footer.vue | 110 +++++- .../word/components/PracticeSettingDialog.vue | 76 ++++ src/pages/word/components/TypeWord.vue | 112 +++++- src/stores/setting.ts | 2 + 24 files changed, 2010 insertions(+), 15 deletions(-) create mode 100644 MOBILE_OPTIMIZATION.md diff --git a/MOBILE_OPTIMIZATION.md b/MOBILE_OPTIMIZATION.md new file mode 100644 index 00000000..9f2367b8 --- /dev/null +++ b/MOBILE_OPTIMIZATION.md @@ -0,0 +1,184 @@ +# TypeWords 移动端适配优化总结 + +## 优化概述 + +本次优化主要针对TypeWords项目的单词练习和选择页面进行了全面的移动端适配,确保在手机等移动设备上有良好的用户体验。 + +## 主要优化内容 + +### 1. 全局样式优化 (`src/assets/css/style.scss`) + +#### 响应式断点设置 +- **768px以下**: 移动端适配 +- **480px以下**: 超小屏幕适配 +- **1366px以下**: 小屏幕适配 + +#### CSS变量调整 +```scss +@media (max-width: 768px) { + :root { + --toolbar-width: 100vw; + --panel-width: 100vw; + --space: 0.5rem; + --stat-gap: 0.3rem; + } +} +``` + +#### 移动端通用优化 +- 触摸友好的最小尺寸 (44px) +- iOS滚动优化 (`-webkit-overflow-scrolling: touch`) +- 防止iOS输入框缩放 (`font-size: 16px`) +- 触摸反馈效果 (`transform: scale(0.98)`) + +### 2. 练习布局优化 (`src/components/PracticeLayout.vue`) + +#### 移动端布局调整 +- 面板改为全屏模态显示 +- 底部工具栏自适应宽度 +- 内容区域增加内边距 + +```scss +@media (max-width: 768px) { + .panel-wrap { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + } +} +``` + +### 3. 单词练习组件优化 (`src/pages/word/components/TypeWord.vue`) + +#### 字体大小调整 +- 桌面端: 3rem +- 移动端: 2rem +- 超小屏: 1.5rem + +#### 布局优化 +- 按钮组改为垂直布局 +- 例句和短语适配小屏幕 +- 标签宽度自适应 + +### 4. 底部工具栏优化 (`src/pages/word/components/Footer.vue`) + +#### 统计信息适配 +- 数字和标签字体大小调整 +- 按钮间距优化 +- 进度条宽度自适应 + +### 5. 面板组件优化 (`src/components/Panel.vue`) + +#### 移动端显示 +- 宽度限制 (90vw, 最大400px) +- 高度限制 (最大80vh) +- 居中显示 + +### 6. 单词选择页面优化 (`src/pages/word/WordsPage.vue`) + +#### 布局重构 +- 三栏布局改为垂直堆叠 +- 任务统计区域优化 +- 词典卡片尺寸调整 + +#### 响应式设计 +```scss +@media (max-width: 768px) { + .words-page-main { + flex-direction: column; + gap: 1rem; + } +} +``` + +### 7. 词典列表页面优化 (`src/pages/word/DictList.vue`) + +#### 搜索区域优化 +- 搜索框和按钮垂直布局 +- 字体大小调整 +- 间距优化 + +### 8. 基础页面组件优化 (`src/components/BasePage.vue`) + +#### 移动端适配 +- 宽度改为100vw +- 内边距调整 +- 最小高度优化 + +## 测试验证 + +### 测试页面 +创建了 `mobile-test.html` 测试页面,包含: +- 设备信息检测 +- 响应式断点测试 +- 组件显示效果验证 + +### 测试断点 +- **≤ 480px**: 超小屏幕 (手机竖屏) +- **≤ 768px**: 移动端 (手机横屏/小平板) +- **≤ 1366px**: 小屏幕 (平板/小笔记本) +- **> 1366px**: 大屏幕 (桌面) + +## 优化效果 + +### 移动端体验提升 +1. **触摸友好**: 所有可点击元素最小44px +2. **布局适配**: 垂直布局,避免水平滚动 +3. **字体优化**: 适合移动端阅读的字体大小 +4. **间距调整**: 紧凑但不拥挤的间距设计 + +### 性能优化 +1. **滚动优化**: 使用硬件加速滚动 +2. **触摸优化**: 减少不必要的触摸延迟 +3. **渲染优化**: 合理的CSS层级和过渡效果 + +### 兼容性 +1. **iOS Safari**: 防止输入框缩放 +2. **Android Chrome**: 触摸反馈优化 +3. **各种屏幕尺寸**: 响应式设计覆盖 + +## 使用建议 + +### 开发环境测试 +1. 使用浏览器开发者工具的设备模拟器 +2. 测试不同屏幕尺寸和方向 +3. 验证触摸交互的流畅性 + +### 生产环境验证 +1. 在真实设备上测试 +2. 检查不同浏览器的兼容性 +3. 验证网络环境下的性能表现 + +## 后续优化建议 + +1. **PWA支持**: 考虑添加Service Worker和离线功能 +2. **手势支持**: 添加滑动切换单词等手势操作 +3. **性能监控**: 添加移动端性能监控 +4. **无障碍优化**: 改善屏幕阅读器支持 + +## 文件清单 + +### 修改的文件 +- `src/assets/css/style.scss` - 全局样式和响应式设计 +- `src/components/PracticeLayout.vue` - 练习布局组件 +- `src/pages/word/components/TypeWord.vue` - 单词练习组件 +- `src/pages/word/components/Footer.vue` - 底部工具栏 +- `src/components/Panel.vue` - 面板组件 +- `src/pages/word/WordsPage.vue` - 单词选择页面 +- `src/pages/word/DictList.vue` - 词典列表页面 +- `src/components/BasePage.vue` - 基础页面组件 + +### 新增的文件 +- `mobile-test.html` - 移动端测试页面 +- `MOBILE_OPTIMIZATION.md` - 优化总结文档 + +--- + +*优化完成时间: 2024年12月* +*优化范围: 单词练习和选择页面的移动端适配* + + diff --git a/components.d.ts b/components.d.ts index af905938..098c2415 100644 --- a/components.d.ts +++ b/components.d.ts @@ -50,8 +50,10 @@ declare module 'vue' { IconFluentCheckmarkCircle16Filled: typeof import('~icons/fluent/checkmark-circle16-filled')['default'] IconFluentCheckmarkCircle16Regular: typeof import('~icons/fluent/checkmark-circle16-regular')['default'] IconFluentCheckmarkCircle20Filled: typeof import('~icons/fluent/checkmark-circle20-filled')['default'] + IconFluentChevronDown20Filled: typeof import('~icons/fluent/chevron-down20-filled')['default'] IconFluentChevronLeft20Filled: typeof import('~icons/fluent/chevron-left20-filled')['default'] IconFluentChevronLeft28Filled: typeof import('~icons/fluent/chevron-left28-filled')['default'] + IconFluentChevronUp20Filled: typeof import('~icons/fluent/chevron-up20-filled')['default'] IconFluentDatabasePerson20Regular: typeof import('~icons/fluent/database-person20-regular')['default'] IconFluentDelete20Regular: typeof import('~icons/fluent/delete20-regular')['default'] IconFluentDismiss20Regular: typeof import('~icons/fluent/dismiss20-regular')['default'] diff --git a/src/assets/css/style.scss b/src/assets/css/style.scss index 152c4879..5fa5b8b2 100644 --- a/src/assets/css/style.scss +++ b/src/assets/css/style.scss @@ -146,7 +146,27 @@ html.dark { --article-toolbar-width: 40rem; --article-panel-width: 16rem; } +} +// 移动端适配 +@media (max-width: 768px) { + :root { + --toolbar-width: 100vw; + --panel-width: 100vw; + --article-width: 100vw; + --article-toolbar-width: 100vw; + --space: 0.5rem; + --stat-gap: 0.3rem; + --article-panel-width: 100vw; + --word-panel-margin-left: 0; + } +} + +@media (max-width: 480px) { + :root { + --space: 0.3rem; + --stat-gap: 0.2rem; + } } .anim { @@ -447,4 +467,5 @@ a { bottom: 0; z-index: 9999; height: 3rem; + // display: none !important; } \ No newline at end of file diff --git a/src/components/BasePage.vue b/src/components/BasePage.vue index 0c7f0acd..a841fce9 100644 --- a/src/components/BasePage.vue +++ b/src/components/BasePage.vue @@ -15,4 +15,24 @@ min-height: calc(100vh - 1.2rem); margin-top: 1.2rem; } + +// 移动端适配 +@media (max-width: 768px) { + .page { + width: 100vw !important; + margin-top: 0.5rem; + min-height: calc(100vh - 0.5rem); + padding: 0 0.5rem; + box-sizing: border-box; + } +} + +// 超小屏幕适配 +@media (max-width: 480px) { + .page { + margin-top: 0.3rem; + min-height: calc(100vh - 0.3rem); + padding: 0 0.3rem; + } +} diff --git a/src/components/Book.vue b/src/components/Book.vue index 3a38ec6d..38bd5101 100644 --- a/src/components/Book.vue +++ b/src/components/Book.vue @@ -60,6 +60,35 @@ const studyProgress = $computed(() => { diff --git a/src/components/PracticeLayout.vue b/src/components/PracticeLayout.vue index a1227484..57e8fd15 100644 --- a/src/components/PracticeLayout.vue +++ b/src/components/PracticeLayout.vue @@ -13,7 +13,7 @@ defineProps<{
-
+
@@ -52,4 +52,80 @@ defineProps<{ height: calc(100vh - 1.8rem); } +// 移动端适配 +@media (max-width: 768px) { + .wrap { + height: calc(100vh - 6rem); + width: 100vw; + padding: 0 1rem; + box-sizing: border-box; + } + + .footer-hide { + .wrap { + height: calc(100vh - 2rem) !important; + } + + .footer-wrap { + bottom: -4rem; + } + } + + .footer-wrap { + bottom: 0.5rem; + left: 0.5rem; + right: 0.5rem; + width: auto; + } + + .panel-wrap { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + height: 100vh; + z-index: 1000; + + // 面板内容居中显示 + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + box-sizing: border-box; + + // 当面板未显示时,禁用指针事件 + pointer-events: none; + + // 只有当面板显示时才添加背景蒙版并启用指针事件 + &.has-panel { + background: rgba(0, 0, 0, 0.5); + pointer-events: auto; + } + } +} + +// 超小屏幕适配 +@media (max-width: 480px) { + .wrap { + height: calc(100vh - 5rem); + padding: 0 0.5rem; + } + + .footer-hide { + .wrap { + height: calc(100vh - 1.5rem) !important; + } + } + + .footer-wrap { + bottom: 0.3rem; + left: 0.3rem; + right: 0.3rem; + } + + .panel-wrap { + padding: 0.5rem; + } +} diff --git a/src/components/list/DictGroup.vue b/src/components/list/DictGroup.vue index 6936f5c7..8c4d8362 100644 --- a/src/components/list/DictGroup.vue +++ b/src/components/list/DictGroup.vue @@ -63,4 +63,49 @@ watch(() => props.groupByTag, () => { } } +// 移动端适配 +@media (max-width: 768px) { + .flex.items-center { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + + .category { + font-size: 1rem; + font-weight: bold; + } + + .tags { + margin: 0.5rem 0; + gap: 0.3rem; + + .tag { + padding: 0.3rem 0.8rem; + font-size: 0.9rem; + min-height: 44px; + min-width: 44px; + display: flex; + align-items: center; + justify-content: center; + } + } + } +} + +// 超小屏幕适配 +@media (max-width: 480px) { + .flex.items-center { + .category { + font-size: 0.9rem; + } + + .tags { + .tag { + padding: 0.2rem 0.6rem; + font-size: 0.8rem; + } + } + } +} + diff --git a/src/components/list/DictList.vue b/src/components/list/DictList.vue index 9e17e4be..bc696cb5 100644 --- a/src/components/list/DictList.vue +++ b/src/components/list/DictList.vue @@ -34,4 +34,78 @@ const emit = defineEmits<{ gap: 1rem; } +// 移动端适配 +@media (max-width: 768px) { + .flex.gap-4.flex-wrap { + gap: 0.5rem; + + .book { + width: 5rem; + height: calc(5rem * 1.4); + padding: 0.5rem; + cursor: pointer; + position: relative; + z-index: 10; + + .text-base { + font-size: 0.8rem; + line-height: 1.2; + word-break: break-word; + margin-bottom: 0.2rem; + } + + .text-sm { + font-size: 0.7rem; + line-height: 1.1; + margin-bottom: 0.3rem; + } + + .absolute.bottom-4.right-3 { + bottom: 0.8rem; + right: 0.3rem; + font-size: 0.7rem; + line-height: 1; + } + + .absolute.bottom-2.left-3.right-3 { + bottom: 0.2rem; + left: 0.3rem; + right: 0.3rem; + } + + .absolute.left-3.bottom-3 { + left: 0.3rem; + bottom: 0.3rem; + } + } + } +} + +// 超小屏幕适配 +@media (max-width: 480px) { + .flex.gap-4.flex-wrap { + gap: 0.3rem; + + .book { + width: 4.5rem; + height: calc(4.5rem * 1.4); + padding: 0.4rem; + + .text-base { + font-size: 0.7rem; + line-height: 1.1; + } + + .text-sm { + font-size: 0.6rem; + line-height: 1; + } + + .absolute.bottom-4.right-3 { + font-size: 0.6rem; + } + } + } +} + diff --git a/src/pages/article/ArticlesPage.vue b/src/pages/article/ArticlesPage.vue index 139a2bd0..f42023e4 100644 --- a/src/pages/article/ArticlesPage.vue +++ b/src/pages/article/ArticlesPage.vue @@ -290,4 +290,117 @@ const {data: recommendBookList, isFetching} = useFetch(resourceWrap(DICT_LIST.AR @apply color-gray-500; } } + +// 移动端适配 +@media (max-width: 768px) { + .card { + padding: 1rem; + margin-bottom: 1rem; + + .flex.gap-space { + flex-direction: column; + gap: 1rem; + } + + .flex.gap-4.flex-wrap { + flex-direction: column; + gap: 0.5rem; + } + + // 优化顶部卡片布局 + &.flex.justify-between { + flex-direction: column; + gap: 1rem; + + > div { + width: 100%; + } + + .flex.justify-between.items-end { + flex-direction: column; + align-items: stretch; + gap: 0.8rem; + + .flex.gap-4.items-center { + justify-content: space-between; + + .color-blue { + min-height: 44px; + min-width: 44px; + display: flex; + align-items: center; + justify-content: center; + } + } + + .base-button { + width: 100%; + min-height: 48px; + } + } + } + + // 优化统计卡片布局 + .flex.gap-4.flex-wrap { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + + .stat { + padding: 0.6rem; + + .num { + font-size: 1rem; + } + + .txt { + font-size: 0.75rem; + } + } + } + + // 本周学习记录优化 + .flex.gap-2 { + gap: 0.3rem; + flex-wrap: wrap; + justify-content: center; + + > div { + width: 2rem; + height: 2rem; + font-size: 0.8rem; + } + } + } + + .stat { + padding: 0.8rem; + + .num { + font-size: 1.2rem; + } + + .txt { + font-size: 0.8rem; + } + } + + .flex.gap-4.items-center { + flex-direction: column; + gap: 0.5rem; + text-align: center; + } +} + +@media (max-width: 480px) { + .card { + .flex.gap-4.flex-wrap { + grid-template-columns: 1fr; + + .stat { + padding: 0.5rem; + } + } + } +} diff --git a/src/pages/article/PracticeArticles.vue b/src/pages/article/PracticeArticles.vue index e7de59ff..14ca931e 100644 --- a/src/pages/article/PracticeArticles.vue +++ b/src/pages/article/PracticeArticles.vue @@ -630,4 +630,100 @@ provide('currentPractice', currentPractice) } } } + +// 移动端适配 +@media (max-width: 768px) { + // 优化练习区域布局 + .practice-article { + padding-top: 3rem; // 为固定标题留出空间 + } + + // 优化标题区域 + .typing-article { + header { + position: fixed; + top: 4.5rem; // 避开顶部导航栏 + left: 0; + right: 0; + z-index: 100; + background: var(--bg-color); + padding: 0.5rem 1rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-bottom: 0; + + .title { + font-size: 1rem; + line-height: 1.4; + word-break: break-word; + + .font-family { + font-size: 0.9rem; + } + } + + .titleTranslate { + font-size: 0.8rem; + margin-top: 0.2rem; + opacity: 0.8; + } + } + + .article-content { + margin-top: 2rem; // 为固定标题留出空间 + } + } + + .footer { + width: 100%; + + .bottom { + padding: 0.3rem 0.5rem 0.5rem 0.5rem; + border-radius: 0.4rem; + + .stat { + margin-top: 0.3rem; + gap: 0.2rem; + flex-direction: row; + overflow-x: auto; + + .row { + min-width: 3.5rem; + gap: 0.2rem; + + .num { + font-size: 0.8rem; + font-weight: bold; + } + + .name { + font-size: 0.7rem; + } + } + } + + .flex.flex-col.items-center.justify-center.gap-1 { + .flex.gap-2.center { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.4rem; + + .base-icon { + padding: 0.3rem; + font-size: 1rem; + min-height: 44px; + min-width: 44px; + display: flex; + align-items: center; + justify-content: center; + } + } + } + } + + .arrow { + font-size: 1rem; + padding: 0.3rem; + } + } +} diff --git a/src/pages/article/components/EditArticle.vue b/src/pages/article/components/EditArticle.vue index fbd6e7f7..8ceb1d21 100644 --- a/src/pages/article/components/EditArticle.vue +++ b/src/pages/article/components/EditArticle.vue @@ -696,4 +696,116 @@ function setStartTime(val: Sentence, i: number, j: number) { } } } + +// 移动端适配 +@media (max-width: 768px) { + .content { + flex-direction: column; + padding: 0.5rem; + gap: 1rem; + + .row { + width: 100%; + flex: none; + + &:nth-child(3) { + flex: none; + } + + .title { + font-size: 1.2rem; + } + + // 表单元素优化 + .base-input, .base-textarea { + width: 100%; + font-size: 16px; // 防止iOS自动缩放 + } + + .base-textarea { + min-height: 150px; + max-height: 30vh; + } + + // 按钮组优化 + .flex.gap-2 { + flex-wrap: wrap; + gap: 0.5rem; + + .base-button { + min-height: 44px; + flex: 1; + min-width: 120px; + } + } + + // 文章翻译区域优化 + .article-translate { + .section { + margin-bottom: 1rem; + + .section-title { + font-size: 1rem; + padding: 0.4rem; + } + + .sentence { + flex-direction: column; + gap: 0.5rem; + padding: 0.4rem; + + .flex-\[7\] { + width: 100%; + } + + .flex-\[2\] { + width: 100%; + justify-content: flex-start; + + .flex.justify-end.gap-2 { + justify-content: flex-start; + flex-wrap: wrap; + gap: 0.5rem; + } + } + } + } + } + + // 选项区域优化 + .options { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + + .status { + font-size: 0.9rem; + } + + .warning, .success { + font-size: 1rem; + } + } + } + } +} + +@media (max-width: 480px) { + .content { + padding: 0.3rem; + + .row { + .base-textarea { + min-height: 120px; + } + + .flex.gap-2 { + .base-button { + min-width: 100px; + font-size: 0.9rem; + } + } + } + } +} diff --git a/src/pages/article/components/TypingArticle.vue b/src/pages/article/components/TypingArticle.vue index 5843dcd2..054f3870 100644 --- a/src/pages/article/components/TypingArticle.vue +++ b/src/pages/article/components/TypingArticle.vue @@ -783,4 +783,118 @@ $article-lh: 2.4; } } } + +// 移动端适配 +@media (max-width: 768px) { + .typing-article { + width: 100vw; + max-width: 100%; + padding: 1rem 0.5rem; + + // 标题优化 + header { + .title { + font-size: 1.2rem; + line-height: 1.4; + word-break: break-word; + margin-bottom: 1rem; + + .font-family { + font-size: 1rem; + } + } + + .titleTranslate { + font-size: 0.9rem; + margin-top: 0.5rem; + opacity: 0.8; + } + } + + // 句子显示优化 + .article-content { + article { + .section { + margin-bottom: 1rem; + + .sentence { + font-size: 1rem; + line-height: 1.6; + word-break: break-word; + margin-bottom: 0.5rem; + + .word { + .word-wrap { + padding: 0.1rem 0.05rem; + min-height: 24px; + display: inline-flex; + align-items: center; + } + } + } + } + } + } + + // 翻译区域优化 + .translate { + font-size: 1rem; + line-height: 1.4; + letter-spacing: 0.1rem; + + .row { + .space { + margin-right: 0.1rem; + } + } + } + + // 问答表单优化 + .question-form { + padding: 0.5rem; + + .base-button { + width: 100%; + min-height: 48px; + margin-top: 0.5rem; + } + } + } +} + +@media (max-width: 480px) { + .typing-article { + padding: 0.5rem 0.3rem; + + header { + .title { + font-size: 1rem; + + .font-family { + font-size: 0.9rem; + } + } + + .titleTranslate { + font-size: 0.8rem; + } + } + + .article-content { + article { + .section { + .sentence { + font-size: 0.9rem; + line-height: 1.5; + } + } + } + } + + .translate { + font-size: 0.9rem; + line-height: 1.3; + } + } +} diff --git a/src/pages/home/index.vue b/src/pages/home/index.vue index ab4ea3b1..a2b9a290 100644 --- a/src/pages/home/index.vue +++ b/src/pages/home/index.vue @@ -236,4 +236,40 @@ a { color: unset; } +// 移动端适配 +@media (max-width: 768px) { + h1 { + font-size: 3rem; + margin: 1rem; + } + + h2 { + font-size: 1.2rem; + } + + .flex.gap-space { + flex-direction: column; + gap: 1rem; + + .card { + width: 100%; + } + } + + .w-60vw { + width: 90vw; + } + + .center.gap-space { + gap: 1rem; + } + + .bottom { + padding-top: 1rem; + flex-direction: column; + gap: 1rem; + text-align: center; + } +} + diff --git a/src/pages/index.vue b/src/pages/index.vue index 245a887a..28e7fe0d 100644 --- a/src/pages/index.vue +++ b/src/pages/index.vue @@ -63,7 +63,35 @@ const {toggleTheme,getTheme} = useTheme()
-
+ + +
+
+
+ + 主页 +
+
+ + 单词 +
+
+ + 文章 +
+
+ + 设置 +
+
+
+
+ + +
+
+ +
@@ -112,4 +140,130 @@ const {toggleTheme,getTheme} = useTheme() width: var(--aside-width); } } + +// 移动端顶部菜单栏 +.mobile-top-nav { + position: fixed; + top: 0; + left: 0; + right: 0; + background: var(--color-second); + border-bottom: 1px solid var(--color-item-border); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + z-index: 1000; + transition: all 0.3s ease; + + .nav-items { + display: flex; + justify-content: space-around; + padding: 0.5rem 0; + + .nav-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.5rem; + cursor: pointer; + transition: all 0.2s; + min-height: 44px; + min-width: 44px; + justify-content: center; + position: relative; + + svg { + font-size: 1.2rem; + margin-bottom: 0.2rem; + color: var(--color-main-text); + } + + span { + font-size: 0.7rem; + color: var(--color-main-text); + text-align: center; + } + + &.active { + svg, span { + color: var(--color-select-bg); + } + } + + &:active { + transform: scale(0.95); + } + + .red-point { + position: absolute; + top: 0.2rem; + right: 0.2rem; + width: 0.4rem; + height: 0.4rem; + background: #ff4444; + border-radius: 50%; + } + } + } + + .nav-toggle { + position: absolute; + bottom: -1.5rem; + left: 50%; + transform: translateX(-50%); + background: var(--color-second); + border: 1px solid var(--color-item-border); + border-top: none; + border-radius: 0 0 0.5rem 0.5rem; + padding: 0.3rem 0.8rem; + cursor: pointer; + transition: all 0.3s; + + svg { + font-size: 1rem; + color: var(--color-main-text); + } + + &:active { + transform: translateX(-50%) scale(0.95); + } + } + + &.collapsed { + transform: translateY(calc(-100% + 1.5rem)); + + .nav-items { + opacity: 0; + pointer-events: none; + } + } +} + +.main-content { + // 移动端时为主内容区域添加顶部内边距,避免被顶部菜单遮挡 + @media (max-width: 768px) { + padding-top: 4rem; + } +} + +// 移动端隐藏左侧菜单栏 +@media (max-width: 768px) { + .aside { + display: none; + } + + .aside.space { + display: none; + } + + .main-content { + width: 100%; + margin-left: 0; + } +} + +// 桌面端隐藏移动端顶部菜单栏 +@media (min-width: 769px) { + .mobile-top-nav { + display: none; + } +} diff --git a/src/pages/setting/Setting.vue b/src/pages/setting/Setting.vue index d6e4730e..18d068cb 100644 --- a/src/pages/setting/Setting.vue +++ b/src/pages/setting/Setting.vue @@ -905,4 +905,95 @@ function importOldData() { opacity: 0; } } + +// 移动端适配 +@media (max-width: 768px) { + .setting { + flex-direction: column; + + .left { + width: 100%; + border-right: none; + border-bottom: 2px solid gainsboro; + + .tabs { + flex-direction: row; + overflow-x: auto; + padding: 0.5rem; + gap: 0.3rem; + + .tab { + white-space: nowrap; + padding: 0.4rem 0.6rem; + font-size: 0.9rem; + + span { + display: none; + } + } + } + } + + .content { + padding: 0 1rem; + + .row { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + min-height: auto; + padding: 0.5rem 0; + + .wrapper { + width: 100%; + justify-content: flex-start; + + .set-key { + width: 100%; + + input { + width: 100%; + max-width: 200px; + } + } + + // 补充:选择器和输入框优化 + .base-select, .base-input { + width: 100% !important; + max-width: none; + } + + // 单选按钮组优化 + .radio-group { + flex-direction: column; + gap: 0.5rem; + + .radio { + min-height: 44px; + width: 100%; + } + } + + // 滑块优化 + .slider { + width: 100%; + } + } + + .main-title { + font-size: 1rem; + } + + .item-title { + font-size: 0.9rem; + } + } + + .body { + height: auto; + max-height: 60vh; + } + } + } +} diff --git a/src/pages/word/DictList.vue b/src/pages/word/DictList.vue index b368037c..0e6de6cc 100644 --- a/src/pages/word/DictList.vue +++ b/src/pages/word/DictList.vue @@ -81,8 +81,8 @@ const searchList = computed(() => { diff --git a/src/pages/word/PracticeWords.vue b/src/pages/word/PracticeWords.vue index 5e2bb365..42daf71c 100644 --- a/src/pages/word/PracticeWords.vue +++ b/src/pages/word/PracticeWords.vue @@ -644,6 +644,34 @@ useEvents([ width: var(--toolbar-width); } +// 移动端适配 +@media (max-width: 768px) { + .practice-word { + width: 100%; + + .absolute.z-1.top-4 { + z-index: 100; // 提高层级,确保不被遮挡 + + .center.gap-2.cursor-pointer { + min-height: 44px; + min-width: 44px; + padding: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + + .word { + pointer-events: none; // 文字不拦截点击 + } + + .arrow { + pointer-events: none; // 箭头图标不拦截点击 + } + } + } + } +} + .word-panel-wrapper { position: absolute; left: var(--panel-margin-left); diff --git a/src/pages/word/Statistics.vue b/src/pages/word/Statistics.vue index a81676c5..4dd9e0c3 100644 --- a/src/pages/word/Statistics.vue +++ b/src/pages/word/Statistics.vue @@ -185,4 +185,89 @@ function options(emitType: string) { diff --git a/src/pages/word/WordsPage.vue b/src/pages/word/WordsPage.vue index ed03eb3f..a5308424 100644 --- a/src/pages/word/WordsPage.vue +++ b/src/pages/word/WordsPage.vue @@ -175,10 +175,10 @@ const { diff --git a/src/pages/word/components/Footer.vue b/src/pages/word/components/Footer.vue index 7522b27a..1e401659 100644 --- a/src/pages/word/components/Footer.vue +++ b/src/pages/word/components/Footer.vue @@ -166,7 +166,6 @@ const progress = $computed(() => { diff --git a/src/pages/word/components/PracticeSettingDialog.vue b/src/pages/word/components/PracticeSettingDialog.vue index 202ea6bc..80e98927 100644 --- a/src/pages/word/components/PracticeSettingDialog.vue +++ b/src/pages/word/components/PracticeSettingDialog.vue @@ -139,4 +139,80 @@ watch(() => model.value, (n) => { @apply bg-blue color-white; } } + +// 移动端适配 +@media (max-width: 768px) { + .target-modal { + width: 90vw !important; + max-width: 400px; + padding: 0 1rem; + + // 模式选择 + .center .flex.gap-4 { + width: 100%; + flex-direction: column; + height: auto; + gap: 0.8rem; + + .mode-item { + width: 100%; + padding: 1rem; + + .title { + font-size: 1rem; + } + + .desc { + font-size: 0.85rem; + margin-top: 0.5rem; + } + } + } + + // 统计显示 + .text-center { + font-size: 0.9rem; + + .text-3xl { + font-size: 1.5rem; + } + } + + // 滑块控件 + .flex.mb-4, .flex.mb-6 { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + + span { + width: 100%; + } + + .flex-1 { + width: 100%; + } + } + + // 按钮 + .base-button { + width: 100%; + min-height: 44px; + } + } +} + +@media (max-width: 480px) { + .target-modal { + width: 95vw !important; + padding: 0 0.5rem; + + .text-center { + font-size: 0.8rem; + + .text-3xl { + font-size: 1.2rem; + } + } + } +} diff --git a/src/pages/word/components/TypeWord.vue b/src/pages/word/components/TypeWord.vue index 793175fe..02fc502a 100644 --- a/src/pages/word/components/TypeWord.vue +++ b/src/pages/word/components/TypeWord.vue @@ -151,7 +151,6 @@ function unknown(e) { } async function onTyping(e: KeyboardEvent) { - debugger let word = props.word.word if (inputLock) { // 因为输入完成会锁死不能再输入,所以在这里判断空格键切换到下一个单词 @@ -615,6 +614,117 @@ useEvents([ font-family: var(--en-article-family); @apply text-lg w-12; } +} + +// 隐藏光标 +.cursor { + display: none !important; +} +// 移动端适配 +@media (max-width: 768px) { + .typing-word { + padding: 0 0.5rem; + + .word { + font-size: 2rem !important; + letter-spacing: 0.1rem; + margin: 0.5rem 0; + } + + .phonetic, .translate { + font-size: 1rem; + } + + .label { + width: 4rem; + font-size: 0.9rem; + } + + .cn { + font-size: 0.9rem; + } + + .en { + font-size: 1rem; + } + + .pos { + font-size: 0.9rem; + width: 3rem; + } + + // 移动端按钮组调整 + .flex.gap-4 { + flex-direction: column; + width: 100%; + gap: 0.5rem; + position: relative; + z-index: 10; // 确保按钮不被其他元素遮挡 + + .base-button { + width: 100%; + min-height: 48px; + padding: 0.8rem; + font-size: 1.1rem; + font-weight: 500; + cursor: pointer; + } + } + + // 移动端例句调整 + .sentence { + font-size: 0.9rem; + line-height: 1.4; + margin-bottom: 0.5rem; + } + + // 移动端短语调整 + .flex.items-center.gap-4 { + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; + } + } +} + +// 超小屏幕适配 +@media (max-width: 480px) { + .typing-word { + padding: 0 0.3rem; + + .word { + font-size: 1.5rem !important; + letter-spacing: 0.05rem; + margin: 0.3rem 0; + } + + .phonetic, .translate { + font-size: 0.9rem; + } + + .label { + width: 3rem; + font-size: 0.8rem; + } + + .cn { + font-size: 0.8rem; + } + + .en { + font-size: 0.9rem; + } + + .pos { + font-size: 0.8rem; + width: 2.5rem; + } + + .sentence { + font-size: 0.8rem; + line-height: 1.3; + } + } } diff --git a/src/stores/setting.ts b/src/stores/setting.ts index 8480abb6..06a77a06 100644 --- a/src/stores/setting.ts +++ b/src/stores/setting.ts @@ -53,6 +53,7 @@ export interface SettingState { disableShowPracticeSettingDialog: boolean // 不默认显示练习设置弹框 autoNextWord: boolean //自动切换下一个单词 inputWrongClear: boolean //单词输入错误,清空已输入内容 + mobileNavCollapsed: boolean // 移动端底部导航栏收缩状态 } export const getDefaultSettingState = (): SettingState => ({ @@ -103,6 +104,7 @@ export const getDefaultSettingState = (): SettingState => ({ disableShowPracticeSettingDialog: false, autoNextWord: true, inputWrongClear: false, + mobileNavCollapsed: false, }) export const useSettingStore = defineStore('setting', { From f3da181e0f3002631f8050e77b8c1221ff4b9a1c Mon Sep 17 00:00:00 2001 From: SMGDev Date: Wed, 29 Oct 2025 09:25:37 +0000 Subject: [PATCH 06/24] refactor: enhance mobile responsiveness and UI components across various pages --- src/assets/css/style.scss | 17 +- src/components/Panel.vue | 43 +-- src/components/PracticeLayout.vue | 19 +- src/hooks/event.ts | 247 ++++++++++++++---- src/pages/article/ArticlesPage.vue | 211 +++++++++------ src/pages/article/BookDetail.vue | 75 +++++- .../article/components/TypingArticle.vue | 110 ++++++-- src/pages/setting/SettingItem.vue | 115 +++++++- src/pages/word/DictDetail.vue | 165 +++++++++++- src/pages/word/components/Footer.vue | 5 +- src/pages/word/components/TypeWord.vue | 16 +- 11 files changed, 820 insertions(+), 203 deletions(-) diff --git a/src/assets/css/style.scss b/src/assets/css/style.scss index 5fa5b8b2..760c157d 100644 --- a/src/assets/css/style.scss +++ b/src/assets/css/style.scss @@ -463,9 +463,16 @@ a { #typing-listener { position: fixed; - right: 0; - bottom: 0; - z-index: 9999; - height: 3rem; - // display: none !important; + left: -9999px; + top: -9999px; + width: 1px; + height: 1px; + opacity: 0.01; + z-index: -1; + pointer-events: none; + border: none; + outline: none; + background: transparent; + font-size: 16px; // 防止iOS缩放 + color: transparent; // 文字透明 } \ No newline at end of file diff --git a/src/components/Panel.vue b/src/components/Panel.vue index 4d1d1f69..142a16b6 100644 --- a/src/components/Panel.vue +++ b/src/components/Panel.vue @@ -24,7 +24,7 @@ provide('tabIndex', computed(() => tabIndex)) -
+
@@ -48,15 +48,20 @@ provide('tabIndex', computed(() => tabIndex)) .panel { width: 90vw; max-width: 400px; - max-height: 80vh; + max-height: 90vh; + height: auto; border-radius: 0.4rem; - - header { - padding: 0.5rem 0.5rem; - - .color-main { - font-size: 0.9rem; - } + } + + .panel > div.flex-1 { + max-height: calc(90vh - 3.2rem); + } + + .panel header { + padding: 0.5rem 0.5rem; + + .color-main { + font-size: 0.9rem; } } } @@ -65,14 +70,18 @@ provide('tabIndex', computed(() => tabIndex)) @media (max-width: 480px) { .panel { width: 95vw; - max-height: 85vh; - - header { - padding: 0.3rem 0.3rem; - - .color-main { - font-size: 0.8rem; - } + max-height: 94vh; + } + + .panel > div.flex-1 { + max-height: calc(94vh - 3rem); + } + + .panel header { + padding: 0.3rem 0.3rem; + + .color-main { + font-size: 0.8rem; } } } diff --git a/src/components/PracticeLayout.vue b/src/components/PracticeLayout.vue index 57e8fd15..6de234cb 100644 --- a/src/components/PracticeLayout.vue +++ b/src/components/PracticeLayout.vue @@ -13,7 +13,7 @@ defineProps<{
-
+
@@ -41,8 +41,9 @@ defineProps<{ .footer-wrap { position: fixed; - bottom: 0.8rem; + bottom: calc(0.8rem + env(safe-area-inset-bottom, 0px)); transition: all var(--anim-time); + z-index: 999; } .panel-wrap { @@ -67,12 +68,12 @@ defineProps<{ } .footer-wrap { - bottom: -4rem; + bottom: calc(-4rem + env(safe-area-inset-bottom, 0px)); } } .footer-wrap { - bottom: 0.5rem; + bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px)); left: 0.5rem; right: 0.5rem; width: auto; @@ -81,13 +82,11 @@ defineProps<{ .panel-wrap { position: fixed; top: 0; - left: 0; - right: 0; + left: 0 !important; + right: 0 !important; bottom: 0; height: 100vh; z-index: 1000; - - // 面板内容居中显示 display: flex; align-items: center; justify-content: center; @@ -119,13 +118,15 @@ defineProps<{ } .footer-wrap { - bottom: 0.3rem; + bottom: calc(0.3rem + env(safe-area-inset-bottom, 0px)); left: 0.3rem; right: 0.3rem; } .panel-wrap { padding: 0.5rem; + left: 0 !important; + right: 0 !important; } } diff --git a/src/hooks/event.ts b/src/hooks/event.ts index b02ffdad..e256e10f 100644 --- a/src/hooks/event.ts +++ b/src/hooks/event.ts @@ -16,55 +16,206 @@ export function useWindowClick(cb: (e: PointerEvent) => void) { } export function useEventListener(type: string, listener: EventListenerOrEventListenerObject) { - onMounted(() => { - if (isMobile()) { - let tx: HTMLInputElement = document.querySelector('#typing-listener') - if (!tx) { - tx = document.createElement('input') - tx.id = 'typing-listener' - tx.type = 'text' - } - tx.addEventListener('input', (e: any) => { - if (e.data === ' ') e.code = 'Space' - if (e.data === null) { - e.key = 'Backspace' - e.keyCode = 1 - } else { - e.keyCode = 66 - e.key = e.data - } - - e.ctrlKey = false - e.altKey = false - e.shiftKey = false - //@ts-ignore - listener(e) - e.target.value = '1' - }) - const ss = () => { - setTimeout(() => tx.focus(), 100) - } - window.removeEventListener('click', ss) - window.addEventListener('click', ss) - window.addEventListener(type, listener) - document.body.appendChild(tx) - tx.focus() - } else { - window.addEventListener(type, listener) + const invokeListener = (event: KeyboardEvent) => { + if (typeof listener === 'function') { + return (listener as EventListener)(event) } - }) - const remove = () => { - if (isMobile()) { - let s = document.querySelector('#typing-listener') - if (s) { - s.removeEventListener(type, listener) - s.parentNode.removeChild(s) - } - window.removeEventListener(type, listener) - } else { - window.removeEventListener(type, listener) + if (listener && typeof (listener as EventListenerObject).handleEvent === 'function') { + return (listener as EventListenerObject).handleEvent(event) } } + + let cleanup: (() => void) | null = null + + onMounted(() => { + const cleanupFns: Array<() => void> = [] + const registerCleanup = (fn: () => void) => cleanupFns.push(fn) + + const performCleanup = () => { + while (cleanupFns.length) { + const fn = cleanupFns.pop() + try { + fn() + } catch (err) { + console.warn('[useEventListener] cleanup error', err) + } + } + } + + if (isMobile() && type === 'keydown') { + const ensureMobileInput = () => { + let input = document.querySelector('#typing-listener') as HTMLInputElement | null + if (!input) { + input = document.createElement('input') + input.id = 'typing-listener' + input.type = 'text' + input.autocomplete = 'off' + input.autocapitalize = 'off' + input.autocorrect = false + input.spellcheck = false + input.tabIndex = -1 + input.setAttribute('aria-hidden', 'true') + Object.assign(input.style, { + position: 'fixed', + opacity: '0', + pointerEvents: 'none', + width: '1px', + height: '1px', + top: '0', + left: '-9999px', + zIndex: '-1', + }) + } + if (!input.parentNode) { + document.body.appendChild(input) + } + return input + } + + const hiddenInput = ensureMobileInput() + let isComposing = false + const ignoredKeys = new Set() + const markIgnore = (key: string) => { + ignoredKeys.add(key) + window.setTimeout(() => ignoredKeys.delete(key), 150) + } + + const createSyntheticEvent = (payload: { key: string; code?: string; keyCode: number }) => { + const base = { + key: payload.key, + code: payload.code ?? '', + keyCode: payload.keyCode, + which: payload.keyCode, + ctrlKey: false, + altKey: false, + shiftKey: false, + metaKey: false, + repeat: false, + isComposing: false, + type, + preventDefault() {}, + stopPropagation() {}, + stopImmediatePropagation() {}, + } + return base as unknown as KeyboardEvent + } + + const dispatchSyntheticKey = (payload: { key: string; code?: string; keyCode: number }) => { + markIgnore(payload.key) + invokeListener(createSyntheticEvent(payload)) + } + + const handleCompositionStart = () => { + isComposing = true + } + + const handleCompositionEnd = (event: CompositionEvent) => { + isComposing = false + if (!event.data) { + hiddenInput.value = '' + return + } + for (const char of event.data) { + const keyCode = char === ' ' ? 32 : char.toUpperCase().charCodeAt(0) + dispatchSyntheticKey({ + key: char, + code: char === ' ' ? 'Space' : undefined, + keyCode, + }) + } + hiddenInput.value = '' + } + + const handleInput = (event: InputEvent) => { + if (isComposing) return + const target = event.target as HTMLInputElement | null + const value = target?.value ?? '' + + if (event.inputType === 'deleteContentBackward') { + dispatchSyntheticKey({ key: 'Backspace', code: 'Backspace', keyCode: 8 }) + if (target) target.value = '' + return + } + + const char = value.slice(-1) || (event as any).data?.slice(-1) + if (!char) { + if (target) target.value = '' + return + } + + const keyCode = char === ' ' ? 32 : char.toUpperCase().charCodeAt(0) + dispatchSyntheticKey({ + key: char, + code: char === ' ' ? 'Space' : undefined, + keyCode, + }) + + window.setTimeout(() => { + if (target) target.value = '' + }, 0) + } + + const shouldFocusInput = (target: HTMLElement | null) => { + if (!target) return false + if (!window.location.pathname.includes('/practice')) return false + const typingWord = target.closest('.typing-word') + if (!typingWord) return false + if (target.closest('.sentence') || target.closest('.phrase')) return false + if (target.classList?.contains('flex') && target.querySelector('.phrase')) return false + return true + } + + const handleFocusRequest = (event: MouseEvent | TouchEvent) => { + const target = event.target as HTMLElement | null + if (!shouldFocusInput(target)) return + window.setTimeout(() => hiddenInput.focus(), 60) + } + + const windowListener = (event: KeyboardEvent) => { + if (ignoredKeys.has(event.key)) { + ignoredKeys.delete(event.key) + return + } + invokeListener(event) + } + + hiddenInput.addEventListener('compositionstart', handleCompositionStart) + registerCleanup(() => hiddenInput.removeEventListener('compositionstart', handleCompositionStart)) + + hiddenInput.addEventListener('compositionend', handleCompositionEnd) + registerCleanup(() => hiddenInput.removeEventListener('compositionend', handleCompositionEnd)) + + hiddenInput.addEventListener('input', handleInput) + registerCleanup(() => hiddenInput.removeEventListener('input', handleInput)) + + window.addEventListener('click', handleFocusRequest) + registerCleanup(() => window.removeEventListener('click', handleFocusRequest)) + + window.addEventListener('touchstart', handleFocusRequest) + registerCleanup(() => window.removeEventListener('touchstart', handleFocusRequest)) + + window.addEventListener(type, windowListener) + registerCleanup(() => window.removeEventListener(type, windowListener)) + + registerCleanup(() => { + hiddenInput.value = '' + }) + } else { + const windowListener = (event: Event) => invokeListener(event as KeyboardEvent) + window.addEventListener(type, windowListener) + registerCleanup(() => window.removeEventListener(type, windowListener)) + } + + cleanup = () => { + performCleanup() + cleanup = null + } + }) + + const remove = () => { + if (cleanup) cleanup() + } + onUnmounted(remove) onDeactivated(remove) } @@ -161,6 +312,10 @@ export function useStartKeyboardEventListener() { || e.keyCode === 229 //当按下功能键时,不阻止事件传播 ) && (!e.ctrlKey && !e.altKey)) { + if (isMobile() && e.keyCode === 229 && e.key === 'Unidentified') { + // 安卓软键盘在keydown阶段不会提供字符,等待input/composition事件来派发实际输入 + return + } e.preventDefault() emitter.emit(EventKey.onTyping, e) } else { diff --git a/src/pages/article/ArticlesPage.vue b/src/pages/article/ArticlesPage.vue index f42023e4..b3d01cd6 100644 --- a/src/pages/article/ArticlesPage.vue +++ b/src/pages/article/ArticlesPage.vue @@ -167,8 +167,8 @@ const {data: recommendBookList, isFetching} = useFetch(resourceWrap(DICT_LIST.AR