feat: add share security, resumable upload, global search and reservation ops panel

This commit is contained in:
2026-02-17 23:36:30 +08:00
parent 3c75986566
commit 1a1c64c0e7
4 changed files with 2745 additions and 22 deletions

View File

@@ -1969,6 +1969,68 @@
</div>
</div>
</div>
<div style="margin: 10px 0 14px 0; position: relative;">
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<input
type="text"
class="form-input"
v-model="globalSearchKeyword"
@input="triggerGlobalSearch"
@focus="globalSearchVisible = !!globalSearchKeyword"
placeholder="全局搜索文件名(跨全部目录)"
style="flex: 1; min-width: 220px;">
<select class="form-input" v-model="globalSearchType" @change="runGlobalSearch" style="width: 120px;">
<option value="all">全部</option>
<option value="file">仅文件</option>
<option value="directory">仅文件夹</option>
</select>
<button class="btn btn-secondary" @click="runGlobalSearch" :disabled="globalSearchLoading" style="min-width: 86px;">
<i :class="globalSearchLoading ? 'fas fa-spinner fa-spin' : 'fas fa-search'"></i>
搜索
</button>
<button class="btn btn-secondary" @click="clearGlobalSearch()" style="min-width: 72px;">
清空
</button>
</div>
<div v-if="globalSearchVisible" style="position: absolute; left: 0; right: 0; top: calc(100% + 8px); z-index: 30; background: var(--bg-card); border: 1px solid var(--glass-border); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,0.22); max-height: 320px; overflow: auto;">
<div v-if="globalSearchLoading" style="padding: 12px; color: var(--text-secondary);">
<i class="fas fa-spinner fa-spin"></i> 正在搜索...
</div>
<div v-else-if="globalSearchError" style="padding: 12px; color: #ef4444;">
<i class="fas fa-circle-exclamation"></i> {{ globalSearchError }}
</div>
<div v-else-if="globalSearchResults.length === 0" style="padding: 12px; color: var(--text-secondary);">
暂无匹配结果
</div>
<div v-else>
<button
v-for="item in globalSearchResults"
:key="item.path"
class="btn"
@click="jumpToSearchResult(item)"
style="display: block; width: 100%; border: none; border-bottom: 1px solid var(--glass-border); border-radius: 0; text-align: left; background: transparent; padding: 10px 12px;">
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: center;">
<div style="min-width: 0;">
<div style="font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<i class="fas" :class="item.isDirectory ? 'fa-folder' : 'fa-file'" style="margin-right: 6px; color: #667eea;"></i>
{{ item.name }}
</div>
<div style="font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ item.path }}
</div>
</div>
<div style="font-size: 12px; color: var(--text-muted); white-space: nowrap;">
{{ item.isDirectory ? '文件夹' : (item.sizeFormatted || '-') }}
</div>
</div>
</button>
<div v-if="globalSearchMeta?.truncated" style="padding: 10px 12px; font-size: 12px; color: #f59e0b;">
结果已截断,请缩小关键词范围
</div>
</div>
</div>
</div>
<div @dragenter="handleDragEnter" @dragover="handleDragOver" @dragleave="handleDragLeave" @drop="handleDrop" class="files-container" :class="{ 'drag-over': isDragging }">
<div v-if="files.length === 0" class="empty-hint files-empty-state">
<i class="fas fa-folder-open"></i>
@@ -2209,6 +2271,49 @@
<label class="form-label">自定义天数</label>
<input type="number" class="form-input" v-model.number="shareFileForm.customDays" min="1" max="365">
</div>
<div class="form-group" style="margin-top: 12px;">
<label class="share-password-toggle">
<input type="checkbox" v-model="shareFileForm.enableAdvancedSecurity">
<span>{{ shareFileForm.enableAdvancedSecurity ? '已启用高级安全策略' : '高级安全策略(可选)' }}</span>
</label>
</div>
<div v-if="shareFileForm.enableAdvancedSecurity" style="padding: 12px; border: 1px dashed var(--glass-border); border-radius: 10px; margin-top: 8px;">
<div class="form-group" style="margin-bottom: 10px;">
<label class="share-password-toggle">
<input type="checkbox" v-model="shareFileForm.maxDownloadsEnabled">
<span>限制下载次数</span>
</label>
<input v-if="shareFileForm.maxDownloadsEnabled" type="number" class="form-input" v-model.number="shareFileForm.maxDownloads" min="1" max="1000000" style="margin-top: 8px;" placeholder="例如 10 次">
</div>
<div class="form-group" style="margin-bottom: 10px;">
<label class="form-label">IP 白名单(可选)</label>
<textarea class="form-input" v-model="shareFileForm.ipWhitelist" rows="2" placeholder="支持逗号/空格分隔例如1.2.3.4, 5.6.7.*"></textarea>
</div>
<div class="form-group" style="margin-bottom: 10px;">
<label class="form-label">设备限制</label>
<select class="form-input" v-model="shareFileForm.deviceLimit">
<option value="all">全部设备</option>
<option value="mobile">仅移动端</option>
<option value="desktop">仅桌面端</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 0;">
<label class="share-password-toggle">
<input type="checkbox" v-model="shareFileForm.accessTimeEnabled">
<span>限制访问时段</span>
</label>
<div v-if="shareFileForm.accessTimeEnabled" style="display: flex; gap: 8px; margin-top: 8px;">
<input type="time" class="form-input" v-model="shareFileForm.accessTimeStart" style="flex: 1;">
<input type="time" class="form-input" v-model="shareFileForm.accessTimeEnd" style="flex: 1;">
</div>
</div>
</div>
<div v-if="shareResult" class="share-success-panel" style="margin-top: 15px;">
<div class="share-success-head">
<i class="fas fa-circle-check"></i>
@@ -3591,6 +3696,109 @@
</div>
</div>
<!-- 下载预扣运维面板 -->
<div class="card" style="margin-bottom: 30px;">
<div style="display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px;">
<h3 style="margin: 0;"><i class="fas fa-gauge-high"></i> 下载预扣运维面板</h3>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<button class="btn btn-secondary" @click="cleanupReservations" :disabled="reservationMonitor.cleaning">
<i :class="reservationMonitor.cleaning ? 'fas fa-spinner fa-spin' : 'fas fa-broom'"></i>
{{ reservationMonitor.cleaning ? '清理中...' : '清理历史' }}
</button>
<button class="btn btn-primary" @click="loadDownloadReservationMonitor(reservationMonitor.page)" :disabled="reservationMonitor.loading">
<i :class="reservationMonitor.loading ? 'fas fa-spinner fa-spin' : 'fas fa-sync'"></i>
刷新
</button>
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px;">
<select class="form-input" v-model="reservationMonitor.filters.status" @change="loadDownloadReservationMonitor(1)" style="width: 140px;">
<option value="">全部状态</option>
<option value="pending">待确认</option>
<option value="confirmed">已确认</option>
<option value="expired">已过期</option>
<option value="cancelled">已取消</option>
</select>
<input class="form-input" v-model="reservationMonitor.filters.userId" @keyup.enter="loadDownloadReservationMonitor(1)" placeholder="用户ID" style="width: 120px;">
<input class="form-input" v-model="reservationMonitor.filters.keyword" @input="triggerReservationKeywordSearch" placeholder="用户名 / 对象Key / 来源" style="flex: 1; min-width: 220px;">
</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; font-size: 12px;">
<span class="share-chip info">总数 {{ reservationMonitor.summary?.total || 0 }}</span>
<span class="share-chip warn">待确认 {{ reservationMonitor.summary?.pending || 0 }}</span>
<span class="share-chip success">已确认 {{ reservationMonitor.summary?.confirmed || 0 }}</span>
<span class="share-chip danger">已过期 {{ reservationMonitor.summary?.expired || 0 }}</span>
<span class="share-chip info">待确认剩余 {{ formatBytes(reservationMonitor.summary?.pending_remaining_bytes || 0) }}</span>
<span class="share-chip info">即将过期 {{ reservationMonitor.summary?.pending_expiring_soon || 0 }}</span>
</div>
<div v-if="reservationMonitor.loading" style="padding: 24px; text-align: center; color: var(--text-muted);">
<i class="fas fa-spinner fa-spin"></i> 正在加载预扣数据...
</div>
<div v-else-if="reservationMonitor.rows.length === 0" style="padding: 24px; text-align: center; color: var(--text-muted);">
暂无预扣记录
</div>
<div v-else style="overflow-x: auto;">
<table class="share-list-table" style="min-width: 920px;">
<thead>
<tr>
<th style="padding: 8px;">ID</th>
<th style="padding: 8px;">用户</th>
<th style="padding: 8px;">来源</th>
<th style="padding: 8px;">已预扣</th>
<th style="padding: 8px;">剩余</th>
<th style="padding: 8px;">状态</th>
<th style="padding: 8px;">到期</th>
<th style="padding: 8px;">对象</th>
<th style="padding: 8px;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in reservationMonitor.rows" :key="row.id">
<td style="padding: 8px; text-align: center;">{{ row.id }}</td>
<td style="padding: 8px; text-align: center;">
<div>#{{ row.user_id }}</div>
<div style="font-size: 12px; color: var(--text-secondary);">{{ row.username || '-' }}</div>
</td>
<td style="padding: 8px; text-align: center;">{{ row.source || '-' }}</td>
<td style="padding: 8px; text-align: center;">{{ formatBytes(row.reserved_bytes || 0) }}</td>
<td style="padding: 8px; text-align: center;">{{ formatBytes(row.remaining_bytes || 0) }}</td>
<td style="padding: 8px; text-align: center; font-weight: 600;" :style="{ color: getReservationStatusColor(row.status) }">
{{ getReservationStatusText(row.status) }}
</td>
<td style="padding: 8px; text-align: center; white-space: nowrap;">{{ formatDate(row.expires_at) }}</td>
<td style="padding: 8px;">
<span style="display: inline-block; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="row.object_key || '-'">
{{ row.object_key || '-' }}
</span>
</td>
<td style="padding: 8px; text-align: center;">
<button v-if="row.status === 'pending'" class="btn btn-secondary" @click="cancelReservation(row)" style="padding: 4px 10px; font-size: 12px;">
<i class="fas fa-ban"></i> 释放
</button>
<span v-else style="color: var(--text-muted); font-size: 12px;">-</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="reservationMonitor.totalPages > 1" style="margin-top: 12px; display: flex; justify-content: center; gap: 8px;">
<button class="btn btn-secondary" @click="changeReservationPage(reservationMonitor.page - 1)" :disabled="reservationMonitor.page <= 1">
上一页
</button>
<span style="display: flex; align-items: center; color: var(--text-secondary);">
{{ reservationMonitor.page }} / {{ reservationMonitor.totalPages }}
</span>
<button class="btn btn-secondary" @click="changeReservationPage(reservationMonitor.page + 1)" :disabled="reservationMonitor.page >= reservationMonitor.totalPages">
下一页
</button>
</div>
</div>
<!-- 系统日志 -->
<div class="card" style="margin-bottom: 30px;">
<div class="admin-log-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">