music: 新建 music app,替换 piano-sheet
deploy cube / build-and-deploy (push) Successful in 1m10s
deploy music / build-and-deploy (push) Successful in 1m47s
deploy simpleasm / build-and-deploy (push) Successful in 1m20s

听歌 + 练琴曲目管理:
- 数据:piece (title/artist/category/lyrics/play_count/notes) + attachments (audio/video/pdf/image; image 带 role=chord/numbered/staff)
- 后端 axum + sqlite,附件流式落 PVC,ServeFile 支持 Range(视频拖动)
- 前端 guitar 风格 player:左 sidebar + tabs(歌词/吉他谱/简谱/五线谱/PDF/视频),LRC 同步、快捷键、笔记自动保存
- ns cube-music + music.famzheng.me + bodylimit 5GiB
- scripts/import_guitar.py 用于把 oci /data/guitar/ 旧曲库导入
This commit is contained in:
Fam Zheng
2026-05-09 22:36:14 +01:00
parent 58f344db85
commit 1a8f297302
30 changed files with 2683 additions and 1314 deletions
@@ -1,24 +1,24 @@
name: deploy piano-sheet
# piano.famzheng.me — 钢琴谱管理 / 阅读。host shell runnerfam 用户)。
name: deploy music
# music.famzheng.me — 听歌 + 练琴 曲目管理。host shell runnerfam 用户)。
on:
push:
branches: [master]
paths:
- 'apps/piano-sheet/**'
- 'apps/music/**'
- 'crates/cube-core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/deploy-piano-sheet.yml'
- '.gitea/workflows/deploy-music.yml'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
APP: piano-sheet
NS: cube-piano
IMAGE: registry.famzheng.me/mochi/piano-sheet
APP: music
NS: cube-music
IMAGE: registry.famzheng.me/mochi/music
steps:
- uses: actions/checkout@v4
@@ -48,7 +48,7 @@ jobs:
- name: Initialize K8s resources
run: |
kubectl apply -f apps/piano-sheet/k8s/all.yaml
kubectl apply -f apps/music/k8s/all.yaml
- name: Roll out to k3s
run: |
Generated
+15 -14
View File
@@ -441,6 +441,21 @@ dependencies = [
"version_check",
]
[[package]]
name = "music"
version = "0.1.0"
dependencies = [
"axum",
"cube-core",
"rusqlite",
"serde",
"serde_json",
"tokio",
"tower",
"tower-http",
"tracing",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -485,20 +500,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "piano-sheet"
version = "0.1.0"
dependencies = [
"axum",
"cube-core",
"rusqlite",
"serde",
"serde_json",
"tokio",
"tower-http",
"tracing",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
+1 -1
View File
@@ -4,7 +4,7 @@ members = [
"crates/cube-core",
"apps/cube",
"apps/simpleasm",
"apps/piano-sheet",
"apps/music",
]
[workspace.package]
@@ -1,15 +1,16 @@
[package]
name = "piano-sheet"
name = "music"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "piano.famzheng.me — 钢琴谱管理 / 阅读 app,多图谱面 BLOB 存 sqlite"
description = "music.famzheng.me — 听歌 + 练琴 曲目管理 (video / audio / pdf / png)"
[dependencies]
cube-core = { path = "../../crates/cube-core" }
axum = { workspace = true, features = ["multipart"] }
tokio = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
FROM scratch
COPY target/x86_64-unknown-linux-musl/release/music /music
COPY apps/music/frontend/dist /dist
EXPOSE 8080
ENTRYPOINT ["/music"]
@@ -2,9 +2,9 @@
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="theme-color" content="#0a0e1a">
<title>Piano Sheet</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0f0f0f">
<title>Music · Euphon</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -1,11 +1,11 @@
{
"name": "piano-sheet",
"name": "music",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "piano-sheet",
"name": "music",
"version": "1.0.0",
"dependencies": {
"pinia": "^2.1.7",
@@ -832,53 +832,53 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
"integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
"integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/shared": "3.5.33",
"@babel/parser": "^7.29.3",
"@vue/shared": "3.5.34",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
"integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
"integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/compiler-core": "3.5.34",
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz",
"integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
"integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.33",
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33",
"@babel/parser": "^7.29.3",
"@vue/compiler-core": "3.5.34",
"@vue/compiler-dom": "3.5.34",
"@vue/compiler-ssr": "3.5.34",
"@vue/shared": "3.5.34",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.10",
"postcss": "^8.5.14",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
"integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
"integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/compiler-dom": "3.5.34",
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/devtools-api": {
@@ -888,53 +888,53 @@
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
"integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
"integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.33"
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
"integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
"integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/reactivity": "3.5.34",
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
"integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
"integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.33",
"@vue/runtime-core": "3.5.33",
"@vue/shared": "3.5.33",
"@vue/reactivity": "3.5.34",
"@vue/runtime-core": "3.5.34",
"@vue/shared": "3.5.34",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
"integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
"integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/compiler-ssr": "3.5.34",
"@vue/shared": "3.5.34"
},
"peerDependencies": {
"vue": "3.5.33"
"vue": "3.5.34"
}
},
"node_modules/@vue/shared": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz",
"integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
"license": "MIT"
},
"node_modules/csstype": {
@@ -1213,16 +1213,16 @@
}
},
"node_modules/vue": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-sfc": "3.5.33",
"@vue/runtime-dom": "3.5.33",
"@vue/server-renderer": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/compiler-dom": "3.5.34",
"@vue/compiler-sfc": "3.5.34",
"@vue/runtime-dom": "3.5.34",
"@vue/server-renderer": "3.5.34",
"@vue/shared": "3.5.34"
},
"peerDependencies": {
"typescript": "*"
@@ -1,5 +1,5 @@
{
"name": "piano-sheet",
"name": "music",
"private": true,
"version": "1.0.0",
"scripts": {
+65
View File
@@ -0,0 +1,65 @@
<template>
<router-view />
</template>
<style>
:root {
--bg: #0f0f0f;
--bg-elev: #161616;
--bg-card: #1a1a2e;
--bg-hover: #232342;
--bg-active: #2a1a3e;
--border: #2a2a3a;
--border-soft: #1f1f2a;
--text: #e0e0e0;
--text-dim: #a0a0a0;
--text-mute: #666;
--accent: #c084fc;
--accent-strong: #7c5cbf;
--accent-cyan: #06b6d4;
--accent-green: #4ade80;
--accent-amber: #f59e0b;
--accent-red: #ef4444;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--radius: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app { height: 100%; }
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
overscroll-behavior: none;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
button {
font-family: var(--font-sans);
cursor: pointer;
border: none;
background: none;
color: inherit;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
input, textarea {
font-family: var(--font-sans);
background: transparent;
border: none;
color: inherit;
outline: none;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-mute); }
</style>
+59
View File
@@ -0,0 +1,59 @@
// 薄薄一层 fetch 封装。错误统一抛 Error(message)。
async function jsonOrThrow(res) {
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `${res.status} ${res.statusText}`)
}
return res.json()
}
export function listPieces() {
return fetch('/api/pieces').then(jsonOrThrow)
}
export function getPiece(id) {
return fetch(`/api/pieces/${id}`).then(jsonOrThrow)
}
export function createPiece(body) {
return fetch('/api/pieces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(jsonOrThrow)
}
export function patchPiece(id, body) {
return fetch(`/api/pieces/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(jsonOrThrow)
}
export function deletePiece(id) {
return fetch(`/api/pieces/${id}`, { method: 'DELETE' }).then(jsonOrThrow)
}
export function recordPlay(id) {
return fetch(`/api/pieces/${id}/play`, { method: 'POST' }).then(jsonOrThrow)
}
// `role`: null | 'chord' | 'numbered' | 'staff'
export function uploadAttachments(pieceId, files, role) {
const fd = new FormData()
for (const f of files) fd.append('files', f, f.name)
const url = role
? `/api/pieces/${pieceId}/attachments?role=${encodeURIComponent(role)}`
: `/api/pieces/${pieceId}/attachments`
return fetch(url, { method: 'POST', body: fd }).then(jsonOrThrow)
}
export function deleteAttachment(id) {
return fetch(`/api/attachments/${id}`, { method: 'DELETE' }).then(jsonOrThrow)
}
export function attachmentUrl(id) {
return `/api/attachments/${id}`
}
+27
View File
@@ -0,0 +1,27 @@
// LRC parser: takes raw text → [{ time: seconds, text }] sorted by time.
export function parseLrc(text) {
if (!text) return []
const out = []
for (const raw of text.split(/\r?\n/)) {
// 一行可能有多个 time tag[00:12.34][00:30.00]歌词
const tags = []
let rest = raw
const re = /^\[(\d+):(\d+)(?:[.:](\d+))?\]/
while (true) {
const m = re.exec(rest)
if (!m) break
const min = parseInt(m[1], 10)
const sec = parseInt(m[2], 10)
const fracRaw = m[3] || '0'
const frac = parseInt(fracRaw, 10) / Math.pow(10, fracRaw.length)
tags.push(min * 60 + sec + frac)
rest = rest.slice(m[0].length)
}
const txt = rest.trim()
if (!txt || tags.length === 0) continue
for (const t of tags) out.push({ time: t, text: txt })
}
out.sort((a, b) => a.time - b.time)
return out
}
+31
View File
@@ -0,0 +1,31 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'player',
component: () => import('../views/PlayerView.vue'),
},
{
path: '/piece/:id',
name: 'piece',
component: () => import('../views/PlayerView.vue'),
props: (route) => ({ id: Number(route.params.id) }),
},
{
path: '/upload',
name: 'upload',
component: () => import('../views/UploadView.vue'),
},
{
path: '/piece/:id/edit',
name: 'edit',
component: () => import('../views/EditView.vue'),
props: (route) => ({ id: Number(route.params.id) }),
},
]
export default createRouter({
history: createWebHistory(),
routes,
})
+396
View File
@@ -0,0 +1,396 @@
<template>
<div class="page">
<header class="bar">
<router-link to="/" class="back"> 列表</router-link>
<router-link :to="{ name: 'piece', params: { id } }" class="back"> 播放</router-link>
<h1 v-if="piece">编辑{{ piece.title }}</h1>
<h1 v-else>编辑曲目</h1>
</header>
<main class="body">
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="loadErr" class="err">{{ loadErr }}</p>
<template v-else-if="piece">
<section class="block">
<h2>基本信息</h2>
<label class="field">
<span>标题</span>
<input v-model="form.title" />
</label>
<label class="field">
<span>歌手 / 作者</span>
<input v-model="form.artist" />
</label>
<label class="field">
<span>分类</span>
<input v-model="form.category" list="cat-list" />
<datalist id="cat-list">
<option value="钢琴曲" />
<option value="流行" />
<option value="练习曲" />
<option value="古典" />
<option value="爵士" />
</datalist>
</label>
<label class="field">
<span>歌词</span>
<textarea v-model="form.lyrics" rows="8" />
</label>
<label class="field">
<span>笔记</span>
<textarea v-model="form.notes" rows="3" />
</label>
<div class="actions">
<button class="btn-primary" :disabled="savingMeta" @click="saveMeta">
{{ savingMeta ? '保存中' : '保存基本信息' }}
</button>
<span v-if="savedFlash" class="flash">已保存</span>
</div>
</section>
<section class="block">
<h2>添加附件</h2>
<p class="hint-sub">
一次可选多个文件video / audio / pdf / image 自动识别
图片需要选 <b>角色</b>吉他谱 / 简谱 / 五线谱其它类型角色无效
</p>
<div class="upload-row">
<label class="role-pick">
<span>角色</span>
<select v-model="uploadRole">
<option :value="null"> 自动 / 通用图 </option>
<option value="chord">吉他谱</option>
<option value="numbered">简谱</option>
<option value="staff">五线谱</option>
</select>
</label>
<label class="file-pick">
<input
ref="fileInputEl"
type="file"
multiple
@change="onFiles"
accept="audio/*,video/*,application/pdf,image/*"
/>
<span class="btn-ghost">{{ pendingFiles.length ? `已选 ${pendingFiles.length}` : '选择文件' }}</span>
</label>
<button
class="btn-primary"
:disabled="!pendingFiles.length || uploading"
@click="upload"
>
{{ uploading ? `上传中… ${uploadPct}%` : '上传' }}
</button>
</div>
<p v-if="uploadErr" class="err">{{ uploadErr }}</p>
</section>
<section class="block">
<h2>已有附件 ({{ piece.attachments.length }})</h2>
<p v-if="!piece.attachments.length" class="hint-sub">还没有附件</p>
<ul class="atts">
<li v-for="att in piece.attachments" :key="att.id">
<div class="att-info">
<span class="att-kind" :class="att.kind">{{ kindLabel(att.kind) }}{{ att.role ? '·' + roleLabel(att.role) : '' }}</span>
<a :href="`/api/attachments/${att.id}`" target="_blank">{{ att.filename }}</a>
<span class="att-size">{{ fmtSize(att.size_bytes) }}</span>
</div>
<button class="btn-danger" @click="removeAtt(att.id)">删除</button>
</li>
</ul>
</section>
<section class="block danger-block">
<h2>危险操作</h2>
<button class="btn-danger" @click="removePiece">删除整首曲目</button>
</section>
</template>
</main>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
getPiece,
patchPiece,
deletePiece,
uploadAttachments,
deleteAttachment,
} from '../lib/api.js'
const props = defineProps({ id: { type: Number, required: true } })
const router = useRouter()
const piece = ref(null)
const loading = ref(true)
const loadErr = ref('')
const form = reactive({
title: '',
artist: '',
category: '',
lyrics: '',
notes: '',
})
const savingMeta = ref(false)
const savedFlash = ref(false)
const fileInputEl = ref(null)
const pendingFiles = ref([])
const uploadRole = ref(null)
const uploading = ref(false)
const uploadPct = ref(0)
const uploadErr = ref('')
async function load() {
loading.value = true
loadErr.value = ''
try {
const p = await getPiece(props.id)
piece.value = p
form.title = p.title || ''
form.artist = p.artist || ''
form.category = p.category || ''
form.lyrics = p.lyrics || ''
form.notes = p.notes || ''
} catch (e) {
loadErr.value = e.message || String(e)
} finally {
loading.value = false
}
}
async function saveMeta() {
savingMeta.value = true
try {
await patchPiece(props.id, {
title: form.title.trim() || piece.value.title,
artist: form.artist.trim() || null,
category: form.category.trim() || null,
lyrics: form.lyrics || null,
notes: form.notes || null,
})
savedFlash.value = true
setTimeout(() => (savedFlash.value = false), 1500)
await load()
} catch (e) {
alert(e.message || String(e))
} finally {
savingMeta.value = false
}
}
function onFiles(e) {
pendingFiles.value = Array.from(e.target.files || [])
}
async function upload() {
if (!pendingFiles.value.length) return
uploading.value = true
uploadErr.value = ''
uploadPct.value = 0
try {
// 简单 fetch(无进度),如需进度可改 XHR
await uploadAttachments(props.id, pendingFiles.value, uploadRole.value || null)
pendingFiles.value = []
if (fileInputEl.value) fileInputEl.value.value = ''
await load()
} catch (e) {
uploadErr.value = e.message || String(e)
} finally {
uploading.value = false
}
}
async function removeAtt(id) {
if (!confirm('删除这个附件?')) return
try {
await deleteAttachment(id)
await load()
} catch (e) {
alert(e.message || String(e))
}
}
async function removePiece() {
if (!confirm(`确认删除 "${piece.value.title}" 及其全部附件?此操作不可逆。`)) return
try {
await deletePiece(props.id)
router.push({ name: 'player' })
} catch (e) {
alert(e.message || String(e))
}
}
function kindLabel(k) {
return ({ audio: '音频', video: '视频', pdf: 'PDF', image: '图片' })[k] || k
}
function roleLabel(r) {
return ({ chord: '吉他谱', numbered: '简谱', staff: '五线谱' })[r] || r
}
function fmtSize(b) {
if (b < 1024) return b + ' B'
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB'
return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
onMounted(load)
</script>
<style scoped>
.page { min-height: 100%; display: flex; flex-direction: column; }
.bar {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 22px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-soft);
}
.bar h1 { font-size: 18px; font-weight: 600; flex: 1; }
.back { color: var(--text-dim); font-size: 14px; }
.back:hover { color: var(--accent); text-decoration: none; }
.body { max-width: 760px; margin: 0 auto; padding: 24px 22px 100px; width: 100%; }
.hint { color: var(--text-mute); padding: 40px 0; text-align: center; }
.err { color: var(--accent-red); background: rgba(239,68,68,0.1); padding: 10px 12px; border-radius: 6px; margin-top: 8px; }
.block {
background: var(--bg-card);
border-radius: 10px;
padding: 18px 20px;
margin-bottom: 18px;
}
.block h2 {
font-size: 14px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 14px;
font-weight: 600;
}
.hint-sub { color: var(--text-mute); font-size: 13px; margin-bottom: 12px; line-height: 1.5; }
.hint-sub b { color: var(--text-dim); }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.field span {
font-size: 11px;
color: var(--text-mute);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.field input, .field textarea, .field select {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 14px;
color: var(--text);
}
.field input:focus, .field textarea:focus, .field select:focus { border-color: var(--accent-strong); outline: none; }
.field textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
resize: vertical;
line-height: 1.5;
}
.actions { display: flex; align-items: center; gap: 14px; margin-top: 4px; }
.flash { color: var(--accent-green); font-size: 12px; }
.btn-primary {
background: var(--accent-strong);
color: #fff;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
}
.btn-primary:hover:not(:disabled) { background: var(--accent); }
.upload-row {
display: flex;
gap: 12px;
align-items: end;
flex-wrap: wrap;
}
.role-pick { display: flex; flex-direction: column; gap: 6px; }
.role-pick span { font-size: 11px; color: var(--text-mute); text-transform: uppercase; }
.role-pick select {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
color: var(--text);
cursor: pointer;
}
.file-pick { position: relative; display: inline-block; }
.file-pick input[type=file] {
position: absolute;
opacity: 0;
inset: 0;
cursor: pointer;
}
.btn-ghost {
display: inline-block;
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.btn-ghost:hover { background: var(--bg-hover); }
.atts { list-style: none; display: flex; flex-direction: column; gap: 6px; }
.atts li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--bg-elev);
border-radius: 6px;
gap: 12px;
}
.att-info { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
.att-kind {
font-size: 11px;
padding: 2px 8px;
border-radius: 8px;
background: rgba(124, 92, 191, 0.15);
color: var(--accent);
white-space: nowrap;
flex-shrink: 0;
}
.att-kind.video { background: rgba(6, 182, 212, 0.15); color: var(--accent-cyan); }
.att-kind.pdf { background: rgba(245, 158, 11, 0.15); color: var(--accent-amber); }
.att-info a {
color: var(--text);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.att-info a:hover { color: var(--accent); }
.att-size { color: var(--text-mute); font-size: 11px; flex-shrink: 0; }
.btn-danger {
background: rgba(239, 68, 68, 0.15);
color: var(--accent-red);
border-radius: 6px;
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
}
.btn-danger:hover { background: rgba(239, 68, 68, 0.3); }
.danger-block { background: rgba(239, 68, 68, 0.05); }
.danger-block .btn-danger { padding: 8px 18px; font-size: 13px; }
</style>
@@ -0,0 +1,919 @@
<template>
<div class="root">
<header class="topbar">
<h1>🎵 Music</h1>
<input
class="search"
type="text"
v-model="search"
placeholder="搜索曲目 / 歌手"
/>
<span class="count">{{ filtered.length }} / {{ pieces.length }} </span>
<router-link to="/upload" class="btn-add" title="新增曲目"></router-link>
</header>
<div class="main">
<aside class="sidebar" :class="{ 'has-selected': !!selected }">
<div class="sort-bar">
<button :class="{ active: sortMode === 'name' }" @click="setSort('name')">名称</button>
<button :class="{ active: sortMode === 'hot' }" @click="setSort('hot')">最多播放</button>
<button :class="{ active: sortMode === 'least' }" @click="setSort('least')">最少播放</button>
<button :class="{ active: sortMode === 'recent' }" @click="setSort('recent')">最近</button>
<button :class="{ active: sortMode === 'random' }" @click="setSort('random')">随机</button>
</div>
<div class="playlist">
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="loadError" class="hint err">{{ loadError }}</p>
<p v-else-if="filtered.length === 0" class="hint">
空空如也<router-link to="/upload">先加一首</router-link>
</p>
<div
v-for="p in filtered"
:key="p.id"
class="row"
:class="{ active: selectedId === p.id }"
@click="selectPiece(p.id)"
>
<div class="row-main">
<div class="row-title">{{ p.title }}</div>
<div class="row-meta">
<span v-if="p.artist">{{ p.artist }}</span>
<span v-if="p.category" class="cat">{{ p.category }}</span>
</div>
</div>
<div class="badges">
<span v-if="p.has_lyrics" class="badge" title="有歌词"></span>
<span v-for="k in iconKinds(p.kinds)" :key="k" class="badge" :title="k">{{ kindLabel(k) }}</span>
<span v-if="p.play_count > 0" class="play-count">{{ p.play_count }}</span>
</div>
</div>
</div>
</aside>
<section class="player-area">
<div v-if="!selected" class="empty">
<p>从左边挑一首吧 🎶</p>
</div>
<template v-else>
<header class="now-playing">
<h2>{{ selected.title }}</h2>
<div class="np-sub">
<span v-if="selected.artist">{{ selected.artist }}</span>
<span v-if="selected.category">· {{ selected.category }}</span>
<span v-if="selected.play_count">· 播放 {{ selected.play_count }} </span>
<router-link :to="{ name: 'edit', params: { id: selected.id } }" class="edit-link">编辑</router-link>
</div>
</header>
<nav v-if="tabs.length" class="tabs">
<button
v-for="t in tabs"
:key="t.key"
:class="{ active: activeTab === t.key }"
@click="setTab(t.key)"
>{{ t.label }}<span v-if="t.count > 1" class="tab-n">{{ t.count }}</span></button>
</nav>
<main class="content">
<!-- 歌词 -->
<div v-show="activeTab === 'lyrics'" class="lyrics-box" ref="lyricsBoxEl">
<div v-if="lyricsLines.length === 0" class="lyrics-none">
<span v-if="selected.lyrics">这首歌的歌词不是 LRC 格式</span>
<span v-else>暂无歌词用心感受 🎶</span>
<pre v-if="selected.lyrics" class="lyrics-raw">{{ selected.lyrics }}</pre>
</div>
<div
v-for="(line, i) in lyricsLines"
:key="i"
class="lyrics-line"
:class="{ active: i === activeLyricIdx }"
:data-i="i"
@click="seek(line.time)"
>{{ line.text }}</div>
</div>
<!-- 谱面chord / numbered / staff -->
<div v-show="['chord', 'numbered', 'staff'].includes(activeTab)" class="sheet-box">
<img
v-for="att in roleAttachments(activeTab)"
:key="att.id"
:src="attachmentUrl(att.id)"
:alt="att.filename"
class="sheet-img"
/>
</div>
<!-- PDF -->
<div v-show="activeTab === 'pdf'" class="pdf-box">
<iframe
v-for="att in pdfAttachments"
:key="att.id"
:src="attachmentUrl(att.id)"
:title="att.filename"
class="pdf-frame"
/>
</div>
<!-- 视频 -->
<div v-show="activeTab === 'video'" class="video-box">
<video
v-for="att in videoAttachments"
:key="att.id"
:src="attachmentUrl(att.id)"
controls
class="video-el"
/>
</div>
</main>
<footer class="controls">
<div class="ctrl-row">
<label class="repeat" :class="{ on: repeatOne }" @click="repeatOne = !repeatOne">
<span>循环</span>
<span class="track"><span class="thumb"></span></span>
</label>
<button @click="prev" class="btn-icon" title="上一首"></button>
<button @click="togglePlay" class="btn-icon big" :title="playing ? '暂停' : '播放'">
{{ playing ? '⏸' : '▶' }}
</button>
<button @click="next" class="btn-icon" title="下一首"></button>
<span class="time">{{ fmtTime(currentTime) }}</span>
<div class="bar" @click="seekBar">
<div class="fill" :style="{ width: progressPct + '%' }"></div>
</div>
<span class="time">{{ fmtTime(duration) }}</span>
</div>
</footer>
<audio
ref="audioEl"
@timeupdate="onTimeUpdate"
@loadedmetadata="onLoaded"
@ended="onEnded"
@play="playing = true"
@pause="playing = false"
/>
</template>
</section>
<aside v-if="selected" class="notes" :class="{ active: notesOpen }">
<header @click="notesOpen = !notesOpen">
<span>笔记</span>
<span v-if="notesSavedFlash" class="saved">已保存</span>
</header>
<textarea
v-model="notesDraft"
@input="onNotesInput"
placeholder="练琴心得 / chord 备注 / 难点…"
/>
</aside>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
listPieces,
getPiece,
patchPiece,
recordPlay,
attachmentUrl as attUrl,
} from '../lib/api.js'
import { parseLrc } from '../lib/lrc.js'
const route = useRoute()
const router = useRouter()
const pieces = ref([])
const loading = ref(true)
const loadError = ref('')
const selected = ref(null)
const selectedId = ref(null)
const search = ref('')
const sortMode = ref(localStorage.getItem('music.sort') || 'name')
const repeatOne = ref(false)
const audioEl = ref(null)
const lyricsBoxEl = ref(null)
const playing = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const activeTab = ref('lyrics')
const notesOpen = ref(false)
const notesDraft = ref('')
const notesSavedFlash = ref(false)
let notesTimer = null
let randomSeed = Math.random()
let lastReportedId = null
const lyricsLines = computed(() => parseLrc(selected.value?.lyrics || ''))
const activeLyricIdx = computed(() => {
const lines = lyricsLines.value
if (!lines.length) return -1
let idx = -1
for (let i = lines.length - 1; i >= 0; i--) {
if (currentTime.value >= lines[i].time) { idx = i; break }
}
return idx
})
const audioAttachments = computed(() =>
(selected.value?.attachments || []).filter(a => a.kind === 'audio'))
const videoAttachments = computed(() =>
(selected.value?.attachments || []).filter(a => a.kind === 'video'))
const pdfAttachments = computed(() =>
(selected.value?.attachments || []).filter(a => a.kind === 'pdf'))
function roleAttachments(role) {
return (selected.value?.attachments || []).filter(
a => a.kind === 'image' && a.role === role,
)
}
const tabs = computed(() => {
if (!selected.value) return []
const list = []
if (selected.value.lyrics) list.push({ key: 'lyrics', label: '歌词', count: 0 })
const chord = roleAttachments('chord').length
if (chord) list.push({ key: 'chord', label: '吉他谱', count: chord })
const num = roleAttachments('numbered').length
if (num) list.push({ key: 'numbered', label: '简谱', count: num })
const staff = roleAttachments('staff').length
if (staff) list.push({ key: 'staff', label: '五线谱', count: staff })
if (pdfAttachments.value.length) list.push({ key: 'pdf', label: '乐谱 PDF', count: pdfAttachments.value.length })
if (videoAttachments.value.length) list.push({ key: 'video', label: '视频', count: videoAttachments.value.length })
// 没歌词也至少给一个 fallback tab
if (list.length === 0) list.push({ key: 'lyrics', label: '歌词', count: 0 })
return list
})
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
let arr = pieces.value
if (q) {
arr = arr.filter(p => {
const hay = `${p.title} ${p.artist || ''} ${p.category || ''}`.toLowerCase()
return hay.includes(q)
})
}
arr = [...arr]
switch (sortMode.value) {
case 'hot':
arr.sort((a, b) => b.play_count - a.play_count || a.title.localeCompare(b.title, 'zh'))
break
case 'least':
arr.sort((a, b) => a.play_count - b.play_count || a.title.localeCompare(b.title, 'zh'))
break
case 'recent':
arr.sort((a, b) => {
const ta = a.last_played_at || ''
const tb = b.last_played_at || ''
return tb.localeCompare(ta) || a.title.localeCompare(b.title, 'zh')
})
break
case 'random': {
// stable random per session
const seeded = arr.map((p, i) => ({ p, k: hash(p.id, randomSeed) }))
seeded.sort((a, b) => a.k - b.k)
arr = seeded.map(x => x.p)
break
}
default:
arr.sort((a, b) => a.title.localeCompare(b.title, 'zh'))
}
return arr
})
function hash(id, seed) {
// 小随机 hashsort key 稳定
let x = (id ^ Math.floor(seed * 1e9)) >>> 0
x = (x ^ (x << 13)) >>> 0
x = (x ^ (x >>> 17)) >>> 0
x = (x ^ (x << 5)) >>> 0
return x
}
function setSort(mode) {
if (mode === 'random' && sortMode.value === 'random') {
randomSeed = Math.random()
}
sortMode.value = mode
localStorage.setItem('music.sort', mode)
}
function iconKinds(kinds) {
// 显示主要 kind 徽章;'image' / 'audio' / 'video' / 'pdf'
const order = ['audio', 'video', 'pdf', 'image']
return order.filter(k => kinds.includes(k))
}
function kindLabel(k) {
return ({ audio: '音', video: '视', pdf: 'PDF', image: '谱' })[k] || k
}
async function loadPieces() {
loading.value = true
loadError.value = ''
try {
pieces.value = await listPieces()
} catch (e) {
loadError.value = e.message || String(e)
} finally {
loading.value = false
}
}
async function loadPiece(id) {
selected.value = null
notesDraft.value = ''
if (!id) return
try {
const p = await getPiece(id)
selected.value = p
notesDraft.value = p.notes || ''
selectedId.value = p.id
// 默认 tab:有歌词进 lyrics,否则进第一个 tab
const t = tabs.value
if (!t.find(x => x.key === activeTab.value)) {
activeTab.value = t[0]?.key || 'lyrics'
}
// 自动开播放(如果有 audio)
await nextTick()
const first = audioAttachments.value[0]
if (first && audioEl.value) {
audioEl.value.src = attUrl(first.id)
audioEl.value.play().catch(() => {})
} else if (audioEl.value) {
audioEl.value.removeAttribute('src')
audioEl.value.load()
}
lastReportedId = null
} catch (e) {
loadError.value = e.message || String(e)
}
}
function selectPiece(id) {
router.push({ name: 'piece', params: { id } })
}
function attachmentUrl(id) { return attUrl(id) }
// player controls
function togglePlay() {
if (!audioEl.value || !audioEl.value.src) return
if (audioEl.value.paused) audioEl.value.play()
else audioEl.value.pause()
}
function next() {
if (!filtered.value.length) return
const idx = filtered.value.findIndex(p => p.id === selectedId.value)
const nextIdx = (idx + 1) % filtered.value.length
selectPiece(filtered.value[nextIdx].id)
}
function prev() {
if (!filtered.value.length) return
const idx = filtered.value.findIndex(p => p.id === selectedId.value)
const prevIdx = (idx - 1 + filtered.value.length) % filtered.value.length
selectPiece(filtered.value[prevIdx].id)
}
function seek(t) {
if (!audioEl.value) return
audioEl.value.currentTime = t
if (audioEl.value.paused) audioEl.value.play().catch(() => {})
}
function seekBar(e) {
if (!audioEl.value || !duration.value) return
const rect = e.currentTarget.getBoundingClientRect()
const ratio = (e.clientX - rect.left) / rect.width
audioEl.value.currentTime = Math.max(0, Math.min(1, ratio)) * duration.value
}
function onTimeUpdate(e) {
currentTime.value = e.target.currentTime
// 上报播放(≥10s 时)
if (selectedId.value && lastReportedId !== selectedId.value && currentTime.value >= 10) {
lastReportedId = selectedId.value
recordPlay(selectedId.value).then(d => {
// 同步本地 + list
if (selected.value) selected.value.play_count = d.play_count
const inList = pieces.value.find(p => p.id === selectedId.value)
if (inList) {
inList.play_count = d.play_count
inList.last_played_at = new Date().toISOString().replace('T', ' ').slice(0, 19)
}
}).catch(() => {})
}
// 自动滚歌词
if (activeTab.value === 'lyrics' && lyricsBoxEl.value) {
const idx = activeLyricIdx.value
if (idx >= 0) {
const el = lyricsBoxEl.value.querySelector(`.lyrics-line[data-i="${idx}"]`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
// 持久化最近播放进度
if (selectedId.value) {
localStorage.setItem('music.last', JSON.stringify({
id: selectedId.value,
time: currentTime.value,
}))
}
}
function onLoaded(e) {
duration.value = e.target.duration || 0
}
function onEnded() {
if (repeatOne.value && audioEl.value) {
audioEl.value.currentTime = 0
audioEl.value.play().catch(() => {})
} else {
next()
}
}
const progressPct = computed(() => {
if (!duration.value) return 0
return Math.max(0, Math.min(100, (currentTime.value / duration.value) * 100))
})
function fmtTime(s) {
if (!s || isNaN(s)) return '0:00'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return m + ':' + (sec < 10 ? '0' : '') + sec
}
function setTab(k) {
activeTab.value = k
}
// notes auto-save
function onNotesInput() {
if (!selectedId.value) return
if (notesTimer) clearTimeout(notesTimer)
notesTimer = setTimeout(async () => {
try {
await patchPiece(selectedId.value, { notes: notesDraft.value || null })
notesSavedFlash.value = true
setTimeout(() => (notesSavedFlash.value = false), 1500)
} catch {}
}, 600)
}
// keyboard
function onKeyDown(e) {
const tag = (e.target.tagName || '').toLowerCase()
if (tag === 'input' || tag === 'textarea') return
if (e.code === 'Space') { e.preventDefault(); togglePlay() }
else if (e.code === 'ArrowRight') {
if (audioEl.value) audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, duration.value)
}
else if (e.code === 'ArrowLeft') {
if (audioEl.value) audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0)
}
else if (e.key === 'Tab') {
e.preventDefault()
const idx = tabs.value.findIndex(t => t.key === activeTab.value)
const nx = tabs.value[(idx + 1) % tabs.value.length]
if (nx) activeTab.value = nx.key
}
}
// route → selected
watch(() => route.params.id, async (idStr) => {
const id = idStr ? Number(idStr) : null
if (id !== selectedId.value) {
selectedId.value = id
if (id) await loadPiece(id)
else selected.value = null
}
}, { immediate: false })
onMounted(async () => {
document.addEventListener('keydown', onKeyDown)
await loadPieces()
const id = route.params.id ? Number(route.params.id) : null
if (id) {
selectedId.value = id
await loadPiece(id)
} else {
// 无路由 id:恢复 last
try {
const last = JSON.parse(localStorage.getItem('music.last') || 'null')
if (last && pieces.value.find(p => p.id === last.id)) {
router.replace({ name: 'piece', params: { id: last.id } })
}
} catch {}
}
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
if (notesTimer) clearTimeout(notesTimer)
})
</script>
<style scoped>
.root {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg);
}
.topbar {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 20px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-soft);
flex-shrink: 0;
}
.topbar h1 { font-size: 18px; font-weight: 600; white-space: nowrap; }
.topbar .search {
flex: 1;
max-width: 380px;
padding: 8px 14px;
border-radius: 20px;
border: 1px solid var(--border);
background: var(--bg);
font-size: 14px;
}
.topbar .search:focus { border-color: var(--accent-strong); }
.topbar .count { color: var(--text-mute); font-size: 12px; white-space: nowrap; }
.topbar .btn-add {
width: 36px; height: 36px;
border-radius: 50%;
background: var(--accent-strong);
color: #fff;
font-size: 22px; font-weight: 600;
display: inline-flex; align-items: center; justify-content: center;
text-decoration: none;
transition: background 0.15s;
}
.topbar .btn-add:hover { background: var(--accent); text-decoration: none; }
.main {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
.sidebar {
width: 340px;
min-width: 280px;
border-right: 1px solid var(--border-soft);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sort-bar {
display: flex;
padding: 6px 8px;
gap: 0;
border-bottom: 1px solid var(--border-soft);
flex-wrap: wrap;
}
.sort-bar button {
font-size: 11px;
padding: 5px 10px;
color: var(--text-mute);
border: 1px solid var(--border);
background: var(--bg-elev);
border-right-width: 0;
transition: all 0.15s;
}
.sort-bar button:first-child { border-radius: 4px 0 0 4px; }
.sort-bar button:last-child { border-radius: 0 4px 4px 0; border-right-width: 1px; }
.sort-bar button:hover { color: var(--text); }
.sort-bar button.active {
background: var(--bg-active);
border-color: var(--accent-strong);
color: var(--accent);
}
.playlist { flex: 1; overflow-y: auto; }
.hint { padding: 40px 20px; text-align: center; color: var(--text-mute); font-size: 14px; }
.hint.err { color: var(--accent-red); }
.row {
display: flex;
padding: 10px 14px;
border-bottom: 1px solid var(--border-soft);
cursor: pointer;
align-items: center;
gap: 10px;
}
.row:hover { background: var(--bg-card); }
.row.active { background: var(--bg-active); }
.row.active .row-title { color: var(--accent); }
.row-main { flex: 1; min-width: 0; }
.row-title {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-meta {
font-size: 11px;
color: var(--text-mute);
margin-top: 2px;
display: flex;
gap: 6px;
}
.row-meta .cat {
color: var(--accent-cyan);
background: rgba(6, 182, 212, 0.1);
padding: 0 6px;
border-radius: 8px;
}
.badges {
display: flex;
gap: 4px;
align-items: center;
flex-shrink: 0;
}
.badge {
font-size: 10px;
color: var(--accent-strong);
background: rgba(124, 92, 191, 0.12);
padding: 1px 6px;
border-radius: 8px;
}
.play-count {
font-size: 10px;
color: var(--text-mute);
}
.player-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-mute);
font-size: 16px;
}
.now-playing {
padding: 18px 24px 8px;
text-align: center;
flex-shrink: 0;
}
.now-playing h2 {
font-size: 22px;
color: var(--accent);
margin-bottom: 4px;
}
.np-sub {
color: var(--text-dim);
font-size: 13px;
display: flex;
gap: 6px;
justify-content: center;
flex-wrap: wrap;
}
.edit-link {
margin-left: 6px;
color: var(--text-mute);
font-size: 12px;
}
.edit-link:hover { color: var(--accent); }
.tabs {
display: flex;
gap: 0;
padding: 0 24px;
border-bottom: 1px solid var(--border-soft);
flex-shrink: 0;
overflow-x: auto;
}
.tabs button {
background: none;
color: var(--text-mute);
border-bottom: 2px solid transparent;
padding: 10px 16px;
font-size: 14px;
white-space: nowrap;
transition: all 0.2s;
}
.tabs button:hover { color: var(--text-dim); }
.tabs button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tabs .tab-n {
font-size: 10px;
color: var(--text-mute);
margin-left: 4px;
}
.content {
flex: 1;
overflow-y: auto;
padding: 12px 24px 80px;
min-height: 0;
}
.lyrics-box .lyrics-line {
padding: 8px 0;
font-size: 16px;
text-align: center;
color: var(--text-mute);
line-height: 1.6;
cursor: pointer;
transition: color 0.3s, font-size 0.3s;
}
.lyrics-box .lyrics-line.active {
color: var(--text);
font-size: 19px;
font-weight: 500;
}
.lyrics-none {
text-align: center;
color: var(--text-mute);
margin-top: 60px;
font-size: 14px;
}
.lyrics-raw {
margin-top: 20px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
text-align: left;
white-space: pre-wrap;
word-break: break-all;
font-size: 12px;
background: var(--bg-elev);
padding: 12px;
border-radius: 6px;
}
.sheet-box {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
}
.sheet-img {
max-width: 100%;
border-radius: 8px;
background: #fff;
}
.pdf-box { display: flex; flex-direction: column; gap: 16px; }
.pdf-frame {
width: 100%;
height: 90vh;
border: none;
background: #fff;
border-radius: 6px;
}
.video-box { display: flex; flex-direction: column; gap: 16px; align-items: center; }
.video-el {
max-width: 100%;
width: 100%;
max-height: 60vh;
background: #000;
border-radius: 8px;
}
.controls {
flex-shrink: 0;
background: var(--bg-card);
padding: 12px 20px;
border-top: 1px solid var(--border);
}
.ctrl-row {
display: flex;
align-items: center;
gap: 12px;
max-width: 900px;
margin: 0 auto;
}
.btn-icon {
font-size: 22px;
color: var(--text-dim);
width: 40px;
height: 40px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.btn-icon:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.btn-icon.big {
font-size: 26px;
background: var(--accent-strong);
color: #fff;
}
.btn-icon.big:hover { background: var(--accent); color: #fff; }
.repeat {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
color: var(--text-mute);
font-size: 11px;
}
.repeat .track {
width: 30px; height: 16px;
border-radius: 8px;
background: var(--border);
position: relative;
transition: background 0.2s;
}
.repeat .thumb {
position: absolute;
top: 2px; left: 2px;
width: 12px; height: 12px;
border-radius: 50%;
background: var(--text-mute);
transition: transform 0.2s, background 0.2s;
}
.repeat.on .track { background: var(--accent-strong); }
.repeat.on .thumb { transform: translateX(14px); background: #fff; }
.bar {
flex: 1;
height: 4px;
background: var(--border);
border-radius: 2px;
cursor: pointer;
position: relative;
}
.fill {
height: 100%;
background: var(--accent-strong);
border-radius: 2px;
}
.time {
font-size: 12px;
color: var(--text-dim);
min-width: 40px;
text-align: center;
}
.notes {
width: 260px;
border-left: 1px solid var(--border-soft);
background: var(--bg-elev);
display: none;
flex-direction: column;
flex-shrink: 0;
}
.notes header {
padding: 12px 16px;
font-size: 12px;
color: var(--text-mute);
border-bottom: 1px solid var(--border-soft);
display: flex;
justify-content: space-between;
cursor: pointer;
}
.notes header .saved { color: var(--accent-green); font-size: 10px; }
.notes textarea {
flex: 1;
padding: 12px 14px;
font-size: 13px;
line-height: 1.6;
resize: none;
color: var(--text-dim);
}
.notes textarea::placeholder { color: var(--text-mute); }
@media (min-width: 1200px) {
.notes { display: flex; }
}
@media (max-width: 768px) {
.main { flex-direction: column; }
.sidebar { width: 100%; height: 38vh; min-height: 200px; border-right: none; border-bottom: 1px solid var(--border-soft); }
.sidebar.has-selected { height: 30vh; }
}
</style>
@@ -0,0 +1,157 @@
<template>
<div class="page">
<header class="bar">
<router-link to="/" class="back"> 返回</router-link>
<h1>新增曲目</h1>
</header>
<main class="form">
<label class="field">
<span>标题<i>*</i></span>
<input v-model="title" placeholder="例:月光奏鸣曲 / Yesterday" />
</label>
<label class="field">
<span>歌手 / 作者</span>
<input v-model="artist" placeholder="例:Beatles / Beethoven" />
</label>
<label class="field">
<span>分类</span>
<input v-model="category" placeholder="例:流行 / 钢琴曲 / 练习曲" list="cat-list" />
<datalist id="cat-list">
<option value="钢琴曲" />
<option value="流行" />
<option value="练习曲" />
<option value="古典" />
<option value="爵士" />
</datalist>
</label>
<label class="field">
<span>歌词LRC 格式最佳</span>
<textarea v-model="lyrics" rows="6" placeholder="[00:12.34]歌词第一行&#10;[00:18.50]歌词第二行" />
</label>
<label class="field">
<span>笔记</span>
<textarea v-model="notes" rows="3" placeholder="练琴心得、曲谱说明…" />
</label>
<p v-if="error" class="err">{{ error }}</p>
<div class="actions">
<button class="btn-primary" :disabled="saving || !title.trim()" @click="submit">
{{ saving ? '创建中' : '创建' }}
</button>
</div>
<p class="tip">创建后进入编辑页给曲目挂 mp3 / 视频 / PDF / 谱子图片</p>
</main>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { createPiece } from '../lib/api.js'
const router = useRouter()
const title = ref('')
const artist = ref('')
const category = ref('')
const lyrics = ref('')
const notes = ref('')
const saving = ref(false)
const error = ref('')
async function submit() {
if (!title.value.trim()) return
saving.value = true
error.value = ''
try {
const r = await createPiece({
title: title.value.trim(),
artist: artist.value.trim() || null,
category: category.value.trim() || null,
lyrics: lyrics.value || null,
notes: notes.value || null,
})
router.push({ name: 'edit', params: { id: r.id } })
} catch (e) {
error.value = e.message || String(e)
} finally {
saving.value = false
}
}
</script>
<style scoped>
.page {
min-height: 100%;
display: flex;
flex-direction: column;
}
.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; }
.form {
max-width: 640px;
margin: 0 auto;
padding: 24px 22px 80px;
width: 100%;
display: flex;
flex-direction: column;
gap: 18px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field span {
font-size: 12px;
color: var(--text-dim);
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.field span i { color: var(--accent-red); margin-left: 2px; font-style: normal; }
.field input, .field textarea {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
font-size: 14px;
color: var(--text);
width: 100%;
}
.field input:focus, .field textarea:focus { border-color: var(--accent-strong); }
.field textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
resize: vertical;
line-height: 1.5;
}
.err {
background: rgba(239, 68, 68, 0.1);
color: var(--accent-red);
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
}
.actions { display: flex; gap: 12px; }
.btn-primary {
background: var(--accent-strong);
color: #fff;
border-radius: 6px;
padding: 10px 22px;
font-size: 14px;
font-weight: 600;
}
.btn-primary:hover:not(:disabled) { background: var(--accent); }
.btn-primary:active { transform: scale(0.98); }
.tip { font-size: 12px; color: var(--text-mute); }
</style>
@@ -1,29 +1,29 @@
apiVersion: v1
kind: Namespace
metadata:
name: cube-piano
name: cube-music
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: piano-sheet-data
namespace: cube-piano
name: music-data
namespace: cube-music
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
# 图片直存 sqlite,留出宽裕空间
storage: 10Gi
# storageClassName 留空 → k3s 默认 local-pathhostPath,单节点足够)
# video / audio 大附件 + 初始 guitar 曲库(~? GB),50Gi 起步
storage: 50Gi
# storageClassName 留空 → k3s 默认 local-path
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: piano-sheet
namespace: cube-piano
name: music
namespace: cube-music
labels:
app: piano-sheet
app: music
spec:
replicas: 1
strategy:
@@ -31,17 +31,17 @@ spec:
type: Recreate
selector:
matchLabels:
app: piano-sheet
app: music
template:
metadata:
labels:
app: piano-sheet
app: music
spec:
imagePullSecrets:
- name: registry-creds
containers:
- name: piano-sheet
image: registry.famzheng.me/mochi/piano-sheet:latest
- name: music
image: registry.famzheng.me/mochi/music:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
@@ -49,6 +49,8 @@ spec:
env:
- name: DB_PATH
value: /data/app.db
- name: BLOBS_DIR
value: /data/blobs
readinessProbe:
httpGet:
path: /healthz
@@ -66,24 +68,24 @@ spec:
cpu: 10m
memory: 32Mi
limits:
cpu: 500m
memory: 256Mi
cpu: 1000m
memory: 512Mi
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: piano-sheet-data
claimName: music-data
---
apiVersion: v1
kind: Service
metadata:
name: piano-sheet
namespace: cube-piano
name: music
namespace: cube-music
spec:
selector:
app: piano-sheet
app: music
ports:
- name: http
port: 80
@@ -93,29 +95,29 @@ apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: bodylimit
namespace: cube-piano
namespace: cube-music
spec:
buffering:
maxRequestBodyBytes: 700000000
# 单文件最大 1GiBmain.rs 里 SINGLE_FILE_BYTES),multipart 总和留 5GiB 余量
maxRequestBodyBytes: 5368709120
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: piano-sheet
namespace: cube-piano
name: music
namespace: cube-music
annotations:
# 上传整组图片可能 ~600MB,调高 traefik 默认上限
traefik.ingress.kubernetes.io/router.middlewares: cube-piano-bodylimit@kubernetescrd
traefik.ingress.kubernetes.io/router.middlewares: cube-music-bodylimit@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: piano.famzheng.me
- host: music.famzheng.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: piano-sheet
name: music
port:
number: 80
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""
从 /data/guitar/ 把旧 guitar app 的曲库导入到 music API。
跑法(在 oci 上,源数据原地):
python3 import_guitar.py --src /data/guitar --api https://music.famzheng.me
每个 mp3 = 一个 piece
- title / artist 从文件名 "{title} - {artist}.mp3"
- lyrics 来自同名 .lrcUTF-8
- chord png 来自 chords/{sanitize(artist)}-{sanitize(title)}.png(如有)
- play_count 来自 playcounts.json[mp3 文件名]
- category 设为 "流行"(旧 guitar 里都是流行/吉他歌)
幂等:以 (title, artist) 作主键去重,已有 piece 跳过附件重传。
"""
import argparse
import json
import re
import sys
import urllib.request
import urllib.parse
import urllib.error
import os
from pathlib import Path
CATEGORY = "流行"
def sanitize(text: str) -> str:
"""匹配旧 chord_server.py 的 sanitize:替换非法文件名字符为 -"""
return re.sub(r'[<>:"/\\|?*]', '-', text).strip()
def parse_filename(stem: str) -> tuple[str, str]:
"""'{title} - {artist}' → (title, artist)"""
parts = stem.split(' - ')
title = parts[0].strip()
artist = ' - '.join(parts[1:]).strip() if len(parts) > 1 else ''
return title, artist
def http_request(method: str, url: str, *, json_body=None, file_field=None,
timeout: int = 600) -> dict:
"""简单 HTTP 客户端,return JSON dict。失败抛异常。"""
headers = {}
data = None
if json_body is not None:
data = json.dumps(json_body).encode('utf-8')
headers['Content-Type'] = 'application/json'
elif file_field is not None:
boundary = '----music-import-' + os.urandom(8).hex()
body = []
for field_name, filename, content, mime in file_field:
body.append(f'--{boundary}\r\n'.encode())
disp = (f'Content-Disposition: form-data; name="{field_name}"; '
f'filename="{filename}"\r\n').encode()
body.append(disp)
body.append(f'Content-Type: {mime}\r\n\r\n'.encode())
body.append(content)
body.append(b'\r\n')
body.append(f'--{boundary}--\r\n'.encode())
data = b''.join(body)
headers['Content-Type'] = f'multipart/form-data; boundary={boundary}'
req = urllib.request.Request(url, data=data, method=method, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode('utf-8'))
def list_pieces(api: str) -> list:
return http_request('GET', f'{api}/api/pieces')
def create_piece(api: str, title: str, artist: str, lyrics: str | None) -> int:
body = {
'title': title,
'artist': artist or None,
'category': CATEGORY,
'lyrics': lyrics or None,
}
r = http_request('POST', f'{api}/api/pieces', json_body=body)
return int(r['id'])
def patch_play_count(api: str, piece_id: int, count: int):
http_request('PATCH', f'{api}/api/pieces/{piece_id}',
json_body={'play_count': count})
def upload_attachment(api: str, piece_id: int, path: Path,
mime: str, role: str | None = None) -> dict:
url = f'{api}/api/pieces/{piece_id}/attachments'
if role:
url += '?role=' + urllib.parse.quote(role)
bytes_content = path.read_bytes()
return http_request('POST', url, file_field=[
('files', path.name, bytes_content, mime),
])
def detect_audio_mime(path: Path) -> str:
ext = path.suffix.lower()
return {
'.mp3': 'audio/mpeg',
'.m4a': 'audio/mp4',
'.flac': 'audio/flac',
'.ogg': 'audio/ogg',
'.wav': 'audio/wav',
}.get(ext, 'application/octet-stream')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--src', default='/data/guitar', help='guitar 数据根目录')
ap.add_argument('--api', required=True, help='music API basee.g. https://music.famzheng.me')
ap.add_argument('--dry-run', action='store_true', help='只打印不实际调用')
ap.add_argument('--limit', type=int, default=0, help='测试用,最多导入 N 首(0=全部)')
args = ap.parse_args()
src = Path(args.src)
if not src.exists():
print(f'[!] {src} 不存在', file=sys.stderr)
sys.exit(1)
chords_dir = src / 'chords'
playcounts_file = src / 'playcounts.json'
playcounts = {}
if playcounts_file.exists():
try:
playcounts = json.loads(playcounts_file.read_text())
except Exception as e:
print(f'[!] 读 playcounts.json 失败: {e}', file=sys.stderr)
# 已有 pieces 去重
existing = {}
if not args.dry_run:
try:
for p in list_pieces(args.api):
key = (p['title'], p.get('artist') or '')
existing[key] = p['id']
print(f'[i] 远端已有 {len(existing)} 首曲目,重复的会跳过创建')
except urllib.error.URLError as e:
print(f'[!] 连不上 {args.api}: {e}', file=sys.stderr)
sys.exit(1)
mp3s = sorted(src.glob('*.mp3'))
if args.limit:
mp3s = mp3s[:args.limit]
total = len(mp3s)
ok = skipped = failed = 0
for i, mp3 in enumerate(mp3s, 1):
stem = mp3.stem
title, artist = parse_filename(stem)
if not title:
print(f'[!] [{i}/{total}] 无法解析文件名: {mp3.name}', file=sys.stderr)
failed += 1
continue
key = (title, artist)
prefix = f'[{i}/{total}] {title} - {artist}'
if key in existing:
print(f'{prefix} (已存在 id={existing[key]}, 跳过)')
skipped += 1
continue
# lrc
lrc_path = mp3.with_suffix('.lrc')
lyrics = None
if lrc_path.exists():
try:
lyrics = lrc_path.read_text(encoding='utf-8')
except UnicodeDecodeError:
lyrics = lrc_path.read_text(encoding='gbk', errors='replace')
# chord png
chord_path = chords_dir / f'{sanitize(artist)}-{sanitize(title)}.png'
if not chord_path.exists():
chord_path = None
play_count = int(playcounts.get(mp3.name, 0))
if args.dry_run:
print(f' + {prefix}'
f'{" [词]" if lyrics else ""}'
f'{" [谱]" if chord_path else ""}'
f'{f" [{play_count}次]" if play_count else ""}')
ok += 1
continue
try:
piece_id = create_piece(args.api, title, artist, lyrics)
print(f' + {prefix} → id={piece_id}', end='', flush=True)
upload_attachment(args.api, piece_id, mp3,
detect_audio_mime(mp3))
print(' [audio]', end='', flush=True)
if chord_path:
upload_attachment(args.api, piece_id, chord_path,
'image/png', role='chord')
print(' [chord]', end='', flush=True)
if play_count:
patch_play_count(args.api, piece_id, play_count)
print(f' [{play_count}次]', end='', flush=True)
print()
ok += 1
except Exception as e:
print(f'\n[!] {prefix} 失败: {e}', file=sys.stderr)
failed += 1
print()
print(f'完成: ok={ok} skipped={skipped} failed={failed} total={total}')
if __name__ == '__main__':
main()
+684
View File
@@ -0,0 +1,684 @@
//! music.famzheng.me — 听歌 + 练琴。
//!
//! 数据模型:曲目 (piece) → 附件 (attachment, 类型 video/audio/pdf/image)。
//! 元数据走 sqlite,附件 bytes 落 `/data/blobs/<id>`Range 下载交给 tower-http ServeFile。
//!
//! API
//! - `GET /api/pieces` 列表(含附件计数 + 简要类型分布)
//! - `POST /api/pieces` 创建(json: title, category?, notes?
//! - `GET /api/pieces/:id` 详情(含 attachments 列表)
//! - `PATCH /api/pieces/:id` 改 title / category / notes
//! - `DELETE /api/pieces/:id` 删曲目 + 级联删附件 + 同步删磁盘
//! - `POST /api/pieces/:id/attachments` multipart 流式上传,可一次多文件
//! - `GET /api/attachments/:id` 下载(带 Rangevideo/audio 拖动用)
//! - `DELETE /api/attachments/:id` 删单个附件 + 磁盘文件
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use axum::{
body::Body,
extract::{DefaultBodyLimit, Multipart, Path, Query, Request, State},
http::{header, StatusCode},
response::{IntoResponse, Json as JsonResp, Response},
routing::{get, post},
Router,
};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::io::AsyncWriteExt;
use tower::ServiceExt;
const SINGLE_FILE_BYTES: usize = 1024 * 1024 * 1024; // 1 GiB / 单附件
const REQUEST_BYTES: usize = 5 * 1024 * 1024 * 1024; // 5 GiB / 单次上传
#[derive(Clone)]
struct AppState {
db: Arc<Mutex<Connection>>,
blobs_dir: PathBuf,
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
cube_core::init_tracing();
let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "/data/app.db".into());
let blobs_dir =
PathBuf::from(std::env::var("BLOBS_DIR").unwrap_or_else(|_| "/data/blobs".into()));
let dist = std::env::var("MUSIC_DIST_DIR").unwrap_or_else(|_| "/dist".into());
std::fs::create_dir_all(&blobs_dir).expect("mkdir blobs_dir");
let conn = Connection::open(&db_path).expect("open sqlite");
conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS pieces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
artist TEXT,
category TEXT,
notes TEXT,
lyrics TEXT,
play_count INTEGER NOT NULL DEFAULT 0,
last_played_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
piece_id INTEGER NOT NULL,
kind TEXT NOT NULL,
role TEXT,
mime TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (piece_id) REFERENCES pieces(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_att_piece ON attachments(piece_id);",
)
.expect("init schema");
tracing::info!(%db_path, blobs = %blobs_dir.display(), "music ready");
let state = AppState {
db: Arc::new(Mutex::new(conn)),
blobs_dir,
};
let api = Router::new()
.route("/pieces", get(list_pieces).post(create_piece))
.route(
"/pieces/:id",
get(get_piece).patch(patch_piece).delete(delete_piece),
)
.route("/pieces/:id/play", post(record_play))
.route(
"/pieces/:id/attachments",
post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)),
)
.route(
"/attachments/:id",
get(get_attachment).delete(delete_attachment),
)
.with_state(state);
let app = cube_core::base(dist).nest("/api", api);
cube_core::serve(app, 8080).await
}
// ---------- 类型 ----------
#[derive(Serialize)]
struct PieceSummary {
id: i64,
title: String,
artist: Option<String>,
category: Option<String>,
play_count: i64,
last_played_at: Option<String>,
attachments: i64,
kinds: Vec<String>,
has_lyrics: bool,
created_at: String,
}
#[derive(Serialize)]
struct PieceDetail {
id: i64,
title: String,
artist: Option<String>,
category: Option<String>,
notes: Option<String>,
lyrics: Option<String>,
play_count: i64,
last_played_at: Option<String>,
created_at: String,
attachments: Vec<Attachment>,
}
#[derive(Serialize)]
struct Attachment {
id: i64,
kind: String,
role: Option<String>,
mime: String,
filename: String,
size_bytes: i64,
sort_order: i64,
created_at: String,
}
#[derive(Deserialize)]
struct UploadQuery {
role: Option<String>,
}
#[derive(Deserialize)]
struct CreatePiece {
title: String,
artist: Option<String>,
category: Option<String>,
notes: Option<String>,
lyrics: Option<String>,
}
#[derive(Deserialize)]
struct PatchPiece {
title: Option<String>,
artist: Option<Option<String>>,
category: Option<Option<String>>,
notes: Option<Option<String>>,
lyrics: Option<Option<String>>,
/// admin / import 用:直接写 play_countmvp 无认证)
play_count: Option<i64>,
}
// ---------- handlers: pieces ----------
async fn list_pieces(State(s): State<AppState>) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT p.id, p.title, p.artist, p.category,
p.play_count, p.last_played_at, p.created_at,
COUNT(a.id) AS att_count,
COALESCE(GROUP_CONCAT(DISTINCT a.kind), '') AS kinds,
CASE WHEN p.lyrics IS NOT NULL AND length(p.lyrics) > 0 THEN 1 ELSE 0 END AS has_lyrics
FROM pieces p
LEFT JOIN attachments a ON a.piece_id = p.id
GROUP BY p.id
ORDER BY p.title COLLATE NOCASE ASC, p.id ASC",
)?;
let rows = stmt
.query_map([], |r| {
let kinds_csv: String = r.get(8)?;
let kinds = if kinds_csv.is_empty() {
Vec::new()
} else {
kinds_csv.split(',').map(|x| x.to_string()).collect()
};
let has_lyrics: i64 = r.get(9)?;
Ok(PieceSummary {
id: r.get(0)?,
title: r.get(1)?,
artist: r.get(2)?,
category: r.get(3)?,
play_count: r.get(4)?,
last_played_at: r.get(5)?,
created_at: r.get(6)?,
attachments: r.get(7)?,
kinds,
has_lyrics: has_lyrics != 0,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!(rows)))
}
async fn create_piece(
State(s): State<AppState>,
JsonResp(body): JsonResp<CreatePiece>,
) -> Result<JsonResp<Value>, AppError> {
let title = body.title.trim();
if title.is_empty() {
return Err(AppError::bad_request("title required"));
}
let conn = s.db.lock().unwrap();
conn.execute(
"INSERT INTO pieces (title, artist, category, notes, lyrics)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
title,
body.artist.as_deref().map(str::trim).filter(|s| !s.is_empty()),
body.category.as_deref().map(str::trim).filter(|s| !s.is_empty()),
body.notes.as_deref(),
body.lyrics.as_deref()
],
)?;
let id = conn.last_insert_rowid();
Ok(JsonResp(json!({ "id": id })))
}
async fn get_piece(
State(s): State<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<PieceDetail>, AppError> {
let conn = s.db.lock().unwrap();
type PieceRow = (
String,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
i64,
Option<String>,
String,
);
let row: Option<PieceRow> = conn
.query_row(
"SELECT title, artist, category, notes, lyrics, play_count, last_played_at, created_at
FROM pieces WHERE id = ?1",
params![id],
|r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
r.get(7)?,
))
},
)
.optional()?;
let (title, artist, category, notes, lyrics, play_count, last_played_at, created_at) =
row.ok_or(AppError::NotFound)?;
let mut stmt = conn.prepare(
"SELECT id, kind, role, mime, filename, size_bytes, sort_order, created_at
FROM attachments
WHERE piece_id = ?1
ORDER BY sort_order ASC, id ASC",
)?;
let attachments = stmt
.query_map(params![id], |r| {
Ok(Attachment {
id: r.get(0)?,
kind: r.get(1)?,
role: r.get(2)?,
mime: r.get(3)?,
filename: r.get(4)?,
size_bytes: r.get(5)?,
sort_order: r.get(6)?,
created_at: r.get(7)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(PieceDetail {
id,
title,
artist,
category,
notes,
lyrics,
play_count,
last_played_at,
created_at,
attachments,
}))
}
async fn patch_piece(
State(s): State<AppState>,
Path(id): Path<i64>,
JsonResp(body): JsonResp<PatchPiece>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let exists: bool = conn
.query_row("SELECT 1 FROM pieces WHERE id = ?1", params![id], |_| {
Ok(true)
})
.optional()?
.unwrap_or(false);
if !exists {
return Err(AppError::NotFound);
}
if let Some(title) = body.title.as_ref() {
let t = title.trim();
if t.is_empty() {
return Err(AppError::bad_request("title can't be blank"));
}
conn.execute("UPDATE pieces SET title = ?1 WHERE id = ?2", params![t, id])?;
}
if let Some(artist) = body.artist {
let artist = artist.as_deref().map(str::trim).filter(|s| !s.is_empty());
conn.execute(
"UPDATE pieces SET artist = ?1 WHERE id = ?2",
params![artist, id],
)?;
}
if let Some(cat) = body.category {
let cat = cat.as_deref().map(str::trim).filter(|s| !s.is_empty());
conn.execute(
"UPDATE pieces SET category = ?1 WHERE id = ?2",
params![cat, id],
)?;
}
if let Some(notes) = body.notes {
let notes = notes.as_deref();
conn.execute(
"UPDATE pieces SET notes = ?1 WHERE id = ?2",
params![notes, id],
)?;
}
if let Some(lyrics) = body.lyrics {
let lyrics = lyrics.as_deref();
conn.execute(
"UPDATE pieces SET lyrics = ?1 WHERE id = ?2",
params![lyrics, id],
)?;
}
if let Some(pc) = body.play_count {
conn.execute(
"UPDATE pieces SET play_count = ?1 WHERE id = ?2",
params![pc, id],
)?;
}
Ok(JsonResp(json!({ "ok": true })))
}
async fn record_play(
State(s): State<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let n = conn.execute(
"UPDATE pieces
SET play_count = play_count + 1, last_played_at = CURRENT_TIMESTAMP
WHERE id = ?1",
params![id],
)?;
if n == 0 {
return Err(AppError::NotFound);
}
let count: i64 = conn.query_row(
"SELECT play_count FROM pieces WHERE id = ?1",
params![id],
|r| r.get(0),
)?;
Ok(JsonResp(json!({ "play_count": count })))
}
async fn delete_piece(
State(s): State<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let to_unlink: Vec<i64> = {
let conn = s.db.lock().unwrap();
let mut stmt =
conn.prepare("SELECT id FROM attachments WHERE piece_id = ?1")?;
let ids: Vec<i64> = stmt
.query_map(params![id], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
let n = conn.execute("DELETE FROM pieces WHERE id = ?1", params![id])?;
if n == 0 {
return Err(AppError::NotFound);
}
ids
};
for aid in to_unlink {
let _ = tokio::fs::remove_file(s.blobs_dir.join(aid.to_string())).await;
}
Ok(JsonResp(json!({ "ok": true })))
}
// ---------- handlers: attachments ----------
/// `POST /api/pieces/:id/attachments?role=chord|numbered|staff` — multipart 流式上传。
/// 每个 file field(任意 name= 一个附件,`role` query 给整批文件。
async fn upload_attachments(
State(s): State<AppState>,
Path(piece_id): Path<i64>,
Query(q): Query<UploadQuery>,
mut form: Multipart,
) -> Result<JsonResp<Value>, AppError> {
let role = match q.role.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
None => None,
Some(r) if matches!(r, "chord" | "numbered" | "staff") => Some(r.to_string()),
Some(other) => {
return Err(AppError::bad_request(format!(
"unsupported role '{other}', expect one of: chord / numbered / staff"
)));
}
};
{
let conn = s.db.lock().unwrap();
let exists: bool = conn
.query_row(
"SELECT 1 FROM pieces WHERE id = ?1",
params![piece_id],
|_| Ok(true),
)
.optional()?
.unwrap_or(false);
if !exists {
return Err(AppError::NotFound);
}
}
let mut created: Vec<Value> = Vec::new();
while let Some(mut field) = form
.next_field()
.await
.map_err(|e| AppError::bad_request(format!("multipart: {e}")))?
{
let filename = field
.file_name()
.map(|s| s.to_string())
.unwrap_or_else(|| "untitled".to_string());
let mime = field
.content_type()
.map(|s| s.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
let kind = classify(&mime).ok_or_else(|| {
AppError::bad_request(format!("unsupported mime '{mime}' for '{filename}'"))
})?;
// 占坑拿 attachment id —— 文件名用 id,能唯一确定路径。
let attachment_id = {
let conn = s.db.lock().unwrap();
conn.execute(
"INSERT INTO attachments (piece_id, kind, role, mime, filename, size_bytes, sort_order)
VALUES (?1, ?2, ?3, ?4, ?5, 0,
COALESCE((SELECT MAX(sort_order) FROM attachments WHERE piece_id = ?1), 0) + 1)",
params![piece_id, kind, role, mime, filename],
)?;
conn.last_insert_rowid()
};
let final_path = s.blobs_dir.join(attachment_id.to_string());
let tmp_path = s.blobs_dir.join(format!("{attachment_id}.tmp"));
let written: usize = match stream_to_file(&mut field, &tmp_path).await {
Ok(n) => n,
Err(e) => {
let _ = tokio::fs::remove_file(&tmp_path).await;
let conn = s.db.lock().unwrap();
let _ = conn.execute(
"DELETE FROM attachments WHERE id = ?1",
params![attachment_id],
);
return Err(e);
}
};
if let Err(e) = tokio::fs::rename(&tmp_path, &final_path).await {
let _ = tokio::fs::remove_file(&tmp_path).await;
let conn = s.db.lock().unwrap();
let _ = conn.execute(
"DELETE FROM attachments WHERE id = ?1",
params![attachment_id],
);
return Err(AppError::Io(e));
}
{
let conn = s.db.lock().unwrap();
conn.execute(
"UPDATE attachments SET size_bytes = ?1 WHERE id = ?2",
params![written as i64, attachment_id],
)?;
}
created.push(json!({
"id": attachment_id,
"kind": kind,
"role": role,
"mime": mime,
"filename": filename,
"size_bytes": written,
}));
}
if created.is_empty() {
return Err(AppError::bad_request("no files uploaded"));
}
Ok(JsonResp(json!({ "attachments": created })))
}
async fn stream_to_file(
field: &mut axum::extract::multipart::Field<'_>,
path: &std::path::Path,
) -> Result<usize, AppError> {
let mut file = tokio::fs::File::create(path).await.map_err(AppError::Io)?;
let mut total: usize = 0;
while let Some(chunk) = field
.chunk()
.await
.map_err(|e| AppError::bad_request(format!("upload read: {e}")))?
{
total += chunk.len();
if total > SINGLE_FILE_BYTES {
return Err(AppError::bad_request(format!(
"single file exceeds {SINGLE_FILE_BYTES} bytes"
)));
}
file.write_all(&chunk).await.map_err(AppError::Io)?;
}
file.flush().await.map_err(AppError::Io)?;
file.sync_all().await.map_err(AppError::Io)?;
Ok(total)
}
/// `GET /api/attachments/:id` — Range-aware 下载。
async fn get_attachment(
State(s): State<AppState>,
Path(id): Path<i64>,
req: Request<Body>,
) -> Result<Response, AppError> {
let row: Option<(String, String, String)> = {
let conn = s.db.lock().unwrap();
conn.query_row(
"SELECT mime, filename, kind FROM attachments WHERE id = ?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?
};
let (mime, filename, _kind) = row.ok_or(AppError::NotFound)?;
let path = s.blobs_dir.join(id.to_string());
let mime_hv: header::HeaderValue = mime
.parse()
.unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream"));
let svc = tower_http::services::ServeFile::new(&path);
let mut resp = svc
.oneshot(req)
.await
.map_err(|e| AppError::Io(std::io::Error::other(e.to_string())))?
.into_response();
// 强一些的缓存头,video 拖动友好
resp.headers_mut()
.insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private, max-age=3600"));
resp.headers_mut().insert(header::CONTENT_TYPE, mime_hv);
if let Ok(disp) = format!(
"inline; filename*=UTF-8''{}",
percent_encode(&filename)
)
.parse()
{
resp.headers_mut().insert(header::CONTENT_DISPOSITION, disp);
}
Ok(resp)
}
async fn delete_attachment(
State(s): State<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let n = {
let conn = s.db.lock().unwrap();
conn.execute("DELETE FROM attachments WHERE id = ?1", params![id])?
};
if n == 0 {
return Err(AppError::NotFound);
}
let _ = tokio::fs::remove_file(s.blobs_dir.join(id.to_string())).await;
Ok(JsonResp(json!({ "ok": true })))
}
// ---------- helpers ----------
fn classify(mime: &str) -> Option<&'static str> {
let m = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
if m.starts_with("video/") {
Some("video")
} else if m.starts_with("audio/") {
Some("audio")
} else if m == "application/pdf" {
Some("pdf")
} else if m.starts_with("image/") {
Some("image")
} else {
None
}
}
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(*b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
// ---------- error type ----------
enum AppError {
BadRequest(String),
NotFound,
Db(rusqlite::Error),
Io(std::io::Error),
}
impl AppError {
fn bad_request(msg: impl Into<String>) -> Self {
Self::BadRequest(msg.into())
}
}
impl From<rusqlite::Error> for AppError {
fn from(e: rusqlite::Error) -> Self {
Self::Db(e)
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match self {
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
Self::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(),
Self::Db(e) => {
tracing::error!(error = %e, "sqlite error");
(StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response()
}
Self::Io(e) => {
tracing::error!(error = %e, "io error");
(StatusCode::INTERNAL_SERVER_ERROR, "io error").into_response()
}
}
}
}
-8
View File
@@ -1,8 +0,0 @@
# piano-sheet — piano.famzheng.me
# Build context = repo root(同 cube),路径都是 apps/piano-sheet/...
# rust + frontend 都在 host 上 build,容器只是拼装。
FROM scratch
COPY target/x86_64-unknown-linux-musl/release/piano-sheet /piano-sheet
COPY apps/piano-sheet/frontend/dist /dist
EXPOSE 8080
ENTRYPOINT ["/piano-sheet"]
-4
View File
@@ -1,4 +0,0 @@
node_modules
dist
.DS_Store
*.log
-74
View File
@@ -1,74 +0,0 @@
<template>
<router-view />
</template>
<style>
:root {
--bg-dark: #0a0e1a;
--bg-card: #141b2d;
--bg-surface: #1e2742;
--bg-hover: #253352;
--border: #2a3655;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-cyan: #06b6d4;
--accent-green: #10b981;
--accent-red: #ef4444;
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--radius: 10px;
--radius-lg: 14px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app {
height: 100%;
}
body {
font-family: var(--font-sans);
background: var(--bg-dark);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
overscroll-behavior: none;
}
a { color: var(--accent-cyan); text-decoration: none; }
a:hover { text-decoration: underline; }
button {
font-family: var(--font-sans);
cursor: pointer;
border: none;
border-radius: var(--radius);
font-size: 16px;
transition: background 0.15s, transform 0.05s;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
button:active { transform: scale(0.97); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
color: white;
padding: 14px 22px;
font-weight: 600;
}
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn-ghost {
background: var(--bg-surface);
color: var(--text-primary);
padding: 12px 18px;
}
.btn-ghost:hover:not(:disabled) { background: var(--bg-hover); }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
</style>
-29
View File
@@ -1,29 +0,0 @@
// 薄薄一层 fetch 封装。错误统一抛 Error(message)。
async function jsonOrThrow(res) {
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `${res.status} ${res.statusText}`)
}
return res.json()
}
export function listSheets() {
return fetch('/api/sheets').then(jsonOrThrow)
}
export function getSheet(id) {
return fetch(`/api/sheets/${id}`).then(jsonOrThrow)
}
export function pageUrl(id, page) {
return `/api/sheets/${id}/pages/${page}`
}
export async function uploadSheet(title, files) {
const fd = new FormData()
fd.append('title', title)
for (const f of files) fd.append('images', f, f.name)
const res = await fetch('/api/upload', { method: 'POST', body: fd })
return jsonOrThrow(res)
}
@@ -1,32 +0,0 @@
// 移动端拍照原图常 512MB15001800px 长边 + JPEG 0.85 已经够看清楚音符。
// 用 createImageBitmap 的 imageOrientation: 'from-image' 自动按 EXIF 旋转,
// 否则横拍照在 canvas 里会变成竖图。
export async function compressImage(file, { maxEdge = 1800, quality = 0.85 } = {}) {
if (!file || !file.type || !file.type.startsWith('image/')) return file
let bitmap
try {
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' })
} catch {
return file
}
const { width, height } = bitmap
const scale = Math.min(1, maxEdge / Math.max(width, height))
const w = Math.max(1, Math.round(width * scale))
const h = Math.max(1, Math.round(height * scale))
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
ctx.drawImage(bitmap, 0, 0, w, h)
bitmap.close?.()
const blob = await new Promise((res) => canvas.toBlob(res, 'image/jpeg', quality))
if (!blob || blob.size >= file.size) return file
const baseName = file.name?.replace(/\.[^.]+$/, '') || 'photo'
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg', lastModified: Date.now() })
}
@@ -1,20 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', name: 'list', component: () => import('../views/ListView.vue') },
{ path: '/upload', name: 'upload', component: () => import('../views/UploadView.vue') },
{
path: '/sheet/:id',
name: 'reader',
component: () => import('../views/ReaderView.vue'),
props: (route) => ({
id: Number(route.params.id),
page: Number(route.query.page) || 1,
}),
},
]
export default createRouter({
history: createWebHistory(),
routes,
})
@@ -1,156 +0,0 @@
<template>
<div class="list-page">
<header class="topbar">
<h1>琴谱</h1>
<router-link to="/upload" class="btn-primary upload-link"> 上传</router-link>
</header>
<main class="content">
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="error" class="hint error">{{ error }}</p>
<p v-else-if="sheets.length === 0" class="hint">
还没有琴谱<router-link to="/upload">先上传一份</router-link>
</p>
<div v-else class="grid">
<router-link
v-for="s in sheets"
:key="s.id"
:to="{ name: 'reader', params: { id: s.id } }"
class="card"
>
<div class="card-thumb">
<img :src="thumbUrl(s.id)" :alt="s.title" loading="lazy" />
</div>
<div class="card-body">
<div class="card-title">{{ s.title }}</div>
<div class="card-meta">{{ s.pages }} · {{ formatDate(s.created_at) }}</div>
</div>
</router-link>
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { listSheets, pageUrl } from '../lib/api.js'
const sheets = ref([])
const loading = ref(true)
const error = ref('')
function thumbUrl(id) {
return pageUrl(id, 1)
}
function formatDate(s) {
if (!s) return ''
const d = new Date(s.replace(' ', 'T') + 'Z')
if (isNaN(d.getTime())) return s
return d.toLocaleDateString()
}
onMounted(async () => {
try {
sheets.value = await listSheets()
} catch (e) {
error.value = e.message || String(e)
} finally {
loading.value = false
}
})
</script>
<style scoped>
.list-page {
min-height: 100%;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 28px;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
position: sticky;
top: 0;
z-index: 10;
}
.topbar h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: 0.02em;
}
.upload-link {
display: inline-flex;
align-items: center;
text-decoration: none;
}
.upload-link:hover { text-decoration: none; }
.content {
flex: 1;
padding: 24px 28px 80px;
}
.hint {
color: var(--text-secondary);
text-align: center;
padding: 80px 20px;
font-size: 18px;
}
.hint.error { color: var(--accent-red); }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 20px;
}
@media (min-width: 900px) {
.grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 24px; }
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
color: var(--text-primary);
text-decoration: none;
transition: transform 0.15s, border-color 0.15s;
}
.card:hover { transform: translateY(-2px); border-color: var(--accent); text-decoration: none; }
.card-thumb {
aspect-ratio: 3 / 4;
background: var(--bg-surface);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.card-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-body {
padding: 14px 16px;
}
.card-title {
font-weight: 600;
font-size: 17px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-meta {
margin-top: 4px;
font-size: 13px;
color: var(--text-muted);
}
</style>
@@ -1,229 +0,0 @@
<template>
<div class="reader" @keydown="onKey" tabindex="0" ref="rootEl">
<div v-if="loading" class="state">加载中</div>
<div v-else-if="error" class="state error">{{ error }}</div>
<template v-else>
<img
:src="pageUrl(id, current)"
:alt="`${sheet.title} - p${current}`"
class="page-img"
draggable="false"
@click="onTap"
/>
<!-- 翻页热区 / 右半屏点按 -->
<div class="hit hit-left" @click="prev" aria-label="上一页"></div>
<div class="hit hit-right" @click="next" aria-label="下一页"></div>
<!-- /底栏会自动隐藏移动一下手指又出现 -->
<transition name="fade">
<header v-if="chromeVisible" class="topbar">
<router-link to="/" class="btn-ghost back"> 列表</router-link>
<div class="title">{{ sheet.title }}</div>
<div class="counter">{{ current }} / {{ totalPages }}</div>
</header>
</transition>
<transition name="fade">
<footer v-if="chromeVisible" class="bottombar">
<button class="btn-ghost" @click="prev" :disabled="current <= 1"> 上一页</button>
<input
type="range"
min="1"
:max="totalPages"
:value="current"
@input="goto(Number($event.target.value))"
class="slider"
/>
<button class="btn-ghost" @click="next" :disabled="current >= totalPages">下一页 </button>
</footer>
</transition>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { getSheet, pageUrl } from '../lib/api.js'
const props = defineProps({
id: { type: Number, required: true },
page: { type: Number, default: 1 },
})
const router = useRouter()
const sheet = ref({ title: '', pages: [] })
const loading = ref(true)
const error = ref('')
const current = ref(props.page)
const chromeVisible = ref(true)
const rootEl = ref(null)
const totalPages = computed(() => sheet.value.pages.length)
let hideTimer = null
function bumpChrome() {
chromeVisible.value = true
clearTimeout(hideTimer)
hideTimer = setTimeout(() => (chromeVisible.value = false), 2500)
}
function goto(p) {
if (!totalPages.value) return
const next = Math.max(1, Math.min(totalPages.value, p))
current.value = next
router.replace({ name: 'reader', params: { id: props.id }, query: { page: next } })
bumpChrome()
}
function prev() { goto(current.value - 1) }
function next() { goto(current.value + 1) }
function onTap() {
// 单击图片中央 → 切换 chrome;左右热区由 .hit 触发翻页
chromeVisible.value ? (chromeVisible.value = false) : bumpChrome()
}
function onKey(e) {
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { prev(); e.preventDefault() }
else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { next(); e.preventDefault() }
else if (e.key === 'Home') { goto(1); e.preventDefault() }
else if (e.key === 'End') { goto(totalPages.value); e.preventDefault() }
}
watch(() => props.page, (p) => { if (p && p !== current.value) current.value = p })
onMounted(async () => {
try {
const data = await getSheet(props.id)
sheet.value = data
if (current.value > data.pages.length) current.value = data.pages.length || 1
if (current.value < 1) current.value = 1
bumpChrome()
await nextTick()
rootEl.value?.focus()
} catch (e) {
error.value = e.message || String(e)
} finally {
loading.value = false
}
})
onUnmounted(() => clearTimeout(hideTimer))
</script>
<style scoped>
.reader {
position: fixed;
inset: 0;
background: #000;
outline: none;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
.state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 18px;
}
.state.error { color: var(--accent-red); }
.page-img {
position: absolute;
inset: 0;
margin: auto;
max-width: 100%;
max-height: 100%;
object-fit: contain;
background: #fff;
}
.hit {
position: absolute;
top: 0;
bottom: 0;
width: 30%;
cursor: pointer;
z-index: 5;
}
.hit-left { left: 0; }
.hit-right { right: 0; }
.topbar, .bottombar {
position: absolute;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 14px;
padding: 14px 20px;
background: rgba(10, 14, 26, 0.85);
backdrop-filter: blur(8px);
z-index: 20;
}
.topbar { top: 0; border-bottom: 1px solid var(--border); }
.bottombar { bottom: 0; border-top: 1px solid var(--border); }
.title {
flex: 1;
text-align: center;
font-weight: 600;
font-size: 17px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.counter {
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
font-size: 15px;
min-width: 70px;
text-align: right;
}
.back { text-decoration: none; }
.back:hover { text-decoration: none; }
.slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
background: transparent;
height: 30px;
cursor: pointer;
}
.slider::-webkit-slider-runnable-track {
height: 4px;
background: var(--border);
border-radius: 2px;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent);
margin-top: -9px;
border: 2px solid #fff;
}
.slider::-moz-range-track { height: 4px; background: var(--border); border-radius: 2px; }
.slider::-moz-range-thumb {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent);
border: 2px solid #fff;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
@@ -1,379 +0,0 @@
<template>
<div class="upload">
<header class="topbar">
<router-link to="/" class="back" aria-label="返回"></router-link>
<h1>上传琴谱</h1>
<span class="spacer" />
</header>
<main class="content">
<label class="field">
<span class="label">曲名</span>
<input
v-model="title"
type="text"
placeholder="例如:Clair de Lune"
maxlength="120"
required
enterkeyhint="done"
class="input"
/>
</label>
<section class="actions-add">
<button type="button" class="btn-camera" @click="cameraInput?.click()" :disabled="processing">
<span class="icon">📷</span>
<span class="title">拍下一页</span>
<span class="hint">{{ items.length === 0 ? '主入口:调用摄像头' : `已拍 ${items.length} 页,继续` }}</span>
</button>
<input
ref="cameraInput"
type="file"
accept="image/*"
capture="environment"
@change="onCamera"
hidden
/>
<button type="button" class="btn-album" @click="albumInput?.click()" :disabled="processing">
<span class="icon">📁</span>
<span class="text">从相册选择可多张</span>
</button>
<input
ref="albumInput"
type="file"
accept="image/*"
multiple
@change="onAlbum"
hidden
/>
</section>
<p v-if="processing" class="msg info">压缩中</p>
<ul v-if="items.length" class="pages">
<li v-for="(it, i) in items" :key="it.key" class="page">
<span class="no">P{{ i + 1 }}</span>
<img :src="it.preview" alt="" class="thumb" />
<div class="meta">
<div class="fname">{{ it.file.name }}</div>
<div class="fsize">{{ formatSize(it.file.size) }}</div>
</div>
<div class="ops">
<button type="button" class="op" :disabled="i === 0" @click="move(i, -1)" aria-label="上移"></button>
<button type="button" class="op" :disabled="i === items.length - 1" @click="move(i, 1)" aria-label="下移"></button>
<button type="button" class="op rm" @click="remove(i)" aria-label="移除"></button>
</div>
</li>
</ul>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="success" class="msg success">{{ success }}</p>
</main>
<footer v-if="items.length" class="bottombar">
<button type="button" class="btn-submit" :disabled="!canSubmit" @click="submit">
{{ submitting ? '上传中' : `上传 ${items.length} ` }}
</button>
</footer>
</div>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { uploadSheet } from '../lib/api.js'
import { compressImage } from '../lib/compress.js'
const router = useRouter()
const title = ref('')
const items = ref([]) // { key, file, preview }
const cameraInput = ref(null)
const albumInput = ref(null)
const processing = ref(false)
const submitting = ref(false)
const error = ref('')
const success = ref('')
let nextKey = 0
const canSubmit = computed(
() => !submitting.value && !processing.value && title.value.trim() && items.value.length > 0,
)
async function addFiles(incoming) {
const imgs = incoming.filter((f) => f.type && f.type.startsWith('image/'))
if (imgs.length === 0) return
processing.value = true
error.value = ''
try {
const compressed = await Promise.all(
imgs.map((f) => compressImage(f).catch(() => f)),
)
const fresh = compressed.map((f) => ({
key: ++nextKey,
file: f,
preview: URL.createObjectURL(f),
}))
items.value = [...items.value, ...fresh]
} finally {
processing.value = false
}
}
function onCamera(e) {
addFiles(Array.from(e.target.files || []))
e.target.value = ''
}
function onAlbum(e) {
addFiles(Array.from(e.target.files || []))
e.target.value = ''
}
function move(i, delta) {
const j = i + delta
if (j < 0 || j >= items.value.length) return
const next = items.value.slice()
;[next[i], next[j]] = [next[j], next[i]]
items.value = next
}
function remove(i) {
const it = items.value[i]
URL.revokeObjectURL(it.preview)
items.value = items.value.filter((_, idx) => idx !== i)
}
function formatSize(n) {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
async function submit() {
if (!canSubmit.value) return
submitting.value = true
error.value = ''
success.value = ''
try {
const r = await uploadSheet(title.value.trim(), items.value.map((x) => x.file))
success.value = `已上传:${r.title}${r.pages} 页)`
router.push({ name: 'reader', params: { id: r.id } })
} catch (e) {
error.value = e.message || String(e)
} finally {
submitting.value = false
}
}
onUnmounted(() => {
for (const it of items.value) URL.revokeObjectURL(it.preview)
})
</script>
<style scoped>
.upload {
min-height: 100%;
display: flex;
flex-direction: column;
/* 给底部 sticky 按钮留 safe areaiOS 刘海/底部条) */
padding-bottom: env(safe-area-inset-bottom);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
padding-top: calc(12px + env(safe-area-inset-top));
border-bottom: 1px solid var(--border);
background: var(--bg-card);
position: sticky;
top: 0;
z-index: 10;
}
.topbar h1 { font-size: 18px; font-weight: 700; }
.back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius);
font-size: 20px;
text-decoration: none;
color: var(--text-primary);
}
.back:hover { background: var(--bg-hover); text-decoration: none; }
.spacer { width: 40px; }
.content {
flex: 1;
padding: 16px 16px 120px;
display: flex;
flex-direction: column;
gap: 18px;
width: 100%;
max-width: 720px;
margin: 0 auto;
}
.field { display: flex; flex-direction: column; gap: 6px; }
.label { font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.input {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
font-size: 17px;
padding: 14px 16px;
outline: none;
font-family: inherit;
/* iOS 默认会放大到 16px+ 才不缩放,这里 17px 安全 */
}
.input:focus { border-color: var(--accent); }
.actions-add { display: flex; flex-direction: column; gap: 12px; }
.btn-camera {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 28px 16px;
border: 0;
border-radius: var(--radius-lg);
background: var(--accent);
color: #fff;
cursor: pointer;
transition: transform 0.05s, opacity 0.15s;
font-family: inherit;
}
.btn-camera:active { transform: scale(0.98); }
.btn-camera:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-camera .icon { font-size: 36px; line-height: 1; }
.btn-camera .title { font-size: 20px; font-weight: 700; }
.btn-camera .hint { font-size: 13px; opacity: 0.9; }
.btn-album {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
font-size: 15px;
font-family: inherit;
}
.btn-album:hover { background: var(--bg-hover); }
.btn-album:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-album .icon { font-size: 18px; }
.pages {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.page {
display: grid;
grid-template-columns: 36px 64px 1fr auto;
align-items: center;
gap: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
}
.no {
background: var(--bg-surface);
color: var(--text-secondary);
font-weight: 600;
border-radius: 6px;
padding: 6px 0;
font-size: 13px;
text-align: center;
}
.thumb {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 6px;
background: var(--bg-surface);
}
.meta { min-width: 0; }
.fname {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
}
.fsize { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
.ops { display: flex; gap: 4px; }
.op {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bg-surface);
color: var(--text-secondary);
border: 0;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
}
.op:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); }
.op:disabled { opacity: 0.3; cursor: not-allowed; }
.op.rm:hover { color: var(--accent-red); }
.msg { font-size: 14px; padding: 10px 14px; border-radius: var(--radius); }
.msg.info { background: var(--bg-surface); color: var(--text-secondary); }
.msg.error { background: rgba(239, 68, 68, 0.1); color: var(--accent-red); }
.msg.success { background: rgba(16, 185, 129, 0.1); color: var(--accent-green); }
.bottombar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
background: var(--bg-card);
border-top: 1px solid var(--border);
z-index: 10;
}
.btn-submit {
width: 100%;
padding: 16px;
border: 0;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
font-size: 17px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.btn-submit:active:not(:disabled) { opacity: 0.9; }
.btn-submit:disabled { opacity: 0.5; cursor: not-allowed; }
/* 平板及以上:拍照与相册按钮并排 */
@media (min-width: 720px) {
.actions-add { flex-direction: row; }
.btn-camera { flex: 2; }
.btn-album { flex: 1; padding: 28px 16px; flex-direction: column; gap: 4px; }
.btn-album .text { font-size: 15px; font-weight: 600; }
.btn-album .icon { font-size: 28px; }
.topbar { padding: 16px 24px; padding-top: calc(16px + env(safe-area-inset-top)); }
.topbar h1 { font-size: 20px; }
.content { padding: 24px 24px 140px; }
}
</style>
-269
View File
@@ -1,269 +0,0 @@
//! piano.famzheng.me — 钢琴谱管理 / 阅读。
//!
//! 5 个 endpoint
//! - `GET /api/health` 前端 ping。
//! - `POST /api/upload` multiparttitle + 多张 image 文件,BLOB 存 sqlite。
//! - `GET /api/sheets` 列表,按 created_at desc。
//! - `GET /api/sheets/:id` 详情:title + 图片 id 列表(按 page asc)。
//! - `GET /api/sheets/:id/pages/:page`
//! 单页图片 BLOB,直接 image/* 响应(用于 <img src>)。
//!
//! 图片 BLOB 直存 sqlite。单张限 10MB,单曲谱最多 64 页。
//! 静态前端 + SPA fallback 由 cube-core::base 处理。
use std::sync::{Arc, Mutex};
use axum::{
extract::{DefaultBodyLimit, Multipart, Path, State},
http::{header, StatusCode},
response::{IntoResponse, Json as JsonResp, Response},
routing::{get, post},
Router,
};
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
use serde_json::json;
type Db = Arc<Mutex<Connection>>;
const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
const MAX_PAGES: usize = 64;
const MAX_REQUEST_BYTES: usize = MAX_IMAGE_BYTES * MAX_PAGES;
#[tokio::main]
async fn main() -> std::io::Result<()> {
cube_core::init_tracing();
let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "/data/app.db".into());
let dist = std::env::var("PIANO_SHEET_DIST_DIR").unwrap_or_else(|_| "/dist".into());
let conn = Connection::open(&db_path).expect("open sqlite");
conn.execute_batch(
"PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS sheets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_id INTEGER NOT NULL,
page INTEGER NOT NULL,
mime TEXT NOT NULL,
bytes BLOB NOT NULL,
FOREIGN KEY (sheet_id) REFERENCES sheets(id) ON DELETE CASCADE,
UNIQUE (sheet_id, page)
);",
)
.expect("init schema");
tracing::info!(%db_path, "sqlite ready");
let db: Db = Arc::new(Mutex::new(conn));
let api = Router::new()
.route("/health", get(|| async { "ok" }))
.route("/upload", post(upload).layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)))
.route("/sheets", get(list_sheets))
.route("/sheets/:id", get(get_sheet))
.route("/sheets/:id/pages/:page", get(get_page))
.with_state(db);
let app = cube_core::base(dist).nest("/api", api);
cube_core::serve(app, 8080).await
}
#[derive(Serialize)]
struct SheetSummary {
id: i64,
title: String,
pages: i64,
created_at: String,
}
/// `POST /api/upload` — multipart`title` 字段 + 一个或多个 `images` 文件字段。
/// 文件按到达顺序编号 page (1-based)。
async fn upload(
State(db): State<Db>,
mut form: Multipart,
) -> Result<impl IntoResponse, AppError> {
let mut title: Option<String> = None;
let mut images: Vec<(String, Vec<u8>)> = Vec::new();
while let Some(field) = form
.next_field()
.await
.map_err(|e| AppError::bad_request(format!("multipart error: {e}")))?
{
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"title" => {
let s = field
.text()
.await
.map_err(|e| AppError::bad_request(format!("title read: {e}")))?;
title = Some(s.trim().to_string());
}
"images" => {
let mime = field
.content_type()
.map(|s| s.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
if !mime.starts_with("image/") {
return Err(AppError::bad_request(format!(
"field 'images' must be image/*, got {mime}"
)));
}
let bytes = field
.bytes()
.await
.map_err(|e| AppError::bad_request(format!("image read: {e}")))?;
if bytes.len() > MAX_IMAGE_BYTES {
return Err(AppError::bad_request(format!(
"image too large ({} bytes, limit {MAX_IMAGE_BYTES})",
bytes.len()
)));
}
if images.len() >= MAX_PAGES {
return Err(AppError::bad_request(format!(
"too many pages (limit {MAX_PAGES})"
)));
}
images.push((mime, bytes.to_vec()));
}
_ => {}
}
}
let title = title
.filter(|s| !s.is_empty())
.ok_or_else(|| AppError::bad_request("missing 'title'"))?;
if images.is_empty() {
return Err(AppError::bad_request("at least one image required"));
}
let mut conn = db.lock().unwrap();
let tx = conn.transaction()?;
tx.execute("INSERT INTO sheets (title) VALUES (?1)", params![title])?;
let sheet_id = tx.last_insert_rowid();
for (i, (mime, bytes)) in images.iter().enumerate() {
let page = (i as i64) + 1;
tx.execute(
"INSERT INTO pages (sheet_id, page, mime, bytes) VALUES (?1, ?2, ?3, ?4)",
params![sheet_id, page, mime, bytes],
)?;
}
tx.commit()?;
Ok(JsonResp(json!({
"id": sheet_id,
"title": title,
"pages": images.len(),
})))
}
/// `GET /api/sheets` — 列表(不返回 BLOB)。
async fn list_sheets(State(db): State<Db>) -> Result<impl IntoResponse, AppError> {
let conn = db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT s.id, s.title, COUNT(p.id) AS pages, s.created_at
FROM sheets s
LEFT JOIN pages p ON p.sheet_id = s.id
GROUP BY s.id
ORDER BY s.created_at DESC, s.id DESC",
)?;
let rows = stmt
.query_map([], |r| {
Ok(SheetSummary {
id: r.get(0)?,
title: r.get(1)?,
pages: r.get(2)?,
created_at: r.get(3)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!(rows)))
}
/// `GET /api/sheets/:id` — title + page 列表(不带 BLOB;前端通过 page url 拿图)。
async fn get_sheet(
State(db): State<Db>,
Path(id): Path<i64>,
) -> Result<impl IntoResponse, AppError> {
let conn = db.lock().unwrap();
let row: Option<(String, String)> = conn
.query_row(
"SELECT title, created_at FROM sheets WHERE id = ?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let (title, created_at) = row.ok_or(AppError::NotFound)?;
let mut stmt = conn.prepare("SELECT page FROM pages WHERE sheet_id = ?1 ORDER BY page ASC")?;
let pages: Vec<i64> = stmt
.query_map(params![id], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!({
"id": id,
"title": title,
"created_at": created_at,
"pages": pages,
})))
}
/// `GET /api/sheets/:id/pages/:page` — 单页图片 BLOB。
async fn get_page(
State(db): State<Db>,
Path((id, page)): Path<(i64, i64)>,
) -> Result<Response, AppError> {
let conn = db.lock().unwrap();
let row: Option<(String, Vec<u8>)> = conn
.query_row(
"SELECT mime, bytes FROM pages WHERE sheet_id = ?1 AND page = ?2",
params![id, page],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let (mime, bytes) = row.ok_or(AppError::NotFound)?;
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, mime),
(header::CACHE_CONTROL, "public, max-age=31536000, immutable".into()),
],
bytes,
)
.into_response())
}
enum AppError {
BadRequest(String),
NotFound,
Db(rusqlite::Error),
}
impl AppError {
fn bad_request(msg: impl Into<String>) -> Self {
Self::BadRequest(msg.into())
}
}
impl From<rusqlite::Error> for AppError {
fn from(e: rusqlite::Error) -> Self {
Self::Db(e)
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match self {
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
Self::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(),
Self::Db(e) => {
tracing::error!(error = %e, "sqlite error");
(StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response()
}
}
}
}