用前端技术做个人工具:开发本地图书管理系统(Vue3+IndexedDB)

用前端技术做个人工具:开发本地图书管理系统(Vue3+IndexedDB)

一篇从零到一的实践型文章:用 Vue3 组合式 API 搭配 IndexedDB,打造离线可用、数据本地持久化的本地图书管理系统。适合个人藏书管理、借阅记录和标签分类等场景。

目标与特性

  • 纯前端本地运行,无需后端。
  • 数据存储使用 IndexedDB,支持大数据量与结构化索引。
  • 基础功能:新增/编辑/删除图书,搜索、标签分类,借阅归还。
  • 扩展功能:批量导入导出、封面图片存储、统计视图、离线可用。
  • 技术栈:Vue3 + TypeScript + IndexedDB

技术选型与架构

  • Vue3:组合式 API 更易拆分业务逻辑,便于小型工具维护与扩展。
  • IndexedDB:浏览器原生数据库,支持对象存储、索引、事务;适合离线、本地持久化场景。
  • 架构要点:
    • 数据层:统一的 db.ts 封装,提供 CRUD 与查询接口。
    • 业务层:useBooks.ts 组合式模块,负责状态与交互。
    • UI层:Books.vue 页面组件,包含表单、列表、搜索与导入导出。

数据模型设计

  • books 对象存储:
    • 主键:id(自增)
    • 字段:isbn, title, author, tags[], status, createdAt, updatedAt, coverBlob?
    • 索引:isbn, title, author, status, createdAt
  • loans 对象存储:
    • 主键:id(自增)
    • 字段:bookId, borrower, loanDate, returnDate, status
    • 索引:bookId, status, loanDate

IndexedDB 封装(db.ts)

ts 复制代码
const DB_NAME = 'local-library'
const DB_VERSION = 1

function openDB() {
  return new Promise<IDBDatabase>((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, DB_VERSION)
    req.onupgradeneeded = () => {
      const db = req.result
      if (!db.objectStoreNames.contains('books')) {
        const store = db.createObjectStore('books', { keyPath: 'id', autoIncrement: true })
        store.createIndex('isbn', 'isbn', { unique: true })
        store.createIndex('title', 'title', { unique: false })
        store.createIndex('author', 'author', { unique: false })
        store.createIndex('status', 'status', { unique: false })
        store.createIndex('createdAt', 'createdAt', { unique: false })
      }
      if (!db.objectStoreNames.contains('loans')) {
        const store = db.createObjectStore('loans', { keyPath: 'id', autoIncrement: true })
        store.createIndex('bookId', 'bookId', { unique: false })
        store.createIndex('status', 'status', { unique: false })
        store.createIndex('loanDate', 'loanDate', { unique: false })
      }
    }
    req.onsuccess = () => resolve(req.result)
    req.onerror = () => reject(req.error)
  })
}

function tx(db: IDBDatabase, names: string[], mode: IDBTransactionMode) {
  return db.transaction(names, mode)
}

export async function addBook(payload: any) {
  const db = await openDB()
  const t = tx(db, ['books'], 'readwrite')
  const s = t.objectStore('books')
  const now = Date.now()
  const data = { ...payload, createdAt: now, updatedAt: now }
  return new Promise<number>((resolve, reject) => {
    const req = s.add(data)
    req.onsuccess = () => resolve(req.result as number)
    req.onerror = () => reject(req.error)
  })
}

export async function updateBook(payload: any) {
  const db = await openDB()
  const t = tx(db, ['books'], 'readwrite')
  const s = t.objectStore('books')
  const data = { ...payload, updatedAt: Date.now() }
  return new Promise<void>((resolve, reject) => {
    const req = s.put(data)
    req.onsuccess = () => resolve()
    req.onerror = () => reject(req.error)
  })
}

export async function removeBook(id: number) {
  const db = await openDB()
  const t = tx(db, ['books'], 'readwrite')
  const s = t.objectStore('books')
  return new Promise<void>((resolve, reject) => {
    const req = s.delete(id)
    req.onsuccess = () => resolve()
    req.onerror = () => reject(req.error)
  })
}

export async function getBook(id: number) {
  const db = await openDB()
  const t = tx(db, ['books'], 'readonly')
  const s = t.objectStore('books')
  return new Promise<any>((resolve, reject) => {
    const req = s.get(id)
    req.onsuccess = () => resolve(req.result)
    req.onerror = () => reject(req.error)
  })
}

export async function getAllBooks() {
  const db = await openDB()
  const t = tx(db, ['books'], 'readonly')
  const s = t.objectStore('books')
  return new Promise<any[]>((resolve, reject) => {
    const req = s.getAll()
    req.onsuccess = () => resolve(req.result as any[])
    req.onerror = () => reject(req.error)
  })
}

export async function searchBooks(q: string, status?: string, tags?: string[]) {
  const list = await getAllBooks()
  const k = q.trim().toLowerCase()
  return list.filter(b => {
    const hit = !k || [b.title, b.author, b.isbn].some(v => String(v || '').toLowerCase().includes(k))
    const sOk = !status || b.status === status
    const tOk = !tags || tags.every(t => (b.tags || []).includes(t))
    return hit && sOk && tOk
  })
}

export async function addLoan(payload: any) {
  const db = await openDB()
  const t = tx(db, ['loans'], 'readwrite')
  const s = t.objectStore('loans')
  return new Promise<number>((resolve, reject) => {
    const req = s.add(payload)
    req.onsuccess = () => resolve(req.result as number)
    req.onerror = () => reject(req.error)
  })
}

export async function exportBooks() {
  const list = await getAllBooks()
  return JSON.stringify(list)
}

export async function importBooks(items: any[]) {
  const db = await openDB()
  const t = tx(db, ['books'], 'readwrite')
  const s = t.objectStore('books')
  await Promise.all(items.map(item => new Promise<void>((resolve, reject) => {
    const req = s.put({ ...item, id: item.id })
    req.onsuccess = () => resolve()
    req.onerror = () => reject(req.error)
  })))
}

组合式业务模块(useBooks.ts)

ts 复制代码
import { ref, computed } from 'vue'
import { addBook, updateBook, removeBook, searchBooks, getAllBooks, exportBooks, importBooks } from './db'

export function useBooks() {
  const books = ref<any[]>([])
  const loading = ref(false)
  const q = ref('')
  const status = ref<string | undefined>(undefined)
  const tags = ref<string[]>([])

  async function refresh() {
    loading.value = true
    books.value = await getAllBooks()
    loading.value = false
  }

  async function search() {
    loading.value = true
    books.value = await searchBooks(q.value, status.value, tags.value)
    loading.value = false
  }

  async function add(payload: any) {
    await addBook(payload)
    await refresh()
  }

  async function update(payload: any) {
    await updateBook(payload)
    await refresh()
  }

  async function remove(id: number) {
    await removeBook(id)
    await refresh()
  }

  async function exportJSON() {
    const s = await exportBooks()
    return s
  }

  async function importJSON(s: string) {
    const list = JSON.parse(s)
    await importBooks(list)
    await refresh()
  }

  const count = computed(() => books.value.length)

  return { books, loading, q, status, tags, refresh, search, add, update, remove, exportJSON, importJSON, count }
}

页面组件示例(Books.vue)

vue 复制代码
<template>
  <section>
    <header>
      <input v-model="q" placeholder="搜索标题/作者/ISBN" />
      <select v-model="status">
        <option value="">全部</option>
        <option value="available">在库</option>
        <option value="loaned">借出</option>
      </select>
      <button @click="search">搜索</button>
      <button @click="refresh">重载</button>
      <button @click="onExport">导出JSON</button>
      <input type="file" accept="application/json" @change="onImport" />
    </header>

    <form @submit.prevent="onSubmit">
      <input v-model="form.title" placeholder="书名" />
      <input v-model="form.author" placeholder="作者" />
      <input v-model="form.isbn" placeholder="ISBN" />
      <input v-model="tagsInput" placeholder="标签,逗号分隔" />
      <select v-model="form.status">
        <option value="available">在库</option>
        <option value="loaned">借出</option>
      </select>
      <button type="submit">保存</button>
    </form>

    <ul>
      <li v-for="b in books" :key="b.id">
        <strong>{{ b.title }}</strong> --- {{ b.author }} --- {{ b.isbn }}
        <em>标签: {{ (b.tags || []).join(', ') }}</em>
        <em>状态: {{ b.status }}</em>
        <button @click="edit(b)">编辑</button>
        <button @click="remove(b.id)">删除</button>
      </li>
    </ul>
  </section>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useBooks } from './useBooks'

const { books, q, status, search, refresh, add, update, remove, exportJSON, importJSON } = useBooks()

const form = reactive<any>({ id: undefined, title: '', author: '', isbn: '', tags: [], status: 'available' })
const tagsInput = ref('')

function edit(b: any) {
  Object.assign(form, b)
  tagsInput.value = (b.tags || []).join(',')
}

async function onSubmit() {
  form.tags = tagsInput.value.split(',').map(s => s.trim()).filter(Boolean)
  if (form.id) {
    await update({ ...form })
  } else {
    await add({ ...form })
  }
  Object.assign(form, { id: undefined, title: '', author: '', isbn: '', tags: [], status: 'available' })
  tagsInput.value = ''
}

async function onExport() {
  const s = await exportJSON()
  const blob = new Blob([s], { type: 'application/json' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = 'books.json'
  a.click()
  URL.revokeObjectURL(url)
}

async function onImport(e: Event) {
  const file = (e.target as HTMLInputElement).files?.[0]
  if (!file) return
  const s = await file.text()
  await importJSON(s)
}
</script>

<style scoped>
section { max-width: 900px; margin: 0 auto; padding: 24px }
header { display: grid; grid-template-columns: 1fr auto auto auto auto; gap: 8px; margin-bottom: 16px }
form { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin-bottom: 16px }
ul { list-style: none; padding: 0 }
li { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr auto auto; gap: 8px; padding: 8px 0; border-bottom: 1px solid #eee }
</style>

封面图片存储(Blob)

  • 将封面图片以 Blob 保存到 books.coverBlob 字段,或单独的 covers 存储。
  • 展示时通过 URL.createObjectURL(blob) 生成临时 URL。
  • 图片字段不参与全文搜索,仅配合 id 关联。

示例:

ts 复制代码
export async function setCover(id: number, blob: Blob) {
  const b = await getBook(id)
  if (!b) return
  await updateBook({ ...b, coverBlob: blob })
}

性能与可用性

  • 搜索优化:为高频字段建立索引;长列表使用分页或虚拟列表。
  • 交互优化:输入搜索加防抖;批量导入采用事务和并发控制。
  • 数据安全:定期导出备份;版本升级中谨慎迁移结构。
  • 兼容性:Safari 私密模式禁用 IndexedDB;需提示与降级方案。

版本升级策略

  • 变更对象存储或索引时提升 DB_VERSION
  • onupgradeneeded 中进行迁移,避免阻塞主逻辑。
  • 保持向后兼容,尽量不破坏旧数据的键路径与字段语义。

离线增强(PWA,选做)

ts 复制代码
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
}
  • 将静态资源与核心页面缓存到 Service Worker。
  • 用户可在无网络环境下继续管理数据。

测试与数据准备

  • 使用假数据快速填充:
ts 复制代码
function fakeBook(i: number) {
  return { title: `书名${i}`, author: `作者${i}`, isbn: `ISBN${100000+i}`, tags: ['技术'], status: 'available' }
}
  • 批量注入后测试搜索与导出。

常见坑与规约

  • 事务作用域:跨存储操作需在同一事务中处理。
  • 索引唯一性:isbn 唯一,批量导入需处理冲突。
  • Blob 存储:大图片会增大数据库;可只存缩略图。
  • 异常恢复:onerror 全面捕获并提示用户导出备份。

收尾与扩展方向

  • 借阅视图:基于 loans 生成借阅统计与提醒。
  • 标签系统:支持多标签筛选和标签云。
  • 聚合统计:作者、年份、标签分布图(结合 Canvas/SVG)。
  • 多端同步:后续可通过云端接口或 WebRTC 做点对点同步。

总结

  • Vue3 + IndexedDB 能轻量实现个人工具的核心能力:离线、持久、足够快。
  • 用组合式模块隔离数据与业务,UI 简洁可维护;随着需求增长再渐进增强。
  • 充分利用索引与事务,谨慎处理版本升级与导入导出,能显著提升可靠性与体验。
相关推荐
消失的旧时光-19434 小时前
Kotlinx.serialization 对多态对象(sealed class )支持更好用
java·服务器·前端
少卿4 小时前
React Compiler 完全指南:自动化性能优化的未来
前端·javascript
广州华水科技4 小时前
水库变形监测推荐:2025年单北斗GNSS变形监测系统TOP5,助力基础设施安全
前端
广州华水科技4 小时前
北斗GNSS变形监测一体机在基础设施安全中的应用与优势
前端
七淮4 小时前
umi4暗黑模式设置
前端
8***B4 小时前
前端路由权限控制,动态路由生成
前端
军军3604 小时前
从图片到点阵:用JavaScript重现复古数码点阵艺术图
前端·javascript
znhy@1235 小时前
Vue基础知识(一)
前端·javascript·vue.js
terminal0075 小时前
浅谈useRef的使用和渲染机制
前端·react.js·面试
我的小月月5 小时前
🔥 手把手教你实现前端邮件预览功能
前端·vue.js