feat: 媒体库支持批量选择、移动与删除文件
This commit is contained in:
parent
c1535227b9
commit
e87c927a09
|
|
@ -27,8 +27,8 @@ default, e.g. via QEMU emulation).
|
|||
|
||||
```sh
|
||||
docker buildx build --platform linux/amd64 \
|
||||
--build-arg VERSION=1.20 \
|
||||
-t 192.168.2.212:3000/tigeren/metube:1.20 \
|
||||
--build-arg VERSION=1.21 \
|
||||
-t 192.168.2.212:3000/tigeren/metube:1.21 \
|
||||
--push .
|
||||
```
|
||||
|
||||
|
|
|
|||
59
app/main.py
59
app/main.py
|
|
@ -4,6 +4,7 @@
|
|||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
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}))
|
||||
|
||||
|
||||
@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')
|
||||
async def library_thumbnail(request):
|
||||
"""Thumbnail for a library file: ffmpeg frame extraction, cached on disk."""
|
||||
|
|
|
|||
|
|
@ -94,9 +94,12 @@
|
|||
<app-library-browser
|
||||
[files]="libraryFiles"
|
||||
[folderPath]="libraryFolder"
|
||||
[folders]="libraryFolderNames"
|
||||
[loading]="libraryLoading"
|
||||
(play)="playLibraryFile($event)"
|
||||
(del)="deleteLibraryFile($event)"
|
||||
(batchDelete)="deleteLibraryFiles($event)"
|
||||
(batchMove)="moveLibraryFiles($event)"
|
||||
(refresh)="loadLibraryFolders(); loadLibraryFiles()">
|
||||
</app-library-browser>
|
||||
</div>
|
||||
|
|
@ -169,7 +172,10 @@
|
|||
[searchable]="true"
|
||||
[clearable]="true"
|
||||
appendTo="body">
|
||||
</ng-select>
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
<span [title]="item">{{ item }}</span>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="closeOrganize()">取消</button>
|
||||
|
|
|
|||
|
|
@ -224,8 +224,13 @@ export class AppComponent implements OnInit {
|
|||
}
|
||||
|
||||
deleteLibraryFile(file: LibraryFile): void {
|
||||
if (!confirm(`确定删除「${file.name}」吗?此操作不可恢复。`)) return;
|
||||
this.downloads.libraryDelete([this.libraryFilePath(file)]).subscribe((res) => {
|
||||
this.deleteLibraryFiles([this.libraryFilePath(file)]);
|
||||
}
|
||||
|
||||
deleteLibraryFiles(paths: string[]): void {
|
||||
if (!paths.length) return;
|
||||
if (!confirm(`确定删除所选 ${paths.length} 个文件吗?此操作不可恢复。`)) return;
|
||||
this.downloads.libraryDelete(paths).subscribe((res) => {
|
||||
if (res.status === 'error') {
|
||||
this.toastService.show(`删除失败: ${res.msg}`, { error: true });
|
||||
return;
|
||||
|
|
@ -233,7 +238,24 @@ export class AppComponent implements OnInit {
|
|||
if (res.errors?.length) {
|
||||
this.toastService.show(res.errors.join('; '), { error: true });
|
||||
} 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.loadLibraryFolders();
|
||||
|
|
|
|||
|
|
@ -62,7 +62,11 @@
|
|||
[disabled]="disabled"
|
||||
[(ngModel)]="folder"
|
||||
(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>
|
||||
|
||||
<button class="advanced-link" (click)="openAdvanced.emit()">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<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">
|
||||
<div class="thumb">
|
||||
<div class="thumb" [class.clickable]="!!downloadUrl" (click)="downloadUrl && play.emit()">
|
||||
<img [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
|
||||
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
|
||||
<span *ngIf="row.value.file_exists === false" class="file-warn" title="文件不存在">
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
</span>
|
||||
</div>
|
||||
<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">
|
||||
<span class="badge folder">{{ folderLabel() }}</span>
|
||||
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span>
|
||||
|
|
@ -45,7 +45,10 @@
|
|||
[ngModel]="row.value.folder"
|
||||
(change)="folderChange.emit({ key: row.key, folder: $event || '' })"
|
||||
appendTo="body"
|
||||
ngbTooltip="移动文件到其他目录">
|
||||
[ngbTooltip]="row.value.folder || '移动文件到其他目录'">
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
<span [title]="item">{{ item }}</span>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -72,6 +72,18 @@
|
|||
overflow: hidden
|
||||
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
|
||||
display: flex
|
||||
align-items: center
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<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">
|
||||
<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">
|
||||
<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>
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
|
||||
</div>
|
||||
<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">
|
||||
<span class="badge folder">{{ folderLabel() }}</span>
|
||||
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span>
|
||||
|
|
@ -29,8 +29,11 @@
|
|||
[closeOnSelect]="true"
|
||||
[ngModel]="row.value.folder"
|
||||
(change)="folderChange.emit({ key: row.key, folder: $event || '' })"
|
||||
appendTo="body"
|
||||
ngbTooltip="更改下载目录">
|
||||
appendTo="body"
|
||||
[ngbTooltip]="row.value.folder || '更改下载目录'">
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
<span [title]="item">{{ item }}</span>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -67,6 +67,18 @@
|
|||
-webkit-box-orient: vertical
|
||||
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
|
||||
display: flex
|
||||
gap: 8px
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ export class DownloadRowComponent {
|
|||
return this.row.value.folder || '默认';
|
||||
}
|
||||
|
||||
canPlay(): boolean {
|
||||
return this.status === 'completed' && !!this.downloadUrl;
|
||||
}
|
||||
|
||||
fullTime(): string {
|
||||
return new Date((this.row.value.timestamp || 0) / 1e6).toLocaleString();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
return 'library/thumbnail?path=' + encodeURIComponent(path);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
<fa-icon [icon]="faThLarge"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
<button class="ghost-btn" (click)="toggleSelectAll()">{{ allSelected() ? '取消全选' : '全选' }}</button>
|
||||
<button class="ghost-btn" (click)="refresh.emit()" title="刷新">
|
||||
<fa-icon [icon]="faRedoAlt"></fa-icon> 刷新
|
||||
</button>
|
||||
|
|
@ -40,12 +41,13 @@
|
|||
|
||||
<div class="list-frame" *ngIf="files.length > 0">
|
||||
<div class="lib-rows" *ngIf="view === 'list'">
|
||||
<div class="lib-row" *ngFor="let file of filteredFiles; trackBy: trackByName">
|
||||
<div class="thumb">
|
||||
<div class="lib-row" *ngFor="let file of filteredFiles; trackBy: trackByName" [class.selected]="isSelected(filePath(file))">
|
||||
<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">
|
||||
</div>
|
||||
<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">
|
||||
<span class="num">{{ file.size | fileSize }}</span>
|
||||
<span>{{ fileTime(file) }}</span>
|
||||
|
|
@ -66,12 +68,13 @@
|
|||
</div>
|
||||
|
||||
<div class="grid" *ngIf="view === 'grid'">
|
||||
<div class="card" *ngFor="let file of filteredFiles; trackBy: trackByName">
|
||||
<div class="thumb" (click)="play.emit(file)">
|
||||
<div class="card" *ngFor="let file of filteredFiles; trackBy: trackByName" [class.selected]="isSelected(filePath(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">
|
||||
</div>
|
||||
<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">
|
||||
<span class="num">{{ file.size | fileSize }}</span>
|
||||
<span>{{ fileTime(file) }}</span>
|
||||
|
|
@ -91,4 +94,26 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -180,15 +180,19 @@
|
|||
|
||||
.lib-row
|
||||
display: grid
|
||||
grid-template-columns: 88px 1fr auto
|
||||
grid-template-columns: 30px 88px 1fr auto
|
||||
gap: 12px
|
||||
align-items: center
|
||||
padding: 10px 14px
|
||||
padding: 10px 14px 10px 8px
|
||||
border-bottom: 1px solid var(--border)
|
||||
transition: background 0.1s ease
|
||||
|
||||
&:last-child
|
||||
border-bottom: none
|
||||
|
||||
&.selected
|
||||
background: var(--accent-tint)
|
||||
|
||||
.thumb
|
||||
width: 88px
|
||||
aspect-ratio: 16 / 9
|
||||
|
|
@ -202,6 +206,13 @@
|
|||
height: 100%
|
||||
object-fit: cover
|
||||
|
||||
.lib-check
|
||||
width: 17px
|
||||
height: 17px
|
||||
accent-color: var(--accent)
|
||||
cursor: pointer
|
||||
margin-left: 6px
|
||||
|
||||
.lib-main
|
||||
min-width: 0
|
||||
|
||||
|
|
@ -213,6 +224,18 @@
|
|||
overflow: hidden
|
||||
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
|
||||
display: flex
|
||||
gap: 10px
|
||||
|
|
@ -244,12 +267,17 @@
|
|||
border: 1px solid var(--border)
|
||||
border-radius: var(--radius)
|
||||
overflow: hidden
|
||||
position: relative
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease
|
||||
|
||||
&:hover
|
||||
border-color: oklch(80% 0.012 240)
|
||||
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
|
||||
aspect-ratio: 16 / 9
|
||||
overflow: hidden
|
||||
|
|
@ -262,6 +290,21 @@
|
|||
height: 100%
|
||||
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
|
||||
padding: 10px 12px 12px
|
||||
|
||||
|
|
@ -314,6 +357,119 @@
|
|||
background: var(--bg)
|
||||
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)
|
||||
.filter-wrap
|
||||
width: 100%
|
||||
|
|
|
|||
|
|
@ -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 { DownloadsService, LibraryFile } from './downloads.service';
|
||||
import { relativeTime } from './labels';
|
||||
|
|
@ -9,19 +9,25 @@ import { relativeTime } from './labels';
|
|||
templateUrl: './library-browser.component.html',
|
||||
styleUrls: ['./library-browser.component.sass']
|
||||
})
|
||||
export class LibraryBrowserComponent {
|
||||
export class LibraryBrowserComponent implements OnChanges {
|
||||
@Input() files: LibraryFile[] = [];
|
||||
@Input() folderPath = ''; // library-relative path of the current folder
|
||||
@Input() folders: string[] = [];
|
||||
@Input() loading = false;
|
||||
|
||||
@Output() play = new EventEmitter<LibraryFile>();
|
||||
@Output() del = new EventEmitter<LibraryFile>();
|
||||
@Output() refresh = new EventEmitter<void>();
|
||||
@Output() batchDelete = new EventEmitter<string[]>();
|
||||
@Output() batchMove = new EventEmitter<{ paths: string[]; folder: string }>();
|
||||
|
||||
filter = '';
|
||||
sort: 'name' | 'date' | 'size' = 'name';
|
||||
sortAscending = true;
|
||||
view: 'list' | 'grid' = 'grid';
|
||||
selected = new Set<string>();
|
||||
moveMenuOpen = false;
|
||||
newFolder = '';
|
||||
|
||||
faList = faList;
|
||||
faThLarge = faThLarge;
|
||||
|
|
@ -32,6 +38,12 @@ export class LibraryBrowserComponent {
|
|||
|
||||
constructor(public downloads: DownloadsService) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['folderPath']) {
|
||||
this.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
get filteredFiles(): LibraryFile[] {
|
||||
let files = this.files;
|
||||
const q = this.filter.trim().toLowerCase();
|
||||
|
|
@ -63,4 +75,59 @@ export class LibraryBrowserComponent {
|
|||
trackByName(_: number, file: LibraryFile): string {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@
|
|||
|
||||
<div class="nav-section" *ngIf="mode === 'library'">
|
||||
<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('')">
|
||||
<fa-icon [icon]="faHome" class="nav-icon"></fa-icon>
|
||||
<span>根目录</span>
|
||||
|
|
@ -58,6 +63,7 @@
|
|||
<span>{{ folder.name }}</span>
|
||||
<span class="nav-count num">{{ folder.count }}</span>
|
||||
</button>
|
||||
<div class="nav-empty" *ngIf="folderFilter && libraryFoldersNonRoot.length === 0">无匹配文件夹</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
|
|
|
|||
|
|
@ -48,6 +48,41 @@
|
|||
color: var(--muted)
|
||||
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
|
||||
display: flex
|
||||
align-items: center
|
||||
|
|
|
|||
|
|
@ -51,12 +51,15 @@ export class SidebarComponent {
|
|||
return name || '未分类';
|
||||
}
|
||||
|
||||
folderFilter = '';
|
||||
|
||||
get libraryRootCount(): number {
|
||||
return this.libraryFolders.find(f => f.name === '')?.count || 0;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -31,3 +31,4 @@ input, select
|
|||
// cascade — the `body >` selector wins on specificity instead.
|
||||
body > .ng-dropdown-panel
|
||||
z-index: 1060
|
||||
min-width: 240px
|
||||
|
|
|
|||
Loading…
Reference in New Issue