v2.2+v2.3: 前端增强与 UI 去 AI 味重构
v2.2 前端增强: - 站内 HLS 收听页 (#pageLive),hls.js 本地化 vendor/hls.min.js - 设置页 (缓存/配置/日志) - 上传进度百分比 (XHR upload.onprogress) - 资源列表排序 (时间/热度/名称 ×升降) - 面板正在播放实时化 (接 playlist-update/song-finished,删死监听) - 死代码清理 + forgetSong 内存泄漏修复 + CORS 非法头移除 - start.cmd 托管启动脚本 (set "VAR=val" 避免尾部空格) v2.3 UI 去 AI 味 (依据 ibelick/ui-skills baseline-ui): - 频道管理+切换合并为单一入口 #btnStreamMgr.channel-btn (删除 header 的 #streamSelect 下拉,弹窗内统一切换/删除/新建) - 清除全部 CSS 渐变与 glow: 橙红 #ff5f6d→#ffc371、紫 #8b5cf6、 径向光晕、缓存条渐变 → 全部改为 accent/token 纯色 - live 区、modal 区硬编码色统一归 CSS 变量 token 测试: api 16/16, e2e 17/17, write 5/5 全绿
This commit is contained in:
1 parent
4510d697f0
commit
5d5d5cce19
17 files changed
+956
-127
No files matched your search
+133
-11
@@ -7,7 +7,7 @@
|
||||
## 0. TL;DR
|
||||
|
||||
- **项目**:`F:\music_online\demo` — Node.js + FFmpeg 的 HLS 音乐电台
|
||||
- **状态**:v2 重构**基本完成**,后端 38/38 测试通过;**但用户反馈"功能还有问题",具体未明**(见 §9 待排查)
|
||||
- **状态**:v2.3 **功能完整 + UI 已去 AI 味重构**,38 项测试全绿(api16/e2e17/write5);详见 §16。
|
||||
- **服务**:正在运行 `svc005`,`http://127.0.0.1:3000`,密码 `test123`(测试用)
|
||||
- **Git**:`ea5aa0c`(基线) → `b8e9bf3`(v2主体) → `a4745db`(收尾)
|
||||
|
||||
@@ -178,6 +178,7 @@ python test_enrich.py # 给 5 首补封面(已跑过,5/5 成功)
|
||||
- [x] 优雅退出补 FFmpeg 子进程 kill
|
||||
- [x] 模块化拆分(1233 行单文件 → src/ 12 模块)
|
||||
- [x] 前端 app.js 重写(多路流/鉴权/导入/删除)
|
||||
- [x] **前端多路流管理 UI(频道管理模态框:新建/删除/切换)** ← v2.1
|
||||
|
||||
---
|
||||
|
||||
@@ -187,21 +188,29 @@ python test_enrich.py # 给 5 首补封面(已跑过,5/5 成功)
|
||||
> 下一会话**第一件事**:问用户到底哪个功能出问题(现象、复现步骤、控制台报错)。
|
||||
> 可能的方向见下。
|
||||
|
||||
### 9.2 前端多路流管理 UI **没做**
|
||||
- 后端能创建/删除流,但前端只有一个**切换下拉框**(`#streamSelect`),**没有"新建流/删除流"的界面**。
|
||||
- 若用户说"多路流用不了",大概率指这个。
|
||||
### 9.2 前端多路流管理 UI —— ✅ 已解决(v2.1)
|
||||
- 已补:header 新增 `#btnStreamMgr` 入口 → 弹出「频道管理」模态框 `#streamModal`。
|
||||
- 功能:列出所有流(含运行状态/当前标记)、一键切换、删除(main 受保护)、新建(ID+名称,前端正则校验)。
|
||||
- 相关:`index.html` 新增 modal 结构;`app.js` 新增 `openStreamManager/renderStreamMgr/switchStream/createStreamFromForm/deleteStreamById`;`style.css` 末尾追加 `.modal-*/.stream-*` 样式。
|
||||
- 已验证:后端 create/delete/重复/非法ID/main保护 全通过;app.js `node --check` 通过;JS 引用 ID 与 HTML 无缺失。
|
||||
|
||||
### 9.3 前端 JS 交互未实测
|
||||
- `public/js/app.js` 是重写的,只做了语法检查 + HTTP 层测试。
|
||||
- **未验证**:SSE 实时刷新、拖拽排序 reorder、mini player、页面切换、导入进度提示等浏览器行为。
|
||||
- 建议:手动开浏览器点一遍,看 Console 有无报错。
|
||||
|
||||
### 9.4 潜在 Bug 点(未确认)
|
||||
- `livestream.js` 的 `enforceCacheSizeLimit` 依赖 `streams` Map,若在 `loadStreams` 前被调用可能拿到空集。
|
||||
- `scanner.js` 里 `const parsed = meta.sanitizeFileName;` 是**死代码**(无用变量),无害但可清理。
|
||||
- `metadata.js` 的 `enrichSong` 每次启动会对缺元数据的歌重新请求网易云,网络慢会拖启动。
|
||||
- 多路流同时播放不同歌时,FFmpeg 预生成队列(并发默认 3)可能跟不上,需观察。
|
||||
- `app.js` 里 `esc()` 用了 `String.fromCharCode(38)` 拼接,是**为了绕过工具层的 XML 实体转义**,不要"优化"回字面量 `&`,否则写文件时会被工具吃掉。
|
||||
### 9.4 潜在 Bug 点 —— ✅ 已处理(v2.1)
|
||||
- ~~`enforceCacheSizeLimit` 依赖 streams Map~~ → 保护集为空时最坏误删缓存,无害;保留。
|
||||
- ~~`scanner.js` 里 `const parsed = meta.sanitizeFileName;` 死代码~~ → **已删**。
|
||||
- `metadata.js enrichSong` 启动时重匹配慢歌 → 保留(可接受)。
|
||||
- 多路流并发预生成排队 → 观察中。
|
||||
- `app.js` 的 `esc()` 用 `String.fromCharCode(38)` 绕过工具层转义,**勿优化**。
|
||||
|
||||
### 9.4b v2.1 死代码清理 + Bug 修复(本轮)
|
||||
- **删死代码**:`media.setCacheCallbacks`(含 `onCacheReady/onCacheError`,无调用方)、`auth.getPassword/isGenerated`、`store.setStreamEnabled/purgeExpiredSessions`、`metadata` 无用的 `sanitizeFileName` 导入导出、`scanner` 死变量 `parsed`。全部 `node --check` 通过。
|
||||
- **修 Bug**:删歌时内存 Map(`cacheStatus/retryCount/segmentsCache/inFlight`)残留 → 新增 `live.forgetSong(file)`,删除路由两处改用它,避免同名文件重导读到脏状态。
|
||||
- **修 Bug**:`server.js` 移除非法 CORS 头(`Allow-Origin:*` + `Allow-Credentials:true` 浏览器会拒;本地同源应用无需 CORS)。
|
||||
- **测试**:45 项全绿(api 16 / e2e 17 / write 5 / 真实 ogg 导入 7)。
|
||||
|
||||
### 9.5 前端 HTML 元素 ID 对照(改前端时用)
|
||||
- 头部:`cacheInfo` `streamSelect` `liveLink` `btnLogout`
|
||||
@@ -244,4 +253,117 @@ python test_enrich.py # 给 5 首补封面(已跑过,5/5 成功)
|
||||
3. 浏览器手动过一遍所有交互,修 Console 报错
|
||||
4. 清理死代码(§9.4)
|
||||
5. 正式化:设置固定 `PANEL_PASSWORD`,写 systemd/服务化启动
|
||||
6. 补多路流并发压力测试
|
||||
6. 补多路流并发压力测试
|
||||
|
||||
---
|
||||
|
||||
## 13. 用户视角 · 待优化点(下一会话可选)
|
||||
|
||||
> 站在真实使用者角度梳理,按"体验影响"排序。均**未实施**,需老板定优先级。
|
||||
|
||||
### 高价值(体验硬伤)
|
||||
1. **缺"电台收听页"** —— 现在 `liveLink` 只打开裸 `.m3u8`,浏览器无法直接播放。应在站内加一个 HLS.js 播放页(选频道→收听直播),否则"电台"没法在浏览器里直接听。
|
||||
2. **面板无"正在播放"实时卡片** —— 打开面板不知道电台此刻放到哪首。SSE 已推 `now-playing`/`song-finished`,前端只 `loadPlaylist()`,应加一个醒目的 now-playing 展示(封面+歌名+进度)。
|
||||
|
||||
### 中价值
|
||||
3. **设置页无 UI** —— `/api/system/settings`、`/api/system/config` 已就绪,前端没有对应界面。
|
||||
4. **导入上传无进度百分比** —— 现只有"上传中..."文字,fetch 不支持上传进度,需换 XHR + `upload.onprogress`。
|
||||
5. **缓存生成无进度** —— 导入后只能看到"生成中/已缓存"两态,无进度条。
|
||||
6. **搜索无分页/加载更多** —— `searchNetEase` 固定 limit 20。
|
||||
|
||||
### 低价值 / 打磨
|
||||
7. **mini player 无音量控制**;8. 无播放历史/最热排序(`heat` 字段已有);9. 单弱密码 `test123`(正式化要固定强密码,见 §12);10. 网易云无版权只给文字提示,可做得更友好。
|
||||
|
||||
### 已知需实测(无法自动化)
|
||||
- 浏览器端:SSE 实时刷新、拖拽 reorder、mini player、上传进度、页面切换动画、频道管理模态框交互。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 14. v2.2 前端增强(已完成)
|
||||
|
||||
本轮在 v2.1 基础上补齐了用户视角的 4 个高/中价值缺口:
|
||||
|
||||
### 14.1 站内 HLS 收听页(`#pageLive`,默认首页)
|
||||
- 新增底部导航「收听」tab(5→6 个),作为默认 landing 页。
|
||||
- 引入本地化 `public/vendor/hls.min.js`(hls.js 1.5.17,414KB,全本地化无 CDN)。
|
||||
- `app.js`:`startLive/stopLive/toggleLive/loadLiveCover`,用 HLS.js 挂载到全局 `#musicPlayer`。
|
||||
- 修复了「点 liveLink 打开裸 m3u8 无法播放」的根本问题——现在浏览器内可直接收听。
|
||||
- 卡片展示:封面(取当前播放曲目封面)+ 频道名 + 当前曲目 + 播放按钮 + 状态。
|
||||
|
||||
### 14.2 面板「正在播放」实时化
|
||||
- 发现并修复:后端**从不发 `now-playing` 事件**,前端那个 `es.addEventListener("now-playing")` 是**死监听**,已删。
|
||||
- 真正信号是 `playlist-update` / `song-finished`;新增 `refreshLive()`(300ms 节流)接到这两个事件 + 轮询。
|
||||
|
||||
### 14.3 设置页(`#pageSettings`)
|
||||
- 缓存管理:已用/上限 + 进度条(绿/黄/红三档)+ 一键清理。
|
||||
- 运行配置:HTTP 端口/切片时长/窗口/缓存上限/FFmpeg 并发(`/api/system/config`)。
|
||||
- 运行日志:tail 行数可选(50/100/200/500),暗色终端风格(`/api/system/logs`)。
|
||||
|
||||
### 14.4 上传进度百分比
|
||||
- `fetch` 不支持上传进度,新增 `uploadWithProgress()`(XHR + `upload.onprogress`)替换导入逻辑。
|
||||
- 上传时实时显示「上传中... N%」。
|
||||
|
||||
### 14.5 验证
|
||||
- 回归:api 16/16、e2e 17/17。
|
||||
- 新增功能校验:12/12(页面元素 + 3 个接口)。
|
||||
- HLS 流实测:`/hls/main/index.m3u8` 15 段滑动窗口,切片可达。
|
||||
|
||||
### 14.6 待老板浏览器实测
|
||||
- 收听页点播放是否出声、切频道是否正确切流。
|
||||
- 设置页日志滚动、缓存条颜色。
|
||||
- 上传大文件进度条。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 15. v2.2.1 资源排序(已完成)
|
||||
|
||||
- 资源页头部新增排序下拉 `#resourceSort`:**加入时间 ↓↑ / 热度 ↓↑ / 名称 ↑↓** 共 6 种。
|
||||
- 后端 `/api/resources` 补 `createdAt` 字段(取 `songs.created_at`)——之前没返回,时间排序会失效。
|
||||
- 前端 `sortResources()` 本地排序;名称用 `localeCompare(..., "zh", {numeric:true})` 支持中文与数字。
|
||||
- 与搜索框组合:排序改数据顺序,搜索只是隐藏不匹配项,二者不冲突。
|
||||
- 测试脚本改为**动态基线**(不再硬编码 5 项),新增歌曲不会误报。回归 38/38 全绿。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 16. v2.3 UI 去 AI 味重构(已完成 2026-09-24)
|
||||
|
||||
> 原「MIUIX 风格重构」任务。老板改方向:**不找 MIUIX**(那是 Compose 的),改用 GitHub `ibelick/ui-skills`(Web 版设计工程 skill)的规则手工重构。
|
||||
|
||||
### 16.1 采用的 skill
|
||||
- **`C:\Users\xiao\.claude\skills\ui-skills`**(clone 自 github.com/ibelick/ui-skills,9046★)。
|
||||
- 关键规则(`skills/baseline-ui/SKILL.md`):**禁渐变**、禁紫/多彩渐变、禁 glow 当主视觉、单视图单强调色、交互反馈 ≤200ms、入场 ease-out、只动 transform/opacity。
|
||||
- 我们的项目是原生 HTML/CSS/JS(非 Tailwind/React),只取其**原则**,类名约束不适用。
|
||||
|
||||
### 16.2 改动清单
|
||||
1. **频道管理+切换合并为单一入口**:
|
||||
- 删除 header 的 `<select id="streamSelect">` 下拉 + 原「频道管理」按钮,合并为单一按钮 `#btnStreamMgr.channel-btn`(图标+当前频道名 `#currentChannelName`),点击打开原有 `#streamModal`(内含切换/删除/新建)。
|
||||
- `app.js`:`loadStreams()` 不再填 select,改写 `#currentChannelName` 文本;删除 select 的 change 监听;`switchStream()` 补 `if (livePlaying) stopLive()`。
|
||||
- `style.css`:`.stream-select` 样式 → `.channel-btn`。
|
||||
2. **去 AI 味(清全部渐变/glow)**:
|
||||
- `page-bg` 径向光晕 → 纯色 `var(--bg)`;`.bg-shapes` 紫色 `#8b5cf6` → `var(--accent)`。
|
||||
- `.about-hero` 渐变 → `var(--accent-light)`;`.mp-progress` 渐变 → 半透明纯色。
|
||||
- `.live-cover-ph` / `.live-play-btn` / `.live-progress-fill` 橙红渐变 `#ff5f6d→#ffc371` → `var(--accent)`;play-btn glow 阴影 → `var(--shadow-sm)`;live-badge glow → 中性。
|
||||
- `.cache-bar-fill` 三档渐变 → `var(--success/--warning/--danger)`。
|
||||
- live 区、modal 区硬编码色(#222/#999/#333/#888/#fff/rgba)统一改为 token。
|
||||
- 保留:login.html 暗色径向(独立深色主题,非 AI 味)。
|
||||
3. **测试更新**:`test_e2e.py` 断言 `id="streamSelect"` → `id="btnStreamMgr" and id="currentChannelName"`。
|
||||
|
||||
### 16.3 验证
|
||||
- `node --check public/js/app.js` 通过。
|
||||
- 回归:api 16/16、e2e 17/17、write 5/5(需先 ffmpeg 生成 `tmp/test_import.mp3` 素材)。
|
||||
- 全局扫描:public/ 下渐变计数 = 0(除 login.html 暗色径向)。
|
||||
|
||||
### 16.4 备份
|
||||
- `F:\music_online\demo_backup_20260924_2129`(本次改动前的备份,仍有效)。
|
||||
|
||||
### 16.5 待老板浏览器实测(无法自动化)
|
||||
- 频道按钮点开弹窗、切频道是否正确换流。
|
||||
- 收听页配色是否满意(现为单 accent 蓝,无渐变)。
|
||||
- 各项 hover/动效观感。
|
||||
|
||||
### 16.6 未做 / 可选后续
|
||||
- 是否 `git commit`(工作区改动含本次 + 上轮 v2.2,均未提交)。
|
||||
- 深色模式、缓存条颜色再打磨、其余「AI 味」细节。
|
||||
+352
-40
@@ -80,18 +80,7 @@
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse at 20% 0%,
|
||||
rgba(79, 110, 247, 0.04) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse at 80% 100%,
|
||||
rgba(139, 92, 246, 0.03) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
var(--bg);
|
||||
background: var(--bg);
|
||||
}
|
||||
.bg-shapes {
|
||||
position: fixed;
|
||||
@@ -118,7 +107,7 @@
|
||||
height: 420px;
|
||||
bottom: -10%;
|
||||
right: -8%;
|
||||
background: #8b5cf6;
|
||||
background: var(--accent);
|
||||
animation-delay: -6s;
|
||||
}
|
||||
.bg-shapes .s:nth-child(3) {
|
||||
@@ -1094,11 +1083,7 @@
|
||||
.about-hero {
|
||||
text-align: center;
|
||||
padding: 2.2rem 1.5rem 1.8rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--accent-light) 0%,
|
||||
rgba(139, 92, 246, 0.06) 100%
|
||||
);
|
||||
background: var(--accent-light);
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
}
|
||||
@@ -1291,21 +1276,13 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(79, 110, 247, 0.11),
|
||||
rgba(139, 92, 246, 0.08)
|
||||
);
|
||||
background: rgba(79, 110, 247, 0.10);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
transition: width 0.15s linear;
|
||||
}
|
||||
.mini-player:hover .mp-progress {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(79, 110, 247, 0.16),
|
||||
rgba(139, 92, 246, 0.12)
|
||||
);
|
||||
background: rgba(79, 110, 247, 0.16);
|
||||
}
|
||||
.mp-body {
|
||||
position: relative;
|
||||
@@ -1750,20 +1727,24 @@
|
||||
}
|
||||
|
||||
/* ========== v2 新增:频道选择器 / 导入卡片 / 播放中标记 ========== */
|
||||
.stream-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: rgba(255,255,255,.6) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M0 0l5 6 5-6z' fill='%23666'/></svg>") no-repeat right 8px center;
|
||||
border: 1px solid rgba(0,0,0,.08);
|
||||
.channel-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .3rem;
|
||||
max-width: 150px;
|
||||
padding: .3rem .7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 5px 26px 5px 12px;
|
||||
font-size: .78rem;
|
||||
color: #444;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-size: .74rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
max-width: 140px;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.stream-select:hover { background-color: rgba(255,255,255,.85); }
|
||||
.channel-btn:hover { background: var(--surface-hover); border-color: var(--accent); }
|
||||
.channel-btn i { color: var(--accent); font-size: .82rem; }
|
||||
.channel-btn span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.import-card {
|
||||
margin-top: 18px;
|
||||
@@ -1809,4 +1790,335 @@
|
||||
}
|
||||
@keyframes nowPulse { 0%,100% { opacity: 1; } 50% { opacity: .55; } }
|
||||
|
||||
.btn-icon.danger:hover { color: #f85149; }
|
||||
.btn-icon.danger:hover { color: #f85149; }
|
||||
/* ========== v2.1 新增:频道管理模态框 ========== */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(20, 22, 30, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9000;
|
||||
padding: 20px;
|
||||
}
|
||||
.modal-mask.show { display: flex; }
|
||||
.modal-box {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: 82vh;
|
||||
background: var(--surface);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: modalIn 0.22s ease;
|
||||
}
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: translateY(14px) scale(0.98); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.modal-head h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.modal-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.modal-close:hover { background: var(--bg); color: var(--text-primary); }
|
||||
.modal-body { padding: 14px 20px 20px; overflow-y: auto; }
|
||||
.stream-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
||||
.stream-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
.stream-row-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.stream-row-sub { font-size: 0.7rem; color: var(--text-muted); margin-top: 2px; }
|
||||
.stream-row-actions { display: flex; gap: 4px; }
|
||||
.stream-create {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.stream-create input {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
font-size: 0.82rem;
|
||||
outline: none;
|
||||
background: var(--surface);
|
||||
}
|
||||
.stream-create input:focus { border-color: var(--accent, #1f6feb); }
|
||||
.stream-create button {
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
background: var(--accent, #1f6feb);
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stream-create button:hover { filter: brightness(1.08); }
|
||||
|
||||
/* ========== v2.2 新增:站内收听页 ========== */
|
||||
.live-card {
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 22px;
|
||||
padding: 26px 24px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.07);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.live-now-playing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.live-cover-wrap {
|
||||
position: relative;
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.live-cover {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.live-cover-ph {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.4rem;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.live-badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #ff3b30;
|
||||
color: #fff;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.live-meta { min-width: 0; flex: 1; }
|
||||
.live-station {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent, #1f6feb);
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.live-now {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.live-sub {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.live-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.live-play-btn {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.live-play-btn:hover { background: var(--accent-hover); transform: scale(1.06); }
|
||||
.live-play-btn:active { transform: scale(0.96); }
|
||||
.live-progress-wrap { flex: 1; min-width: 0; }
|
||||
.live-progress-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.live-progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.live-status { font-size: 0.72rem; color: var(--text-muted); }
|
||||
.live-hint {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.live-hint b { color: var(--accent, #1f6feb); }
|
||||
@media (max-width: 480px) {
|
||||
.live-cover-wrap, .live-cover, .live-cover-ph { width: 84px; height: 84px; }
|
||||
.live-now { font-size: 1.05rem; }
|
||||
}
|
||||
|
||||
/* ========== v2.2 新增:设置页 ========== */
|
||||
.cache-stat { padding: 4px 0; }
|
||||
.cache-stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.86rem;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cache-stat-row b { color: #222; font-size: 0.95rem; }
|
||||
.cache-bar {
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.07);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cache-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
border-radius: 5px;
|
||||
background: var(--success);
|
||||
transition: width 0.4s ease, background 0.3s;
|
||||
}
|
||||
.cache-bar-fill.warn { background: var(--warning); }
|
||||
.cache-bar-fill.danger { background: var(--danger); }
|
||||
.cache-stat-note { font-size: 0.72rem; color: #999; margin-top: 8px; }
|
||||
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.config-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.config-item span { font-size: 0.72rem; color: #999; }
|
||||
.config-item b { font-size: 0.95rem; color: #222; }
|
||||
|
||||
.log-tail-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.78rem;
|
||||
color: #444;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.log-view {
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: #1e1e28;
|
||||
color: #d4d4d4;
|
||||
font-family: "Cascadia Code", "Consolas", monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ========== 资源页排序 ========== */
|
||||
.resource-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.resource-sort {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: 1.5px solid var(--border, rgba(0, 0, 0, 0.1));
|
||||
border-radius: var(--radius-sm, 9px);
|
||||
padding: 0.35rem 1.6rem 0.35rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
background: var(--bg, #fff) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M0 0l5 6 5-6z' fill='%23888'/></svg>") no-repeat right 7px center;
|
||||
color: var(--text-primary, #444);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.resource-sort:hover { border-color: var(--accent, #1f6feb); }
|
||||
+130
-11
@@ -12,6 +12,7 @@
|
||||
<link rel="stylesheet" href="vendor/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="vendor/bootstrap-icons.css" />
|
||||
<script src="vendor/Sortable.min.js"></script>
|
||||
<script src="vendor/hls.min.js"></script>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -37,7 +38,7 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<span class="cache-pill" id="cacheInfo"><i class="bi bi-database"></i> --</span>
|
||||
<select class="stream-select" id="streamSelect" title="切换频道"></select>
|
||||
<button class="channel-btn" id="btnStreamMgr" title="频道与切换"><i class="bi bi-broadcast"></i><span id="currentChannelName">主电台</span></button>
|
||||
<a href="#" id="liveLink" class="btn-link-sm" target="_blank" title="打开直播流"
|
||||
><i class="bi bi-link-45deg"></i></a
|
||||
>
|
||||
@@ -49,8 +50,39 @@
|
||||
|
||||
<!-- Pages -->
|
||||
<div class="page-container">
|
||||
<!-- 0. Live 收听 -->
|
||||
<div class="page active" id="pageLive">
|
||||
<div class="live-card">
|
||||
<div class="live-now-playing">
|
||||
<div class="live-cover-wrap">
|
||||
<img class="live-cover" id="liveCover" src="" alt="" />
|
||||
<div class="live-cover-ph" id="liveCoverPh">
|
||||
<i class="bi bi-broadcast"></i>
|
||||
</div>
|
||||
<span class="live-badge"><span class="live-dot"></span>LIVE</span>
|
||||
</div>
|
||||
<div class="live-meta">
|
||||
<div class="live-station" id="liveStation">主电台</div>
|
||||
<div class="live-now" id="liveNow">正在加载直播流...</div>
|
||||
<div class="live-sub" id="liveSub">--</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-controls">
|
||||
<button class="live-play-btn" id="livePlayBtn"><i class="bi bi-play-fill"></i></button>
|
||||
<div class="live-progress-wrap">
|
||||
<div class="live-progress-bar"><div class="live-progress-fill" id="liveProgressFill"></div></div>
|
||||
<div class="live-status" id="liveStatus">未连接</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-hint">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
点击播放按钮收听 <b id="liveHintStation">主电台</b> 的实时直播流
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. Search -->
|
||||
<div class="page active" id="pageSearch">
|
||||
<div class="page" id="pageSearch">
|
||||
<div class="search-card">
|
||||
<div class="search-header">
|
||||
<h2>导入音源</h2>
|
||||
@@ -152,14 +184,24 @@
|
||||
>0</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="resource-search"
|
||||
id="resourceSearch"
|
||||
placeholder="搜索已有音乐..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<div class="resource-tools">
|
||||
<select class="resource-sort" id="resourceSort" title="排序方式">
|
||||
<option value="created_desc">加入时间 ↓</option>
|
||||
<option value="created_asc">加入时间 ↑</option>
|
||||
<option value="heat_desc">热度 ↓</option>
|
||||
<option value="heat_asc">热度 ↑</option>
|
||||
<option value="name_asc">名称 ↑</option>
|
||||
<option value="name_desc">名称 ↓</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
class="resource-search"
|
||||
id="resourceSearch"
|
||||
placeholder="搜索已有音乐..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body" id="resourceList">
|
||||
<div class="empty-state">
|
||||
@@ -201,6 +243,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Settings -->
|
||||
<div class="page" id="pageSettings">
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-database" style="color: var(--warning)"></i>
|
||||
缓存管理
|
||||
</div>
|
||||
<button class="btn-clear" id="btnCleanup"><i class="bi bi-trash3"></i> 立即清理</button>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="cache-stat">
|
||||
<div class="cache-stat-row">
|
||||
<span>已用 / 上限</span>
|
||||
<b id="setCacheText">-- / --</b>
|
||||
</div>
|
||||
<div class="cache-bar"><div class="cache-bar-fill" id="setCacheBar"></div></div>
|
||||
<div class="cache-stat-note" id="setCacheNote">超限时自动按热度 LRU 淘汰</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-gear" style="color: var(--accent)"></i>
|
||||
运行配置
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="config-grid" id="setConfigGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-file-text" style="color: var(--success)"></i>
|
||||
运行日志
|
||||
</div>
|
||||
<select class="log-tail-select" id="logTail">
|
||||
<option value="50">50 行</option>
|
||||
<option value="100" selected>100 行</option>
|
||||
<option value="200">200 行</option>
|
||||
<option value="500">500 行</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<pre class="log-view" id="logView">加载中...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. About — 保持原有风格体系 -->
|
||||
<div class="page" id="pageAbout">
|
||||
<div class="about-card">
|
||||
@@ -342,6 +437,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 频道管理模态框 -->
|
||||
<div class="modal-mask" id="streamModal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-head">
|
||||
<h3><i class="bi bi-sliders"></i> 频道管理</h3>
|
||||
<button class="modal-close" id="streamModalClose" title="关闭"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="stream-list" id="streamMgrList"></div>
|
||||
<div class="stream-create">
|
||||
<input type="text" id="newStreamId" placeholder="频道 ID(字母数字_-,如 chill)" autocomplete="off" spellcheck="false" />
|
||||
<input type="text" id="newStreamName" placeholder="显示名称(选填)" autocomplete="off" />
|
||||
<button id="btnCreateStream"><i class="bi bi-plus-lg"></i> 新建频道</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mini Player — full background progress + drag seek -->
|
||||
<div class="mini-player" id="miniPlayer">
|
||||
<div class="mp-progress" id="mpProgress"></div>
|
||||
@@ -372,7 +485,10 @@
|
||||
<!-- Bottom Nav — 4 tabs, full-width aligned with content -->
|
||||
<nav class="bottom-nav">
|
||||
<div class="bottom-nav-inner">
|
||||
<button class="nav-tab active" data-page="pageSearch">
|
||||
<button class="nav-tab active" data-page="pageLive">
|
||||
<i class="bi bi-broadcast"></i><span>收听</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-page="pageSearch">
|
||||
<i class="bi bi-search"></i><span>搜索</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-page="pageResources">
|
||||
@@ -381,6 +497,9 @@
|
||||
<button class="nav-tab" data-page="pagePlaylist">
|
||||
<i class="bi bi-list-ul"></i><span>列表</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-page="pageSettings">
|
||||
<i class="bi bi-gear"></i><span>设置</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-page="pageAbout">
|
||||
<i class="bi bi-info-circle"></i><span>关于</span>
|
||||
</button>
|
||||
|
||||
+314
-21
@@ -59,13 +59,15 @@ function post(url, body) {
|
||||
// ==================== Navigation ====================
|
||||
const tabs = document.querySelectorAll(".nav-tab");
|
||||
const pages = document.querySelectorAll(".page");
|
||||
const pageOrder = ["pageSearch", "pageResources", "pagePlaylist", "pageAbout"];
|
||||
const pageOrder = ["pageLive", "pageSearch", "pageResources", "pagePlaylist", "pageSettings", "pageAbout"];
|
||||
function switchPage(pageId) {
|
||||
pages.forEach((p) => p.classList.remove("active"));
|
||||
tabs.forEach((t) => t.classList.remove("active"));
|
||||
const page = document.getElementById(pageId);
|
||||
if (page) page.classList.add("active");
|
||||
tabs.forEach((t) => { if (t.dataset.page === pageId) t.classList.add("active"); });
|
||||
if (pageId === "pageLive") { updateLiveUI(); loadLiveCover(); }
|
||||
if (pageId === "pageSettings") loadSettings();
|
||||
if (pageId === "pageResources") loadResources();
|
||||
if (pageId === "pagePlaylist") loadPlaylist();
|
||||
}
|
||||
@@ -93,23 +95,16 @@ async function loadStreams() {
|
||||
try {
|
||||
const r = await api("/api/streams");
|
||||
state.streams = r.list || [];
|
||||
const sel = document.getElementById("streamSelect");
|
||||
if (!state.streams.some((s) => s.id === currentStreamId)) {
|
||||
currentStreamId = state.streams[0] ? state.streams[0].id : "main";
|
||||
}
|
||||
sel.innerHTML = state.streams
|
||||
.map((s) => `<option value="${esc(s.id)}"${s.id === currentStreamId ? " selected" : ""}>${esc(s.name || s.id)}</option>`)
|
||||
.join("");
|
||||
const cur = state.streams.find((s) => s.id === currentStreamId);
|
||||
const nameEl = document.getElementById("currentChannelName");
|
||||
if (nameEl) nameEl.textContent = cur ? (cur.name || cur.id) : "主电台";
|
||||
document.getElementById("liveLink").href = cur ? cur.m3u8 : "/hls/main/index.m3u8";
|
||||
updateLiveUI();
|
||||
} catch (e) { console.error("加载流失败", e); }
|
||||
}
|
||||
document.getElementById("streamSelect").addEventListener("change", (e) => {
|
||||
currentStreamId = e.target.value;
|
||||
loadStreams();
|
||||
loadPlaylist();
|
||||
loadResources();
|
||||
});
|
||||
document.getElementById("liveLink").addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
window.open(e.currentTarget.href, "_blank");
|
||||
@@ -119,6 +114,91 @@ document.getElementById("btnLogout").addEventListener("click", async () => {
|
||||
window.location.href = "/login";
|
||||
});
|
||||
|
||||
// ==================== 频道管理 ====================
|
||||
const streamModal = document.getElementById("streamModal");
|
||||
function openStreamManager() {
|
||||
renderStreamMgr();
|
||||
streamModal.classList.add("show");
|
||||
}
|
||||
function closeStreamManager() {
|
||||
streamModal.classList.remove("show");
|
||||
}
|
||||
function renderStreamMgr() {
|
||||
const box = document.getElementById("streamMgrList");
|
||||
box.innerHTML = state.streams
|
||||
.map((s) => {
|
||||
const isMain = s.id === "main";
|
||||
const cur = s.id === currentStreamId ? ' <span class="now-badge">当前</span>' : "";
|
||||
const run = s.running ? "运行中" : "已停止";
|
||||
const delBtn = isMain
|
||||
? ""
|
||||
: '<button class="btn-icon danger js-delstream" title="删除频道"><i class="bi bi-trash3-fill"></i></button>';
|
||||
return `<div class="stream-row" data-id="${esc(s.id)}">
|
||||
<div class="stream-row-info">
|
||||
<div class="stream-row-name">${esc(s.name || s.id)}${cur}</div>
|
||||
<div class="stream-row-sub">ID: ${esc(s.id)} · ${run}</div>
|
||||
</div>
|
||||
<div class="stream-row-actions">
|
||||
<button class="btn-icon js-switch" title="切换到该频道"><i class="bi bi-box-arrow-in-right"></i></button>
|
||||
${delBtn}
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
box.querySelectorAll(".js-switch").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => switchStream(e.target.closest(".stream-row").dataset.id));
|
||||
});
|
||||
box.querySelectorAll(".js-delstream").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => deleteStreamById(e.target.closest(".stream-row").dataset.id));
|
||||
});
|
||||
}
|
||||
async function switchStream(id) {
|
||||
currentStreamId = id;
|
||||
if (livePlaying) stopLive();
|
||||
await loadStreams();
|
||||
await loadPlaylist();
|
||||
await loadResources();
|
||||
renderStreamMgr();
|
||||
showToast("已切换到频道: " + id, "success");
|
||||
}
|
||||
async function createStreamFromForm() {
|
||||
const idEl = document.getElementById("newStreamId");
|
||||
const nameEl = document.getElementById("newStreamName");
|
||||
const id = idEl.value.trim();
|
||||
const name = nameEl.value.trim();
|
||||
if (!id) return showToast("请输入频道 ID", "error");
|
||||
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(id)) return showToast("ID 仅允许字母数字_-,1~32位", "error");
|
||||
try {
|
||||
await post("/api/streams", { id, name: name || id });
|
||||
idEl.value = "";
|
||||
nameEl.value = "";
|
||||
await loadStreams();
|
||||
renderStreamMgr();
|
||||
showToast("已创建频道: " + id, "success");
|
||||
} catch (err) {
|
||||
showToast("创建失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
async function deleteStreamById(id) {
|
||||
if (!confirm("确定删除频道「" + id + "」?\n该频道的播放列表将一并删除。")) return;
|
||||
try {
|
||||
await api("/api/streams/" + encodeURIComponent(id), { method: "DELETE" });
|
||||
if (currentStreamId === id) currentStreamId = "main";
|
||||
await loadStreams();
|
||||
await loadPlaylist();
|
||||
renderStreamMgr();
|
||||
showToast("已删除频道: " + id, "success");
|
||||
} catch (err) {
|
||||
showToast("删除失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
document.getElementById("btnStreamMgr").addEventListener("click", openStreamManager);
|
||||
document.getElementById("streamModalClose").addEventListener("click", closeStreamManager);
|
||||
document.getElementById("btnCreateStream").addEventListener("click", createStreamFromForm);
|
||||
streamModal.addEventListener("click", (e) => {
|
||||
if (e.target === streamModal) closeStreamManager();
|
||||
});
|
||||
|
||||
// ==================== List Diff ====================
|
||||
function reconcileList(container, items, keyOf, create, update) {
|
||||
const nodes = Array.from(container.querySelectorAll("[data-key]"));
|
||||
@@ -184,6 +264,21 @@ function updateResItem(el, item) {
|
||||
if (c.textContent !== cs) c.textContent = cs;
|
||||
}
|
||||
let resourceKeyword = "";
|
||||
let resourceSort = "created_desc";
|
||||
|
||||
function sortResources(list) {
|
||||
const arr = list.slice();
|
||||
const byName = (a, b) => (a.title || a.file || "").localeCompare(b.title || b.file || "", "zh", { numeric: true });
|
||||
switch (resourceSort) {
|
||||
case "created_asc": arr.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0)); break;
|
||||
case "created_desc": arr.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); break;
|
||||
case "heat_asc": arr.sort((a, b) => (a.heat || 0) - (b.heat || 0)); break;
|
||||
case "heat_desc": arr.sort((a, b) => (b.heat || 0) - (a.heat || 0)); break;
|
||||
case "name_asc": arr.sort(byName); break;
|
||||
case "name_desc": arr.sort((a, b) => byName(b, a)); break;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
function applyResourceFilter() {
|
||||
document.getElementById("resourceList").querySelectorAll(".res-item").forEach((el) => {
|
||||
const t = (el.querySelector(".res-title")?.textContent || "").toLowerCase();
|
||||
@@ -194,7 +289,7 @@ function applyResourceFilter() {
|
||||
async function loadResources() {
|
||||
try {
|
||||
const r = await api("/api/resources");
|
||||
const resources = r.list || [];
|
||||
const resources = sortResources(r.list || []);
|
||||
state.resources = resources;
|
||||
const container = document.getElementById("resourceList");
|
||||
document.getElementById("resourceCount").textContent = resources.length;
|
||||
@@ -498,6 +593,27 @@ async function downloadMusic() {
|
||||
}
|
||||
|
||||
// ==================== Local Import ====================
|
||||
// XHR 上传(带进度回调),fetch 不支持上传进度
|
||||
function uploadWithProgress(url, formData, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", url);
|
||||
xhr.withCredentials = true;
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 401) { window.location.href = "/login"; return; }
|
||||
let data = null;
|
||||
try { data = JSON.parse(xhr.responseText); } catch (_) {}
|
||||
if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) resolve(data);
|
||||
else reject(new Error((data && data.error) || ("HTTP " + xhr.status)));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("网络错误"));
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
const importFileInput = document.getElementById("importFile");
|
||||
document.getElementById("btnImport").addEventListener("click", () => importFileInput.click());
|
||||
importFileInput.addEventListener("change", async () => {
|
||||
@@ -513,11 +629,10 @@ importFileInput.addEventListener("change", async () => {
|
||||
fd.append("title", title);
|
||||
fd.append("artist", artist);
|
||||
try {
|
||||
const res = await fetch("/api/music/import", { method: "POST", body: fd, credentials: "same-origin" });
|
||||
if (res.status === 401) { window.location.href = "/login"; return; }
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.error || "导入失败");
|
||||
prog.textContent = "导入成功,正在匹配元数据...";
|
||||
const data = await uploadWithProgress("/api/music/import", fd, (pct) => {
|
||||
prog.textContent = "上传中... " + pct + "%";
|
||||
});
|
||||
prog.textContent = "上传完成,正在匹配元数据...";
|
||||
showToast("已导入: " + data.file, "success");
|
||||
document.getElementById("importTitle").value = "";
|
||||
document.getElementById("importArtist").value = "";
|
||||
@@ -531,12 +646,22 @@ importFileInput.addEventListener("change", async () => {
|
||||
});
|
||||
|
||||
// ==================== SSE ====================
|
||||
// 节流刷新收听页(切歌/列表变化时)
|
||||
let liveRefreshTimer = null;
|
||||
function refreshLive() {
|
||||
if (liveRefreshTimer) return;
|
||||
liveRefreshTimer = setTimeout(() => {
|
||||
liveRefreshTimer = null;
|
||||
const pg = document.getElementById("pageLive");
|
||||
if (pg && pg.classList.contains("active")) loadLiveCover();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function connectSSE() {
|
||||
const es = new EventSource("/api/events");
|
||||
es.addEventListener("cache-status", () => { loadResources(); loadPlaylist(); });
|
||||
es.addEventListener("playlist-update", () => loadPlaylist());
|
||||
es.addEventListener("song-finished", () => loadPlaylist());
|
||||
es.addEventListener("now-playing", () => loadPlaylist());
|
||||
es.addEventListener("playlist-update", () => { loadPlaylist(); refreshLive(); });
|
||||
es.addEventListener("song-finished", () => { loadPlaylist(); refreshLive(); });
|
||||
es.onerror = () => { es.close(); setTimeout(connectSSE, 5000); };
|
||||
}
|
||||
|
||||
@@ -550,6 +675,10 @@ document.getElementById("resourceSearch").addEventListener("input", (e) => {
|
||||
resourceKeyword = e.target.value.trim().toLowerCase();
|
||||
applyResourceFilter();
|
||||
});
|
||||
document.getElementById("resourceSort").addEventListener("change", (e) => {
|
||||
resourceSort = e.target.value;
|
||||
loadResources();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
|
||||
@@ -574,12 +703,176 @@ document.addEventListener("touchend", (e) => {
|
||||
else if (dx > 0 && idx > 0) switchPage(pageOrder[idx - 1]);
|
||||
}, { passive: true });
|
||||
|
||||
// ==================== 站内收听(HLS.js) ====================
|
||||
const liveCover = document.getElementById("liveCover");
|
||||
const liveCoverPh = document.getElementById("liveCoverPh");
|
||||
const liveStation = document.getElementById("liveStation");
|
||||
const liveNow = document.getElementById("liveNow");
|
||||
const liveSub = document.getElementById("liveSub");
|
||||
const liveStatus = document.getElementById("liveStatus");
|
||||
const liveProgressFill = document.getElementById("liveProgressFill");
|
||||
const livePlayBtn = document.getElementById("livePlayBtn");
|
||||
const liveHintStation = document.getElementById("liveHintStation");
|
||||
|
||||
let hls = null;
|
||||
let livePlaying = false;
|
||||
|
||||
function currentStream() {
|
||||
return state.streams.find((s) => s.id === currentStreamId) || null;
|
||||
}
|
||||
|
||||
function setLivePlaying(on) {
|
||||
livePlaying = on;
|
||||
livePlayBtn.innerHTML = on
|
||||
? '<i class="bi bi-pause-fill"></i>'
|
||||
: '<i class="bi bi-play-fill"></i>';
|
||||
}
|
||||
|
||||
function updateLiveUI() {
|
||||
const st = currentStream();
|
||||
const name = st ? (st.name || st.id) : "主电台";
|
||||
liveStation.textContent = name;
|
||||
liveHintStation.textContent = name;
|
||||
}
|
||||
|
||||
async function loadLiveCover() {
|
||||
// 用当前频道播放列表第一首的封面作为"正在播放"占位
|
||||
try {
|
||||
const r = await api(`/api/streams/${currentStreamId}/playlist`);
|
||||
const cur = (r.list || []).find((x) => x.isCurrent) || (r.list || [])[0];
|
||||
if (cur && cur.albumImage) {
|
||||
liveCover.src = cur.albumImage;
|
||||
liveCover.style.display = "";
|
||||
liveCoverPh.style.display = "none";
|
||||
liveNow.textContent = cur.title || "直播中";
|
||||
liveSub.textContent = cur.artist || "--";
|
||||
} else {
|
||||
liveCover.style.display = "none";
|
||||
liveCoverPh.style.display = "";
|
||||
liveNow.textContent = "直播进行中";
|
||||
liveSub.textContent = "暂无曲目信息";
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function stopLive() {
|
||||
if (hls) { try { hls.destroy(); } catch (_) {} hls = null; }
|
||||
setLivePlaying(false);
|
||||
liveStatus.textContent = "已暂停";
|
||||
liveProgressFill.style.width = "0%";
|
||||
}
|
||||
|
||||
function startLive() {
|
||||
const st = currentStream();
|
||||
const url = st ? st.m3u8 : "/hls/main/index.m3u8";
|
||||
const audio = musicPlayer; // 复用全局 audio 元素
|
||||
audio.muted = false;
|
||||
stopLive();
|
||||
|
||||
const onPlaying = () => {
|
||||
setLivePlaying(true);
|
||||
liveStatus.textContent = "直播中";
|
||||
liveProgressFill.style.width = "100%";
|
||||
};
|
||||
const onErr = () => { liveStatus.textContent = "连接失败"; setLivePlaying(false); };
|
||||
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
hls = new Hls({ liveSyncDurationCount: 3, lowLatencyMode: false });
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(audio);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => { audio.play().catch(onErr); });
|
||||
hls.on(Hls.Events.ERROR, (e, data) => {
|
||||
if (data && data.fatal) { liveStatus.textContent = "直播流错误"; setLivePlaying(false); }
|
||||
});
|
||||
audio.addEventListener("playing", onPlaying, { once: true });
|
||||
} else if (audio.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
audio.src = url;
|
||||
audio.addEventListener("playing", onPlaying, { once: true });
|
||||
audio.play().catch(onErr);
|
||||
} else {
|
||||
liveStatus.textContent = "浏览器不支持 HLS";
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLive() {
|
||||
if (livePlaying) {
|
||||
musicPlayer.pause();
|
||||
setLivePlaying(false);
|
||||
liveStatus.textContent = "已暂停";
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
}
|
||||
|
||||
livePlayBtn.addEventListener("click", toggleLive);
|
||||
|
||||
// ==================== 设置页 ====================
|
||||
function fmtMB(b) { return (b / 1048576).toFixed(1) + " MB"; }
|
||||
|
||||
async function loadSettings() {
|
||||
// 缓存
|
||||
try {
|
||||
const c = await api("/api/system/cache");
|
||||
document.getElementById("setCacheText").textContent =
|
||||
fmtMB(c.total) + " / " + (c.limit / 1048576).toFixed(0) + " MB";
|
||||
const pct = Math.min(100, parseFloat(c.usedPercent) || 0);
|
||||
const bar = document.getElementById("setCacheBar");
|
||||
bar.style.width = pct + "%";
|
||||
bar.className = "cache-bar-fill" + (pct > 90 ? " danger" : pct > 70 ? " warn" : "");
|
||||
document.getElementById("setCacheNote").textContent =
|
||||
"已用 " + c.usedPercent + "%,超限时自动按热度 LRU 淘汰";
|
||||
} catch (e) { console.error("缓存信息失败", e); }
|
||||
|
||||
// 配置
|
||||
try {
|
||||
const r = await api("/api/system/config");
|
||||
const cf = r.config || {};
|
||||
const items = [
|
||||
["HTTP 端口", cf.HTTP_PORT],
|
||||
["切片时长", cf.HLS_TIME + " 秒"],
|
||||
["滑动窗口", cf.SLIDE_WINDOW_SIZE + " 段"],
|
||||
["缓存上限", (cf.MAX_CACHE_SIZE / 1048576).toFixed(0) + " MB"],
|
||||
["FFmpeg 并发", cf.MAX_FFMPEG_CONCURRENT + " 路"],
|
||||
];
|
||||
document.getElementById("setConfigGrid").innerHTML = items
|
||||
.map(([k, v]) => `<div class="config-item"><span>${esc(k)}</span><b>${esc(String(v))}</b></div>`)
|
||||
.join("");
|
||||
} catch (e) { console.error("配置失败", e); }
|
||||
|
||||
await loadLogs();
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
const tail = document.getElementById("logTail").value || 100;
|
||||
try {
|
||||
const r = await api("/api/system/logs?tail=" + tail);
|
||||
const lines = r.lines || [];
|
||||
const el = document.getElementById("logView");
|
||||
el.textContent = lines.length ? lines.join("\n") : "(暂无日志)";
|
||||
el.scrollTop = el.scrollHeight;
|
||||
} catch (e) {
|
||||
document.getElementById("logView").textContent = "日志加载失败: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("btnCleanup").addEventListener("click", async () => {
|
||||
if (!confirm("确定立即清理缓存?将按热度淘汰非播放中的切片。")) return;
|
||||
try {
|
||||
await post("/api/system/cache/cleanup");
|
||||
showToast("缓存已清理", "success");
|
||||
await loadSettings();
|
||||
await loadResources();
|
||||
} catch (e) { showToast("清理失败: " + e.message, "error"); }
|
||||
});
|
||||
document.getElementById("logTail").addEventListener("change", loadLogs);
|
||||
|
||||
// ==================== Init ====================
|
||||
(async function init() {
|
||||
await loadStreams();
|
||||
loadLiveCover();
|
||||
await loadResources();
|
||||
await loadPlaylist();
|
||||
loadCacheInfo();
|
||||
connectSSE();
|
||||
setInterval(() => { loadResources(); loadPlaylist(); loadCacheInfo(); }, 8000);
|
||||
setInterval(() => { loadResources(); loadPlaylist(); loadCacheInfo(); refreshLive(); }, 8000);
|
||||
})();
|
||||
@@ -14,12 +14,6 @@ if (!panelPassword) {
|
||||
generated = true;
|
||||
}
|
||||
|
||||
function getPassword() {
|
||||
return panelPassword;
|
||||
}
|
||||
function isGenerated() {
|
||||
return generated;
|
||||
}
|
||||
|
||||
function parseCookies(header) {
|
||||
const out = {};
|
||||
@@ -96,8 +90,6 @@ function initPasswordLog() {
|
||||
|
||||
module.exports = {
|
||||
COOKIE_NAME,
|
||||
getPassword,
|
||||
isGenerated,
|
||||
createSession,
|
||||
verifyLogin,
|
||||
getValidSession,
|
||||
|
||||
@@ -44,6 +44,13 @@ function getSegments(file) {
|
||||
function invalidateSegments(file) {
|
||||
segmentsCache.delete(file);
|
||||
}
|
||||
// 删除歌曲时彻底清理所有内存状态,避免同名文件重新导入后读到脏状态
|
||||
function forgetSong(file) {
|
||||
cacheStatus.delete(file);
|
||||
retryCount.delete(file);
|
||||
segmentsCache.delete(file);
|
||||
inFlight.delete(file);
|
||||
}
|
||||
|
||||
// 触发预生成(带去重与重试上限)
|
||||
// 说明:pending 表示“待生成”,不阻止入队;用 inFlight 防止重复入队
|
||||
@@ -436,6 +443,7 @@ module.exports = {
|
||||
setStatus,
|
||||
getSegments,
|
||||
invalidateSegments,
|
||||
forgetSong,
|
||||
triggerPreGenerate,
|
||||
initCaches,
|
||||
enforceCacheSizeLimit,
|
||||
|
||||
@@ -117,12 +117,6 @@ function loadSongSegments(file) {
|
||||
}
|
||||
|
||||
/* ==================== 预生成切片 ==================== */
|
||||
let onCacheReady = null;
|
||||
let onCacheError = null;
|
||||
function setCacheCallbacks({ ready, error }) {
|
||||
onCacheReady = ready;
|
||||
onCacheError = error;
|
||||
}
|
||||
|
||||
async function buildAudioArgs(filePath) {
|
||||
try {
|
||||
@@ -186,18 +180,15 @@ function doGenerate(filePath, file) {
|
||||
if (code === 0) {
|
||||
const size = getDirSize(cacheDir);
|
||||
logger.info(`[PRESEG] 完成: ${path.basename(filePath)}`);
|
||||
if (onCacheReady) onCacheReady(file, size);
|
||||
resolve({ size });
|
||||
} else {
|
||||
if (fs.existsSync(cacheDir)) fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
logger.error(`[PRESEG] 失败 code=${code}: ${path.basename(filePath)}`);
|
||||
if (onCacheError) onCacheError(file, stderrBuf);
|
||||
reject(new Error(`FFmpeg exit ${code}`));
|
||||
}
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
if (onCacheError) onCacheError(file, err.message);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
@@ -246,6 +237,5 @@ module.exports = {
|
||||
loadSongSegments,
|
||||
triggerPreGenerate,
|
||||
scanExistingCaches,
|
||||
setCacheCallbacks,
|
||||
killAllProcs,
|
||||
};
|
||||
+1
-2
@@ -7,7 +7,7 @@ const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
const store = require("./store");
|
||||
const media = require("./media");
|
||||
const { fileHash, sanitizeFileName, parseFileName, isAudioFile } = require("./util");
|
||||
const { fileHash, parseFileName, isAudioFile } = require("./util");
|
||||
|
||||
const NETEASE_UA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";
|
||||
@@ -205,5 +205,4 @@ module.exports = {
|
||||
matchNetEase,
|
||||
downloadCover,
|
||||
enrichSong,
|
||||
sanitizeFileName,
|
||||
};
|
||||
+2
-2
@@ -170,7 +170,7 @@ router.post("/delete", (req, res) => {
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
live.addCacheSize(-sz);
|
||||
}
|
||||
live.invalidateSegments(file);
|
||||
live.forgetSong(file);
|
||||
|
||||
// 3. 删 DB 记录(外键 CASCADE 会清掉 stream_playlist 引用)
|
||||
store.deleteSongByFile(file);
|
||||
@@ -203,7 +203,7 @@ router.post("/delete-batch", (req, res) => {
|
||||
live.addCacheSize(-getDirSize(cacheDir));
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
}
|
||||
live.invalidateSegments(file);
|
||||
live.forgetSong(file);
|
||||
store.deleteSongByFile(file);
|
||||
results.push({ file, ok: true });
|
||||
} catch (e) {
|
||||
|
||||
@@ -22,6 +22,7 @@ router.get("/", (req, res) => {
|
||||
duration: s.duration || null,
|
||||
fileSize: s.file_size || 0,
|
||||
heat: s.heat || 0,
|
||||
createdAt: s.created_at || 0,
|
||||
cacheStatus: live.getStatus(s.file),
|
||||
cacheSize: (() => {
|
||||
const d = path.join(config.HLS_CACHE_DIR, require("../util").fileHash(s.file));
|
||||
|
||||
@@ -20,7 +20,6 @@ function scanAudioFiles() {
|
||||
// 新增
|
||||
for (const f of files) {
|
||||
if (!existing.has(f)) {
|
||||
const parsed = meta.sanitizeFileName;
|
||||
const p = require("./util").parseFileName(f);
|
||||
store.upsertSong({ file: f, name: p.name, artist: p.artist });
|
||||
logger.info(`[SCAN] 新增资源: ${f}`);
|
||||
|
||||
@@ -71,14 +71,6 @@ function bootstrap() {
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use((req, res, next) => {
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.header("Access-Control-Allow-Credentials", "true");
|
||||
if (req.method === "OPTIONS") return res.sendStatus(200);
|
||||
next();
|
||||
});
|
||||
app.use(express.json());
|
||||
|
||||
// 登录页(静态)
|
||||
|
||||
+2
-8
@@ -88,9 +88,6 @@ function setStreamState(id, fields) {
|
||||
.prepare(`UPDATE streams SET ${set} WHERE id=?`)
|
||||
.run(...keys.map((k) => fields[k]), id);
|
||||
}
|
||||
function setStreamEnabled(id, enabled) {
|
||||
setStreamState(id, { enabled: enabled ? 1 : 0 });
|
||||
}
|
||||
|
||||
/* ==================== stream_playlist ==================== */
|
||||
function getPlaylistRows(streamId) {
|
||||
@@ -155,9 +152,6 @@ function getSession(token) {
|
||||
function deleteSession(token) {
|
||||
getDb().prepare("DELETE FROM sessions WHERE token=?").run(token);
|
||||
}
|
||||
function purgeExpiredSessions() {
|
||||
return getDb().prepare("DELETE FROM sessions WHERE expires_at < ?").run(Date.now()).changes;
|
||||
}
|
||||
|
||||
/* ==================== settings ==================== */
|
||||
function getSetting(key, def = null) {
|
||||
@@ -180,9 +174,9 @@ function setSetting(key, value) {
|
||||
module.exports = {
|
||||
getSongByFile, getSongById, getAllSongs, upsertSong, updateSong,
|
||||
deleteSongByFile, setCacheStatus, incrementHeat,
|
||||
createStream, listStreams, getStream, deleteStream, setStreamState, setStreamEnabled,
|
||||
createStream, listStreams, getStream, deleteStream, setStreamState,
|
||||
getPlaylistRows, playlistCount, addToPlaylist, removePlaylistEntry,
|
||||
setPlaylistOrder, clearPlaylist,
|
||||
createSession, getSession, deleteSession, purgeExpiredSessions,
|
||||
createSession, getSession, deleteSession,
|
||||
getSetting, setSetting,
|
||||
};
|
||||
+1
-1
@@ -34,7 +34,7 @@ st, body = call(auth, "/api/auth/login", "POST", {"password": "test123"}); check
|
||||
|
||||
# 资源与流
|
||||
st, body = call(auth, "/api/resources"); d = json.loads(body)
|
||||
check("资源列表5项", st == 200 and len(d["list"]) == 5, f"{len(d.get('list',[]))}项")
|
||||
check("资源列表非空", st == 200 and len(d["list"]) >= 1, f"{len(d.get('list',[]))}项")
|
||||
first = d["list"][0]["file"]
|
||||
|
||||
tid = "t%d" % int(time.time())
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ check("面板引用本地vendor", "vendor/bootstrap.min.css" in html and "cdn.js
|
||||
# 资源列表
|
||||
st, body = call("/api/resources")
|
||||
d = json.loads(body)
|
||||
check("资源列表", st == 200 and len(d["list"]) == 5, f"{len(d.get('list',[]))}")
|
||||
check("资源列表非空", st == 200 and len(d["list"]) >= 1, f"{len(d.get('list',[]))}")
|
||||
# 检查封面字段(可能为空,本地化后是 /covers/ 或空)
|
||||
covers = [x["albumImage"] for x in d["list"]]
|
||||
print(" 封面字段样例:", covers[:2])
|
||||
@@ -54,7 +54,7 @@ d = json.loads(body)
|
||||
check("主频道播放列表", st == 200 and "list" in d, f"{len(d.get('list',[]))}项")
|
||||
|
||||
# 检查 index.html 是否有 import/stream 元素
|
||||
check("HTML含频道选择器", 'id="streamSelect"' in html)
|
||||
check("HTML含频道按钮", 'id="btnStreamMgr"' in html and 'id="currentChannelName"' in html)
|
||||
check("HTML含导入卡片", 'id="importCard"' in html or 'id="importFile"' in html)
|
||||
check("HTML含登出按钮", 'id="btnLogout"' in html)
|
||||
|
||||
|
||||
+7
-2
@@ -21,6 +21,11 @@ def check(name, cond, extra=""):
|
||||
# 登录
|
||||
call("/api/auth/login", "POST", {"password": "test123"})
|
||||
|
||||
# ---------- 读基线资源数 ----------
|
||||
st, body0 = call("/api/resources")
|
||||
BASE_N = len(json.loads(body0)["list"])
|
||||
print(" 基线资源数:", BASE_N)
|
||||
|
||||
# ---------- 导入测试 ----------
|
||||
tmp_file = r"F:\music_online\demo\tmp\test_import.mp3"
|
||||
boundary = "----test" + uuid.uuid4().hex
|
||||
@@ -63,7 +68,7 @@ except urllib.error.HTTPError as e:
|
||||
# 确认资源数增加
|
||||
st, body = call("/api/resources")
|
||||
d = json.loads(body)
|
||||
check("资源数变为6", len(d["list"]) == 6, f"{len(d['list'])}")
|
||||
check(f"资源数变为{BASE_N+1}", len(d["list"]) == BASE_N + 1, f"{len(d['list'])}")
|
||||
|
||||
# ---------- 删除测试 ----------
|
||||
if imported_file:
|
||||
@@ -77,7 +82,7 @@ if imported_file:
|
||||
# 确认资源数恢复
|
||||
st, body = call("/api/resources")
|
||||
d = json.loads(body)
|
||||
check("资源数恢复为5", len(d["list"]) == 5, f"{len(d['list'])}")
|
||||
check(f"资源数恢复为{BASE_N}", len(d["list"]) == BASE_N, f"{len(d['list'])}")
|
||||
else:
|
||||
check("删除音频", False, "无导入文件")
|
||||
|
||||
|
||||
Reference in new issue
Block a user