feat: 媒体库支持批量选择、移动与删除文件

This commit is contained in:
tigerenwork 2026-08-15 01:24:08 +08:00
parent c1535227b9
commit e87c927a09
18 changed files with 449 additions and 25 deletions

View File

@ -27,8 +27,8 @@ default, e.g. via QEMU emulation).
```sh ```sh
docker buildx build --platform linux/amd64 \ docker buildx build --platform linux/amd64 \
--build-arg VERSION=1.20 \ --build-arg VERSION=1.21 \
-t 192.168.2.212:3000/tigeren/metube:1.20 \ -t 192.168.2.212:3000/tigeren/metube:1.21 \
--push . --push .
``` ```

View File

@ -4,6 +4,7 @@
import os import os
import sys import sys
import asyncio import asyncio
import shutil
from pathlib import Path from pathlib import Path
from aiohttp import web from aiohttp import web
from aiohttp.log import access_logger from aiohttp.log import access_logger
@ -505,6 +506,64 @@ async def library_delete(request):
return web.Response(text=serializer.encode({'status': 'ok', 'deleted': deleted, 'errors': errors})) return web.Response(text=serializer.encode({'status': 'ok', 'deleted': deleted, 'errors': errors}))
@routes.post(config.URL_PREFIX + 'library/move')
async def library_move(request):
post = await request.json()
paths = post.get('paths')
folder = post.get('folder', '')
if not paths:
raise web.HTTPBadRequest()
root = _library_root()
if root is None:
raise web.HTTPBadRequest(text='LIBRARY_DIR is not configured')
target_dir = os.path.realpath(os.path.join(root, folder or ''))
if target_dir != root and not target_dir.startswith(root + os.sep):
raise web.HTTPBadRequest(text='Invalid library folder')
def _move():
os.makedirs(target_dir, exist_ok=True)
moved = 0
errors = []
for rel in paths:
_, src = _resolve_library_path(rel)
if src is None or not os.path.isfile(src):
errors.append(f'{rel}: invalid path')
continue
if os.path.realpath(os.path.dirname(src)) == target_dir:
continue
dst = os.path.join(target_dir, os.path.basename(src))
if os.path.exists(dst):
errors.append(f'{rel}: target already exists')
continue
try:
shutil.move(src, dst)
except OSError as e:
errors.append(f'{rel}: {e}')
continue
meta_src = library_meta_path(src)
if os.path.isfile(meta_src):
try:
shutil.move(meta_src, library_meta_path(dst))
except OSError:
pass
moved += 1
# Clean up emptied parent directories, never the library root itself
parent = os.path.dirname(src)
while parent != root and parent.startswith(root + os.sep):
try:
os.rmdir(parent)
except OSError:
break
parent = os.path.dirname(parent)
return moved, errors
moved, errors = await asyncio.get_running_loop().run_in_executor(None, _move)
if moved:
_invalidate_library_folders()
log.info(f"Library move request processed: moved={moved} errors={len(errors)}")
return web.Response(text=serializer.encode({'status': 'ok', 'moved': moved, 'errors': errors}))
@routes.get(config.URL_PREFIX + 'library/thumbnail') @routes.get(config.URL_PREFIX + 'library/thumbnail')
async def library_thumbnail(request): async def library_thumbnail(request):
"""Thumbnail for a library file: ffmpeg frame extraction, cached on disk.""" """Thumbnail for a library file: ffmpeg frame extraction, cached on disk."""

View File

@ -94,9 +94,12 @@
<app-library-browser <app-library-browser
[files]="libraryFiles" [files]="libraryFiles"
[folderPath]="libraryFolder" [folderPath]="libraryFolder"
[folders]="libraryFolderNames"
[loading]="libraryLoading" [loading]="libraryLoading"
(play)="playLibraryFile($event)" (play)="playLibraryFile($event)"
(del)="deleteLibraryFile($event)" (del)="deleteLibraryFile($event)"
(batchDelete)="deleteLibraryFiles($event)"
(batchMove)="moveLibraryFiles($event)"
(refresh)="loadLibraryFolders(); loadLibraryFiles()"> (refresh)="loadLibraryFolders(); loadLibraryFiles()">
</app-library-browser> </app-library-browser>
</div> </div>
@ -169,7 +172,10 @@
[searchable]="true" [searchable]="true"
[clearable]="true" [clearable]="true"
appendTo="body"> appendTo="body">
</ng-select> <ng-template ng-option-tmp let-item="item">
<span [title]="item">{{ item }}</span>
</ng-template>
</ng-select>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="closeOrganize()">取消</button> <button type="button" class="btn btn-secondary" (click)="closeOrganize()">取消</button>

View File

@ -224,8 +224,13 @@ export class AppComponent implements OnInit {
} }
deleteLibraryFile(file: LibraryFile): void { deleteLibraryFile(file: LibraryFile): void {
if (!confirm(`确定删除「${file.name}」吗?此操作不可恢复。`)) return; this.deleteLibraryFiles([this.libraryFilePath(file)]);
this.downloads.libraryDelete([this.libraryFilePath(file)]).subscribe((res) => { }
deleteLibraryFiles(paths: string[]): void {
if (!paths.length) return;
if (!confirm(`确定删除所选 ${paths.length} 个文件吗?此操作不可恢复。`)) return;
this.downloads.libraryDelete(paths).subscribe((res) => {
if (res.status === 'error') { if (res.status === 'error') {
this.toastService.show(`删除失败: ${res.msg}`, { error: true }); this.toastService.show(`删除失败: ${res.msg}`, { error: true });
return; return;
@ -233,7 +238,24 @@ export class AppComponent implements OnInit {
if (res.errors?.length) { if (res.errors?.length) {
this.toastService.show(res.errors.join('; '), { error: true }); this.toastService.show(res.errors.join('; '), { error: true });
} else { } else {
this.toastService.show('已删除'); this.toastService.show(paths.length === 1 ? '已删除' : `已删除 ${paths.length} 个文件`);
}
this.loadLibraryFiles();
this.loadLibraryFolders();
});
}
moveLibraryFiles(evt: { paths: string[]; folder: string }): void {
if (!evt.paths.length) return;
this.downloads.libraryMove(evt.paths, evt.folder).subscribe((res) => {
if (res.status === 'error') {
this.toastService.show(`移动失败: ${res.msg}`, { error: true });
return;
}
if (res.errors?.length) {
this.toastService.show(`已移动 ${res.moved || 0} 个文件,${res.errors.length} 个失败: ${res.errors[0]}`, { error: true });
} else {
this.toastService.show(`已移动 ${res.moved || 0} 个文件到「${evt.folder || '根目录'}」`);
} }
this.loadLibraryFiles(); this.loadLibraryFiles();
this.loadLibraryFolders(); this.loadLibraryFolders();

View File

@ -62,7 +62,11 @@
[disabled]="disabled" [disabled]="disabled"
[(ngModel)]="folder" [(ngModel)]="folder"
(ngModelChange)="folderChange.emit($event)" (ngModelChange)="folderChange.emit($event)"
ngbTooltip="选择保存目录,可直接输入创建新目录"> appendTo="body"
[ngbTooltip]="folder || '选择保存目录,可直接输入创建新目录'">
<ng-template ng-option-tmp let-item="item">
<span [title]="item">{{ item }}</span>
</ng-template>
</ng-select> </ng-select>
<button class="advanced-link" (click)="openAdvanced.emit()"> <button class="advanced-link" (click)="openAdvanced.emit()">

View File

@ -1,6 +1,6 @@
<div class="card" [class.selected]="selected"> <div class="card" [class.selected]="selected">
<input type="checkbox" class="card-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title"> <input type="checkbox" class="card-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
<div class="thumb"> <div class="thumb" [class.clickable]="!!downloadUrl" (click)="downloadUrl && play.emit()">
<img [src]="thumbUrl()" [alt]="row.value.title" loading="lazy"> <img [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span> <span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
<span *ngIf="row.value.file_exists === false" class="file-warn" title="文件不存在"> <span *ngIf="row.value.file_exists === false" class="file-warn" title="文件不存在">
@ -8,7 +8,7 @@
</span> </span>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="card-title" [title]="row.value.title">{{ row.value.title }}</div> <div class="card-title" [title]="row.value.title" [class.clickable]="!!downloadUrl" (click)="downloadUrl && play.emit()">{{ row.value.title }}</div>
<div class="card-meta"> <div class="card-meta">
<span class="badge folder">{{ folderLabel() }}</span> <span class="badge folder">{{ folderLabel() }}</span>
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span> <span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span>
@ -45,7 +45,10 @@
[ngModel]="row.value.folder" [ngModel]="row.value.folder"
(change)="folderChange.emit({ key: row.key, folder: $event || '' })" (change)="folderChange.emit({ key: row.key, folder: $event || '' })"
appendTo="body" appendTo="body"
ngbTooltip="移动文件到其他目录"> [ngbTooltip]="row.value.folder || '移动文件到其他目录'">
<ng-template ng-option-tmp let-item="item">
<span [title]="item">{{ item }}</span>
</ng-template>
</ng-select> </ng-select>
</div> </div>
</div> </div>

View File

@ -72,6 +72,18 @@
overflow: hidden overflow: hidden
min-height: 38px min-height: 38px
.clickable
cursor: pointer
.card-title.clickable:hover
color: var(--accent-strong)
.thumb.clickable img
transition: opacity 0.12s ease
.thumb.clickable:hover img
opacity: 0.85
.card-meta .card-meta
display: flex display: flex
align-items: center align-items: center

View File

@ -1,6 +1,6 @@
<div class="q-row" [class.selected]="selected"> <div class="q-row" [class.selected]="selected">
<input type="checkbox" class="q-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title"> <input type="checkbox" class="q-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
<div class="thumb"> <div class="thumb" [class.clickable]="canPlay()" (click)="canPlay() && play.emit()">
<img *ngIf="status === 'completed' || row.value.thumbnail" [src]="thumbUrl()" [alt]="row.value.title" loading="lazy"> <img *ngIf="status === 'completed' || row.value.thumbnail" [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
<span class="thumb-ph" *ngIf="status !== 'completed' && !row.value.thumbnail"> <span class="thumb-ph" *ngIf="status !== 'completed' && !row.value.thumbnail">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
@ -8,7 +8,7 @@
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span> <span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
</div> </div>
<div class="q-main"> <div class="q-main">
<div class="q-title" [title]="row.value.title">{{ row.value.title }}</div> <div class="q-title" [title]="row.value.title" [class.clickable]="canPlay()" (click)="canPlay() && play.emit()">{{ row.value.title }}</div>
<div class="q-meta"> <div class="q-meta">
<span class="badge folder">{{ folderLabel() }}</span> <span class="badge folder">{{ folderLabel() }}</span>
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span> <span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span>
@ -29,8 +29,11 @@
[closeOnSelect]="true" [closeOnSelect]="true"
[ngModel]="row.value.folder" [ngModel]="row.value.folder"
(change)="folderChange.emit({ key: row.key, folder: $event || '' })" (change)="folderChange.emit({ key: row.key, folder: $event || '' })"
appendTo="body" appendTo="body"
ngbTooltip="更改下载目录"> [ngbTooltip]="row.value.folder || '更改下载目录'">
<ng-template ng-option-tmp let-item="item">
<span [title]="item">{{ item }}</span>
</ng-template>
</ng-select> </ng-select>
</div> </div>
</div> </div>

View File

@ -67,6 +67,18 @@
-webkit-box-orient: vertical -webkit-box-orient: vertical
overflow: hidden overflow: hidden
.clickable
cursor: pointer
.q-title.clickable:hover
color: var(--accent-strong)
.thumb.clickable img
transition: opacity 0.12s ease
.thumb.clickable:hover img
opacity: 0.85
.q-meta .q-meta
display: flex display: flex
gap: 8px gap: 8px

View File

@ -50,6 +50,10 @@ export class DownloadRowComponent {
return this.row.value.folder || '默认'; return this.row.value.folder || '默认';
} }
canPlay(): boolean {
return this.status === 'completed' && !!this.downloadUrl;
}
fullTime(): string { fullTime(): string {
return new Date((this.row.value.timestamp || 0) / 1e6).toLocaleString(); return new Date((this.row.value.timestamp || 0) / 1e6).toLocaleString();
} }

View File

@ -229,6 +229,12 @@ export class DownloadsService {
); );
} }
public libraryMove(paths: string[], folder: string): Observable<Status & { moved?: number; errors?: string[] }> {
return this.http.post<Status & { moved?: number; errors?: string[] }>('library/move', { paths, folder }).pipe(
catchError(this.handleHTTPError)
);
}
public libraryThumbUrl(path: string): string { public libraryThumbUrl(path: string): string {
return 'library/thumbnail?path=' + encodeURIComponent(path); return 'library/thumbnail?path=' + encodeURIComponent(path);
} }

View File

@ -26,6 +26,7 @@
<fa-icon [icon]="faThLarge"></fa-icon> <fa-icon [icon]="faThLarge"></fa-icon>
</button> </button>
</div> </div>
<button class="ghost-btn" (click)="toggleSelectAll()">{{ allSelected() ? '取消全选' : '全选' }}</button>
<button class="ghost-btn" (click)="refresh.emit()" title="刷新"> <button class="ghost-btn" (click)="refresh.emit()" title="刷新">
<fa-icon [icon]="faRedoAlt"></fa-icon>&nbsp;刷新 <fa-icon [icon]="faRedoAlt"></fa-icon>&nbsp;刷新
</button> </button>
@ -40,12 +41,13 @@
<div class="list-frame" *ngIf="files.length > 0"> <div class="list-frame" *ngIf="files.length > 0">
<div class="lib-rows" *ngIf="view === 'list'"> <div class="lib-rows" *ngIf="view === 'list'">
<div class="lib-row" *ngFor="let file of filteredFiles; trackBy: trackByName"> <div class="lib-row" *ngFor="let file of filteredFiles; trackBy: trackByName" [class.selected]="isSelected(filePath(file))">
<div class="thumb"> <input type="checkbox" class="lib-check" [checked]="isSelected(filePath(file))" (change)="toggleSelect(filePath(file), $event.target.checked)" [attr.aria-label]="'选择 ' + file.name">
<div class="thumb clickable" (click)="play.emit(file)">
<img [src]="thumbUrl(file)" [alt]="file.name" loading="lazy"> <img [src]="thumbUrl(file)" [alt]="file.name" loading="lazy">
</div> </div>
<div class="lib-main"> <div class="lib-main">
<div class="lib-title" [title]="file.name">{{ file.name }}</div> <div class="lib-title clickable" [title]="file.name" (click)="play.emit(file)">{{ file.name }}</div>
<div class="lib-meta"> <div class="lib-meta">
<span class="num">{{ file.size | fileSize }}</span> <span class="num">{{ file.size | fileSize }}</span>
<span>{{ fileTime(file) }}</span> <span>{{ fileTime(file) }}</span>
@ -66,12 +68,13 @@
</div> </div>
<div class="grid" *ngIf="view === 'grid'"> <div class="grid" *ngIf="view === 'grid'">
<div class="card" *ngFor="let file of filteredFiles; trackBy: trackByName"> <div class="card" *ngFor="let file of filteredFiles; trackBy: trackByName" [class.selected]="isSelected(filePath(file))">
<div class="thumb" (click)="play.emit(file)"> <input type="checkbox" class="card-check" [checked]="isSelected(filePath(file))" (change)="toggleSelect(filePath(file), $event.target.checked)" [attr.aria-label]="'选择 ' + file.name">
<div class="thumb clickable" (click)="play.emit(file)">
<img [src]="thumbUrl(file)" [alt]="file.name" loading="lazy"> <img [src]="thumbUrl(file)" [alt]="file.name" loading="lazy">
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="card-title" [title]="file.name">{{ file.name }}</div> <div class="card-title clickable" [title]="file.name" (click)="play.emit(file)">{{ file.name }}</div>
<div class="card-meta"> <div class="card-meta">
<span class="num">{{ file.size | fileSize }}</span> <span class="num">{{ file.size | fileSize }}</span>
<span>{{ fileTime(file) }}</span> <span>{{ fileTime(file) }}</span>
@ -91,4 +94,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="batchbar" [class.show]="selected.size > 0">
<span class="batch-count">已选 {{ selected.size }} 项</span>
<div class="move-wrap">
<button type="button" class="batch-btn" (click)="moveMenuOpen = !moveMenuOpen">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
移动到…
</button>
<div class="move-menu" *ngIf="moveMenuOpen">
<button *ngFor="let opt of folderOptions()" type="button" class="move-item" (click)="moveTo(opt.path)">{{ opt.label }}</button>
<div class="move-new">
<input type="text" placeholder="新建文件夹…" [(ngModel)]="newFolder" (keydown.enter)="confirmNewFolder()">
<button type="button" class="batch-btn small" (click)="confirmNewFolder()">创建</button>
</div>
</div>
</div>
<button type="button" class="batch-btn danger" (click)="delSelected()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除
</button>
<button type="button" class="batch-btn" (click)="clearSelection()">取消选择</button>
</div>
</section> </section>

View File

@ -180,15 +180,19 @@
.lib-row .lib-row
display: grid display: grid
grid-template-columns: 88px 1fr auto grid-template-columns: 30px 88px 1fr auto
gap: 12px gap: 12px
align-items: center align-items: center
padding: 10px 14px padding: 10px 14px 10px 8px
border-bottom: 1px solid var(--border) border-bottom: 1px solid var(--border)
transition: background 0.1s ease
&:last-child &:last-child
border-bottom: none border-bottom: none
&.selected
background: var(--accent-tint)
.thumb .thumb
width: 88px width: 88px
aspect-ratio: 16 / 9 aspect-ratio: 16 / 9
@ -202,6 +206,13 @@
height: 100% height: 100%
object-fit: cover object-fit: cover
.lib-check
width: 17px
height: 17px
accent-color: var(--accent)
cursor: pointer
margin-left: 6px
.lib-main .lib-main
min-width: 0 min-width: 0
@ -213,6 +224,18 @@
overflow: hidden overflow: hidden
text-overflow: ellipsis text-overflow: ellipsis
.clickable
cursor: pointer
.lib-title.clickable:hover, .card-title.clickable:hover
color: var(--accent-strong)
.thumb.clickable img
transition: opacity 0.12s ease
.thumb.clickable:hover img
opacity: 0.85
.lib-meta .lib-meta
display: flex display: flex
gap: 10px gap: 10px
@ -244,12 +267,17 @@
border: 1px solid var(--border) border: 1px solid var(--border)
border-radius: var(--radius) border-radius: var(--radius)
overflow: hidden overflow: hidden
position: relative
transition: border-color 0.12s ease, box-shadow 0.12s ease transition: border-color 0.12s ease, box-shadow 0.12s ease
&:hover &:hover
border-color: oklch(80% 0.012 240) border-color: oklch(80% 0.012 240)
box-shadow: 0 4px 14px oklch(30% 0.02 240 / 0.08) box-shadow: 0 4px 14px oklch(30% 0.02 240 / 0.08)
&.selected
border-color: var(--accent)
box-shadow: 0 0 0 1px var(--accent)
.thumb .thumb
aspect-ratio: 16 / 9 aspect-ratio: 16 / 9
overflow: hidden overflow: hidden
@ -262,6 +290,21 @@
height: 100% height: 100%
object-fit: cover object-fit: cover
.card-check
position: absolute
top: 8px
left: 8px
z-index: 2
width: 17px
height: 17px
accent-color: var(--accent)
cursor: pointer
opacity: 0
transition: opacity 0.12s ease
.card:hover .card-check, .card-check:checked
opacity: 1
.card-body .card-body
padding: 10px 12px 12px padding: 10px 12px 12px
@ -314,6 +357,119 @@
background: var(--bg) background: var(--bg)
color: var(--fg) color: var(--fg)
// Batch bar
.batchbar
position: fixed
left: 50%
bottom: 18px
transform: translate(-50%, 76px)
display: flex
align-items: center
gap: 6px
background: var(--fg)
color: oklch(96% 0.008 240)
border-radius: 10px
padding: 8px 8px 8px 16px
box-shadow: 0 10px 30px oklch(20% 0.02 240 / 0.3)
transition: transform 0.2s cubic-bezier(0.2, 0.9, 0.3, 1.2)
z-index: 30
pointer-events: none
&.show
transform: translate(-50%, 0)
pointer-events: auto
.batch-count
font-size: 13px
font-weight: 550
margin-right: 8px
white-space: nowrap
.batch-btn
display: inline-flex
align-items: center
gap: 6px
border: none
background: oklch(100% 0 0 / 0.1)
color: oklch(96% 0.008 240)
border-radius: 6px
padding: 7px 12px
font-size: 12.5px
font-weight: 500
transition: background 0.12s ease
white-space: nowrap
&:hover
background: oklch(100% 0 0 / 0.2)
&.danger:hover
background: var(--danger)
&.small
padding: 6px 10px
font-size: 12px
.move-wrap
position: relative
.move-menu
position: absolute
bottom: calc(100% + 8px)
left: 0
z-index: 35
min-width: 190px
max-height: 320px
overflow-y: auto
background: var(--surface)
border: 1px solid var(--border)
border-radius: 8px
box-shadow: 0 8px 24px oklch(30% 0.02 240 / 0.18)
padding: 6px
display: flex
flex-direction: column
gap: 2px
color: var(--fg)
.move-item
width: 100%
border: none
background: none
border-radius: 6px
padding: 7px 10px
font-size: 12.5px
color: var(--fg)
text-align: left
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
&:hover:not(:disabled)
background: var(--bg)
&:disabled
color: var(--muted)
.move-new
display: flex
gap: 6px
padding: 6px 2px 2px
border-top: 1px solid var(--border)
margin-top: 4px
input
flex: 1
min-width: 0
border: 1px solid var(--border)
background: var(--bg)
border-radius: 6px
padding: 5px 8px
font-size: 12.5px
color: var(--fg)
outline: none
&:focus
border-color: var(--accent)
@media (max-width: 900px) @media (max-width: 900px)
.filter-wrap .filter-wrap
width: 100% width: 100%

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { faExternalLinkAlt, faList, faPlay, faRedoAlt, faThLarge, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { faExternalLinkAlt, faList, faPlay, faRedoAlt, faThLarge, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { DownloadsService, LibraryFile } from './downloads.service'; import { DownloadsService, LibraryFile } from './downloads.service';
import { relativeTime } from './labels'; import { relativeTime } from './labels';
@ -9,19 +9,25 @@ import { relativeTime } from './labels';
templateUrl: './library-browser.component.html', templateUrl: './library-browser.component.html',
styleUrls: ['./library-browser.component.sass'] styleUrls: ['./library-browser.component.sass']
}) })
export class LibraryBrowserComponent { export class LibraryBrowserComponent implements OnChanges {
@Input() files: LibraryFile[] = []; @Input() files: LibraryFile[] = [];
@Input() folderPath = ''; // library-relative path of the current folder @Input() folderPath = ''; // library-relative path of the current folder
@Input() folders: string[] = [];
@Input() loading = false; @Input() loading = false;
@Output() play = new EventEmitter<LibraryFile>(); @Output() play = new EventEmitter<LibraryFile>();
@Output() del = new EventEmitter<LibraryFile>(); @Output() del = new EventEmitter<LibraryFile>();
@Output() refresh = new EventEmitter<void>(); @Output() refresh = new EventEmitter<void>();
@Output() batchDelete = new EventEmitter<string[]>();
@Output() batchMove = new EventEmitter<{ paths: string[]; folder: string }>();
filter = ''; filter = '';
sort: 'name' | 'date' | 'size' = 'name'; sort: 'name' | 'date' | 'size' = 'name';
sortAscending = true; sortAscending = true;
view: 'list' | 'grid' = 'grid'; view: 'list' | 'grid' = 'grid';
selected = new Set<string>();
moveMenuOpen = false;
newFolder = '';
faList = faList; faList = faList;
faThLarge = faThLarge; faThLarge = faThLarge;
@ -32,6 +38,12 @@ export class LibraryBrowserComponent {
constructor(public downloads: DownloadsService) {} constructor(public downloads: DownloadsService) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['folderPath']) {
this.clearSelection();
}
}
get filteredFiles(): LibraryFile[] { get filteredFiles(): LibraryFile[] {
let files = this.files; let files = this.files;
const q = this.filter.trim().toLowerCase(); const q = this.filter.trim().toLowerCase();
@ -63,4 +75,59 @@ export class LibraryBrowserComponent {
trackByName(_: number, file: LibraryFile): string { trackByName(_: number, file: LibraryFile): string {
return file.name; return file.name;
} }
isSelected(path: string): boolean {
return this.selected.has(path);
}
toggleSelect(path: string, checked: boolean): void {
if (checked) {
this.selected.add(path);
} else {
this.selected.delete(path);
}
this.moveMenuOpen = false;
}
allSelected(): boolean {
return this.filteredFiles.length > 0 && this.filteredFiles.every(f => this.selected.has(this.filePath(f)));
}
toggleSelectAll(): void {
if (this.allSelected()) {
this.filteredFiles.forEach(f => this.selected.delete(this.filePath(f)));
} else {
this.filteredFiles.forEach(f => this.selected.add(this.filePath(f)));
}
this.moveMenuOpen = false;
}
clearSelection(): void {
this.selected.clear();
this.moveMenuOpen = false;
}
folderOptions(): { label: string; path: string }[] {
const opts = [{ label: '根目录', path: '' }];
[...this.folders].sort((a, b) => a.localeCompare(b, 'zh')).forEach(f => {
if (f) opts.push({ label: f, path: f });
});
return opts.filter(o => o.path !== this.folderPath);
}
moveTo(folder: string): void {
this.batchMove.emit({ paths: Array.from(this.selected), folder });
this.clearSelection();
}
confirmNewFolder(): void {
const folder = this.newFolder.trim();
if (folder) this.moveTo(folder);
this.newFolder = '';
}
delSelected(): void {
this.batchDelete.emit(Array.from(this.selected));
this.clearSelection();
}
} }

View File

@ -48,6 +48,11 @@
<div class="nav-section" *ngIf="mode === 'library'"> <div class="nav-section" *ngIf="mode === 'library'">
<div class="nav-label">媒体库文件夹</div> <div class="nav-label">媒体库文件夹</div>
<div class="folder-filter">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="search" placeholder="筛选文件夹…" aria-label="筛选文件夹"
[(ngModel)]="folderFilter">
</div>
<button class="nav-item" [class.active]="selectedLibraryFolder === ''" (click)="selectLibraryFolder('')"> <button class="nav-item" [class.active]="selectedLibraryFolder === ''" (click)="selectLibraryFolder('')">
<fa-icon [icon]="faHome" class="nav-icon"></fa-icon> <fa-icon [icon]="faHome" class="nav-icon"></fa-icon>
<span>根目录</span> <span>根目录</span>
@ -58,6 +63,7 @@
<span>{{ folder.name }}</span> <span>{{ folder.name }}</span>
<span class="nav-count num">{{ folder.count }}</span> <span class="nav-count num">{{ folder.count }}</span>
</button> </button>
<div class="nav-empty" *ngIf="folderFilter && libraryFoldersNonRoot.length === 0">无匹配文件夹</div>
</div> </div>
<div class="sidebar-foot"> <div class="sidebar-foot">

View File

@ -48,6 +48,41 @@
color: var(--muted) color: var(--muted)
padding: 0 8px 6px padding: 0 8px 6px
.folder-filter
display: flex
align-items: center
gap: 7px
border: 1px solid var(--border)
background: var(--surface)
border-radius: 6px
padding: 0 8px
height: 30px
margin: 0 8px 6px
transition: border-color 0.12s ease
&:focus-within
border-color: var(--accent)
svg
color: var(--muted)
flex: none
input
border: none
background: none
outline: none
font-size: 12.5px
color: var(--fg)
width: 100%
&::placeholder
color: var(--muted)
.nav-empty
font-size: 12px
color: var(--muted)
padding: 4px 8px
.nav-item .nav-item
display: flex display: flex
align-items: center align-items: center

View File

@ -51,12 +51,15 @@ export class SidebarComponent {
return name || '未分类'; return name || '未分类';
} }
folderFilter = '';
get libraryRootCount(): number { get libraryRootCount(): number {
return this.libraryFolders.find(f => f.name === '')?.count || 0; return this.libraryFolders.find(f => f.name === '')?.count || 0;
} }
get libraryFoldersNonRoot(): FolderNav[] { get libraryFoldersNonRoot(): FolderNav[] {
return this.libraryFolders.filter(f => f.name !== ''); const q = this.folderFilter.trim().toLowerCase();
return this.libraryFolders.filter(f => f.name !== '' && (!q || f.name.toLowerCase().includes(q)));
} }
selectStatus(status: string): void { selectStatus(status: string): void {

View File

@ -31,3 +31,4 @@ input, select
// cascade — the `body >` selector wins on specificity instead. // cascade — the `body >` selector wins on specificity instead.
body > .ng-dropdown-panel body > .ng-dropdown-panel
z-index: 1060 z-index: 1060
min-width: 240px