diff --git a/src/components/TransformMedia.vue b/src/components/TransformMedia.vue
index afbbd80..31ba00d 100644
--- a/src/components/TransformMedia.vue
+++ b/src/components/TransformMedia.vue
@@ -162,6 +162,10 @@ export default {
const el = this.$refs.videoEl || this.$refs.audioEl;
if (!el) return;
+ // 重置重试计数器
+ this.retryCount = 0;
+ this.maxRetries = 3;
+
// 音频不需要全屏按钮
const controls = this.isAudio
? ['play', 'progress', 'current-time', 'mute', 'volume']
@@ -173,6 +177,22 @@ export default {
resetOnEnd: true,
});
+ // 监听加载错误,自动重试
+ el.addEventListener('error', () => {
+ if (this.retryCount < this.maxRetries) {
+ this.retryCount++;
+ console.warn(`Media load failed, retrying (${this.retryCount}/${this.maxRetries})...`);
+ setTimeout(() => {
+ if (el && this.src && this.player) {
+ const baseSrc = this.src.split('?_retry=')[0];
+ const retrySrc = baseSrc + (baseSrc.includes('?') ? '&' : '?') + '_retry=' + Date.now();
+ el.src = retrySrc;
+ el.load();
+ }
+ }, 500 * this.retryCount);
+ }
+ });
+
// 等待 Plyr ready 后添加自定义菜单
this.player.on('ready', () => {
this.tryAddCustomMenu();
diff --git a/src/views/PublicBrowse.vue b/src/views/PublicBrowse.vue
index 802577a..b48f0b2 100644
--- a/src/views/PublicBrowse.vue
+++ b/src/views/PublicBrowse.vue
@@ -15,6 +15,25 @@
@@ -120,6 +139,11 @@
+
+
@@ -307,6 +331,13 @@ export default {
previewIndex: 0,
observer: null,
pageSize: 24,
+ searchInput: '',
+ searchKeyword: '',
+ currentStartIndex: 0,
+ searchExpanded: false,
+ filterType: '',
+ lastScrollY: 0,
+ scrollPage: 0,
columnCount: 4,
columnHeights: [0, 0, 0, 0],
// 桌面端旋转和缩放
@@ -357,6 +388,16 @@ export default {
mediaFiles() {
return this.files.filter(f => !f.isFolder);
},
+ totalPages() {
+ return Math.ceil(this.totalCount / this.pageSize);
+ },
+ displayCurrentPage() {
+ // 基于滚动位置计算当前页码
+ const startPage = Math.floor(this.currentStartIndex / this.pageSize) + 1;
+ const loadedPages = Math.ceil(this.mediaFiles.length / this.pageSize);
+ // 根据滚动比例计算当前在第几页
+ return Math.min(startPage + Math.floor(this.scrollPage * loadedPages), this.totalPages);
+ },
columns() {
const cols = Array.from({ length: this.columnCount }, () => []);
for (const file of this.mediaFiles) {
@@ -415,6 +456,7 @@ export default {
this.updateColumnCount();
window.addEventListener('resize', this.updateColumnCount);
window.addEventListener('resize', this.checkMobile);
+ window.addEventListener('scroll', this.handleScroll);
},
beforeUnmount() {
if (this.observer) {
@@ -422,15 +464,112 @@ export default {
}
window.removeEventListener('resize', this.updateColumnCount);
window.removeEventListener('resize', this.checkMobile);
+ window.removeEventListener('scroll', this.handleScroll);
},
methods: {
+ // 搜索处理
+ handleSearch() {
+ const input = this.searchInput.trim();
+ if (!input) {
+ // 清空搜索,重置
+ this.searchKeyword = '';
+ this.filterType = '';
+ this.currentStartIndex = 0;
+ this.resetAndLoad();
+ return;
+ }
+
+ // 检查是否是页码跳转 #数字
+ const pageMatch = input.match(/^#(\d+)$/);
+ if (pageMatch) {
+ const page = parseInt(pageMatch[1], 10);
+ const maxPage = Math.ceil(this.totalCount / this.pageSize);
+ const targetPage = Math.min(Math.max(1, page), maxPage || 1);
+ this.currentStartIndex = (targetPage - 1) * this.pageSize;
+ this.searchKeyword = '';
+ this.filterType = '';
+ this.searchInput = '';
+ this.resetAndLoad();
+ return;
+ }
+
+ // 检查是否是类型关键词
+ const typeKeywords = {
+ '图片': 'image', '图': 'image', 'image': 'image', 'img': 'image', '照片': 'image',
+ '视频': 'video', 'video': 'video', '影片': 'video', '电影': 'video',
+ '音乐': 'audio', '音频': 'audio', 'audio': 'audio', 'music': 'audio', '歌曲': 'audio'
+ };
+
+ const lowerInput = input.toLowerCase();
+ if (typeKeywords[lowerInput]) {
+ this.filterType = typeKeywords[lowerInput];
+ this.searchKeyword = '';
+ this.currentStartIndex = 0;
+ this.resetAndLoad();
+ return;
+ }
+
+ // 普通文件名搜索
+ this.filterType = '';
+ this.searchKeyword = input;
+ this.currentStartIndex = 0;
+ this.resetAndLoad();
+ },
+
+ // 重置并加载
+ resetAndLoad() {
+ this.files = [];
+ this.hasMore = true;
+ this.columnHeights = new Array(this.columnCount).fill(0);
+ this.loadFiles().then(() => {
+ // 重新观察加载触发器,确保无限滚动继续工作
+ this.observeLoadTrigger();
+ });
+ },
+
+ // 搜索框展开/收起
+ toggleSearch() {
+ this.searchExpanded = !this.searchExpanded;
+ if (this.searchExpanded) {
+ this.$nextTick(() => {
+ this.$refs.searchInputRef?.focus();
+ });
+ }
+ },
+
+ // 监听滚动收起搜索框 + 计算当前页码
+ handleScroll() {
+ const currentScrollY = window.scrollY;
+
+ // 收起搜索框
+ if (this.searchExpanded) {
+ if (currentScrollY > this.lastScrollY + 20) {
+ this.searchExpanded = false;
+ }
+ }
+ this.lastScrollY = currentScrollY;
+
+ // 计算滚动页码比例
+ const gallery = this.$refs.galleryContainer;
+ if (gallery && this.mediaFiles.length > 0) {
+ const galleryRect = gallery.getBoundingClientRect();
+ const galleryTop = gallery.offsetTop;
+ const scrollableHeight = gallery.scrollHeight - window.innerHeight;
+
+ if (scrollableHeight > 0) {
+ const scrolled = Math.max(0, currentScrollY - galleryTop);
+ this.scrollPage = Math.min(1, scrolled / scrollableHeight);
+ } else {
+ this.scrollPage = 0;
+ }
+ }
+ },
+
// 检测是否为移动设备(用 JS 判断,避免全屏时 CSS 媒体查询失效)
checkMobile() {
- // 用 pointer: coarse 检测(触摸屏主输入),结合屏幕宽度
- // 不单独用 ontouchstart,因为很多电脑也支持触摸
- const isCoarse = window.matchMedia?.('(pointer: coarse)').matches;
- const isSmall = window.innerWidth <= 768;
- this.isMobile = isCoarse || isSmall;
+ // 只有屏幕宽度 ≤600px 才算手机端(与瀑布流2列的断点一致)
+ // 不用 pointer: coarse,因为很多电脑也有触摸屏
+ this.isMobile = window.innerWidth <= 600;
},
// 生成 slide key,切换时让子组件重新挂载以重置 transform
@@ -535,6 +674,11 @@ export default {
this.files = [];
this.hasMore = true;
this.columnHeights = new Array(this.columnCount).fill(0);
+ // 重置搜索状态
+ this.searchInput = '';
+ this.searchKeyword = '';
+ this.filterType = '';
+ this.currentStartIndex = 0;
await this.loadFiles();
this.observeLoadTrigger();
@@ -546,7 +690,14 @@ export default {
this.canRetry = true;
try {
- const res = await axios.get(`/api/public/list?dir=${encodeURIComponent(this.currentPath)}&count=${this.pageSize}`);
+ let url = `/api/public/list?dir=${encodeURIComponent(this.currentPath)}&start=${this.currentStartIndex}&count=${this.pageSize}`;
+ if (this.searchKeyword) {
+ url += `&search=${encodeURIComponent(this.searchKeyword)}`;
+ }
+ if (this.filterType) {
+ url += `&type=${this.filterType}`;
+ }
+ const res = await axios.get(url);
if (res.data.allowedDirs) {
this.allowedDirs = res.data.allowedDirs;
@@ -567,7 +718,7 @@ export default {
this.files = [...dirs, ...files];
this.totalCount = res.data.totalCount || this.files.length;
- this.hasMore = this.mediaFiles.length < this.totalCount;
+ this.hasMore = (this.currentStartIndex + this.mediaFiles.length) < this.totalCount;
} catch (err) {
if (err.response?.status === 403) {
const msg = err.response?.data?.error || '';
@@ -591,8 +742,15 @@ export default {
if (this.loading || !this.hasMore) return;
this.loading = true;
try {
- const start = this.mediaFiles.length;
- const res = await axios.get(`/api/public/list?dir=${encodeURIComponent(this.currentPath)}&start=${start}&count=${this.pageSize}`);
+ const start = this.currentStartIndex + this.mediaFiles.length;
+ let url = `/api/public/list?dir=${encodeURIComponent(this.currentPath)}&start=${start}&count=${this.pageSize}`;
+ if (this.searchKeyword) {
+ url += `&search=${encodeURIComponent(this.searchKeyword)}`;
+ }
+ if (this.filterType) {
+ url += `&type=${this.filterType}`;
+ }
+ const res = await axios.get(url);
const moreFiles = (res.data.files || []).map(f => ({
name: f.name,
isFolder: false,
@@ -602,7 +760,7 @@ export default {
moreFiles.forEach(f => this.assignToColumn(f));
this.files.push(...moreFiles);
- this.hasMore = this.mediaFiles.length < this.totalCount;
+ this.hasMore = (this.currentStartIndex + this.mediaFiles.length) < this.totalCount;
} catch (err) {
console.error('加载更多失败', err);
} finally {
@@ -640,12 +798,13 @@ export default {
isVideo(file) {
const ext = file.name.split('.').pop().toLowerCase();
- return ['mp4', 'webm', 'ogg', 'mov'].includes(ext);
+ // 浏览器原生支持的视频格式 + 部分浏览器支持的格式
+ return ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv', 'avi', '3gp', 'mpeg', 'mpg'].includes(ext);
},
isAudio(file) {
const ext = file.name.split('.').pop().toLowerCase();
- return ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(ext);
+ return ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', 'ape', 'opus'].includes(ext);
},
getFileName(name) {
@@ -653,7 +812,21 @@ export default {
},
handleImageError(e) {
- e.target.style.display = 'none';
+ const img = e.target;
+ const retryCount = parseInt(img.dataset.retryCount || '0');
+ const maxRetries = 3;
+
+ if (retryCount < maxRetries) {
+ // 重试:添加时间戳避免缓存
+ img.dataset.retryCount = retryCount + 1;
+ const originalSrc = img.src.split('?_retry=')[0];
+ setTimeout(() => {
+ img.src = originalSrc + '?_retry=' + Date.now();
+ }, 500 * (retryCount + 1));
+ } else {
+ // 重试次数用完,隐藏图片
+ img.style.display = 'none';
+ }
},
copyLink(name) {
@@ -1018,7 +1191,83 @@ export default {
gap: 8px;
}
+.header-right {
+ flex: 0 0 auto;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+/* 搜索框:默认只显示放大镜图标 */
+.search-box {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(255,255,255,0.1);
+ border-radius: 50%;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ transition: all 0.3s ease;
+ cursor: pointer;
+}
+
+.search-box .search-icon {
+ color: rgba(255,255,255,0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.search-box .search-icon svg {
+ width: 14px;
+ height: 14px;
+}
+
+/* 搜索框展开状态 */
+.search-box.expanded {
+ width: auto;
+ min-width: 160px;
+ border-radius: 14px;
+ padding: 4px 10px;
+ background: rgba(30, 30, 30, 0.98);
+ box-shadow: 0 2px 12px rgba(0,0,0,0.3);
+}
+
+.search-box.expanded .search-icon svg {
+ width: 12px;
+ height: 12px;
+}
+
+.search-box.expanded .search-input {
+ width: 110px;
+ font-size: 12px;
+}
+
+.search-box:focus-within {
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
+}
+
+.search-input {
+ background: transparent;
+ border: none;
+ outline: none;
+ color: #fff;
+ font-size: 12px;
+ transition: width 0.3s;
+}
+
+.search-input::placeholder {
+ color: rgba(255,255,255,0.5);
+ font-size: 11px;
+}
+
+/* 主题切换按钮对齐 */
.theme-toggle-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
background: transparent;
border-radius: 8px;
transition: background 0.2s;
@@ -1028,12 +1277,36 @@ export default {
background: rgba(255, 255, 255, 0.1);
}
-.header-right {
- flex: 0 0 auto;
- z-index: 1;
- display: flex;
- align-items: center;
- gap: 12px;
+/* 手机端搜索框更小 */
+@media (max-width: 600px) {
+ .header-right {
+ gap: 8px;
+ }
+
+ .search-box {
+ width: 26px;
+ height: 26px;
+ }
+
+ .search-box .search-icon svg {
+ width: 12px;
+ height: 12px;
+ }
+
+ .search-box.expanded {
+ min-width: 140px;
+ padding: 3px 8px;
+ }
+
+ .search-box.expanded .search-input {
+ width: 90px;
+ font-size: 11px;
+ }
+
+ .search-box.expanded .search-icon svg {
+ width: 11px;
+ height: 11px;
+ }
}
.header-center {
@@ -1403,6 +1676,33 @@ export default {
min-height: 100px;
}
+/* 浮动页码指示器 */
+.floating-page-indicator {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(8px);
+ color: rgba(255, 255, 255, 0.85);
+ padding: 6px 12px;
+ border-radius: 16px;
+ font-size: 12px;
+ z-index: 50;
+ pointer-events: none;
+ user-select: none;
+ transition: opacity 0.3s;
+}
+
+@media (max-width: 600px) {
+ .floating-page-indicator {
+ bottom: 16px;
+ right: 16px;
+ padding: 4px 10px;
+ font-size: 11px;
+ border-radius: 12px;
+ }
+}
+
.loading-more {
display: flex;
align-items: center;
@@ -1799,6 +2099,31 @@ export default {
color: #999;
}
+:root:not(.dark) .search-box {
+ background: rgba(0,0,0,0.08);
+}
+
+:root:not(.dark) .search-box.expanded {
+ background: rgba(255, 255, 255, 0.98);
+ box-shadow: 0 4px 20px rgba(0,0,0,0.15);
+}
+
+:root:not(.dark) .search-box.expanded .search-input {
+ color: #333;
+}
+
+:root:not(.dark) .search-box.expanded .search-input::placeholder {
+ color: rgba(0,0,0,0.4);
+}
+
+:root:not(.dark) .search-box .search-icon {
+ color: rgba(0,0,0,0.6);
+}
+
+:root:not(.dark) .search-box .search-icon:hover {
+ color: #333;
+}
+
:root:not(.dark) .loading-container,
:root:not(.dark) .error-container {
color: #999;
@@ -1893,4 +2218,10 @@ export default {
:root:not(.dark) .theme-toggle-btn:hover {
background: rgba(0, 0, 0, 0.08);
}
+
+:root:not(.dark) .floating-page-indicator {
+ background: rgba(255, 255, 255, 0.85);
+ color: rgba(0, 0, 0, 0.7);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}