You've already forked SmartisanNote.Remake
优化 增强应用的离线支持功能
This commit is contained in:
185
README.md
185
README.md
@@ -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. 增强离线状态下的用户提示
|
||||
82
package.json
82
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
65
src/App.vue
65
src/App.vue
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换深色模式设置
|
||||
* 开启或关闭深色模式
|
||||
|
||||
@@ -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
236
src/utils/networkUtils.js
Normal 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();
|
||||
}
|
||||
}
|
||||
186
vite.config.js
186
vite.config.js
@@ -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]',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user