Spaces:
Paused
Paused
| <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 ; | |
| bottom: 40px ; | |
| left: 50% ; | |
| transform: translateX(-50%) ; | |
| } | |
| .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 ; | |
| } | |
| .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 ; | |
| 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) ; | |
| border-radius: 22px ; | |
| border: 1px solid rgba(17, 24, 39, 0.08) ; | |
| background: rgba(255, 255, 255, 0.85) ; | |
| box-shadow: 0 8px 40px rgba(0, 0, 0, 0.1) ; | |
| 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) ; | |
| background: rgba(12, 18, 30, 0.85) ; | |
| box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45) ; | |
| } | |
| 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 ; | |
| } | |
| .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> | |