Files
K-Vault/admin.html

4323 lines
182 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="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>
<style>
/* 底部消息提示样式 */
.el-message {
top: auto !important;
bottom: 40px !important;
left: 50% !important;
transform: translateX(-50%) !important;
}
.el-message-box__wrapper {
display: flex;
align-items: center;
justify-content: center;
}
/* 预览弹窗 */
.preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.92);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 20px;
}
.preview-modal img {
max-width: 90%;
max-height: 75vh;
border-radius: 8px;
object-fit: contain;
}
.preview-modal .iframe-container {
width: 90vw;
height: 75vh;
position: relative;
border-radius: 8px;
overflow: hidden;
background: #000;
}
.preview-modal .iframe-container iframe {
width: 100%;
height: 100%;
border: none;
}
.preview-close {
position: absolute;
top: 20px;
right: 20px;
width: 48px;
height: 48px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
color: white;
font-size: 1.5em;
cursor: pointer;
transition: all 0.3s;
z-index: 10001;
}
.preview-close:hover {
background: rgba(255, 255, 255, 0.35);
transform: rotate(90deg);
}
.preview-toolbar {
margin-top: 16px;
display: flex;
align-items: center;
gap: 16px;
padding: 10px 20px;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
}
.preview-filename {
color: rgba(255, 255, 255, 0.7);
font-size: 0.9em;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-btns {
display: flex;
gap: 8px;
}
.disk-layout {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
gap: 14px;
align-items: start;
margin-top: 14px;
}
.folder-sidebar {
position: sticky;
top: 88px;
max-height: calc(100vh - 130px);
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
}
.folder-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: nowrap;
padding: 10px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
font-weight: 600;
color: #4c3b7a;
}
.folder-head-title {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 0 1 auto;
white-space: nowrap;
font-size: 18px;
line-height: 1;
overflow: hidden;
}
.folder-head-title i {
flex: 0 0 auto;
font-size: 18px;
}
.folder-head-title span {
white-space: nowrap;
letter-spacing: 0.01em;
}
.folder-head-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
white-space: nowrap;
margin-left: auto;
}
.folder-head .el-button {
width: 28px;
height: 28px;
padding: 0;
margin: 0 !important;
}
.folder-current {
padding: 8px 12px;
font-size: 12px;
color: #666;
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
word-break: break-all;
}
.folder-tree {
overflow: auto;
padding: 8px;
display: grid;
gap: 6px;
}
.folder-node {
width: 100%;
border: 1px solid transparent;
background: transparent;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 7px 8px;
font-size: 13px;
color: #333;
cursor: pointer;
transition: background-color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.14s ease;
will-change: transform, background-color, border-color;
}
.folder-node:hover {
border-color: rgba(138, 75, 255, 0.25);
background: rgba(138, 75, 255, 0.08);
}
.folder-node.is-active {
border-color: rgba(138, 75, 255, 0.4);
background: rgba(138, 75, 255, 0.14);
color: #53308a;
font-weight: 600;
}
.folder-node-main {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-node-count {
font-size: 12px;
color: #7d6d97;
background: rgba(138, 75, 255, 0.08);
border-radius: 999px;
padding: 2px 7px;
min-width: 24px;
text-align: center;
}
.disk-content {
min-width: 0;
}
.folder-breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.65);
border-radius: 12px;
background: rgba(255, 255, 255, 0.86);
margin-bottom: 14px;
font-size: 13px;
color: #555;
}
.folder-crumb {
color: #6c3ce0;
cursor: pointer;
user-select: none;
}
.folder-crumb:hover {
text-decoration: underline;
}
.list-view-card {
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.62);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
padding: 8px;
}
.list-view-card .el-table th {
background: rgba(138, 75, 255, 0.08);
color: #4f3d75;
font-weight: 600;
}
.list-actions .el-button {
margin-left: 0 !important;
margin-right: 6px;
}
.row-drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 6px;
color: #9a8ab6;
cursor: grab;
vertical-align: middle;
}
.row-drag-handle:active {
cursor: grabbing;
}
.folder-node.is-drop-target {
border-style: dashed;
border-color: rgba(67, 195, 124, 0.8);
background: rgba(67, 195, 124, 0.16);
box-shadow: inset 0 0 0 1px rgba(67, 195, 124, 0.45);
transform: translateX(2px);
}
.content .el-card[draggable="true"] {
cursor: grab;
user-select: none;
}
.content .el-card[draggable="true"]:active {
cursor: grabbing;
}
.content .el-card.dragging-file-card {
opacity: 0.72;
transform: scale(0.985);
}
body.is-dragging-files .folder-node {
transition-duration: 0.12s;
}
body.is-dragging-files .folder-node.is-drop-target {
box-shadow: inset 0 0 0 1px rgba(67, 195, 124, 0.55), 0 4px 12px rgba(67, 195, 124, 0.18);
}
.empty-tip {
margin: 26px auto 8px;
max-width: 620px;
border-radius: 14px;
border: 1px dashed rgba(138, 75, 255, 0.32);
background: rgba(255, 255, 255, 0.82);
padding: 16px 18px;
text-align: center;
color: #63557f;
}
.empty-tip .el-button {
margin-top: 10px;
}
@media (max-width: 980px) {
.disk-layout {
grid-template-columns: 1fr;
margin-top: 12px;
}
.folder-sidebar {
position: static;
max-height: 320px;
}
}
html[data-theme="dark"] .folder-sidebar,
html[data-theme="dark"] .folder-breadcrumb,
html[data-theme="dark"] .list-view-card {
background: rgba(18, 25, 41, 0.9);
border-color: rgba(120, 145, 192, 0.3);
color: #dce7ff;
}
html[data-theme="dark"] .folder-head,
html[data-theme="dark"] .folder-current {
color: #c9dbff;
border-color: rgba(120, 145, 192, 0.24);
}
html[data-theme="dark"] .folder-node {
color: #dce7ff;
}
html[data-theme="dark"] .folder-node:hover {
background: rgba(103, 118, 255, 0.15);
border-color: rgba(103, 118, 255, 0.35);
}
html[data-theme="dark"] .folder-node.is-active {
background: rgba(103, 118, 255, 0.22);
border-color: rgba(135, 152, 255, 0.5);
color: #e8eeff;
}
html[data-theme="dark"] .folder-node-count {
background: rgba(122, 140, 255, 0.18);
color: #deebff;
}
html[data-theme="dark"] .list-view-card .el-table th {
background: rgba(76, 93, 164, 0.25);
color: #dce7ff;
}
html[data-theme="dark"] .row-drag-handle {
color: #9cb0dd;
}
html[data-theme="dark"] .folder-node.is-drop-target {
border-color: rgba(87, 207, 138, 0.8);
background: rgba(87, 207, 138, 0.22);
box-shadow: inset 0 0 0 1px rgba(87, 207, 138, 0.45);
}
html[data-theme="dark"] .empty-tip {
border-color: rgba(141, 162, 255, 0.42);
background: rgba(16, 27, 44, 0.9);
color: #d5e3ff;
}
.token-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.token-loading,
.token-empty {
padding: 18px 14px;
border-radius: 10px;
border: 1px dashed rgba(64, 158, 255, 0.45);
background: rgba(245, 250, 255, 0.75);
text-align: center;
color: #4a5f7a;
margin-bottom: 8px;
}
.token-empty i {
font-size: 20px;
margin-bottom: 8px;
color: #409eff;
}
.token-scope-tag {
margin-right: 4px;
margin-bottom: 4px;
text-transform: lowercase;
}
.token-time-hint {
color: #8a96a8;
font-size: 12px;
}
.token-scope-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 10px;
}
.token-scope-group .el-checkbox {
margin-right: 0;
}
.token-once-warning {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(230, 162, 60, 0.5);
background: rgba(230, 162, 60, 0.1);
color: #8d5d00;
font-weight: 600;
}
.token-created-actions {
margin-top: 12px;
text-align: right;
}
.token-status-text {
display: inline-block;
margin-left: 8px;
min-width: 58px;
text-align: left;
font-size: 12px;
color: #f56c6c;
}
.token-status-text.is-enabled {
color: #67c23a;
}
html[data-theme="dark"] .token-loading,
html[data-theme="dark"] .token-empty {
background: rgba(20, 34, 55, 0.85);
border-color: rgba(122, 183, 255, 0.45);
color: #d7e6ff;
}
html[data-theme="dark"] .token-time-hint {
color: #9eb2d8;
}
html[data-theme="dark"] .token-once-warning {
background: rgba(230, 162, 60, 0.18);
border-color: rgba(230, 162, 60, 0.5);
color: #ffd997;
}
html[data-theme="dark"] .token-status-text {
color: #ff9a9a;
}
html[data-theme="dark"] .token-status-text.is-enabled {
color: #8edc8e;
}
.ui-design-panel {
display: grid;
gap: 16px;
line-height: 1.6;
color: #111827;
}
.ui-design-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 2px;
}
.ui-design-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
color: #0f172a;
letter-spacing: 0.01em;
}
.ui-design-subtitle {
margin-top: 3px;
color: #667085;
font-size: 12px;
}
.ui-design-section {
border: 1px solid rgba(17, 24, 39, 0.08);
border-radius: 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.74);
display: grid;
gap: 10px;
}
.ui-design-section-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.ui-design-inline {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.ui-design-input {
flex: 1 1 240px;
min-width: 0;
width: 100%;
height: 38px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid rgba(17, 24, 39, 0.1);
background: rgba(255, 255, 255, 0.92);
color: #111827;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.ui-design-input:focus {
outline: none;
border-color: rgba(15, 118, 110, 0.55);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.16);
}
.ui-upload-drop {
min-width: 128px;
height: 38px;
border-radius: 12px;
border: 1px dashed rgba(15, 23, 42, 0.24);
background: rgba(255, 255, 255, 0.72);
color: #344054;
padding: 0 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.ui-upload-drop:hover {
background: rgba(248, 250, 252, 0.95);
border-color: rgba(15, 118, 110, 0.35);
color: #0f766e;
transform: translateY(-1px);
}
.ui-segmented {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px;
border-radius: 999px;
border: 1px solid rgba(17, 24, 39, 0.1);
background: rgba(255, 255, 255, 0.7);
flex-wrap: wrap;
}
.ui-segment {
position: relative;
margin: 0;
display: inline-flex;
align-items: center;
}
.ui-segment input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.ui-segment span {
min-height: 34px;
padding: 0 14px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #475467;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.ui-segment input:checked + span {
background: #0f766e;
color: #fff;
box-shadow: 0 6px 14px rgba(15, 118, 110, 0.26);
}
.ui-design-range-wrap {
display: grid;
gap: 6px;
}
.ui-design-range-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 13px;
color: #475467;
}
.ui-design-range {
width: 100%;
appearance: none;
-webkit-appearance: none;
height: 4px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(15, 118, 110, 0.82), rgba(15, 118, 110, 0.35));
outline: none;
cursor: pointer;
}
.ui-design-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #0f766e;
background: #ffffff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.24);
transition: transform 0.2s ease;
}
.ui-design-range:hover::-webkit-slider-thumb {
transform: scale(1.12);
}
.ui-design-range::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #0f766e;
background: #ffffff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.24);
transition: transform 0.2s ease;
}
.ui-design-range:hover::-moz-range-thumb {
transform: scale(1.12);
}
.ui-design-tip {
color: #667085;
font-size: 12px;
line-height: 1.5;
}
.ui-design-value {
min-width: 52px;
text-align: right;
color: #344054;
font-size: 12px;
font-weight: 600;
}
.ui-design-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.ui-design-save-status {
min-height: 20px;
font-size: 12px;
color: #667085;
display: inline-flex;
align-items: center;
gap: 6px;
}
.ui-design-save-status[data-state="saving"] {
color: #0f766e;
}
.ui-design-save-status[data-state="success"] {
color: #16a34a;
}
.ui-design-save-status[data-state="warning"] {
color: #d97706;
}
.ui-design-alert .el-message-box {
width: min(860px, 94vw) !important;
border-radius: 22px !important;
border: 1px solid rgba(17, 24, 39, 0.08) !important;
background: rgba(255, 255, 255, 0.85) !important;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.1) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
overflow: hidden;
animation: uiPanelEnter 0.2s ease-out;
}
.ui-design-alert .el-message-box__header {
padding: 20px 24px 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.ui-design-alert .el-message-box__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #111827;
}
.ui-design-alert .el-message-box__message {
margin: 0;
max-height: min(72vh, 680px);
overflow: auto;
padding: 20px 24px 4px;
}
.ui-design-alert .el-message-box__btns {
border-top: 1px solid rgba(0, 0, 0, 0.06);
padding: 16px 24px 24px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.ui-design-alert .el-message-box__btns .el-button {
min-height: 40px;
border-radius: 10px;
padding: 10px 24px;
transition: transform 0.2s ease, filter 0.2s ease;
}
.ui-design-alert .el-message-box__btns .el-button:hover {
transform: translateY(-1px);
filter: brightness(1.03);
}
.ui-design-alert .el-message-box__btns .el-button--primary {
background: #0f766e;
border-color: #0f766e;
color: #fff;
}
.ui-design-alert .el-message-box__btns .el-button--default {
border-color: rgba(17, 24, 39, 0.12);
color: #344054;
background: rgba(255, 255, 255, 0.8);
}
@keyframes uiPanelEnter {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
html[data-theme="dark"] .ui-design-panel {
color: #dce7ff;
}
html[data-theme="dark"] .ui-design-title,
html[data-theme="dark"] .ui-design-alert .el-message-box__title {
color: #eff5ff;
}
html[data-theme="dark"] .ui-design-section {
border-color: rgba(148, 163, 184, 0.24);
background: rgba(15, 23, 39, 0.72);
}
html[data-theme="dark"] .ui-design-input {
background: rgba(15, 23, 39, 0.84);
border-color: rgba(148, 163, 184, 0.28);
color: #e2ebff;
}
html[data-theme="dark"] .ui-design-input:focus {
border-color: rgba(122, 183, 255, 0.6);
box-shadow: 0 0 0 3px rgba(122, 183, 255, 0.2);
}
html[data-theme="dark"] .ui-upload-drop,
html[data-theme="dark"] .ui-segmented {
background: rgba(15, 23, 39, 0.72);
border-color: rgba(148, 163, 184, 0.26);
color: #c9d8f7;
}
html[data-theme="dark"] .ui-segment span {
color: #b2c2e5;
}
html[data-theme="dark"] .ui-design-tip,
html[data-theme="dark"] .ui-design-value,
html[data-theme="dark"] .ui-design-range-head,
html[data-theme="dark"] .ui-design-subtitle,
html[data-theme="dark"] .ui-design-save-status {
color: #9fb2d8;
}
html[data-theme="dark"] .ui-design-alert .el-message-box {
border-color: rgba(148, 163, 184, 0.24) !important;
background: rgba(12, 18, 30, 0.85) !important;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45) !important;
}
html[data-theme="dark"] .ui-design-alert .el-message-box__header,
html[data-theme="dark"] .ui-design-alert .el-message-box__btns {
border-color: rgba(148, 163, 184, 0.18);
}
html[data-theme="dark"] .ui-design-alert .el-message-box__btns .el-button--default {
background: rgba(15, 23, 39, 0.76);
color: #d8e4ff;
border-color: rgba(148, 163, 184, 0.28);
}
@media (max-width: 768px) {
.token-scope-group {
grid-template-columns: 1fr;
}
.ui-design-alert .el-message-box__header,
.ui-design-alert .el-message-box__message,
.ui-design-alert .el-message-box__btns {
padding-left: 16px;
padding-right: 16px;
}
.ui-design-alert .el-message-box__btns {
justify-content: stretch;
}
.ui-design-alert .el-message-box__btns .el-button {
flex: 1 1 0;
margin: 0 !important;
}
.ui-segmented {
border-radius: 14px;
}
.ui-segment {
flex: 1 1 auto;
}
.ui-segment span {
width: 100%;
}
}
</style>
<link rel="stylesheet" href="/mobile-refactor.css">
</head>
<body>
<div id="app" v-cloak>
<el-container>
<el-header>
<div class="header-content">
<span class="home-btn" @click="goHome"><i class="fas fa-home"></i></span>
<span class="title" @click="refreshDashboard">管理后台</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-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="all" :class="{ 'el-dropdown-menu__item--selected': fileType === 'all' }"><i class="fas fa-th-large"></i> 全部</el-dropdown-item>
<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-dropdown @command="switchStorageFilter" :hide-on-click="false">
<span class="el-dropdown-link"><i :class="storageFilterIcon"></i></span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="all" :class="{ 'el-dropdown-menu__item--selected': storageFilter === 'all' }"><i class="fas fa-database"></i> 全部存储</el-dropdown-item>
<el-dropdown-item command="telegram" :class="{ 'el-dropdown-menu__item--selected': storageFilter === 'telegram' }"><i class="fab fa-telegram"></i> Telegram</el-dropdown-item>
<el-dropdown-item command="r2" :class="{ 'el-dropdown-menu__item--selected': storageFilter === 'r2' }"><i class="fas fa-cloud"></i> R2 存储</el-dropdown-item>
<el-dropdown-item command="s3" :class="{ 'el-dropdown-menu__item--selected': storageFilter === 's3' }"><i class="fas fa-database"></i> S3 存储</el-dropdown-item>
<el-dropdown-item command="discord" :class="{ 'el-dropdown-menu__item--selected': storageFilter === 'discord' }"><i class="fab fa-discord"></i> Discord</el-dropdown-item>
<el-dropdown-item command="huggingface" :class="{ 'el-dropdown-menu__item--selected': storageFilter === 'huggingface' }"><i class="fas fa-robot"></i> HuggingFace</el-dropdown-item>
<el-dropdown-item command="webdav" :class="{ 'el-dropdown-menu__item--selected': storageFilter === 'webdav' }"><i class="fas fa-hard-drive"></i> WebDAV</el-dropdown-item>
<el-dropdown-item command="github" :class="{ 'el-dropdown-menu__item--selected': storageFilter === 'github' }"><i class="fab fa-github"></i> GitHub</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<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-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-dropdown @command="switchViewMode" :hide-on-click="false">
<span class="el-dropdown-link"><i :class="viewModeIcon"></i></span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="grid" :class="{ 'el-dropdown-menu__item--selected': viewMode === 'grid' }"><i class="fas fa-th-large"></i> 平铺视图</el-dropdown-item>
<el-dropdown-item command="list" :class="{ 'el-dropdown-menu__item--selected': viewMode === 'list' }"><i class="fas fa-list"></i> 详细视图</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<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="selectAll"><i class="fas fa-check-double"></i> {{ isAllSelected ? '取消全选' : '全选当前页' }}</el-dropdown-item>
<el-dropdown-item command="selectAllLoaded"><i class="fas fa-check-square"></i> 全选已加载 ({{ tableData.length }})</el-dropdown-item>
<el-dropdown-item divided 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="moveFolder" :class="{ disabled: selectedFiles.length === 0 }"><i class="fas fa-folder-tree"></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-dropdown @command="handleWebsite">
<span class="el-dropdown-link"><i class="fas fa-external-link-alt"></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-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-item command="manageApiTokens"><i class="fas fa-key"></i> API Token 管理</el-dropdown-item>
<el-dropdown-item command="checkStatus" divided><i class="fas fa-heartbeat"></i> 检查连接状态</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<div class="status-panel" @click="checkConnectionStatus">
<div class="status-item">
<i class="fas fa-circle" :style="{ color: systemStatus.telegram.connected ? '#67c23a' : '#f56c6c' }"></i>
<span>TG</span>
</div>
<div class="status-item">
<i class="fas fa-circle" :style="{ color: systemStatus.kv.connected ? '#67c23a' : '#f56c6c' }"></i>
<span>KV</span>
</div>
<div class="status-item">
<i class="fas fa-circle" :style="{ color: systemStatus.r2.connected ? '#67c23a' : (isUnconfiguredStatus(systemStatus.r2) ? '#909399' : '#f56c6c') }"></i>
<span>R2</span>
</div>
<div class="status-item">
<i class="fas fa-circle" :style="{ color: systemStatus.s3.connected ? '#67c23a' : (isUnconfiguredStatus(systemStatus.s3) ? '#909399' : '#f56c6c') }"></i>
<span>S3</span>
</div>
<div class="status-item">
<i class="fas fa-circle" :style="{ color: systemStatus.discord.connected ? '#67c23a' : (isUnconfiguredStatus(systemStatus.discord) ? '#909399' : '#f56c6c') }"></i>
<span>DC</span>
</div>
<div class="status-item">
<i class="fas fa-circle" :style="{ color: systemStatus.huggingface.connected ? '#67c23a' : (isUnconfiguredStatus(systemStatus.huggingface) ? '#909399' : '#f56c6c') }"></i>
<span>HF</span>
</div>
<div class="status-item">
<i class="fas fa-circle" :style="{ color: systemStatus.github.connected ? '#67c23a' : (isUnconfiguredStatus(systemStatus.github) ? '#909399' : '#f56c6c') }"></i>
<span>GH</span>
</div>
</div>
<el-dropdown @command="handleLoadSettings" :hide-on-click="false">
<span class="el-dropdown-link"><i class="fas fa-cog"></i></span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="normal" :class="{ 'el-dropdown-menu__item--selected': loadMode === 'normal' }"><i class="fas fa-eye"></i> 正常模式</el-dropdown-item>
<el-dropdown-item command="dataSaver" :class="{ 'el-dropdown-menu__item--selected': loadMode === 'dataSaver' }"><i class="fas fa-bolt"></i> 省流模式</el-dropdown-item>
<el-dropdown-item command="noImage" :class="{ 'el-dropdown-menu__item--selected': loadMode === 'noImage' }"><i class="fas fa-eye-slash"></i> 无图模式</el-dropdown-item>
<el-dropdown-item command="safeMode" divided :class="{ 'el-dropdown-menu__item--selected': safeMode }"><i class="fas fa-shield-alt"></i> {{ safeMode ? '✓' : '' }} 安全模式(NSFW)</el-dropdown-item>
<el-dropdown-item command="uiDesign" divided><i class="fas fa-sliders-h"></i> 前端 UI 设计</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<el-tooltip content="前端 UI 设计" placement="bottom"><i class="fas fa-sliders-h" @click="showUiDesignSettingsPanel"></i></el-tooltip>
<i class="fas fa-sign-out-alt" @click="handleLogout"></i>
</div>
</div>
</el-header>
<el-main class="main-container">
<div class="disk-layout">
<aside class="folder-sidebar">
<div class="folder-head">
<span class="folder-head-title"><i class="fas fa-folder-tree"></i><span>目录管理</span></span>
<span class="folder-head-actions">
<el-button size="mini" circle icon="el-icon-refresh" title="刷新目录与文件" :disabled="folderMutating" :loading="folderLoading" @click="refreshFolderResources"></el-button>
<el-button size="mini" circle icon="el-icon-plus" title="新建目录" :disabled="folderMutating" :loading="folderMutatingAction === 'create'" @click="createFolder"></el-button>
<el-button size="mini" circle icon="el-icon-edit" title="重命名目录" :disabled="folderMutating || !folderPath" :loading="folderMutatingAction === 'rename'" @click="renameCurrentFolder"></el-button>
<el-button size="mini" circle icon="el-icon-delete" title="删除目录" :disabled="folderMutating || !folderPath" :loading="folderMutatingAction === 'delete'" @click="deleteCurrentFolder"></el-button>
</span>
</div>
<div class="folder-current">当前位置:/{{ folderPath || '' }}</div>
<div class="folder-tree">
<button
v-for="folder in folderTreeNodes"
:key="'folder-' + folder.path"
class="folder-node"
:class="{ 'is-active': folder.path === folderPath, 'is-drop-target': dragState.active && dragState.targetPath === folder.path }"
@click="selectFolder(folder.path)"
@dragover.prevent="handleFolderDragOver(folder.path, $event)"
@dragenter.prevent="handleFolderDragEnter(folder.path)"
@dragleave="handleFolderDragLeave(folder.path, $event)"
@drop.prevent="handleFolderDrop(folder.path, $event)"
>
<span class="folder-node-main" :style="{ paddingLeft: (folder.depth * 14 + 6) + 'px' }">
<i class="fas" :class="folder.path === folderPath ? 'fa-folder-open' : 'fa-folder'"></i>
<span>{{ folder.name }}</span>
</span>
<span class="folder-node-count">{{ folder.fileCount }}</span>
</button>
</div>
</aside>
<section class="disk-content">
<div class="folder-breadcrumb">
<i class="fas fa-location-arrow"></i>
<span class="folder-crumb" @click="selectFolder('')">根目录</span>
<template v-for="item in folderBreadcrumbs">
<i :key="'sep-' + item.path" class="el-icon-arrow-right"></i>
<span :key="'crumb-' + item.path" class="folder-crumb" @click="selectFolder(item.path)">{{ item.name }}</span>
</template>
</div>
<template v-if="viewMode === 'grid'">
<div class="content">
<template v-for="(item, index) in paginatedTableData" :key="index">
<!-- 图片 - 根据实际文件类型渲染 -->
<template v-if="getActualFileType(item.name) === 'image'">
<el-card class="image-card" :draggable="true" @dragstart="handleFileDragStart(item, $event)" @dragend="handleFileDragEnd">
<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="getActualFileType(item.name) === 'video'">
<el-card class="video-card" :class="{ 'selected': item.selected }" :draggable="true" @dragstart="handleFileDragStart(item, $event)" @dragend="handleFileDragEnd">
<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="openPreview(item)" title="预览"><i class="fas fa-eye"></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="getActualFileType(item.name) === 'audio'">
<el-card class="audio-card" :class="{ 'selected': item.selected }" :draggable="true" @dragstart="handleFileDragStart(item, $event)" @dragend="handleFileDragEnd">
<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="openPreview(item)" title="预览"><i class="fas fa-eye"></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 }" :draggable="true" @dragstart="handleFileDragStart(item, $event)" @dragend="handleFileDragEnd">
<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="openPreview(item)" title="预览"><i class="fas fa-eye"></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 v-if="paginatedTableData.length === 0" class="empty-tip">
当前目录下没有符合条件的文件。
<div>
<el-button size="mini" type="primary" @click="resetViewConditions">清空筛选并返回根目录</el-button>
</div>
</div>
</div>
</template>
<template v-else>
<div class="list-view-card">
<el-table :data="paginatedTableData" border stripe row-key="name" style="width: 100%">
<el-table-column label="选择" width="76" align="center">
<template slot-scope="scope">
<el-checkbox :value="scope.row.selected" @change="toggleSelectByName(scope.row.name)"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="文件名" min-width="280" show-overflow-tooltip>
<template slot-scope="scope">
<span class="row-drag-handle" draggable="true" @dragstart="handleFileDragStart(scope.row, $event)" @dragend="handleFileDragEnd">
<i class="fas fa-grip-lines"></i>
</span>
<span :style="{ color: scope.row.metadata.ListType === 'Block' ? '#999' : '' }">
{{ scope.row.metadata.fileName || scope.row.name }}
</span>
</template>
</el-table-column>
<el-table-column label="目录" width="180" show-overflow-tooltip>
<template slot-scope="scope">
{{ scope.row.metadata.folderPath || '根目录' }}
</template>
</el-table-column>
<el-table-column label="类型" width="92" align="center">
<template slot-scope="scope">
{{ getFileType(scope.row.name) }}
</template>
</el-table-column>
<el-table-column label="大小" width="116" align="right">
<template slot-scope="scope">
{{ formatFileSize(scope.row.metadata.fileSize || 0) }}
</template>
</el-table-column>
<el-table-column label="存储" width="126" align="center">
<template slot-scope="scope">
{{ getStorageLabel(scope.row.name) }}
</template>
</el-table-column>
<el-table-column label="操作" width="236" fixed="right">
<template slot-scope="scope">
<div class="list-actions">
<el-button type="text" size="mini" @click="toggleLike(0, scope.row.name)">
{{ scope.row.metadata.liked ? '取消收藏' : '收藏' }}
</el-button>
<el-button type="text" size="mini" @click="handleCopy(0, scope.row.name)">复制</el-button>
<el-button type="text" size="mini" @click="handleEditName(scope.row)">重命名</el-button>
<el-button type="text" size="mini" @click="openPreview(scope.row)">预览</el-button>
<el-button type="text" size="mini" style="color:#f56c6c" @click="handleDelete(0, scope.row.name)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div v-if="paginatedTableData.length === 0" class="empty-tip">
当前目录下没有符合条件的文件。
<div>
<el-button size="mini" type="primary" @click="resetViewConditions">清空筛选并返回根目录</el-button>
</div>
</div>
</div>
</template>
<div class="pagination-container">
<el-pagination
background layout="total, prev, pager, next"
:total="paginationTotal" :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>
</section>
</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>
<el-dialog
title="API Token 管理"
:visible.sync="tokenDialogVisible"
width="860px"
custom-class="token-manager-dialog">
<div class="token-toolbar">
<el-button size="mini" type="primary" icon="el-icon-plus" @click="openCreateTokenDialog">新建 Token</el-button>
<el-button size="mini" icon="el-icon-refresh" :loading="tokenLoading" @click="loadApiTokens">刷新</el-button>
</div>
<div v-if="tokenLoading" class="token-loading">
<i class="el-icon-loading"></i> 正在加载 Token...
</div>
<div v-else-if="apiTokens.length === 0" class="token-empty">
<i class="fas fa-key"></i>
<p>暂无 API Token可创建后用于脚本、curl 或 ShareX 上传。</p>
</div>
<el-table
v-else
:data="apiTokens"
stripe
border
size="mini"
row-key="id">
<el-table-column prop="name" label="名称" min-width="170"></el-table-column>
<el-table-column label="权限" min-width="190">
<template slot-scope="scope">
<el-tag
v-for="scopeName in scope.row.scopes"
:key="scope.row.id + '-' + scopeName"
size="mini"
:type="tokenScopeTagType(scopeName)"
class="token-scope-tag">
{{ scopeName }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="140">
<template slot-scope="scope">
<div>{{ formatDateTime(scope.row.createdAt) }}</div>
<small class="token-time-hint">{{ formatRelativeTime(scope.row.createdAt) }}</small>
</template>
</el-table-column>
<el-table-column label="最后使用" min-width="140">
<template slot-scope="scope">
<span v-if="scope.row.lastUsedAt">{{ formatRelativeTime(scope.row.lastUsedAt) }}</span>
<span v-else class="token-time-hint">从未使用</span>
</template>
</el-table-column>
<el-table-column label="Token 预览" min-width="122">
<template slot-scope="scope">
<code>{{ scope.row.tokenPreview }}</code>
</template>
</el-table-column>
<el-table-column label="状态" width="176" align="center">
<template slot-scope="scope">
<el-switch
v-model="scope.row.enabled"
:disabled="Boolean(tokenMutatingMap[scope.row.id])"
active-color="#13ce66"
inactive-color="#ff6b6b"
@change="toggleApiTokenEnabled(scope.row)">
</el-switch>
<span class="token-status-text" :class="{ 'is-enabled': scope.row.enabled }">
{{ scope.row.enabled ? '启用' : '禁用' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="86" align="center">
<template slot-scope="scope">
<el-button
type="text"
style="color:#f56c6c"
:disabled="Boolean(tokenMutatingMap[scope.row.id])"
@click="removeApiToken(scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog
title="创建 API Token"
:visible.sync="createTokenDialogVisible"
width="560px"
append-to-body
custom-class="token-create-dialog">
<el-form label-position="top" size="small">
<el-form-item label="Token 名称">
<el-input v-model="tokenForm.name" maxlength="64" placeholder="例如ShareX 截图上传 / CI 备份脚本"></el-input>
</el-form-item>
<el-form-item label="权限">
<el-checkbox-group v-model="tokenForm.scopes" class="token-scope-group">
<el-checkbox label="upload"><i class="fas fa-upload"></i> 上传文件</el-checkbox>
<el-checkbox label="read"><i class="fas fa-eye"></i> 读取/列表/下载</el-checkbox>
<el-checkbox label="delete"><i class="fas fa-trash-alt"></i> 删除文件/粘贴</el-checkbox>
<el-checkbox label="paste"><i class="fas fa-file-alt"></i> 创建文本粘贴</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="过期时间">
<el-select v-model="tokenForm.expiryPreset" placeholder="请选择过期策略" style="width:100%;">
<el-option label="永不过期" value="never"></el-option>
<el-option label="7 天后过期" value="7d"></el-option>
<el-option label="30 天后过期" value="30d"></el-option>
<el-option label="90 天后过期" value="90d"></el-option>
<el-option label="自定义时间" value="custom"></el-option>
</el-select>
</el-form-item>
<el-form-item v-if="tokenForm.expiryPreset === 'custom'" label="自定义过期时间">
<el-date-picker
v-model="tokenForm.customExpiresAt"
type="datetime"
style="width:100%;"
value-format="timestamp"
placeholder="选择日期和时间">
</el-date-picker>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button size="small" @click="createTokenDialogVisible = false">取消</el-button>
<el-button size="small" type="primary" :loading="tokenCreateLoading" @click="createApiToken">创建</el-button>
</span>
</el-dialog>
<el-dialog
title="Token 已创建"
:visible.sync="createdTokenDialogVisible"
width="620px"
append-to-body
custom-class="token-created-dialog">
<div class="token-once-warning">
<i class="fas fa-exclamation-triangle"></i>
完整 Token 仅在此处展示一次,请立即复制并妥善保存。
</div>
<el-input
type="textarea"
:rows="3"
readonly
:value="createdTokenValue">
</el-input>
<div class="token-created-actions">
<el-button size="small" type="primary" @click="copyCreatedTokenValue">
{{ createdTokenCopied ? '已复制 ✓' : '复制 Token' }}
</el-button>
</div>
</el-dialog>
<!-- 预览弹窗 - 模态框模式(不跳转新标签页) -->
<div class="preview-modal" v-if="previewData" @click="closePreview">
<button class="preview-close" @click.stop="closePreview">
<i class="fas fa-times"></i>
</button>
<!-- 原生图片预览 -->
<img v-if="previewData.type === 'native-image'" :src="previewData.url" @click.stop>
<!-- iframe 预览(视频/音频/文档) -->
<div v-else-if="previewData.type === 'iframe'" class="iframe-container" @click.stop>
<iframe
ref="previewIframe"
:src="previewData.iframeUrl"
allow="clipboard-write; autoplay; fullscreen; encrypted-media"
allowfullscreen>
</iframe>
</div>
<!-- 底部操作栏 -->
<div class="preview-toolbar" @click.stop>
<span class="preview-filename">{{ previewData.fileName }}</span>
<div class="preview-btns">
<el-button size="small" type="primary" @click="copyPreviewLink"><i class="fas fa-copy"></i> 复制直链</el-button>
<el-button size="small" @click="downloadPreviewFile"><i class="fas fa-download"></i> 下载</el-button>
</div>
</div>
</div>
<!-- 底部悬浮批量操作栏 -->
<transition name="batch-float">
<div class="batch-toolbar"
v-if="selectedFiles.length > 0"
ref="batchToolbar"
@mousedown.stop="startBatchDrag"
:class="{ 'is-dragging': batchDragState.dragging }"
:style="batchToolbarStyle">
<span class="batch-count">已选 {{ selectedFiles.length }} 项</span>
<el-button-group size="mini" class="batch-actions">
<el-button class="batch-btn batch-btn-copy" icon="el-icon-document-copy" @click="handleBatchCopy">复制链接</el-button>
<el-button class="batch-btn batch-btn-move" icon="el-icon-folder-opened" @click="moveSelectedToFolder">移动目录</el-button>
<el-button class="batch-btn batch-btn-delete" icon="el-icon-delete" @click="handleBatchDelete">删除</el-button>
<el-button class="batch-btn batch-btn-download" icon="el-icon-download" @click="handleBatchDownload">下载</el-button>
</el-button-group>
<el-button class="batch-btn batch-btn-cancel" icon="el-icon-close" @click="clearSelection">取消选择</el-button>
<span class="batch-shortcuts">捷键Ctrl/Cmd + C 复制 · M 移动 · D 下载 · Del 删除 · Esc 取消</span>
</div>
</transition>
</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: {
all: {
name: '全部',
exts: [], // 空数组表示匹配所有
icon: 'fas fa-th-large',
count: 0
},
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: 24,
selectedFiles: [],
sortOption: 'dateDesc',
filterOption: 'all',
fileType: 'all',
storageFilter: 'all', // all | telegram | r2
viewMode: 'grid', // grid | list
folders: [],
folderPath: '',
folderLoading: false,
folderMutating: false,
folderMutatingAction: '',
listRequestSeq: 0,
isMobileViewport: false,
mobileNavMetricsRaf: 0,
headerResizeObserver: null,
folderListCache: {},
folderCacheTTL: 3 * 60 * 1000,
folderCacheMaxEntries: 12,
dragState: {
active: false,
fileIds: [],
targetPath: null,
leaveTimer: null,
rafId: null,
},
loadMode: 'normal', // normal | dataSaver | noImage
safeMode: true, // NSFW 安全模式
systemStatus: {
telegram: { connected: false, message: '检查中...' },
kv: { connected: false, message: '检查中...' },
r2: { connected: false, message: '检查中...', enabled: false },
s3: { connected: false, message: '检查中...', enabled: false },
discord: { connected: false, message: '检查中...', enabled: false },
huggingface: { connected: false, message: '检查中...', enabled: false },
github: { connected: false, message: '检查中...', enabled: false },
auth: { enabled: false, message: '检查中...' }
},
batchToolbarPosition: {
left: null,
top: null
},
batchDragState: {
dragging: false,
offsetX: 0,
offsetY: 0
},
previewData: null, // 模态框预览数据
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' },
],
tokenDialogVisible: false,
tokenLoading: false,
createTokenDialogVisible: false,
tokenCreateLoading: false,
createdTokenDialogVisible: false,
createdTokenValue: '',
createdTokenCopied: false,
apiTokens: [],
tokenMutatingMap: {},
tokenForm: {
name: '',
scopes: ['upload', 'read'],
expiryPreset: 'never',
customExpiresAt: '',
},
},
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();
let matchesType = true;
if (this.fileType === 'all') {
// 全部模式:显示所有文件
matchesType = true;
} else if (this.fileType === 'document') {
// 文档模式:排除图片、视频、音频
matchesType = !Object.keys(this.fileConfig).some(type =>
type !== 'document' && type !== 'all' && this.fileConfig[type].exts.includes(ext)
);
} else {
// 特定类型模式
matchesType = this.fileConfig[this.fileType].exts.includes(ext);
}
// 存储类型匹配
const storagePrefix = this.getStorageType(data.name);
const matchesStorage = this.storageFilter === 'all' || this.storageFilter === storagePrefix;
// 目录筛选由后端 list 接口按 folderPath 负责,前端此处不再重复过滤,
// 避免“全部文件”分页出现空白页。
const matchesFolder = true;
return matchesSearch && matchesFilter && matchesType && matchesStorage && matchesFolder;
});
},
paginatedTableData() {
return this.sortData(this.filteredTableData)
.slice((this.currentPage - 1) * this.pageSize, this.currentPage * this.pageSize);
},
paginationTotal() {
return this.filteredTableData.length;
},
paginatedData() {
return this.paginatedTableData;
},
isAllSelected() {
return this.paginatedData.length > 0 && this.paginatedData.every(f => f.selected);
},
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 === 'all' ? 'fas fa-th-large' :
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' : 'fas fa-th-large';
},
storageFilterIcon() {
return this.storageFilter === 'all' ? 'fas fa-database' :
this.storageFilter === 'telegram' ? 'fab fa-telegram' :
this.storageFilter === 'r2' ? 'fas fa-cloud' :
this.storageFilter === 's3' ? 'fas fa-database' :
this.storageFilter === 'discord' ? 'fab fa-discord' :
this.storageFilter === 'huggingface' ? 'fas fa-robot' :
this.storageFilter === 'webdav' ? 'fas fa-hard-drive' :
this.storageFilter === 'github' ? 'fab fa-github' : 'fas fa-database';
},
viewModeIcon() {
return this.viewMode === 'list' ? 'fas fa-list' : 'fas fa-th-large';
},
folderBreadcrumbs() {
const parts = this.normalizeFolderPath(this.folderPath).split('/').filter(Boolean);
const result = [];
for (let i = 0; i < parts.length; i += 1) {
result.push({
name: parts[i],
path: parts.slice(0, i + 1).join('/'),
});
}
return result;
},
folderTreeNodes() {
const list = [{
path: '',
name: '全部文件',
depth: 0,
fileCount: this.folderPath ? '-' : this.Number,
}];
const nodes = (this.folders || []).map((folder) => {
const path = this.normalizeFolderPath(folder.path || folder.folderPath || '');
const depth = Number.isFinite(folder.depth) ? folder.depth : (path ? path.split('/').length : 0);
return {
path,
name: folder.name || (path.split('/').pop() || path || '未命名目录'),
depth,
fileCount: Number(folder.fileCount || 0),
};
}).sort((a, b) => {
if (a.depth !== b.depth) return a.depth - b.depth;
return a.path.localeCompare(b.path, 'zh-CN');
});
return list.concat(nodes);
},
batchToolbarStyle() {
if (this.isMobileViewport) {
return {};
}
if (this.batchToolbarPosition.left === null || this.batchToolbarPosition.top === null) {
return {};
}
return {
left: `${this.batchToolbarPosition.left}px`,
top: `${this.batchToolbarPosition.top}px`,
bottom: 'auto',
transform: 'translate(0, 0)'
};
}
},
watch: { // 监听数据变化
tableData: {
handler(newData) {
this.selectedFiles = newData.filter(file => file.selected);
},
deep: true
},
search() {
this.currentPage = 1;
this.normalizeCurrentPage();
},
selectedFiles(newValue) {
if (newValue.length === 0) {
this.batchToolbarPosition.left = null;
this.batchToolbarPosition.top = null;
return;
}
if (this.isMobileViewport) {
this.batchToolbarPosition.left = null;
this.batchToolbarPosition.top = null;
return;
}
if (this.batchToolbarPosition.left === null) {
this.$nextTick(() => this.snapBatchToolbarToBottom());
}
},
sortOption(newOption) { localStorage.setItem('sortOption', newOption); },
filterOption(newOption) {
localStorage.setItem('filterOption', newOption);
this.currentPage = 1;
this.normalizeCurrentPage();
},
paginationTotal() {
this.normalizeCurrentPage();
},
viewMode(newValue) { localStorage.setItem('adminViewMode', newValue); },
folderPath(newValue) { localStorage.setItem('adminFolderPath', this.normalizeFolderPath(newValue)); }
},
methods: {
normalizeFolderPath(value = '') {
const raw = String(value || '').replace(/\\/g, '/').trim();
const output = [];
raw.split('/').forEach((part) => {
const piece = part.trim();
if (!piece || piece === '.') return;
if (piece === '..') {
output.pop();
return;
}
output.push(piece);
});
return output.join('/');
},
isUnconfiguredStatus(item = {}) {
const message = String(item?.message || '').trim().toLowerCase();
return message === '未配置' || message === 'not configured' || message.includes('not configured');
},
tokenScopeTagType(scopeName = '') {
const map = {
upload: '',
read: 'success',
delete: 'danger',
paste: 'warning',
};
return map[String(scopeName || '').toLowerCase()] || 'info';
},
formatDateTime(timestamp) {
const value = Number(timestamp || 0);
if (!Number.isFinite(value) || value <= 0) return '-';
return new Date(value).toLocaleString('zh-CN', { hour12: false });
},
formatRelativeTime(timestamp) {
const value = Number(timestamp || 0);
if (!Number.isFinite(value) || value <= 0) return '从未';
const diff = Date.now() - value;
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) return '刚刚';
if (diff < hour) return `${Math.floor(diff / minute)} 分钟前`;
if (diff < day) return `${Math.floor(diff / hour)} 小时前`;
if (diff < day * 30) return `${Math.floor(diff / day)} 天前`;
return this.formatDateTime(value);
},
openApiTokenDialog() {
this.tokenDialogVisible = true;
this.loadApiTokens();
},
openCreateTokenDialog() {
this.tokenForm = {
name: '',
scopes: ['upload', 'read'],
expiryPreset: 'never',
customExpiresAt: '',
};
this.createTokenDialogVisible = true;
},
resolveTokenExpiresAt() {
const preset = String(this.tokenForm.expiryPreset || 'never');
if (preset === '7d') return Date.now() + 7 * 24 * 3600 * 1000;
if (preset === '30d') return Date.now() + 30 * 24 * 3600 * 1000;
if (preset === '90d') return Date.now() + 90 * 24 * 3600 * 1000;
if (preset === 'custom') {
const custom = Number(this.tokenForm.customExpiresAt || 0);
return Number.isFinite(custom) && custom > Date.now() ? custom : null;
}
return null;
},
async loadApiTokens() {
this.tokenLoading = true;
try {
const response = await fetch('./api/admin/tokens', {
method: 'GET',
credentials: 'include',
});
const payload = await response.json();
if (!response.ok || !payload?.success) {
throw new Error(payload?.error?.message || payload?.message || '加载 Token 列表失败。');
}
this.apiTokens = Array.isArray(payload.tokens) ? payload.tokens : [];
} catch (error) {
this.$message.error(error.message || '加载 API Token 失败。');
} finally {
this.tokenLoading = false;
}
},
async createApiToken() {
const name = String(this.tokenForm.name || '').trim();
const scopes = Array.isArray(this.tokenForm.scopes) ? this.tokenForm.scopes : [];
if (!name) {
this.$message.warning('请输入 Token 名称。');
return;
}
if (scopes.length === 0) {
this.$message.warning('请至少选择一个权限。');
return;
}
const expiresAt = this.resolveTokenExpiresAt();
if (String(this.tokenForm.expiryPreset) === 'custom' && !expiresAt) {
this.$message.warning('请选择有效的未来过期时间。');
return;
}
this.tokenCreateLoading = true;
try {
const response = await fetch('./api/admin/tokens', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
scopes,
expiresAt,
}),
});
const payload = await response.json();
if (!response.ok || !payload?.success) {
throw new Error(payload?.error?.message || payload?.message || '创建 Token 失败。');
}
this.createTokenDialogVisible = false;
this.createdTokenValue = String(payload.token || '');
this.createdTokenCopied = false;
this.createdTokenDialogVisible = true;
await this.loadApiTokens();
} catch (error) {
this.$message.error(error.message || '创建 Token 失败。');
} finally {
this.tokenCreateLoading = false;
}
},
async copyCreatedTokenValue() {
const token = String(this.createdTokenValue || '');
if (!token) return;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(token);
} else {
this.copyToClipboardFallback(token);
}
this.createdTokenCopied = true;
} catch {
this.copyToClipboardFallback(token);
this.createdTokenCopied = true;
}
},
async toggleApiTokenEnabled(token) {
const tokenId = token?.id;
if (!tokenId) return;
const nextEnabled = Boolean(token.enabled);
this.$set(this.tokenMutatingMap, tokenId, true);
try {
const response = await fetch(`./api/admin/tokens/${encodeURIComponent(tokenId)}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: nextEnabled }),
});
const payload = await response.json();
if (!response.ok || !payload?.success) {
throw new Error(payload?.error?.message || payload?.message || '更新 Token 状态失败。');
}
this.$message.success(nextEnabled ? 'Token 已启用。' : 'Token 已禁用。');
} catch (error) {
token.enabled = !nextEnabled;
this.$message.error(error.message || '更新 Token 状态失败。');
} finally {
this.$delete(this.tokenMutatingMap, tokenId);
}
},
async removeApiToken(token) {
const tokenId = token?.id;
if (!tokenId) return;
try {
await this.$confirm(`确认删除 Token「${token.name}」吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
this.$set(this.tokenMutatingMap, tokenId, true);
try {
const response = await fetch(`./api/admin/tokens/${encodeURIComponent(tokenId)}`, {
method: 'DELETE',
credentials: 'include',
});
const payload = await response.json();
if (!response.ok || !payload?.success) {
throw new Error(payload?.error?.message || payload?.message || '删除 Token 失败。');
}
this.apiTokens = this.apiTokens.filter((item) => item.id !== tokenId);
this.$message.success('Token 已删除。');
} catch (error) {
this.$message.error(error.message || '删除 Token 失败。');
} finally {
this.$delete(this.tokenMutatingMap, tokenId);
}
},
queueMobileNavMetricsUpdate() {
if (this.mobileNavMetricsRaf) {
cancelAnimationFrame(this.mobileNavMetricsRaf);
}
this.mobileNavMetricsRaf = requestAnimationFrame(() => {
this.mobileNavMetricsRaf = 0;
this.updateMobileNavMetrics();
});
},
updateMobileNavMetrics() {
const root = document.documentElement;
if (!root) return;
const isMobile = window.matchMedia('(max-width: 900px)').matches;
if (!isMobile) {
root.style.setProperty('--nav-height', '0px');
root.style.setProperty('--nav-offset', '0px');
return;
}
const header = (this.$el && this.$el.querySelector('.header-content')) || document.querySelector('.header-content');
if (!header) return;
const rect = header.getBoundingClientRect();
const computed = window.getComputedStyle(header);
const top = Number.parseFloat(computed.top) || 0;
const marginBottom = Number.parseFloat(computed.marginBottom) || 0;
const navHeight = Math.max(0, Math.ceil(rect.height));
const navOffset = Math.max(0, Math.ceil(top + navHeight + marginBottom));
root.style.setProperty('--nav-height', `${navHeight}px`);
root.style.setProperty('--nav-offset', `${navOffset}px`);
},
observeMobileNavHeight() {
if (typeof ResizeObserver !== 'function') return;
this.unobserveMobileNavHeight();
const header = (this.$el && this.$el.querySelector('.header-content')) || document.querySelector('.header-content');
if (!header) return;
this.headerResizeObserver = new ResizeObserver(() => {
this.queueMobileNavMetricsUpdate();
});
this.headerResizeObserver.observe(header);
},
unobserveMobileNavHeight() {
if (this.headerResizeObserver && typeof this.headerResizeObserver.disconnect === 'function') {
this.headerResizeObserver.disconnect();
}
this.headerResizeObserver = null;
},
updateViewportFlags() {
this.isMobileViewport = window.matchMedia('(max-width: 900px)').matches;
if (this.isMobileViewport) {
this.batchToolbarPosition.left = null;
this.batchToolbarPosition.top = null;
this.batchDragState.dragging = false;
}
this.queueMobileNavMetricsUpdate();
},
restoreFolderCache() {
try {
const raw = localStorage.getItem('adminFolderListCache');
const parsed = raw ? JSON.parse(raw) : {};
this.folderListCache = parsed && typeof parsed === 'object' ? parsed : {};
this.pruneFolderCache();
} catch {
this.folderListCache = {};
}
},
persistFolderCache() {
try {
localStorage.setItem('adminFolderListCache', JSON.stringify(this.folderListCache));
} catch {
// ignore storage quota errors
}
},
pruneFolderCache() {
const now = Date.now();
const entries = Object.entries(this.folderListCache || {})
.filter(([, value]) => value && Number.isFinite(Number(value.time)) && (now - Number(value.time) <= this.folderCacheTTL))
.sort((a, b) => Number(b[1].time || 0) - Number(a[1].time || 0))
.slice(0, this.folderCacheMaxEntries);
this.folderListCache = Object.fromEntries(entries);
},
getFolderCacheKey() {
const folder = this.normalizeFolderPath(this.folderPath || '');
const storage = String(this.storageFilter || 'all').toLowerCase();
return `${storage}::${folder}`;
},
cloneRowsForCache(rows = []) {
return rows.map((item) => ({
...item,
selected: false,
metadata: { ...(item.metadata || {}) },
}));
},
readFolderCache(cacheKey) {
const entry = this.folderListCache?.[cacheKey];
if (!entry) return null;
if (!Number.isFinite(Number(entry.time)) || (Date.now() - Number(entry.time) > this.folderCacheTTL)) {
delete this.folderListCache[cacheKey];
this.persistFolderCache();
return null;
}
return entry;
},
applyFolderCacheEntry(entry) {
if (!entry) return;
this.tableData = this.cloneRowsForCache(entry.rows || []);
this.nextCursor = entry.nextCursor || null;
this.totalCount = Number.isFinite(entry.totalCount) ? Number(entry.totalCount) : this.tableData.length;
this.updateStats();
this.calculatePageSize();
this.sortData(this.tableData);
this.normalizeCurrentPage();
},
writeFolderCache(cacheKey) {
if (!cacheKey) return;
this.folderListCache[cacheKey] = {
time: Date.now(),
rows: this.cloneRowsForCache(this.tableData),
nextCursor: this.nextCursor,
totalCount: this.totalCount,
};
this.pruneFolderCache();
this.persistFolderCache();
},
clearFolderCache() {
this.folderListCache = {};
try {
localStorage.removeItem('adminFolderListCache');
} catch {
// ignore
}
},
getStorageType(name = '') {
if (String(name).startsWith('r2:')) return 'r2';
if (String(name).startsWith('s3:')) return 's3';
if (String(name).startsWith('discord:')) return 'discord';
if (String(name).startsWith('hf:')) return 'huggingface';
if (String(name).startsWith('webdav:')) return 'webdav';
if (String(name).startsWith('github:')) return 'github';
return 'telegram';
},
getStorageLabel(name = '') {
const type = this.getStorageType(name);
const map = {
telegram: 'Telegram',
r2: 'R2',
s3: 'S3',
discord: 'Discord',
huggingface: 'HuggingFace',
webdav: 'WebDAV',
github: 'GitHub',
};
return map[type] || 'Telegram';
},
toggleSelectByName(name) {
const target = this.tableData.find((item) => item.name === name);
if (target) target.selected = !target.selected;
},
switchViewMode(mode) {
if (!['grid', 'list'].includes(mode)) return;
this.viewMode = mode;
},
cloneFoldersSnapshot() {
return Array.isArray(this.folders) ? this.folders.map((item) => ({ ...item })) : [];
},
sortFoldersLocal() {
this.folders = [...(this.folders || [])].sort((a, b) => {
const pathA = this.normalizeFolderPath(a.path || a.folderPath || '');
const pathB = this.normalizeFolderPath(b.path || b.folderPath || '');
const depthA = Number.isFinite(a.depth) ? a.depth : (pathA ? pathA.split('/').length : 0);
const depthB = Number.isFinite(b.depth) ? b.depth : (pathB ? pathB.split('/').length : 0);
if (depthA !== depthB) return depthA - depthB;
return pathA.localeCompare(pathB, 'zh-CN', { sensitivity: 'base' });
});
},
buildFolderNode(path, fileCount = 0) {
const normalized = this.normalizeFolderPath(path);
if (!normalized) return null;
const segments = normalized.split('/');
return {
path: normalized,
name: segments[segments.length - 1] || normalized,
parentPath: segments.length > 1 ? segments.slice(0, -1).join('/') : '',
depth: segments.length,
fileCount: Number(fileCount || 0),
};
},
ensureFolderBranchLocal(path) {
const normalized = this.normalizeFolderPath(path);
if (!normalized) return;
const parts = normalized.split('/');
let current = '';
parts.forEach((part) => {
current = current ? `${current}/${part}` : part;
const exists = (this.folders || []).some((folder) => {
return this.normalizeFolderPath(folder.path || folder.folderPath || '') === current;
});
if (!exists) {
const node = this.buildFolderNode(current, 0);
if (node) this.folders.push(node);
}
});
this.sortFoldersLocal();
},
renameFolderBranchLocal(sourcePath, targetPath) {
const source = this.normalizeFolderPath(sourcePath);
const target = this.normalizeFolderPath(targetPath);
if (!source || !target || source === target) return;
this.folders = (this.folders || []).map((folder) => {
const current = this.normalizeFolderPath(folder.path || folder.folderPath || '');
if (!current || (current !== source && !current.startsWith(`${source}/`))) {
return folder;
}
const suffix = current === source ? '' : current.slice(source.length + 1);
const nextPath = suffix ? `${target}/${suffix}` : target;
const nextNode = this.buildFolderNode(nextPath, Number(folder.fileCount || 0));
return nextNode ? { ...folder, ...nextNode } : folder;
});
this.sortFoldersLocal();
},
removeFolderBranchLocal(path) {
const target = this.normalizeFolderPath(path);
if (!target) return;
this.folders = (this.folders || []).filter((folder) => {
const current = this.normalizeFolderPath(folder.path || folder.folderPath || '');
return current !== target && !current.startsWith(`${target}/`);
});
this.sortFoldersLocal();
},
async selectFolder(path = '') {
const normalized = this.normalizeFolderPath(path);
if (normalized === this.folderPath && this.tableData.length > 0) return;
this.folderPath = normalized;
this.currentPage = 1;
await this.refreshFileList({ preferCache: true });
},
async fetchFolders() {
this.folderLoading = true;
try {
const params = new URLSearchParams();
if (this.storageFilter && this.storageFilter !== 'all') {
params.set('storage', this.storageFilter);
}
const query = params.toString();
const response = await fetch(`./api/manage/folders${query ? `?${query}` : ''}`, { method: 'GET', credentials: 'include' });
const data = await response.json();
this.folders = Array.isArray(data?.folders) ? data.folders : [];
} catch (error) {
this.folders = [];
this.$message.error('目录列表加载失败');
} finally {
this.folderLoading = false;
}
},
async refreshFolderResources(showMessage = true) {
try {
this.clearFolderCache();
await Promise.all([this.fetchFolders(), this.refreshFileList({ force: true })]);
if (showMessage) {
this.$message.success('目录与文件已刷新');
}
} catch (error) {
if (showMessage) {
this.$message.error(error?.message || '刷新失败,请稍后重试');
}
}
},
async createFolder() {
if (this.folderMutating) return;
const seed = this.folderPath ? `${this.folderPath}/新目录` : '新目录';
try {
const prompt = await this.$prompt('请输入目录路径例如项目A/一月)', '新建目录', {
inputValue: seed,
confirmButtonText: '创建',
cancelButtonText: '取消',
inputValidator: (value) => !!this.normalizeFolderPath(value) || '目录路径不能为空',
});
const path = this.normalizeFolderPath(prompt.value);
if (!path) return;
const existed = (this.folders || []).some((folder) => this.normalizeFolderPath(folder.path || folder.folderPath || '') === path);
if (existed) {
await this.selectFolder(path);
this.$message.info('目录已存在,已切换到该目录');
return;
}
const previousFolders = this.cloneFoldersSnapshot();
const previousFolderPath = this.folderPath;
this.folderMutating = true;
this.folderMutatingAction = 'create';
this.ensureFolderBranchLocal(path);
this.folderPath = path;
this.currentPage = 1;
this.tableData = [];
this.nextCursor = null;
try {
const response = await fetch('./api/manage/folders', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
}).then((r) => r.json());
if (!response?.success) throw new Error(response?.error || '目录创建失败');
this.$message.success('目录创建成功');
this.clearFolderCache();
await Promise.all([this.fetchFolders(), this.refreshFileList()]);
} catch (error) {
this.folders = previousFolders;
this.folderPath = previousFolderPath;
await this.refreshFileList();
this.$message.error(error.message || '目录创建失败');
} finally {
this.folderMutating = false;
this.folderMutatingAction = '';
}
} catch (error) {
if (error !== 'cancel' && error !== 'close') this.$message.error(error.message || '目录创建失败');
}
},
async renameCurrentFolder() {
if (!this.folderPath || this.folderMutating) return;
const parts = this.folderPath.split('/');
const oldName = parts[parts.length - 1] || '';
const prefix = parts.slice(0, -1).join('/');
try {
const prompt = await this.$prompt('请输入新的目录名称', '重命名目录', {
inputValue: oldName,
confirmButtonText: '保存',
cancelButtonText: '取消',
inputValidator: (value) => !!String(value || '').trim() || '目录名称不能为空',
});
const newName = String(prompt.value || '').trim();
const targetPath = this.normalizeFolderPath(prefix ? `${prefix}/${newName}` : newName);
if (!targetPath || targetPath === this.folderPath) return;
const sourcePath = this.folderPath;
const previousFolders = this.cloneFoldersSnapshot();
this.folderMutating = true;
this.folderMutatingAction = 'rename';
this.renameFolderBranchLocal(sourcePath, targetPath);
this.folderPath = targetPath;
try {
const res = await fetch('./api/manage/folders', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourcePath, targetPath }),
}).then((r) => r.json());
if (!res?.success) throw new Error(res?.error || '重命名失败');
this.$message.success('目录重命名成功');
this.clearFolderCache();
await Promise.all([this.fetchFolders(), this.refreshFileList()]);
} catch (error) {
this.folders = previousFolders;
this.folderPath = sourcePath;
await this.refreshFileList();
this.$message.error(error.message || '目录重命名失败');
} finally {
this.folderMutating = false;
this.folderMutatingAction = '';
}
} catch (error) {
if (error !== 'cancel' && error !== 'close') this.$message.error(error.message || '目录重命名失败');
}
},
async deleteCurrentFolder() {
if (!this.folderPath || this.folderMutating) return;
try {
await this.$confirm('确认删除该目录标记并将目录内文件移回根目录?文件直链不会失效。', '删除目录', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
const target = this.folderPath;
const parent = target.split('/').slice(0, -1).join('/');
const previousFolders = this.cloneFoldersSnapshot();
const previousFolderPath = this.folderPath;
this.folderMutating = true;
this.folderMutatingAction = 'delete';
this.removeFolderBranchLocal(target);
this.folderPath = this.normalizeFolderPath(parent);
this.currentPage = 1;
this.tableData = [];
this.nextCursor = null;
try {
const response = await fetch(`./api/manage/folders?path=${encodeURIComponent(target)}&recursive=1`, {
method: 'DELETE',
credentials: 'include',
}).then((r) => r.json());
if (!response?.success) throw new Error(response?.error || '删除目录失败');
this.clearFolderCache();
await Promise.all([this.fetchFolders(), this.refreshFileList()]);
this.$message.success('目录删除成功');
} catch (error) {
this.folders = previousFolders;
this.folderPath = previousFolderPath;
await this.refreshFileList();
this.$message.error(error.message || '目录删除失败');
} finally {
this.folderMutating = false;
this.folderMutatingAction = '';
}
} catch (error) {
if (error !== 'cancel' && error !== 'close') this.$message.error(error.message || '目录删除失败');
}
},
async moveSelectedToFolder() {
if (this.selectedFiles.length === 0) {
this.$message.warning('请先选择文件');
return;
}
let previousRows = null;
let previousFolders = null;
try {
const prompt = await this.$prompt('请输入目标目录路径(留空表示根目录)', '移动文件', {
inputValue: this.folderPath || '',
confirmButtonText: '移动',
cancelButtonText: '取消',
});
const targetFolderPath = this.normalizeFolderPath(prompt.value || '');
const ids = this.selectedFiles.map((item) => item.name);
const hasEffectiveMove = ids.some((id) => {
const file = this.tableData.find((item) => item.name === id);
const current = this.normalizeFolderPath(file?.metadata?.folderPath || '');
return current !== targetFolderPath;
});
if (!hasEffectiveMove) {
this.$message.info('所选文件已在当前目录');
return;
}
const idSet = new Set(ids);
previousRows = this.tableData.map((file) => ({
...file,
metadata: { ...(file.metadata || {}) },
}));
previousFolders = this.cloneFoldersSnapshot();
const normalizedCurrentFolder = this.normalizeFolderPath(this.folderPath);
const movedRows = this.tableData
.filter((row) => idSet.has(row.name))
.map((row) => ({
name: row.name,
sourceFolderPath: this.normalizeFolderPath(row?.metadata?.folderPath || ''),
}));
this.tableData = this.tableData
.map((row) => {
if (!idSet.has(row.name)) return row;
return {
...row,
metadata: {
...(row.metadata || {}),
folderPath: targetFolderPath,
},
selected: false,
};
})
.filter((row) => this.normalizeFolderPath(row?.metadata?.folderPath || '') === normalizedCurrentFolder);
movedRows.forEach((moved) => {
const sourceNode = (this.folders || []).find((folder) => {
const candidate = this.normalizeFolderPath(folder.path || folder.folderPath || '');
return candidate === moved.sourceFolderPath;
});
if (sourceNode && moved.sourceFolderPath !== targetFolderPath) {
sourceNode.fileCount = Math.max(0, Number(sourceNode.fileCount || 0) - 1);
}
});
if (targetFolderPath) {
this.ensureFolderBranchLocal(targetFolderPath);
const targetNode = (this.folders || []).find((folder) => {
const candidate = this.normalizeFolderPath(folder.path || folder.folderPath || '');
return candidate === targetFolderPath;
});
if (targetNode) {
const addCount = movedRows.filter((item) => item.sourceFolderPath !== targetFolderPath).length;
targetNode.fileCount = Number(targetNode.fileCount || 0) + addCount;
}
}
this.sortFoldersLocal();
this.updateStats();
const result = await fetch('./api/manage/files/move-folder', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, targetFolderPath }),
}).then((r) => r.json());
if (!result.success) throw new Error(result.error || '移动失败');
this.$message.success(`已移动 ${result.moved || 0} 个文件到 ${targetFolderPath || '根目录'}`);
this.clearFolderCache();
await this.fetchFolders();
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
if (previousRows) {
this.tableData = previousRows;
}
if (previousFolders) {
this.folders = previousFolders;
}
this.updateStats();
this.$message.error(error.message || '移动文件失败');
}
}
},
resetViewConditions() {
this.search = '';
this.filterOption = 'all';
this.storageFilter = 'all';
this.fileType = 'all';
this.folderPath = '';
this.currentPage = 1;
this.refreshFileList({ syncFolders: true });
this.$message.success('已恢复默认筛选');
},
resolveDraggedIds(item) {
if (!item || !item.name) return [];
if (item.selected) {
const selected = this.selectedFiles.map((file) => file.name).filter(Boolean);
if (selected.length > 0) return selected;
}
return [item.name];
},
handleFileDragStart(item, event) {
const ids = this.resolveDraggedIds(item);
if (!ids.length) return;
if (event?.currentTarget?.classList) {
event.currentTarget.classList.add('dragging-file-card');
}
this.dragState.active = true;
this.dragState.fileIds = ids;
this.dragState.targetPath = null;
document.body.classList.add('is-dragging-files');
if (event && event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('application/x-kvault-files', JSON.stringify(ids));
event.dataTransfer.setData('text/plain', ids.join(','));
const dragGhost = document.createElement('div');
dragGhost.textContent = ids.length > 1 ? `移动 ${ids.length} 个文件` : (item?.metadata?.fileName || item?.name || '移动文件');
dragGhost.style.cssText = 'position:fixed;top:-9999px;left:-9999px;padding:8px 12px;border-radius:999px;background:rgba(19,29,48,0.92);color:#fff;font-size:12px;pointer-events:none;z-index:9999;';
document.body.appendChild(dragGhost);
event.dataTransfer.setDragImage(dragGhost, 18, 16);
requestAnimationFrame(() => dragGhost.remove());
}
},
handleFileDragEnd() {
if (this.dragState.leaveTimer) {
clearTimeout(this.dragState.leaveTimer);
this.dragState.leaveTimer = null;
}
if (this.dragState.rafId) {
cancelAnimationFrame(this.dragState.rafId);
this.dragState.rafId = null;
}
this.dragState.active = false;
this.dragState.fileIds = [];
this.dragState.targetPath = null;
document.body.classList.remove('is-dragging-files');
document.querySelectorAll('.dragging-file-card').forEach((node) => node.classList.remove('dragging-file-card'));
},
handleFolderDragOver(path, event) {
if (!this.dragState.active) return;
if (event && event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
},
handleFolderDragEnter(path) {
if (!this.dragState.active) return;
const normalizedPath = this.normalizeFolderPath(path);
if (this.dragState.leaveTimer) {
clearTimeout(this.dragState.leaveTimer);
this.dragState.leaveTimer = null;
}
if (this.dragState.targetPath === normalizedPath) return;
if (this.dragState.rafId) {
cancelAnimationFrame(this.dragState.rafId);
this.dragState.rafId = null;
}
this.dragState.rafId = requestAnimationFrame(() => {
this.dragState.targetPath = normalizedPath;
this.dragState.rafId = null;
});
},
handleFolderDragLeave(path, event) {
if (!this.dragState.active) return;
const currentTarget = event?.currentTarget;
const relatedTarget = event?.relatedTarget;
if (currentTarget && relatedTarget && currentTarget.contains(relatedTarget)) {
return;
}
const normalizedPath = this.normalizeFolderPath(path);
if (this.dragState.leaveTimer) {
clearTimeout(this.dragState.leaveTimer);
}
this.dragState.leaveTimer = setTimeout(() => {
if (this.dragState.targetPath === normalizedPath) {
this.dragState.targetPath = null;
}
this.dragState.leaveTimer = null;
}, 60);
},
extractDragIds(event) {
if (this.dragState.fileIds.length > 0) return this.dragState.fileIds;
const raw = event?.dataTransfer?.getData('application/x-kvault-files') || '';
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.map((item) => String(item || '')).filter(Boolean) : [];
} catch {
return [];
}
},
async handleFolderDrop(path, event) {
const targetFolderPath = this.normalizeFolderPath(path);
const ids = this.extractDragIds(event);
if (!ids.length) {
this.handleFileDragEnd();
return;
}
const hasEffectiveMove = ids.some((id) => {
const file = this.tableData.find((item) => item.name === id);
const current = this.normalizeFolderPath(file?.metadata?.folderPath || '');
return current !== targetFolderPath;
});
if (!hasEffectiveMove) {
this.handleFileDragEnd();
return;
}
const idSet = new Set(ids);
const previousRows = this.tableData.map((file) => ({
...file,
metadata: { ...(file.metadata || {}) },
}));
const previousFolders = this.cloneFoldersSnapshot();
const normalizedCurrentFolder = this.normalizeFolderPath(this.folderPath);
const movedRows = this.tableData
.filter((row) => idSet.has(row.name))
.map((row) => ({
name: row.name,
sourceFolderPath: this.normalizeFolderPath(row?.metadata?.folderPath || ''),
}));
// 先本地更新,拖拽交互更流畅
this.tableData = this.tableData
.map((row) => {
if (!idSet.has(row.name)) return row;
return {
...row,
metadata: {
...(row.metadata || {}),
folderPath: targetFolderPath,
},
selected: false,
};
})
.filter((row) => this.normalizeFolderPath(row?.metadata?.folderPath || '') === normalizedCurrentFolder);
movedRows.forEach((moved) => {
const sourceNode = (this.folders || []).find((folder) => {
const candidate = this.normalizeFolderPath(folder.path || folder.folderPath || '');
return candidate === moved.sourceFolderPath;
});
if (sourceNode && moved.sourceFolderPath !== targetFolderPath) {
sourceNode.fileCount = Math.max(0, Number(sourceNode.fileCount || 0) - 1);
}
});
if (targetFolderPath) {
this.ensureFolderBranchLocal(targetFolderPath);
const targetNode = (this.folders || []).find((folder) => {
const candidate = this.normalizeFolderPath(folder.path || folder.folderPath || '');
return candidate === targetFolderPath;
});
if (targetNode) {
const addCount = movedRows.filter((item) => item.sourceFolderPath !== targetFolderPath).length;
targetNode.fileCount = Number(targetNode.fileCount || 0) + addCount;
}
}
this.sortFoldersLocal();
this.updateStats();
try {
const result = await fetch('./api/manage/files/move-folder', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, targetFolderPath }),
}).then((r) => r.json());
if (!result.success) throw new Error(result.error || '拖拽移动失败');
const folderLabel = targetFolderPath || '根目录';
this.$message.success(`已移动 ${result.moved || 0} 个文件到 ${folderLabel}`);
this.clearFolderCache();
await this.fetchFolders();
} catch (error) {
this.tableData = previousRows;
this.folders = previousFolders;
this.updateStats();
this.$message.error(error.message || '拖拽移动失败');
} finally {
this.handleFileDragEnd();
}
},
goHome() { window.location.href = './'; }, // 返回首页
refreshDashboard() { location.reload(); }, // 刷新页面
clearSelection() { // 取消全部选择
this.tableData.forEach(file => file.selected = false);
},
startBatchDrag(event) {
if (this.isMobileViewport) return;
if (event.button !== 0) return;
const toolbar = this.$refs.batchToolbar;
if (!toolbar) return;
const rect = toolbar.getBoundingClientRect();
this.batchDragState.dragging = true;
this.batchDragState.offsetX = event.clientX - rect.left;
this.batchDragState.offsetY = event.clientY - rect.top;
this.batchToolbarPosition.left = rect.left;
this.batchToolbarPosition.top = rect.top;
document.addEventListener('mousemove', this.onBatchDrag);
document.addEventListener('mouseup', this.stopBatchDrag);
},
onBatchDrag(event) {
if (!this.batchDragState.dragging) return;
const toolbar = this.$refs.batchToolbar;
if (!toolbar) return;
const width = toolbar.offsetWidth;
const height = toolbar.offsetHeight;
const padding = 12;
let left = event.clientX - this.batchDragState.offsetX;
let top = event.clientY - this.batchDragState.offsetY;
left = Math.max(padding, Math.min(left, window.innerWidth - width - padding));
top = Math.max(padding, Math.min(top, window.innerHeight - height - padding));
this.batchToolbarPosition.left = left;
this.batchToolbarPosition.top = top;
},
stopBatchDrag() {
if (this.isMobileViewport) return;
if (!this.batchDragState.dragging) return;
this.batchDragState.dragging = false;
document.removeEventListener('mousemove', this.onBatchDrag);
document.removeEventListener('mouseup', this.stopBatchDrag);
this.snapBatchToolbarToBottom();
},
snapBatchToolbarToBottom() {
if (this.isMobileViewport) {
this.batchToolbarPosition.left = null;
this.batchToolbarPosition.top = null;
return;
}
const toolbar = this.$refs.batchToolbar;
if (!toolbar) return;
const padding = 12;
const bottomOffset = 24;
const width = toolbar.offsetWidth;
const height = toolbar.offsetHeight;
let left = this.batchToolbarPosition.left ?? (window.innerWidth - width) / 2;
left = Math.max(padding, Math.min(left, window.innerWidth - width - padding));
this.batchToolbarPosition.left = left;
this.batchToolbarPosition.top = window.innerHeight - height - bottomOffset;
},
handleGlobalKeydown(event) {
if (this.selectedFiles.length === 0) return;
const target = event.target;
const isInput = target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
);
if (isInput) return;
const key = event.key.toLowerCase();
if ((event.ctrlKey || event.metaKey) && key === 'c') {
event.preventDefault();
this.handleBatchCopy();
return;
}
if (key === 'd') {
event.preventDefault();
this.handleBatchDownload();
return;
}
if (key === 'm') {
event.preventDefault();
this.moveSelectedToFolder();
return;
}
if (key === 'delete' || key === 'backspace') {
event.preventDefault();
this.handleBatchDelete();
return;
}
if (key === 'escape') {
event.preventDefault();
if (this.previewData) {
this.closePreview();
} else {
this.clearSelection();
}
}
},
// 获取文件的实际类型(用于模板条件渲染)
getActualFileType(fileName) {
const ext = fileName.split('.').pop().toLowerCase();
if (this.fileConfig.image.exts.includes(ext)) return 'image';
if (this.fileConfig.video.exts.includes(ext)) return 'video';
if (this.fileConfig.audio.exts.includes(ext)) return 'audio';
return 'document'; // 默认归类为文档
},
// 打开预览 - 使用模态框(与首页保持一致,不跳转新标签页)
openPreview(item) {
const fileName = item.metadata?.fileName || item.name;
const fileUrl = `${this.baseURL}/file/${item.name}`;
const fileType = this.getActualFileType(item.name);
// 图片类型 - 原生图片 Lightbox
if (fileType === 'image') {
this.previewData = {
type: 'native-image',
url: fileUrl,
fileName: fileName,
fileKey: item.name
};
return;
}
// 视频/音频/文档类型 - iframe 预览
const PREVIEW_SERVICE_BASE = 'https://www.katelya.eu.org';
const targetUrl = encodeURIComponent(fileUrl);
const targetTitle = encodeURIComponent(fileName);
const iframeSrc = `${PREVIEW_SERVICE_BASE}/preview?url=${targetUrl}&title=${targetTitle}&autoplay=1`;
this.previewData = {
type: 'iframe',
iframeUrl: iframeSrc,
url: fileUrl,
fileName: fileName,
fileKey: item.name
};
},
// 关闭预览弹窗
closePreview() {
if (this.$refs.previewIframe) {
this.$refs.previewIframe.src = 'about:blank';
}
this.previewData = null;
},
// 复制预览文件的直链
copyPreviewLink() {
if (!this.previewData) return;
const link = this.previewData.url;
const fallback = () => {
this.copyToClipboardFallback(link);
this.$message.success('直链已复制');
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(link)
.then(() => this.$message.success('直链已复制'))
.catch(() => fallback());
} else {
fallback();
}
},
// 下载预览中的文件
downloadPreviewFile() {
if (!this.previewData) return;
const a = document.createElement('a');
a.href = this.previewData.url;
a.download = this.previewData.fileName;
a.click();
},
async handleLogout() { // 退出登录
try {
const response = await fetch('./api/auth/logout', { method: 'POST', credentials: 'include' });
// 清除本地存储的认证信息
sessionStorage.clear();
localStorage.removeItem('storageMode');
// 尝试清除浏览器缓存的 Basic Auth 凭证
// 通过发送一个带有错误凭证的请求来清除缓存
try {
await fetch('./api/manage/list', {
method: 'GET',
headers: { 'Authorization': 'Basic ' + btoa('logout:logout') },
credentials: 'include'
});
} catch (e) { /* 忽略错误 */ }
this.$message.success('已退出登录');
// 跳转到登录页,禁止返回
setTimeout(() => {
window.location.replace('./login.html');
}, 500);
} catch (e) {
this.$message.error('退出失败,请重试');
}
},
handlePageChange(page) {
this.currentPage = page;
this.normalizeCurrentPage();
}, // 切换页面
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());
},
getListRequestLimit(forLoadMore = false) {
const basePage = this.isMobileViewport ? 12 : 24;
return forLoadMore ? basePage * 4 : basePage * 3;
},
buildListQueryParams({ cursor = null, limit = null, includeStats = true } = {}) {
const params = new URLSearchParams();
if (cursor !== null && cursor !== undefined && cursor !== '') {
params.set('cursor', String(cursor));
}
params.set('limit', String(limit ?? this.getListRequestLimit(false)));
if (includeStats) {
params.set('includeStats', '1');
}
// 首屏优先取小文件,先让可见内容更快完成渲染
params.set('sort', 'sizeAsc');
params.set('folderPath', this.folderPath || '');
if (this.storageFilter && this.storageFilter !== 'all') {
params.set('storage', this.storageFilter);
}
return params;
},
sort(command) { this.sortOption = command; }, // 切换排序方式
filter(command) { this.filterOption = command; }, // 切换筛选方式
async switchStorageFilter(command) {
this.storageFilter = command;
this.currentPage = 1;
await this.refreshFileList({ syncFolders: true });
}, // 切换存储类型筛选
async loadMore(options = {}) {
const { silent = false, auto = false } = options;
if (this.isLoadingMore || !this.nextCursor) return;
this.isLoadingMore = true;
const startCount = this.tableData.length;
try {
const params = this.buildListQueryParams({
cursor: this.nextCursor,
limit: this.getListRequestLimit(true),
includeStats: true,
});
const result = await fetch(`./api/manage/list?${params.toString()}`, {
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();
this.writeFolderCache(this.getFolderCacheKey());
this.normalizeCurrentPage();
const loaded = Math.max(0, this.tableData.length - startCount);
if (!silent) {
if (this.nextCursor) {
this.$message.success(`已补充 ${loaded} 条数据,可继续翻页`);
} else if (!auto) {
this.$message.success(`已加载剩余 ${loaded}`);
}
}
} catch {
if (!silent) {
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' });
const storageType = this.getStorageType(item.name);
const storageLabel = this.getStorageLabel(item.name);
const storageColor = storageType === 'r2' ? '#409eff'
: storageType === 's3' ? '#e6a23c'
: storageType === 'discord' ? '#7289da'
: storageType === 'huggingface' ? '#ff9d00'
: storageType === 'webdav' ? '#8f65ff'
: storageType === 'github' ? '#24292f'
: '#67c23a';
const fileSize = metadata.fileSize ? this.formatFileSize(metadata.fileSize) : '未知';
const folderPath = this.normalizeFolderPath(metadata.folderPath || '');
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>${fileSize}</div>
<div><strong>存储位置:</strong><span style="color: ${storageColor}; font-weight: bold;">${storageLabel}</span></div>
<div><strong>所在目录:</strong>${folderPath || '根目录'}</div>
<div><strong>上传时间:</strong>${timestamp}</div>
<div><strong>状态:</strong>${metadata.ListType || 'None'}</div>
</div>
`;
},
formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
calculatePageSize() { // 设置页面大小
const config = {
desktopMinSize: 20,
desktopMaxSize: 25,
mobileMinSize: 10,
mobileMaxSize: 14,
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.max(1, Math.floor(width / (config.cardWidth + config.gap)));
const cardHeight = Math.max(120, (width / cols - config.gap) * config.ratio);
const rows = Math.max(1, Math.floor(height / (cardHeight + config.gap)));
const estimated = rows * cols;
if (this.isMobileViewport) {
this.pageSize = Math.max(config.mobileMinSize, Math.min(config.mobileMaxSize, estimated));
} else {
this.pageSize = Math.max(config.desktopMinSize, Math.min(config.desktopMaxSize, estimated));
}
},
updateWindowWidth() { // 动态调整页面大小
this.windowWidth = window.innerWidth;
this.calculatePageSize();
},
normalizeCurrentPage() {
const totalPages = Math.max(1, Math.ceil((this.paginationTotal || 0) / this.pageSize));
if (this.currentPage > totalPages) this.currentPage = totalPages;
if (this.currentPage < 1) this.currentPage = 1;
},
handleWindowResize() {
this.updateViewportFlags();
this.updateWindowWidth();
this.normalizeCurrentPage();
if (this.selectedFiles.length > 0 && !this.isMobileViewport) {
this.$nextTick(() => this.snapBatchToolbarToBottom());
}
},
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 {
const storageTarget = this.systemStatus.r2.connected && this.systemStatus.r2.enabled
? 'R2 存储桶'
: 'Telegram 频道';
const folderText = this.folderPath ? ` / ${this.folderPath}` : ' / 根目录';
await this.$confirm(
`确定要上传这 ${valid.length} 个文件到 <b>${storageTarget}</b> 的目录 <b>${folderText}</b> 吗?`,
'上传确认',
{
type: 'info',
dangerouslyUseHTMLString: true,
confirmButtonText: '开始上传',
cancelButtonText: '取消'
}
);
const loading = this.$message({ message: `正在上传到 ${storageTarget}...`, duration: 0 });
let [successCount, failed] = [0, []];
// 并发上传处理函数
const upload = async file => {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('folderPath', this.folderPath || '');
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();
if (successCount) {
// 显示存储位置信息
const storageInfo = this.systemStatus.r2.connected && this.systemStatus.r2.enabled
? 'R2 存储桶'
: 'Telegram 频道';
this.$message({
dangerouslyUseHTMLString: true,
message: `<i class="fas fa-check-circle" style="color:#67c23a;margin-right:8px;"></i>成功上传 <b>${successCount}</b> 个文件到 <b>${storageInfo}</b>`,
type: 'success',
duration: 3000
});
}
failed.length && this.$message.error(`上传失败: ${failed.join(', ')}`);
this.clearFolderCache();
this.refreshFileList();
} catch {
this.$message.info('已取消上传');
}
event.target.value = '';
},
async refreshFileList(options = {}) { // 不刷新页面,仅更新数据
const { syncFolders = false, preferCache = false, force = false, silent = false } = options;
const cacheKey = this.getFolderCacheKey();
if (preferCache && !force) {
const cached = this.readFolderCache(cacheKey);
if (cached) {
this.applyFolderCacheEntry(cached);
this.currentPage = 1;
this.refreshFileList({ syncFolders, force: true, silent: true }).catch(() => {});
return;
}
}
const requestSeq = ++this.listRequestSeq;
try {
const params = this.buildListQueryParams({
limit: this.getListRequestLimit(false),
includeStats: true,
});
const result = await fetch(`./api/manage/list?${params.toString()}`, { method: 'GET', credentials: 'include' })
.then(response => response.json());
if (requestSeq !== this.listRequestSeq) return;
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.calculatePageSize();
this.sortData(this.tableData);
this.normalizeCurrentPage();
this.writeFolderCache(cacheKey);
if (syncFolders) {
await this.fetchFolders();
}
} catch {
if (requestSeq !== this.listRequestSeq) return;
if (!silent) {
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) {
const isR2 = key.startsWith('r2:');
const storageInfo = isR2 ? 'R2 存储和 KV 记录' : 'KV 记录';
this.$confirm(`此操作将永久删除该文件的 ${storageInfo}, 是否继续?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const encodedKey = encodeURIComponent(key);
fetch(`./api/manage/delete/${encodedKey}`, { method: 'GET', credentials: 'include' })
.then(response => {
if (!response.ok) throw new Error('删除失败');
return response.json();
})
.then(result => {
if (result.success) {
// 从本地数据中移除
const fileIndex = this.tableData.findIndex(f => f.name === key);
if (fileIndex !== -1) {
this.tableData.splice(fileIndex, 1);
}
this.updateStats();
this.clearFolderCache();
this.$message.success('删除成功!');
} else {
throw new Error(result.error || '删除失败');
}
})
.catch(err => this.$message.error('删除失败: ' + err.message));
}).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() { // 批量删除
const r2Count = this.selectedFiles.filter(f => f.name.startsWith('r2:')).length;
const tgCount = this.selectedFiles.length - r2Count;
let msg = `此操作将永久删除这 ${this.selectedFiles.length} 个文件`;
if (r2Count > 0 && tgCount > 0) {
msg += ` (其中 ${r2Count} 个 R2 文件将完全删除, ${tgCount} 个 Telegram 文件仅删除 KV 记录)`;
} else if (r2Count > 0) {
msg += ` (将从 R2 存储和 KV 中完全删除)`;
}
msg += ', 是否继续?';
this.$confirm(msg, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const promises = this.selectedFiles.map(file => {
const encodedKey = encodeURIComponent(file.name);
return fetch(`./api/manage/delete/${encodedKey}`, { method: 'GET', credentials: 'include' })
.then(r => r.json())
.then(result => ({ name: file.name, success: result.success }))
.catch(() => ({ name: file.name, success: false }));
});
Promise.all(promises)
.then(results => {
let successCount = 0;
let failedCount = 0;
results.forEach(result => {
if (result.success) {
const fileIndex = this.tableData.findIndex(file => file.name === result.name);
if (fileIndex !== -1) {
this.tableData.splice(fileIndex, 1);
successCount++;
}
} else {
failedCount++;
}
});
this.selectedFiles = [];
this.updateStats();
if (successCount > 0 && failedCount === 0) {
this.$message.success(`成功删除 ${successCount} 个文件!`);
} else if (successCount > 0 && failedCount > 0) {
this.$message.warning(`删除完成: ${successCount} 成功, ${failedCount} 失败`);
} else {
this.$message.error('删除失败');
}
})
.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 (command === 'selectAll') {
this.selectAllInPage();
return;
}
if (command === 'selectAllLoaded') {
this.selectAllLoaded();
return;
}
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 'moveFolder': this.moveSelectedToFolder(); break;
case 'block': this.handleBatchBlockOrUnblock('Block'); break;
case 'unblock': this.handleBatchBlockOrUnblock('White'); break;
}
},
// 全选当前页
selectAllInPage() {
const newState = !this.isAllSelected;
this.paginatedData.forEach(file => file.selected = newState);
this.$message.success(newState ? `已选中当前页 ${this.paginatedData.length}` : '已取消选择');
},
// 全选已加载的所有数据
selectAllLoaded() {
const allSelected = this.tableData.every(f => f.selected);
this.tableData.forEach(file => file.selected = !allSelected);
this.$message.success(!allSelected ? `已选中全部 ${this.tableData.length}` : '已取消选择');
},
// 工具相关
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;
case 'manageApiTokens': this.openApiTokenDialog(); break;
case 'checkStatus': this.checkConnectionStatus(); break;
}
},
// 检查连接状态
async checkConnectionStatus() {
const loading = this.$message({ message: '正在检查连接状态...', duration: 0 });
try {
const response = await fetch('./api/status', { credentials: 'include' });
const status = await response.json();
this.systemStatus = {
...this.systemStatus,
...(status || {}),
github: status?.github || this.systemStatus.github,
};
loading.close();
// 显示详细状态对话框
const statusHtml = `
<div style="line-height: 2;">
<p><i class="fas fa-robot" style="width: 20px;"></i> <b>Telegram:</b>
<span style="color: ${status.telegram.connected ? '#67c23a' : '#f56c6c'}">
${status.telegram.connected ? '✓' : '✗'} ${status.telegram.message}
</span>
</p>
<p><i class="fas fa-database" style="width: 20px;"></i> <b>KV 存储:</b>
<span style="color: ${status.kv.connected ? '#67c23a' : '#f56c6c'}">
${status.kv.connected ? '✓' : '✗'} ${status.kv.message}
</span>
</p>
<p><i class="fas fa-cloud" style="width: 20px;"></i> <b>R2 存储:</b>
<span style="color: ${status.r2.connected ? '#67c23a' : (this.isUnconfiguredStatus(status.r2) ? '#909399' : '#f56c6c')}">
${status.r2.connected ? '✓' : (this.isUnconfiguredStatus(status.r2) ? '○' : '✗')} ${status.r2.message}
</span>
</p>
<p><i class="fas fa-database" style="width: 20px; color: #e6a23c;"></i> <b>S3 存储:</b>
<span style="color: ${status.s3?.connected ? '#67c23a' : (this.isUnconfiguredStatus(status.s3) ? '#909399' : '#f56c6c')}">
${status.s3?.connected ? '✓' : (this.isUnconfiguredStatus(status.s3) ? '○' : '✗')} ${status.s3?.message || '未知'}
</span>
</p>
<p><i class="fab fa-discord" style="width: 20px; color: #7289da;"></i> <b>Discord:</b>
<span style="color: ${status.discord?.connected ? '#67c23a' : (this.isUnconfiguredStatus(status.discord) ? '#909399' : '#f56c6c')}">
${status.discord?.connected ? '✓' : (this.isUnconfiguredStatus(status.discord) ? '○' : '✗')} ${status.discord?.message || '未知'}
</span>
</p>
<p><i class="fas fa-robot" style="width: 20px; color: #ff9d00;"></i> <b>HuggingFace:</b>
<span style="color: ${status.huggingface?.connected ? '#67c23a' : (this.isUnconfiguredStatus(status.huggingface) ? '#909399' : '#f56c6c')}">
${status.huggingface?.connected ? '✓' : (this.isUnconfiguredStatus(status.huggingface) ? '○' : '✗')} ${status.huggingface?.message || '未知'}
</span>
</p>
<p><i class="fab fa-github" style="width: 20px; color: #24292f;"></i> <b>GitHub:</b>
<span style="color: ${status.github?.connected ? '#67c23a' : (this.isUnconfiguredStatus(status.github) ? '#909399' : '#f56c6c')}">
${status.github?.connected ? '✓' : (this.isUnconfiguredStatus(status.github) ? '○' : '✗')} ${status.github?.message || '未知'}
</span>
</p>
<p><i class="fas fa-lock" style="width: 20px;"></i> <b>身份验证:</b>
<span style="color: ${status.auth.enabled ? '#67c23a' : '#909399'}">
${status.auth.enabled ? '✓' : '○'} ${status.auth.message}
</span>
</p>
</div>
`;
this.$alert(statusHtml, '系统连接状态', {
dangerouslyUseHTMLString: true,
confirmButtonText: '确定'
});
} catch (error) {
loading.close();
this.$message.error('检查状态失败: ' + error.message);
}
},
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, showMessage = true) { // 切换文件类型
this.fileType = type;
this.currentPage = 1;
localStorage.setItem('fileType', type);
if (showMessage) {
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(() => {});
},
// 加载设置处理
handleLoadSettings(command) {
if (command === 'safeMode') {
this.safeMode = !this.safeMode;
localStorage.setItem('safeMode', this.safeMode);
this.$message.success(this.safeMode ? '安全模式已开启' : '安全模式已关闭');
} else if (command === 'loginBackground' || command === 'uiDesign') {
this.showUiDesignSettingsPanel();
} else {
this.loadMode = command;
localStorage.setItem('loadMode', command);
const modeNames = { normal: '正常模式', dataSaver: '省流模式', noImage: '无图模式' };
this.$message.success(`已切换为${modeNames[command]}`);
}
},
// 登录页背景设置
showLoginBackgroundSettings() {
this.showUiDesignSettingsPanel();
return;
const currentBg = localStorage.getItem('loginBackgroundUrl') || '';
const currentMode = localStorage.getItem('loginBackgroundMode') || 'gradient';
const html = `
<div style="line-height: 1.8;">
<p style="margin-bottom: 12px;"><b>选择背景模式:</b></p>
<div style="margin-bottom: 16px;">
<label style="display: block; margin: 8px 0; cursor: pointer;">
<input type="radio" name="bgMode" value="gradient" ${currentMode === 'gradient' ? 'checked' : ''}>
<i class="fas fa-palette" style="margin: 0 8px;"></i>默认渐变色
</label>
<label style="display: block; margin: 8px 0; cursor: pointer;">
<input type="radio" name="bgMode" value="image" ${currentMode === 'image' ? 'checked' : ''}>
<i class="fas fa-image" style="margin: 0 8px;"></i>自定义图片
</label>
<label style="display: block; margin: 8px 0; cursor: pointer;">
<input type="radio" name="bgMode" value="bing" ${currentMode === 'bing' ? 'checked' : ''}>
<i class="fas fa-globe" style="margin: 0 8px;"></i>Bing 每日壁纸
</label>
</div>
<p style="margin-bottom: 8px;"><b>自定义图片 URL</b></p>
<input type="text" id="bgUrlInput" value="${currentBg}"
placeholder="输入图片链接,如 https://example.com/bg.jpg"
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 8px; margin-bottom: 8px;">
<p style="color: #999; font-size: 12px;">
<i class="fas fa-info-circle"></i>
提示:可以使用本站上传的图片链接,或者任意外部图片链接
</p>
</div>
`;
this.$alert(html, '登录页背景设置', {
dangerouslyUseHTMLString: true,
showCancelButton: true,
confirmButtonText: '保存',
cancelButtonText: '取消',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
const mode = document.querySelector('input[name="bgMode"]:checked')?.value || 'gradient';
const url = document.getElementById('bgUrlInput')?.value?.trim() || '';
localStorage.setItem('loginBackgroundMode', mode);
localStorage.setItem('loginBackgroundUrl', url);
this.$message.success('背景设置已保存');
}
done();
}
});
},
// 根据加载模式获取图片显示状态
async uploadUiDesignBackgroundFile(file) {
if (!file) return '';
const formData = new FormData();
formData.append('file', file);
formData.append('folderPath', this.folderPath || '');
const response = await fetch(`${this.baseURL}/upload`, {
method: 'POST',
body: formData,
credentials: 'include'
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !Array.isArray(payload) || !payload[0]?.src) {
const message = Array.isArray(payload) ? payload[0]?.error : payload?.error;
throw new Error(message || '上传失败');
}
return `${this.baseURL}${payload[0].src}`;
},
async showUiDesignSettingsPanel() {
const manager = window.UIDesignManager;
if (!manager || typeof manager.getSettings !== 'function') {
this.$message.error('当前版本暂不支持前端 UI 设置。');
return;
}
const requestUiConfigDirect = async (method, config) => {
const url = method === 'GET'
? `/api/ui-config?_ts=${Date.now()}`
: '/api/ui-config';
const init = {
method,
credentials: 'include',
headers: {
Accept: 'application/json'
}
};
if (method !== 'GET') {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify({ config });
}
const response = await fetch(url, init);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success === false) {
const detail = payload?.error?.message || payload?.error || `请求失败 (${response.status})`;
throw new Error(detail);
}
return payload;
};
const syncFromServerFallback = async () => {
try {
const payload = await requestUiConfigDirect('GET');
const config = payload?.config || payload?.settings || {};
if (typeof manager.setSettings === 'function') {
manager.setSettings(config, { persist: true, silent: true });
}
return {
success: true,
source: 'server',
settings: config,
binding: payload?.binding || ''
};
} catch (error) {
return {
success: false,
source: 'local',
settings: manager.getSettings(),
error: error?.message || String(error),
binding: ''
};
}
};
const saveToServerFallback = async (values) => {
const localApplied = typeof manager.setSettings === 'function'
? manager.setSettings(values, { persist: true, silent: true })
: values;
try {
const payload = await requestUiConfigDirect('POST', localApplied);
const config = payload?.config || payload?.settings || localApplied;
const verifyPayload = await requestUiConfigDirect('GET');
const verifiedConfig = verifyPayload?.config || verifyPayload?.settings || {};
const isMatch = JSON.stringify(verifiedConfig) === JSON.stringify(config);
if (!isMatch) {
throw new Error('保存后回读校验失败,请检查 KV 绑定与 Functions 日志。');
}
if (typeof manager.setSettings === 'function') {
manager.setSettings(verifiedConfig, { persist: true, silent: true });
}
return {
success: true,
source: 'server',
settings: verifiedConfig,
binding: verifyPayload?.binding || payload?.binding || ''
};
} catch (error) {
return {
success: false,
source: 'local',
settings: localApplied,
error: error?.message || String(error),
binding: ''
};
}
};
if (typeof manager.syncFromServer === 'function') {
const syncResult = await manager.syncFromServer({
silent: true,
applyLocalOnFailure: true
});
if (syncResult && syncResult.success === false && syncResult.error) {
this.$message.warning(`读取服务端配置失败,已使用本地缓存:${syncResult.error}`);
}
} else {
const syncResult = await syncFromServerFallback();
if (syncResult && syncResult.success === false && syncResult.error) {
this.$message.warning(`读取服务端配置失败,已使用本地缓存:${syncResult.error}`);
}
}
const snapshot = manager.getSettings();
const defaults = typeof manager.getDefaults === 'function'
? manager.getDefaults()
: manager.getSettings();
const clamp = (value, min, max) => {
const n = Number(value);
if (!Number.isFinite(n)) return min;
return Math.min(max, Math.max(min, n));
};
const escapeAttr = (value) =>
String(value || '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const checked = (condition) => (condition ? 'checked' : '');
const html = `
<div class="ui-design-panel" id="uiDesignPanel">
<div class="ui-design-head">
<div>
<div class="ui-design-title"><i class="fas fa-palette"></i><span>前端 UI 设计</span></div>
<div class="ui-design-subtitle">当前改动实时预览,点击保存后同步到所有设备。</div>
</div>
</div>
<section class="ui-design-section">
<div class="ui-design-section-title"><i class="fas fa-image"></i> 背景图设置(全站)</div>
<div class="ui-design-inline">
<input id="uiGlobalBgUrl" class="ui-design-input" type="text" value="${escapeAttr(snapshot.globalBackgroundUrl)}" placeholder="请输入图片链接https://example.com/background.jpg">
</div>
<div class="ui-design-inline">
<button type="button" id="uiUploadGlobalBtn" class="ui-upload-drop"><i class="fas fa-cloud-upload-alt"></i><span>上传图片</span></button>
<input id="uiUploadGlobalInput" type="file" accept="image/*" style="display:none;">
<button type="button" id="uiClearGlobalBg" class="el-button el-button--small">清除</button>
</div>
<div class="ui-design-tip">将应用到首页、图片浏览、后台、WebDAV 及其他所有可见页面。</div>
</section>
<section class="ui-design-section">
<div class="ui-design-section-title"><i class="fas fa-sign-in-alt"></i> 登录页背景图</div>
<div class="ui-segmented">
<label class="ui-segment"><input type="radio" name="uiLoginMode" value="follow-global" ${checked(snapshot.loginBackgroundMode !== 'custom')}><span>与全站一致</span></label>
<label class="ui-segment"><input type="radio" name="uiLoginMode" value="custom" ${checked(snapshot.loginBackgroundMode === 'custom')}><span>仅登录页单独设置</span></label>
</div>
<div class="ui-design-inline" id="uiLoginCustomRow">
<input id="uiLoginBgUrl" class="ui-design-input" type="text" value="${escapeAttr(snapshot.loginBackgroundUrl)}" placeholder="请输入登录页背景图链接">
<button type="button" id="uiUploadLoginBtn" class="ui-upload-drop"><i class="fas fa-cloud-upload-alt"></i><span>上传图片</span></button>
<input id="uiUploadLoginInput" type="file" accept="image/*" style="display:none;">
<button type="button" id="uiClearLoginBg" class="el-button el-button--small">清除</button>
</div>
</section>
<section class="ui-design-section">
<div class="ui-design-section-title"><i class="fas fa-clone"></i> 界面透明度</div>
<div class="ui-design-range-wrap">
<div class="ui-design-range-head">
<span>卡片透明度</span>
<span class="ui-design-value" id="uiCardOpacityValue"></span>
</div>
<input id="uiCardOpacity" class="ui-design-range" type="range" min="0" max="100" value="${clamp(snapshot.cardOpacity, 0, 100)}">
</div>
<div class="ui-design-range-wrap">
<div class="ui-design-range-head">
<span>模糊强度</span>
<span class="ui-design-value" id="uiCardBlurValue"></span>
</div>
<input id="uiCardBlur" class="ui-design-range" type="range" min="0" max="32" value="${clamp(snapshot.cardBlur, 0, 32)}">
</div>
</section>
<section class="ui-design-section">
<div class="ui-design-section-title"><i class="fas fa-wave-square"></i> 动态背景特效</div>
<div class="ui-segmented">
<label class="ui-segment"><input type="radio" name="uiEffectStyle" value="none" ${checked(snapshot.effectStyle === 'none')}><span>无特效</span></label>
<label class="ui-segment"><input type="radio" name="uiEffectStyle" value="math" ${checked(snapshot.effectStyle === 'math')}><span>数学符号</span></label>
<label class="ui-segment"><input type="radio" name="uiEffectStyle" value="particle" ${checked(snapshot.effectStyle === 'particle')}><span>粒子网格</span></label>
<label class="ui-segment"><input type="radio" name="uiEffectStyle" value="texture" ${checked(snapshot.effectStyle === 'texture')}><span>纸纹叠加</span></label>
</div>
<div class="ui-design-range-wrap">
<div class="ui-design-range-head">
<span>特效强度</span>
<span class="ui-design-value" id="uiEffectIntensityValue"></span>
</div>
<input id="uiEffectIntensity" class="ui-design-range" type="range" min="0" max="100" value="${clamp(snapshot.effectIntensity, 0, 100)}">
</div>
<label class="ui-design-inline">
<input id="uiOptimizeMobile" type="checkbox" ${checked(snapshot.optimizeMobile !== false)}>
<span>移动端自动优化特效性能</span>
</label>
<div class="ui-design-tip">移动端会自动降低特效密度,减少 CPU 占用。</div>
</section>
<div class="ui-design-foot">
<button type="button" id="uiResetDefaults" class="el-button el-button--small">恢复默认</button>
<span id="uiSaveStatus" class="ui-design-save-status"></span>
</div>
</div>
`;
const readValues = () => {
const loginMode =
document.querySelector('input[name="uiLoginMode"]:checked')?.value ||
'follow-global';
const effectStyle =
document.querySelector('input[name="uiEffectStyle"]:checked')?.value ||
'none';
return {
baseColor: '#fafaf8',
globalBackgroundUrl: document.getElementById('uiGlobalBgUrl')?.value?.trim() || '',
loginBackgroundMode: loginMode,
loginBackgroundUrl: document.getElementById('uiLoginBgUrl')?.value?.trim() || '',
cardOpacity: clamp(document.getElementById('uiCardOpacity')?.value, 0, 100),
cardBlur: clamp(document.getElementById('uiCardBlur')?.value, 0, 32),
effectStyle,
effectIntensity: clamp(document.getElementById('uiEffectIntensity')?.value, 0, 100),
optimizeMobile: !!document.getElementById('uiOptimizeMobile')?.checked
};
};
const setSaveStatus = (state, text) => {
const status = document.getElementById('uiSaveStatus');
if (!status) return;
status.setAttribute('data-state', state || '');
status.textContent = text || '';
};
const updateValueLabels = () => {
const opacityEl = document.getElementById('uiCardOpacityValue');
const blurEl = document.getElementById('uiCardBlurValue');
const intensityEl = document.getElementById('uiEffectIntensityValue');
if (opacityEl) {
opacityEl.textContent = `${Math.round(clamp(document.getElementById('uiCardOpacity')?.value, 0, 100))}%`;
}
if (blurEl) {
blurEl.textContent = `${Math.round(clamp(document.getElementById('uiCardBlur')?.value, 0, 32))}px`;
}
if (intensityEl) {
intensityEl.textContent = `${Math.round(clamp(document.getElementById('uiEffectIntensity')?.value, 0, 100))}%`;
}
};
const toggleLoginCustomRow = () => {
const loginMode =
document.querySelector('input[name="uiLoginMode"]:checked')?.value ||
'follow-global';
const row = document.getElementById('uiLoginCustomRow');
if (!row) return;
const enabled = loginMode === 'custom';
row.style.opacity = enabled ? '1' : '0.52';
row.style.pointerEvents = enabled ? 'auto' : 'none';
};
const applyPreview = () => {
updateValueLabels();
toggleLoginCustomRow();
const values = readValues();
if (typeof manager.previewSettings === 'function') {
manager.previewSettings(values);
} else if (typeof manager.setSettings === 'function') {
manager.setSettings(values, { persist: false, silent: true });
}
setSaveStatus('', '预览中:当前改动仅在本地生效,保存后全站同步。');
};
const applyValuesToForm = (values) => {
const state = Object.assign({}, defaults, values || {});
const globalUrl = document.getElementById('uiGlobalBgUrl');
const loginUrl = document.getElementById('uiLoginBgUrl');
const cardOpacity = document.getElementById('uiCardOpacity');
const cardBlur = document.getElementById('uiCardBlur');
const effectIntensity = document.getElementById('uiEffectIntensity');
const optimizeMobile = document.getElementById('uiOptimizeMobile');
if (globalUrl) globalUrl.value = state.globalBackgroundUrl || '';
if (loginUrl) loginUrl.value = state.loginBackgroundUrl || '';
if (cardOpacity) cardOpacity.value = clamp(state.cardOpacity, 0, 100);
if (cardBlur) cardBlur.value = clamp(state.cardBlur, 0, 32);
if (effectIntensity) effectIntensity.value = clamp(state.effectIntensity, 0, 100);
if (optimizeMobile) optimizeMobile.checked = state.optimizeMobile !== false;
const loginModeRadio = document.querySelector(`input[name="uiLoginMode"][value="${state.loginBackgroundMode === 'custom' ? 'custom' : 'follow-global'}"]`);
if (loginModeRadio) loginModeRadio.checked = true;
const effectRadio = document.querySelector(`input[name="uiEffectStyle"][value="${state.effectStyle || 'none'}"]`);
if (effectRadio) effectRadio.checked = true;
};
const bindUpload = (triggerId, inputId, targetInputId) => {
const trigger = document.getElementById(triggerId);
const fileInput = document.getElementById(inputId);
const targetInput = document.getElementById(targetInputId);
if (!trigger || !fileInput || !targetInput) return;
trigger.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
const loading = this.$message({ message: '正在上传背景图...', duration: 0 });
try {
const url = await this.uploadUiDesignBackgroundFile(file);
targetInput.value = url;
applyPreview();
this.$message.success('背景图上传成功。');
} catch (error) {
this.$message.error(`上传失败:${error.message || error}`);
} finally {
loading.close();
fileInput.value = '';
}
});
};
this.$alert(html, '前端 UI 设计', {
customClass: 'ui-design-alert',
dangerouslyUseHTMLString: true,
showCancelButton: true,
closeOnClickModal: false,
confirmButtonText: '保存设置',
cancelButtonText: '取消',
beforeClose: (action, instance, done) => {
if (action !== 'confirm') {
if (typeof manager.restorePersisted === 'function') {
manager.restorePersisted();
}
done();
return;
}
const values = readValues();
instance.confirmButtonLoading = true;
instance.confirmButtonText = '保存中...';
setSaveStatus('saving', '保存中...');
const saveRequest = typeof manager.saveToServer === 'function'
? manager.saveToServer(values)
: saveToServerFallback(values);
saveRequest
.then((result) => {
instance.confirmButtonLoading = false;
if (result?.success) {
instance.confirmButtonText = '✓ 已保存';
const bindingName = result?.binding ? `KV: ${result.binding}` : '';
setSaveStatus('success', `已保存并同步到服务端${bindingName}`);
this.$message.success(`前端 UI 设置已保存并同步${bindingName}`);
} else {
instance.confirmButtonText = '已本地保存';
setSaveStatus('warning', '服务端不可用,已保存到本地缓存。');
this.$message.warning(result?.error
? `已本地保存,服务端同步失败:${result.error}`
: '已本地保存,服务端同步失败。');
}
window.setTimeout(() => done(), 260);
})
.catch((error) => {
instance.confirmButtonLoading = false;
instance.confirmButtonText = '保存设置';
setSaveStatus('warning', '保存失败,请重试。');
this.$message.error(`保存失败:${error.message || error}`);
});
}
});
this.$nextTick(() => {
applyValuesToForm(snapshot);
updateValueLabels();
toggleLoginCustomRow();
applyPreview();
const listenIds = [
'uiGlobalBgUrl',
'uiLoginBgUrl',
'uiCardOpacity',
'uiCardBlur',
'uiEffectIntensity',
'uiOptimizeMobile'
];
listenIds.forEach((id) => {
const node = document.getElementById(id);
if (!node) return;
node.addEventListener('input', applyPreview);
node.addEventListener('change', applyPreview);
});
document
.querySelectorAll('input[name="uiLoginMode"], input[name="uiEffectStyle"]')
.forEach((node) => node.addEventListener('change', applyPreview));
const clearGlobalBtn = document.getElementById('uiClearGlobalBg');
if (clearGlobalBtn) {
clearGlobalBtn.addEventListener('click', () => {
const input = document.getElementById('uiGlobalBgUrl');
if (input) input.value = '';
applyPreview();
});
}
const clearLoginBtn = document.getElementById('uiClearLoginBg');
if (clearLoginBtn) {
clearLoginBtn.addEventListener('click', () => {
const input = document.getElementById('uiLoginBgUrl');
if (input) input.value = '';
applyPreview();
});
}
const resetBtn = document.getElementById('uiResetDefaults');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
applyValuesToForm(defaults);
applyPreview();
setSaveStatus('', '已恢复默认预览,点击保存后全站生效。');
});
}
bindUpload('uiUploadGlobalBtn', 'uiUploadGlobalInput', 'uiGlobalBgUrl');
bindUpload('uiUploadLoginBtn', 'uiUploadLoginInput', 'uiLoginBgUrl');
});
},
shouldShowImage(item) {
if (this.loadMode === 'noImage') return false;
if (this.loadMode === 'dataSaver') {
// 省流模式跳过大于5MB的图片
const sizeLimit = 5 * 1024 * 1024;
return (item.metadata?.fileSize || 0) <= sizeLimit;
}
return true;
},
// 检查是否为NSFW内容
isNsfwContent(item) {
return this.safeMode && item.metadata?.Label?.toLowerCase() === 'adult';
}
},
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.handleWindowResize);
window.addEventListener('orientationchange', this.handleWindowResize);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', this.handleWindowResize);
}
window.addEventListener('keydown', this.handleGlobalKeydown);
this.restoreFolderCache();
this.updateViewportFlags();
this.updateWindowWidth();
this.$nextTick(() => {
this.observeMobileNavHeight();
this.queueMobileNavMetricsUpdate();
});
// 恢复设置
this.sortOption = localStorage.getItem('sortOption') || this.sortOption;
this.filterOption = localStorage.getItem('filterOption') || this.filterOption;
this.fileType = localStorage.getItem('fileType') || this.fileType;
this.loadMode = localStorage.getItem('loadMode') || this.loadMode;
this.safeMode = localStorage.getItem('safeMode') !== 'false';
this.viewMode = localStorage.getItem('adminViewMode') || this.viewMode;
this.folderPath = this.normalizeFolderPath(localStorage.getItem('adminFolderPath') || '');
// 获取文件列表数据
try {
await this.refreshFileList({ syncFolders: true });
this.sortData(this.tableData);
// 初始化时不显示切换消息
this.switchFileType(this.fileType, false);
if (this.tableData.length > 0 && this.filteredTableData.length === 0) {
this.search = '';
this.filterOption = 'all';
this.storageFilter = 'all';
this.fileType = 'all';
this.folderPath = '';
await this.refreshFileList({ syncFolders: true });
this.$message.warning('检测到筛选条件无匹配,已自动恢复默认视图');
}
} catch {
this.$message.error('同步数据时出错,请检查网络连接');
}
// 检查系统连接状态
fetch('./api/status', { credentials: 'include' })
.then(response => response.json())
.then(status => {
this.systemStatus = {
...this.systemStatus,
...(status || {}),
github: status?.github || this.systemStatus.github,
};
})
.catch(() => {});
// 恢复快捷方式设置
if (localStorage.getItem('quickWebsites')) {
this.quickWebsites = JSON.parse(localStorage.getItem('quickWebsites'));
}
}
,
beforeDestroy() {
document.body.classList.remove('is-dragging-files');
if (this.dragState.leaveTimer) {
clearTimeout(this.dragState.leaveTimer);
this.dragState.leaveTimer = null;
}
if (this.dragState.rafId) {
cancelAnimationFrame(this.dragState.rafId);
this.dragState.rafId = null;
}
document.removeEventListener('mousemove', this.onBatchDrag);
document.removeEventListener('mouseup', this.stopBatchDrag);
window.removeEventListener('keydown', this.handleGlobalKeydown);
window.removeEventListener('resize', this.handleWindowResize);
window.removeEventListener('orientationchange', this.handleWindowResize);
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', this.handleWindowResize);
}
this.unobserveMobileNavHeight();
if (this.mobileNavMetricsRaf) {
cancelAnimationFrame(this.mobileNavMetricsRaf);
this.mobileNavMetricsRaf = 0;
}
document.documentElement.style.removeProperty('--nav-height');
document.documentElement.style.removeProperty('--nav-offset');
}
});
</script>
</body>
</html>