piano-sheet(upload): mobile/pad first,主入口直调后置摄像头
deploy piano-sheet / build-and-deploy (push) Successful in 1m17s

- 主 CTA「拍下一页」+ capture=environment,副 CTA「从相册」multiple
- 底部 sticky 上传按钮,iOS safe area 兜住刘海/Home indicator
- 客户端压缩 1800px JPEG 0.85(createImageBitmap + EXIF 自动旋转)
- 页码 ↑↓ 移动 + ✕ 删除替代旧拖拽
This commit is contained in:
Fam Zheng
2026-05-05 10:57:38 +01:00
parent 1e04655003
commit 58f344db85
2 changed files with 282 additions and 132 deletions
@@ -0,0 +1,32 @@
// 移动端拍照原图常 512MB15001800px 长边 + JPEG 0.85 已经够看清楚音符。
// 用 createImageBitmap 的 imageOrientation: 'from-image' 自动按 EXIF 旋转,
// 否则横拍照在 canvas 里会变成竖图。
export async function compressImage(file, { maxEdge = 1800, quality = 0.85 } = {}) {
if (!file || !file.type || !file.type.startsWith('image/')) return file
let bitmap
try {
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' })
} catch {
return file
}
const { width, height } = bitmap
const scale = Math.min(1, maxEdge / Math.max(width, height))
const w = Math.max(1, Math.round(width * scale))
const h = Math.max(1, Math.round(height * scale))
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
ctx.drawImage(bitmap, 0, 0, w, h)
bitmap.close?.()
const blob = await new Promise((res) => canvas.toBlob(res, 'image/jpeg', quality))
if (!blob || blob.size >= file.size) return file
const baseName = file.name?.replace(/\.[^.]+$/, '') || 'photo'
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg', lastModified: Date.now() })
}
+229 -111
View File
@@ -1,13 +1,12 @@
<template> <template>
<div class="upload-page"> <div class="upload">
<header class="topbar"> <header class="topbar">
<router-link to="/" class="btn-ghost back"> 返回</router-link> <router-link to="/" class="back" aria-label="返回"></router-link>
<h1>上传琴谱</h1> <h1>上传琴谱</h1>
<span class="spacer" /> <span class="spacer" />
</header> </header>
<main class="content"> <main class="content">
<form @submit.prevent="submit" class="form">
<label class="field"> <label class="field">
<span class="label">曲名</span> <span class="label">曲名</span>
<input <input
@@ -16,100 +15,135 @@
placeholder="例如:Clair de Lune" placeholder="例如:Clair de Lune"
maxlength="120" maxlength="120"
required required
enterkeyhint="done"
class="input" class="input"
/> />
</label> </label>
<label <section class="actions-add">
class="dropzone" <button type="button" class="btn-camera" @click="cameraInput?.click()" :disabled="processing">
:class="{ dragging }" <span class="icon">📷</span>
@dragenter.prevent="dragging = true" <span class="title">拍下一页</span>
@dragover.prevent="dragging = true" <span class="hint">{{ items.length === 0 ? '主入口:调用摄像头' : `已拍 ${items.length} 页,继续` }}</span>
@dragleave.prevent="dragging = false" </button>
@drop.prevent="onDrop"
>
<input <input
ref="cameraInput"
type="file"
accept="image/*"
capture="environment"
@change="onCamera"
hidden
/>
<button type="button" class="btn-album" @click="albumInput?.click()" :disabled="processing">
<span class="icon">📁</span>
<span class="text">从相册选择可多张</span>
</button>
<input
ref="albumInput"
type="file" type="file"
accept="image/*" accept="image/*"
multiple multiple
@change="onPick" @change="onAlbum"
class="file-input" hidden
/> />
<div class="dz-inner"> </section>
<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"> <p v-if="processing" class="msg info">压缩中</p>
<li v-for="(f, i) in files" :key="i" class="fileitem">
<span class="page-no">P{{ i + 1 }}</span> <ul v-if="items.length" class="pages">
<img :src="previews[i]" alt="" class="thumb" /> <li v-for="(it, i) in items" :key="it.key" class="page">
<span class="fname">{{ f.name }}</span> <span class="no">P{{ i + 1 }}</span>
<span class="fsize">{{ formatSize(f.size) }}</span> <img :src="it.preview" alt="" class="thumb" />
<button type="button" class="rm" @click="remove(i)" aria-label="移除"></button> <div class="meta">
<div class="fname">{{ it.file.name }}</div>
<div class="fsize">{{ formatSize(it.file.size) }}</div>
</div>
<div class="ops">
<button type="button" class="op" :disabled="i === 0" @click="move(i, -1)" aria-label="上移"></button>
<button type="button" class="op" :disabled="i === items.length - 1" @click="move(i, 1)" aria-label="下移"></button>
<button type="button" class="op rm" @click="remove(i)" aria-label="移除"></button>
</div>
</li> </li>
</ul> </ul>
<p v-if="error" class="msg error">{{ error }}</p> <p v-if="error" class="msg error">{{ error }}</p>
<p v-if="success" class="msg success">{{ success }}</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> </main>
<footer v-if="items.length" class="bottombar">
<button type="button" class="btn-submit" :disabled="!canSubmit" @click="submit">
{{ submitting ? '上传中' : `上传 ${items.length} ` }}
</button>
</footer>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { uploadSheet } from '../lib/api.js' import { uploadSheet } from '../lib/api.js'
import { compressImage } from '../lib/compress.js'
const router = useRouter() const router = useRouter()
const title = ref('') const title = ref('')
const files = ref([]) const items = ref([]) // { key, file, preview }
const previews = ref([]) const cameraInput = ref(null)
const dragging = ref(false) const albumInput = ref(null)
const processing = ref(false)
const submitting = ref(false) const submitting = ref(false)
const error = ref('') const error = ref('')
const success = ref('') const success = ref('')
let nextKey = 0
const canSubmit = computed( const canSubmit = computed(
() => !submitting.value && title.value.trim() && files.value.length > 0, () => !submitting.value && !processing.value && title.value.trim() && items.value.length > 0,
) )
watch( async function addFiles(incoming) {
files, const imgs = incoming.filter((f) => f.type && f.type.startsWith('image/'))
(list) => { if (imgs.length === 0) return
previews.value.forEach((u) => URL.revokeObjectURL(u)) processing.value = true
previews.value = list.map((f) => URL.createObjectURL(f)) error.value = ''
}, try {
{ deep: false }, const compressed = await Promise.all(
imgs.map((f) => compressImage(f).catch(() => f)),
) )
const fresh = compressed.map((f) => ({
function addFiles(incoming) { key: ++nextKey,
const imgs = incoming.filter((f) => f.type.startsWith('image/')) file: f,
files.value = [...files.value, ...imgs] preview: URL.createObjectURL(f),
}))
items.value = [...items.value, ...fresh]
} finally {
processing.value = false
}
} }
function onPick(e) { function onCamera(e) {
addFiles(Array.from(e.target.files || [])) addFiles(Array.from(e.target.files || []))
e.target.value = '' e.target.value = ''
} }
function onDrop(e) { function onAlbum(e) {
dragging.value = false addFiles(Array.from(e.target.files || []))
addFiles(Array.from(e.dataTransfer.files || [])) e.target.value = ''
}
function move(i, delta) {
const j = i + delta
if (j < 0 || j >= items.value.length) return
const next = items.value.slice()
;[next[i], next[j]] = [next[j], next[i]]
items.value = next
} }
function remove(i) { function remove(i) {
files.value = files.value.filter((_, idx) => idx !== i) const it = items.value[i]
URL.revokeObjectURL(it.preview)
items.value = items.value.filter((_, idx) => idx !== i)
} }
function formatSize(n) { function formatSize(n) {
@@ -124,7 +158,7 @@ async function submit() {
error.value = '' error.value = ''
success.value = '' success.value = ''
try { try {
const r = await uploadSheet(title.value.trim(), files.value) const r = await uploadSheet(title.value.trim(), items.value.map((x) => x.file))
success.value = `已上传:${r.title}${r.pages} 页)` success.value = `已上传:${r.title}${r.pages} 页)`
router.push({ name: 'reader', params: { id: r.id } }) router.push({ name: 'reader', params: { id: r.id } })
} catch (e) { } catch (e) {
@@ -133,129 +167,213 @@ async function submit() {
submitting.value = false submitting.value = false
} }
} }
onUnmounted(() => {
for (const it of items.value) URL.revokeObjectURL(it.preview)
})
</script> </script>
<style scoped> <style scoped>
.upload-page { .upload {
min-height: 100%; min-height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* 给底部 sticky 按钮留 safe areaiOS 刘海/底部条) */
padding-bottom: env(safe-area-inset-bottom);
} }
.topbar { .topbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 16px 24px; padding: 12px 16px;
padding-top: calc(12px + env(safe-area-inset-top));
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg-card); background: var(--bg-card);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
} }
.topbar h1 { font-size: 20px; font-weight: 700; } .topbar h1 { font-size: 18px; font-weight: 700; }
.back { text-decoration: none; } .back {
.back:hover { text-decoration: none; } display: inline-flex;
.spacer { width: 80px; } align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius);
font-size: 20px;
text-decoration: none;
color: var(--text-primary);
}
.back:hover { background: var(--bg-hover); text-decoration: none; }
.spacer { width: 40px; }
.content { .content {
flex: 1; flex: 1;
padding: 32px 24px 80px; padding: 16px 16px 120px;
display: flex;
justify-content: center;
}
.form {
width: 100%;
max-width: 720px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 22px; gap: 18px;
width: 100%;
max-width: 720px;
margin: 0 auto;
} }
.field { display: flex; flex-direction: column; gap: 8px; }
.label { font-size: 14px; color: var(--text-secondary); font-weight: 500; } .field { display: flex; flex-direction: column; gap: 6px; }
.label { font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.input { .input {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
color: var(--text-primary); color: var(--text-primary);
font-size: 18px; font-size: 17px;
padding: 14px 16px; padding: 14px 16px;
outline: none; outline: none;
font-family: inherit; font-family: inherit;
/* iOS 默认会放大到 16px+ 才不缩放,这里 17px 安全 */
} }
.input:focus { border-color: var(--accent); } .input:focus { border-color: var(--accent); }
.dropzone { .actions-add { display: flex; flex-direction: column; gap: 12px; }
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 { .btn-camera {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 28px 16px;
border: 0;
border-radius: var(--radius-lg);
background: var(--accent);
color: #fff;
cursor: pointer;
transition: transform 0.05s, opacity 0.15s;
font-family: inherit;
}
.btn-camera:active { transform: scale(0.98); }
.btn-camera:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-camera .icon { font-size: 36px; line-height: 1; }
.btn-camera .title { font-size: 20px; font-weight: 700; }
.btn-camera .hint { font-size: 13px; opacity: 0.9; }
.btn-album {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
font-size: 15px;
font-family: inherit;
}
.btn-album:hover { background: var(--bg-hover); }
.btn-album:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-album .icon { font-size: 18px; }
.pages {
list-style: none; list-style: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.fileitem { .page {
display: flex; display: grid;
grid-template-columns: 36px 64px 1fr auto;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
padding: 8px 12px; padding: 8px 10px;
} }
.page-no { .no {
background: var(--bg-surface); background: var(--bg-surface);
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 600; font-weight: 600;
border-radius: 6px; border-radius: 6px;
padding: 4px 8px; padding: 6px 0;
font-size: 13px; font-size: 13px;
min-width: 38px;
text-align: center; text-align: center;
} }
.thumb { .thumb {
width: 44px; width: 64px;
height: 44px; height: 64px;
object-fit: cover; object-fit: cover;
border-radius: 6px; border-radius: 6px;
background: var(--bg-surface);
} }
.meta { min-width: 0; }
.fname { .fname {
flex: 1;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
font-size: 14px;
} }
.fsize { color: var(--text-muted); font-size: 13px; } .fsize { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
.rm { .ops { display: flex; gap: 4px; }
background: transparent; .op {
color: var(--text-muted); width: 36px;
padding: 6px 10px; height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bg-surface);
color: var(--text-secondary);
border: 0;
border-radius: 6px; border-radius: 6px;
font-size: 16px;
cursor: pointer;
} }
.rm:hover { background: var(--bg-hover); color: var(--accent-red); } .op:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); }
.op:disabled { opacity: 0.3; cursor: not-allowed; }
.op.rm:hover { color: var(--accent-red); }
.msg { font-size: 14px; padding: 10px 14px; border-radius: var(--radius); } .msg { font-size: 14px; padding: 10px 14px; border-radius: var(--radius); }
.msg.info { background: var(--bg-surface); color: var(--text-secondary); }
.msg.error { background: rgba(239, 68, 68, 0.1); color: var(--accent-red); } .msg.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); } .msg.success { background: rgba(16, 185, 129, 0.1); color: var(--accent-green); }
.actions { display: flex; justify-content: flex-end; } .bottombar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
background: var(--bg-card);
border-top: 1px solid var(--border);
z-index: 10;
}
.btn-submit {
width: 100%;
padding: 16px;
border: 0;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
font-size: 17px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.btn-submit:active:not(:disabled) { opacity: 0.9; }
.btn-submit:disabled { opacity: 0.5; cursor: not-allowed; }
/* 平板及以上:拍照与相册按钮并排 */
@media (min-width: 720px) {
.actions-add { flex-direction: row; }
.btn-camera { flex: 2; }
.btn-album { flex: 1; padding: 28px 16px; flex-direction: column; gap: 4px; }
.btn-album .text { font-size: 15px; font-weight: 600; }
.btn-album .icon { font-size: 28px; }
.topbar { padding: 16px 24px; padding-top: calc(16px + env(safe-area-inset-top)); }
.topbar h1 { font-size: 20px; }
.content { padding: 24px 24px 140px; }
}
</style> </style>