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,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'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user