- vite-plugin-pwa injectManifest 模式,自定义 sw.js precache app shell - manifest 支持加桌面 + standalone(icon 暂借 werewolf 紫色调,后续换) - src/lib/cache.js IDB 缓存层:audio + 谱面 PNG 单 attachment id 存放,blob URL 复用 - 启动 initCache 按 localStorage 'music.cache.enabled' 决定是否后台开始下载 - 后台 worker:串行 concurrency=2 + 80ms 间隔,仅 WiFi 时跑(默认) - audio src 优先走 IDB blob URL,没缓存才走网络 - /settings 配置页:开关 + 仅 WiFi 切换 + 进度条 + 用量/quota + 清空缓存 - topbar 加 ⚙ 按钮 默认关,首次明确 prompt-by-checkbox 才开。整库 ~1.5GB。
This commit is contained in:
Generated
+4851
-18
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0",
|
||||||
|
"vite-plugin-pwa": "^0.20.5",
|
||||||
|
"workbox-build": "^7.1.0",
|
||||||
|
"workbox-window": "^7.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 336 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 558 KiB |
@@ -0,0 +1,264 @@
|
|||||||
|
// 离线缓存 — pieces metadata + audio + chord PNG 都存 IndexedDB。
|
||||||
|
// 不用 Cache API 是因为 IDB 单条删除可控,大文件 Blob 友好。
|
||||||
|
//
|
||||||
|
// 配置(localStorage):
|
||||||
|
// music.cache.enabled 'true' | 'false' 默认 false
|
||||||
|
// music.cache.wifiOnly 'true' | 'false' 默认 true
|
||||||
|
//
|
||||||
|
// 进度向 window 广播 CustomEvent('music-cache-progress', { detail: { done, total, busy } })
|
||||||
|
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { listPieces, getPiece, attachmentUrl } from './api.js'
|
||||||
|
|
||||||
|
const DB_NAME = 'music-cache'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
const STORE_AUDIO = 'audio' // key: attachment id (number)
|
||||||
|
const STORE_IMAGE = 'image' // key: attachment id
|
||||||
|
const STORE_META = 'meta' // key: 'pieces' | 'updated_at' ...
|
||||||
|
|
||||||
|
let dbPromise = null
|
||||||
|
|
||||||
|
function openDb() {
|
||||||
|
if (dbPromise) return dbPromise
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result
|
||||||
|
if (!db.objectStoreNames.contains(STORE_AUDIO)) db.createObjectStore(STORE_AUDIO)
|
||||||
|
if (!db.objectStoreNames.contains(STORE_IMAGE)) db.createObjectStore(STORE_IMAGE)
|
||||||
|
if (!db.objectStoreNames.contains(STORE_META)) db.createObjectStore(STORE_META)
|
||||||
|
}
|
||||||
|
req.onsuccess = () => resolve(req.result)
|
||||||
|
req.onerror = () => reject(req.error)
|
||||||
|
})
|
||||||
|
return dbPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function idbGet(store, key) {
|
||||||
|
const db = await openDb()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(store, 'readonly')
|
||||||
|
const req = tx.objectStore(store).get(key)
|
||||||
|
req.onsuccess = () => resolve(req.result)
|
||||||
|
req.onerror = () => reject(req.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function idbPut(store, key, value) {
|
||||||
|
const db = await openDb()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(store, 'readwrite')
|
||||||
|
tx.objectStore(store).put(value, key)
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function idbDelete(store, key) {
|
||||||
|
const db = await openDb()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(store, 'readwrite')
|
||||||
|
tx.objectStore(store).delete(key)
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function idbKeys(store) {
|
||||||
|
const db = await openDb()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(store, 'readonly')
|
||||||
|
const req = tx.objectStore(store).getAllKeys()
|
||||||
|
req.onsuccess = () => resolve(req.result)
|
||||||
|
req.onerror = () => reject(req.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function idbClearAll() {
|
||||||
|
const db = await openDb()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction([STORE_AUDIO, STORE_IMAGE, STORE_META], 'readwrite')
|
||||||
|
tx.objectStore(STORE_AUDIO).clear()
|
||||||
|
tx.objectStore(STORE_IMAGE).clear()
|
||||||
|
tx.objectStore(STORE_META).clear()
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 配置 ----
|
||||||
|
|
||||||
|
export function isCacheEnabled() {
|
||||||
|
return localStorage.getItem('music.cache.enabled') === 'true'
|
||||||
|
}
|
||||||
|
export function setCacheEnabled(v) {
|
||||||
|
localStorage.setItem('music.cache.enabled', v ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
export function isWifiOnly() {
|
||||||
|
return localStorage.getItem('music.cache.wifiOnly') !== 'false' // 默认 true
|
||||||
|
}
|
||||||
|
export function setWifiOnly(v) {
|
||||||
|
localStorage.setItem('music.cache.wifiOnly', v ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网络感知:不是 WiFi(蜂窝 / 慢速)就跳过
|
||||||
|
function isLikelyMetered() {
|
||||||
|
const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection
|
||||||
|
if (!c) return false // 不知道就当 OK
|
||||||
|
if (c.saveData) return true
|
||||||
|
if (c.type === 'cellular') return true
|
||||||
|
if (['slow-2g', '2g'].includes(c.effectiveType)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 媒体取用 ----
|
||||||
|
|
||||||
|
const blobUrlCache = new Map() // attId -> blob URL(避免反复 createObjectURL)
|
||||||
|
|
||||||
|
export async function getCachedBlobUrl(store, attId) {
|
||||||
|
if (blobUrlCache.has(attId)) return blobUrlCache.get(attId)
|
||||||
|
const blob = await idbGet(store, attId)
|
||||||
|
if (!blob) return null
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
blobUrlCache.set(attId, url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAudioUrl(attId) {
|
||||||
|
const cached = await getCachedBlobUrl(STORE_AUDIO, attId)
|
||||||
|
return cached || attachmentUrl(attId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getImageUrl(attId) {
|
||||||
|
const cached = await getCachedBlobUrl(STORE_IMAGE, attId)
|
||||||
|
return cached || attachmentUrl(attId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 状态 ----
|
||||||
|
|
||||||
|
export const cacheStats = ref({ audioCount: 0, imageCount: 0, busy: false, done: 0, total: 0 })
|
||||||
|
|
||||||
|
async function refreshStats() {
|
||||||
|
const [a, i] = await Promise.all([idbKeys(STORE_AUDIO), idbKeys(STORE_IMAGE)])
|
||||||
|
cacheStats.value.audioCount = a.length
|
||||||
|
cacheStats.value.imageCount = i.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitProgress() {
|
||||||
|
window.dispatchEvent(new CustomEvent('music-cache-progress', { detail: { ...cacheStats.value } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 下载 worker ----
|
||||||
|
|
||||||
|
let workerRunning = false
|
||||||
|
let workerAbort = null
|
||||||
|
|
||||||
|
async function downloadOne(url, store, key) {
|
||||||
|
const r = await fetch(url, { cache: 'reload' })
|
||||||
|
if (!r.ok) throw new Error(`${r.status}`)
|
||||||
|
const blob = await r.blob()
|
||||||
|
await idbPut(store, key, blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startCacheWorker() {
|
||||||
|
if (workerRunning) return
|
||||||
|
if (!isCacheEnabled()) return
|
||||||
|
if (isWifiOnly() && isLikelyMetered()) {
|
||||||
|
console.log('[cache] skip: on metered connection')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 主动申请永久存储
|
||||||
|
if (navigator.storage && navigator.storage.persist) {
|
||||||
|
try { await navigator.storage.persist() } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
workerRunning = true
|
||||||
|
cacheStats.value.busy = true
|
||||||
|
workerAbort = new AbortController()
|
||||||
|
try {
|
||||||
|
// 1) 拉全部 pieces metadata
|
||||||
|
const pieces = await listPieces()
|
||||||
|
await idbPut(STORE_META, 'pieces', pieces)
|
||||||
|
await idbPut(STORE_META, 'updated_at', Date.now())
|
||||||
|
|
||||||
|
// 2) 算出所有要下载的 (store, id) 列表
|
||||||
|
const haveAudio = new Set(await idbKeys(STORE_AUDIO))
|
||||||
|
const haveImage = new Set(await idbKeys(STORE_IMAGE))
|
||||||
|
const targets = []
|
||||||
|
for (const p of pieces) {
|
||||||
|
const detail = await getPiece(p.id)
|
||||||
|
for (const a of detail.attachments || []) {
|
||||||
|
if (a.kind === 'audio' && !haveAudio.has(a.id)) {
|
||||||
|
targets.push({ store: STORE_AUDIO, id: a.id })
|
||||||
|
} else if (a.kind === 'image' && !haveImage.has(a.id)) {
|
||||||
|
targets.push({ store: STORE_IMAGE, id: a.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (workerAbort.signal.aborted) break
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheStats.value.total = targets.length
|
||||||
|
cacheStats.value.done = 0
|
||||||
|
emitProgress()
|
||||||
|
|
||||||
|
// 3) 串行下载(concurrency=2)
|
||||||
|
const concurrency = 2
|
||||||
|
const queue = [...targets]
|
||||||
|
async function worker() {
|
||||||
|
while (queue.length && !workerAbort.signal.aborted) {
|
||||||
|
if (isWifiOnly() && isLikelyMetered()) break
|
||||||
|
const t = queue.shift()
|
||||||
|
try {
|
||||||
|
await downloadOne(attachmentUrl(t.id), t.store, t.id)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[cache] dl failed', t, e)
|
||||||
|
}
|
||||||
|
cacheStats.value.done++
|
||||||
|
emitProgress()
|
||||||
|
await new Promise((r) => setTimeout(r, 80))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: concurrency }, () => worker()))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[cache] worker error', e)
|
||||||
|
} finally {
|
||||||
|
workerRunning = false
|
||||||
|
cacheStats.value.busy = false
|
||||||
|
workerAbort = null
|
||||||
|
await refreshStats()
|
||||||
|
emitProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abortCacheWorker() {
|
||||||
|
if (workerAbort) workerAbort.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCache() {
|
||||||
|
abortCacheWorker()
|
||||||
|
for (const u of blobUrlCache.values()) URL.revokeObjectURL(u)
|
||||||
|
blobUrlCache.clear()
|
||||||
|
await idbClearAll()
|
||||||
|
await refreshStats()
|
||||||
|
emitProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 存储用量 ----
|
||||||
|
|
||||||
|
export async function estimateUsage() {
|
||||||
|
if (navigator.storage && navigator.storage.estimate) {
|
||||||
|
const e = await navigator.storage.estimate()
|
||||||
|
return { usage: e.usage || 0, quota: e.quota || 0 }
|
||||||
|
}
|
||||||
|
return { usage: 0, quota: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 启动入口 ----
|
||||||
|
|
||||||
|
export async function initCache() {
|
||||||
|
await refreshStats()
|
||||||
|
if (isCacheEnabled()) {
|
||||||
|
// 不阻塞主线程,延后 3s 让 app 先 render
|
||||||
|
setTimeout(() => startCacheWorker(), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,14 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router/index.js'
|
import router from './router/index.js'
|
||||||
|
import { registerPwa } from './pwa.js'
|
||||||
|
import { initCache } from './lib/cache.js'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
|
registerPwa()
|
||||||
|
initCache() // 启动时按配置决定是否后台缓存
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// 注册 service worker(如果浏览器支持 + 不在 dev 模式下)。
|
||||||
|
// vite-plugin-pwa injectManifest 模式:build 后 dist/sw.js 是处理过的 worker。
|
||||||
|
import { registerSW } from 'virtual:pwa-register'
|
||||||
|
|
||||||
|
export function registerPwa() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
if (!('serviceWorker' in navigator)) return
|
||||||
|
registerSW({
|
||||||
|
immediate: true,
|
||||||
|
onRegisteredSW(_swUrl, _r) {
|
||||||
|
console.log('[pwa] sw registered')
|
||||||
|
},
|
||||||
|
onOfflineReady() {
|
||||||
|
console.log('[pwa] offline ready')
|
||||||
|
},
|
||||||
|
onNeedRefresh() {
|
||||||
|
console.log('[pwa] update available')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -23,6 +23,11 @@ const routes = [
|
|||||||
component: () => import('../views/EditView.vue'),
|
component: () => import('../views/EditView.vue'),
|
||||||
props: (route) => ({ id: Number(route.params.id) }),
|
props: (route) => ({ id: Number(route.params.id) }),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: () => import('../views/SettingsView.vue'),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default createRouter({
|
export default createRouter({
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
// Music PWA service worker(injectManifest 模式)。
|
||||||
|
// 只 precache app shell(HTML/JS/CSS/icon),媒体(audio + chord PNG)走前端 IDB
|
||||||
|
// 显式缓存(lib/cache.js),不让 SW 自动 cache 大文件避免 quota 失控。
|
||||||
|
|
||||||
|
const MANIFEST = self.__WB_MANIFEST || []
|
||||||
|
|
||||||
|
function hashStr(s) {
|
||||||
|
let h = 5381
|
||||||
|
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0
|
||||||
|
return h.toString(36)
|
||||||
|
}
|
||||||
|
const VERSION = hashStr(MANIFEST.map((e) => `${e.url}@${e.revision || ''}`).join('|'))
|
||||||
|
const CACHE = `music-shell-${VERSION}`
|
||||||
|
|
||||||
|
const INDEX = new URL('index.html', self.location.href).href
|
||||||
|
const URLS = Array.from(new Set([INDEX, ...MANIFEST.map((e) => new URL(e.url, self.location.href).href)]))
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(CACHE)
|
||||||
|
// 串行避免一次性请求过多
|
||||||
|
for (const u of URLS) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(u, { cache: 'reload' })
|
||||||
|
if (r.ok) await cache.put(u, r)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
await self.skipWaiting()
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const keys = await caches.keys()
|
||||||
|
await Promise.all(
|
||||||
|
keys.filter((k) => k.startsWith('music-shell-') && k !== CACHE).map((k) => caches.delete(k)),
|
||||||
|
)
|
||||||
|
await self.clients.claim()
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const req = event.request
|
||||||
|
if (req.method !== 'GET') return
|
||||||
|
const url = new URL(req.url)
|
||||||
|
// 只接管同源 GET,跳过 /api/*(前端 cache.js 自己管)
|
||||||
|
if (url.origin !== self.location.origin) return
|
||||||
|
if (url.pathname.startsWith('/api/')) return
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(CACHE)
|
||||||
|
// 导航请求总是回 index.html,让 SPA 路由跑(离线也能进任何 route)
|
||||||
|
if (req.mode === 'navigate') {
|
||||||
|
const cached = await cache.match(INDEX)
|
||||||
|
if (cached) return cached
|
||||||
|
}
|
||||||
|
const cached = await cache.match(req)
|
||||||
|
if (cached) return cached
|
||||||
|
try {
|
||||||
|
const fresh = await fetch(req)
|
||||||
|
if (fresh.ok) cache.put(req, fresh.clone()).catch(() => {})
|
||||||
|
return fresh
|
||||||
|
} catch (e) {
|
||||||
|
// 离线 + 没缓存
|
||||||
|
return new Response('offline', { status: 503 })
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<span class="count">{{ filtered.length }} / {{ pieces.length }} 首</span>
|
<span class="count">{{ filtered.length }} / {{ pieces.length }} 首</span>
|
||||||
<button class="btn-inspire" title="今天练什么?" @click="openInspire">💡</button>
|
<button class="btn-inspire" title="今天练什么?" @click="openInspire">💡</button>
|
||||||
<router-link to="/upload" class="btn-add" title="新增曲目">+</router-link>
|
<router-link to="/upload" class="btn-add" title="新增曲目">+</router-link>
|
||||||
|
<router-link to="/settings" class="btn-settings" title="设置">⚙</router-link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="filterbar">
|
<nav class="filterbar">
|
||||||
@@ -386,6 +387,7 @@ import {
|
|||||||
streamChat,
|
streamChat,
|
||||||
streamInspire,
|
streamInspire,
|
||||||
} from '../lib/api.js'
|
} from '../lib/api.js'
|
||||||
|
import { getAudioUrl } from '../lib/cache.js'
|
||||||
import { parseLrc } from '../lib/lrc.js'
|
import { parseLrc } from '../lib/lrc.js'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -771,8 +773,8 @@ async function loadPiece(id) {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
const first = audioAttachments.value[0]
|
const first = audioAttachments.value[0]
|
||||||
if (first && audioEl.value) {
|
if (first && audioEl.value) {
|
||||||
audioEl.value.src = attUrl(first.id)
|
// 优先用 IDB 缓存的 blob URL,没有再走网络
|
||||||
// 只有上一首在播才自动续播;暂停状态 / 第一次进入 → 只设源,等用户点 ▶
|
audioEl.value.src = await getAudioUrl(first.id)
|
||||||
if (wasPlaying) audioEl.value.play().catch(() => {})
|
if (wasPlaying) audioEl.value.play().catch(() => {})
|
||||||
} else if (audioEl.value) {
|
} else if (audioEl.value) {
|
||||||
audioEl.value.removeAttribute('src')
|
audioEl.value.removeAttribute('src')
|
||||||
@@ -1241,6 +1243,14 @@ onBeforeUnmount(() => {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.topbar .btn-add:hover { background: var(--accent); text-decoration: none; }
|
.topbar .btn-add:hover { background: var(--accent); text-decoration: none; }
|
||||||
|
.topbar .btn-settings {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
background: var(--bg-elev); color: var(--text-dim);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 16px; text-decoration: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.topbar .btn-settings:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
|
||||||
|
|
||||||
.filterbar {
|
.filterbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<header class="bar">
|
||||||
|
<router-link to="/" class="back">← 返回</router-link>
|
||||||
|
<h1>设置</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="body">
|
||||||
|
<section class="block">
|
||||||
|
<h2>离线缓存</h2>
|
||||||
|
<p class="desc">
|
||||||
|
把所有曲目的 audio 和谱面 PNG 异步下载到浏览器 IndexedDB。下载完没网也能播放、看谱。
|
||||||
|
默认 <b>关</b>。整库约 1.5 GB,移动网络下慎开。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="row">
|
||||||
|
<span class="lbl">启用自动缓存</span>
|
||||||
|
<input type="checkbox" v-model="enabled" @change="onEnabled" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="row" :class="{ disabled: !enabled }">
|
||||||
|
<span class="lbl">仅 WiFi 时下载</span>
|
||||||
|
<input type="checkbox" v-model="wifiOnly" :disabled="!enabled" @change="onWifi" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="key">已缓存 audio</span>
|
||||||
|
<span class="val">{{ stats.audioCount }} 个</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="key">已缓存谱面</span>
|
||||||
|
<span class="val">{{ stats.imageCount }} 个</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="key">磁盘占用</span>
|
||||||
|
<span class="val">{{ fmtMb(usage.usage) }} / {{ fmtMb(usage.quota) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="stats.busy" class="progress">
|
||||||
|
<div class="bar-bg">
|
||||||
|
<div class="bar-fill" :style="{ width: pct + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text">下载中 {{ stats.done }} / {{ stats.total }} ({{ pct }}%)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" :disabled="stats.busy || !enabled" @click="onStart">
|
||||||
|
{{ stats.busy ? '运行中…' : '立即开始下载' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn" :disabled="!stats.busy" @click="onStop">暂停</button>
|
||||||
|
<button class="btn danger" :disabled="stats.busy" @click="onClear">清空所有缓存</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>关于</h2>
|
||||||
|
<p class="desc">
|
||||||
|
PWA 可"加到主屏幕"作为独立 app 启动。<br/>
|
||||||
|
数据用 IndexedDB,可在浏览器开发者工具的 Application → IndexedDB → music-cache 看到详细条目。<br/>
|
||||||
|
清空缓存只删本地数据,不影响服务端。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import {
|
||||||
|
isCacheEnabled, setCacheEnabled,
|
||||||
|
isWifiOnly, setWifiOnly,
|
||||||
|
startCacheWorker, abortCacheWorker, clearCache,
|
||||||
|
estimateUsage, cacheStats,
|
||||||
|
} from '../lib/cache.js'
|
||||||
|
|
||||||
|
const enabled = ref(isCacheEnabled())
|
||||||
|
const wifiOnly = ref(isWifiOnly())
|
||||||
|
const stats = cacheStats
|
||||||
|
const usage = ref({ usage: 0, quota: 0 })
|
||||||
|
|
||||||
|
const pct = ref(0)
|
||||||
|
|
||||||
|
function onEnabled() {
|
||||||
|
setCacheEnabled(enabled.value)
|
||||||
|
if (enabled.value) startCacheWorker()
|
||||||
|
else abortCacheWorker()
|
||||||
|
}
|
||||||
|
function onWifi() {
|
||||||
|
setWifiOnly(wifiOnly.value)
|
||||||
|
}
|
||||||
|
function onStart() {
|
||||||
|
if (!enabled.value) {
|
||||||
|
enabled.value = true
|
||||||
|
setCacheEnabled(true)
|
||||||
|
}
|
||||||
|
startCacheWorker()
|
||||||
|
}
|
||||||
|
function onStop() {
|
||||||
|
abortCacheWorker()
|
||||||
|
}
|
||||||
|
async function onClear() {
|
||||||
|
if (!confirm('确认清空所有离线缓存?')) return
|
||||||
|
await clearCache()
|
||||||
|
await refreshUsage()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUsage() {
|
||||||
|
usage.value = await estimateUsage()
|
||||||
|
pct.value = stats.value.total > 0
|
||||||
|
? Math.round((stats.value.done / stats.value.total) * 100)
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProgress() {
|
||||||
|
pct.value = stats.value.total > 0
|
||||||
|
? Math.round((stats.value.done / stats.value.total) * 100)
|
||||||
|
: 0
|
||||||
|
refreshUsage()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshUsage()
|
||||||
|
window.addEventListener('music-cache-progress', onProgress)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('music-cache-progress', onProgress)
|
||||||
|
})
|
||||||
|
|
||||||
|
function fmtMb(b) {
|
||||||
|
if (!b) return '?'
|
||||||
|
if (b < 1024 * 1024) return (b / 1024).toFixed(0) + ' KB'
|
||||||
|
if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB'
|
||||||
|
return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { min-height: 100%; display: flex; flex-direction: column; background: var(--bg); }
|
||||||
|
.bar {
|
||||||
|
display: flex; align-items: center; gap: 18px;
|
||||||
|
padding: 14px 22px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
.bar h1 { font-size: 18px; font-weight: 600; }
|
||||||
|
.back { color: var(--text-dim); font-size: 14px; }
|
||||||
|
.back:hover { color: var(--accent); text-decoration: none; }
|
||||||
|
|
||||||
|
.body { max-width: 720px; margin: 0 auto; padding: 24px 22px 80px; width: 100%; }
|
||||||
|
.block {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.block h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.desc { color: var(--text-dim); font-size: 13px; line-height: 1.7; margin-bottom: 14px; }
|
||||||
|
.desc b { color: var(--accent); }
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
.row:last-of-type { border-bottom: none; }
|
||||||
|
.row.disabled { opacity: 0.5; }
|
||||||
|
.row .lbl { font-size: 14px; }
|
||||||
|
.row input[type=checkbox] { width: 18px; height: 18px; cursor: pointer; }
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.stat .key { color: var(--text-mute); display: block; margin-bottom: 4px; }
|
||||||
|
.stat .val { color: var(--text); font-weight: 600; }
|
||||||
|
|
||||||
|
.progress { margin: 14px 0; }
|
||||||
|
.bar-bg {
|
||||||
|
height: 6px; background: var(--bg-elev); border-radius: 3px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%; background: var(--accent-strong); transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.progress-text { font-size: 11px; color: var(--text-mute); margin-top: 6px; }
|
||||||
|
|
||||||
|
.actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
|
||||||
|
.btn {
|
||||||
|
font-size: 13px; padding: 8px 16px; border-radius: 6px;
|
||||||
|
background: var(--accent-strong); color: #fff; font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) { background: var(--accent); }
|
||||||
|
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.btn.danger { background: rgba(239,68,68,0.15); color: var(--accent-red); }
|
||||||
|
.btn.danger:hover:not(:disabled) { background: rgba(239,68,68,0.3); }
|
||||||
|
</style>
|
||||||
@@ -1,11 +1,40 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
VitePWA({
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw.js',
|
||||||
|
injectRegister: false, // 注册由 src/pwa.js 手动处理
|
||||||
|
injectManifest: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
|
||||||
|
},
|
||||||
|
includeAssets: ['favicon-48x48.png', 'apple-touch-icon-180x180.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'Music · Euphon',
|
||||||
|
short_name: 'Music',
|
||||||
|
description: '听歌 + 练琴 曲目管理(离线缓存可选)',
|
||||||
|
lang: 'zh-CN',
|
||||||
|
theme_color: '#0f0f0f',
|
||||||
|
background_color: '#0f0f0f',
|
||||||
|
display: 'standalone',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
icons: [
|
||||||
|
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
{ src: 'maskable-icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8080'
|
'/api': 'http://localhost:8080',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user