feat: create piano-sheet app for tablet sheet music management #1

Merged
fam merged 1 commits from feat/piano-sheet into master 2026-05-05 08:35:13 +00:00
23 changed files with 2616 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
name: deploy piano-sheet
# piano.famzheng.me — 钢琴谱管理 / 阅读。host shell runnerfam 用户)。
on:
push:
branches: [master]
paths:
- 'apps/piano-sheet/**'
- 'crates/cube-core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/deploy-piano-sheet.yml'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
APP: piano-sheet
IMAGE: registry.famzheng.me/mochi/piano-sheet
steps:
- uses: actions/checkout@v4
- name: Resolve image tag
id: tag
run: |
echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
- name: Build rust (musl static)
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release --target x86_64-unknown-linux-musl -p "$APP"
- name: Build frontend
run: |
cd "apps/$APP/frontend"
npm ci
npm run build
- name: Build & push image
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
docker build -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
- name: Roll out to k3s
run: |
kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
kubectl -n "cube-$APP" rollout status "deploy/$APP" --timeout=120s
Generated
+47
View File
@@ -59,6 +59,7 @@ dependencies = [
"matchit", "matchit",
"memchr", "memchr",
"mime", "mime",
"multer",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
@@ -142,6 +143,15 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@@ -414,6 +424,23 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -458,6 +485,20 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "piano-sheet"
version = "0.1.0"
dependencies = [
"axum",
"cube-core",
"rusqlite",
"serde",
"serde_json",
"tokio",
"tower-http",
"tracing",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -672,6 +713,12 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
+1
View File
@@ -4,6 +4,7 @@ members = [
"crates/cube-core", "crates/cube-core",
"apps/cube", "apps/cube",
"apps/simpleasm", "apps/simpleasm",
"apps/piano-sheet",
] ]
[workspace.package] [workspace.package]
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "piano-sheet"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "piano.famzheng.me — 钢琴谱管理 / 阅读 app,多图谱面 BLOB 存 sqlite"
[dependencies]
cube-core = { path = "../../crates/cube-core" }
axum = { workspace = true, features = ["multipart"] }
tokio = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
rusqlite = { workspace = true }
+8
View File
@@ -0,0 +1,8 @@
# 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
@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.log
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<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>
<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">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "piano-sheet",
"private": true,
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.4.0"
}
}
+74
View File
@@ -0,0 +1,74 @@
<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
@@ -0,0 +1,29 @@
// 薄薄一层 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)
}
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router/index.js'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
@@ -0,0 +1,20 @@
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,
})
@@ -0,0 +1,156 @@
<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>
@@ -0,0 +1,229 @@
<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>
@@ -0,0 +1,261 @@
<template>
<div class="upload-page">
<header class="topbar">
<router-link to="/" class="btn-ghost back"> 返回</router-link>
<h1>上传琴谱</h1>
<span class="spacer" />
</header>
<main class="content">
<form @submit.prevent="submit" class="form">
<label class="field">
<span class="label">曲名</span>
<input
v-model="title"
type="text"
placeholder="例如:Clair de Lune"
maxlength="120"
required
class="input"
/>
</label>
<label
class="dropzone"
:class="{ dragging }"
@dragenter.prevent="dragging = true"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<input
type="file"
accept="image/*"
multiple
@change="onPick"
class="file-input"
/>
<div class="dz-inner">
<div class="dz-icon">🎼</div>
<div class="dz-title">点击或拖拽图片到这里</div>
<div class="dz-sub">支持多张按选择顺序作为页码 · 单张 10MB</div>
</div>
</label>
<ul v-if="files.length" class="filelist">
<li v-for="(f, i) in files" :key="i" class="fileitem">
<span class="page-no">P{{ i + 1 }}</span>
<img :src="previews[i]" alt="" class="thumb" />
<span class="fname">{{ f.name }}</span>
<span class="fsize">{{ formatSize(f.size) }}</span>
<button type="button" class="rm" @click="remove(i)" aria-label="移除"></button>
</li>
</ul>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="success" class="msg success">{{ success }}</p>
<div class="actions">
<button type="submit" class="btn-primary" :disabled="!canSubmit">
{{ submitting ? '上传中…' : `上传 ${files.length}` }}
</button>
</div>
</form>
</main>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { uploadSheet } from '../lib/api.js'
const router = useRouter()
const title = ref('')
const files = ref([])
const previews = ref([])
const dragging = ref(false)
const submitting = ref(false)
const error = ref('')
const success = ref('')
const canSubmit = computed(
() => !submitting.value && title.value.trim() && files.value.length > 0,
)
watch(
files,
(list) => {
previews.value.forEach((u) => URL.revokeObjectURL(u))
previews.value = list.map((f) => URL.createObjectURL(f))
},
{ deep: false },
)
function addFiles(incoming) {
const imgs = incoming.filter((f) => f.type.startsWith('image/'))
files.value = [...files.value, ...imgs]
}
function onPick(e) {
addFiles(Array.from(e.target.files || []))
e.target.value = ''
}
function onDrop(e) {
dragging.value = false
addFiles(Array.from(e.dataTransfer.files || []))
}
function remove(i) {
files.value = files.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(), files.value)
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
}
}
</script>
<style scoped>
.upload-page {
min-height: 100%;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
position: sticky;
top: 0;
z-index: 10;
}
.topbar h1 { font-size: 20px; font-weight: 700; }
.back { text-decoration: none; }
.back:hover { text-decoration: none; }
.spacer { width: 80px; }
.content {
flex: 1;
padding: 32px 24px 80px;
display: flex;
justify-content: center;
}
.form {
width: 100%;
max-width: 720px;
display: flex;
flex-direction: column;
gap: 22px;
}
.field { display: flex; flex-direction: column; gap: 8px; }
.label { font-size: 14px; 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: 18px;
padding: 14px 16px;
outline: none;
font-family: inherit;
}
.input:focus { border-color: var(--accent); }
.dropzone {
position: relative;
border: 2px dashed var(--border);
border-radius: var(--radius-lg);
padding: 40px 20px;
text-align: center;
background: var(--bg-card);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
display: block;
}
.dropzone.dragging { border-color: var(--accent); background: var(--bg-surface); }
.file-input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.dz-inner { pointer-events: none; }
.dz-icon { font-size: 42px; margin-bottom: 8px; }
.dz-title { font-size: 18px; font-weight: 600; }
.dz-sub { color: var(--text-muted); margin-top: 6px; font-size: 14px; }
.filelist {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.fileitem {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 12px;
}
.page-no {
background: var(--bg-surface);
color: var(--text-secondary);
font-weight: 600;
border-radius: 6px;
padding: 4px 8px;
font-size: 13px;
min-width: 38px;
text-align: center;
}
.thumb {
width: 44px;
height: 44px;
object-fit: cover;
border-radius: 6px;
}
.fname {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fsize { color: var(--text-muted); font-size: 13px; }
.rm {
background: transparent;
color: var(--text-muted);
padding: 6px 10px;
border-radius: 6px;
}
.rm:hover { background: var(--bg-hover); color: var(--accent-red); }
.msg { font-size: 14px; padding: 10px 14px; border-radius: var(--radius); }
.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); }
.actions { display: flex; justify-content: flex-end; }
</style>
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:8080'
}
}
})
+58
View File
@@ -0,0 +1,58 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: piano-sheet
namespace: cube-piano-sheet
labels:
app: piano-sheet
spec:
replicas: 1
strategy:
# PVC 是 RWOrolling 上线时新旧 pod 抢 PVC 会卡住,直接 Recreate
type: Recreate
selector:
matchLabels:
app: piano-sheet
template:
metadata:
labels:
app: piano-sheet
spec:
imagePullSecrets:
- name: registry-creds
containers:
- name: piano-sheet
image: registry.famzheng.me/mochi/piano-sheet:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
env:
- name: DB_PATH
value: /data/app.db
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 1
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 15
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 500m
memory: 256Mi
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: piano-sheet-data
+30
View File
@@ -0,0 +1,30 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: piano-sheet
namespace: cube-piano-sheet
annotations:
# 上传整组图片可能 ~600MB,调高 traefik 默认上限
traefik.ingress.kubernetes.io/router.middlewares: cube-piano-sheet-bodylimit@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: piano.famzheng.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: piano-sheet
port:
number: 80
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: bodylimit
namespace: cube-piano-sheet
spec:
buffering:
maxRequestBodyBytes: 700000000
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: cube-piano-sheet
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: piano-sheet-data
namespace: cube-piano-sheet
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
# 图片直存 sqlite,留出宽裕空间
storage: 10Gi
# storageClassName 留空 → 走 k3s 默认 local-pathhostPath,单节点足够)
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: piano-sheet
namespace: cube-piano-sheet
spec:
selector:
app: piano-sheet
ports:
- name: http
port: 80
targetPort: 8080
+269
View File
@@ -0,0 +1,269 @@
//! 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()
}
}
}
}