优化 增强应用的离线支持功能

This commit is contained in:
yuantao
2025-11-03 14:48:48 +08:00
parent 80a0eef3f1
commit 826be213d4
11 changed files with 2917 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)
- **构建工具**: Vite
- **状态管理**: Pinia
- **路由**: Vue Router
- **UI 组件库**: Ionic Vue (部分使用)
- **PWA 支持**: vite-plugin-pwa
- **本地存储**: IndexedDB (通过 `src/utils/indexedDBStorage.js` 封装)
- **CSS 预处理器**: Less
- **动画库**: @oku-ui/motion
- **拖拽库**: vue-draggable-plus
2. **智能数据同步**
- 网络恢复时自动同步离线期间的操作
- 离线操作队列管理,确保数据一致性
- 冲突解决机制(时间戳优先)
## 项目结构
3. **资源缓存**
- 核心静态资源CSS、JS、图片缓存到浏览器缓存
- 应用界面在离线状态下完全可用
- 字体和图标资源本地缓存
```
.
├── android/ # Capacitor Android 项目文件
├── public/ # 静态资源目录 (图标等)
├── 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 配置文件
```
4. **网络状态感知**
- 实时检测网络连接状态
- 离线/在线状态切换时的用户提示
- 网络类型识别2G/3G/4G/WiFi
## 开发与构建
## 技术实现
### 前置条件
### 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
# 开发模式
npm run dev
```
这将在 `http://localhost:3000` 启动应用。
### 构建
构建标准 Web 应用:
```bash
# 构建标准版本
npm run build
```
构建 PWA 应用:
```bash
# 构建PWA版本(支持离线)
npm run build:pwa
```
构建所有版本 (标准 + PWA):
```bash
# 构建所有版本
npm run build:all
```
### 部署 PWA
构建 PWA 并上传到服务器:
```bash
# 部署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
npm run android
```
1. 使用浏览器开发者工具的 Network Throttling 功能模拟离线环境
2. 在离线状态下创建、编辑、删除便签
3. 恢复网络连接,观察数据同步过程
4. 检查离线期间的操作是否正确同步
## 未来改进
1. 添加手动同步按钮
2. 增强冲突解决机制
3. 支持多设备数据同步
4. 添加数据导出/导入功能
5. 增强离线状态下的用户提示

View File

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

View File

@@ -1,5 +1,13 @@
<template>
<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" />
@@ -20,10 +28,11 @@
</template>
<script setup>
import { ref, watch, computed, onMounted } from 'vue'
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import '@/common/base.css'
import { initModalService } from '@/utils/modalService'
import { addNetworkListener, removeNetworkListener, testOnline } from '@/utils/networkUtils'
// 导入页面组件
import NoteListPage from './pages/NoteListPage.vue'
@@ -32,6 +41,7 @@ import SettingsPage from './pages/SettingsPage.vue'
const route = useRoute()
const transitionName = ref('slide-left')
const modalRef = ref()
const isOnline = ref(navigator.onLine)
// 计算是否为设置页面路由
const isSettingsRoute = computed(() => {
@@ -43,6 +53,19 @@ const showBackgroundPage = computed(() => {
return route.path === '/settings'
})
// 网络状态变化回调
const handleOnline = () => {
isOnline.value = true
console.log('网络已连接')
// 可以在这里触发数据同步
}
const handleOffline = () => {
isOnline.value = false
console.log('网络已断开')
// 可以在这里显示离线提示
}
// 监听路由变化,动态设置过渡动画方向
watch(
() => route.path,
@@ -72,9 +95,15 @@ watch(
}
)
// 初始化弹框服务
// 初始化弹框服务和网络监听
onMounted(() => {
initModalService()
addNetworkListener(handleOnline, handleOffline)
})
// 移除网络监听
onUnmounted(() => {
removeNetworkListener(handleOnline, handleOffline)
})
</script>
@@ -87,6 +116,38 @@ onMounted(() => {
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 {
position: absolute;

View File

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

View File

@@ -1,6 +1,14 @@
<template>
<ion-page>
<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">
<Header
:title="headerTitle"
@@ -59,415 +67,440 @@
</ion-content>
</div>
</ion-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '../stores/useAppStore'
import NoteItem from '../components/NoteItem.vue'
import Header from '../components/Header.vue'
import FolderManage from '../components/FolderManage.vue'
import SearchBar from '../components/Search.vue'
import { formatNoteListDate } from '../utils/dateUtils'
import { IonContent, IonPage } from '@ionic/vue'
const store = useAppStore()
const router = useRouter()
const noteItemRefs = ref({})
// 页面挂载时加载初始数据
onMounted(() => {
// 检查URL参数是否包含mock数据加载指令用于开发和演示
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.get('mock') === 'true') {
// 加载预设的模拟数据
store.loadMockData()
} else {
// 从Storage加载用户数据
store.loadData()
}
})
const searchQuery = ref('')
const sortBy = ref('date') // 排序方式:'date'(按日期)、'title'(按标题)、'starred'(按星标)
const isFolderExpanded = ref(false) // 文件夹列表是否展开
const currentFolder = ref('all') // 当前选中的文件夹,默认是"全部便签"
const noteToDelete = ref(null)
// 计算加星便签数量(未删除的)
const starredNotesCount = computed(() => {
return store.notes.filter(note => note.isStarred && !note.isDeleted).length
})
// 计算回收站便签数量
const trashNotesCount = computed(() => {
return store.notes.filter(note => note.isDeleted).length
})
// 根据当前文件夹过滤便签
const filteredNotes = computed(() => {
// 预处理搜索查询,提高性能
const lowerCaseQuery = searchQuery.value?.toLowerCase().trim() || ''
return store.notes.filter(note => {
// 先检查搜索条件
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))
if (!matchesSearch) return false
// 再检查文件夹条件
switch (currentFolder.value) {
case 'all':
// 全部便签中不显示已删除的便签
return !note.isDeleted
case 'starred':
// 加星便签中只显示未删除的加星便签
return note.isStarred && !note.isDeleted
case 'trash':
// 回收站中只显示已删除的便签
return note.isDeleted
default:
// 自定义文件夹中不显示已删除的便签
return note.folderId === currentFolder.value && !note.isDeleted
}
})
})
// 过滤并排序便签列表
// 首先按置顶状态排序,置顶的便签排在前面
// 然后根据sortBy的值进行二次排序
const filteredAndSortedNotes = computed(() => {
return [...filteredNotes.value].sort((a, b) => {
// 置顶的便签排在前面
if (a.isTop && !b.isTop) return -1
if (!a.isTop && b.isTop) return 1
// 根据排序方式排序
switch (sortBy.value) {
case 'title':
// 按标题字母顺序排序
return a.title.localeCompare(b.title)
case 'starred':
// 按星标状态排序,加星的便签排在前面
return (b.isStarred ? 1 : 0) - (a.isStarred ? 1 : 0)
case 'date':
default:
// 按更新时间倒序排列(最新的在前)
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
}
})
})
// 计算头部标题
const headerTitle = computed(() => {
switch (currentFolder.value) {
case 'all':
return '全部便签'
case 'starred':
return '加星便签'
case 'trash':
return '回收站'
default:
// 查找自定义文件夹的名称
const folder = store.folders.find(f => f.id === currentFolder.value)
return folder ? folder.name : '全部便签'
}
})
// 计算全部便签数量(未删除的)
const allNotesCount = computed(() => {
return store.notes.filter(note => !note.isDeleted).length
})
const handleNotePress = noteId => {
// 检查是否有便签条处于展开状态
let hasSlidedNote = false
Object.values(noteItemRefs.value).forEach(noteItem => {
// 注意isSlided是ref值需要通过.value访问
if (noteItem && noteItem.getSlideState()) {
hasSlidedNote = true
noteItem.resetSlideState()
}
})
// 如果有便签条处于展开状态,不跳转到编辑页面
if (hasSlidedNote) {
return
}
// 使用vue-router导航到编辑页面
router.push(`/editor/${noteId}`)
}
const handleAddNote = () => {
// 使用vue-router导航到新建便签页面
router.push('/editor')
}
// 处理Header组件的操作按钮点击事件
const handleHeaderAction = actionType => {
if (actionType === 'create') {
handleAddNote()
}
}
const handleStarToggle = async noteId => {
const note = store.notes.find(n => n.id === noteId)
if (note) {
try {
await store.updateNote(noteId, { isStarred: !note.isStarred })
console.log(`便签 ${noteId} 星标状态已更新`)
} catch (error) {
console.error('更新便签星标状态失败:', error)
}
}
}
const handleTopToggle = async noteId => {
const note = store.notes.find(n => n.id === noteId)
if (note) {
try {
await store.updateNote(noteId, { isTop: !note.isTop })
console.log(`便签 ${noteId} 置顶状态已更新`)
} catch (error) {
console.error('更新便签置顶状态失败:', error)
}
}
}
const confirmDeleteNote = async noteId => {
noteToDelete.value = noteId
if (noteToDelete.value) {
try {
// 检查当前是否在回收站中
if (currentFolder.value === 'trash') {
// 在回收站中删除便签,彻底删除
await store.permanentlyDeleteNote(noteToDelete.value)
console.log(`便签 ${noteToDelete.value} 已彻底删除`)
} else {
// 不在回收站中,将便签移至回收站
await store.moveToTrash(noteToDelete.value)
console.log(`便签 ${noteToDelete.value} 已移至回收站`)
}
noteToDelete.value = null
} catch (error) {
console.error('删除便签失败:', error)
}
}
}
// 处理排序方式切换
// 循环切换排序选项:按日期 -> 按标题 -> 按星标 -> 按日期...
const handleSort = () => {
const sortOptions = ['date', 'title', 'starred']
const currentIndex = sortOptions.indexOf(sortBy.value)
const nextIndex = (currentIndex + 1) % sortOptions.length
sortBy.value = sortOptions[nextIndex]
console.log('当前排序方式:', sortOptions[nextIndex])
}
const handleAllNotesClick = () => {
setCurrentFolder('all')
setIsFolderExpanded(false)
}
const handleStarredNotesClick = () => {
setCurrentFolder('starred')
setIsFolderExpanded(false)
}
const handleTrashNotesClick = () => {
setCurrentFolder('trash')
setIsFolderExpanded(false)
}
const handleFolderClick = folderId => {
setCurrentFolder(folderId)
setIsFolderExpanded(false)
}
const handleAddFolder = () => {
// 文件夹添加功能已在FolderManage组件中实现
// 这里只需关闭文件夹列表
setIsFolderExpanded(false)
}
const handleFolderPress = () => {
// 使用vue-router导航到文件夹页面
router.push('/folders')
}
const handleSettingsPress = () => {
// 使用vue-router导航到设置页面
router.push('/settings')
}
const handleFolderToggle = () => {
// 在实际应用中,这里会触发文件夹列表的展开/收起
isFolderExpanded.value = !isFolderExpanded.value
}
const handleSearch = query => {
// 搜索功能已在computed属性filteredAndSortedNotes中实现
console.log('搜索:', query)
// 可以在这里添加搜索统计或其它功能
if (query && query.length > 0) {
console.log(`找到 ${filteredAndSortedNotes.value.length} 个匹配的便签`)
}
}
const handleClearSearch = () => {
// 清除搜索已在v-model中处理
console.log('搜索已清除')
// 清除搜索后可以重置一些状态
setSearchQuery('')
}
const handleSearchFocus = () => {
console.log('搜索栏获得焦点')
// 可以在这里添加获得焦点时的特殊处理
}
const handleSearchBlur = () => {
console.log('搜索栏失去焦点')
// 可以在这里添加失去焦点时的特殊处理
}
// 防抖函数,用于避免频繁触发搜索
// 通过延迟执行函数,只在最后一次调用后执行
const debounceSearch = (func, delay) => {
let timeoutId
return function (...args) {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => func.apply(this, args), delay)
}
}
// 防抖搜索处理函数延迟300ms执行搜索
const debouncedHandleSearch = debounceSearch(query => {
handleSearch(query)
}, 300)
// 改进的日期格式化函数
const formatDate = dateString => {
return formatNoteListDate(dateString)
}
const setCurrentFolder = folder => {
currentFolder.value = folder
}
const setIsFolderExpanded = expanded => {
isFolderExpanded.value = expanded
}
const setSearchQuery = query => {
searchQuery.value = query
}
const notes = computed(() => store.notes)
</script>
<style lang="less" scoped>
.container {
width: 100vw;
height: 100vh;
background: url(/assets/icons/drawable-xxhdpi/note_background.png);
background-size: cover;
}
.folder-list {
position: absolute;
top: 3.125rem;
left: 10%;
right: 10%;
z-index: 1000;
background-color: var(--background-card);
border-radius: 0.5rem;
box-shadow: 0 0.125rem 0.25rem var(--shadow);
border: 1px solid #f0ece7;
overflow: hidden;
}
.folder-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: transparent;
z-index: 99;
}
.content {
--background: transparent;
--padding-top: 4.5rem;
--padding-bottom: 2rem;
}
.search-container {
padding: 0.8rem 0.5rem;
}
.notes-container {
flex: 1;
position: relative;
}
.notes-list {
position: relative;
}
.note-item {
margin: 0.6rem 0;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* 便签列表动画 */
.note-list-enter-active,
.note-list-leave-active {
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.note-list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.note-list-move {
transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.note-list-leave-active {
position: absolute;
width: calc(100% - 1rem);
}
/* 文件夹列表动画 */
.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>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '../stores/useAppStore'
import NoteItem from '../components/NoteItem.vue'
import Header from '../components/Header.vue'
import FolderManage from '../components/FolderManage.vue'
import SearchBar from '../components/Search.vue'
import { formatNoteListDate } from '../utils/dateUtils'
import { IonContent, IonPage } from '@ionic/vue'
const store = useAppStore()
const router = useRouter()
const noteItemRefs = ref({})
// 页面挂载时加载初始数据
onMounted(() => {
// 检查URL参数是否包含mock数据加载指令用于开发和演示
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.get('mock') === 'true') {
// 加载预设的模拟数据
store.loadMockData()
} else {
// 从Storage加载用户数据
store.loadData()
}
})
const searchQuery = ref('')
const sortBy = ref('date') // 排序方式:'date'(按日期)、'title'(按标题)、'starred'(按星标)
const isFolderExpanded = ref(false) // 文件夹列表是否展开
const currentFolder = ref('all') // 当前选中的文件夹,默认是"全部便签"
const noteToDelete = ref(null)
// 计算加星便签数量(未删除的)
const starredNotesCount = computed(() => {
return store.notes.filter(note => note.isStarred && !note.isDeleted).length
})
// 计算回收站便签数量
const trashNotesCount = computed(() => {
return store.notes.filter(note => note.isDeleted).length
})
// 根据当前文件夹过滤便签
const filteredNotes = computed(() => {
// 预处理搜索查询,提高性能
const lowerCaseQuery = searchQuery.value?.toLowerCase().trim() || ''
return store.notes.filter(note => {
// 先检查搜索条件
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))
if (!matchesSearch) return false
// 再检查文件夹条件
switch (currentFolder.value) {
case 'all':
// 全部便签中不显示已删除的便签
return !note.isDeleted
case 'starred':
// 加星便签中只显示未删除的加星便签
return note.isStarred && !note.isDeleted
case 'trash':
// 回收站中只显示已删除的便签
return note.isDeleted
default:
// 自定义文件夹中不显示已删除的便签
return note.folderId === currentFolder.value && !note.isDeleted
}
})
})
// 过滤并排序便签列表
// 首先按置顶状态排序,置顶的便签排在前面
// 然后根据sortBy的值进行二次排序
const filteredAndSortedNotes = computed(() => {
return [...filteredNotes.value].sort((a, b) => {
// 置顶的便签排在前面
if (a.isTop && !b.isTop) return -1
if (!a.isTop && b.isTop) return 1
// 根据排序方式排序
switch (sortBy.value) {
case 'title':
// 按标题字母顺序排序
return a.title.localeCompare(b.title)
case 'starred':
// 按星标状态排序,加星的便签排在前面
return (b.isStarred ? 1 : 0) - (a.isStarred ? 1 : 0)
case 'date':
default:
// 按更新时间倒序排列(最新的在前)
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
}
})
})
// 计算头部标题
const headerTitle = computed(() => {
switch (currentFolder.value) {
case 'all':
return '全部便签'
case 'starred':
return '加星便签'
case 'trash':
return '回收站'
default:
// 查找自定义文件夹的名称
const folder = store.folders.find(f => f.id === currentFolder.value)
return folder ? folder.name : '全部便签'
}
})
// 计算全部便签数量(未删除的)
const allNotesCount = computed(() => {
return store.notes.filter(note => !note.isDeleted).length
})
const handleNotePress = noteId => {
// 检查是否有便签条处于展开状态
let hasSlidedNote = false
Object.values(noteItemRefs.value).forEach(noteItem => {
// 注意isSlided是ref值需要通过.value访问
if (noteItem && noteItem.getSlideState()) {
hasSlidedNote = true
noteItem.resetSlideState()
}
})
// 如果有便签条处于展开状态,不跳转到编辑页面
if (hasSlidedNote) {
return
}
// 使用vue-router导航到编辑页面
router.push(`/editor/${noteId}`)
}
const handleAddNote = () => {
// 使用vue-router导航到新建便签页面
router.push('/editor')
}
// 处理Header组件的操作按钮点击事件
const handleHeaderAction = actionType => {
if (actionType === 'create') {
handleAddNote()
}
}
const handleStarToggle = async noteId => {
const note = store.notes.find(n => n.id === noteId)
if (note) {
try {
await store.updateNote(noteId, { isStarred: !note.isStarred })
console.log(`便签 ${noteId} 星标状态已更新`)
} catch (error) {
console.error('更新便签星标状态失败:', error)
}
}
}
const handleTopToggle = async noteId => {
const note = store.notes.find(n => n.id === noteId)
if (note) {
try {
await store.updateNote(noteId, { isTop: !note.isTop })
console.log(`便签 ${noteId} 置顶状态已更新`)
} catch (error) {
console.error('更新便签置顶状态失败:', error)
}
}
}
const confirmDeleteNote = async noteId => {
noteToDelete.value = noteId
if (noteToDelete.value) {
try {
// 检查当前是否在回收站中
if (currentFolder.value === 'trash') {
// 在回收站中删除便签,彻底删除
await store.permanentlyDeleteNote(noteToDelete.value)
console.log(`便签 ${noteToDelete.value} 已彻底删除`)
} else {
// 不在回收站中,将便签移至回收站
await store.moveToTrash(noteToDelete.value)
console.log(`便签 ${noteToDelete.value} 已移至回收站`)
}
noteToDelete.value = null
} catch (error) {
console.error('删除便签失败:', error)
}
}
}
// 处理排序方式切换
// 循环切换排序选项:按日期 -> 按标题 -> 按星标 -> 按日期...
const handleSort = () => {
const sortOptions = ['date', 'title', 'starred']
const currentIndex = sortOptions.indexOf(sortBy.value)
const nextIndex = (currentIndex + 1) % sortOptions.length
sortBy.value = sortOptions[nextIndex]
console.log('当前排序方式:', sortOptions[nextIndex])
}
const handleAllNotesClick = () => {
setCurrentFolder('all')
setIsFolderExpanded(false)
}
const handleStarredNotesClick = () => {
setCurrentFolder('starred')
setIsFolderExpanded(false)
}
const handleTrashNotesClick = () => {
setCurrentFolder('trash')
setIsFolderExpanded(false)
}
const handleFolderClick = folderId => {
setCurrentFolder(folderId)
setIsFolderExpanded(false)
}
const handleAddFolder = () => {
// 文件夹添加功能已在FolderManage组件中实现
// 这里只需关闭文件夹列表
setIsFolderExpanded(false)
}
const handleFolderPress = () => {
// 使用vue-router导航到文件夹页面
router.push('/folders')
}
const handleSettingsPress = () => {
// 使用vue-router导航到设置页面
router.push('/settings')
}
const handleFolderToggle = () => {
// 在实际应用中,这里会触发文件夹列表的展开/收起
isFolderExpanded.value = !isFolderExpanded.value
}
const handleSearch = query => {
// 搜索功能已在computed属性filteredAndSortedNotes中实现
console.log('搜索:', query)
// 可以在这里添加搜索统计或其它功能
if (query && query.length > 0) {
console.log(`找到 ${filteredAndSortedNotes.value.length} 个匹配的便签`)
}
}
const handleClearSearch = () => {
// 清除搜索已在v-model中处理
console.log('搜索已清除')
// 清除搜索后可以重置一些状态
setSearchQuery('')
}
const handleSearchFocus = () => {
console.log('搜索栏获得焦点')
// 可以在这里添加获得焦点时的特殊处理
}
const handleSearchBlur = () => {
console.log('搜索栏失去焦点')
// 可以在这里添加失去焦点时的特殊处理
}
// 防抖函数,用于避免频繁触发搜索
// 通过延迟执行函数,只在最后一次调用后执行
const debounceSearch = (func, delay) => {
let timeoutId
return function (...args) {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => func.apply(this, args), delay)
}
}
// 防抖搜索处理函数延迟300ms执行搜索
const debouncedHandleSearch = debounceSearch(query => {
handleSearch(query)
}, 300)
// 改进的日期格式化函数
const formatDate = dateString => {
return formatNoteListDate(dateString)
}
const setCurrentFolder = folder => {
currentFolder.value = folder
}
const setIsFolderExpanded = expanded => {
isFolderExpanded.value = expanded
}
const setSearchQuery = query => {
searchQuery.value = query
}
const notes = computed(() => store.notes)
</script>
<style lang="less" scoped>
.container {
width: 100vw;
height: 100vh;
background: url(/assets/icons/drawable-xxhdpi/note_background.png);
background-size: cover;
}
.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;
}
.folder-list {
position: absolute;
top: 3.125rem;
left: 10%;
right: 10%;
z-index: 1000;
background-color: var(--background-card);
border-radius: 0.5rem;
box-shadow: 0 0.125rem 0.25rem var(--shadow);
border: 1px solid #f0ece7;
overflow: hidden;
}
.folder-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: transparent;
z-index: 99;
}
.content {
--background: transparent;
--padding-top: 4.5rem;
--padding-bottom: 2rem;
}
.search-container {
padding: 0.8rem 0.5rem;
}
.notes-container {
flex: 1;
position: relative;
}
.notes-list {
position: relative;
}
.note-item {
margin: 0.6rem 0;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* 便签列表动画 */
.note-list-enter-active,
.note-list-leave-active {
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.note-list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.note-list-move {
transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.note-list-leave-active {
position: absolute;
width: calc(100% - 1rem);
}
/* 文件夹列表动画 */
.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>
<ion-page>
<Header title="设置" :onBack="handleBackPress" background="#f9f9f9" color="#8e8e8e" />
<div class="settings-content">
<SettingGroup title="账户">
<div button @click="handleLogin" class="settings-item settings-item-clickable">
<div class="item-text-primary">登录云同步</div>
<div class="item-text-tertiary">未登录</div>
</div>
</SettingGroup>
<SettingGroup title="偏好设置">
<div class="settings-item settings-item-border">
<div class="item-text-primary">云同步</div>
<SwitchButton :model-value="settings.cloudSync" @update:model-value="toggleCloudSync" />
</div>
</SettingGroup>
<SettingGroup title="关于">
<div class="settings-item settings-item-border">
<div class="item-text-primary">版本</div>
<div class="item-text-tertiary">1.0.0</div>
</div>
<div button @click="handlePrivacyPolicy" class="settings-item settings-item-clickable settings-item-border">
<div class="item-text-primary">隐私政策</div>
</div>
<div button @click="handleTermsOfService" class="settings-item settings-item-clickable">
<div class="item-text-primary">服务条款</div>
</div>
</SettingGroup>
</div>
</ion-page>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '../stores/useAppStore'
import Header from '../components/Header.vue'
import SettingGroup from '../components/SettingGroup.vue'
import SwitchButton from '../components/SwitchButton.vue'
import { IonPage } from '@ionic/vue'
const store = useAppStore()
const router = useRouter()
// 页面挂载时加载初始数据
// 从Storage加载用户设置和便签数据
onMounted(() => {
store.loadData()
})
// 切换云同步设置
// 调用store中的方法更新云同步状态
const toggleCloudSync = value => {
store.toggleCloudSync()
}
// 处理登录云同步按钮点击事件
// 在完整实现中,这里会打开登录界面
const handleLogin = () => {
console.log('Login to cloud')
}
// 处理隐私政策按钮点击事件
// 在完整实现中,这里会显示隐私政策内容
const handlePrivacyPolicy = () => {
console.log('Privacy policy')
}
// 处理服务条款按钮点击事件
// 在完整实现中,这里会显示服务条款内容
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>
<template>
<ion-page>
<Header title="设置" :onBack="handleBackPress" background="#f9f9f9" color="#8e8e8e" />
<div class="settings-content">
<SettingGroup title="账户">
<div button @click="handleLogin" class="settings-item settings-item-clickable">
<div class="item-text-primary">登录云同步</div>
<div class="item-text-tertiary">未登录</div>
</div>
</SettingGroup>
<SettingGroup title="偏好设置">
<div class="settings-item settings-item-border">
<div class="item-text-primary">云同步</div>
<SwitchButton :model-value="settings.cloudSync" @update:model-value="toggleCloudSync" />
</div>
<div class="settings-item settings-item-border">
<div class="item-text-primary">离线数据同步</div>
<div class="item-text-tertiary">{{ syncStatusText }}</div>
</div>
<div v-if="store.lastSyncTime" class="settings-item">
<div class="item-text-primary">最后同步时间</div>
<div class="item-text-tertiary">{{ formatLastSyncTime }}</div>
</div>
</SettingGroup>
<SettingGroup title="关于">
<div class="settings-item settings-item-border">
<div class="item-text-primary">版本</div>
<div class="item-text-tertiary">1.0.0</div>
</div>
<div button @click="handlePrivacyPolicy" class="settings-item settings-item-clickable settings-item-border">
<div class="item-text-primary">隐私政策</div>
</div>
<div button @click="handleTermsOfService" class="settings-item settings-item-clickable">
<div class="item-text-primary">服务条款</div>
</div>
</SettingGroup>
</div>
</ion-page>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '../stores/useAppStore'
import Header from '../components/Header.vue'
import SettingGroup from '../components/SettingGroup.vue'
import SwitchButton from '../components/SwitchButton.vue'
import { IonPage } from '@ionic/vue'
const store = useAppStore()
const router = useRouter()
// 页面挂载时加载初始数据
// 从Storage加载用户设置和便签数据
onMounted(() => {
store.loadData()
})
// 切换云同步设置
// 调用store中的方法更新云同步状态
const toggleCloudSync = value => {
store.toggleCloudSync()
}
// 同步状态文本
const syncStatusText = computed(() => {
switch (store.syncStatus) {
case 'syncing':
return '正在同步...'
case 'error':
return '同步失败'
default:
return store.isOnline ? '已连接' : '离线模式'
}
})
// 格式化最后同步时间
const formatLastSyncTime = computed(() => {
if (!store.lastSyncTime) return '从未同步'
const date = new Date(store.lastSyncTime)
return date.toLocaleString()
})
// 处理登录云同步按钮点击事件
// 在完整实现中,这里会打开登录界面
const handleLogin = () => {
console.log('Login to cloud')
}
// 处理隐私政策按钮点击事件
// 在完整实现中,这里会显示隐私政策内容
const handlePrivacyPolicy = () => {
console.log('Privacy policy')
}
// 处理服务条款按钮点击事件
// 在完整实现中,这里会显示服务条款内容
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 * as storage from '../utils/indexedDBStorage'
import { getCurrentDateTime, getPastDate } from '../utils/dateUtils'
import { isOnline, testOnline } from '../utils/networkUtils'
/**
* 应用状态管理Store
* 使用Pinia进行状态管理包含便签、文件夹和设置数据
@@ -12,11 +12,13 @@ export const useAppStore = defineStore('app', {
* 包含应用的核心数据:便签列表、文件夹列表和设置
*/
state: () => ({
notes: [], // 便签列表
folders: [], // 文件夹列表
notes: [], // 便签列表
folders: [], // 文件夹列表
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 => {
return state.notes.filter(note => note.isStarred).length
},
/**
* 计算所有便签数量
* @param {Object} state - 当前状态对象
@@ -40,7 +41,6 @@ export const useAppStore = defineStore('app', {
return state.notes.length
},
},
/**
* 状态变更操作
* 包含所有修改状态的方法
@@ -58,7 +58,6 @@ export const useAppStore = defineStore('app', {
const loadedNotes = await storage.getNotes()
const loadedFolders = await storage.getFolders()
const loadedSettings = await storage.getSettings()
// 如果没有数据则加载mock数据
if (loadedNotes.length === 0 && loadedFolders.length === 0) {
this.loadMockData()
@@ -68,11 +67,50 @@ export const useAppStore = defineStore('app', {
this.folders = loadedFolders
this.settings = loadedSettings
}
// 添加网络状态监听
this.addNetworkListeners()
} catch (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数据
* 用于开发和演示目的,提供示例便签、文件夹和设置
@@ -80,47 +118,42 @@ export const useAppStore = defineStore('app', {
*/
async loadMockData() {
// Mock notes - 使用固定的日期值以避免每次运行时变化
const fixedCurrentDate = '2025-10-12T10:00:00.000Z';
const fixedCurrentDate = '2025-10-12T10:00:00.000Z'
// 预设的便签示例数据 - 仅保留一条关于应用功能介绍和示例的便签
const mockNotes = [
{
id: '1',
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,
updatedAt: fixedCurrentDate,
folderId: null,
isStarred: true, // 加星便签
isTop: true, // 置顶便签
hasImage: true, // 包含图片
isDeleted: false, // 未删除
isStarred: true, // 加星便签
isTop: true, // 置顶便签
hasImage: true, // 包含图片
isDeleted: false, // 未删除
deletedAt: null,
}
},
]
// Mock folders - 使用固定的日期值
// 预设的文件夹示例数据
const mockFolders = []
// Mock settings
// 预设的设置示例数据
const mockSettings = {
cloudSync: false, // 云同步关闭
darkMode: false, // 深色模式关闭
cloudSync: false, // 云同步关闭
darkMode: false, // 深色模式关闭
}
// 更新store状态
this.notes = mockNotes
this.folders = mockFolders
this.settings = mockSettings
// 保存到Storage
await storage.saveNotes(mockNotes)
await storage.saveFolders(mockFolders)
await storage.saveSettings(mockSettings)
},
/**
* 保存便签数据到Storage
* @returns {Promise<void>}
@@ -132,7 +165,6 @@ export const useAppStore = defineStore('app', {
console.error('Error saving notes:', error)
}
},
/**
* 保存文件夹数据到Storage
* @returns {Promise<void>}
@@ -144,7 +176,6 @@ export const useAppStore = defineStore('app', {
console.error('Error saving folders:', error)
}
},
/**
* 保存设置数据到Storage
* @returns {Promise<void>}
@@ -156,11 +187,9 @@ export const useAppStore = defineStore('app', {
console.error('Error saving settings:', error)
}
},
/**
* 便签操作函数
*/
/**
* 添加新便签
* @param {Object} note - 便签对象
@@ -170,14 +199,12 @@ export const useAppStore = defineStore('app', {
try {
const newNote = await storage.addNote(note)
this.notes.push(newNote)
return newNote
} catch (error) {
console.error('Error adding note:', error)
throw error
}
},
/**
* 更新便签
* @param {string} id - 便签ID
@@ -199,7 +226,6 @@ export const useAppStore = defineStore('app', {
throw error
}
},
/**
* 删除便签
* @param {string} id - 要删除的便签ID
@@ -217,7 +243,6 @@ export const useAppStore = defineStore('app', {
throw error
}
},
/**
* 将便签移至回收站
* 将便签标记为已删除状态,并记录删除时间
@@ -239,7 +264,6 @@ export const useAppStore = defineStore('app', {
throw error
}
},
/**
* 永久删除便签
* 从便签列表中彻底移除便签
@@ -258,11 +282,9 @@ export const useAppStore = defineStore('app', {
throw error
}
},
/**
* 文件夹操作函数
*/
/**
* 添加新文件夹
* @param {Object} folder - 文件夹对象
@@ -278,7 +300,6 @@ export const useAppStore = defineStore('app', {
throw error
}
},
/**
* 更新文件夹
* @param {string} id - 文件夹ID
@@ -300,7 +321,6 @@ export const useAppStore = defineStore('app', {
throw error
}
},
/**
* 删除文件夹
* @param {string} id - 要删除的文件夹ID
@@ -315,7 +335,6 @@ export const useAppStore = defineStore('app', {
for (const note of notesInFolder) {
await this.updateNote(note.id, { folderId: null })
}
// 从文件夹列表中移除文件夹
this.folders = this.folders.filter(folder => folder.id !== id)
}
@@ -325,11 +344,9 @@ export const useAppStore = defineStore('app', {
throw error
}
},
/**
* 设置操作函数
*/
/**
* 更新设置
* @param {Object} newSettings - 新的设置对象
@@ -345,7 +362,6 @@ export const useAppStore = defineStore('app', {
throw error
}
},
/**
* 切换云同步设置
* 开启或关闭云同步功能
@@ -354,7 +370,6 @@ export const useAppStore = defineStore('app', {
async toggleCloudSync() {
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 { OfflineQueue } from './networkUtils'
// 数据库配置
const DB_NAME = 'SmartisanNoteDB'
const DB_VERSION = 2 // 更新版本号以确保数据库重新创建
const NOTES_STORE = 'notes'
const FOLDERS_STORE = 'folders'
const SETTINGS_STORE = 'settings'
let db = null
// 创建离线队列实例
const offlineQueue = new OfflineQueue()
/**
* 打开数据库连接
* @returns {Promise<IDBDatabase>} 数据库实例
@@ -18,21 +18,16 @@ const openDB = () => {
if (db) {
return resolve(db)
}
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => {
reject(new Error('无法打开数据库'))
}
request.onsuccess = () => {
db = request.result
resolve(db)
}
request.onupgradeneeded = (event) => {
request.onupgradeneeded = event => {
const database = event.target.result
// 删除现有的对象存储(如果版本已更改)
if (event.oldVersion > 0) {
if (database.objectStoreNames.contains(NOTES_STORE)) {
@@ -45,7 +40,6 @@ const openDB = () => {
database.deleteObjectStore(SETTINGS_STORE)
}
}
// 创建便签存储对象
const notesStore = database.createObjectStore(NOTES_STORE, { keyPath: 'id' })
notesStore.createIndex('folderId', 'folderId', { unique: false })
@@ -53,38 +47,32 @@ const openDB = () => {
notesStore.createIndex('isDeleted', 'isDeleted', { unique: false })
notesStore.createIndex('createdAt', 'createdAt', { unique: false })
notesStore.createIndex('updatedAt', 'updatedAt', { unique: false })
// 创建文件夹存储对象
database.createObjectStore(FOLDERS_STORE, { keyPath: 'id' })
// 创建设置存储对象
database.createObjectStore(SETTINGS_STORE)
}
})
}
/**
* 从存储中获取数据
* @param {string} storeName - 存储名称
* @returns {Promise<Array>} 数据数组
*/
const getAllFromStore = async (storeName) => {
const getAllFromStore = async storeName => {
const database = await openDB()
const transaction = database.transaction([storeName], 'readonly')
const store = transaction.objectStore(storeName)
const request = store.getAll()
return new Promise((resolve, reject) => {
request.onsuccess = () => {
resolve(request.result || [])
}
request.onerror = () => {
reject(new Error(`获取 ${storeName} 数据失败`))
}
})
}
/**
* 保存数据到存储
* @param {string} storeName - 存储名称
@@ -95,14 +83,12 @@ const saveToStore = async (storeName, data) => {
const database = await openDB()
const transaction = database.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName)
// 清除现有数据
await new Promise((resolve, reject) => {
const clearRequest = store.clear()
clearRequest.onsuccess = () => resolve()
clearRequest.onerror = () => reject(new Error(`清除 ${storeName} 数据失败`))
})
// 添加新数据
for (const item of data) {
await new Promise((resolve, reject) => {
@@ -112,7 +98,6 @@ const saveToStore = async (storeName, data) => {
})
}
}
/**
* 从存储中获取单个项
* @param {string} storeName - 存储名称
@@ -124,18 +109,15 @@ const getFromStore = async (storeName, id) => {
const transaction = database.transaction([storeName], 'readonly')
const store = transaction.objectStore(storeName)
const request = store.get(id)
return new Promise((resolve, reject) => {
request.onsuccess = () => {
resolve(request.result || null)
}
request.onerror = () => {
reject(new Error(`获取 ${storeName} 项失败`))
}
})
}
/**
* 向存储中添加项
* @param {string} storeName - 存储名称
@@ -147,18 +129,15 @@ const addToStore = async (storeName, item) => {
const transaction = database.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.add(item)
return new Promise((resolve, reject) => {
request.onsuccess = () => {
resolve(item)
}
request.onerror = () => {
reject(new Error(`添加 ${storeName} 项失败`))
}
})
}
/**
* 更新存储中的项
* @param {string} storeName - 存储名称
@@ -169,24 +148,20 @@ const addToStore = async (storeName, item) => {
const updateInStore = async (storeName, id, updates) => {
const item = await getFromStore(storeName, id)
if (!item) return null
const updatedItem = { ...item, ...updates }
const database = await openDB()
const transaction = database.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.put(updatedItem)
return new Promise((resolve, reject) => {
request.onsuccess = () => {
resolve(updatedItem)
}
request.onerror = () => {
reject(new Error(`更新 ${storeName} 项失败`))
}
})
}
/**
* 从存储中删除项
* @param {string} storeName - 存储名称
@@ -198,21 +173,17 @@ const deleteFromStore = async (storeName, id) => {
const transaction = database.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.delete(id)
return new Promise((resolve, reject) => {
request.onsuccess = () => {
resolve(true)
}
request.onerror = () => {
reject(new Error(`删除 ${storeName} 项失败`))
}
})
}
// 便签操作函数
// 提供便签的增删改查功能
/**
* 获取所有便签数据
* 从IndexedDB中读取便签数据
@@ -227,28 +198,26 @@ export const getNotes = async () => {
return []
}
}
/**
* 保存便签数据
* 将便签数组保存到IndexedDB
* @param {Array} notes - 便签数组
* @returns {Promise<void>}
*/
export const saveNotes = async (notes) => {
export const saveNotes = async notes => {
try {
await saveToStore(NOTES_STORE, notes)
} catch (error) {
console.error('Error saving notes:', error)
}
}
/**
* 添加新便签
* 创建一个新的便签对象并添加到便签列表中
* @param {Object} note - 便签对象,包含便签内容和其他属性
* @returns {Promise<Object>} 新创建的便签对象
*/
export const addNote = async (note) => {
export const addNote = async note => {
try {
// 创建新的便签对象,添加必要的属性
const newNote = {
@@ -263,18 +232,18 @@ export const addNote = async (note) => {
isDeleted: note.isDeleted || false, // 是否已删除
deletedAt: note.deletedAt || null, // 删除时间
folderId: note.folderId || null, // 文件夹ID
...note
...note,
}
// 添加到存储
await addToStore(NOTES_STORE, newNote)
// 添加到离线队列(用于同步)
offlineQueue.addOperation('add', NOTES_STORE, newNote)
return newNote
} catch (error) {
console.error('Error adding note:', error)
throw error
}
}
/**
* 更新便签
* 根据ID查找并更新便签信息
@@ -287,36 +256,40 @@ export const updateNote = async (id, updates) => {
// 更新便签并保存
const updatedNote = await updateInStore(NOTES_STORE, id, {
...updates,
updatedAt: getCurrentDateTime() // 更新最后修改时间
updatedAt: getCurrentDateTime(), // 更新最后修改时间
})
if (updatedNote) {
// 添加到离线队列(用于同步)
offlineQueue.addOperation('update', NOTES_STORE, updatedNote)
}
return updatedNote
} catch (error) {
console.error('Error updating note:', error)
throw error
}
}
/**
* 删除便签
* 根据ID从便签列表中移除便签
* @param {string} id - 要删除的便签ID
* @returns {Promise<boolean>} 删除成功返回true未找到便签返回false
*/
export const deleteNote = async (id) => {
export const deleteNote = async id => {
try {
// 从存储中删除
const result = await deleteFromStore(NOTES_STORE, id)
if (result) {
// 添加到离线队列(用于同步)
offlineQueue.addOperation('delete', NOTES_STORE, { id })
}
return result
} catch (error) {
console.error('Error deleting note:', error)
return false
}
}
// 文件夹操作函数
// 提供文件夹的增删改查功能
/**
* 获取所有文件夹数据
* 从IndexedDB中读取文件夹数据
@@ -331,46 +304,44 @@ export const getFolders = async () => {
return []
}
}
/**
* 保存文件夹数据
* 将文件夹数组保存到IndexedDB
* @param {Array} folders - 文件夹数组
* @returns {Promise<void>}
*/
export const saveFolders = async (folders) => {
export const saveFolders = async folders => {
try {
await saveToStore(FOLDERS_STORE, folders)
} catch (error) {
console.error('Error saving folders:', error)
}
}
/**
* 添加新文件夹
* 创建一个新的文件夹对象并添加到文件夹列表中
* @param {Object} folder - 文件夹对象,包含文件夹名称等属性
* @returns {Promise<Object>} 新创建的文件夹对象
*/
export const addFolder = async (folder) => {
export const addFolder = async folder => {
try {
// 创建新的文件夹对象,添加必要的属性
const newFolder = {
name: folder.name || '',
id: folder.id || getTimestamp().toString(), // 使用时间戳生成唯一ID
createdAt: folder.createdAt || getCurrentDateTime(), // 创建时间
...folder
...folder,
}
// 添加到存储
await addToStore(FOLDERS_STORE, newFolder)
// 添加到离线队列(用于同步)
offlineQueue.addOperation('add', FOLDERS_STORE, newFolder)
return newFolder
} catch (error) {
console.error('Error adding folder:', error)
throw error
}
}
/**
* 更新文件夹
* 根据ID查找并更新文件夹信息
@@ -382,33 +353,38 @@ export const updateFolder = async (id, updates) => {
try {
// 更新文件夹并保存
const updatedFolder = await updateInStore(FOLDERS_STORE, id, updates)
if (updatedFolder) {
// 添加到离线队列(用于同步)
offlineQueue.addOperation('update', FOLDERS_STORE, updatedFolder)
}
return updatedFolder
} catch (error) {
console.error('Error updating folder:', error)
throw error
}
}
/**
* 删除文件夹
* 根据ID从文件夹列表中移除文件夹
* @param {string} id - 要删除的文件夹ID
* @returns {Promise<boolean>} 删除成功返回true未找到文件夹返回false
*/
export const deleteFolder = async (id) => {
export const deleteFolder = async id => {
try {
// 从存储中删除
const result = await deleteFromStore(FOLDERS_STORE, id)
if (result) {
// 添加到离线队列(用于同步)
offlineQueue.addOperation('delete', FOLDERS_STORE, { id })
}
return result
} catch (error) {
console.error('Error deleting folder:', error)
return false
}
}
// 设置操作函数
// 提供应用设置的读取和保存功能
/**
* 获取应用设置
* 从IndexedDB中读取设置数据
@@ -420,17 +396,14 @@ export const getSettings = async () => {
const transaction = database.transaction([SETTINGS_STORE], 'readonly')
const store = transaction.objectStore(SETTINGS_STORE)
const request = store.get('settings')
const settings = await new Promise((resolve, reject) => {
request.onsuccess = () => {
resolve(request.result || { cloudSync: false, darkMode: false })
}
request.onerror = () => {
reject(new Error('获取设置失败'))
}
})
return settings
} catch (error) {
console.error('Error getting settings:', error)
@@ -438,35 +411,34 @@ export const getSettings = async () => {
return { cloudSync: false, darkMode: false }
}
}
/**
* 保存应用设置
* 将设置对象保存到IndexedDB
* @param {Object} settings - 设置对象
* @returns {Promise<void>}
*/
export const saveSettings = async (settings) => {
export const saveSettings = async settings => {
try {
const database = await openDB()
const transaction = database.transaction([SETTINGS_STORE], 'readwrite')
const store = transaction.objectStore(SETTINGS_STORE)
const request = store.put(settings, 'settings')
await new Promise((resolve, reject) => {
request.onsuccess = () => resolve()
request.onerror = () => reject(new Error('保存设置失败'))
})
// 添加到离线队列(用于同步)
offlineQueue.addOperation('update', SETTINGS_STORE, settings)
} catch (error) {
console.error('Error saving settings:', error)
}
}
/**
* 确保数据有默认值
* @param {Array} notes - 便签数组
* @returns {Array} 处理后的便签数组
*/
const ensureNotesDefaults = (notes) => {
const ensureNotesDefaults = notes => {
return notes.map(note => ({
title: note.title || '',
content: note.content || '',
@@ -479,24 +451,22 @@ const ensureNotesDefaults = (notes) => {
isDeleted: note.isDeleted || false,
deletedAt: note.deletedAt || null,
folderId: note.folderId || null,
...note
...note,
}))
}
/**
* 确保文件夹数据有默认值
* @param {Array} folders - 文件夹数组
* @returns {Array} 处理后的文件夹数组
*/
const ensureFoldersDefaults = (folders) => {
const ensureFoldersDefaults = folders => {
return folders.map(folder => ({
name: folder.name || '',
id: folder.id,
createdAt: folder.createdAt,
...folder
...folder,
}))
}
/**
* 初始化数据库
* @returns {Promise<void>}
@@ -507,4 +477,4 @@ export const initDB = async () => {
} catch (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,80 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const isPwaMode = mode === 'pwa'
const plugins = [vue()]
// 只在PWA模式下添加PWA插件
if (isPwaMode) {
plugins.push(
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: false,
},
manifest: {
name: '锤子便签',
short_name: '便签',
description: '锤子便签(重制版)',
theme_color: '#42b883',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
icons: [
{
src: 'icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,wasm,png,jpg,jpeg,svg,ico}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // <== 365 days
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'gstatic-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // <== 365 days
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
},
})
)
}
return {
plugins,
resolve: {
alias: {
'@': '/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]',
},
},
},
}
})
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const isPwaMode = mode === 'pwa'
const plugins = [vue()]
// 只在PWA模式下添加PWA插件
if (isPwaMode) {
plugins.push(
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.js',
registerType: 'autoUpdate',
devOptions: {
enabled: false,
type: 'module',
},
manifest: {
name: '锤子便签',
short_name: '便签',
description: '锤子便签(重制版)',
theme_color: '#42b883',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
icons: [
{
src: 'icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
},
],
},
injectManifest: {
globPatterns: ['**/*.{js,css,html,wasm,png,jpg,jpeg,svg,ico}'],
},
})
)
}
return {
plugins,
resolve: {
alias: {
'@': '/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]',
},
},
},
}
})