music: 新建 music app,替换 piano-sheet
听歌 + 练琴曲目管理: - 数据:piece (title/artist/category/lyrics/play_count/notes) + attachments (audio/video/pdf/image; image 带 role=chord/numbered/staff) - 后端 axum + sqlite,附件流式落 PVC,ServeFile 支持 Range(视频拖动) - 前端 guitar 风格 player:左 sidebar + tabs(歌词/吉他谱/简谱/五线谱/PDF/视频),LRC 同步、快捷键、笔记自动保存 - ns cube-music + music.famzheng.me + bodylimit 5GiB - scripts/import_guitar.py 用于把 oci /data/guitar/ 旧曲库导入
This commit is contained in:
@@ -1,24 +1,24 @@
|
|||||||
name: deploy piano-sheet
|
name: deploy music
|
||||||
# piano.famzheng.me — 钢琴谱管理 / 阅读。host shell runner(fam 用户)。
|
# music.famzheng.me — 听歌 + 练琴 曲目管理。host shell runner(fam 用户)。
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- 'apps/piano-sheet/**'
|
- 'apps/music/**'
|
||||||
- 'crates/cube-core/**'
|
- 'crates/cube-core/**'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- '.gitea/workflows/deploy-piano-sheet.yml'
|
- '.gitea/workflows/deploy-music.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
APP: piano-sheet
|
APP: music
|
||||||
NS: cube-piano
|
NS: cube-music
|
||||||
IMAGE: registry.famzheng.me/mochi/piano-sheet
|
IMAGE: registry.famzheng.me/mochi/music
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Initialize K8s resources
|
- name: Initialize K8s resources
|
||||||
run: |
|
run: |
|
||||||
kubectl apply -f apps/piano-sheet/k8s/all.yaml
|
kubectl apply -f apps/music/k8s/all.yaml
|
||||||
|
|
||||||
- name: Roll out to k3s
|
- name: Roll out to k3s
|
||||||
run: |
|
run: |
|
||||||
Generated
+15
-14
@@ -441,6 +441,21 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "music"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"cube-core",
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -485,20 +500,6 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "piano-sheet"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"axum",
|
|
||||||
"cube-core",
|
|
||||||
"rusqlite",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tokio",
|
|
||||||
"tower-http",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ members = [
|
|||||||
"crates/cube-core",
|
"crates/cube-core",
|
||||||
"apps/cube",
|
"apps/cube",
|
||||||
"apps/simpleasm",
|
"apps/simpleasm",
|
||||||
"apps/piano-sheet",
|
"apps/music",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "piano-sheet"
|
name = "music"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
description = "piano.famzheng.me — 钢琴谱管理 / 阅读 app,多图谱面 BLOB 存 sqlite"
|
description = "music.famzheng.me — 听歌 + 练琴 曲目管理 (video / audio / pdf / png)"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cube-core = { path = "../../crates/cube-core" }
|
cube-core = { path = "../../crates/cube-core" }
|
||||||
axum = { workspace = true, features = ["multipart"] }
|
axum = { workspace = true, features = ["multipart"] }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
tower = { workspace = true }
|
||||||
tower-http = { workspace = true }
|
tower-http = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
FROM scratch
|
||||||
|
COPY target/x86_64-unknown-linux-musl/release/music /music
|
||||||
|
COPY apps/music/frontend/dist /dist
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/music"]
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="zh">
|
<html lang="zh">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#0a0e1a">
|
<meta name="theme-color" content="#0f0f0f">
|
||||||
<title>Piano Sheet</title>
|
<title>Music · Euphon</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
+58
-58
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "piano-sheet",
|
"name": "music",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "piano-sheet",
|
"name": "music",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
@@ -832,53 +832,53 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-core": {
|
"node_modules/@vue/compiler-core": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.34",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
|
||||||
"integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
|
"integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.2",
|
"@babel/parser": "^7.29.3",
|
||||||
"@vue/shared": "3.5.33",
|
"@vue/shared": "3.5.34",
|
||||||
"entities": "^7.0.1",
|
"entities": "^7.0.1",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-dom": {
|
"node_modules/@vue/compiler-dom": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.34",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
|
||||||
"integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
|
"integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-core": "3.5.33",
|
"@vue/compiler-core": "3.5.34",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.34"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-sfc": {
|
"node_modules/@vue/compiler-sfc": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.34",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
|
||||||
"integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==",
|
"integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.2",
|
"@babel/parser": "^7.29.3",
|
||||||
"@vue/compiler-core": "3.5.33",
|
"@vue/compiler-core": "3.5.34",
|
||||||
"@vue/compiler-dom": "3.5.33",
|
"@vue/compiler-dom": "3.5.34",
|
||||||
"@vue/compiler-ssr": "3.5.33",
|
"@vue/compiler-ssr": "3.5.34",
|
||||||
"@vue/shared": "3.5.33",
|
"@vue/shared": "3.5.34",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"postcss": "^8.5.10",
|
"postcss": "^8.5.14",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-ssr": {
|
"node_modules/@vue/compiler-ssr": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.34",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
|
||||||
"integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
|
"integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.33",
|
"@vue/compiler-dom": "3.5.34",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.34"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-api": {
|
"node_modules/@vue/devtools-api": {
|
||||||
@@ -888,53 +888,53 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.34",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
|
||||||
"integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
|
"integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.34"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-core": {
|
"node_modules/@vue/runtime-core": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.34",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
|
||||||
"integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
|
"integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.33",
|
"@vue/reactivity": "3.5.34",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.34"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-dom": {
|
"node_modules/@vue/runtime-dom": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.34",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
|
||||||
"integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
|
"integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.33",
|
"@vue/reactivity": "3.5.34",
|
||||||
"@vue/runtime-core": "3.5.33",
|
"@vue/runtime-core": "3.5.34",
|
||||||
"@vue/shared": "3.5.33",
|
"@vue/shared": "3.5.34",
|
||||||
"csstype": "^3.2.3"
|
"csstype": "^3.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/server-renderer": {
|
"node_modules/@vue/server-renderer": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.34",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
|
||||||
"integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
|
"integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-ssr": "3.5.33",
|
"@vue/compiler-ssr": "3.5.34",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.34"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "3.5.33"
|
"vue": "3.5.34"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/shared": {
|
"node_modules/@vue/shared": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.34",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
|
||||||
"integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
|
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
@@ -1213,16 +1213,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.34",
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
|
||||||
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
|
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.33",
|
"@vue/compiler-dom": "3.5.34",
|
||||||
"@vue/compiler-sfc": "3.5.33",
|
"@vue/compiler-sfc": "3.5.34",
|
||||||
"@vue/runtime-dom": "3.5.33",
|
"@vue/runtime-dom": "3.5.34",
|
||||||
"@vue/server-renderer": "3.5.33",
|
"@vue/server-renderer": "3.5.34",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.34"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "*"
|
"typescript": "*"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "piano-sheet",
|
"name": "music",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f0f0f;
|
||||||
|
--bg-elev: #161616;
|
||||||
|
--bg-card: #1a1a2e;
|
||||||
|
--bg-hover: #232342;
|
||||||
|
--bg-active: #2a1a3e;
|
||||||
|
--border: #2a2a3a;
|
||||||
|
--border-soft: #1f1f2a;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-dim: #a0a0a0;
|
||||||
|
--text-mute: #666;
|
||||||
|
--accent: #c084fc;
|
||||||
|
--accent-strong: #7c5cbf;
|
||||||
|
--accent-cyan: #06b6d4;
|
||||||
|
--accent-green: #4ade80;
|
||||||
|
--accent-amber: #f59e0b;
|
||||||
|
--accent-red: #ef4444;
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body, #app { height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-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-mute); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// 薄薄一层 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 listPieces() {
|
||||||
|
return fetch('/api/pieces').then(jsonOrThrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPiece(id) {
|
||||||
|
return fetch(`/api/pieces/${id}`).then(jsonOrThrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPiece(body) {
|
||||||
|
return fetch('/api/pieces', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).then(jsonOrThrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchPiece(id, body) {
|
||||||
|
return fetch(`/api/pieces/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).then(jsonOrThrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePiece(id) {
|
||||||
|
return fetch(`/api/pieces/${id}`, { method: 'DELETE' }).then(jsonOrThrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordPlay(id) {
|
||||||
|
return fetch(`/api/pieces/${id}/play`, { method: 'POST' }).then(jsonOrThrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `role`: null | 'chord' | 'numbered' | 'staff'
|
||||||
|
export function uploadAttachments(pieceId, files, role) {
|
||||||
|
const fd = new FormData()
|
||||||
|
for (const f of files) fd.append('files', f, f.name)
|
||||||
|
const url = role
|
||||||
|
? `/api/pieces/${pieceId}/attachments?role=${encodeURIComponent(role)}`
|
||||||
|
: `/api/pieces/${pieceId}/attachments`
|
||||||
|
return fetch(url, { method: 'POST', body: fd }).then(jsonOrThrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAttachment(id) {
|
||||||
|
return fetch(`/api/attachments/${id}`, { method: 'DELETE' }).then(jsonOrThrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachmentUrl(id) {
|
||||||
|
return `/api/attachments/${id}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// LRC parser: takes raw text → [{ time: seconds, text }] sorted by time.
|
||||||
|
|
||||||
|
export function parseLrc(text) {
|
||||||
|
if (!text) return []
|
||||||
|
const out = []
|
||||||
|
for (const raw of text.split(/\r?\n/)) {
|
||||||
|
// 一行可能有多个 time tag:[00:12.34][00:30.00]歌词
|
||||||
|
const tags = []
|
||||||
|
let rest = raw
|
||||||
|
const re = /^\[(\d+):(\d+)(?:[.:](\d+))?\]/
|
||||||
|
while (true) {
|
||||||
|
const m = re.exec(rest)
|
||||||
|
if (!m) break
|
||||||
|
const min = parseInt(m[1], 10)
|
||||||
|
const sec = parseInt(m[2], 10)
|
||||||
|
const fracRaw = m[3] || '0'
|
||||||
|
const frac = parseInt(fracRaw, 10) / Math.pow(10, fracRaw.length)
|
||||||
|
tags.push(min * 60 + sec + frac)
|
||||||
|
rest = rest.slice(m[0].length)
|
||||||
|
}
|
||||||
|
const txt = rest.trim()
|
||||||
|
if (!txt || tags.length === 0) continue
|
||||||
|
for (const t of tags) out.push({ time: t, text: txt })
|
||||||
|
}
|
||||||
|
out.sort((a, b) => a.time - b.time)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'player',
|
||||||
|
component: () => import('../views/PlayerView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/piece/:id',
|
||||||
|
name: 'piece',
|
||||||
|
component: () => import('../views/PlayerView.vue'),
|
||||||
|
props: (route) => ({ id: Number(route.params.id) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/upload',
|
||||||
|
name: 'upload',
|
||||||
|
component: () => import('../views/UploadView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/piece/:id/edit',
|
||||||
|
name: 'edit',
|
||||||
|
component: () => import('../views/EditView.vue'),
|
||||||
|
props: (route) => ({ id: Number(route.params.id) }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<header class="bar">
|
||||||
|
<router-link to="/" class="back">← 列表</router-link>
|
||||||
|
<router-link :to="{ name: 'piece', params: { id } }" class="back">▶ 播放</router-link>
|
||||||
|
<h1 v-if="piece">编辑:{{ piece.title }}</h1>
|
||||||
|
<h1 v-else>编辑曲目</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="body">
|
||||||
|
<p v-if="loading" class="hint">加载中…</p>
|
||||||
|
<p v-else-if="loadErr" class="err">{{ loadErr }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="piece">
|
||||||
|
<section class="block">
|
||||||
|
<h2>基本信息</h2>
|
||||||
|
<label class="field">
|
||||||
|
<span>标题</span>
|
||||||
|
<input v-model="form.title" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>歌手 / 作者</span>
|
||||||
|
<input v-model="form.artist" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>分类</span>
|
||||||
|
<input v-model="form.category" list="cat-list" />
|
||||||
|
<datalist id="cat-list">
|
||||||
|
<option value="钢琴曲" />
|
||||||
|
<option value="流行" />
|
||||||
|
<option value="练习曲" />
|
||||||
|
<option value="古典" />
|
||||||
|
<option value="爵士" />
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>歌词</span>
|
||||||
|
<textarea v-model="form.lyrics" rows="8" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>笔记</span>
|
||||||
|
<textarea v-model="form.notes" rows="3" />
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-primary" :disabled="savingMeta" @click="saveMeta">
|
||||||
|
{{ savingMeta ? '保存中…' : '保存基本信息' }}
|
||||||
|
</button>
|
||||||
|
<span v-if="savedFlash" class="flash">已保存</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>添加附件</h2>
|
||||||
|
<p class="hint-sub">
|
||||||
|
一次可选多个文件。video / audio / pdf / image 自动识别。
|
||||||
|
图片需要选 <b>角色</b>(吉他谱 / 简谱 / 五线谱),其它类型角色无效。
|
||||||
|
</p>
|
||||||
|
<div class="upload-row">
|
||||||
|
<label class="role-pick">
|
||||||
|
<span>角色</span>
|
||||||
|
<select v-model="uploadRole">
|
||||||
|
<option :value="null">— 自动 / 通用图 —</option>
|
||||||
|
<option value="chord">吉他谱</option>
|
||||||
|
<option value="numbered">简谱</option>
|
||||||
|
<option value="staff">五线谱</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="file-pick">
|
||||||
|
<input
|
||||||
|
ref="fileInputEl"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
@change="onFiles"
|
||||||
|
accept="audio/*,video/*,application/pdf,image/*"
|
||||||
|
/>
|
||||||
|
<span class="btn-ghost">{{ pendingFiles.length ? `已选 ${pendingFiles.length} 个` : '选择文件' }}</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="!pendingFiles.length || uploading"
|
||||||
|
@click="upload"
|
||||||
|
>
|
||||||
|
{{ uploading ? `上传中… ${uploadPct}%` : '上传' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploadErr" class="err">{{ uploadErr }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>已有附件 ({{ piece.attachments.length }})</h2>
|
||||||
|
<p v-if="!piece.attachments.length" class="hint-sub">还没有附件。</p>
|
||||||
|
<ul class="atts">
|
||||||
|
<li v-for="att in piece.attachments" :key="att.id">
|
||||||
|
<div class="att-info">
|
||||||
|
<span class="att-kind" :class="att.kind">{{ kindLabel(att.kind) }}{{ att.role ? '·' + roleLabel(att.role) : '' }}</span>
|
||||||
|
<a :href="`/api/attachments/${att.id}`" target="_blank">{{ att.filename }}</a>
|
||||||
|
<span class="att-size">{{ fmtSize(att.size_bytes) }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn-danger" @click="removeAtt(att.id)">删除</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block danger-block">
|
||||||
|
<h2>危险操作</h2>
|
||||||
|
<button class="btn-danger" @click="removePiece">删除整首曲目</button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
getPiece,
|
||||||
|
patchPiece,
|
||||||
|
deletePiece,
|
||||||
|
uploadAttachments,
|
||||||
|
deleteAttachment,
|
||||||
|
} from '../lib/api.js'
|
||||||
|
|
||||||
|
const props = defineProps({ id: { type: Number, required: true } })
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const piece = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadErr = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
artist: '',
|
||||||
|
category: '',
|
||||||
|
lyrics: '',
|
||||||
|
notes: '',
|
||||||
|
})
|
||||||
|
const savingMeta = ref(false)
|
||||||
|
const savedFlash = ref(false)
|
||||||
|
|
||||||
|
const fileInputEl = ref(null)
|
||||||
|
const pendingFiles = ref([])
|
||||||
|
const uploadRole = ref(null)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadPct = ref(0)
|
||||||
|
const uploadErr = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
loadErr.value = ''
|
||||||
|
try {
|
||||||
|
const p = await getPiece(props.id)
|
||||||
|
piece.value = p
|
||||||
|
form.title = p.title || ''
|
||||||
|
form.artist = p.artist || ''
|
||||||
|
form.category = p.category || ''
|
||||||
|
form.lyrics = p.lyrics || ''
|
||||||
|
form.notes = p.notes || ''
|
||||||
|
} catch (e) {
|
||||||
|
loadErr.value = e.message || String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMeta() {
|
||||||
|
savingMeta.value = true
|
||||||
|
try {
|
||||||
|
await patchPiece(props.id, {
|
||||||
|
title: form.title.trim() || piece.value.title,
|
||||||
|
artist: form.artist.trim() || null,
|
||||||
|
category: form.category.trim() || null,
|
||||||
|
lyrics: form.lyrics || null,
|
||||||
|
notes: form.notes || null,
|
||||||
|
})
|
||||||
|
savedFlash.value = true
|
||||||
|
setTimeout(() => (savedFlash.value = false), 1500)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || String(e))
|
||||||
|
} finally {
|
||||||
|
savingMeta.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFiles(e) {
|
||||||
|
pendingFiles.value = Array.from(e.target.files || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
if (!pendingFiles.value.length) return
|
||||||
|
uploading.value = true
|
||||||
|
uploadErr.value = ''
|
||||||
|
uploadPct.value = 0
|
||||||
|
try {
|
||||||
|
// 简单 fetch(无进度),如需进度可改 XHR
|
||||||
|
await uploadAttachments(props.id, pendingFiles.value, uploadRole.value || null)
|
||||||
|
pendingFiles.value = []
|
||||||
|
if (fileInputEl.value) fileInputEl.value.value = ''
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
uploadErr.value = e.message || String(e)
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAtt(id) {
|
||||||
|
if (!confirm('删除这个附件?')) return
|
||||||
|
try {
|
||||||
|
await deleteAttachment(id)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePiece() {
|
||||||
|
if (!confirm(`确认删除 "${piece.value.title}" 及其全部附件?此操作不可逆。`)) return
|
||||||
|
try {
|
||||||
|
await deletePiece(props.id)
|
||||||
|
router.push({ name: 'player' })
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindLabel(k) {
|
||||||
|
return ({ audio: '音频', video: '视频', pdf: 'PDF', image: '图片' })[k] || k
|
||||||
|
}
|
||||||
|
function roleLabel(r) {
|
||||||
|
return ({ chord: '吉他谱', numbered: '简谱', staff: '五线谱' })[r] || r
|
||||||
|
}
|
||||||
|
function fmtSize(b) {
|
||||||
|
if (b < 1024) return b + ' B'
|
||||||
|
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
|
||||||
|
if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB'
|
||||||
|
return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { min-height: 100%; display: flex; flex-direction: column; }
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 22px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
.bar h1 { font-size: 18px; font-weight: 600; flex: 1; }
|
||||||
|
.back { color: var(--text-dim); font-size: 14px; }
|
||||||
|
.back:hover { color: var(--accent); text-decoration: none; }
|
||||||
|
|
||||||
|
.body { max-width: 760px; margin: 0 auto; padding: 24px 22px 100px; width: 100%; }
|
||||||
|
.hint { color: var(--text-mute); padding: 40px 0; text-align: center; }
|
||||||
|
.err { color: var(--accent-red); background: rgba(239,68,68,0.1); padding: 10px 12px; border-radius: 6px; margin-top: 8px; }
|
||||||
|
|
||||||
|
.block {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.block h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.hint-sub { color: var(--text-mute); font-size: 13px; margin-bottom: 12px; line-height: 1.5; }
|
||||||
|
.hint-sub b { color: var(--text-dim); }
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||||
|
.field span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.field input, .field textarea, .field select {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.field input:focus, .field textarea:focus, .field select:focus { border-color: var(--accent-strong); outline: none; }
|
||||||
|
.field textarea {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions { display: flex; align-items: center; gap: 14px; margin-top: 4px; }
|
||||||
|
.flash { color: var(--accent-green); font-size: 12px; }
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) { background: var(--accent); }
|
||||||
|
|
||||||
|
.upload-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.role-pick { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.role-pick span { font-size: 11px; color: var(--text-mute); text-transform: uppercase; }
|
||||||
|
.role-pick select {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-pick { position: relative; display: inline-block; }
|
||||||
|
.file-pick input[type=file] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
inset: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
.atts { list-style: none; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.atts li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-radius: 6px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.att-info { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
|
||||||
|
.att-kind {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(124, 92, 191, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.att-kind.video { background: rgba(6, 182, 212, 0.15); color: var(--accent-cyan); }
|
||||||
|
.att-kind.pdf { background: rgba(245, 158, 11, 0.15); color: var(--accent-amber); }
|
||||||
|
.att-info a {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.att-info a:hover { color: var(--accent); }
|
||||||
|
.att-size { color: var(--text-mute); font-size: 11px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: var(--accent-red);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: rgba(239, 68, 68, 0.3); }
|
||||||
|
|
||||||
|
.danger-block { background: rgba(239, 68, 68, 0.05); }
|
||||||
|
.danger-block .btn-danger { padding: 8px 18px; font-size: 13px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,919 @@
|
|||||||
|
<template>
|
||||||
|
<div class="root">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>🎵 Music</h1>
|
||||||
|
<input
|
||||||
|
class="search"
|
||||||
|
type="text"
|
||||||
|
v-model="search"
|
||||||
|
placeholder="搜索曲目 / 歌手"
|
||||||
|
/>
|
||||||
|
<span class="count">{{ filtered.length }} / {{ pieces.length }} 首</span>
|
||||||
|
<router-link to="/upload" class="btn-add" title="新增曲目">+</router-link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<aside class="sidebar" :class="{ 'has-selected': !!selected }">
|
||||||
|
<div class="sort-bar">
|
||||||
|
<button :class="{ active: sortMode === 'name' }" @click="setSort('name')">名称</button>
|
||||||
|
<button :class="{ active: sortMode === 'hot' }" @click="setSort('hot')">最多播放</button>
|
||||||
|
<button :class="{ active: sortMode === 'least' }" @click="setSort('least')">最少播放</button>
|
||||||
|
<button :class="{ active: sortMode === 'recent' }" @click="setSort('recent')">最近</button>
|
||||||
|
<button :class="{ active: sortMode === 'random' }" @click="setSort('random')">随机</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="playlist">
|
||||||
|
<p v-if="loading" class="hint">加载中…</p>
|
||||||
|
<p v-else-if="loadError" class="hint err">{{ loadError }}</p>
|
||||||
|
<p v-else-if="filtered.length === 0" class="hint">
|
||||||
|
空空如也,<router-link to="/upload">先加一首</router-link>。
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="p in filtered"
|
||||||
|
:key="p.id"
|
||||||
|
class="row"
|
||||||
|
:class="{ active: selectedId === p.id }"
|
||||||
|
@click="selectPiece(p.id)"
|
||||||
|
>
|
||||||
|
<div class="row-main">
|
||||||
|
<div class="row-title">{{ p.title }}</div>
|
||||||
|
<div class="row-meta">
|
||||||
|
<span v-if="p.artist">{{ p.artist }}</span>
|
||||||
|
<span v-if="p.category" class="cat">{{ p.category }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="badges">
|
||||||
|
<span v-if="p.has_lyrics" class="badge" title="有歌词">词</span>
|
||||||
|
<span v-for="k in iconKinds(p.kinds)" :key="k" class="badge" :title="k">{{ kindLabel(k) }}</span>
|
||||||
|
<span v-if="p.play_count > 0" class="play-count">{{ p.play_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="player-area">
|
||||||
|
<div v-if="!selected" class="empty">
|
||||||
|
<p>从左边挑一首吧 🎶</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<header class="now-playing">
|
||||||
|
<h2>{{ selected.title }}</h2>
|
||||||
|
<div class="np-sub">
|
||||||
|
<span v-if="selected.artist">{{ selected.artist }}</span>
|
||||||
|
<span v-if="selected.category">· {{ selected.category }}</span>
|
||||||
|
<span v-if="selected.play_count">· 播放 {{ selected.play_count }} 次</span>
|
||||||
|
<router-link :to="{ name: 'edit', params: { id: selected.id } }" class="edit-link">编辑</router-link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav v-if="tabs.length" class="tabs">
|
||||||
|
<button
|
||||||
|
v-for="t in tabs"
|
||||||
|
:key="t.key"
|
||||||
|
:class="{ active: activeTab === t.key }"
|
||||||
|
@click="setTab(t.key)"
|
||||||
|
>{{ t.label }}<span v-if="t.count > 1" class="tab-n">{{ t.count }}</span></button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<!-- 歌词 -->
|
||||||
|
<div v-show="activeTab === 'lyrics'" class="lyrics-box" ref="lyricsBoxEl">
|
||||||
|
<div v-if="lyricsLines.length === 0" class="lyrics-none">
|
||||||
|
<span v-if="selected.lyrics">这首歌的歌词不是 LRC 格式</span>
|
||||||
|
<span v-else>暂无歌词,用心感受 🎶</span>
|
||||||
|
<pre v-if="selected.lyrics" class="lyrics-raw">{{ selected.lyrics }}</pre>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(line, i) in lyricsLines"
|
||||||
|
:key="i"
|
||||||
|
class="lyrics-line"
|
||||||
|
:class="{ active: i === activeLyricIdx }"
|
||||||
|
:data-i="i"
|
||||||
|
@click="seek(line.time)"
|
||||||
|
>{{ line.text }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 谱面(chord / numbered / staff) -->
|
||||||
|
<div v-show="['chord', 'numbered', 'staff'].includes(activeTab)" class="sheet-box">
|
||||||
|
<img
|
||||||
|
v-for="att in roleAttachments(activeTab)"
|
||||||
|
:key="att.id"
|
||||||
|
:src="attachmentUrl(att.id)"
|
||||||
|
:alt="att.filename"
|
||||||
|
class="sheet-img"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PDF -->
|
||||||
|
<div v-show="activeTab === 'pdf'" class="pdf-box">
|
||||||
|
<iframe
|
||||||
|
v-for="att in pdfAttachments"
|
||||||
|
:key="att.id"
|
||||||
|
:src="attachmentUrl(att.id)"
|
||||||
|
:title="att.filename"
|
||||||
|
class="pdf-frame"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视频 -->
|
||||||
|
<div v-show="activeTab === 'video'" class="video-box">
|
||||||
|
<video
|
||||||
|
v-for="att in videoAttachments"
|
||||||
|
:key="att.id"
|
||||||
|
:src="attachmentUrl(att.id)"
|
||||||
|
controls
|
||||||
|
class="video-el"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="controls">
|
||||||
|
<div class="ctrl-row">
|
||||||
|
<label class="repeat" :class="{ on: repeatOne }" @click="repeatOne = !repeatOne">
|
||||||
|
<span>循环</span>
|
||||||
|
<span class="track"><span class="thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
<button @click="prev" class="btn-icon" title="上一首">⏮</button>
|
||||||
|
<button @click="togglePlay" class="btn-icon big" :title="playing ? '暂停' : '播放'">
|
||||||
|
{{ playing ? '⏸' : '▶' }}
|
||||||
|
</button>
|
||||||
|
<button @click="next" class="btn-icon" title="下一首">⏭</button>
|
||||||
|
<span class="time">{{ fmtTime(currentTime) }}</span>
|
||||||
|
<div class="bar" @click="seekBar">
|
||||||
|
<div class="fill" :style="{ width: progressPct + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="time">{{ fmtTime(duration) }}</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<audio
|
||||||
|
ref="audioEl"
|
||||||
|
@timeupdate="onTimeUpdate"
|
||||||
|
@loadedmetadata="onLoaded"
|
||||||
|
@ended="onEnded"
|
||||||
|
@play="playing = true"
|
||||||
|
@pause="playing = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside v-if="selected" class="notes" :class="{ active: notesOpen }">
|
||||||
|
<header @click="notesOpen = !notesOpen">
|
||||||
|
<span>笔记</span>
|
||||||
|
<span v-if="notesSavedFlash" class="saved">已保存</span>
|
||||||
|
</header>
|
||||||
|
<textarea
|
||||||
|
v-model="notesDraft"
|
||||||
|
@input="onNotesInput"
|
||||||
|
placeholder="练琴心得 / chord 备注 / 难点…"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
listPieces,
|
||||||
|
getPiece,
|
||||||
|
patchPiece,
|
||||||
|
recordPlay,
|
||||||
|
attachmentUrl as attUrl,
|
||||||
|
} from '../lib/api.js'
|
||||||
|
import { parseLrc } from '../lib/lrc.js'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const pieces = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref('')
|
||||||
|
const selected = ref(null)
|
||||||
|
const selectedId = ref(null)
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const sortMode = ref(localStorage.getItem('music.sort') || 'name')
|
||||||
|
const repeatOne = ref(false)
|
||||||
|
|
||||||
|
const audioEl = ref(null)
|
||||||
|
const lyricsBoxEl = ref(null)
|
||||||
|
const playing = ref(false)
|
||||||
|
const currentTime = ref(0)
|
||||||
|
const duration = ref(0)
|
||||||
|
const activeTab = ref('lyrics')
|
||||||
|
const notesOpen = ref(false)
|
||||||
|
const notesDraft = ref('')
|
||||||
|
const notesSavedFlash = ref(false)
|
||||||
|
let notesTimer = null
|
||||||
|
let randomSeed = Math.random()
|
||||||
|
let lastReportedId = null
|
||||||
|
|
||||||
|
const lyricsLines = computed(() => parseLrc(selected.value?.lyrics || ''))
|
||||||
|
|
||||||
|
const activeLyricIdx = computed(() => {
|
||||||
|
const lines = lyricsLines.value
|
||||||
|
if (!lines.length) return -1
|
||||||
|
let idx = -1
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
if (currentTime.value >= lines[i].time) { idx = i; break }
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
})
|
||||||
|
|
||||||
|
const audioAttachments = computed(() =>
|
||||||
|
(selected.value?.attachments || []).filter(a => a.kind === 'audio'))
|
||||||
|
const videoAttachments = computed(() =>
|
||||||
|
(selected.value?.attachments || []).filter(a => a.kind === 'video'))
|
||||||
|
const pdfAttachments = computed(() =>
|
||||||
|
(selected.value?.attachments || []).filter(a => a.kind === 'pdf'))
|
||||||
|
|
||||||
|
function roleAttachments(role) {
|
||||||
|
return (selected.value?.attachments || []).filter(
|
||||||
|
a => a.kind === 'image' && a.role === role,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = computed(() => {
|
||||||
|
if (!selected.value) return []
|
||||||
|
const list = []
|
||||||
|
if (selected.value.lyrics) list.push({ key: 'lyrics', label: '歌词', count: 0 })
|
||||||
|
const chord = roleAttachments('chord').length
|
||||||
|
if (chord) list.push({ key: 'chord', label: '吉他谱', count: chord })
|
||||||
|
const num = roleAttachments('numbered').length
|
||||||
|
if (num) list.push({ key: 'numbered', label: '简谱', count: num })
|
||||||
|
const staff = roleAttachments('staff').length
|
||||||
|
if (staff) list.push({ key: 'staff', label: '五线谱', count: staff })
|
||||||
|
if (pdfAttachments.value.length) list.push({ key: 'pdf', label: '乐谱 PDF', count: pdfAttachments.value.length })
|
||||||
|
if (videoAttachments.value.length) list.push({ key: 'video', label: '视频', count: videoAttachments.value.length })
|
||||||
|
// 没歌词也至少给一个 fallback tab
|
||||||
|
if (list.length === 0) list.push({ key: 'lyrics', label: '歌词', count: 0 })
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = search.value.trim().toLowerCase()
|
||||||
|
let arr = pieces.value
|
||||||
|
if (q) {
|
||||||
|
arr = arr.filter(p => {
|
||||||
|
const hay = `${p.title} ${p.artist || ''} ${p.category || ''}`.toLowerCase()
|
||||||
|
return hay.includes(q)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
arr = [...arr]
|
||||||
|
switch (sortMode.value) {
|
||||||
|
case 'hot':
|
||||||
|
arr.sort((a, b) => b.play_count - a.play_count || a.title.localeCompare(b.title, 'zh'))
|
||||||
|
break
|
||||||
|
case 'least':
|
||||||
|
arr.sort((a, b) => a.play_count - b.play_count || a.title.localeCompare(b.title, 'zh'))
|
||||||
|
break
|
||||||
|
case 'recent':
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const ta = a.last_played_at || ''
|
||||||
|
const tb = b.last_played_at || ''
|
||||||
|
return tb.localeCompare(ta) || a.title.localeCompare(b.title, 'zh')
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'random': {
|
||||||
|
// stable random per session
|
||||||
|
const seeded = arr.map((p, i) => ({ p, k: hash(p.id, randomSeed) }))
|
||||||
|
seeded.sort((a, b) => a.k - b.k)
|
||||||
|
arr = seeded.map(x => x.p)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
arr.sort((a, b) => a.title.localeCompare(b.title, 'zh'))
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
|
||||||
|
function hash(id, seed) {
|
||||||
|
// 小随机 hash,sort key 稳定
|
||||||
|
let x = (id ^ Math.floor(seed * 1e9)) >>> 0
|
||||||
|
x = (x ^ (x << 13)) >>> 0
|
||||||
|
x = (x ^ (x >>> 17)) >>> 0
|
||||||
|
x = (x ^ (x << 5)) >>> 0
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSort(mode) {
|
||||||
|
if (mode === 'random' && sortMode.value === 'random') {
|
||||||
|
randomSeed = Math.random()
|
||||||
|
}
|
||||||
|
sortMode.value = mode
|
||||||
|
localStorage.setItem('music.sort', mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconKinds(kinds) {
|
||||||
|
// 显示主要 kind 徽章;'image' / 'audio' / 'video' / 'pdf'
|
||||||
|
const order = ['audio', 'video', 'pdf', 'image']
|
||||||
|
return order.filter(k => kinds.includes(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindLabel(k) {
|
||||||
|
return ({ audio: '音', video: '视', pdf: 'PDF', image: '谱' })[k] || k
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPieces() {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
pieces.value = await listPieces()
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e.message || String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPiece(id) {
|
||||||
|
selected.value = null
|
||||||
|
notesDraft.value = ''
|
||||||
|
if (!id) return
|
||||||
|
try {
|
||||||
|
const p = await getPiece(id)
|
||||||
|
selected.value = p
|
||||||
|
notesDraft.value = p.notes || ''
|
||||||
|
selectedId.value = p.id
|
||||||
|
// 默认 tab:有歌词进 lyrics,否则进第一个 tab
|
||||||
|
const t = tabs.value
|
||||||
|
if (!t.find(x => x.key === activeTab.value)) {
|
||||||
|
activeTab.value = t[0]?.key || 'lyrics'
|
||||||
|
}
|
||||||
|
// 自动开播放(如果有 audio)
|
||||||
|
await nextTick()
|
||||||
|
const first = audioAttachments.value[0]
|
||||||
|
if (first && audioEl.value) {
|
||||||
|
audioEl.value.src = attUrl(first.id)
|
||||||
|
audioEl.value.play().catch(() => {})
|
||||||
|
} else if (audioEl.value) {
|
||||||
|
audioEl.value.removeAttribute('src')
|
||||||
|
audioEl.value.load()
|
||||||
|
}
|
||||||
|
lastReportedId = null
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e.message || String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPiece(id) {
|
||||||
|
router.push({ name: 'piece', params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentUrl(id) { return attUrl(id) }
|
||||||
|
|
||||||
|
// player controls
|
||||||
|
function togglePlay() {
|
||||||
|
if (!audioEl.value || !audioEl.value.src) return
|
||||||
|
if (audioEl.value.paused) audioEl.value.play()
|
||||||
|
else audioEl.value.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (!filtered.value.length) return
|
||||||
|
const idx = filtered.value.findIndex(p => p.id === selectedId.value)
|
||||||
|
const nextIdx = (idx + 1) % filtered.value.length
|
||||||
|
selectPiece(filtered.value[nextIdx].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
if (!filtered.value.length) return
|
||||||
|
const idx = filtered.value.findIndex(p => p.id === selectedId.value)
|
||||||
|
const prevIdx = (idx - 1 + filtered.value.length) % filtered.value.length
|
||||||
|
selectPiece(filtered.value[prevIdx].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function seek(t) {
|
||||||
|
if (!audioEl.value) return
|
||||||
|
audioEl.value.currentTime = t
|
||||||
|
if (audioEl.value.paused) audioEl.value.play().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekBar(e) {
|
||||||
|
if (!audioEl.value || !duration.value) return
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const ratio = (e.clientX - rect.left) / rect.width
|
||||||
|
audioEl.value.currentTime = Math.max(0, Math.min(1, ratio)) * duration.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTimeUpdate(e) {
|
||||||
|
currentTime.value = e.target.currentTime
|
||||||
|
// 上报播放(≥10s 时)
|
||||||
|
if (selectedId.value && lastReportedId !== selectedId.value && currentTime.value >= 10) {
|
||||||
|
lastReportedId = selectedId.value
|
||||||
|
recordPlay(selectedId.value).then(d => {
|
||||||
|
// 同步本地 + list
|
||||||
|
if (selected.value) selected.value.play_count = d.play_count
|
||||||
|
const inList = pieces.value.find(p => p.id === selectedId.value)
|
||||||
|
if (inList) {
|
||||||
|
inList.play_count = d.play_count
|
||||||
|
inList.last_played_at = new Date().toISOString().replace('T', ' ').slice(0, 19)
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
// 自动滚歌词
|
||||||
|
if (activeTab.value === 'lyrics' && lyricsBoxEl.value) {
|
||||||
|
const idx = activeLyricIdx.value
|
||||||
|
if (idx >= 0) {
|
||||||
|
const el = lyricsBoxEl.value.querySelector(`.lyrics-line[data-i="${idx}"]`)
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 持久化最近播放进度
|
||||||
|
if (selectedId.value) {
|
||||||
|
localStorage.setItem('music.last', JSON.stringify({
|
||||||
|
id: selectedId.value,
|
||||||
|
time: currentTime.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLoaded(e) {
|
||||||
|
duration.value = e.target.duration || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnded() {
|
||||||
|
if (repeatOne.value && audioEl.value) {
|
||||||
|
audioEl.value.currentTime = 0
|
||||||
|
audioEl.value.play().catch(() => {})
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPct = computed(() => {
|
||||||
|
if (!duration.value) return 0
|
||||||
|
return Math.max(0, Math.min(100, (currentTime.value / duration.value) * 100))
|
||||||
|
})
|
||||||
|
|
||||||
|
function fmtTime(s) {
|
||||||
|
if (!s || isNaN(s)) return '0:00'
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const sec = Math.floor(s % 60)
|
||||||
|
return m + ':' + (sec < 10 ? '0' : '') + sec
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTab(k) {
|
||||||
|
activeTab.value = k
|
||||||
|
}
|
||||||
|
|
||||||
|
// notes auto-save
|
||||||
|
function onNotesInput() {
|
||||||
|
if (!selectedId.value) return
|
||||||
|
if (notesTimer) clearTimeout(notesTimer)
|
||||||
|
notesTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await patchPiece(selectedId.value, { notes: notesDraft.value || null })
|
||||||
|
notesSavedFlash.value = true
|
||||||
|
setTimeout(() => (notesSavedFlash.value = false), 1500)
|
||||||
|
} catch {}
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyboard
|
||||||
|
function onKeyDown(e) {
|
||||||
|
const tag = (e.target.tagName || '').toLowerCase()
|
||||||
|
if (tag === 'input' || tag === 'textarea') return
|
||||||
|
if (e.code === 'Space') { e.preventDefault(); togglePlay() }
|
||||||
|
else if (e.code === 'ArrowRight') {
|
||||||
|
if (audioEl.value) audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, duration.value)
|
||||||
|
}
|
||||||
|
else if (e.code === 'ArrowLeft') {
|
||||||
|
if (audioEl.value) audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0)
|
||||||
|
}
|
||||||
|
else if (e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
const idx = tabs.value.findIndex(t => t.key === activeTab.value)
|
||||||
|
const nx = tabs.value[(idx + 1) % tabs.value.length]
|
||||||
|
if (nx) activeTab.value = nx.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// route → selected
|
||||||
|
watch(() => route.params.id, async (idStr) => {
|
||||||
|
const id = idStr ? Number(idStr) : null
|
||||||
|
if (id !== selectedId.value) {
|
||||||
|
selectedId.value = id
|
||||||
|
if (id) await loadPiece(id)
|
||||||
|
else selected.value = null
|
||||||
|
}
|
||||||
|
}, { immediate: false })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
await loadPieces()
|
||||||
|
const id = route.params.id ? Number(route.params.id) : null
|
||||||
|
if (id) {
|
||||||
|
selectedId.value = id
|
||||||
|
await loadPiece(id)
|
||||||
|
} else {
|
||||||
|
// 无路由 id:恢复 last
|
||||||
|
try {
|
||||||
|
const last = JSON.parse(localStorage.getItem('music.last') || 'null')
|
||||||
|
if (last && pieces.value.find(p => p.id === last.id)) {
|
||||||
|
router.replace({ name: 'piece', params: { id: last.id } })
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
if (notesTimer) clearTimeout(notesTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.root {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.topbar h1 { font-size: 18px; font-weight: 600; white-space: nowrap; }
|
||||||
|
.topbar .search {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 380px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.topbar .search:focus { border-color: var(--accent-strong); }
|
||||||
|
.topbar .count { color: var(--text-mute); font-size: 12px; white-space: nowrap; }
|
||||||
|
.topbar .btn-add {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-strong);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22px; font-weight: 600;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.topbar .btn-add:hover { background: var(--accent); text-decoration: none; }
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 340px;
|
||||||
|
min-width: 280px;
|
||||||
|
border-right: 1px solid var(--border-soft);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-bar {
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 8px;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sort-bar button {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-right-width: 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.sort-bar button:first-child { border-radius: 4px 0 0 4px; }
|
||||||
|
.sort-bar button:last-child { border-radius: 0 4px 4px 0; border-right-width: 1px; }
|
||||||
|
.sort-bar button:hover { color: var(--text); }
|
||||||
|
.sort-bar button.active {
|
||||||
|
background: var(--bg-active);
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist { flex: 1; overflow-y: auto; }
|
||||||
|
.hint { padding: 40px 20px; text-align: center; color: var(--text-mute); font-size: 14px; }
|
||||||
|
.hint.err { color: var(--accent-red); }
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.row:hover { background: var(--bg-card); }
|
||||||
|
.row.active { background: var(--bg-active); }
|
||||||
|
.row.active .row-title { color: var(--accent); }
|
||||||
|
.row-main { flex: 1; min-width: 0; }
|
||||||
|
.row-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.row-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
margin-top: 2px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.row-meta .cat {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
background: rgba(6, 182, 212, 0.1);
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
background: rgba(124, 92, 191, 0.12);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.play-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing {
|
||||||
|
padding: 18px 24px 8px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.now-playing h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.np-sub {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.edit-link {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.edit-link:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.tabs button {
|
||||||
|
background: none;
|
||||||
|
color: var(--text-mute);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.tabs button:hover { color: var(--text-dim); }
|
||||||
|
.tabs button.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
.tabs .tab-n {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 24px 80px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-box .lyrics-line {
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-mute);
|
||||||
|
line-height: 1.6;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.3s, font-size 0.3s;
|
||||||
|
}
|
||||||
|
.lyrics-box .lyrics-line.active {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.lyrics-none {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-mute);
|
||||||
|
margin-top: 60px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.lyrics-raw {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.sheet-img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-box { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.pdf-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 90vh;
|
||||||
|
border: none;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-box { display: flex; flex-direction: column; gap: 16px; align-items: center; }
|
||||||
|
.video-el {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 60vh;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ctrl-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-icon:hover { background: rgba(255,255,255,0.06); color: var(--text); }
|
||||||
|
.btn-icon.big {
|
||||||
|
font-size: 26px;
|
||||||
|
background: var(--accent-strong);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-icon.big:hover { background: var(--accent); color: #fff; }
|
||||||
|
.repeat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.repeat .track {
|
||||||
|
width: 30px; height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--border);
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.repeat .thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px; left: 2px;
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-mute);
|
||||||
|
transition: transform 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.repeat.on .track { background: var(--accent-strong); }
|
||||||
|
.repeat.on .thumb { transform: translateX(14px); background: #fff; }
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-strong);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
width: 260px;
|
||||||
|
border-left: 1px solid var(--border-soft);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.notes header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.notes header .saved { color: var(--accent-green); font-size: 10px; }
|
||||||
|
.notes textarea {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.notes textarea::placeholder { color: var(--text-mute); }
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.notes { display: flex; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main { flex-direction: column; }
|
||||||
|
.sidebar { width: 100%; height: 38vh; min-height: 200px; border-right: none; border-bottom: 1px solid var(--border-soft); }
|
||||||
|
.sidebar.has-selected { height: 30vh; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<header class="bar">
|
||||||
|
<router-link to="/" class="back">← 返回</router-link>
|
||||||
|
<h1>新增曲目</h1>
|
||||||
|
</header>
|
||||||
|
<main class="form">
|
||||||
|
<label class="field">
|
||||||
|
<span>标题<i>*</i></span>
|
||||||
|
<input v-model="title" placeholder="例:月光奏鸣曲 / Yesterday" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>歌手 / 作者</span>
|
||||||
|
<input v-model="artist" placeholder="例:Beatles / Beethoven" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>分类</span>
|
||||||
|
<input v-model="category" placeholder="例:流行 / 钢琴曲 / 练习曲" list="cat-list" />
|
||||||
|
<datalist id="cat-list">
|
||||||
|
<option value="钢琴曲" />
|
||||||
|
<option value="流行" />
|
||||||
|
<option value="练习曲" />
|
||||||
|
<option value="古典" />
|
||||||
|
<option value="爵士" />
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>歌词(LRC 格式最佳)</span>
|
||||||
|
<textarea v-model="lyrics" rows="6" placeholder="[00:12.34]歌词第一行 [00:18.50]歌词第二行" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>笔记</span>
|
||||||
|
<textarea v-model="notes" rows="3" placeholder="练琴心得、曲谱说明…" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="error" class="err">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-primary" :disabled="saving || !title.trim()" @click="submit">
|
||||||
|
{{ saving ? '创建中…' : '创建' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="tip">创建后进入编辑页给曲目挂 mp3 / 视频 / PDF / 谱子图片。</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { createPiece } from '../lib/api.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const title = ref('')
|
||||||
|
const artist = ref('')
|
||||||
|
const category = ref('')
|
||||||
|
const lyrics = ref('')
|
||||||
|
const notes = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!title.value.trim()) return
|
||||||
|
saving.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const r = await createPiece({
|
||||||
|
title: title.value.trim(),
|
||||||
|
artist: artist.value.trim() || null,
|
||||||
|
category: category.value.trim() || null,
|
||||||
|
lyrics: lyrics.value || null,
|
||||||
|
notes: notes.value || null,
|
||||||
|
})
|
||||||
|
router.push({ name: 'edit', params: { id: r.id } })
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message || String(e)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 14px 22px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
.bar h1 { font-size: 18px; font-weight: 600; }
|
||||||
|
.back { color: var(--text-dim); font-size: 14px; }
|
||||||
|
.back:hover { color: var(--accent); text-decoration: none; }
|
||||||
|
|
||||||
|
.form {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 22px 80px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.field span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.field span i { color: var(--accent-red); margin-left: 2px; font-style: normal; }
|
||||||
|
.field input, .field textarea {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.field input:focus, .field textarea:focus { border-color: var(--accent-strong); }
|
||||||
|
.field textarea {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--accent-red);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions { display: flex; gap: 12px; }
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 22px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) { background: var(--accent); }
|
||||||
|
.btn-primary:active { transform: scale(0.98); }
|
||||||
|
|
||||||
|
.tip { font-size: 12px; color: var(--text-mute); }
|
||||||
|
</style>
|
||||||
@@ -1,29 +1,29 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Namespace
|
kind: Namespace
|
||||||
metadata:
|
metadata:
|
||||||
name: cube-piano
|
name: cube-music
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
name: piano-sheet-data
|
name: music-data
|
||||||
namespace: cube-piano
|
namespace: cube-music
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
# 图片直存 sqlite,留出宽裕空间
|
# video / audio 大附件 + 初始 guitar 曲库(~? GB),50Gi 起步
|
||||||
storage: 10Gi
|
storage: 50Gi
|
||||||
# storageClassName 留空 → 走 k3s 默认 local-path(hostPath,单节点足够)
|
# storageClassName 留空 → k3s 默认 local-path
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: piano-sheet
|
name: music
|
||||||
namespace: cube-piano
|
namespace: cube-music
|
||||||
labels:
|
labels:
|
||||||
app: piano-sheet
|
app: music
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
strategy:
|
strategy:
|
||||||
@@ -31,17 +31,17 @@ spec:
|
|||||||
type: Recreate
|
type: Recreate
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: piano-sheet
|
app: music
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: piano-sheet
|
app: music
|
||||||
spec:
|
spec:
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: registry-creds
|
- name: registry-creds
|
||||||
containers:
|
containers:
|
||||||
- name: piano-sheet
|
- name: music
|
||||||
image: registry.famzheng.me/mochi/piano-sheet:latest
|
image: registry.famzheng.me/mochi/music:latest
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
@@ -49,6 +49,8 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: DB_PATH
|
- name: DB_PATH
|
||||||
value: /data/app.db
|
value: /data/app.db
|
||||||
|
- name: BLOBS_DIR
|
||||||
|
value: /data/blobs
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
@@ -66,24 +68,24 @@ spec:
|
|||||||
cpu: 10m
|
cpu: 10m
|
||||||
memory: 32Mi
|
memory: 32Mi
|
||||||
limits:
|
limits:
|
||||||
cpu: 500m
|
cpu: 1000m
|
||||||
memory: 256Mi
|
memory: 512Mi
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /data
|
mountPath: /data
|
||||||
volumes:
|
volumes:
|
||||||
- name: data
|
- name: data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: piano-sheet-data
|
claimName: music-data
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: piano-sheet
|
name: music
|
||||||
namespace: cube-piano
|
namespace: cube-music
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app: piano-sheet
|
app: music
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
port: 80
|
port: 80
|
||||||
@@ -93,29 +95,29 @@ apiVersion: traefik.io/v1alpha1
|
|||||||
kind: Middleware
|
kind: Middleware
|
||||||
metadata:
|
metadata:
|
||||||
name: bodylimit
|
name: bodylimit
|
||||||
namespace: cube-piano
|
namespace: cube-music
|
||||||
spec:
|
spec:
|
||||||
buffering:
|
buffering:
|
||||||
maxRequestBodyBytes: 700000000
|
# 单文件最大 1GiB(main.rs 里 SINGLE_FILE_BYTES),multipart 总和留 5GiB 余量
|
||||||
|
maxRequestBodyBytes: 5368709120
|
||||||
---
|
---
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: piano-sheet
|
name: music
|
||||||
namespace: cube-piano
|
namespace: cube-music
|
||||||
annotations:
|
annotations:
|
||||||
# 上传整组图片可能 ~600MB,调高 traefik 默认上限
|
traefik.ingress.kubernetes.io/router.middlewares: cube-music-bodylimit@kubernetescrd
|
||||||
traefik.ingress.kubernetes.io/router.middlewares: cube-piano-bodylimit@kubernetescrd
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
- host: piano.famzheng.me
|
- host: music.famzheng.me
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: piano-sheet
|
name: music
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
从 /data/guitar/ 把旧 guitar app 的曲库导入到 music API。
|
||||||
|
|
||||||
|
跑法(在 oci 上,源数据原地):
|
||||||
|
python3 import_guitar.py --src /data/guitar --api https://music.famzheng.me
|
||||||
|
|
||||||
|
每个 mp3 = 一个 piece:
|
||||||
|
- title / artist 从文件名 "{title} - {artist}.mp3" 拆
|
||||||
|
- lyrics 来自同名 .lrc(UTF-8)
|
||||||
|
- chord png 来自 chords/{sanitize(artist)}-{sanitize(title)}.png(如有)
|
||||||
|
- play_count 来自 playcounts.json[mp3 文件名]
|
||||||
|
- category 设为 "流行"(旧 guitar 里都是流行/吉他歌)
|
||||||
|
|
||||||
|
幂等:以 (title, artist) 作主键去重,已有 piece 跳过附件重传。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.error
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CATEGORY = "流行"
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize(text: str) -> str:
|
||||||
|
"""匹配旧 chord_server.py 的 sanitize:替换非法文件名字符为 -"""
|
||||||
|
return re.sub(r'[<>:"/\\|?*]', '-', text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_filename(stem: str) -> tuple[str, str]:
|
||||||
|
"""'{title} - {artist}' → (title, artist)"""
|
||||||
|
parts = stem.split(' - ')
|
||||||
|
title = parts[0].strip()
|
||||||
|
artist = ' - '.join(parts[1:]).strip() if len(parts) > 1 else ''
|
||||||
|
return title, artist
|
||||||
|
|
||||||
|
|
||||||
|
def http_request(method: str, url: str, *, json_body=None, file_field=None,
|
||||||
|
timeout: int = 600) -> dict:
|
||||||
|
"""简单 HTTP 客户端,return JSON dict。失败抛异常。"""
|
||||||
|
headers = {}
|
||||||
|
data = None
|
||||||
|
if json_body is not None:
|
||||||
|
data = json.dumps(json_body).encode('utf-8')
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
elif file_field is not None:
|
||||||
|
boundary = '----music-import-' + os.urandom(8).hex()
|
||||||
|
body = []
|
||||||
|
for field_name, filename, content, mime in file_field:
|
||||||
|
body.append(f'--{boundary}\r\n'.encode())
|
||||||
|
disp = (f'Content-Disposition: form-data; name="{field_name}"; '
|
||||||
|
f'filename="{filename}"\r\n').encode()
|
||||||
|
body.append(disp)
|
||||||
|
body.append(f'Content-Type: {mime}\r\n\r\n'.encode())
|
||||||
|
body.append(content)
|
||||||
|
body.append(b'\r\n')
|
||||||
|
body.append(f'--{boundary}--\r\n'.encode())
|
||||||
|
data = b''.join(body)
|
||||||
|
headers['Content-Type'] = f'multipart/form-data; boundary={boundary}'
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return json.loads(resp.read().decode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
def list_pieces(api: str) -> list:
|
||||||
|
return http_request('GET', f'{api}/api/pieces')
|
||||||
|
|
||||||
|
|
||||||
|
def create_piece(api: str, title: str, artist: str, lyrics: str | None) -> int:
|
||||||
|
body = {
|
||||||
|
'title': title,
|
||||||
|
'artist': artist or None,
|
||||||
|
'category': CATEGORY,
|
||||||
|
'lyrics': lyrics or None,
|
||||||
|
}
|
||||||
|
r = http_request('POST', f'{api}/api/pieces', json_body=body)
|
||||||
|
return int(r['id'])
|
||||||
|
|
||||||
|
|
||||||
|
def patch_play_count(api: str, piece_id: int, count: int):
|
||||||
|
http_request('PATCH', f'{api}/api/pieces/{piece_id}',
|
||||||
|
json_body={'play_count': count})
|
||||||
|
|
||||||
|
|
||||||
|
def upload_attachment(api: str, piece_id: int, path: Path,
|
||||||
|
mime: str, role: str | None = None) -> dict:
|
||||||
|
url = f'{api}/api/pieces/{piece_id}/attachments'
|
||||||
|
if role:
|
||||||
|
url += '?role=' + urllib.parse.quote(role)
|
||||||
|
bytes_content = path.read_bytes()
|
||||||
|
return http_request('POST', url, file_field=[
|
||||||
|
('files', path.name, bytes_content, mime),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def detect_audio_mime(path: Path) -> str:
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
return {
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.m4a': 'audio/mp4',
|
||||||
|
'.flac': 'audio/flac',
|
||||||
|
'.ogg': 'audio/ogg',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
}.get(ext, 'application/octet-stream')
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument('--src', default='/data/guitar', help='guitar 数据根目录')
|
||||||
|
ap.add_argument('--api', required=True, help='music API base,e.g. https://music.famzheng.me')
|
||||||
|
ap.add_argument('--dry-run', action='store_true', help='只打印不实际调用')
|
||||||
|
ap.add_argument('--limit', type=int, default=0, help='测试用,最多导入 N 首(0=全部)')
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
src = Path(args.src)
|
||||||
|
if not src.exists():
|
||||||
|
print(f'[!] {src} 不存在', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
chords_dir = src / 'chords'
|
||||||
|
playcounts_file = src / 'playcounts.json'
|
||||||
|
playcounts = {}
|
||||||
|
if playcounts_file.exists():
|
||||||
|
try:
|
||||||
|
playcounts = json.loads(playcounts_file.read_text())
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[!] 读 playcounts.json 失败: {e}', file=sys.stderr)
|
||||||
|
|
||||||
|
# 已有 pieces 去重
|
||||||
|
existing = {}
|
||||||
|
if not args.dry_run:
|
||||||
|
try:
|
||||||
|
for p in list_pieces(args.api):
|
||||||
|
key = (p['title'], p.get('artist') or '')
|
||||||
|
existing[key] = p['id']
|
||||||
|
print(f'[i] 远端已有 {len(existing)} 首曲目,重复的会跳过创建')
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(f'[!] 连不上 {args.api}: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
mp3s = sorted(src.glob('*.mp3'))
|
||||||
|
if args.limit:
|
||||||
|
mp3s = mp3s[:args.limit]
|
||||||
|
|
||||||
|
total = len(mp3s)
|
||||||
|
ok = skipped = failed = 0
|
||||||
|
|
||||||
|
for i, mp3 in enumerate(mp3s, 1):
|
||||||
|
stem = mp3.stem
|
||||||
|
title, artist = parse_filename(stem)
|
||||||
|
if not title:
|
||||||
|
print(f'[!] [{i}/{total}] 无法解析文件名: {mp3.name}', file=sys.stderr)
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = (title, artist)
|
||||||
|
prefix = f'[{i}/{total}] {title} - {artist}'
|
||||||
|
|
||||||
|
if key in existing:
|
||||||
|
print(f' ↻ {prefix} (已存在 id={existing[key]}, 跳过)')
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# lrc
|
||||||
|
lrc_path = mp3.with_suffix('.lrc')
|
||||||
|
lyrics = None
|
||||||
|
if lrc_path.exists():
|
||||||
|
try:
|
||||||
|
lyrics = lrc_path.read_text(encoding='utf-8')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
lyrics = lrc_path.read_text(encoding='gbk', errors='replace')
|
||||||
|
|
||||||
|
# chord png
|
||||||
|
chord_path = chords_dir / f'{sanitize(artist)}-{sanitize(title)}.png'
|
||||||
|
if not chord_path.exists():
|
||||||
|
chord_path = None
|
||||||
|
|
||||||
|
play_count = int(playcounts.get(mp3.name, 0))
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(f' + {prefix}'
|
||||||
|
f'{" [词]" if lyrics else ""}'
|
||||||
|
f'{" [谱]" if chord_path else ""}'
|
||||||
|
f'{f" [{play_count}次]" if play_count else ""}')
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
piece_id = create_piece(args.api, title, artist, lyrics)
|
||||||
|
print(f' + {prefix} → id={piece_id}', end='', flush=True)
|
||||||
|
|
||||||
|
upload_attachment(args.api, piece_id, mp3,
|
||||||
|
detect_audio_mime(mp3))
|
||||||
|
print(' [audio]', end='', flush=True)
|
||||||
|
|
||||||
|
if chord_path:
|
||||||
|
upload_attachment(args.api, piece_id, chord_path,
|
||||||
|
'image/png', role='chord')
|
||||||
|
print(' [chord]', end='', flush=True)
|
||||||
|
|
||||||
|
if play_count:
|
||||||
|
patch_play_count(args.api, piece_id, play_count)
|
||||||
|
print(f' [{play_count}次]', end='', flush=True)
|
||||||
|
|
||||||
|
print()
|
||||||
|
ok += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f'\n[!] {prefix} 失败: {e}', file=sys.stderr)
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f'完成: ok={ok} skipped={skipped} failed={failed} total={total}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
//! music.famzheng.me — 听歌 + 练琴。
|
||||||
|
//!
|
||||||
|
//! 数据模型:曲目 (piece) → 附件 (attachment, 类型 video/audio/pdf/image)。
|
||||||
|
//! 元数据走 sqlite,附件 bytes 落 `/data/blobs/<id>`,Range 下载交给 tower-http ServeFile。
|
||||||
|
//!
|
||||||
|
//! API:
|
||||||
|
//! - `GET /api/pieces` 列表(含附件计数 + 简要类型分布)
|
||||||
|
//! - `POST /api/pieces` 创建(json: title, category?, notes?)
|
||||||
|
//! - `GET /api/pieces/:id` 详情(含 attachments 列表)
|
||||||
|
//! - `PATCH /api/pieces/:id` 改 title / category / notes
|
||||||
|
//! - `DELETE /api/pieces/:id` 删曲目 + 级联删附件 + 同步删磁盘
|
||||||
|
//! - `POST /api/pieces/:id/attachments` multipart 流式上传,可一次多文件
|
||||||
|
//! - `GET /api/attachments/:id` 下载(带 Range,video/audio 拖动用)
|
||||||
|
//! - `DELETE /api/attachments/:id` 删单个附件 + 磁盘文件
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{DefaultBodyLimit, Multipart, Path, Query, Request, State},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{IntoResponse, Json as JsonResp, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
const SINGLE_FILE_BYTES: usize = 1024 * 1024 * 1024; // 1 GiB / 单附件
|
||||||
|
const REQUEST_BYTES: usize = 5 * 1024 * 1024 * 1024; // 5 GiB / 单次上传
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
db: Arc<Mutex<Connection>>,
|
||||||
|
blobs_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 blobs_dir =
|
||||||
|
PathBuf::from(std::env::var("BLOBS_DIR").unwrap_or_else(|_| "/data/blobs".into()));
|
||||||
|
let dist = std::env::var("MUSIC_DIST_DIR").unwrap_or_else(|_| "/dist".into());
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&blobs_dir).expect("mkdir blobs_dir");
|
||||||
|
|
||||||
|
let conn = Connection::open(&db_path).expect("open sqlite");
|
||||||
|
conn.execute_batch(
|
||||||
|
"PRAGMA journal_mode=WAL;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
CREATE TABLE IF NOT EXISTS pieces (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist TEXT,
|
||||||
|
category TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
lyrics TEXT,
|
||||||
|
play_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_played_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
piece_id INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
role TEXT,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (piece_id) REFERENCES pieces(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_att_piece ON attachments(piece_id);",
|
||||||
|
)
|
||||||
|
.expect("init schema");
|
||||||
|
tracing::info!(%db_path, blobs = %blobs_dir.display(), "music ready");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db: Arc::new(Mutex::new(conn)),
|
||||||
|
blobs_dir,
|
||||||
|
};
|
||||||
|
|
||||||
|
let api = Router::new()
|
||||||
|
.route("/pieces", get(list_pieces).post(create_piece))
|
||||||
|
.route(
|
||||||
|
"/pieces/:id",
|
||||||
|
get(get_piece).patch(patch_piece).delete(delete_piece),
|
||||||
|
)
|
||||||
|
.route("/pieces/:id/play", post(record_play))
|
||||||
|
.route(
|
||||||
|
"/pieces/:id/attachments",
|
||||||
|
post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/attachments/:id",
|
||||||
|
get(get_attachment).delete(delete_attachment),
|
||||||
|
)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let app = cube_core::base(dist).nest("/api", api);
|
||||||
|
cube_core::serve(app, 8080).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PieceSummary {
|
||||||
|
id: i64,
|
||||||
|
title: String,
|
||||||
|
artist: Option<String>,
|
||||||
|
category: Option<String>,
|
||||||
|
play_count: i64,
|
||||||
|
last_played_at: Option<String>,
|
||||||
|
attachments: i64,
|
||||||
|
kinds: Vec<String>,
|
||||||
|
has_lyrics: bool,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PieceDetail {
|
||||||
|
id: i64,
|
||||||
|
title: String,
|
||||||
|
artist: Option<String>,
|
||||||
|
category: Option<String>,
|
||||||
|
notes: Option<String>,
|
||||||
|
lyrics: Option<String>,
|
||||||
|
play_count: i64,
|
||||||
|
last_played_at: Option<String>,
|
||||||
|
created_at: String,
|
||||||
|
attachments: Vec<Attachment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Attachment {
|
||||||
|
id: i64,
|
||||||
|
kind: String,
|
||||||
|
role: Option<String>,
|
||||||
|
mime: String,
|
||||||
|
filename: String,
|
||||||
|
size_bytes: i64,
|
||||||
|
sort_order: i64,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UploadQuery {
|
||||||
|
role: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreatePiece {
|
||||||
|
title: String,
|
||||||
|
artist: Option<String>,
|
||||||
|
category: Option<String>,
|
||||||
|
notes: Option<String>,
|
||||||
|
lyrics: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PatchPiece {
|
||||||
|
title: Option<String>,
|
||||||
|
artist: Option<Option<String>>,
|
||||||
|
category: Option<Option<String>>,
|
||||||
|
notes: Option<Option<String>>,
|
||||||
|
lyrics: Option<Option<String>>,
|
||||||
|
/// admin / import 用:直接写 play_count(mvp 无认证)
|
||||||
|
play_count: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- handlers: pieces ----------
|
||||||
|
|
||||||
|
async fn list_pieces(State(s): State<AppState>) -> Result<JsonResp<Value>, AppError> {
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT p.id, p.title, p.artist, p.category,
|
||||||
|
p.play_count, p.last_played_at, p.created_at,
|
||||||
|
COUNT(a.id) AS att_count,
|
||||||
|
COALESCE(GROUP_CONCAT(DISTINCT a.kind), '') AS kinds,
|
||||||
|
CASE WHEN p.lyrics IS NOT NULL AND length(p.lyrics) > 0 THEN 1 ELSE 0 END AS has_lyrics
|
||||||
|
FROM pieces p
|
||||||
|
LEFT JOIN attachments a ON a.piece_id = p.id
|
||||||
|
GROUP BY p.id
|
||||||
|
ORDER BY p.title COLLATE NOCASE ASC, p.id ASC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |r| {
|
||||||
|
let kinds_csv: String = r.get(8)?;
|
||||||
|
let kinds = if kinds_csv.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
kinds_csv.split(',').map(|x| x.to_string()).collect()
|
||||||
|
};
|
||||||
|
let has_lyrics: i64 = r.get(9)?;
|
||||||
|
Ok(PieceSummary {
|
||||||
|
id: r.get(0)?,
|
||||||
|
title: r.get(1)?,
|
||||||
|
artist: r.get(2)?,
|
||||||
|
category: r.get(3)?,
|
||||||
|
play_count: r.get(4)?,
|
||||||
|
last_played_at: r.get(5)?,
|
||||||
|
created_at: r.get(6)?,
|
||||||
|
attachments: r.get(7)?,
|
||||||
|
kinds,
|
||||||
|
has_lyrics: has_lyrics != 0,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(JsonResp(json!(rows)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_piece(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
JsonResp(body): JsonResp<CreatePiece>,
|
||||||
|
) -> Result<JsonResp<Value>, AppError> {
|
||||||
|
let title = body.title.trim();
|
||||||
|
if title.is_empty() {
|
||||||
|
return Err(AppError::bad_request("title required"));
|
||||||
|
}
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO pieces (title, artist, category, notes, lyrics)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
|
params![
|
||||||
|
title,
|
||||||
|
body.artist.as_deref().map(str::trim).filter(|s| !s.is_empty()),
|
||||||
|
body.category.as_deref().map(str::trim).filter(|s| !s.is_empty()),
|
||||||
|
body.notes.as_deref(),
|
||||||
|
body.lyrics.as_deref()
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let id = conn.last_insert_rowid();
|
||||||
|
Ok(JsonResp(json!({ "id": id })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_piece(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<JsonResp<PieceDetail>, AppError> {
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
type PieceRow = (
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
i64,
|
||||||
|
Option<String>,
|
||||||
|
String,
|
||||||
|
);
|
||||||
|
let row: Option<PieceRow> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT title, artist, category, notes, lyrics, play_count, last_played_at, created_at
|
||||||
|
FROM pieces WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| {
|
||||||
|
Ok((
|
||||||
|
r.get(0)?,
|
||||||
|
r.get(1)?,
|
||||||
|
r.get(2)?,
|
||||||
|
r.get(3)?,
|
||||||
|
r.get(4)?,
|
||||||
|
r.get(5)?,
|
||||||
|
r.get(6)?,
|
||||||
|
r.get(7)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let (title, artist, category, notes, lyrics, play_count, last_played_at, created_at) =
|
||||||
|
row.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, kind, role, mime, filename, size_bytes, sort_order, created_at
|
||||||
|
FROM attachments
|
||||||
|
WHERE piece_id = ?1
|
||||||
|
ORDER BY sort_order ASC, id ASC",
|
||||||
|
)?;
|
||||||
|
let attachments = stmt
|
||||||
|
.query_map(params![id], |r| {
|
||||||
|
Ok(Attachment {
|
||||||
|
id: r.get(0)?,
|
||||||
|
kind: r.get(1)?,
|
||||||
|
role: r.get(2)?,
|
||||||
|
mime: r.get(3)?,
|
||||||
|
filename: r.get(4)?,
|
||||||
|
size_bytes: r.get(5)?,
|
||||||
|
sort_order: r.get(6)?,
|
||||||
|
created_at: r.get(7)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(JsonResp(PieceDetail {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
category,
|
||||||
|
notes,
|
||||||
|
lyrics,
|
||||||
|
play_count,
|
||||||
|
last_played_at,
|
||||||
|
created_at,
|
||||||
|
attachments,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_piece(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
JsonResp(body): JsonResp<PatchPiece>,
|
||||||
|
) -> Result<JsonResp<Value>, AppError> {
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
let exists: bool = conn
|
||||||
|
.query_row("SELECT 1 FROM pieces WHERE id = ?1", params![id], |_| {
|
||||||
|
Ok(true)
|
||||||
|
})
|
||||||
|
.optional()?
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !exists {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(title) = body.title.as_ref() {
|
||||||
|
let t = title.trim();
|
||||||
|
if t.is_empty() {
|
||||||
|
return Err(AppError::bad_request("title can't be blank"));
|
||||||
|
}
|
||||||
|
conn.execute("UPDATE pieces SET title = ?1 WHERE id = ?2", params![t, id])?;
|
||||||
|
}
|
||||||
|
if let Some(artist) = body.artist {
|
||||||
|
let artist = artist.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pieces SET artist = ?1 WHERE id = ?2",
|
||||||
|
params![artist, id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
if let Some(cat) = body.category {
|
||||||
|
let cat = cat.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pieces SET category = ?1 WHERE id = ?2",
|
||||||
|
params![cat, id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
if let Some(notes) = body.notes {
|
||||||
|
let notes = notes.as_deref();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pieces SET notes = ?1 WHERE id = ?2",
|
||||||
|
params![notes, id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
if let Some(lyrics) = body.lyrics {
|
||||||
|
let lyrics = lyrics.as_deref();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pieces SET lyrics = ?1 WHERE id = ?2",
|
||||||
|
params![lyrics, id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
if let Some(pc) = body.play_count {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pieces SET play_count = ?1 WHERE id = ?2",
|
||||||
|
params![pc, id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(JsonResp(json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_play(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<JsonResp<Value>, AppError> {
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
let n = conn.execute(
|
||||||
|
"UPDATE pieces
|
||||||
|
SET play_count = play_count + 1, last_played_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
)?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
let count: i64 = conn.query_row(
|
||||||
|
"SELECT play_count FROM pieces WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(JsonResp(json!({ "play_count": count })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_piece(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<JsonResp<Value>, AppError> {
|
||||||
|
let to_unlink: Vec<i64> = {
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT id FROM attachments WHERE piece_id = ?1")?;
|
||||||
|
let ids: Vec<i64> = stmt
|
||||||
|
.query_map(params![id], |r| r.get(0))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let n = conn.execute("DELETE FROM pieces WHERE id = ?1", params![id])?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
ids
|
||||||
|
};
|
||||||
|
for aid in to_unlink {
|
||||||
|
let _ = tokio::fs::remove_file(s.blobs_dir.join(aid.to_string())).await;
|
||||||
|
}
|
||||||
|
Ok(JsonResp(json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- handlers: attachments ----------
|
||||||
|
|
||||||
|
/// `POST /api/pieces/:id/attachments?role=chord|numbered|staff` — multipart 流式上传。
|
||||||
|
/// 每个 file field(任意 name)= 一个附件,`role` query 给整批文件。
|
||||||
|
async fn upload_attachments(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(piece_id): Path<i64>,
|
||||||
|
Query(q): Query<UploadQuery>,
|
||||||
|
mut form: Multipart,
|
||||||
|
) -> Result<JsonResp<Value>, AppError> {
|
||||||
|
let role = match q.role.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
None => None,
|
||||||
|
Some(r) if matches!(r, "chord" | "numbered" | "staff") => Some(r.to_string()),
|
||||||
|
Some(other) => {
|
||||||
|
return Err(AppError::bad_request(format!(
|
||||||
|
"unsupported role '{other}', expect one of: chord / numbered / staff"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
let exists: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM pieces WHERE id = ?1",
|
||||||
|
params![piece_id],
|
||||||
|
|_| Ok(true),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !exists {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut created: Vec<Value> = Vec::new();
|
||||||
|
|
||||||
|
while let Some(mut field) = form
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::bad_request(format!("multipart: {e}")))?
|
||||||
|
{
|
||||||
|
let filename = field
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "untitled".to_string());
|
||||||
|
let mime = field
|
||||||
|
.content_type()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
|
let kind = classify(&mime).ok_or_else(|| {
|
||||||
|
AppError::bad_request(format!("unsupported mime '{mime}' for '{filename}'"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 占坑拿 attachment id —— 文件名用 id,能唯一确定路径。
|
||||||
|
let attachment_id = {
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO attachments (piece_id, kind, role, mime, filename, size_bytes, sort_order)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, 0,
|
||||||
|
COALESCE((SELECT MAX(sort_order) FROM attachments WHERE piece_id = ?1), 0) + 1)",
|
||||||
|
params![piece_id, kind, role, mime, filename],
|
||||||
|
)?;
|
||||||
|
conn.last_insert_rowid()
|
||||||
|
};
|
||||||
|
|
||||||
|
let final_path = s.blobs_dir.join(attachment_id.to_string());
|
||||||
|
let tmp_path = s.blobs_dir.join(format!("{attachment_id}.tmp"));
|
||||||
|
|
||||||
|
let written: usize = match stream_to_file(&mut field, &tmp_path).await {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"DELETE FROM attachments WHERE id = ?1",
|
||||||
|
params![attachment_id],
|
||||||
|
);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = tokio::fs::rename(&tmp_path, &final_path).await {
|
||||||
|
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"DELETE FROM attachments WHERE id = ?1",
|
||||||
|
params![attachment_id],
|
||||||
|
);
|
||||||
|
return Err(AppError::Io(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE attachments SET size_bytes = ?1 WHERE id = ?2",
|
||||||
|
params![written as i64, attachment_id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
created.push(json!({
|
||||||
|
"id": attachment_id,
|
||||||
|
"kind": kind,
|
||||||
|
"role": role,
|
||||||
|
"mime": mime,
|
||||||
|
"filename": filename,
|
||||||
|
"size_bytes": written,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if created.is_empty() {
|
||||||
|
return Err(AppError::bad_request("no files uploaded"));
|
||||||
|
}
|
||||||
|
Ok(JsonResp(json!({ "attachments": created })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream_to_file(
|
||||||
|
field: &mut axum::extract::multipart::Field<'_>,
|
||||||
|
path: &std::path::Path,
|
||||||
|
) -> Result<usize, AppError> {
|
||||||
|
let mut file = tokio::fs::File::create(path).await.map_err(AppError::Io)?;
|
||||||
|
let mut total: usize = 0;
|
||||||
|
while let Some(chunk) = field
|
||||||
|
.chunk()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::bad_request(format!("upload read: {e}")))?
|
||||||
|
{
|
||||||
|
total += chunk.len();
|
||||||
|
if total > SINGLE_FILE_BYTES {
|
||||||
|
return Err(AppError::bad_request(format!(
|
||||||
|
"single file exceeds {SINGLE_FILE_BYTES} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
file.write_all(&chunk).await.map_err(AppError::Io)?;
|
||||||
|
}
|
||||||
|
file.flush().await.map_err(AppError::Io)?;
|
||||||
|
file.sync_all().await.map_err(AppError::Io)?;
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/attachments/:id` — Range-aware 下载。
|
||||||
|
async fn get_attachment(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let row: Option<(String, String, String)> = {
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT mime, filename, kind FROM attachments WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
};
|
||||||
|
let (mime, filename, _kind) = row.ok_or(AppError::NotFound)?;
|
||||||
|
let path = s.blobs_dir.join(id.to_string());
|
||||||
|
|
||||||
|
let mime_hv: header::HeaderValue = mime
|
||||||
|
.parse()
|
||||||
|
.unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream"));
|
||||||
|
let svc = tower_http::services::ServeFile::new(&path);
|
||||||
|
let mut resp = svc
|
||||||
|
.oneshot(req)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Io(std::io::Error::other(e.to_string())))?
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
// 强一些的缓存头,video 拖动友好
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private, max-age=3600"));
|
||||||
|
resp.headers_mut().insert(header::CONTENT_TYPE, mime_hv);
|
||||||
|
if let Ok(disp) = format!(
|
||||||
|
"inline; filename*=UTF-8''{}",
|
||||||
|
percent_encode(&filename)
|
||||||
|
)
|
||||||
|
.parse()
|
||||||
|
{
|
||||||
|
resp.headers_mut().insert(header::CONTENT_DISPOSITION, disp);
|
||||||
|
}
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_attachment(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<JsonResp<Value>, AppError> {
|
||||||
|
let n = {
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
conn.execute("DELETE FROM attachments WHERE id = ?1", params![id])?
|
||||||
|
};
|
||||||
|
if n == 0 {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
let _ = tokio::fs::remove_file(s.blobs_dir.join(id.to_string())).await;
|
||||||
|
Ok(JsonResp(json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
fn classify(mime: &str) -> Option<&'static str> {
|
||||||
|
let m = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
|
||||||
|
if m.starts_with("video/") {
|
||||||
|
Some("video")
|
||||||
|
} else if m.starts_with("audio/") {
|
||||||
|
Some("audio")
|
||||||
|
} else if m == "application/pdf" {
|
||||||
|
Some("pdf")
|
||||||
|
} else if m.starts_with("image/") {
|
||||||
|
Some("image")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_encode(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for b in s.as_bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||||
|
out.push(*b as char)
|
||||||
|
}
|
||||||
|
_ => out.push_str(&format!("%{:02X}", b)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- error type ----------
|
||||||
|
|
||||||
|
enum AppError {
|
||||||
|
BadRequest(String),
|
||||||
|
NotFound,
|
||||||
|
Db(rusqlite::Error),
|
||||||
|
Io(std::io::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()
|
||||||
|
}
|
||||||
|
Self::Io(e) => {
|
||||||
|
tracing::error!(error = %e, "io error");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "io error").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.DS_Store
|
|
||||||
*.log
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// 薄薄一层 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)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// 移动端拍照原图常 5–12MB,1500–1800px 长边 + 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() })
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="upload">
|
|
||||||
<header class="topbar">
|
|
||||||
<router-link to="/" class="back" aria-label="返回">←</router-link>
|
|
||||||
<h1>上传琴谱</h1>
|
|
||||||
<span class="spacer" />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="content">
|
|
||||||
<label class="field">
|
|
||||||
<span class="label">曲名</span>
|
|
||||||
<input
|
|
||||||
v-model="title"
|
|
||||||
type="text"
|
|
||||||
placeholder="例如:Clair de Lune"
|
|
||||||
maxlength="120"
|
|
||||||
required
|
|
||||||
enterkeyhint="done"
|
|
||||||
class="input"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<section class="actions-add">
|
|
||||||
<button type="button" class="btn-camera" @click="cameraInput?.click()" :disabled="processing">
|
|
||||||
<span class="icon">📷</span>
|
|
||||||
<span class="title">拍下一页</span>
|
|
||||||
<span class="hint">{{ items.length === 0 ? '主入口:调用摄像头' : `已拍 ${items.length} 页,继续` }}</span>
|
|
||||||
</button>
|
|
||||||
<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"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
@change="onAlbum"
|
|
||||||
hidden
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p v-if="processing" class="msg info">压缩中…</p>
|
|
||||||
|
|
||||||
<ul v-if="items.length" class="pages">
|
|
||||||
<li v-for="(it, i) in items" :key="it.key" class="page">
|
|
||||||
<span class="no">P{{ i + 1 }}</span>
|
|
||||||
<img :src="it.preview" alt="" class="thumb" />
|
|
||||||
<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>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p v-if="error" class="msg error">{{ error }}</p>
|
|
||||||
<p v-if="success" class="msg success">{{ success }}</p>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer v-if="items.length" class="bottombar">
|
|
||||||
<button type="button" class="btn-submit" :disabled="!canSubmit" @click="submit">
|
|
||||||
{{ submitting ? '上传中…' : `上传 ${items.length} 页` }}
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onUnmounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { uploadSheet } from '../lib/api.js'
|
|
||||||
import { compressImage } from '../lib/compress.js'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const title = ref('')
|
|
||||||
const items = ref([]) // { key, file, preview }
|
|
||||||
const cameraInput = ref(null)
|
|
||||||
const albumInput = ref(null)
|
|
||||||
const processing = ref(false)
|
|
||||||
const submitting = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
const success = ref('')
|
|
||||||
|
|
||||||
let nextKey = 0
|
|
||||||
|
|
||||||
const canSubmit = computed(
|
|
||||||
() => !submitting.value && !processing.value && title.value.trim() && items.value.length > 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
async function addFiles(incoming) {
|
|
||||||
const imgs = incoming.filter((f) => f.type && f.type.startsWith('image/'))
|
|
||||||
if (imgs.length === 0) return
|
|
||||||
processing.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
const compressed = await Promise.all(
|
|
||||||
imgs.map((f) => compressImage(f).catch(() => f)),
|
|
||||||
)
|
|
||||||
const fresh = compressed.map((f) => ({
|
|
||||||
key: ++nextKey,
|
|
||||||
file: f,
|
|
||||||
preview: URL.createObjectURL(f),
|
|
||||||
}))
|
|
||||||
items.value = [...items.value, ...fresh]
|
|
||||||
} finally {
|
|
||||||
processing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCamera(e) {
|
|
||||||
addFiles(Array.from(e.target.files || []))
|
|
||||||
e.target.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAlbum(e) {
|
|
||||||
addFiles(Array.from(e.target.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) {
|
|
||||||
const it = items.value[i]
|
|
||||||
URL.revokeObjectURL(it.preview)
|
|
||||||
items.value = items.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(), items.value.map((x) => x.file))
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
for (const it of items.value) URL.revokeObjectURL(it.preview)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.upload {
|
|
||||||
min-height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
/* 给底部 sticky 按钮留 safe area(iOS 刘海/底部条) */
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 16px;
|
|
||||||
padding-top: calc(12px + env(safe-area-inset-top));
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg-card);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.topbar h1 { font-size: 18px; font-weight: 700; }
|
|
||||||
.back {
|
|
||||||
display: inline-flex;
|
|
||||||
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 {
|
|
||||||
flex: 1;
|
|
||||||
padding: 16px 16px 120px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 18px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.label { font-size: 13px; 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: 17px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
outline: none;
|
|
||||||
font-family: inherit;
|
|
||||||
/* iOS 默认会放大到 16px+ 才不缩放,这里 17px 安全 */
|
|
||||||
}
|
|
||||||
.input:focus { border-color: var(--accent); }
|
|
||||||
|
|
||||||
.actions-add { display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
|
|
||||||
.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;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.page {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 36px 64px 1fr auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
|
||||||
.no {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 6px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.thumb {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
}
|
|
||||||
.meta { min-width: 0; }
|
|
||||||
.fname {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.fsize { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
|
|
||||||
.ops { display: flex; gap: 4px; }
|
|
||||||
.op {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.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.info { background: var(--bg-surface); color: var(--text-secondary); }
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
.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>
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
//! 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