diff --git a/vue/src/App.vue b/vue/src/App.vue index 0cd4ca1..cc5fb4a 100644 --- a/vue/src/App.vue +++ b/vue/src/App.vue @@ -191,6 +191,10 @@ ⚙️ TMDB 配置 + -
-
+
+
@@ -816,6 +836,16 @@ @cancel="showConfirmDialog = false" /> + + +
+
+ + +
+
+
+ @@ -848,6 +878,7 @@ import UserManageDialog from './components/UserManageDialog.vue' import DriveManageDialog from './components/DriveManageDialog.vue' import ConfirmDialog from './components/ConfirmDialog.vue' import IconManageDialog from './components/IconManageDialog.vue' +import SettingsView from './components/SettingsView.vue' import { batchScrapeAll, batchScrapeMovies, @@ -859,6 +890,7 @@ import { deleteMovieInDrive, deleteRecord, exportData, + fetchAllMovies, fetchBatchScrapeStatus, fetchDashboardData, fetchDriveList, @@ -1055,15 +1087,48 @@ const folderTreeData = computed(() => { return buildTree(rows) }) -// 海报展示数据(只包含文件类型,排除文件夹,显示所有网盘的文件) -const posterShowcaseRows = computed(() => { - // 获取所有网盘的所有行 - const allSheetsRows = flattenRows(rawData.value?.sheets || []) - // 根据搜索关键词过滤 - const filtered = filterRows(allSheetsRows, searchKeyword.value) - // 只保留文件类型,排除文件夹 - return filtered.filter(item => item.type !== 'folder') +// 海报展示数据(从后端获取,支持排序和分页) +const posterShowcaseData = ref([]) // 从后端获取的海报展示数据 +const loadingPosterShowcase = ref(false) // 海报展示加载状态 + +// 过滤后的海报展示数据(用于搜索) +const filteredPosterShowcaseData = computed(() => { + if (!searchKeyword.value.trim()) { + return posterShowcaseData.value + } + const keyword = searchKeyword.value.toLowerCase() + return posterShowcaseData.value.filter(item => { + const title = (item.title || '').toLowerCase() + const originalName = (item.originalName || '').toLowerCase() + const description = (item.description || '').toLowerCase() + return title.includes(keyword) || originalName.includes(keyword) || description.includes(keyword) + }) }) + +/** + * 加载海报展示数据(从后端获取所有电影) + * @param {string} sortBy - 排序字段:createTime/updateTime/rating/title + * @param {string} sortOrder - 排序方式:asc/desc + */ +async function loadPosterShowcase(sortBy = 'updateTime', sortOrder = 'desc') { + loadingPosterShowcase.value = true + try { + const result = await fetchAllMovies({ pageNo: 1, pageSize: 50, sortBy, sortOrder }) + if (result?.code === 200 && Array.isArray(result?.data?.list)) { + posterShowcaseData.value = result.data.list + console.log(`海报展示加载成功,共 ${result.data.list.length} 条`) + } else { + console.warn('海报展示返回数据异常', result) + posterShowcaseData.value = [] + } + } catch (err) { + console.error('加载海报展示失败', err) + posterShowcaseData.value = [] + } finally { + loadingPosterShowcase.value = false + } +} + const loginLogs = computed(() => rawData.value?.loginLogs || []) const logIntegrity = computed(() => loginLogs.value.length > 0) @@ -1102,16 +1167,36 @@ const hasValidFeaturedPosters = computed(() => { return featuredPosters.value.length > 0 && featuredPosters.value.some(p => p.posterUrl && p.posterUrl.trim() !== '') }) +/** + * 判断海报是否为横向图片(基于 URL 特征或宽高比) + * @param {string|object} poster - 海报 URL 或海报对象 + * @returns {boolean} + */ +function isWidePoster(poster) { + // 如果传入的是对象,取 posterUrl + const url = typeof poster === 'object' ? poster?.posterUrl : poster + + // 如果 URL 为空,不是横向海报 + if (!url || url.trim() === '') return false + + // 如果 URL 包含 backdrop 关键字,通常是横向背景图 + if (url.toLowerCase().includes('backdrop')) return true + + // 如果 URL 包含 wide、horizontal、landscape 等关键字 + if (url.toLowerCase().match(/wide|horizontal|landscape|banner/)) return true + + // 默认按竖向海报处理 + return false +} + +/** + * 构建大海报样式(不再使用 background-image,改用 img 标签) + * @param {object} poster - 海报对象 { id, title, posterUrl, ... } + * @returns {object} Vue 样式对象 + */ function buildHeroStyle(poster) { - // 当没有有效海报数据时,使用"暂无新片上映"默认海报 - if (!poster || poster.trim() === '') { - return { - backgroundImage: `url('/暂无新片上映海报生成.png')`, - } - } - return { - backgroundImage: `linear-gradient(180deg, rgba(8, 15, 29, 0.1), rgba(8, 15, 29, 0.82)), url(${poster})`, - } + // 返回空对象,因为图片现在由 标签处理 + return {} } function buildSubThumbStyle(poster) { @@ -1829,6 +1914,14 @@ function handleTmdbConfig() { showTmdbDialog.value = true } +// 设置页面 +const showSettingsView = ref(false) + +function handleSettings() { + showUserMenu.value = false + showSettingsView.value = true +} + // 修改头像 function handleChangeAvatar() { showUserMenu.value = false @@ -2111,9 +2204,11 @@ async function doDeleteNode(node) { /** * 切换到海报展示视图 */ -function switchToPoster() { +async function switchToPoster() { showUserMenu.value = false currentView.value = 'poster' + // 从后端加载海报展示数据 + await loadPosterShowcase() } /** @@ -2951,10 +3046,11 @@ onMounted(() => { window.addEventListener('wangpan:session-expiring', handleSessionExpiring) document.addEventListener('click', handleClickOutside) - // 并行加载仪表盘数据和每日推荐 + // 并行加载仪表盘数据、每日推荐和海报展示数据 Promise.all([ loadDashboard(), loadRecommendMovies(), + loadPosterShowcase(), ]).catch(err => { console.error('初始化加载失败', err) }) diff --git a/vue/src/api/index.js b/vue/src/api/index.js index 8cb67d8..cb3fe29 100644 --- a/vue/src/api/index.js +++ b/vue/src/api/index.js @@ -475,7 +475,7 @@ export function createMovieInDrive(driveId, payload) { * 更新电影信息 * @param {string|number} driveId - 网盘ID * @param {string|number} movieId - 电影ID - * @param {Object} payload - 可选字段:title, originalName, subtitle, description, rating, tags, category + * @param {Object} payload - 可选字段:title, originalName, subtitle, description, rating, tags, category, directors, casts * @returns {Promise} */ export function updateMovieInDrive(driveId, movieId, payload) { @@ -487,6 +487,8 @@ export function updateMovieInDrive(driveId, movieId, payload) { if (payload.rating !== undefined) body.rating = payload.rating if (payload.tags !== undefined) body.tags = payload.tags if (payload.category !== undefined) body.category = payload.category + if (payload.directors !== undefined) body.directors = payload.directors + if (payload.casts !== undefined) body.casts = payload.casts return request(`${API_BASE_URL}/drive/${driveId}/movies/${movieId}`, { method: 'PUT', @@ -720,6 +722,30 @@ export function fetchScraperLogs(params = {}) { }) } +// ==================== 获取所有电影(海报展示用) ==================== + +/** + * 获取所有电影列表(用于海报展示页) + * @param {Object} params - { pageNo, pageSize, sortBy, sortOrder } + * @param {number} [params.pageNo=1] - 页码 + * @param {number} [params.pageSize=50] - 每页条数,最多50 + * @param {string} [params.sortBy=createTime] - 排序字段:createTime/updateTime/rating/title + * @param {string} [params.sortOrder=desc] - 排序方式:asc/desc + * @returns {Promise} 返回格式:{ code: 200, data: { total, list: [{ id, title, posterUrl, ... }], pageNo, pageSize } } + */ +export function fetchAllMovies(params = {}) { + const searchParams = new URLSearchParams() + if (params.pageNo) searchParams.set('pageNo', String(params.pageNo)) + if (params.pageSize) searchParams.set('pageSize', String(Math.min(params.pageSize, 50))) // 最多50条 + if (params.sortBy) searchParams.set('sortBy', params.sortBy) + if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder) + + const query = searchParams.toString() + return request(`${API_BASE_URL}/movies/all${query ? `?${query}` : ''}`, { + method: 'GET', + }) +} + // ==================== 批量刮削 API ==================== /** @@ -804,3 +830,51 @@ export async function fetchBatchScrapeStatus(movieIds) { data: results.filter(Boolean), } } + +// ==================== OpenList 绑定 API ==================== + +/** + * 查询 OpenList 绑定信息 + * @returns {Promise} 返回格式:{ code: 200, data: { id, openlistUrl, openlistUsername, storageId, mountPath, status, tokenValid, createTime, updateTime } } 或 null + */ +export function getOpenListInfo() { + return request(`${API_BASE_URL}/openlist/info`, { + method: 'GET', + }) +} + +/** + * 绑定 OpenList 后端接口 + * @param {Object} payload - { url, username, password } + * @returns {Promise} 返回格式:{ code: 200, message: "绑定成功" } + */ +export function bindOpenList(payload) { + return request(`${API_BASE_URL}/openlist/bind`, { + method: 'POST', + body: JSON.stringify({ + url: payload.url, + username: payload.username, + password: payload.password, + }), + }) +} + +/** + * 查询 OpenList 绑定状态 + * @returns {Promise} 返回格式:{ code: 200, data: { bound: boolean, url?: string, username?: string, boundAt?: string } } + */ +export function fetchOpenListStatus() { + return request(`${API_BASE_URL}/openlist/status`, { + method: 'GET', + }) +} + +/** + * 解除 OpenList 绑定 + * @returns {Promise} 返回格式:{ code: 200, message: "已解除绑定" } + */ +export function unbindOpenList() { + return request(`${API_BASE_URL}/openlist/unbind`, { + method: 'POST', + }) +} \ No newline at end of file diff --git a/vue/src/components/PosterCard.vue b/vue/src/components/PosterCard.vue index 2df5255..2a13859 100644 --- a/vue/src/components/PosterCard.vue +++ b/vue/src/components/PosterCard.vue @@ -1,31 +1,92 @@ + \ No newline at end of file diff --git a/vue/src/components/SettingsView.vue b/vue/src/components/SettingsView.vue index b698d37..324284b 100644 --- a/vue/src/components/SettingsView.vue +++ b/vue/src/components/SettingsView.vue @@ -121,8 +121,28 @@ {{ boundInfo.username }}
- 绑定时间: - {{ boundInfo.boundAt }} + 存储ID: + {{ boundInfo.storageId || '-' }} +
+
+ 挂载路径: + {{ boundInfo.mountPath || '-' }} +
+
+ 状态: + {{ boundInfo.status === 'active' ? '正常' : (boundInfo.status || '-') }} +
+
+ Token有效: + {{ boundInfo.tokenValid ? '✅ 是' : '❌ 否' }} +
+
+ 创建时间: + {{ formatTime(boundInfo.createTime) }} +
+
+ 更新时间: + {{ formatTime(boundInfo.updateTime) }}