首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >用前端技术做个人工具:开发本地图书管理系统(Vue3+IndexedDB)

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

作者头像
fruge365
发布2025-12-15 13:43:35
发布2025-12-15 13:43:35
2300
举报

用前端技术做个人工具:开发本地图书管理系统(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)

代码语言:javascript
复制
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)

代码语言:javascript
复制
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)

代码语言:javascript
复制
<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 关联。

示例:

代码语言:javascript
复制
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,选做)

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

测试与数据准备

  • 使用假数据快速填充:
代码语言:javascript
复制
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 简洁可维护;随着需求增长再渐进增强。
  • 充分利用索引与事务,谨慎处理版本升级与导入导出,能显著提升可靠性与体验。

进阶 UI 状态管理(可选 Pinia)

代码语言:javascript
复制
import { defineStore } from 'pinia'
export const useBooksStore = defineStore('books', {
  state: () => ({ list: [] as any[], loading: false }),
  actions: {
    async refresh() { this.loading = true; this.list = await getAllBooks(); this.loading = false },
    async add(payload: any) { await addBook(payload); await this.refresh() }
  }
})

虚拟列表与分页

代码语言:javascript
复制
export function usePager(list: any[], size = 50) {
  const page = ref(1)
  const total = computed(() => Math.ceil(list.length / size))
  const view = computed(() => list.slice((page.value - 1) * size, page.value * size))
  return { page, total, view }
}

借阅提醒与通知

代码语言:javascript
复制
export async function notify(title: string, body: string) {
  if (Notification.permission !== 'granted') await Notification.requestPermission()
  if (Notification.permission === 'granted') new Notification(title, { body })
}

封面图片压缩

代码语言:javascript
复制
export async function compressCover(file: File, w = 240) {
  const img = new Image()
  img.src = URL.createObjectURL(file)
  await new Promise(r => { img.onload = r })
  const h = Math.round(img.height * (w / img.width))
  const cvs = document.createElement('canvas')
  cvs.width = w; cvs.height = h
  const ctx = cvs.getContext('2d')!
  ctx.drawImage(img, 0, 0, w, h)
  const blob = await new Promise<Blob>(r => cvs.toBlob(b => r(b!), 'image/jpeg', 0.8))
  URL.revokeObjectURL(img.src)
  return blob
}

条码扫描(可选)

代码语言:javascript
复制
export async function scanBarcode(video: HTMLVideoElement) {
  const ok = 'BarcodeDetector' in window
  if (!ok) return null
  const det = new (window as any).BarcodeDetector({ formats: ['ean_13', 'code_128'] })
  const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
  video.srcObject = stream
  await video.play()
  const cvs = document.createElement('canvas')
  const ctx = cvs.getContext('2d')!
  cvs.width = video.videoWidth; cvs.height = video.videoHeight
  ctx.drawImage(video, 0, 0)
  const codes = await det.detect(cvs)
  const val = codes[0]?.rawValue || null
  stream.getTracks().forEach(t => t.stop())
  return val
}

事务一致性示例(跨存储)

代码语言:javascript
复制
export async function addBookWithLoan(book: any, loan: any) {
  const db = await openDB()
  const t = db.transaction(['books', 'loans'], 'readwrite')
  const sb = t.objectStore('books')
  const sl = t.objectStore('loans')
  const now = Date.now()
  const id = await new Promise<number>((resolve, reject) => { const r = sb.add({ ...book, createdAt: now, updatedAt: now }); r.onsuccess = () => resolve(r.result as number); r.onerror = () => reject(r.error) })
  await new Promise<void>((resolve, reject) => { const r = sl.add({ ...loan, bookId: id }); r.onsuccess = () => resolve(); r.onerror = () => reject(r.error) })
}

备份加密(Web Crypto)

代码语言:javascript
复制
export async function encryptBackup(s: string, password: string) {
  const enc = new TextEncoder().encode(password)
  const key = await crypto.subtle.importKey('raw', enc, 'PBKDF2', false, ['deriveKey'])
  const salt = crypto.getRandomValues(new Uint8Array(16))
  const aes = await crypto.subtle.deriveKey({ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' }, key, { name: 'AES-GCM', length: 256 }, false, ['encrypt'])
  const iv = crypto.getRandomValues(new Uint8Array(12))
  const data = new TextEncoder().encode(s)
  const buf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aes, data)
  return { salt: Array.from(salt), iv: Array.from(iv), payload: Array.from(new Uint8Array(buf)) }
}

离线操作队列

代码语言:javascript
复制
export async function enqueue(op: { type: string; payload: any }) {
  const db = await openDB()
  const t = tx(db, ['queue'], 'readwrite')
  t.objectStore('queue').add({ id: crypto.randomUUID(), op, time: Date.now() })
}

export async function flushQueue(handler: (op: any) => Promise<void>) {
  const db = await openDB()
  const t = tx(db, ['queue'], 'readwrite')
  const s = t.objectStore('queue')
  const list = await new Promise<any[]>((resolve, reject) => { const r = s.getAll(); r.onsuccess = () => resolve(r.result as any[]); r.onerror = () => reject(r.error) })
  for (let i = 0; i < list.length; i++) { await handler(list[i].op); await new Promise<void>((r, j) => { const d = s.delete(list[i].id); d.onsuccess = () => r(); d.onerror = () => j(d.error) }) }
}

无障碍与键盘可用性

代码语言:javascript
复制
<button aria-label="保存" @keydown.enter="onSubmit">保存</button>

主题与样式基线

代码语言:javascript
复制
:root { --bg: #fff; --fg: #222; --primary: #3b82f6 }
[data-theme="dark"] { --bg: #111; --fg: #eee }
body { background: var(--bg); color: var(--fg) }
button { background: var(--primary); color: #fff }

类型与校验

代码语言:javascript
复制
export type Book = { id?: number; isbn?: string; title: string; author?: string; tags?: string[]; status: 'available' | 'loaned'; coverBlob?: Blob; createdAt?: number; updatedAt?: number }
export type Loan = { id?: number; bookId: number; borrower: string; loanDate: number; returnDate?: number; status: 'open' | 'closed' }

export function validateBook(b: Partial<Book>): Book {
  const title = String(b.title || '').trim()
  const status = (b.status as any) || 'available'
  const isbn = b.isbn ? String(b.isbn).trim() : undefined
  const author = b.author ? String(b.author).trim() : undefined
  const tags = Array.isArray(b.tags) ? b.tags.filter(Boolean) : []
  return { id: b.id, title, status, isbn, author, tags }
}

进阶查询与索引命中

代码语言:javascript
复制
export async function listByStatus(status: string) {
  const db = await openDB()
  const t = tx(db, ['books'], 'readonly')
  const s = t.objectStore('books').index('status')
  return new Promise<any[]>((resolve, reject) => {
    const out: any[] = []
    const req = s.openCursor(IDBKeyRange.only(status))
    req.onsuccess = () => { const cur = req.result; if (cur) { out.push(cur.value); cur.continue() } else resolve(out) }
    req.onerror = () => reject(req.error)
  })
}

export async function listByCreatedRange(start: number, end: number) {
  const db = await openDB()
  const t = tx(db, ['books'], 'readonly')
  const s = t.objectStore('books').index('createdAt')
  return new Promise<any[]>((resolve, reject) => {
    const out: any[] = []
    const req = s.openCursor(IDBKeyRange.bound(start, end))
    req.onsuccess = () => { const cur = req.result; if (cur) { out.push(cur.value); cur.continue() } else resolve(out) }
    req.onerror = () => reject(req.error)
  })
}

简易全文索引(选做)

代码语言:javascript
复制
function tokenize(s: string) {
  return String(s || '').toLowerCase().split(/\s+/).filter(Boolean)
}

export async function ensureFTS(db: IDBDatabase) {
  const names = Array.from(db.objectStoreNames)
  if (!names.includes('fts')) {
    const req = indexedDB.open(DB_NAME, DB_VERSION + 1)
  }
}

export async function buildFTS(book: any) {
  const db = await openDB()
  const t = tx(db, ['fts'], 'readwrite')
  const s = t.objectStore('fts')
  const tokens = tokenize([book.title, book.author, book.isbn].join(' '))
  await Promise.all(tokens.map(tok => new Promise<void>((resolve, reject) => { const req = s.put({ k: tok, id: book.id }); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error) })))
}

export async function searchFTS(q: string) {
  const db = await openDB()
  const t = tx(db, ['fts', 'books'], 'readonly')
  const idx = t.objectStore('fts')
  const bs = t.objectStore('books')
  const token = String(q || '').toLowerCase().trim()
  return new Promise<any[]>((resolve, reject) => {
    const ids = new Set<number>()
    const req = idx.openCursor(IDBKeyRange.only(token))
    req.onsuccess = async () => {
      const cur = req.result
      if (cur) { ids.add(cur.value.id); cur.continue() } else {
        const out: any[] = []
        await Promise.all(Array.from(ids).map(id => new Promise<void>((r, j) => { const g = bs.get(id); g.onsuccess = () => { if (g.result) out.push(g.result); r() }; g.onerror = () => j(g.error) })))
        resolve(out)
      }
    }
    req.onerror = () => reject(req.error)
  })
}

CSV 导入导出

代码语言:javascript
复制
export function toCSV(items: any[]) {
  const heads = ['title','author','isbn','status','tags']
  const rows = items.map(b => [b.title, b.author || '', b.isbn || '', b.status, (b.tags || []).join('|')])
  return [heads.join(','), ...rows.map(r => r.map(x => String(x).replace(/"/g,'""')).join(','))].join('\n')
}

export function fromCSV(s: string) {
  const lines = s.trim().split(/\n/)
  const heads = lines.shift()?.split(',') || []
  return lines.map(line => {
    const cells = line.split(',')
    const m: any = {}
    for (let i = 0; i < heads.length; i++) m[heads[i]] = cells[i]
    m.tags = String(m.tags || '').split('|').filter(Boolean)
    return validateBook(m)
  })
}

键盘快捷键与交互

代码语言:javascript
复制
export function useHotkeys(onSave: () => void, onSearch: () => void) {
  function handler(e: KeyboardEvent) {
    if (e.ctrlKey && e.key.toLowerCase() === 's') { e.preventDefault(); onSave() }
    if (e.ctrlKey && e.key.toLowerCase() === 'f') { e.preventDefault(); onSearch() }
  }
  window.addEventListener('keydown', handler)
  return () => window.removeEventListener('keydown', handler)
}

国际化与文案

代码语言:javascript
复制
export const i18n = { zh: { search: '搜索', save: '保存', export: '导出', import: '导入' }, en: { search: 'Search', save: 'Save', export: 'Export', import: 'Import' } }
export function t(lang: 'zh' | 'en', k: keyof typeof i18n['zh']) { return i18n[lang][k] }

冲突与去重策略

代码语言:javascript
复制
export async function upsertBookByISBN(item: any) {
  const db = await openDB()
  const t = tx(db, ['books'], 'readwrite')
  const s = t.objectStore('books').index('isbn')
  return new Promise<void>((resolve, reject) => {
    const req = s.get(item.isbn)
    req.onsuccess = () => {
      const found = req.result
      const merged = found ? { ...found, ...item, id: found.id, updatedAt: Date.now() } : { ...item, createdAt: Date.now(), updatedAt: Date.now() }
      const put = t.objectStore('books').put(merged)
      put.onsuccess = () => resolve()
      put.onerror = () => reject(put.error)
    }
    req.onerror = () => reject(req.error)
  })
}

单元测试思路

  • 使用浏览器环境的测试框架或在页面内加载测试脚本
  • 构造假数据验证新增、更新、查询、导入导出的一致性

部署与备份

  • 通过 PWA 缓存核心页面,实现离线可用
  • 定期导出 JSON 或 CSV 作为备份,存储在本地或云盘
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-11-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 用前端技术做个人工具:开发本地图书管理系统(Vue3+IndexedDB)
    • 目标与特性
    • 技术选型与架构
    • 数据模型设计
    • IndexedDB 封装(db.ts)
    • 组合式业务模块(useBooks.ts)
    • 页面组件示例(Books.vue)
    • 封面图片存储(Blob)
    • 性能与可用性
    • 版本升级策略
    • 离线增强(PWA,选做)
    • 测试与数据准备
    • 常见坑与规约
    • 收尾与扩展方向
    • 总结
    • 进阶 UI 状态管理(可选 Pinia)
    • 虚拟列表与分页
    • 借阅提醒与通知
    • 封面图片压缩
    • 条码扫描(可选)
    • 事务一致性示例(跨存储)
    • 备份加密(Web Crypto)
    • 离线操作队列
    • 无障碍与键盘可用性
    • 主题与样式基线
    • 类型与校验
    • 进阶查询与索引命中
    • 简易全文索引(选做)
    • CSV 导入导出
    • 键盘快捷键与交互
    • 国际化与文案
    • 冲突与去重策略
    • 单元测试思路
    • 部署与备份
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档