
一篇从零到一的实践型文章:用 Vue3 组合式 API 搭配 IndexedDB,打造离线可用、数据本地持久化的本地图书管理系统。适合个人藏书管理、借阅记录和标签分类等场景。
IndexedDB,支持大数据量与结构化索引。Vue3 + TypeScript + IndexedDB。Vue3:组合式 API 更易拆分业务逻辑,便于小型工具维护与扩展。IndexedDB:浏览器原生数据库,支持对象存储、索引、事务;适合离线、本地持久化场景。db.ts 封装,提供 CRUD 与查询接口。useBooks.ts 组合式模块,负责状态与交互。Books.vue 页面组件,包含表单、列表、搜索与导入导出。books 对象存储: id(自增)isbn, title, author, tags[], status, createdAt, updatedAt, coverBlob?isbn, title, author, status, createdAtloans 对象存储: id(自增)bookId, borrower, loanDate, returnDate, statusbookId, status, loanDateconst 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)
})))
}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 }
}<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 保存到 books.coverBlob 字段,或单独的 covers 存储。URL.createObjectURL(blob) 生成临时 URL。id 关联。示例:
export async function setCover(id: number, blob: Blob) {
const b = await getBook(id)
if (!b) return
await updateBook({ ...b, coverBlob: blob })
}DB_VERSION。onupgradeneeded 中进行迁移,避免阻塞主逻辑。if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
}function fakeBook(i: number) {
return { title: `书名${i}`, author: `作者${i}`, isbn: `ISBN${100000+i}`, tags: ['技术'], status: 'available' }
}isbn 唯一,批量导入需处理冲突。onerror 全面捕获并提示用户导出备份。loans 生成借阅统计与提醒。Vue3 + IndexedDB 能轻量实现个人工具的核心能力:离线、持久、足够快。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() }
}
})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 }
}export async function notify(title: string, body: string) {
if (Notification.permission !== 'granted') await Notification.requestPermission()
if (Notification.permission === 'granted') new Notification(title, { body })
}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
}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
}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) })
}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)) }
}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) }) }
}<button aria-label="保存" @keydown.enter="onSubmit">保存</button>: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 }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 }
}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)
})
}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)
})
}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)
})
}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)
}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] }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)
})
}