Files
K-Vault/admin-imgtc.html

1024 lines
50 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>K-Vault | 管理后台</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/logo.png">
<link rel="shortcut icon" href="/favicon.ico">
<link rel="stylesheet" href="/theme.css">
<script src="/theme.js?v=20260305"></script>
<!-- Import CSS -->
<link rel="stylesheet" href="./admin-imgtc.css">
<link rel="stylesheet" href="/mobile-refactor.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-ui@2.15.3/lib/theme-chalk/index.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free/css/all.min.css">
<script src="https://js.sentry-cdn.com/219f636ac7bde5edab2c3e16885cb535.min.js" crossorigin="anonymous"></script>
</head>
<body>
<div id="app" v-cloak>
<el-container>
<el-header>
<div class="header-content">
<span class="title" @click="refreshDashboard">Dashboard</span>
<div class="search-card"><el-input v-model="search" size="mini" placeholder="输入关键字搜索"></el-input></div>
<span class="stats" role="status" aria-live="polite">
<i class="fas fa-chart-line stats-icon"></i>
<span class="stats-text">总记录: {{ totalCount || Number }}</span>
<small class="stats-loaded">已加载 {{ Number }}</small>
</span>
<div class="actions">
<el-tooltip content="文件类型" placement="bottom">
<el-dropdown @command="switchFileType" :hide-on-click="false">
<span class="el-dropdown-link"><i :class="fileTypeIcon"></i></span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="image" :class="{ 'el-dropdown-menu__item--selected': fileType === 'image' }"><i :class="fileConfig.image.icon"></i> 图片</el-dropdown-item>
<el-dropdown-item command="video" :class="{ 'el-dropdown-menu__item--selected': fileType === 'video' }"><i :class="fileConfig.video.icon"></i> 视频</el-dropdown-item>
<el-dropdown-item command="audio" :class="{ 'el-dropdown-menu__item--selected': fileType === 'audio' }"><i :class="fileConfig.audio.icon"></i> 音频</el-dropdown-item>
<el-dropdown-item command="document" :class="{ 'el-dropdown-menu__item--selected': fileType === 'document' }"><i :class="fileConfig.document.icon"></i> 文件</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</el-tooltip>
<el-tooltip content="排序" placement="bottom">
<el-dropdown @command="sort" :hide-on-click="false">
<span class="el-dropdown-link"><i :class="sortIcon"></i></span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="dateDesc" :class="{ 'el-dropdown-menu__item--selected': sortOption === 'dateDesc' }"><i class="fas fa-sort-amount-down"></i> 按时间倒序</el-dropdown-item>
<el-dropdown-item command="nameAsc" :class="{ 'el-dropdown-menu__item--selected': sortOption === 'nameAsc' }"><i class="fas fa-sort-alpha-up"></i> 按名称升序</el-dropdown-item>
<el-dropdown-item command="sizeDesc" :class="{ 'el-dropdown-menu__item--selected': sortOption === 'sizeDesc' }"><i class="fas fa-sort-amount-down"></i> 按大小倒序</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</el-tooltip>
<el-tooltip content="筛选" placement="bottom">
<el-dropdown @command="filter" :hide-on-click="false">
<span class="el-dropdown-link"><i :class="filterIcon"></i></span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="all" :class="{ 'el-dropdown-menu__item--selected': filterOption === 'all' }"><i class="fas fa-filter"></i> 全部</el-dropdown-item>
<el-dropdown-item command="favorites" :class="{ 'el-dropdown-menu__item--selected': filterOption === 'favorites' }"><i class="fas fa-bookmark"></i> 收藏</el-dropdown-item>
<el-dropdown-item command="blocked" :class="{ 'el-dropdown-menu__item--selected': filterOption === 'blocked' }"><i class="fas fa-lock"></i> 黑名单</el-dropdown-item>
<el-dropdown-item command="unblocked" :class="{ 'el-dropdown-menu__item--selected': filterOption === 'unblocked' }"><i class="fas fa-unlock"></i> 白名单</el-dropdown-item>
<el-dropdown-item command="adult" :class="{ 'el-dropdown-menu__item--selected': filterOption === 'adult' }"><i class="fas fa-user-secret"></i> NSFW</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</el-tooltip>
<el-tooltip content="批量操作" placement="bottom">
<el-dropdown @command="handleBatchOperation" :hide-on-click="false">
<span class="el-dropdown-link"><i class="fas fa-tasks"></i></span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="copy" :class="{ disabled: selectedFiles.length === 0 }"><i class="fas fa-link"></i> 批量复制</el-dropdown-item>
<el-dropdown-item command="copyMarkdown" :class="{ disabled: selectedFiles.length === 0 }"><i class="fab fa-markdown"></i> 复制Markdown</el-dropdown-item>
<el-dropdown-item command="copyHtml" :class="{ disabled: selectedFiles.length === 0 }"><i class="fas fa-code"></i> 复制HTML</el-dropdown-item>
<el-dropdown-item command="delete" :class="{ disabled: selectedFiles.length === 0 }"><i class="fas fa-trash-alt"></i> 批量删除</el-dropdown-item>
<el-dropdown-item command="download" :class="{ disabled: selectedFiles.length === 0 }"><i class="fas fa-download"></i> 批量下载</el-dropdown-item>
<el-dropdown-item command="block" :class="{ disabled: selectedFiles.length === 0 }"><i class="fas fa-lock"></i> 加入黑名单</el-dropdown-item>
<el-dropdown-item command="unblock" :class="{ disabled: selectedFiles.length === 0 }"><i class="fas fa-unlock"></i> 加入白名单</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</el-tooltip>
<el-tooltip content="快捷方式" placement="bottom">
<el-dropdown @command="handleWebsite">
<span class="el-dropdown-link"><i class="fas fa-link"></i></span>
<el-dropdown-menu slot="dropdown">
<template v-for="site in quickWebsites">
<el-dropdown-item :command="site.url">
<i :class="site.icon"></i> {{ site.name }}
</el-dropdown-item>
</template>
</el-dropdown-menu>
</el-dropdown>
</el-tooltip>
<el-tooltip content="工具箱" placement="bottom">
<el-dropdown @command="handleToolkit" :hide-on-click="false">
<span class="el-dropdown-link"><i class="fas fa-toolbox"></i></span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="selectAllInPage"><i class="fas fa-check-square"></i> 全选当前页</el-dropdown-item>
<el-dropdown-item command="checkBrokenFiles"><i class="fas fa-wrench"></i> 检测失效文件</el-dropdown-item>
<el-dropdown-item command="editWebsites"><i class="fas fa-edit"></i> 编辑快捷方式</el-dropdown-item>
<el-dropdown-item command="openUploader"><i class="fas fa-cloud-upload-alt"></i> 打开上传中心</el-dropdown-item>
<el-dropdown-item command="exportLinks"><i class="fas fa-file-export"></i> 导出全部链接</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</el-tooltip>
<el-tooltip content="退出登录" placement="bottom"><i class="fas fa-sign-out-alt" @click="handleLogout"></i></el-tooltip>
</div>
</div>
</el-header>
<el-main class="main-container">
<div class="content">
<template v-for="(item, index) in paginatedTableData" :key="index">
<!-- 图片 -->
<template v-if="fileType === 'image'">
<el-card class="image-card">
<span class="collect-icon" @click.stop="toggleLike(index, item.name)">
<i :class="item.metadata.liked ? 'fa-solid fa-bookmark liked' : 'fa-regular fa-bookmark not-liked'"></i>
</span>
<el-checkbox v-model="item.selected" :ref="'checkbox-' + index"></el-checkbox>
<el-image :src="'/file/' + item.name" :preview-src-list="['/file/' + item.name]" fit="cover" lazy="true"></el-image>
<div class="image-overlay">
<div class="overlay-buttons">
<el-dropdown @command="(cmd) => handleQuickCopy(cmd, item.name)" trigger="click" size="mini">
<el-button size="mini" type="primary" @click.stop>复制 <i class="el-icon-arrow-down"></i></el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="url">直链 URL</el-dropdown-item>
<el-dropdown-item command="markdown">Markdown</el-dropdown-item>
<el-dropdown-item command="html">HTML</el-dropdown-item>
<el-dropdown-item command="bbcode">BBCode</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<el-button size="mini" type="info" @click.stop="handleEditName(item)">编辑</el-button>
<el-button size="mini" type="danger" @click.stop="handleDelete(index, item.name)">删除</el-button>
</div>
</div>
<div class="card-footer">
<el-popover
trigger="click"
placement="top"
popper-class="custom-popover">
<template #default>
<p v-html="formattedFileDetails(item)"></p>
</template>
<template #reference>
<span :style="{ color: item.metadata.ListType !== 'Block' ? '#fff' : '#aaa' }">
{{ item.metadata.fileName || item.name }}
</span>
</template>
</el-popover>
</div>
</el-card>
</template>
<!-- 视频 -->
<template v-else-if="fileType === 'video'">
<el-card class="video-card" :class="{ 'selected': item.selected }">
<div class="video-content">
<video :src="'/file/' + item.name" controls style="width: 100%; height: 100%; object-fit: cover;"></video>
<div class="video-title">
<el-popover
trigger="click"
placement="top"
popper-class="custom-popover">
<template #default>
<p v-html="formattedFileDetails(item)"></p>
</template>
<template #reference>
<span :style="{ color: item.metadata.ListType !== 'Block' ? '#fff' : '#aaa' }">{{ item.metadata.fileName || item.name }}</span>
</template>
</el-popover>
</div>
<!-- 控制按钮区域 -->
<div class="video-controls">
<button class="control-btn like-btn" @click.stop="toggleLike(index, item.name)"><i :class="item.metadata.liked ? 'fas fa-heart liked' : 'far fa-heart'"></i></button>
<button class="control-btn edit-btn" @click.stop="handleEditName(item)"><i class="fas fa-edit"></i></button>
<button class="control-btn select-btn" @click.stop="toggleSelect(index, item.name)"><i :class="item.selected ? 'fas fa-square-check selected' : 'far fa-square'"></i></button>
<button class="control-btn" @click.stop="handleCopy(index, item.name)"><i class="fas fa-link"></i></button>
<button class="control-btn" @click.stop="handleDelete(index, item.name)"><i class="fas fa-trash-alt"></i></button>
</div>
</div>
</el-card>
</template>
<!-- 音频 -->
<template v-else-if="fileType === 'audio'">
<el-card class="audio-card" :class="{ 'selected': item.selected }">
<div class="audio-content">
<!-- 音频标题区域 -->
<div class="audio-header">
<div class="audio-avatar">
<img src="./music.svg" alt="Music">
</div>
<div class="audio-info">
<div class="audio-title">
<el-popover
trigger="click"
placement="top"
popper-class="custom-popover">
<template #default>
<p v-html="formattedFileDetails(item)"></p>
</template>
<template #reference>
<span :style="{ color: item.metadata.ListType !== 'Block' ? '#fff' : '#aaa' }">{{ item.metadata.fileName || item.name }}</span>
</template>
</el-popover>
</div>
<div class="audio-subtitle">{{ getFileType(item.name) }}</div>
</div>
</div>
<audio
class="custom-audio-player"
:src="'/file/' + item.name"
controls
preload="metadata">
当前浏览器不支持音频播放
</audio>
<!-- 控制按钮区域 -->
<div class="audio-controls">
<button class="control-btn like-btn" @click.stop="toggleLike(index, item.name)"><i :class="item.metadata.liked ? 'fas fa-heart liked' : 'far fa-heart'"></i></button>
<button class="control-btn edit-btn" @click.stop="handleEditName(item)"><i class="fas fa-edit"></i></button>
<button class="control-btn select-btn" @click.stop="toggleSelect(index, item.name)"><i :class="item.selected ? 'fas fa-square-check selected' : 'far fa-square'"></i></button>
<button class="control-btn" @click.stop="handleCopy(index, item.name)"><i class="fas fa-copy"></i></button>
<button class="control-btn" @click.stop="handleDelete(index, item.name)"><i class="fas fa-trash-alt"></i></button>
</div>
</div>
</el-card>
</template>
<!-- 文件 -->
<template v-else>
<el-card class="file-card" :class="{ 'selected': item.selected }">
<div class="file-content">
<!-- 文件标题区域 -->
<div class="file-header">
<div class="file-avatar">
<i :class="getFileIcon(item.name)" style="font-size: 32px;"></i>
</div>
<div class="file-info">
<div class="file-title">
<el-popover
trigger="click"
placement="top"
popper-class="custom-popover">
<template #default>
<p v-html="formattedFileDetails(item)"></p>
</template>
<template #reference>
<span :style="{ color: item.metadata.ListType !== 'Block' ? '#fff' : '#aaa' }">{{ item.metadata.fileName || item.name }}</span>
</template>
</el-popover>
</div>
<div class="file-subtitle">{{ getFileType(item.name) }}</div>
</div>
</div>
<!-- 控制按钮区域 -->
<div class="file-controls">
<button class="control-btn like-btn" @click.stop="toggleLike(index, item.name)"><i :class="item.metadata.liked ? 'fas fa-heart liked' : 'far fa-heart'"></i></button>
<button class="control-btn edit-btn" @click.stop="handleEditName(item)"><i class="fas fa-edit"></i></button>
<button class="control-btn select-btn" @click.stop="toggleSelect(index, item.name)"><i :class="item.selected ? 'fas fa-square-check selected' : 'far fa-square'"></i></button>
<button class="control-btn" @click.stop="handleCopy(index, item.name)"><i class="fas fa-copy"></i></button>
<button class="control-btn" @click.stop="handleDelete(index, item.name)"><i class="fas fa-trash-alt"></i></button>
</div>
</div>
</el-card>
</template>
</template>
</div>
<div class="pagination-container">
<el-pagination
background layout="prev, pager, next"
:total="filteredTableData.length" :page-size="pageSize"
@current-change="handlePageChange" :current-page.sync="currentPage" />
</div>
<div style="text-align:center;margin-top:16px;">
<el-button v-if="nextCursor" :loading="isLoadingMore" :disabled="isLoadingMore" @click="loadMore">
{{ isLoadingMore ? '加载中...' : ('加载更多 (' + Number + '/' + (totalCount || Number) + ')') }}
</el-button>
<div v-else-if="(totalCount || Number) > 0 && Number >= (totalCount || Number)" class="load-more-end">
全部加载完成,共 {{ totalCount || Number }} 条
</div>
</div>
<el-footer class="footer">
<div>Powered By K-Vault</div>
<a href="https://github.com/katelya77/K-Vault" target="_blank" rel="noopener noreferrer">
<div><i class="fa-brands fa-github"></i> K-Vault</div>
</a>
</el-footer>
</el-main>
</el-container>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js"></script> <!-- Vue -->
<script src="https://cdn.jsdelivr.net/npm/element-ui@2.15.3/lib/index.js"></script> <!-- ElementUI -->
<script>
new Vue({
el: '#app',
data: {
baseURL: document.location.origin,
Number: 0,
totalCount: 0,
fileConfig: {
image: {
name: '图片',
exts: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'ico', 'svg', 'heic', 'heif', 'avif'],
icon: 'fas fa-image',
count: 0
},
video: {
name: '视频',
exts: ['mp4', 'webm', 'ogg', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'm4v', '3gp', 'ts'],
icon: 'fas fa-video',
count: 0
},
audio: {
name: '音频',
exts: ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', 'ape', 'opus'],
icon: 'fas fa-music',
count: 0
},
document: {
name: '文件',
exts: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'zip', 'rar', '7z', 'tar', 'gz', 'exe', 'apk', 'dmg', 'iso', 'msi', 'deb', 'rpm', 'json', 'xml', 'csv', 'sql', 'html', 'css', 'js', 'py', 'java', 'go', 'rs', 'cpp', 'c', 'h'],
icon: 'fas fa-folder-open',
count: 0
}
},
uploadConfig: {
maxSize: 20 * 1024 * 1024, // 最大上传20MB
maxConcurrent: 3
},
tableData: [],
nextCursor: null,
isLoadingMore: false,
search: '',
currentPage: 1,
pageSize: 15,
selectedFiles: [],
sortOption: 'dateDesc',
filterOption: 'all',
fileType: 'image',
quickWebsites: [
{ name: '上传中心', url: './', icon: 'fas fa-cloud-upload-alt' },
{ name: '图片浏览', url: './gallery.html', icon: 'fas fa-images' },
{ name: 'Movavi', url: 'https://www.movavi.com/zh/movavi-video-converter.html', icon: 'fas fa-file-video' },
{ name: 'FreeConvert', url: 'https://www.freeconvert.com/zh/video-compressor', icon: 'fas fa-file' },
{ name: 'YouCompress', url: 'https://www.youcompress.com/zh-cn/', icon: 'fas fa-file-zipper' },
{ name: 'Cloudinary', url: 'https://console.cloudinary.com/', icon: 'fas fa-cloud' },
],
},
computed: {
filteredTableData() {
return this.tableData.filter(data => {
// 搜索匹配
const searchLower = this.search.toLowerCase();
const matchesSearch = !searchLower || [
(data.metadata.fileName || '').toLowerCase(),
data.name?.toLowerCase(),
].some(field => field?.includes(searchLower));
// 筛选匹配
const matchesFilter = {
'all': true,
'favorites': data.metadata.liked,
'blocked': data.metadata.ListType === 'Block',
'unblocked': data.metadata.ListType === 'White',
'adult': data.metadata.Label?.toLowerCase() === 'adult',
}[this.filterOption] ?? true;
// 文件类型匹配
const ext = data.name.split('.').pop().toLowerCase();
const matchesType = this.fileType === 'document' ?
!Object.keys(this.fileConfig).some(type =>
type !== 'document' && this.fileConfig[type].exts.includes(ext) // 匹配其他所有文件
) :
this.fileConfig[this.fileType].exts.includes(ext);
return matchesSearch && matchesFilter && matchesType;
});
},
paginatedTableData() {
return this.sortData(this.filteredTableData)
.slice((this.currentPage - 1) * this.pageSize, this.currentPage * this.pageSize);
},
sortIcon() { return `fas fa-sort-${this.sortOption === 'dateDesc' ? 'amount-down' : 'alpha-up'}`; },
filterIcon() {
return this.filterOption === 'all' ? 'fas fa-filter' :
this.filterOption === 'favorites' ? 'fas fa-bookmark' :
this.filterOption === 'blocked' ? 'fas fa-lock' :
this.filterOption === 'unblocked' ? 'fas fa-unlock' :
this.filterOption === 'adult' ? 'fas fa-user-secret' : '';
},
fileTypeIcon() {
return this.fileType === 'image' ? 'fas fa-image' :
this.fileType === 'video' ? 'fas fa-video' :
this.fileType === 'audio' ? 'fas fa-music' :
this.fileType === 'document' ? 'fas fa-folder-open' : '';
}
},
watch: { // 监听数据变化
tableData: {
handler(newData) {
this.selectedFiles = newData.filter(file => file.selected);
},
deep: true
},
sortOption(newOption) { localStorage.setItem('sortOption', newOption); },
filterOption(newOption) { localStorage.setItem('filterOption', newOption); }
},
methods: {
refreshDashboard() {location.reload();}, // 刷新页面
async handleLogout() { // 退出登录
try {
await fetch('./api/auth/logout', { method: 'POST', credentials: 'include' });
} catch (e) {}
window.location.href = './login.html';
},
handlePageChange(page) { this.currentPage = page; }, // 切换页面
normalizeListItem(file) {
return {
...file,
selected: false,
metadata: {
...file.metadata,
liked: file.metadata?.liked ?? false,
fileName: file.metadata?.fileName ?? file.name,
fileSize: file.metadata?.fileSize ?? 0,
},
};
},
mergeListData(files) {
const map = new Map(this.tableData.map((item) => [item.name, item]));
files.forEach((item) => {
if (!item || !item.name) return;
const prev = map.get(item.name);
map.set(item.name, prev ? { ...prev, ...item, selected: prev.selected || item.selected } : item);
});
this.tableData = Array.from(map.values());
},
sort(command) { this.sortOption = command; }, // 切换排序方式
filter(command) { this.filterOption = command; }, // 切换筛选方式
async loadMore() {
if (this.isLoadingMore || !this.nextCursor) return;
this.isLoadingMore = true;
try {
const result = await fetch(`./api/manage/list?cursor=${encodeURIComponent(this.nextCursor)}`, {
method: 'GET',
credentials: 'include',
}).then((r) => r.json());
const mapped = (result.keys || []).map((file) => this.normalizeListItem(file));
this.mergeListData(mapped);
this.nextCursor = result.list_complete ? null : result.cursor;
if (Number.isFinite(result?.stats?.total)) {
this.totalCount = result.stats.total;
}
this.updateStats();
} catch {
this.$message.error('加载更多失败,请稍后重试');
} finally {
this.isLoadingMore = false;
}
},
sortData(data) {
return this.sortOption === 'nameAsc' ? data.sort((a, b) => a.name.localeCompare(b.name)) :
this.sortOption === 'sizeDesc' ? data.sort((a, b) => b.metadata.fileSize - a.metadata.fileSize) :
data.sort((a, b) => b.metadata.TimeStamp - a.metadata.TimeStamp);
},
formattedFileDetails(item) {
const metadata = item.metadata;
const timestamp = new Date(metadata.TimeStamp).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
return `
<div style="text-align: left; padding: 5px;">
<div><strong>ID</strong>${item.name}</div>
<div><strong>文件名:</strong>${metadata.fileName || item.name}</div>
<div><strong>上传时间:</strong>${timestamp}</div>
<div><strong>元数据:</strong>${JSON.stringify(metadata)}</div>
</div>
`;
},
calculatePageSize() { // 设置页面大小
const config = {
minSize: 15, // 最小页面卡片数
cardWidth: 240,
ratio: 3/4, // 卡片高宽比
gap: 20, // 卡片间距
defaultWidth: 800,
defaultHeaderHeight: 60 // 默认Dashboard高度
};
// 获取容器尺寸
const content = document.querySelector('.content');
const header = document.querySelector('.header-content');
const width = content?.clientWidth || config.defaultWidth;
const height = window.innerHeight - (header?.offsetHeight || config.defaultHeaderHeight);
// 计算行列数
const cols = Math.floor(width / (config.cardWidth + config.gap));
const cardHeight = (width / cols - config.gap) * config.ratio;
const rows = Math.floor(height / (cardHeight + config.gap));
// 设置页面大小
this.pageSize = Math.max(rows * cols, config.minSize);
},
updateWindowWidth() { // 动态调整页面大小
this.windowWidth = window.innerWidth;
this.calculatePageSize();
},
updateStats() {
this.Number = this.tableData.length;
if (!Number.isFinite(this.totalCount) || this.totalCount < this.Number) {
this.totalCount = this.Number;
}
let fileCount = {image: 0, video: 0, audio: 0, document: 0};
this.tableData.forEach(file => {
const ext = file.name.split('.').pop().toLowerCase();
const type = Object.keys(this.fileConfig).find(t =>
this.fileConfig[t].exts.includes(ext)
) || 'document';
fileCount[type]++;
});
['image', 'video', 'audio', 'document'].forEach((type) => {
this.fileConfig[type].count = fileCount[type] || 0;
});
},
// 文件操作
async uploadFiles(event) {
const files = Array.from(event.target.files || []);
if (!files.length) return;
// 文件验证配置
// telegram-bot可分发的最大文件大小20MB
// 大于20MB虽然能够成功上传但无法获取文件链接
const config = this.uploadConfig;
// 过滤有效文件
const valid = [], invalid = [];
files.forEach(file => {
(file.size <= config.maxSize ? valid : invalid).push(file);
});
// 显示错误信息
if(invalid.length) { this.$message.error(`文件超过${config.maxSize/1024/1024}MB: \n${invalid.map(f => f.name).join('\n')}`); }
if(!valid.length) { this.$message.info('没有符合条件的文件'); event.target.value = ''; return; }
try {
await this.$confirm(`确定要上传这 ${valid.length} 个文件吗?`, '提示', {
type: 'warning'
});
const loading = this.$message({ message: '上传中...', duration: 0 });
let [successCount, failed] = [0, []];
// 并发上传处理函数
const upload = async file => {
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${this.baseURL}/upload`, {
method: 'POST',
body: formData,
credentials: 'include'
});
const data = await res.json();
if (!res.ok || (Array.isArray(data) && data[0]?.error)) {
const serverError = Array.isArray(data)
? data[0]?.error
: data?.error;
throw new Error(serverError || '上传失败');
}
const src = data[0]?.src;
if (!src) throw new Error('未返回文件路径');
// 验证并添加到列表
const preview = await fetch(`${this.baseURL}${src}`, {
credentials: 'include'
});
if (preview.ok) {
this.tableData.unshift({
name: src.replace(/^\/file\//, ''),
selected: false,
metadata: { TimeStamp: Date.now(), fileSize: file.size, fileName: file.name }
});
successCount++;
}
} catch (err) {
failed.push(`${file.name} (${err.message})`);
console.error('Upload error:', err);
}
};
// 分批上传
for (let i = 0; i < valid.length; i += config.maxConcurrent) {
await Promise.all(
valid.slice(i, i + config.maxConcurrent).map(upload)
);
}
loading.close();
successCount && this.$message.success(`成功上传 ${successCount} 个文件`);
failed.length && this.$message.error(`上传失败: ${failed.join(', ')}`);
this.refreshFileList();
} catch {
this.$message.info('已取消上传');
}
event.target.value = '';
},
async refreshFileList() { // 不刷新页面,仅更新数据
try {
const result = await fetch("./api/manage/list?limit=100&includeStats=1", { method: 'GET', credentials: 'include' })
.then(response => response.json());
const mapped = (result.keys || []).map((file) => this.normalizeListItem(file));
this.tableData = mapped;
this.nextCursor = result.list_complete ? null : result.cursor;
this.totalCount = Number.isFinite(result?.stats?.total) ? result.stats.total : mapped.length;
this.updateStats();
this.sortData(this.tableData);
} catch {
this.$message.error('刷新文件列表失败,请检查网络连接');
}
},
toggleSelect(index, name) {
const fileIndex = this.tableData.findIndex(file => file.name === name);
this.tableData[fileIndex].selected = !this.tableData[fileIndex].selected;
},
toggleLike(index, name) {
console.log(`Toggling like for : ${name}`);
const fileIndex = this.tableData.findIndex(file => file.name === name);
// 乐观更新收藏状态
this.tableData[fileIndex].metadata.liked = !(this.tableData[fileIndex].metadata.liked ?? false);
// 发送请求更新服务器数据
var requestOptions = { method: 'GET', redirect: 'follow', credentials: 'include' };
fetch(`./api/manage/toggleLike/${name}`, requestOptions)
.then(response => response.json())
.then(result => {
if (!result.success) { // 如果服务器更新失败,将状态还原
this.tableData[fileIndex].metadata.liked = !this.tableData[fileIndex].metadata.liked;
this.$message({message: '更新收藏状态失败,请稍后重试', type: 'error'});
} else {
this.$message.success(this.tableData[fileIndex].metadata.liked ? '收藏成功' : '取消收藏');
}
})
.catch(error => { // 如果服务器响应错误,将状态还原
this.tableData[fileIndex].metadata.liked = !this.tableData[fileIndex].metadata.liked;
this.$message({message: '同步服务器失败,请检查网络连接', type: 'error'});
});
},
handleDelete(index, key) {
this.$confirm('此操作将永久删除该文件, 是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
fetch(`./api/manage/delete/${key}`, { method: 'GET', credentials: 'include' })
.then(response => response.ok ? this.tableData.splice(index, 1) : Promise.reject())
.then(() => {
this.updateStats();
this.$message.success('删除成功!');
})
.catch(() => this.$message.error('删除失败,请检查网络连接'));
}).catch(() => this.$message.info('已取消删除'));
},
copyToClipboardFallback(text) {
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
textarea.style.position = 'fixed';
textarea.style.clip = 'rect(0 0 0 0)';
textarea.style.top = '10px';
textarea.value = text;
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
},
handleCopy(index, key) {
const link = `${this.baseURL}/file/${key}`;
(navigator.clipboard?.writeText(link) || this.copyToClipboardFallback(link))
.then(() => this.$message.success('复制文件链接成功~'))
.catch(() => this.$message.error('自动复制失败,请手动复制链接:' + link));
},
handleQuickCopy(format, key) { // 快速复制不同格式
const url = `${this.baseURL}/file/${key}`;
const file = this.tableData.find(f => f.name === key);
const name = file?.metadata?.fileName || key;
let link = url;
switch (format) {
case 'markdown':
link = `![${name}](${url})`;
break;
case 'html':
link = `<img src="${url}" alt="${name}">`;
break;
case 'bbcode':
link = `[img]${url}[/img]`;
break;
default:
link = url;
}
(navigator.clipboard?.writeText(link) || this.copyToClipboardFallback(link))
.then(() => this.$message.success(`${format.toUpperCase()} 格式链接已复制~`))
.catch(() => this.$message.error('复制失败'));
},
// 批处理相关
selectAllInPage() { // 全选当前页
const selected = !this.paginatedTableData.every(file => file.selected);
this.paginatedTableData.forEach(file => file.selected = selected);
},
handleBatchDelete() { // 批量删除
this.$confirm('此操作将永久删除这 ' + this.selectedFiles.length + ' 个文件, 是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const promises = this.selectedFiles.map(file => fetch(`./api/manage/delete/${file.name}`, { method: 'GET', credentials: 'include' }));
Promise.all(promises)
.then(results => {
results.forEach((response, index) => {
if (response.ok) {
const fileIndex = this.tableData.findIndex(file => file.name === this.selectedFiles[index].name);
if (fileIndex !== -1) {
this.tableData.splice(fileIndex, 1);
}
}
});
this.selectedFiles = [];
this.updateStats();
this.$message.success('批量删除成功!');
})
.catch(() => this.$message.error('批量删除失败,请检查网络连接'));
}).catch(() => this.$message.info('已取消批量删除'));
},
handleBatchCopy() { // 批量复制链接
const links = this.selectedFiles.map(file => `${document.location.origin}/file/${file.name}`).join('\n');
(navigator.clipboard?.writeText(links) || this.copyToClipboardFallback(links))
.then(() => this.$message.success('批量复制链接成功~'));
},
handleBatchCopyMarkdown() { // 批量复制Markdown格式
const links = this.selectedFiles.map(file => {
const url = `${document.location.origin}/file/${file.name}`;
const name = file.metadata?.fileName || file.name;
return `![${name}](${url})`;
}).join('\n');
(navigator.clipboard?.writeText(links) || this.copyToClipboardFallback(links))
.then(() => this.$message.success('Markdown格式链接已复制~'));
},
handleBatchCopyHtml() { // 批量复制HTML格式
const links = this.selectedFiles.map(file => {
const url = `${document.location.origin}/file/${file.name}`;
const name = file.metadata?.fileName || file.name;
return `<img src="${url}" alt="${name}">`;
}).join('\n');
(navigator.clipboard?.writeText(links) || this.copyToClipboardFallback(links))
.then(() => this.$message.success('HTML格式链接已复制~'));
},
handleBatchDownload() { // 批量下载
this.$message.info(`正在下载 ${this.selectedFiles.length} 个文件`, { duration: 1000 });
this.selectedFiles.forEach((file, index) => {
setTimeout(() => {
const link = document.createElement('a');
link.href = `/file/${file.name}`;
link.download = file.metadata.fileName || file.name;
link.click();
}, index * 800);
});
this.selectedFiles = [];
},
handleBatchBlockOrUnblock(type) { // 批量加入黑/白名单
if (type !== 'Block' && type !== 'White') { this.$message.error('无效的操作类型'); return; }
const typeToName = { Block: '黑名单', White: '白名单' };
this.$confirm(`确定要将这 ${this.selectedFiles.length} 个文件加入${typeToName[type]}吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const promises = this.selectedFiles.map(file => fetch(`./api/manage/${type.toLowerCase()}/${file.name}`, { method: 'GET', credentials: 'include' }));
Promise.all(promises)
.then(responses => {
responses.forEach((response, index) => {
if (response.ok) {
const fileIndex = this.tableData.findIndex(item => item.name === this.selectedFiles[index].name);
if (fileIndex !== -1) {
this.tableData[fileIndex].metadata.ListType = type;
}
}
});
this.$message.success(`批量加入${typeToName[type]}成功`);
this.selectedFiles = [];
})
.catch(() => this.$message.error(`操作失败,请检查网络连接`));
});
},
handleBatchOperation(command) {
if (this.selectedFiles.length === 0) { this.$message.warning('请先选择文件'); return; }
switch (command) {
case 'copy': this.handleBatchCopy(); break;
case 'copyMarkdown': this.handleBatchCopyMarkdown(); break;
case 'copyHtml': this.handleBatchCopyHtml(); break;
case 'delete': this.handleBatchDelete(); break;
case 'download': this.handleBatchDownload(); break;
case 'block': this.handleBatchBlockOrUnblock('Block'); break;
case 'unblock': this.handleBatchBlockOrUnblock('White'); break;
}
},
// 工具相关
handleToolkit(command) {
switch (command) {
case 'selectAllInPage': this.selectAllInPage(); break;
case 'checkBrokenFiles': this.checkBrokenFiles(); break;
case 'editWebsites': this.editWebsites(); break;
case 'openUploader': this.openUploader(); break;
case 'exportLinks': this.exportAllLinks(); break;
}
},
openUploader() { // 打开上传中心
window.open('./', '_blank');
},
exportAllLinks() { // 导出全部链接
const loading = this.$message({ message: '正在生成链接列表...', duration: 0 });
const links = this.tableData.map(file => `${document.location.origin}/file/${file.name}`);
const linksText = links.join('\n');
// 创建下载文件
const blob = new Blob([linksText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `image-links-${new Date().toISOString().slice(0,10)}.txt`;
a.click();
URL.revokeObjectURL(url);
loading.close();
this.$message.success(`已导出 ${links.length} 个链接`);
},
switchFileType(type) { // 切换文件类型
this.fileType = type;
this.currentPage = 1;
localStorage.setItem('fileType', type);
this.$message({
message: `已切换为${this.fileConfig[type].name}模式, 共${this.fileConfig[type].count}个文件`,
type: 'success',
duration: 1500
});
},
checkBrokenFiles() { // 检测失效文件
const loadingMessage = this.$message({ message: '正在检测失效文件...', duration: 0});
let brokenCount = 0;
const promises = this.tableData.map((item, index) => {
const fileIndex = this.tableData.findIndex(item => item.name === this.selectedFiles[index].name);
return new Promise((resolve) => {
fetch(`${this.baseURL}/file/${item.name}`, {
method: 'HEAD',
cache: 'no-cache' // 避免缓存影响检测结果
})
.then(response => {
if (!response.ok) {
brokenCount++;
this.tableData[fileIndex].selected = true;
resolve({ index, status: 'error' });
} else {
resolve({ index, status: 'success' });
}
})
.catch(() => {
brokenCount++;
this.tableData[fileIndex].selected = true;
resolve({ index, status: 'error' });
});
});
});
Promise.all(promises).then(() => {
loadingMessage.close();
if (brokenCount > 0) {
this.$message({
dangerouslyUseHTMLString: true,
message: `检测到 ${brokenCount} 个失效文件,已自动选中。<br>您可以使用批量删除功能移除它们。`,
type: 'warning',
duration: 5000
});
} else {
this.$message({
message: '未检测到失效文件',
type: 'success'
});
}
});
},
// 处理网站点击
handleWebsite(url) { window.open(url, '_blank'); },
// 编辑快捷方式
editWebsites() {
const websiteText = this.quickWebsites
.map(site => `${site.name}|${site.url}|${site.icon}`)
.join('\n');
this.$prompt('', '编辑快捷方式', {
inputType: 'textarea',
inputValue: websiteText,
confirmButtonText: '确定',
cancelButtonText: '取消',
dangerouslyUseHTMLString: true,
customClass: 'website-edit-dialog',
message: `
<div>
每行一个网站,格式:名称|网址|图标类名<br>
图标需在 <a href="https://fontawesome.com/v6/search?m=free" target="_blank" style="color: #409EFF; text-decoration: underline">Font Awesome</a> 中选择
</div>
`,
inputValidator: (value) => {
const lines = value.split('\n');
if (lines.length > 10) return '不能超过10行';
for (let line of lines) {
if (!line.trim()) continue;
const [name, url, icon = 'fas fa-link'] = line.split('|');
if (!name || !url) return '名称和网址不能为空:' + line;
if (!(/^https?:\/\/.+/.test(url) || /^\.\/.*/.test(url))) return '网址格式错误:'+line;
if (!/^(fas?|fa-brands|fa-regular|fa-solid)\s+fa-[a-z0-9-]+$/.test(icon)) {
return '图标类名格式错误:' + line;
}
}
return true;
}
}).then(({ value }) => {
const newSites = value.split('\n')
.filter(line => line.trim())
.map(line => {
const [name, url, icon = 'fas fa-link'] = line.split('|');
return { name, url, icon };
});
this.quickWebsites = newSites;
localStorage.setItem('quickWebsites', JSON.stringify(newSites));
this.$message.success('保存成功');
}).catch(() => {});
},
getFileIcon(filename) {
const ext = filename.split('.').pop().toLowerCase();
const iconMap = {
'pdf': 'fas fa-file-pdf',
'doc,docx': 'fas fa-file-word',
'xls,xlsx,csv': 'fas fa-file-excel',
'ppt,pptx': 'fas fa-file-powerpoint',
'txt,md,log': 'fas fa-file-lines',
'zip,rar,7z,tar,gz': 'fas fa-file-zipper',
'html,htm,css,js,ts,jsx,tsx,vue,php,py,java,c,cpp,h,hpp,cs,go,rs,rb,pl,sh,sql': 'fas fa-file-code',
'json,xml,yaml,yml,toml': 'fas fa-file-code',
'mp4,avi,mov,wmv,flv,mkv,webm': 'fas fa-file-video',
'mp3,wav,ogg,flac,aac,m4a,wma': 'fas fa-file-audio',
'jpg,jpeg,png,gif,bmp,webp,svg,ico': 'fas fa-file-image',
'psd,ai,eps,cdr': 'fas fa-file-image',
'exe,msi,app,dmg,deb,rpm': 'fas fa-file-arrow-down'
};
for(const [exts, icon] of Object.entries(iconMap)) {
if(exts.split(',').includes(ext)) {
return icon;
}
}
return 'fas fa-file';
},
getFileType(filename) {
const ext = filename.split('.').pop();
return `${ext.toUpperCase()}`;
},
handleEditName(item) {
this.$prompt('', '修改文件名', {
inputValue: item.metadata?.fileName || item.name,
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValidator: (value) => {
if (!value) return '文件名不能为空';
if (value.length > 64) return '文件名不能超过64个字符';
return true;
}
}).then(({ value }) => {
fetch(`./api/manage/editName/${item.name}?newName=${encodeURIComponent(value)}`, {
method: 'GET',
credentials: 'include'
})
.then(response => response.json())
.then(result => {
if (result.success) {
item.metadata.fileName = value;
this.$message.success('文件名修改成功');
} else {
this.$message.error('文件名修改失败');
}
})
.catch(() => this.$message.error('修改文件名时出错,请检查网络连接'));
}).catch(() => {});
}
},
async mounted() {
// 先检查认证状态
try {
const authResponse = await fetch('./api/auth/check', { method: 'GET', credentials: 'include' });
const authData = await authResponse.json();
if (authData.authRequired && !authData.authenticated) {
window.location.href = './login.html?redirect=' + encodeURIComponent(window.location.pathname);
return;
}
this.showLogoutButton = authData.authRequired && authData.authenticated;
} catch (e) {
this.$message.error('认证检查失败,请刷新页面');
return;
}
window.addEventListener('resize', this.calculatePageSize);
this.updateWindowWidth();
// 获取文件列表数据
try {
const result = await fetch("./api/manage/list?limit=100&includeStats=1", { method: 'GET', credentials: 'include' })
.then(response => response.json());
const mapped = (result.keys || []).map((file) => this.normalizeListItem(file));
this.tableData = mapped;
this.nextCursor = result.list_complete ? null : result.cursor;
this.totalCount = Number.isFinite(result?.stats?.total) ? result.stats.total : mapped.length;
this.updateStats();
// 恢复设置
this.sortOption = localStorage.getItem('sortOption') || this.sortOption;
this.sortData(this.tableData);
this.fileType = localStorage.getItem('fileType') || this.fileType;
this.switchFileType(this.fileType);
} catch {
this.$message.error('同步数据时出错,请检查网络连接');
}
if (localStorage.getItem('quickWebsites')) {
this.quickWebsites = JSON.parse(localStorage.getItem('quickWebsites'));
}
},
beforeDestroy() {
window.removeEventListener('resize', this.calculatePageSize);
}
});
</script>
</html>