app #2 piano-sheet: 钢琴谱管理 / 阅读,piano.famzheng.me
deploy cube / build-and-deploy (push) Successful in 1m8s
deploy piano-sheet / build-and-deploy (push) Failing after 1m38s
deploy simpleasm / build-and-deploy (push) Successful in 1m22s

后端 axum + sqlite,图片直接 BLOB 存进 pages 表(单张 ≤ 10MB / 单
谱 ≤ 64 页),5 个 endpoint:multipart upload、列表、详情、单页图片
(带 immutable cache header)+ healthz。

前端 vue3 + pinia + vue-router,3 个视图:列表(卡片网格 + 首页缩
略)、上传(拖拽 + 顺序预览)、阅读(全屏,左右点按 / 键盘 / 拖
拽进度条翻页,2.5s 自动隐藏 chrome)。视图状态走 URL(reader 的
当前页是 ?page=N)。

部署:cube-piano-sheet ns + 10Gi PVC + traefik ingress + 一条
buffering middleware 把 body 上限抬到 700MB。镜像 < 20MB(scratch
+ musl 静态)。
This commit was merged in pull request #1.
This commit is contained in:
Fam Zheng
2026-05-05 09:29:02 +01:00
parent e81f44662a
commit 28713e489f
23 changed files with 2616 additions and 0 deletions
+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'
}
}
})