app #2 piano-sheet: 钢琴谱管理 / 阅读,piano.famzheng.me
后端 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:
@@ -0,0 +1,51 @@
|
||||
name: deploy piano-sheet
|
||||
# piano.famzheng.me — 钢琴谱管理 / 阅读。host shell runner(fam 用户)。
|
||||
|
||||
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
@@ -59,6 +59,7 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
@@ -142,6 +143,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -414,6 +424,23 @@ dependencies = [
|
||||
"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]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -458,6 +485,20 @@ 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"
|
||||
@@ -672,6 +713,12 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
|
||||
@@ -4,6 +4,7 @@ members = [
|
||||
"crates/cube-core",
|
||||
"apps/cube",
|
||||
"apps/simpleasm",
|
||||
"apps/piano-sheet",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -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 }
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -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>
|
||||
+1278
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 是 RWO,rolling 上线时新旧 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: cube-piano-sheet
|
||||
@@ -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-path(hostPath,单节点足够)
|
||||
@@ -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
|
||||
@@ -0,0 +1,269 @@
|
||||
//! piano.famzheng.me — 钢琴谱管理 / 阅读。
|
||||
//!
|
||||
//! 5 个 endpoint:
|
||||
//! - `GET /api/health` 前端 ping。
|
||||
//! - `POST /api/upload` multipart:title + 多张 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user