Merge pull request 'future' (#15) from future into main

Reviewed-on: yuantao/SmartisanNote.Remake#15
This commit is contained in:
2025-11-03 15:24:15 +08:00
11 changed files with 2922 additions and 919 deletions

185
README.md
View File

@@ -1,109 +1,130 @@
# 锤子便签(重制版) # Smartisan Note 离线支持说明
这是一个基于 Vue 3、Vite 和 Pinia 的移动端现代化 Web 应用,旨在重现并改进经典的锤子便签应用体验。该项目采用 PWA渐进式 Web 应用)技术,支持离线使用和安装到主屏幕。 ## 离线功能特性
## 主要特性 Smartisan Note 应用支持完整的离线工作模式,确保用户在没有网络连接的情况下也能正常使用所有功能。
- **便签管理**: 创建、编辑、删除、置顶、加星便签 ### 核心离线功能
- **文件夹管理**: 将便签分类到不同的文件夹中
- **搜索功能**: 按标题或内容搜索便签
- **回收站**: 临时存储已删除的便签,支持彻底删除
- **多种排序方式**: 按更新时间、标题、星标状态排序
- **PWA 支持**: 可安装为独立应用,支持离线使用
- **本地存储**: 所有数据存储在浏览器的 IndexedDB 中
- **富文本编辑**: 支持标题、待办事项、列表、加粗、引用、图片等多种格式
- **动画效果**: 使用 Oku Motion 实现流畅的动画效果
- **拖拽排序**: 支持图片拖拽排序
## 技术栈 1. **完整的离线编辑**
- 所有便签的创建、编辑、删除操作都可以在离线状态下完成
- 支持富文本编辑器的所有功能(标题、待办事项、列表、加粗、引用、图片等)
- 便签数据实时保存到本地 IndexedDB 数据库
- **框架**: Vue 3 (Composition API) 2. **智能数据同步**
- **构建工具**: Vite - 网络恢复时自动同步离线期间的操作
- **状态管理**: Pinia - 离线操作队列管理,确保数据一致性
- **路由**: Vue Router - 冲突解决机制(时间戳优先)
- **UI 组件库**: Ionic Vue (部分使用)
- **PWA 支持**: vite-plugin-pwa
- **本地存储**: IndexedDB (通过 `src/utils/indexedDBStorage.js` 封装)
- **CSS 预处理器**: Less
- **动画库**: @oku-ui/motion
- **拖拽库**: vue-draggable-plus
## 项目结构 3. **资源缓存**
- 核心静态资源CSS、JS、图片缓存到浏览器缓存
- 应用界面在离线状态下完全可用
- 字体和图标资源本地缓存
``` 4. **网络状态感知**
. - 实时检测网络连接状态
├── android/ # Capacitor Android 项目文件 - 离线/在线状态切换时的用户提示
├── public/ # 静态资源目录 (图标等) - 网络类型识别2G/3G/4G/WiFi
├── src/ # 源代码目录
│ ├── App.vue # 根组件
│ ├── main.js # 应用入口文件
│ ├── common/ # 通用样式
│ ├── components/ # 可复用的 UI 组件
│ ├── pages/ # 页面组件
│ ├── stores/ # Pinia 状态管理
│ └── utils/ # 工具函数
├── index.html # 应用入口 HTML 文件
├── package.json # 项目依赖和脚本
├── vite.config.js # Vite 配置文件
└── capacitor.config.json # Capacitor 配置文件
```
## 开发与构建 ## 技术实现
### 前置条件 ### Service Worker
确保已安装 Node.js (>=14) 和 npm。 应用使用自定义 Service Worker (`src/sw.js`) 实现以下功能:
### 安装依赖 - **缓存策略**
- 静态资源:缓存优先
```bash - 数据请求:网络优先,失败时回退到缓存
npm install - API 请求:网络优先,失败时返回离线响应
```
- **后台同步**
### 开发 - 支持后台数据同步
- 离线操作队列处理
启动开发服务器:
### IndexedDB 存储
使用 IndexedDB 作为本地数据存储:
- **数据结构**
- 便签存储 (`notes`)
- 文件夹存储 (`folders`)
- 设置存储 (`settings`)
- **离线队列**
- 操作记录到本地存储队列
- 网络恢复时自动处理队列
### 网络状态管理
- 实时网络状态检测
- 网络类型识别
- 在线/离线状态切换处理
## 使用说明
### 离线使用
1. 应用首次加载时会缓存核心资源
2. 断网后应用仍可正常使用所有功能
3. 离线期间的所有操作都会保存到本地
### 数据同步
1. 网络恢复时自动开始同步
2. 可在设置中查看同步状态
3. 支持手动触发同步(未来版本)
## 开发说明
### 构建命令
```bash ```bash
# 开发模式
npm run dev npm run dev
```
这将在 `http://localhost:3000` 启动应用。 # 构建标准版本
### 构建
构建标准 Web 应用:
```bash
npm run build npm run build
```
构建 PWA 应用: # 构建PWA版本(支持离线)
```bash
npm run build:pwa npm run build:pwa
```
构建所有版本 (标准 + PWA): # 构建所有版本
```bash
npm run build:all npm run build:all
```
### 部署 PWA # 部署PWA版本
构建 PWA 并上传到服务器:
```bash
npm run deploy:pwa npm run deploy:pwa
``` ```
这将执行 `vite build --mode pwa` 并运行 `upload-pwa.js` 脚本。 ### 目录结构
### Android 应用 ```
src/
├── sw.js # Service Worker 实现
├── utils/
│ ├── indexedDBStorage.js # IndexedDB 存储管理
│ ├── networkUtils.js # 网络状态工具
│ └── dateUtils.js # 日期工具
├── stores/
│ └── useAppStore.js # 应用状态管理
├── components/
│ └── ... # UI 组件
└── pages/
├── NoteListPage.vue # 便签列表页面
├── NoteEditorPage.vue # 便签编辑页面
└── SettingsPage.vue # 设置页面
```
运行 Android 应用: ## 测试离线功能
```bash 1. 使用浏览器开发者工具的 Network Throttling 功能模拟离线环境
npm run android 2. 在离线状态下创建、编辑、删除便签
``` 3. 恢复网络连接,观察数据同步过程
4. 检查离线期间的操作是否正确同步
## 未来改进
1. 添加手动同步按钮
2. 增强冲突解决机制
3. 支持多设备数据同步
4. 添加数据导出/导入功能
5. 增强离线状态下的用户提示

View File

@@ -1,40 +1,42 @@
{ {
"name": "smartisannote.re", "name": "smartisannote.re",
"version": "1.0.0", "version": "1.0.0",
"description": "锤子便签(重制版)", "description": "锤子便签(重制版)",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"android": "npx cap run android", "android": "npx cap run android",
"build:all": "vite build && vite build --mode pwa", "build": "vite build",
"deploy:pwa": "vite build --mode pwa && node upload-pwa.js", "build:pwa": "vite build --mode pwa",
"dev": "vite" "build:all": "vite build && vite build --mode pwa",
}, "deploy:pwa": "vite build --mode pwa && node upload-pwa.js",
"keywords": [], "dev": "vite"
"author": "", },
"license": "ISC", "keywords": [],
"type": "module", "author": "",
"dependencies": { "license": "ISC",
"@capacitor/android": "^5.7.2", "type": "module",
"@capacitor/cli": "^5.7.2", "dependencies": {
"@capacitor/core": "^5.7.2", "@capacitor/android": "^5.7.2",
"@capacitor/ios": "^5.7.2", "@capacitor/cli": "^5.7.2",
"@ionic/vue": "^8.7.6", "@capacitor/core": "^5.7.2",
"@oku-ui/motion": "^0.4.3", "@capacitor/ios": "^5.7.2",
"@vue/cli-service": "^5.0.9", "@ionic/vue": "^8.7.6",
"@vue/compiler-sfc": "^3.5.22", "@oku-ui/motion": "^0.4.3",
"basic-ftp": "^5.0.5", "@vue/cli-service": "^5.0.9",
"ionicons": "^7.4.0", "@vue/compiler-sfc": "^3.5.22",
"moment": "^2.30.1", "basic-ftp": "^5.0.5",
"pinia": "^3.0.3", "ionicons": "^7.4.0",
"vue": "^3.5.22", "moment": "^2.30.1",
"vue-router": "^4.5.1" "pinia": "^3.0.3",
}, "vue": "^3.5.22",
"devDependencies": { "vue-router": "^4.5.1"
"@rollup/plugin-terser": "^0.4.4", },
"@vitejs/plugin-vue": "^5.1.4", "devDependencies": {
"less": "^4.4.2", "@rollup/plugin-terser": "^0.4.4",
"terser": "^5.44.0", "@vitejs/plugin-vue": "^5.1.4",
"vite": "^5.4.8", "less": "^4.4.2",
"vite-plugin-pwa": "^1.0.3" "terser": "^5.44.0",
} "vite": "^5.4.8",
} "vite-plugin-pwa": "^1.0.3"
}
}

View File

@@ -1,5 +1,13 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<!-- 网络状态指示器 -->
<div v-if="!isOnline" class="network-status">
<div class="offline-indicator">
<span class="offline-icon"></span>
<span class="offline-text">离线模式</span>
</div>
</div>
<!-- 设置页面背景列表页 --> <!-- 设置页面背景列表页 -->
<NoteListPage v-show="showBackgroundPage" class="background-page" /> <NoteListPage v-show="showBackgroundPage" class="background-page" />
@@ -20,10 +28,11 @@
</template> </template>
<script setup> <script setup>
import { ref, watch, computed, onMounted } from 'vue' import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import '@/common/base.css' import '@/common/base.css'
import { initModalService } from '@/utils/modalService' import { initModalService } from '@/utils/modalService'
import { addNetworkListener, removeNetworkListener, testOnline } from '@/utils/networkUtils'
// 导入页面组件 // 导入页面组件
import NoteListPage from './pages/NoteListPage.vue' import NoteListPage from './pages/NoteListPage.vue'
@@ -32,6 +41,7 @@ import SettingsPage from './pages/SettingsPage.vue'
const route = useRoute() const route = useRoute()
const transitionName = ref('slide-left') const transitionName = ref('slide-left')
const modalRef = ref() const modalRef = ref()
const isOnline = ref(navigator.onLine)
// 计算是否为设置页面路由 // 计算是否为设置页面路由
const isSettingsRoute = computed(() => { const isSettingsRoute = computed(() => {
@@ -43,6 +53,19 @@ const showBackgroundPage = computed(() => {
return route.path === '/settings' return route.path === '/settings'
}) })
// 网络状态变化回调
const handleOnline = () => {
isOnline.value = true
console.log('网络已连接')
// 可以在这里触发数据同步
}
const handleOffline = () => {
isOnline.value = false
console.log('网络已断开')
// 可以在这里显示离线提示
}
// 监听路由变化,动态设置过渡动画方向 // 监听路由变化,动态设置过渡动画方向
watch( watch(
() => route.path, () => route.path,
@@ -72,9 +95,15 @@ watch(
} }
) )
// 初始化弹框服务 // 初始化弹框服务和网络监听
onMounted(() => { onMounted(() => {
initModalService() initModalService()
addNetworkListener(handleOnline, handleOffline)
})
// 移除网络监听
onUnmounted(() => {
removeNetworkListener(handleOnline, handleOffline)
}) })
</script> </script>
@@ -87,6 +116,38 @@ onMounted(() => {
background-color: #f5f5f5; // 设置默认背景色,防止闪烁 background-color: #f5f5f5; // 设置默认背景色,防止闪烁
} }
// 网络状态指示器
.network-status {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 10000;
display: flex;
justify-content: center;
pointer-events: none;
}
.offline-indicator {
background-color: #ff6b6b;
color: white;
padding: 8px 16px;
border-radius: 0 0 4px 4px;
font-size: 14px;
display: flex;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
pointer-events: auto;
}
.offline-icon {
margin-right: 8px;
}
.offline-text {
font-weight: 500;
}
// 背景页面样式 // 背景页面样式
.background-page { .background-page {
position: absolute; position: absolute;

View File

@@ -1,6 +1,13 @@
<template> <template>
<ion-page> <ion-page>
<div class="container"> <div class="container">
<!-- 离线状态提示 -->
<div v-if="!store.isOnline" class="offline-banner">
<div class="offline-content">
<span class="offline-icon"></span>
<span class="offline-text">离线模式 - 数据将自动同步</span>
</div>
</div>
<!-- 头部编辑模式 --> <!-- 头部编辑模式 -->
<Header v-if="isEditorFocus" :onBack="handleCancel" :onAction="handleAction" actionIcon="edit" /> <Header v-if="isEditorFocus" :onBack="handleCancel" :onAction="handleAction" actionIcon="edit" />
<!-- 头部预览模式 --> <!-- 头部预览模式 -->
@@ -12,7 +19,6 @@
<span>|</span> <span>|</span>
<span class="word-count">{{ wordCount }}</span> <span class="word-count">{{ wordCount }}</span>
</div> </div>
<!-- 富文本编辑器 --> <!-- 富文本编辑器 -->
<div class="note-container" :style="{ height: noteContainerHeight }"> <div class="note-container" :style="{ height: noteContainerHeight }">
<RichTextEditor ref="editorRef" :modelValue="content" @update:modelValue="handleContentChange" @focus="handleEditorFocus" @blur="handleEditorBlur" class="rich-text-editor" /> <RichTextEditor ref="editorRef" :modelValue="content" @update:modelValue="handleContentChange" @focus="handleEditorFocus" @blur="handleEditorBlur" class="rich-text-editor" />
@@ -21,7 +27,6 @@
</div> </div>
</ion-page> </ion-page>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, nextTick, watch, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, nextTick, watch, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -30,18 +35,16 @@ import Header from '../components/Header.vue'
import RichTextEditor from '../components/RichTextEditor.vue' import RichTextEditor from '../components/RichTextEditor.vue'
import { formatNoteEditorDate } from '../utils/dateUtils' import { formatNoteEditorDate } from '../utils/dateUtils'
import { IonPage } from '@ionic/vue' import { IonPage } from '@ionic/vue'
import { isOnline } from '../utils/networkUtils'
const props = defineProps({ const props = defineProps({
id: { id: {
type: String, type: String,
default: null, default: null,
}, },
}) })
// 为了保持向后兼容性我们也支持noteId属性 // 为了保持向后兼容性我们也支持noteId属性
// 通过计算属性确保无论使用id还是noteId都能正确获取便签ID // 通过计算属性确保无论使用id还是noteId都能正确获取便签ID
const noteId = computed(() => props.id || props.noteId) const noteId = computed(() => props.id || props.noteId)
const store = useAppStore() const store = useAppStore()
const router = useRouter() const router = useRouter()
const editorRef = ref(null) const editorRef = ref(null)
@@ -49,10 +52,8 @@ const headerRef = ref(null)
const headerInfoRef = ref(null) const headerInfoRef = ref(null)
// 是否聚焦编辑器 // 是否聚焦编辑器
const isEditorFocus = ref(false) const isEditorFocus = ref(false)
// 计算.note-container的高度 // 计算.note-container的高度
const noteContainerHeight = ref('100vh') const noteContainerHeight = ref('100vh')
// 设置便签内容的函数 // 设置便签内容的函数
// 用于在编辑器中加载指定便签的内容 // 用于在编辑器中加载指定便签的内容
const setNoteContent = async noteId => { const setNoteContent = async noteId => {
@@ -60,13 +61,10 @@ const setNoteContent = async noteId => {
if (store.notes.length === 0) { if (store.notes.length === 0) {
await store.loadData() await store.loadData()
} }
// 从store中查找指定ID的便签 // 从store中查找指定ID的便签
const note = store.notes.find(n => n.id === noteId) const note = store.notes.find(n => n.id === noteId)
// 确保编辑器已经初始化完成 // 确保编辑器已经初始化完成
await nextTick() await nextTick()
if (note) { if (note) {
// 无论editorRef是否可用都先设置content的值作为备份 // 无论editorRef是否可用都先设置content的值作为备份
content.value = note.content || '' content.value = note.content || ''
@@ -76,20 +74,16 @@ const setNoteContent = async noteId => {
} }
} }
} }
// 加载初始数据 // 加载初始数据
onMounted(async () => { onMounted(async () => {
await store.loadData() await store.loadData()
// 如果是编辑现有便签,在组件挂载后设置内容 // 如果是编辑现有便签,在组件挂载后设置内容
if (noteId.value) { if (noteId.value) {
await setNoteContent(noteId.value) await setNoteContent(noteId.value)
} }
// 等待DOM更新后计算.note-container的高度 // 等待DOM更新后计算.note-container的高度
calculateNoteContainerHeight() calculateNoteContainerHeight()
}) })
// 监听noteId变化确保在编辑器准备好后设置内容 // 监听noteId变化确保在编辑器准备好后设置内容
watch( watch(
noteId, noteId,
@@ -102,7 +96,6 @@ watch(
}, },
{ immediate: true } { immediate: true }
) )
// 监听store变化确保在store加载后设置内容 // 监听store变化确保在store加载后设置内容
watch( watch(
() => store.notes, () => store.notes,
@@ -115,15 +108,12 @@ watch(
}, },
{ immediate: true } { immediate: true }
) )
// 检查是否正在编辑现有便签 // 检查是否正在编辑现有便签
// 如果noteId存在则表示是编辑模式否则是新建模式 // 如果noteId存在则表示是编辑模式否则是新建模式
const isEditing = !!noteId.value const isEditing = !!noteId.value
const existingNote = isEditing ? store.notes.find(n => n.id === noteId.value) : null const existingNote = isEditing ? store.notes.find(n => n.id === noteId.value) : null
// 初始化内容状态,如果是编辑现有便签则使用便签内容,否则为空字符串 // 初始化内容状态,如果是编辑现有便签则使用便签内容,否则为空字符串
const content = ref(existingNote?.content || '') const content = ref(existingNote?.content || '')
// 当组件挂载时,确保编辑器初始化为空内容(针对新建便签) // 当组件挂载时,确保编辑器初始化为空内容(针对新建便签)
onMounted(() => { onMounted(() => {
if (!isEditing && editorRef.value) { if (!isEditing && editorRef.value) {
@@ -131,7 +121,6 @@ onMounted(() => {
editorRef.value.setContent('') editorRef.value.setContent('')
} }
}) })
// 监听store变化确保在store加载后设置内容 // 监听store变化确保在store加载后设置内容
watch( watch(
() => store.notes, () => store.notes,
@@ -145,7 +134,6 @@ watch(
{ immediate: true } { immediate: true }
) )
const showAlert = ref(false) const showAlert = ref(false)
// 防抖函数 // 防抖函数
// 用于避免函数在短时间内被频繁调用,提高性能 // 用于避免函数在短时间内被频繁调用,提高性能
const debounce = (func, delay) => { const debounce = (func, delay) => {
@@ -155,19 +143,16 @@ const debounce = (func, delay) => {
timeoutId = setTimeout(() => func.apply(this, args), delay) timeoutId = setTimeout(() => func.apply(this, args), delay)
} }
} }
// 防抖处理内容变化 // 防抖处理内容变化
// 延迟300ms更新内容避免用户输入时频繁触发更新 // 延迟300ms更新内容避免用户输入时频繁触发更新
const debouncedHandleContentChange = debounce(newContent => { const debouncedHandleContentChange = debounce(newContent => {
content.value = newContent content.value = newContent
}, 300) }, 300)
// 监听编辑器内容变化 // 监听编辑器内容变化
// 当编辑器内容发生变化时调用此函数 // 当编辑器内容发生变化时调用此函数
const handleContentChange = newContent => { const handleContentChange = newContent => {
debouncedHandleContentChange(newContent) debouncedHandleContentChange(newContent)
} }
// 计算属性 - 格式化时间显示 // 计算属性 - 格式化时间显示
// 如果是编辑现有便签则显示便签的更新时间,否则显示当前时间 // 如果是编辑现有便签则显示便签的更新时间,否则显示当前时间
const formattedTime = computed(() => { const formattedTime = computed(() => {
@@ -176,7 +161,6 @@ const formattedTime = computed(() => {
} }
return formatNoteEditorDate(new Date()) return formatNoteEditorDate(new Date())
}) })
// 计算属性 - 计算字数 // 计算属性 - 计算字数
// 移除HTML标签后计算纯文本字数 // 移除HTML标签后计算纯文本字数
const wordCount = computed(() => { const wordCount = computed(() => {
@@ -184,13 +168,11 @@ const wordCount = computed(() => {
const textContent = content.value.replace(/<[^>]*>/g, '') const textContent = content.value.replace(/<[^>]*>/g, '')
return textContent.length || 0 return textContent.length || 0
}) })
// 处理保存 // 处理保存
const handleSave = async () => { const handleSave = async () => {
try { try {
// 获取编辑器中的实际内容 // 获取编辑器中的实际内容
const editorContent = editorRef.value ? editorRef.value.getContent() : content.value const editorContent = editorRef.value ? editorRef.value.getContent() : content.value
// 检查内容是否为空 // 检查内容是否为空
if (isContentEmpty(editorContent)) { if (isContentEmpty(editorContent)) {
// 如果是编辑模式且内容为空,则删除便签 // 如果是编辑模式且内容为空,则删除便签
@@ -215,7 +197,6 @@ const handleSave = async () => {
}) })
console.log('新便签已创建') console.log('新便签已创建')
} }
// 保存后切换到预览模式(失去编辑器焦点) // 保存后切换到预览模式(失去编辑器焦点)
isEditorFocus.value = false isEditorFocus.value = false
} catch (error) { } catch (error) {
@@ -223,35 +204,28 @@ const handleSave = async () => {
console.log('Save error: Failed to save note. Please try again.') console.log('Save error: Failed to save note. Please try again.')
} }
} }
// 检查内容是否为空(无实质性内容) // 检查内容是否为空(无实质性内容)
const isContentEmpty = content => { const isContentEmpty = content => {
if (!content) return true if (!content) return true
// 检查是否包含图片元素 // 检查是否包含图片元素
const hasImages = /<img[^>]*>|<div[^>]*class="[^"]*editor-image[^"]*"[^>]*>/.test(content) const hasImages = /<img[^>]*>|<div[^>]*class="[^"]*editor-image[^"]*"[^>]*>/.test(content)
if (hasImages) return false if (hasImages) return false
// 移除HTML标签和空白字符后检查是否为空 // 移除HTML标签和空白字符后检查是否为空
const plainText = content.replace(/<[^>]*>/g, '').trim() const plainText = content.replace(/<[^>]*>/g, '').trim()
if (plainText === '') return true if (plainText === '') return true
// 检查是否只有空的HTML元素 // 检查是否只有空的HTML元素
const strippedContent = content const strippedContent = content
.replace(/\s+/g, '') .replace(/\s+/g, '')
.replace(/<br>/g, '') .replace(/<br>/g, '')
.replace(/<br\/>/g, '') .replace(/<br\/>/g, '')
if (strippedContent === '<p></p>' || strippedContent === '<div></div>' || strippedContent === '') return true if (strippedContent === '<p></p>' || strippedContent === '<div></div>' || strippedContent === '') return true
return false return false
} }
// 自动保存便签(仅在非主动保存操作时调用) // 自动保存便签(仅在非主动保存操作时调用)
const autoSaveNote = async () => { const autoSaveNote = async () => {
try { try {
// 获取编辑器中的实际内容 // 获取编辑器中的实际内容
const editorContent = editorRef.value ? editorRef.value.getContent() : content.value const editorContent = editorRef.value ? editorRef.value.getContent() : content.value
if (isEditing && existingNote) { if (isEditing && existingNote) {
// 检查内容是否为空,如果为空则删除便签 // 检查内容是否为空,如果为空则删除便签
if (isContentEmpty(editorContent)) { if (isContentEmpty(editorContent)) {
@@ -281,22 +255,18 @@ const autoSaveNote = async () => {
console.error('自动保存失败:', error) console.error('自动保存失败:', error)
} }
} }
// 处理取消 // 处理取消
const handleCancel = async () => { const handleCancel = async () => {
// 自动保存便签 // 自动保存便签
await autoSaveNote() await autoSaveNote()
// 直接导航回便签列表页面,因为已经处理了保存或删除逻辑 // 直接导航回便签列表页面,因为已经处理了保存或删除逻辑
router.push('/notes') router.push('/notes')
} }
// 处理创建(用于新建便签) // 处理创建(用于新建便签)
const handleCreate = async () => { const handleCreate = async () => {
try { try {
// 获取编辑器中的实际内容 // 获取编辑器中的实际内容
const editorContent = editorRef.value ? editorRef.value.getContent() : content.value const editorContent = editorRef.value ? editorRef.value.getContent() : content.value
// 只有当有内容时才创建新便签 // 只有当有内容时才创建新便签
if (!isContentEmpty(editorContent)) { if (!isContentEmpty(editorContent)) {
await store.addNote({ await store.addNote({
@@ -305,7 +275,6 @@ const handleCreate = async () => {
}) })
console.log('新便签已创建') console.log('新便签已创建')
} }
// 创建后切换到预览模式(失去编辑器焦点) // 创建后切换到预览模式(失去编辑器焦点)
isEditorFocus.value = false isEditorFocus.value = false
} catch (error) { } catch (error) {
@@ -313,7 +282,6 @@ const handleCreate = async () => {
console.log('Create error: Failed to create note. Please try again.') console.log('Create error: Failed to create note. Please try again.')
} }
} }
// 处理Header组件的操作按钮点击事件 // 处理Header组件的操作按钮点击事件
const handleAction = actionType => { const handleAction = actionType => {
if (actionType === 'save') { if (actionType === 'save') {
@@ -334,11 +302,9 @@ const handleAction = actionType => {
handleShare() handleShare()
} }
} }
const setShowAlert = value => { const setShowAlert = value => {
showAlert.value = value showAlert.value = value
} }
// 处理编辑器获得焦点 // 处理编辑器获得焦点
const handleEditorFocus = () => { const handleEditorFocus = () => {
isEditorFocus.value = true isEditorFocus.value = true
@@ -347,7 +313,6 @@ const handleEditorFocus = () => {
calculateNoteContainerHeight() calculateNoteContainerHeight()
}) })
} }
// 处理编辑器失去焦点 // 处理编辑器失去焦点
const handleEditorBlur = () => { const handleEditorBlur = () => {
isEditorFocus.value = false isEditorFocus.value = false
@@ -356,7 +321,6 @@ const handleEditorBlur = () => {
calculateNoteContainerHeight() calculateNoteContainerHeight()
}) })
} }
// 处理删除便签 // 处理删除便签
const handleDelete = async () => { const handleDelete = async () => {
if (isEditing && existingNote) { if (isEditing && existingNote) {
@@ -364,7 +328,6 @@ const handleDelete = async () => {
if (headerRef.value && headerRef.value.playDeleteAnimation) { if (headerRef.value && headerRef.value.playDeleteAnimation) {
headerRef.value.playDeleteAnimation() headerRef.value.playDeleteAnimation()
} }
// 等待动画播放完成后再执行删除操作 // 等待动画播放完成后再执行删除操作
// 15帧 * 50ms = 750ms再加上一些缓冲时间 // 15帧 * 50ms = 750ms再加上一些缓冲时间
setTimeout(async () => { setTimeout(async () => {
@@ -380,15 +343,12 @@ const handleDelete = async () => {
}, 800) // 等待约800ms让动画播放完成 }, 800) // 等待约800ms让动画播放完成
} }
} }
// 处理分享便签 // 处理分享便签
const handleShare = () => { const handleShare = () => {
// 获取编辑器中的实际内容 // 获取编辑器中的实际内容
const editorContent = editorRef.value ? editorRef.value.getContent() : content.value const editorContent = editorRef.value ? editorRef.value.getContent() : content.value
// 移除HTML标签获取纯文本内容用于分享 // 移除HTML标签获取纯文本内容用于分享
const plainText = editorContent.replace(/<[^>]*>/g, '').trim() const plainText = editorContent.replace(/<[^>]*>/g, '').trim()
if (plainText) { if (plainText) {
// 在实际应用中,这里会调用设备的分享功能 // 在实际应用中,这里会调用设备的分享功能
// 为了演示我们使用Web Share API如果支持 // 为了演示我们使用Web Share API如果支持
@@ -415,13 +375,11 @@ const handleShare = () => {
} }
} }
} }
// 计算.note-container的高度 // 计算.note-container的高度
const calculateNoteContainerHeight = () => { const calculateNoteContainerHeight = () => {
nextTick(() => { nextTick(() => {
let headerHeight = 0 let headerHeight = 0
let headerInfoHeight = 0 let headerInfoHeight = 0
// 获取Header组件的高度 // 获取Header组件的高度
if (headerRef.value?.$el) { if (headerRef.value?.$el) {
headerHeight = headerRef.value.$el.offsetHeight || 0 headerHeight = headerRef.value.$el.offsetHeight || 0
@@ -430,7 +388,6 @@ const calculateNoteContainerHeight = () => {
const headerElement = document.querySelector('.component') const headerElement = document.querySelector('.component')
headerHeight = headerElement ? headerElement.offsetHeight : 100 // 默认高度 headerHeight = headerElement ? headerElement.offsetHeight : 100 // 默认高度
} }
// 获取.header-info的高度 // 获取.header-info的高度
if (headerInfoRef.value) { if (headerInfoRef.value) {
headerInfoHeight = headerInfoRef.value.offsetHeight || 0 headerInfoHeight = headerInfoRef.value.offsetHeight || 0
@@ -439,42 +396,35 @@ const calculateNoteContainerHeight = () => {
const headerInfoElement = document.querySelector('.header-info') const headerInfoElement = document.querySelector('.header-info')
headerInfoHeight = headerInfoElement ? headerInfoElement.offsetHeight : 40 // 默认高度 headerInfoHeight = headerInfoElement ? headerInfoElement.offsetHeight : 40 // 默认高度
} }
// 计算剩余高度 (屏幕高度 - Header高度 - HeaderInfo高度) // 计算剩余高度 (屏幕高度 - Header高度 - HeaderInfo高度)
const totalHeaderHeight = headerHeight + headerInfoHeight const totalHeaderHeight = headerHeight + headerInfoHeight
const screenHeight = window.innerHeight const screenHeight = window.innerHeight
const newHeight = screenHeight - totalHeaderHeight const newHeight = screenHeight - totalHeaderHeight
// 设置.note-container的高度 // 设置.note-container的高度
noteContainerHeight.value = `${newHeight}px` noteContainerHeight.value = `${newHeight}px`
}) })
} }
// 在组件挂载后和内容变化时重新计算高度 // 在组件挂载后和内容变化时重新计算高度
onMounted(() => { onMounted(() => {
// 等待DOM更新后再计算高度 // 等待DOM更新后再计算高度
nextTick(() => { nextTick(() => {
calculateNoteContainerHeight() calculateNoteContainerHeight()
}) })
// 监听窗口大小变化事件 // 监听窗口大小变化事件
window.addEventListener('resize', calculateNoteContainerHeight) window.addEventListener('resize', calculateNoteContainerHeight)
}) })
// 监听编辑器焦点变化,重新计算高度 // 监听编辑器焦点变化,重新计算高度
watch(isEditorFocus, () => { watch(isEditorFocus, () => {
nextTick(() => { nextTick(() => {
calculateNoteContainerHeight() calculateNoteContainerHeight()
}) })
}) })
// 监听内容变化重新计算高度当内容变化可能导致header-info高度变化时 // 监听内容变化重新计算高度当内容变化可能导致header-info高度变化时
watch(content, () => { watch(content, () => {
nextTick(() => { nextTick(() => {
calculateNoteContainerHeight() calculateNoteContainerHeight()
}) })
}) })
// 在组件卸载前自动保存或删除 // 在组件卸载前自动保存或删除
onBeforeUnmount(async () => { onBeforeUnmount(async () => {
await autoSaveNote() await autoSaveNote()
@@ -482,7 +432,6 @@ onBeforeUnmount(async () => {
window.removeEventListener('resize', calculateNoteContainerHeight) window.removeEventListener('resize', calculateNoteContainerHeight)
}) })
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.container { .container {
display: flex; display: flex;
@@ -494,17 +443,36 @@ onBeforeUnmount(async () => {
height: 100%; height: 100%;
} }
} }
.offline-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 10000;
background-color: #ff6b6b;
color: white;
padding: 8px 16px;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.offline-content {
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
}
.offline-icon {
margin-right: 8px;
}
.note-container { .note-container {
flex: 1; flex: 1;
overflow-y: scroll; overflow-y: scroll;
background-color: var(--background); background-color: var(--background);
} }
.rich-text-editor { .rich-text-editor {
min-height: 100%; min-height: 100%;
} }
.header-info { .header-info {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;

View File

@@ -1,6 +1,14 @@
<template> <template>
<ion-page> <ion-page>
<div class="container"> <div class="container">
<!-- 离线状态提示 -->
<div v-if="!store.isOnline" class="offline-banner">
<div class="offline-content">
<span class="offline-icon"></span>
<span class="offline-text">离线模式 - 数据将自动同步</span>
</div>
</div>
<ion-content class="content"> <ion-content class="content">
<Header <Header
:title="headerTitle" :title="headerTitle"
@@ -59,415 +67,440 @@
</ion-content> </ion-content>
</div> </div>
</ion-page> </ion-page>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAppStore } from '../stores/useAppStore' import { useAppStore } from '../stores/useAppStore'
import NoteItem from '../components/NoteItem.vue' import NoteItem from '../components/NoteItem.vue'
import Header from '../components/Header.vue' import Header from '../components/Header.vue'
import FolderManage from '../components/FolderManage.vue' import FolderManage from '../components/FolderManage.vue'
import SearchBar from '../components/Search.vue' import SearchBar from '../components/Search.vue'
import { formatNoteListDate } from '../utils/dateUtils' import { formatNoteListDate } from '../utils/dateUtils'
import { IonContent, IonPage } from '@ionic/vue' import { IonContent, IonPage } from '@ionic/vue'
const store = useAppStore() const store = useAppStore()
const router = useRouter() const router = useRouter()
const noteItemRefs = ref({}) const noteItemRefs = ref({})
// 页面挂载时加载初始数据 // 页面挂载时加载初始数据
onMounted(() => { onMounted(() => {
// 检查URL参数是否包含mock数据加载指令用于开发和演示 // 检查URL参数是否包含mock数据加载指令用于开发和演示
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
if (urlParams.get('mock') === 'true') { if (urlParams.get('mock') === 'true') {
// 加载预设的模拟数据 // 加载预设的模拟数据
store.loadMockData() store.loadMockData()
} else { } else {
// 从Storage加载用户数据 // 从Storage加载用户数据
store.loadData() store.loadData()
} }
}) })
const searchQuery = ref('') const searchQuery = ref('')
const sortBy = ref('date') // 排序方式:'date'(按日期)、'title'(按标题)、'starred'(按星标) const sortBy = ref('date') // 排序方式:'date'(按日期)、'title'(按标题)、'starred'(按星标)
const isFolderExpanded = ref(false) // 文件夹列表是否展开 const isFolderExpanded = ref(false) // 文件夹列表是否展开
const currentFolder = ref('all') // 当前选中的文件夹,默认是"全部便签" const currentFolder = ref('all') // 当前选中的文件夹,默认是"全部便签"
const noteToDelete = ref(null) const noteToDelete = ref(null)
// 计算加星便签数量(未删除的) // 计算加星便签数量(未删除的)
const starredNotesCount = computed(() => { const starredNotesCount = computed(() => {
return store.notes.filter(note => note.isStarred && !note.isDeleted).length return store.notes.filter(note => note.isStarred && !note.isDeleted).length
}) })
// 计算回收站便签数量 // 计算回收站便签数量
const trashNotesCount = computed(() => { const trashNotesCount = computed(() => {
return store.notes.filter(note => note.isDeleted).length return store.notes.filter(note => note.isDeleted).length
}) })
// 根据当前文件夹过滤便签 // 根据当前文件夹过滤便签
const filteredNotes = computed(() => { const filteredNotes = computed(() => {
// 预处理搜索查询,提高性能 // 预处理搜索查询,提高性能
const lowerCaseQuery = searchQuery.value?.toLowerCase().trim() || '' const lowerCaseQuery = searchQuery.value?.toLowerCase().trim() || ''
return store.notes.filter(note => { return store.notes.filter(note => {
// 先检查搜索条件 // 先检查搜索条件
const matchesSearch = const matchesSearch =
!lowerCaseQuery || (note.title && typeof note.title === 'string' && note.title.toLowerCase().includes(lowerCaseQuery)) || (note.content && typeof note.content === 'string' && note.content.toLowerCase().includes(lowerCaseQuery)) !lowerCaseQuery || (note.title && typeof note.title === 'string' && note.title.toLowerCase().includes(lowerCaseQuery)) || (note.content && typeof note.content === 'string' && note.content.toLowerCase().includes(lowerCaseQuery))
if (!matchesSearch) return false if (!matchesSearch) return false
// 再检查文件夹条件 // 再检查文件夹条件
switch (currentFolder.value) { switch (currentFolder.value) {
case 'all': case 'all':
// 全部便签中不显示已删除的便签 // 全部便签中不显示已删除的便签
return !note.isDeleted return !note.isDeleted
case 'starred': case 'starred':
// 加星便签中只显示未删除的加星便签 // 加星便签中只显示未删除的加星便签
return note.isStarred && !note.isDeleted return note.isStarred && !note.isDeleted
case 'trash': case 'trash':
// 回收站中只显示已删除的便签 // 回收站中只显示已删除的便签
return note.isDeleted return note.isDeleted
default: default:
// 自定义文件夹中不显示已删除的便签 // 自定义文件夹中不显示已删除的便签
return note.folderId === currentFolder.value && !note.isDeleted return note.folderId === currentFolder.value && !note.isDeleted
} }
}) })
}) })
// 过滤并排序便签列表 // 过滤并排序便签列表
// 首先按置顶状态排序,置顶的便签排在前面 // 首先按置顶状态排序,置顶的便签排在前面
// 然后根据sortBy的值进行二次排序 // 然后根据sortBy的值进行二次排序
const filteredAndSortedNotes = computed(() => { const filteredAndSortedNotes = computed(() => {
return [...filteredNotes.value].sort((a, b) => { return [...filteredNotes.value].sort((a, b) => {
// 置顶的便签排在前面 // 置顶的便签排在前面
if (a.isTop && !b.isTop) return -1 if (a.isTop && !b.isTop) return -1
if (!a.isTop && b.isTop) return 1 if (!a.isTop && b.isTop) return 1
// 根据排序方式排序 // 根据排序方式排序
switch (sortBy.value) { switch (sortBy.value) {
case 'title': case 'title':
// 按标题字母顺序排序 // 按标题字母顺序排序
return a.title.localeCompare(b.title) return a.title.localeCompare(b.title)
case 'starred': case 'starred':
// 按星标状态排序,加星的便签排在前面 // 按星标状态排序,加星的便签排在前面
return (b.isStarred ? 1 : 0) - (a.isStarred ? 1 : 0) return (b.isStarred ? 1 : 0) - (a.isStarred ? 1 : 0)
case 'date': case 'date':
default: default:
// 按更新时间倒序排列(最新的在前) // 按更新时间倒序排列(最新的在前)
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
} }
}) })
}) })
// 计算头部标题 // 计算头部标题
const headerTitle = computed(() => { const headerTitle = computed(() => {
switch (currentFolder.value) { switch (currentFolder.value) {
case 'all': case 'all':
return '全部便签' return '全部便签'
case 'starred': case 'starred':
return '加星便签' return '加星便签'
case 'trash': case 'trash':
return '回收站' return '回收站'
default: default:
// 查找自定义文件夹的名称 // 查找自定义文件夹的名称
const folder = store.folders.find(f => f.id === currentFolder.value) const folder = store.folders.find(f => f.id === currentFolder.value)
return folder ? folder.name : '全部便签' return folder ? folder.name : '全部便签'
} }
}) })
// 计算全部便签数量(未删除的) // 计算全部便签数量(未删除的)
const allNotesCount = computed(() => { const allNotesCount = computed(() => {
return store.notes.filter(note => !note.isDeleted).length return store.notes.filter(note => !note.isDeleted).length
}) })
const handleNotePress = noteId => { const handleNotePress = noteId => {
// 检查是否有便签条处于展开状态 // 检查是否有便签条处于展开状态
let hasSlidedNote = false let hasSlidedNote = false
Object.values(noteItemRefs.value).forEach(noteItem => { Object.values(noteItemRefs.value).forEach(noteItem => {
// 注意isSlided是ref值需要通过.value访问 // 注意isSlided是ref值需要通过.value访问
if (noteItem && noteItem.getSlideState()) { if (noteItem && noteItem.getSlideState()) {
hasSlidedNote = true hasSlidedNote = true
noteItem.resetSlideState() noteItem.resetSlideState()
} }
}) })
// 如果有便签条处于展开状态,不跳转到编辑页面 // 如果有便签条处于展开状态,不跳转到编辑页面
if (hasSlidedNote) { if (hasSlidedNote) {
return return
} }
// 使用vue-router导航到编辑页面 // 使用vue-router导航到编辑页面
router.push(`/editor/${noteId}`) router.push(`/editor/${noteId}`)
} }
const handleAddNote = () => { const handleAddNote = () => {
// 使用vue-router导航到新建便签页面 // 使用vue-router导航到新建便签页面
router.push('/editor') router.push('/editor')
} }
// 处理Header组件的操作按钮点击事件 // 处理Header组件的操作按钮点击事件
const handleHeaderAction = actionType => { const handleHeaderAction = actionType => {
if (actionType === 'create') { if (actionType === 'create') {
handleAddNote() handleAddNote()
} }
} }
const handleStarToggle = async noteId => { const handleStarToggle = async noteId => {
const note = store.notes.find(n => n.id === noteId) const note = store.notes.find(n => n.id === noteId)
if (note) { if (note) {
try { try {
await store.updateNote(noteId, { isStarred: !note.isStarred }) await store.updateNote(noteId, { isStarred: !note.isStarred })
console.log(`便签 ${noteId} 星标状态已更新`) console.log(`便签 ${noteId} 星标状态已更新`)
} catch (error) { } catch (error) {
console.error('更新便签星标状态失败:', error) console.error('更新便签星标状态失败:', error)
} }
} }
} }
const handleTopToggle = async noteId => { const handleTopToggle = async noteId => {
const note = store.notes.find(n => n.id === noteId) const note = store.notes.find(n => n.id === noteId)
if (note) { if (note) {
try { try {
await store.updateNote(noteId, { isTop: !note.isTop }) await store.updateNote(noteId, { isTop: !note.isTop })
console.log(`便签 ${noteId} 置顶状态已更新`) console.log(`便签 ${noteId} 置顶状态已更新`)
} catch (error) { } catch (error) {
console.error('更新便签置顶状态失败:', error) console.error('更新便签置顶状态失败:', error)
} }
} }
} }
const confirmDeleteNote = async noteId => { const confirmDeleteNote = async noteId => {
noteToDelete.value = noteId noteToDelete.value = noteId
if (noteToDelete.value) { if (noteToDelete.value) {
try { try {
// 检查当前是否在回收站中 // 检查当前是否在回收站中
if (currentFolder.value === 'trash') { if (currentFolder.value === 'trash') {
// 在回收站中删除便签,彻底删除 // 在回收站中删除便签,彻底删除
await store.permanentlyDeleteNote(noteToDelete.value) await store.permanentlyDeleteNote(noteToDelete.value)
console.log(`便签 ${noteToDelete.value} 已彻底删除`) console.log(`便签 ${noteToDelete.value} 已彻底删除`)
} else { } else {
// 不在回收站中,将便签移至回收站 // 不在回收站中,将便签移至回收站
await store.moveToTrash(noteToDelete.value) await store.moveToTrash(noteToDelete.value)
console.log(`便签 ${noteToDelete.value} 已移至回收站`) console.log(`便签 ${noteToDelete.value} 已移至回收站`)
} }
noteToDelete.value = null noteToDelete.value = null
} catch (error) { } catch (error) {
console.error('删除便签失败:', error) console.error('删除便签失败:', error)
} }
} }
} }
// 处理排序方式切换 // 处理排序方式切换
// 循环切换排序选项:按日期 -> 按标题 -> 按星标 -> 按日期... // 循环切换排序选项:按日期 -> 按标题 -> 按星标 -> 按日期...
const handleSort = () => { const handleSort = () => {
const sortOptions = ['date', 'title', 'starred'] const sortOptions = ['date', 'title', 'starred']
const currentIndex = sortOptions.indexOf(sortBy.value) const currentIndex = sortOptions.indexOf(sortBy.value)
const nextIndex = (currentIndex + 1) % sortOptions.length const nextIndex = (currentIndex + 1) % sortOptions.length
sortBy.value = sortOptions[nextIndex] sortBy.value = sortOptions[nextIndex]
console.log('当前排序方式:', sortOptions[nextIndex]) console.log('当前排序方式:', sortOptions[nextIndex])
} }
const handleAllNotesClick = () => { const handleAllNotesClick = () => {
setCurrentFolder('all') setCurrentFolder('all')
setIsFolderExpanded(false) setIsFolderExpanded(false)
} }
const handleStarredNotesClick = () => { const handleStarredNotesClick = () => {
setCurrentFolder('starred') setCurrentFolder('starred')
setIsFolderExpanded(false) setIsFolderExpanded(false)
} }
const handleTrashNotesClick = () => { const handleTrashNotesClick = () => {
setCurrentFolder('trash') setCurrentFolder('trash')
setIsFolderExpanded(false) setIsFolderExpanded(false)
} }
const handleFolderClick = folderId => { const handleFolderClick = folderId => {
setCurrentFolder(folderId) setCurrentFolder(folderId)
setIsFolderExpanded(false) setIsFolderExpanded(false)
} }
const handleAddFolder = () => { const handleAddFolder = () => {
// 文件夹添加功能已在FolderManage组件中实现 // 文件夹添加功能已在FolderManage组件中实现
// 这里只需关闭文件夹列表 // 这里只需关闭文件夹列表
setIsFolderExpanded(false) setIsFolderExpanded(false)
} }
const handleFolderPress = () => { const handleFolderPress = () => {
// 使用vue-router导航到文件夹页面 // 使用vue-router导航到文件夹页面
router.push('/folders') router.push('/folders')
} }
const handleSettingsPress = () => { const handleSettingsPress = () => {
// 使用vue-router导航到设置页面 // 使用vue-router导航到设置页面
router.push('/settings') router.push('/settings')
} }
const handleFolderToggle = () => { const handleFolderToggle = () => {
// 在实际应用中,这里会触发文件夹列表的展开/收起 // 在实际应用中,这里会触发文件夹列表的展开/收起
isFolderExpanded.value = !isFolderExpanded.value isFolderExpanded.value = !isFolderExpanded.value
} }
const handleSearch = query => { const handleSearch = query => {
// 搜索功能已在computed属性filteredAndSortedNotes中实现 // 搜索功能已在computed属性filteredAndSortedNotes中实现
console.log('搜索:', query) console.log('搜索:', query)
// 可以在这里添加搜索统计或其它功能 // 可以在这里添加搜索统计或其它功能
if (query && query.length > 0) { if (query && query.length > 0) {
console.log(`找到 ${filteredAndSortedNotes.value.length} 个匹配的便签`) console.log(`找到 ${filteredAndSortedNotes.value.length} 个匹配的便签`)
} }
} }
const handleClearSearch = () => { const handleClearSearch = () => {
// 清除搜索已在v-model中处理 // 清除搜索已在v-model中处理
console.log('搜索已清除') console.log('搜索已清除')
// 清除搜索后可以重置一些状态 // 清除搜索后可以重置一些状态
setSearchQuery('') setSearchQuery('')
} }
const handleSearchFocus = () => { const handleSearchFocus = () => {
console.log('搜索栏获得焦点') console.log('搜索栏获得焦点')
// 可以在这里添加获得焦点时的特殊处理 // 可以在这里添加获得焦点时的特殊处理
} }
const handleSearchBlur = () => { const handleSearchBlur = () => {
console.log('搜索栏失去焦点') console.log('搜索栏失去焦点')
// 可以在这里添加失去焦点时的特殊处理 // 可以在这里添加失去焦点时的特殊处理
} }
// 防抖函数,用于避免频繁触发搜索 // 防抖函数,用于避免频繁触发搜索
// 通过延迟执行函数,只在最后一次调用后执行 // 通过延迟执行函数,只在最后一次调用后执行
const debounceSearch = (func, delay) => { const debounceSearch = (func, delay) => {
let timeoutId let timeoutId
return function (...args) { return function (...args) {
clearTimeout(timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => func.apply(this, args), delay) timeoutId = setTimeout(() => func.apply(this, args), delay)
} }
} }
// 防抖搜索处理函数延迟300ms执行搜索 // 防抖搜索处理函数延迟300ms执行搜索
const debouncedHandleSearch = debounceSearch(query => { const debouncedHandleSearch = debounceSearch(query => {
handleSearch(query) handleSearch(query)
}, 300) }, 300)
// 改进的日期格式化函数 // 改进的日期格式化函数
const formatDate = dateString => { const formatDate = dateString => {
return formatNoteListDate(dateString) return formatNoteListDate(dateString)
} }
const setCurrentFolder = folder => { const setCurrentFolder = folder => {
currentFolder.value = folder currentFolder.value = folder
} }
const setIsFolderExpanded = expanded => { const setIsFolderExpanded = expanded => {
isFolderExpanded.value = expanded isFolderExpanded.value = expanded
} }
const setSearchQuery = query => { const setSearchQuery = query => {
searchQuery.value = query searchQuery.value = query
} }
const notes = computed(() => store.notes) const notes = computed(() => store.notes)
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.container { .container {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background: url(/assets/icons/drawable-xxhdpi/note_background.png); background: url(/assets/icons/drawable-xxhdpi/note_background.png);
background-size: cover; background-size: cover;
} }
.folder-list { .offline-banner {
position: absolute; position: fixed;
top: 3.125rem; top: 0;
left: 10%; left: 0;
right: 10%; right: 0;
z-index: 1000; z-index: 10000;
background-color: var(--background-card); background-color: #ff6b6b;
border-radius: 0.5rem; color: white;
box-shadow: 0 0.125rem 0.25rem var(--shadow); padding: 8px 16px;
border: 1px solid #f0ece7; text-align: center;
overflow: hidden; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
} }
.folder-overlay { .offline-content {
position: absolute; display: flex;
top: 0; align-items: center;
left: 0; justify-content: center;
right: 0; font-size: 14px;
bottom: 0; font-weight: 500;
background-color: transparent; }
z-index: 99;
} .offline-icon {
.content { margin-right: 8px;
--background: transparent; }
--padding-top: 4.5rem;
--padding-bottom: 2rem; .folder-list {
} position: absolute;
top: 3.125rem;
.search-container { left: 10%;
padding: 0.8rem 0.5rem; right: 10%;
} z-index: 1000;
background-color: var(--background-card);
.notes-container { border-radius: 0.5rem;
flex: 1; box-shadow: 0 0.125rem 0.25rem var(--shadow);
position: relative; border: 1px solid #f0ece7;
} overflow: hidden;
}
.notes-list {
position: relative; .folder-overlay {
} position: absolute;
top: 0;
.note-item { left: 0;
margin: 0.6rem 0; right: 0;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); bottom: 0;
} background-color: transparent;
z-index: 99;
/* 便签列表动画 */ }
.note-list-enter-active, .content {
.note-list-leave-active { --background: transparent;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); --padding-top: 4.5rem;
} --padding-bottom: 2rem;
}
.note-list-leave-to {
opacity: 0; .search-container {
transform: translateX(-30px); padding: 0.8rem 0.5rem;
} }
.note-list-move { .notes-container {
transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); flex: 1;
} position: relative;
}
.note-list-leave-active {
position: absolute; .notes-list {
width: calc(100% - 1rem); position: relative;
} }
/* 文件夹列表动画 */ .note-item {
.folder-slide-enter-active, margin: 0.6rem 0;
.folder-slide-leave-active { transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); }
}
/* 便签列表动画 */
.folder-slide-enter-from { .note-list-enter-active,
opacity: 0; .note-list-leave-active {
transform: scale(0.8) translateY(-20px); transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
} }
.folder-slide-enter-to { .note-list-leave-to {
opacity: 1; opacity: 0;
transform: scale(1) translateY(0); transform: translateX(-30px);
} }
.folder-slide-leave-from { .note-list-move {
opacity: 1; transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform: scale(1) translateY(0); }
}
.note-list-leave-active {
.folder-slide-leave-to { position: absolute;
opacity: 0; width: calc(100% - 1rem);
transform: scale(0.8) translateY(-20px); }
}
</style> /* 文件夹列表动画 */
.folder-slide-enter-active,
.folder-slide-leave-active {
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.folder-slide-enter-from {
opacity: 0;
transform: scale(0.8) translateY(-20px);
}
.folder-slide-enter-to {
opacity: 1;
transform: scale(1) translateY(0);
}
.folder-slide-leave-from {
opacity: 1;
transform: scale(1) translateY(0);
}
.folder-slide-leave-to {
opacity: 0;
transform: scale(0.8) translateY(-20px);
}
</style>

View File

@@ -1,102 +1,130 @@
<template> <template>
<ion-page> <ion-page>
<Header title="设置" :onBack="handleBackPress" background="#f9f9f9" color="#8e8e8e" /> <Header title="设置" :onBack="handleBackPress" background="#f9f9f9" color="#8e8e8e" />
<div class="settings-content"> <div class="settings-content">
<SettingGroup title="账户"> <SettingGroup title="账户">
<div button @click="handleLogin" class="settings-item settings-item-clickable"> <div button @click="handleLogin" class="settings-item settings-item-clickable">
<div class="item-text-primary">登录云同步</div> <div class="item-text-primary">登录云同步</div>
<div class="item-text-tertiary">未登录</div> <div class="item-text-tertiary">未登录</div>
</div> </div>
</SettingGroup> </SettingGroup>
<SettingGroup title="偏好设置"> <SettingGroup title="偏好设置">
<div class="settings-item settings-item-border"> <div class="settings-item settings-item-border">
<div class="item-text-primary">云同步</div> <div class="item-text-primary">云同步</div>
<SwitchButton :model-value="settings.cloudSync" @update:model-value="toggleCloudSync" /> <SwitchButton :model-value="settings.cloudSync" @update:model-value="toggleCloudSync" />
</div> </div>
</SettingGroup> <div class="settings-item settings-item-border">
<div class="item-text-primary">离线数据同步</div>
<SettingGroup title="关于"> <div class="item-text-tertiary">{{ syncStatusText }}</div>
<div class="settings-item settings-item-border"> </div>
<div class="item-text-primary">版本</div> <div v-if="store.lastSyncTime" class="settings-item">
<div class="item-text-tertiary">1.0.0</div> <div class="item-text-primary">最后同步时间</div>
</div> <div class="item-text-tertiary">{{ formatLastSyncTime }}</div>
<div button @click="handlePrivacyPolicy" class="settings-item settings-item-clickable settings-item-border"> </div>
<div class="item-text-primary">隐私政策</div> </SettingGroup>
</div>
<div button @click="handleTermsOfService" class="settings-item settings-item-clickable"> <SettingGroup title="关于">
<div class="item-text-primary">服务条款</div> <div class="settings-item settings-item-border">
</div> <div class="item-text-primary">版本</div>
</SettingGroup> <div class="item-text-tertiary">1.0.0</div>
</div> </div>
</ion-page> <div button @click="handlePrivacyPolicy" class="settings-item settings-item-clickable settings-item-border">
</template> <div class="item-text-primary">隐私政策</div>
</div>
<script setup> <div button @click="handleTermsOfService" class="settings-item settings-item-clickable">
import { computed, onMounted } from 'vue' <div class="item-text-primary">服务条款</div>
import { useRouter } from 'vue-router' </div>
import { useAppStore } from '../stores/useAppStore' </SettingGroup>
import Header from '../components/Header.vue' </div>
import SettingGroup from '../components/SettingGroup.vue' </ion-page>
import SwitchButton from '../components/SwitchButton.vue' </template>
import { IonPage } from '@ionic/vue'
<script setup>
const store = useAppStore() import { computed, onMounted } from 'vue'
const router = useRouter() import { useRouter } from 'vue-router'
import { useAppStore } from '../stores/useAppStore'
// 页面挂载时加载初始数据 import Header from '../components/Header.vue'
// 从Storage加载用户设置和便签数据 import SettingGroup from '../components/SettingGroup.vue'
onMounted(() => { import SwitchButton from '../components/SwitchButton.vue'
store.loadData() import { IonPage } from '@ionic/vue'
})
const store = useAppStore()
// 切换云同步设置 const router = useRouter()
// 调用store中的方法更新云同步状态
const toggleCloudSync = value => { // 页面挂载时加载初始数据
store.toggleCloudSync() // 从Storage加载用户设置和便签数据
} onMounted(() => {
store.loadData()
// 处理登录云同步按钮点击事件 })
// 在完整实现中,这里会打开登录界面
const handleLogin = () => { // 切换云同步设置
console.log('Login to cloud') // 调用store中的方法更新云同步状态
} const toggleCloudSync = value => {
store.toggleCloudSync()
// 处理隐私政策按钮点击事件 }
// 在完整实现中,这里会显示隐私政策内容
const handlePrivacyPolicy = () => { // 同步状态文本
console.log('Privacy policy') const syncStatusText = computed(() => {
} switch (store.syncStatus) {
case 'syncing':
// 处理服务条款按钮点击事件 return '正在同步...'
// 在完整实现中,这里会显示服务条款内容 case 'error':
const handleTermsOfService = () => { return '同步失败'
console.log('Terms of service') default:
} return store.isOnline ? '已连接' : '离线模式'
}
const handleBackPress = () => { })
router.back()
} // 格式化最后同步时间
const formatLastSyncTime = computed(() => {
const settings = computed(() => store.settings) if (!store.lastSyncTime) return '从未同步'
</script>
const date = new Date(store.lastSyncTime)
<style scoped lang="less"> return date.toLocaleString()
.settings-content { })
background: url('/assets/icons/drawable-xxhdpi/note_setting_bg.png');
width: 100%; // 处理登录云同步按钮点击事件
height: 100vh; // 在完整实现中,这里会打开登录界面
overflow-y: scroll; const handleLogin = () => {
console.log('Login to cloud')
.item-text-primary { }
font-size: 1rem;
color: #8e8e8e; // 处理隐私政策按钮点击事件
} // 在完整实现中,这里会显示隐私政策内容
const handlePrivacyPolicy = () => {
.item-text-tertiary { console.log('Privacy policy')
font-size: 0.9375rem; }
color: #b4b4b4;
} // 处理服务条款按钮点击事件
} // 在完整实现中,这里会显示服务条款内容
</style> const handleTermsOfService = () => {
console.log('Terms of service')
}
const handleBackPress = () => {
router.back()
}
const settings = computed(() => store.settings)
</script>
<style scoped lang="less">
.settings-content {
background: url('/assets/icons/drawable-xxhdpi/note_setting_bg.png');
width: 100%;
height: 100vh;
overflow-y: scroll;
.item-text-primary {
font-size: 1rem;
color: #8e8e8e;
}
.item-text-tertiary {
font-size: 0.9375rem;
color: #b4b4b4;
}
}
</style>

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import * as storage from '../utils/indexedDBStorage' import * as storage from '../utils/indexedDBStorage'
import { getCurrentDateTime, getPastDate } from '../utils/dateUtils' import { getCurrentDateTime, getPastDate } from '../utils/dateUtils'
import { isOnline, testOnline } from '../utils/networkUtils'
/** /**
* 应用状态管理Store * 应用状态管理Store
* 使用Pinia进行状态管理包含便签、文件夹和设置数据 * 使用Pinia进行状态管理包含便签、文件夹和设置数据
@@ -12,11 +12,13 @@ export const useAppStore = defineStore('app', {
* 包含应用的核心数据:便签列表、文件夹列表和设置 * 包含应用的核心数据:便签列表、文件夹列表和设置
*/ */
state: () => ({ state: () => ({
notes: [], // 便签列表 notes: [], // 便签列表
folders: [], // 文件夹列表 folders: [], // 文件夹列表
settings: { cloudSync: false, darkMode: false }, // 应用设置 settings: { cloudSync: false, darkMode: false }, // 应用设置
isOnline: navigator.onLine, // 网络状态
syncStatus: 'idle', // 同步状态idle, syncing, error
lastSyncTime: null, // 最后同步时间
}), }),
/** /**
* 计算属性 * 计算属性
* 基于状态派生的计算值 * 基于状态派生的计算值
@@ -30,7 +32,6 @@ export const useAppStore = defineStore('app', {
starredNotesCount: state => { starredNotesCount: state => {
return state.notes.filter(note => note.isStarred).length return state.notes.filter(note => note.isStarred).length
}, },
/** /**
* 计算所有便签数量 * 计算所有便签数量
* @param {Object} state - 当前状态对象 * @param {Object} state - 当前状态对象
@@ -40,7 +41,6 @@ export const useAppStore = defineStore('app', {
return state.notes.length return state.notes.length
}, },
}, },
/** /**
* 状态变更操作 * 状态变更操作
* 包含所有修改状态的方法 * 包含所有修改状态的方法
@@ -58,7 +58,6 @@ export const useAppStore = defineStore('app', {
const loadedNotes = await storage.getNotes() const loadedNotes = await storage.getNotes()
const loadedFolders = await storage.getFolders() const loadedFolders = await storage.getFolders()
const loadedSettings = await storage.getSettings() const loadedSettings = await storage.getSettings()
// 如果没有数据则加载mock数据 // 如果没有数据则加载mock数据
if (loadedNotes.length === 0 && loadedFolders.length === 0) { if (loadedNotes.length === 0 && loadedFolders.length === 0) {
this.loadMockData() this.loadMockData()
@@ -68,11 +67,50 @@ export const useAppStore = defineStore('app', {
this.folders = loadedFolders this.folders = loadedFolders
this.settings = loadedSettings this.settings = loadedSettings
} }
// 添加网络状态监听
this.addNetworkListeners()
} catch (error) { } catch (error) {
console.error('Error loading data:', error) console.error('Error loading data:', error)
} }
}, },
/**
* 添加网络状态监听器
*/
addNetworkListeners() {
const handleOnline = () => {
this.isOnline = true
console.log('网络已连接')
// 网络连接时尝试同步数据
this.syncOfflineData()
}
const handleOffline = () => {
this.isOnline = false
console.log('网络已断开')
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
},
/**
* 同步离线数据
* 当网络连接恢复时同步离线期间的操作
*/
async syncOfflineData() {
if (!this.settings.cloudSync) {
return
}
this.syncStatus = 'syncing'
try {
// 这里应该实现与云服务同步的逻辑
// 暂时使用模拟同步
await new Promise(resolve => setTimeout(resolve, 1000))
this.syncStatus = 'idle'
this.lastSyncTime = new Date().toISOString()
console.log('离线数据同步完成')
} catch (error) {
console.error('离线数据同步失败:', error)
this.syncStatus = 'error'
}
},
/** /**
* 加载预设的mock数据 * 加载预设的mock数据
* 用于开发和演示目的,提供示例便签、文件夹和设置 * 用于开发和演示目的,提供示例便签、文件夹和设置
@@ -80,47 +118,42 @@ export const useAppStore = defineStore('app', {
*/ */
async loadMockData() { async loadMockData() {
// Mock notes - 使用固定的日期值以避免每次运行时变化 // Mock notes - 使用固定的日期值以避免每次运行时变化
const fixedCurrentDate = '2025-10-12T10:00:00.000Z'; const fixedCurrentDate = '2025-10-12T10:00:00.000Z'
// 预设的便签示例数据 - 仅保留一条关于应用功能介绍和示例的便签 // 预设的便签示例数据 - 仅保留一条关于应用功能介绍和示例的便签
const mockNotes = [ const mockNotes = [
{ {
id: '1', id: '1',
title: '欢迎使用锤子便签', title: '欢迎使用锤子便签',
content: '<p>这是一个功能强大的便签应用,您可以在这里记录您的想法、待办事项等。</p><br><h2>功能介绍</h2><p>1. 创建和编辑便签<br>2. 为便签加星和置顶<br>3. 将便签分类到文件夹<br>4. 搜索便签内容<br>5. 回收站功能</p><br><h2>编辑器功能演示</h2><br><h2>标题格式</h2><p>点击标题按钮可创建居中的标题</p><br><h2>待办事项</h2><div class="todo-container"><div class="todo-icon"></div><div contenteditable="true" class="todo-content">这是一个待办事项</div></div><div class="todo-container"><div class="todo-icon completed"></div><div contenteditable="true" class="todo-content" style="color: var(--text-tertiary); text-decoration: line-through;">这是一个已完成的待办事项</div></div><br><h2>列表格式</h2><ul><li>无序列表项1</li><li>无序列表项2</li></ul><br><h2>文本格式</h2><p><strong>加粗文本</strong></p><br><h2>引用格式</h2><div class="quote-container"><div class="quote-icon"></div><div class="quote-content">这是一段引用文本<br>可以用来引用他人的话语</div></div><br><h2>图片</h2><p>点击图片按钮可以插入图片,长按图片可以拖拽排序</p>', content:
'<p>这是一个功能强大的便签应用,您可以在这里记录您的想法、待办事项等。</p><br><h2>功能介绍</h2><p>1. 创建和编辑便签<br>2. 为便签加星和置顶<br>3. 将便签分类到文件夹<br>4. 搜索便签内容<br>5. 回收站功能</p><br><h2>编辑器功能演示</h2><br><h2>标题格式</h2><p>点击标题按钮可创建居中的标题</p><br><h2>待办事项</h2><div class="todo-container"><div class="todo-icon"></div><div contenteditable="true" class="todo-content">这是一个待办事项</div></div><div class="todo-container"><div class="todo-icon completed"></div><div contenteditable="true" class="todo-content" style="color: var(--text-tertiary); text-decoration: line-through;">这是一个已完成的待办事项</div></div><br><h2>列表格式</h2><ul><li>无序列表项1</li><li>无序列表项2</li></ul><br><h2>文本格式</h2><p><strong>加粗文本</strong></p><br><h2>引用格式</h2><div class="quote-container"><div class="quote-icon"></div><div class="quote-content">这是一段引用文本<br>可以用来引用他人的话语</div></div><br><h2>图片</h2><p>点击图片按钮可以插入图片,长按图片可以拖拽排序</p>',
createdAt: fixedCurrentDate, createdAt: fixedCurrentDate,
updatedAt: fixedCurrentDate, updatedAt: fixedCurrentDate,
folderId: null, folderId: null,
isStarred: true, // 加星便签 isStarred: true, // 加星便签
isTop: true, // 置顶便签 isTop: true, // 置顶便签
hasImage: true, // 包含图片 hasImage: true, // 包含图片
isDeleted: false, // 未删除 isDeleted: false, // 未删除
deletedAt: null, deletedAt: null,
} },
] ]
// Mock folders - 使用固定的日期值 // Mock folders - 使用固定的日期值
// 预设的文件夹示例数据 // 预设的文件夹示例数据
const mockFolders = [] const mockFolders = []
// Mock settings // Mock settings
// 预设的设置示例数据 // 预设的设置示例数据
const mockSettings = { const mockSettings = {
cloudSync: false, // 云同步关闭 cloudSync: false, // 云同步关闭
darkMode: false, // 深色模式关闭 darkMode: false, // 深色模式关闭
} }
// 更新store状态 // 更新store状态
this.notes = mockNotes this.notes = mockNotes
this.folders = mockFolders this.folders = mockFolders
this.settings = mockSettings this.settings = mockSettings
// 保存到Storage // 保存到Storage
await storage.saveNotes(mockNotes) await storage.saveNotes(mockNotes)
await storage.saveFolders(mockFolders) await storage.saveFolders(mockFolders)
await storage.saveSettings(mockSettings) await storage.saveSettings(mockSettings)
}, },
/** /**
* 保存便签数据到Storage * 保存便签数据到Storage
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -132,7 +165,6 @@ export const useAppStore = defineStore('app', {
console.error('Error saving notes:', error) console.error('Error saving notes:', error)
} }
}, },
/** /**
* 保存文件夹数据到Storage * 保存文件夹数据到Storage
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -144,7 +176,6 @@ export const useAppStore = defineStore('app', {
console.error('Error saving folders:', error) console.error('Error saving folders:', error)
} }
}, },
/** /**
* 保存设置数据到Storage * 保存设置数据到Storage
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -156,11 +187,9 @@ export const useAppStore = defineStore('app', {
console.error('Error saving settings:', error) console.error('Error saving settings:', error)
} }
}, },
/** /**
* 便签操作函数 * 便签操作函数
*/ */
/** /**
* 添加新便签 * 添加新便签
* @param {Object} note - 便签对象 * @param {Object} note - 便签对象
@@ -170,14 +199,12 @@ export const useAppStore = defineStore('app', {
try { try {
const newNote = await storage.addNote(note) const newNote = await storage.addNote(note)
this.notes.push(newNote) this.notes.push(newNote)
return newNote return newNote
} catch (error) { } catch (error) {
console.error('Error adding note:', error) console.error('Error adding note:', error)
throw error throw error
} }
}, },
/** /**
* 更新便签 * 更新便签
* @param {string} id - 便签ID * @param {string} id - 便签ID
@@ -199,7 +226,6 @@ export const useAppStore = defineStore('app', {
throw error throw error
} }
}, },
/** /**
* 删除便签 * 删除便签
* @param {string} id - 要删除的便签ID * @param {string} id - 要删除的便签ID
@@ -217,7 +243,6 @@ export const useAppStore = defineStore('app', {
throw error throw error
} }
}, },
/** /**
* 将便签移至回收站 * 将便签移至回收站
* 将便签标记为已删除状态,并记录删除时间 * 将便签标记为已删除状态,并记录删除时间
@@ -239,7 +264,6 @@ export const useAppStore = defineStore('app', {
throw error throw error
} }
}, },
/** /**
* 永久删除便签 * 永久删除便签
* 从便签列表中彻底移除便签 * 从便签列表中彻底移除便签
@@ -258,11 +282,9 @@ export const useAppStore = defineStore('app', {
throw error throw error
} }
}, },
/** /**
* 文件夹操作函数 * 文件夹操作函数
*/ */
/** /**
* 添加新文件夹 * 添加新文件夹
* @param {Object} folder - 文件夹对象 * @param {Object} folder - 文件夹对象
@@ -278,7 +300,6 @@ export const useAppStore = defineStore('app', {
throw error throw error
} }
}, },
/** /**
* 更新文件夹 * 更新文件夹
* @param {string} id - 文件夹ID * @param {string} id - 文件夹ID
@@ -300,7 +321,6 @@ export const useAppStore = defineStore('app', {
throw error throw error
} }
}, },
/** /**
* 删除文件夹 * 删除文件夹
* @param {string} id - 要删除的文件夹ID * @param {string} id - 要删除的文件夹ID
@@ -315,7 +335,6 @@ export const useAppStore = defineStore('app', {
for (const note of notesInFolder) { for (const note of notesInFolder) {
await this.updateNote(note.id, { folderId: null }) await this.updateNote(note.id, { folderId: null })
} }
// 从文件夹列表中移除文件夹 // 从文件夹列表中移除文件夹
this.folders = this.folders.filter(folder => folder.id !== id) this.folders = this.folders.filter(folder => folder.id !== id)
} }
@@ -325,11 +344,9 @@ export const useAppStore = defineStore('app', {
throw error throw error
} }
}, },
/** /**
* 设置操作函数 * 设置操作函数
*/ */
/** /**
* 更新设置 * 更新设置
* @param {Object} newSettings - 新的设置对象 * @param {Object} newSettings - 新的设置对象
@@ -345,7 +362,6 @@ export const useAppStore = defineStore('app', {
throw error throw error
} }
}, },
/** /**
* 切换云同步设置 * 切换云同步设置
* 开启或关闭云同步功能 * 开启或关闭云同步功能
@@ -354,7 +370,6 @@ export const useAppStore = defineStore('app', {
async toggleCloudSync() { async toggleCloudSync() {
await this.updateSettings({ cloudSync: !this.settings.cloudSync }) await this.updateSettings({ cloudSync: !this.settings.cloudSync })
}, },
/** /**
* 切换深色模式设置 * 切换深色模式设置
* 开启或关闭深色模式 * 开启或关闭深色模式

1690
src/sw.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
import { getCurrentDateTime, getTimestamp } from './dateUtils' import { getCurrentDateTime, getTimestamp } from './dateUtils'
import { OfflineQueue } from './networkUtils'
// 数据库配置 // 数据库配置
const DB_NAME = 'SmartisanNoteDB' const DB_NAME = 'SmartisanNoteDB'
const DB_VERSION = 2 // 更新版本号以确保数据库重新创建 const DB_VERSION = 2 // 更新版本号以确保数据库重新创建
const NOTES_STORE = 'notes' const NOTES_STORE = 'notes'
const FOLDERS_STORE = 'folders' const FOLDERS_STORE = 'folders'
const SETTINGS_STORE = 'settings' const SETTINGS_STORE = 'settings'
let db = null let db = null
// 创建离线队列实例
const offlineQueue = new OfflineQueue()
/** /**
* 打开数据库连接 * 打开数据库连接
* @returns {Promise<IDBDatabase>} 数据库实例 * @returns {Promise<IDBDatabase>} 数据库实例
@@ -18,21 +18,16 @@ const openDB = () => {
if (db) { if (db) {
return resolve(db) return resolve(db)
} }
const request = indexedDB.open(DB_NAME, DB_VERSION) const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => { request.onerror = () => {
reject(new Error('无法打开数据库')) reject(new Error('无法打开数据库'))
} }
request.onsuccess = () => { request.onsuccess = () => {
db = request.result db = request.result
resolve(db) resolve(db)
} }
request.onupgradeneeded = event => {
request.onupgradeneeded = (event) => {
const database = event.target.result const database = event.target.result
// 删除现有的对象存储(如果版本已更改) // 删除现有的对象存储(如果版本已更改)
if (event.oldVersion > 0) { if (event.oldVersion > 0) {
if (database.objectStoreNames.contains(NOTES_STORE)) { if (database.objectStoreNames.contains(NOTES_STORE)) {
@@ -45,7 +40,6 @@ const openDB = () => {
database.deleteObjectStore(SETTINGS_STORE) database.deleteObjectStore(SETTINGS_STORE)
} }
} }
// 创建便签存储对象 // 创建便签存储对象
const notesStore = database.createObjectStore(NOTES_STORE, { keyPath: 'id' }) const notesStore = database.createObjectStore(NOTES_STORE, { keyPath: 'id' })
notesStore.createIndex('folderId', 'folderId', { unique: false }) notesStore.createIndex('folderId', 'folderId', { unique: false })
@@ -53,38 +47,32 @@ const openDB = () => {
notesStore.createIndex('isDeleted', 'isDeleted', { unique: false }) notesStore.createIndex('isDeleted', 'isDeleted', { unique: false })
notesStore.createIndex('createdAt', 'createdAt', { unique: false }) notesStore.createIndex('createdAt', 'createdAt', { unique: false })
notesStore.createIndex('updatedAt', 'updatedAt', { unique: false }) notesStore.createIndex('updatedAt', 'updatedAt', { unique: false })
// 创建文件夹存储对象 // 创建文件夹存储对象
database.createObjectStore(FOLDERS_STORE, { keyPath: 'id' }) database.createObjectStore(FOLDERS_STORE, { keyPath: 'id' })
// 创建设置存储对象 // 创建设置存储对象
database.createObjectStore(SETTINGS_STORE) database.createObjectStore(SETTINGS_STORE)
} }
}) })
} }
/** /**
* 从存储中获取数据 * 从存储中获取数据
* @param {string} storeName - 存储名称 * @param {string} storeName - 存储名称
* @returns {Promise<Array>} 数据数组 * @returns {Promise<Array>} 数据数组
*/ */
const getAllFromStore = async (storeName) => { const getAllFromStore = async storeName => {
const database = await openDB() const database = await openDB()
const transaction = database.transaction([storeName], 'readonly') const transaction = database.transaction([storeName], 'readonly')
const store = transaction.objectStore(storeName) const store = transaction.objectStore(storeName)
const request = store.getAll() const request = store.getAll()
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request.onsuccess = () => { request.onsuccess = () => {
resolve(request.result || []) resolve(request.result || [])
} }
request.onerror = () => { request.onerror = () => {
reject(new Error(`获取 ${storeName} 数据失败`)) reject(new Error(`获取 ${storeName} 数据失败`))
} }
}) })
} }
/** /**
* 保存数据到存储 * 保存数据到存储
* @param {string} storeName - 存储名称 * @param {string} storeName - 存储名称
@@ -95,14 +83,12 @@ const saveToStore = async (storeName, data) => {
const database = await openDB() const database = await openDB()
const transaction = database.transaction([storeName], 'readwrite') const transaction = database.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName) const store = transaction.objectStore(storeName)
// 清除现有数据 // 清除现有数据
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const clearRequest = store.clear() const clearRequest = store.clear()
clearRequest.onsuccess = () => resolve() clearRequest.onsuccess = () => resolve()
clearRequest.onerror = () => reject(new Error(`清除 ${storeName} 数据失败`)) clearRequest.onerror = () => reject(new Error(`清除 ${storeName} 数据失败`))
}) })
// 添加新数据 // 添加新数据
for (const item of data) { for (const item of data) {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
@@ -112,7 +98,6 @@ const saveToStore = async (storeName, data) => {
}) })
} }
} }
/** /**
* 从存储中获取单个项 * 从存储中获取单个项
* @param {string} storeName - 存储名称 * @param {string} storeName - 存储名称
@@ -124,18 +109,15 @@ const getFromStore = async (storeName, id) => {
const transaction = database.transaction([storeName], 'readonly') const transaction = database.transaction([storeName], 'readonly')
const store = transaction.objectStore(storeName) const store = transaction.objectStore(storeName)
const request = store.get(id) const request = store.get(id)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request.onsuccess = () => { request.onsuccess = () => {
resolve(request.result || null) resolve(request.result || null)
} }
request.onerror = () => { request.onerror = () => {
reject(new Error(`获取 ${storeName} 项失败`)) reject(new Error(`获取 ${storeName} 项失败`))
} }
}) })
} }
/** /**
* 向存储中添加项 * 向存储中添加项
* @param {string} storeName - 存储名称 * @param {string} storeName - 存储名称
@@ -147,18 +129,15 @@ const addToStore = async (storeName, item) => {
const transaction = database.transaction([storeName], 'readwrite') const transaction = database.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName) const store = transaction.objectStore(storeName)
const request = store.add(item) const request = store.add(item)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request.onsuccess = () => { request.onsuccess = () => {
resolve(item) resolve(item)
} }
request.onerror = () => { request.onerror = () => {
reject(new Error(`添加 ${storeName} 项失败`)) reject(new Error(`添加 ${storeName} 项失败`))
} }
}) })
} }
/** /**
* 更新存储中的项 * 更新存储中的项
* @param {string} storeName - 存储名称 * @param {string} storeName - 存储名称
@@ -169,24 +148,20 @@ const addToStore = async (storeName, item) => {
const updateInStore = async (storeName, id, updates) => { const updateInStore = async (storeName, id, updates) => {
const item = await getFromStore(storeName, id) const item = await getFromStore(storeName, id)
if (!item) return null if (!item) return null
const updatedItem = { ...item, ...updates } const updatedItem = { ...item, ...updates }
const database = await openDB() const database = await openDB()
const transaction = database.transaction([storeName], 'readwrite') const transaction = database.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName) const store = transaction.objectStore(storeName)
const request = store.put(updatedItem) const request = store.put(updatedItem)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request.onsuccess = () => { request.onsuccess = () => {
resolve(updatedItem) resolve(updatedItem)
} }
request.onerror = () => { request.onerror = () => {
reject(new Error(`更新 ${storeName} 项失败`)) reject(new Error(`更新 ${storeName} 项失败`))
} }
}) })
} }
/** /**
* 从存储中删除项 * 从存储中删除项
* @param {string} storeName - 存储名称 * @param {string} storeName - 存储名称
@@ -198,21 +173,17 @@ const deleteFromStore = async (storeName, id) => {
const transaction = database.transaction([storeName], 'readwrite') const transaction = database.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName) const store = transaction.objectStore(storeName)
const request = store.delete(id) const request = store.delete(id)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request.onsuccess = () => { request.onsuccess = () => {
resolve(true) resolve(true)
} }
request.onerror = () => { request.onerror = () => {
reject(new Error(`删除 ${storeName} 项失败`)) reject(new Error(`删除 ${storeName} 项失败`))
} }
}) })
} }
// 便签操作函数 // 便签操作函数
// 提供便签的增删改查功能 // 提供便签的增删改查功能
/** /**
* 获取所有便签数据 * 获取所有便签数据
* 从IndexedDB中读取便签数据 * 从IndexedDB中读取便签数据
@@ -227,28 +198,26 @@ export const getNotes = async () => {
return [] return []
} }
} }
/** /**
* 保存便签数据 * 保存便签数据
* 将便签数组保存到IndexedDB * 将便签数组保存到IndexedDB
* @param {Array} notes - 便签数组 * @param {Array} notes - 便签数组
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export const saveNotes = async (notes) => { export const saveNotes = async notes => {
try { try {
await saveToStore(NOTES_STORE, notes) await saveToStore(NOTES_STORE, notes)
} catch (error) { } catch (error) {
console.error('Error saving notes:', error) console.error('Error saving notes:', error)
} }
} }
/** /**
* 添加新便签 * 添加新便签
* 创建一个新的便签对象并添加到便签列表中 * 创建一个新的便签对象并添加到便签列表中
* @param {Object} note - 便签对象,包含便签内容和其他属性 * @param {Object} note - 便签对象,包含便签内容和其他属性
* @returns {Promise<Object>} 新创建的便签对象 * @returns {Promise<Object>} 新创建的便签对象
*/ */
export const addNote = async (note) => { export const addNote = async note => {
try { try {
// 创建新的便签对象,添加必要的属性 // 创建新的便签对象,添加必要的属性
const newNote = { const newNote = {
@@ -263,18 +232,18 @@ export const addNote = async (note) => {
isDeleted: note.isDeleted || false, // 是否已删除 isDeleted: note.isDeleted || false, // 是否已删除
deletedAt: note.deletedAt || null, // 删除时间 deletedAt: note.deletedAt || null, // 删除时间
folderId: note.folderId || null, // 文件夹ID folderId: note.folderId || null, // 文件夹ID
...note ...note,
} }
// 添加到存储 // 添加到存储
await addToStore(NOTES_STORE, newNote) await addToStore(NOTES_STORE, newNote)
// 添加到离线队列(用于同步)
offlineQueue.addOperation('add', NOTES_STORE, newNote)
return newNote return newNote
} catch (error) { } catch (error) {
console.error('Error adding note:', error) console.error('Error adding note:', error)
throw error throw error
} }
} }
/** /**
* 更新便签 * 更新便签
* 根据ID查找并更新便签信息 * 根据ID查找并更新便签信息
@@ -287,36 +256,40 @@ export const updateNote = async (id, updates) => {
// 更新便签并保存 // 更新便签并保存
const updatedNote = await updateInStore(NOTES_STORE, id, { const updatedNote = await updateInStore(NOTES_STORE, id, {
...updates, ...updates,
updatedAt: getCurrentDateTime() // 更新最后修改时间 updatedAt: getCurrentDateTime(), // 更新最后修改时间
}) })
if (updatedNote) {
// 添加到离线队列(用于同步)
offlineQueue.addOperation('update', NOTES_STORE, updatedNote)
}
return updatedNote return updatedNote
} catch (error) { } catch (error) {
console.error('Error updating note:', error) console.error('Error updating note:', error)
throw error throw error
} }
} }
/** /**
* 删除便签 * 删除便签
* 根据ID从便签列表中移除便签 * 根据ID从便签列表中移除便签
* @param {string} id - 要删除的便签ID * @param {string} id - 要删除的便签ID
* @returns {Promise<boolean>} 删除成功返回true未找到便签返回false * @returns {Promise<boolean>} 删除成功返回true未找到便签返回false
*/ */
export const deleteNote = async (id) => { export const deleteNote = async id => {
try { try {
// 从存储中删除 // 从存储中删除
const result = await deleteFromStore(NOTES_STORE, id) const result = await deleteFromStore(NOTES_STORE, id)
if (result) {
// 添加到离线队列(用于同步)
offlineQueue.addOperation('delete', NOTES_STORE, { id })
}
return result return result
} catch (error) { } catch (error) {
console.error('Error deleting note:', error) console.error('Error deleting note:', error)
return false return false
} }
} }
// 文件夹操作函数 // 文件夹操作函数
// 提供文件夹的增删改查功能 // 提供文件夹的增删改查功能
/** /**
* 获取所有文件夹数据 * 获取所有文件夹数据
* 从IndexedDB中读取文件夹数据 * 从IndexedDB中读取文件夹数据
@@ -331,46 +304,44 @@ export const getFolders = async () => {
return [] return []
} }
} }
/** /**
* 保存文件夹数据 * 保存文件夹数据
* 将文件夹数组保存到IndexedDB * 将文件夹数组保存到IndexedDB
* @param {Array} folders - 文件夹数组 * @param {Array} folders - 文件夹数组
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export const saveFolders = async (folders) => { export const saveFolders = async folders => {
try { try {
await saveToStore(FOLDERS_STORE, folders) await saveToStore(FOLDERS_STORE, folders)
} catch (error) { } catch (error) {
console.error('Error saving folders:', error) console.error('Error saving folders:', error)
} }
} }
/** /**
* 添加新文件夹 * 添加新文件夹
* 创建一个新的文件夹对象并添加到文件夹列表中 * 创建一个新的文件夹对象并添加到文件夹列表中
* @param {Object} folder - 文件夹对象,包含文件夹名称等属性 * @param {Object} folder - 文件夹对象,包含文件夹名称等属性
* @returns {Promise<Object>} 新创建的文件夹对象 * @returns {Promise<Object>} 新创建的文件夹对象
*/ */
export const addFolder = async (folder) => { export const addFolder = async folder => {
try { try {
// 创建新的文件夹对象,添加必要的属性 // 创建新的文件夹对象,添加必要的属性
const newFolder = { const newFolder = {
name: folder.name || '', name: folder.name || '',
id: folder.id || getTimestamp().toString(), // 使用时间戳生成唯一ID id: folder.id || getTimestamp().toString(), // 使用时间戳生成唯一ID
createdAt: folder.createdAt || getCurrentDateTime(), // 创建时间 createdAt: folder.createdAt || getCurrentDateTime(), // 创建时间
...folder ...folder,
} }
// 添加到存储 // 添加到存储
await addToStore(FOLDERS_STORE, newFolder) await addToStore(FOLDERS_STORE, newFolder)
// 添加到离线队列(用于同步)
offlineQueue.addOperation('add', FOLDERS_STORE, newFolder)
return newFolder return newFolder
} catch (error) { } catch (error) {
console.error('Error adding folder:', error) console.error('Error adding folder:', error)
throw error throw error
} }
} }
/** /**
* 更新文件夹 * 更新文件夹
* 根据ID查找并更新文件夹信息 * 根据ID查找并更新文件夹信息
@@ -382,33 +353,38 @@ export const updateFolder = async (id, updates) => {
try { try {
// 更新文件夹并保存 // 更新文件夹并保存
const updatedFolder = await updateInStore(FOLDERS_STORE, id, updates) const updatedFolder = await updateInStore(FOLDERS_STORE, id, updates)
if (updatedFolder) {
// 添加到离线队列(用于同步)
offlineQueue.addOperation('update', FOLDERS_STORE, updatedFolder)
}
return updatedFolder return updatedFolder
} catch (error) { } catch (error) {
console.error('Error updating folder:', error) console.error('Error updating folder:', error)
throw error throw error
} }
} }
/** /**
* 删除文件夹 * 删除文件夹
* 根据ID从文件夹列表中移除文件夹 * 根据ID从文件夹列表中移除文件夹
* @param {string} id - 要删除的文件夹ID * @param {string} id - 要删除的文件夹ID
* @returns {Promise<boolean>} 删除成功返回true未找到文件夹返回false * @returns {Promise<boolean>} 删除成功返回true未找到文件夹返回false
*/ */
export const deleteFolder = async (id) => { export const deleteFolder = async id => {
try { try {
// 从存储中删除 // 从存储中删除
const result = await deleteFromStore(FOLDERS_STORE, id) const result = await deleteFromStore(FOLDERS_STORE, id)
if (result) {
// 添加到离线队列(用于同步)
offlineQueue.addOperation('delete', FOLDERS_STORE, { id })
}
return result return result
} catch (error) { } catch (error) {
console.error('Error deleting folder:', error) console.error('Error deleting folder:', error)
return false return false
} }
} }
// 设置操作函数 // 设置操作函数
// 提供应用设置的读取和保存功能 // 提供应用设置的读取和保存功能
/** /**
* 获取应用设置 * 获取应用设置
* 从IndexedDB中读取设置数据 * 从IndexedDB中读取设置数据
@@ -420,17 +396,14 @@ export const getSettings = async () => {
const transaction = database.transaction([SETTINGS_STORE], 'readonly') const transaction = database.transaction([SETTINGS_STORE], 'readonly')
const store = transaction.objectStore(SETTINGS_STORE) const store = transaction.objectStore(SETTINGS_STORE)
const request = store.get('settings') const request = store.get('settings')
const settings = await new Promise((resolve, reject) => { const settings = await new Promise((resolve, reject) => {
request.onsuccess = () => { request.onsuccess = () => {
resolve(request.result || { cloudSync: false, darkMode: false }) resolve(request.result || { cloudSync: false, darkMode: false })
} }
request.onerror = () => { request.onerror = () => {
reject(new Error('获取设置失败')) reject(new Error('获取设置失败'))
} }
}) })
return settings return settings
} catch (error) { } catch (error) {
console.error('Error getting settings:', error) console.error('Error getting settings:', error)
@@ -438,35 +411,34 @@ export const getSettings = async () => {
return { cloudSync: false, darkMode: false } return { cloudSync: false, darkMode: false }
} }
} }
/** /**
* 保存应用设置 * 保存应用设置
* 将设置对象保存到IndexedDB * 将设置对象保存到IndexedDB
* @param {Object} settings - 设置对象 * @param {Object} settings - 设置对象
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export const saveSettings = async (settings) => { export const saveSettings = async settings => {
try { try {
const database = await openDB() const database = await openDB()
const transaction = database.transaction([SETTINGS_STORE], 'readwrite') const transaction = database.transaction([SETTINGS_STORE], 'readwrite')
const store = transaction.objectStore(SETTINGS_STORE) const store = transaction.objectStore(SETTINGS_STORE)
const request = store.put(settings, 'settings') const request = store.put(settings, 'settings')
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
request.onsuccess = () => resolve() request.onsuccess = () => resolve()
request.onerror = () => reject(new Error('保存设置失败')) request.onerror = () => reject(new Error('保存设置失败'))
}) })
// 添加到离线队列(用于同步)
offlineQueue.addOperation('update', SETTINGS_STORE, settings)
} catch (error) { } catch (error) {
console.error('Error saving settings:', error) console.error('Error saving settings:', error)
} }
} }
/** /**
* 确保数据有默认值 * 确保数据有默认值
* @param {Array} notes - 便签数组 * @param {Array} notes - 便签数组
* @returns {Array} 处理后的便签数组 * @returns {Array} 处理后的便签数组
*/ */
const ensureNotesDefaults = (notes) => { const ensureNotesDefaults = notes => {
return notes.map(note => ({ return notes.map(note => ({
title: note.title || '', title: note.title || '',
content: note.content || '', content: note.content || '',
@@ -479,24 +451,22 @@ const ensureNotesDefaults = (notes) => {
isDeleted: note.isDeleted || false, isDeleted: note.isDeleted || false,
deletedAt: note.deletedAt || null, deletedAt: note.deletedAt || null,
folderId: note.folderId || null, folderId: note.folderId || null,
...note ...note,
})) }))
} }
/** /**
* 确保文件夹数据有默认值 * 确保文件夹数据有默认值
* @param {Array} folders - 文件夹数组 * @param {Array} folders - 文件夹数组
* @returns {Array} 处理后的文件夹数组 * @returns {Array} 处理后的文件夹数组
*/ */
const ensureFoldersDefaults = (folders) => { const ensureFoldersDefaults = folders => {
return folders.map(folder => ({ return folders.map(folder => ({
name: folder.name || '', name: folder.name || '',
id: folder.id, id: folder.id,
createdAt: folder.createdAt, createdAt: folder.createdAt,
...folder ...folder,
})) }))
} }
/** /**
* 初始化数据库 * 初始化数据库
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -507,4 +477,4 @@ export const initDB = async () => {
} catch (error) { } catch (error) {
console.error('Error initializing database:', error) console.error('Error initializing database:', error)
} }
} }

236
src/utils/networkUtils.js Normal file
View File

@@ -0,0 +1,236 @@
/**
* 网络状态检测和离线功能支持工具
*/
/**
* 检查网络连接状态
* @returns {boolean} 网络是否连接
*/
export const isOnline = () => {
return navigator.onLine;
};
/**
* 监听网络状态变化
* @param {Function} onlineCallback - 网络连接时的回调
* @param {Function} offlineCallback - 网络断开时的回调
*/
export const addNetworkListener = (onlineCallback, offlineCallback) => {
window.addEventListener('online', onlineCallback);
window.addEventListener('offline', offlineCallback);
};
/**
* 移除网络状态监听
* @param {Function} onlineCallback - 网络连接时的回调
* @param {Function} offlineCallback - 网络断开时的回调
*/
export const removeNetworkListener = (onlineCallback, offlineCallback) => {
window.removeEventListener('online', onlineCallback);
window.removeEventListener('offline', offlineCallback);
};
/**
* 检测网络类型和速度
* @returns {Object} 网络信息对象
*/
export const getNetworkInfo = () => {
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (connection) {
return {
effectiveType: connection.effectiveType, // 'slow-2g', '2g', '3g', '4g'
downlink: connection.downlink, // 下行速度 (Mbps)
rtt: connection.rtt, // 往返时间 (ms)
saveData: connection.saveData, // 是否开启了数据保护模式
};
}
return {
effectiveType: 'unknown',
downlink: null,
rtt: null,
saveData: false,
};
};
/**
* 检查是否为慢速网络
* @returns {boolean} 是否为慢速网络
*/
export const isSlowNetwork = () => {
const networkInfo = getNetworkInfo();
return networkInfo.effectiveType === 'slow-2g' || networkInfo.effectiveType === '2g';
};
/**
* 在线测试 - 发送一个请求来测试网络连接
* @param {string} url - 测试URL默认为当前域名
* @returns {Promise<boolean>} 网络是否可用
*/
export const testOnline = async (url = null) => {
if (!isOnline()) {
return false;
}
try {
const testUrl = url || `${window.location.protocol}//${window.location.host}/`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时
const response = await fetch(testUrl, {
method: 'HEAD',
cache: 'no-cache',
signal: controller.signal,
});
clearTimeout(timeoutId);
return response.ok;
} catch (error) {
console.warn('Online test failed:', error.message);
return false;
}
};
/**
* 检查 IndexedDB 可用性
* @returns {Promise<boolean>} IndexedDB 是否可用
*/
export const isIndexedDBAvailable = async () => {
if (!window.indexedDB) {
return false;
}
try {
const name = 'test-db';
const version = 1;
const request = window.indexedDB.open(name, version);
return new Promise((resolve) => {
request.onsuccess = () => {
const db = request.result;
db.close();
window.indexedDB.deleteDatabase(name); // 清理测试数据库
resolve(true);
};
request.onerror = () => {
resolve(false);
};
// 超时处理
setTimeout(() => {
resolve(false);
}, 5000);
});
} catch (error) {
console.error('IndexedDB availability check failed:', error);
return false;
}
};
/**
* 检查存储配额
* @returns {Promise<Object>} 存储配额信息
*/
export const getStorageQuota = async () => {
if (navigator.storage && navigator.storage.estimate) {
try {
const quota = await navigator.storage.estimate();
return {
quota: quota.quota,
usage: quota.usage,
usageDetails: quota.usageDetails,
percentage: quota.quota ? (quota.usage / quota.quota) * 100 : 0,
};
} catch (error) {
console.error('Storage quota estimation failed:', error);
return null;
}
}
return null;
};
/**
* 检查存储空间是否充足
* @param {number} requiredSpace - 需要的存储空间(字节)
* @returns {Promise<boolean>} 是否有足够存储空间
*/
export const hasSufficientStorage = async (requiredSpace = 10 * 1024 * 1024) => { // 默认10MB
const storageInfo = await getStorageQuota();
if (!storageInfo) {
// 无法获取存储信息时假设空间充足
return true;
}
return (storageInfo.quota - storageInfo.usage) > requiredSpace;
};
/**
* 离线数据操作队列
*/
export class OfflineQueue {
constructor() {
this.queue = JSON.parse(localStorage.getItem('offlineQueue') || '[]');
}
/**
* 添加操作到离线队列
* @param {string} type - 操作类型 (add, update, delete)
* @param {string} storeName - 存储名
* @param {Object} data - 数据
*/
addOperation(type, storeName, data) {
const operation = {
id: Date.now() + Math.random(),
type,
storeName,
data,
timestamp: Date.now(),
};
this.queue.push(operation);
this.saveQueue();
}
/**
* 获取所有待处理的操作
* @returns {Array} 操作队列
*/
getOperations() {
return this.queue;
}
/**
* 处理操作队列
* @param {Function} processFn - 处理函数
*/
async processQueue(processFn) {
for (const operation of [...this.queue]) {
try {
await processFn(operation);
// 从队列中移除已处理的操作
this.queue = this.queue.filter(op => op.id !== operation.id);
this.saveQueue();
} catch (error) {
console.error('Failed to process offline operation:', error);
// 如果处理失败,保留操作在队列中以备后续重试
}
}
}
/**
* 保存队列到本地存储
*/
saveQueue() {
localStorage.setItem('offlineQueue', JSON.stringify(this.queue));
}
/**
* 清空队列
*/
clear() {
this.queue = [];
this.saveQueue();
}
}

View File

@@ -1,106 +1,85 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/
// https://vitejs.dev/config/ export default defineConfig(({ mode }) => {
export default defineConfig(({ mode }) => { const isPwaMode = mode === 'pwa'
const isPwaMode = mode === 'pwa' const plugins = [vue()]
// 只在PWA模式下添加PWA插件
const plugins = [vue()] if (isPwaMode) {
plugins.push(
// 只在PWA模式下添加PWA插件 VitePWA({
if (isPwaMode) { strategies: 'generateSW',
plugins.push( registerType: 'autoUpdate',
VitePWA({ devOptions: {
registerType: 'autoUpdate', enabled: false,
devOptions: { type: 'module',
enabled: false, },
}, manifest: {
manifest: { name: '锤子便签',
name: '锤子便签', short_name: '便签',
short_name: '便签', description: '锤子便签(重制版)',
description: '锤子便签(重制版)', theme_color: '#42b883',
theme_color: '#42b883', start_url: '/',
start_url: '/', display: 'standalone',
display: 'standalone', background_color: '#ffffff',
background_color: '#ffffff', icons: [
icons: [ {
{ src: 'icons/icon-192.png',
src: 'icons/icon-192.png', sizes: '192x192',
sizes: '192x192', type: 'image/png',
type: 'image/png', },
}, {
{ src: 'icons/icon-512.png',
src: 'icons/icon-512.png', sizes: '512x512',
sizes: '512x512', type: 'image/png',
type: 'image/png', },
}, ],
], },
}, workbox: {
workbox: { globPatterns: ['**/*.{js,css,html,ico,png,jpg,jpeg,svg,woff,woff2,ttf,eot}'],
globPatterns: ['**/*.{js,css,html,wasm,png,jpg,jpeg,svg,ico}'], // 预缓存我们指定的静态资源
runtimeCaching: [ additionalManifestEntries: [
{ { url: '/', revision: null },
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, { url: '/index.html', revision: null },
handler: 'CacheFirst', { url: '/icons/icon-192.png', revision: null },
options: { { url: '/icons/icon-512.png', revision: null },
cacheName: 'google-fonts-cache', // 添加更多需要预缓存的静态资源
expiration: { { url: '/assets/icons/drawable-xxhdpi/note_background.png', revision: null },
maxEntries: 10, { url: '/assets/icons/drawable-xxhdpi/note_setting_bg.png', revision: null },
maxAgeSeconds: 60 * 60 * 24 * 365, // <== 365 days { url: '/assets/icons/drawable-xxhdpi/action_bar_default.png', revision: null },
}, ],
cacheableResponse: { },
statuses: [0, 200], })
}, )
}, }
}, return {
{ plugins,
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, resolve: {
handler: 'CacheFirst', alias: {
options: { '@': '/src',
cacheName: 'gstatic-fonts-cache', },
expiration: { },
maxEntries: 10, server: {
maxAgeSeconds: 60 * 60 * 24 * 365, // <== 365 days port: 3000,
}, },
cacheableResponse: { build: {
statuses: [0, 200], outDir: isPwaMode ? 'dist/offline' : 'dist/standard',
}, minify: 'terser',
}, terserOptions: {
}, compress: {
], drop_console: true,
}, drop_debugger: true,
}) },
) },
} rollupOptions: {
output: {
return { // 为CSS和JS文件添加哈希后缀
plugins, entryFileNames: 'assets/[name].[hash].js',
resolve: { chunkFileNames: 'assets/[name].[hash].js',
alias: { assetFileNames: 'assets/[name].[hash].[ext]',
'@': '/src', },
}, },
}, },
server: { }
port: 3000, })
},
build: {
outDir: isPwaMode ? 'dist/offline' : 'dist/standard',
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
rollupOptions: {
output: {
// 为CSS和JS文件添加哈希后缀
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]',
},
},
},
}
})