From 70bb6e104075792a61910c6af3392d59dad24748 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 8 Aug 2026 12:56:22 +0800 Subject: [PATCH] feat(music): add MusicAlbumView, MusicArtistView, and MusicDetailView components - Implement MusicAlbumView for displaying album details, including tracks and artist links. - Create MusicArtistView to show artist information and their albums. - Develop MusicDetailView for individual music tracks, integrating album details and related artists. - Introduce MusicDetailLayout for consistent layout across music-related views. - Add MusicArtistSlideView for displaying related artists in a slide format. - Enhance user experience with loading states and error handling in NoDataFound component. --- .github/workflows/test.yml | 6 +- docs/code-quality.md | 16 +- docs/testing.md | 6 +- src/api/constants.ts | 4 + src/api/types.ts | 155 ++++++++- src/components/cards/MediaServerCard.vue | 11 +- src/components/cards/MusicArtistCard.vue | 66 ++++ src/components/cards/MusicCard.vue | 223 ++++++++++++ src/components/cards/SubscribeCard.vue | 42 ++- .../cards/__tests__/SubscribeCard.spec.ts | 12 + .../dialog/MediaServerInfoDialog.vue | 86 +++++ src/components/music/MusicTrackList.vue | 111 ++++++ src/locales/en-US.ts | 61 +++- src/locales/zh-CN.ts | 55 ++- src/locales/zh-TW.ts | 63 +++- src/pages/__tests__/music-album.spec.ts | 164 +++++++++ src/pages/__tests__/music-artist.spec.ts | 93 +++++ src/pages/__tests__/music-detail.spec.ts | 194 +++++++++-- src/pages/__tests__/music.spec.ts | 171 ++++++--- src/pages/music-album.vue | 18 + src/pages/music-artist.vue | 18 + src/pages/music-detail.vue | 146 +------- src/pages/music.vue | 167 +-------- src/router/index.ts | 14 + src/utils/__tests__/music.spec.ts | 115 +++++++ src/utils/music.ts | 69 +++- .../dashboard/AnalyticsMediaStatistic.vue | 16 +- .../__tests__/AnalyticsMediaStatistic.spec.ts | 14 +- src/views/discover/MusicAlbumView.vue | 317 +++++++++++++++++ src/views/discover/MusicArtistSlideView.vue | 88 +++++ src/views/discover/MusicArtistView.vue | 226 ++++++++++++ src/views/discover/MusicDetailLayout.vue | 279 +++++++++++++++ src/views/discover/MusicDetailView.vue | 324 ++++++++++++++++++ src/views/discover/MusicView.vue | 155 ++++++++- .../discover/__tests__/MusicView.spec.ts | 63 +++- tests/config/frontend-workflow.spec.ts | 10 +- 36 files changed, 3084 insertions(+), 494 deletions(-) create mode 100644 src/components/cards/MusicArtistCard.vue create mode 100644 src/components/cards/MusicCard.vue create mode 100644 src/components/music/MusicTrackList.vue create mode 100644 src/pages/__tests__/music-album.spec.ts create mode 100644 src/pages/__tests__/music-artist.spec.ts create mode 100644 src/pages/music-album.vue create mode 100644 src/pages/music-artist.vue create mode 100644 src/utils/__tests__/music.spec.ts create mode 100644 src/views/discover/MusicAlbumView.vue create mode 100644 src/views/discover/MusicArtistSlideView.vue create mode 100644 src/views/discover/MusicArtistView.vue create mode 100644 src/views/discover/MusicDetailLayout.vue create mode 100644 src/views/discover/MusicDetailView.vue diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 070dda62..aa2a32e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,7 +62,7 @@ jobs: - name: Lint run: yarn lint - typecheck-and-coverage: + typecheck-and-tests: runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -82,5 +82,5 @@ jobs: - name: Typecheck run: yarn typecheck - - name: Unit tests with coverage - run: yarn test:coverage + - name: Unit tests + run: yarn test:run diff --git a/docs/code-quality.md b/docs/code-quality.md index 456be918..2f128673 100644 --- a/docs/code-quality.md +++ b/docs/code-quality.md @@ -9,7 +9,7 @@ | ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 | | Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 | | TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 | -| Vitest | 单元测试、组件测试和覆盖率门槛 | 生产构建与真实浏览器行为 | +| Vitest | 单元测试、组件测试和按需覆盖率报告 | 生产构建与真实浏览器行为 | | Vite | 生产构建和构建期集成验证 | 类型完整性和代码质量规则 | ESLint 配置由仓库显式维护,不继承 Antfu 等覆盖面较大的个人风格预设。JavaScript、TypeScript 与 Vue 使用各自面向正确性的 recommended/essential 基线;SonarJS 不整包展开 recommended,而只显式启用安全和正确性的高信号规则,避免插件升级隐式扩大检查范围。 @@ -22,7 +22,7 @@ Prettier 独立运行,ESLint 中与格式重叠的规则保持关闭。任何 - 最低兼容目标为 Node.js 20.19;Node.js 20.19、22 和 24 的开发环境可以安装依赖并运行项目命令。 - 推荐开发环境和 CI 主环境使用 Node.js 24。 -- CI 若继续声明兼容 Node.js 20.19,应至少在该版本验证 frozen lockfile 安装、lint、typecheck 和 build;完整覆盖率门禁在 Node.js 24 执行。 +- CI 若继续声明兼容 Node.js 20.19,应至少在该版本验证 frozen lockfile 安装、lint、typecheck 和 build;主测试 job 使用 Node.js 24 执行单元测试。 - Yarn 1 与 `yarn.lock` 继续作为依赖安装事实源,CI 使用 `yarn --frozen-lockfile`。 - Node 只用于依赖安装、开发、测试和前端构建;MoviePilot 正式 Docker 镜像使用预构建前端产物,不因开发工具链升级而增加 Node 运行时。 @@ -60,7 +60,7 @@ ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业 5. 只检查 JavaScript、TypeScript、Vue 与相关测试文件;Markdown、JSON、YAML、TOML、构建产物和生成文件不在首阶段扩展范围内。 6. 默认 lint 只读,并提供单独的 `lint:fix`。 7. 对确有价值但存在存量的问题使用明确基线或分阶段启用,不通过全仓自动修复制造大面积混合变更。 -8. 项目边界与新增问题门禁使用 Vitest 配置契约测试保护,并随现有 `test:coverage` CI 自动执行。 +8. 项目边界与新增问题门禁使用 Vitest 配置契约测试保护,并随现有 `test:run` CI 自动执行。 `eslint-suppressions.json` 只冻结迁移时已经确认的文件、规则和数量。新增问题不得加入 baseline;修复存量问题后运行 `yarn lint:suppressions:prune`,并把裁剪结果与代码修复一同提交。日常开发和 CI 不得使用 `--suppress-all`。 @@ -70,7 +70,7 @@ ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业 yarn --frozen-lockfile yarn lint yarn typecheck -yarn test:coverage +yarn test:run yarn build ``` @@ -80,7 +80,7 @@ yarn build 第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job: -1. lint 与 `typecheck-and-coverage` 使用不同 job,保持静态检查和测试覆盖率职责独立。 +1. lint 与 `typecheck-and-tests` 使用不同 job,保持静态检查和单元测试职责独立。 2. 初始阶段作为普通 check 运行,不立即配置 required check。 3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。 4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。 @@ -135,7 +135,7 @@ Prettier 3.9.5 基础设施接入时,全仓只读检查在 `v2` 基线报告 2 - 全仓 `yarn format:all:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。 - 最近的活跃分支已合并或完成同步,避免集中格式变化制造冲突。 -- `yarn lint`、`yarn typecheck`、`yarn test:coverage` 和 `yarn build` 在格式收敛后全部通过。 +- `yarn lint`、`yarn typecheck`、`yarn test:run` 和 `yarn build` 在格式收敛后全部通过。 - Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。 - workflow 已以非 required 状态稳定运行多个 PR。 @@ -146,7 +146,7 @@ yarn --frozen-lockfile yarn format:all:check yarn lint yarn typecheck -yarn test:coverage +yarn test:run yarn build ``` @@ -159,7 +159,7 @@ PR-Agent、编辑器诊断和人工 review 可以补充判断,但不能替代 1. 只对本次新增或修改的文件执行 Prettier。 2. 运行全仓只读 lint。 3. 运行 typecheck。 -4. 按影响面运行聚焦测试;提交 PR 前运行覆盖率门禁。 +4. 按影响面运行聚焦测试;覆盖率报告按需本地运行,不作为 Actions 门禁。 5. 涉及构建配置、依赖、Vue SFC 或模块联邦时运行生产 build。 编辑器设置不得成为通过验证的前提。文档、Agent 指令和 PR 说明应引用 `yarn` 命令,而不是“在 VS Code 保存一次”或依赖某个 IDE 扩展完成修复。 diff --git a/docs/testing.md b/docs/testing.md index 7f2a6605..11e0c2e8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -72,13 +72,13 @@ tests/ 3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/.ts` 增加对应 handler。 4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。 5. 核心覆盖范围发生变化时,同步更新 `vite.config.ts` 的 `coverage.include`。 -6. 提交前运行测试、覆盖率、类型检查、lint 和生产构建。 +6. 提交前按影响面运行测试、类型检查、lint 和生产构建;覆盖率报告按需本地执行。 ## 配置边界 Vitest 收集 `src/**/__tests__/**/*.spec.ts` 和 `tests/config/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。配置契约测试随全量测试执行,但不加入业务源码覆盖率统计范围。 -当前核心覆盖范围在 `vite.config.ts` 的 `coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%,Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%,Branches 不低于 75%。覆盖率报告写入 `coverage/`。 +当前核心覆盖范围在 `vite.config.ts` 的 `coverage.include` 中显式维护,覆盖率报告写入 `coverage/`。覆盖率配置保留给本地质量分析,不作为 GitHub Actions 的单元测试门禁。 ## 命令与 CI @@ -92,4 +92,4 @@ yarn lint yarn build ``` -`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile,在面向 `v3` 的 Pull Request 和推送到 `v3` 时运行。`typecheck-and-coverage` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查。变更文件格式检查依赖 Pull Request 的 base/head SHA,因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。 +`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile,在面向 `v3` 的 Pull Request 和推送到 `v3` 时运行。`typecheck-and-tests` job 执行类型检查和单元测试(`yarn test:run`),不要求覆盖率达标;覆盖率可按需在本地运行 `yarn test:coverage`。独立的 `lint` job 执行全仓只读 ESLint 检查。变更文件格式检查依赖 Pull Request 的 base/head SHA,因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。 diff --git a/src/api/constants.ts b/src/api/constants.ts index 1fc732a8..dc115036 100644 --- a/src/api/constants.ts +++ b/src/api/constants.ts @@ -97,6 +97,10 @@ export const mediaServerOptions = [ value: 'ugreen', title: i18n.global.t('setting.system.ugreen'), }, + { + value: 'navidrome', + title: i18n.global.t('setting.system.navidrome'), + }, ] export const mediaServerDict = mediaServerOptions.reduce( diff --git a/src/api/types.ts b/src/api/types.ts index b7d2f53f..05640630 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -440,10 +440,18 @@ export interface MediaInfo { artists?: string[] // 音乐艺术家展示文本 artist?: string + // 音乐实体类型:recording 单曲、album 专辑、artist 艺术家 + music_type?: MusicEntityType + // 音乐艺术家标准ID,顺序与 artists 一致 + artist_ids?: string[] // 专辑 album?: string // 专辑艺术家 album_artist?: string + // 所属专辑标准ID + album_id?: string + // 专辑主类型:Album、EP、Single 等 + album_type?: string // 发行版本 version?: string // 音轨号 @@ -462,6 +470,145 @@ export interface MediaInfo { listen_count?: number } +// 音乐可浏览实体类型 +export type MusicEntityType = 'recording' | 'album' | 'artist' + +// 音乐专辑下的单个发行版本 +export interface MusicRelease { + // 发行版本标准ID + media_id?: string + // 发行版本名称 + title?: string + // 发行日期 + date?: string + // 发行年份 + year?: number + // 发行地区 + country?: string + // 发行状态:Official、Promotion 等 + status?: string + // 包装形式 + packaging?: string + // 介质格式:CD、Vinyl 等 + formats?: string[] + // 音轨总数 + track_count?: number + // 封面 + cover_url?: string +} + +// 音乐专辑详情 +export interface MusicAlbumInfo { + // 类型,固定为音乐 + type?: string + // 实体类型,固定为 album + music_type?: MusicEntityType + // 来源 + source?: string + // 专辑标准ID + media_id?: string + // 专辑名称 + title?: string + // 艺术家列表 + artists?: string[] + // 艺术家展示文本 + artist?: string + // 艺术家标准ID,顺序与 artists 一致 + artist_ids?: string[] + // 专辑名称,与 title 一致,便于复用音乐展示组件 + album?: string + // 专辑主类型 + album_type?: string + // 专辑副类型 + secondary_types?: string[] + // 首次发行年份 + year?: number + // 首次发行日期 + release_date?: string + // 音轨总数 + total_tracks?: number + // 专辑总时长(秒) + duration?: number + // 封面 + cover_url?: string + // 风格 + genres?: string[] + // 标签 + tags?: string[] + // 主类型与副类型组合文本 + category?: string + // 10 分制评分 + rating?: number + // 评分人数 + rating_votes?: number + // 详情页面 + detail_link?: string + // 专辑内的音乐 + tracks?: MediaInfo[] + // 同一专辑的其它发行版本 + releases?: MusicRelease[] + // 海报 + poster_path?: string + // 背景图 + backdrop_path?: string + // 摘要 + overview?: string +} + +// 音乐艺术家详情 +export interface MusicArtistInfo { + // 类型,固定为音乐 + type?: string + // 实体类型,固定为 artist + music_type?: MusicEntityType + // 来源 + source?: string + // 艺术家标准ID + media_id?: string + // 艺术家名称 + name?: string + // 名称,与 name 一致,便于复用通用展示组件 + title?: string + // 排序名称 + sort_name?: string + // 消歧义说明 + disambiguation?: string + // 艺术家类型:Person、Group 等 + artist_type?: string + // 性别 + gender?: string + // 国家代码 + country?: string + // 地区 + area?: string + // 出道或成立日期 + begin_date?: string + // 解散或去世日期 + end_date?: string + // 是否已结束活动 + ended?: boolean + // 活跃时间区间文本 + life_span?: string + // 风格 + genres?: string[] + // 标签 + tags?: string[] + // 别名 + aliases?: string[] + // 与当前艺术家的关系文本 + relation?: string + // 艺术家图片 + image_url?: string + // 详情页面 + detail_link?: string + // 外部站点链接 + external_links?: Record + // 海报 + poster_path?: string + // 摘要 + overview?: string +} + // 季信息 export interface MediaSeason { // 上映日期 @@ -1139,8 +1286,10 @@ export interface MediaStatistic { tv_count: number // 电视剧总集数,未获取时为 null episode_count: number | null - // 用户数量 - user_count: number + // 音乐总数 + music_count: number + // 用户数量,仪表板已改为展示音乐数量,仅保留接口字段兼容 + user_count?: number // 本月新增电影数量 movie_count_month: number // 本月新增电视剧数量 @@ -1484,7 +1633,7 @@ export interface StorageConf { export interface MediaServerConf { // 名称 name: string - // 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen + // 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen/navidrome type: string // 配置 config: { [key: string]: any } diff --git a/src/components/cards/MediaServerCard.vue b/src/components/cards/MediaServerCard.vue index 7335b67c..56681454 100644 --- a/src/components/cards/MediaServerCard.vue +++ b/src/components/cards/MediaServerCard.vue @@ -48,8 +48,8 @@ const infoItems = ref([ amount: '0', }, { - avatar: 'mdi-account', - title: t('common.user'), + avatar: 'mdi-music-box-multiple', + title: t('mediaType.music'), amount: '0', }, ]) @@ -87,6 +87,7 @@ const getIcon = computed(() => { case 'plex': return getLogoUrl('plex') default: + // Navidrome 等未内置图标的服务器统一回退到通用媒体服务器图标 return getLogoUrl('mediaserver') } }) @@ -118,9 +119,9 @@ async function loadMediaStatistic() { amount: res.tv_count.toLocaleString(), }, { - avatar: 'mdi-account', - title: t('common.user'), - amount: res.user_count.toLocaleString(), + avatar: 'mdi-music-box-multiple', + title: t('mediaType.music'), + amount: (res.music_count || 0).toLocaleString(), }, ] } diff --git a/src/components/cards/MusicArtistCard.vue b/src/components/cards/MusicArtistCard.vue new file mode 100644 index 00000000..f95b475a --- /dev/null +++ b/src/components/cards/MusicArtistCard.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/src/components/cards/MusicCard.vue b/src/components/cards/MusicCard.vue new file mode 100644 index 00000000..d6c63ca7 --- /dev/null +++ b/src/components/cards/MusicCard.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/src/components/cards/SubscribeCard.vue b/src/components/cards/SubscribeCard.vue index e4028ed1..956b609f 100644 --- a/src/components/cards/SubscribeCard.vue +++ b/src/components/cards/SubscribeCard.vue @@ -12,6 +12,7 @@ import { useGlobalSettingsStore } from '@/stores' import { openSharedDialog } from '@/composables/useSharedDialog' import { getDisplayImageUrl } from '@/utils/imageUtils' import { buildMusicDetailRoute } from '@/utils/music' +import noImage from '@images/no-image.jpeg' const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue')) const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue')) @@ -57,6 +58,10 @@ const $toast = useToast() // 图片是否加载完成 const imageLoaded = ref(false) +// 背景图或海报加载失败时使用统一占位图,避免订阅卡片留下空白图片区。 +const backdropLoadError = ref(false) +const posterLoadError = ref(false) + // 当前的订阅状态 const subscribeState = ref(props.media?.state ?? 'P') @@ -155,6 +160,17 @@ function imageLoadHandler() { imageLoaded.value = true } +// 背景图加载失败后直接切换占位图,避免同一失效地址在 poster fallback 中重复请求。 +function backdropErrorHandler() { + backdropLoadError.value = true + imageLoaded.value = true +} + +// 海报加载失败后使用占位图,保留卡片布局和可点击区域。 +function posterErrorHandler() { + posterLoadError.value = true +} + // 进度条 model 段百分比:洗版订阅表示"已洗版"占比(亮段),普通订阅表示"已下载"占比 function getPercentage() { const total = props.media?.total_episode || 0 @@ -395,15 +411,25 @@ watch( }, ) +// 切换订阅记录时重新尝试加载图片,避免复用卡片组件后沿用旧的失败状态。 +watch( + () => [props.media?.id, props.media?.backdrop, props.media?.poster], + () => { + imageLoaded.value = false + backdropLoadError.value = false + posterLoadError.value = false + }, +) + // 计算backdrop图片地址 const backdropUrl = computed(() => { - const url = props.media?.backdrop || props.media?.poster + const url = backdropLoadError.value ? noImage : props.media?.backdrop || props.media?.poster || noImage return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE) }) // 计算海报图片地址 const posterUrl = computed(() => { - const url = props.media?.poster + const url = posterLoadError.value ? noImage : props.media?.poster || noImage return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE) }) @@ -484,7 +510,14 @@ function handleCardClick() {