mirror of
https://github.com/katelya77/K-Vault.git
synced 2026-05-06 22:10:57 +08:00
4323 lines
182 KiB
HTML
4323 lines
182 KiB
HTML
<!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 = ``;
|
||
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 ``;
|
||
}).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, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
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>
|