فهرست منبع

1. 实现了Canvas粒子系统星空动态背景(StarField.vue),支持多色发光星星、鼠标吸引力、星星间引力/斥力双力模型、近距离连线效果、闪烁动画和prefers-reduced-motion无障碍适配;采用离屏Canvas预渲染光晕、帧率限制~30fps、批量连线绘制等性能优化手段,在视觉效果与流畅度之间取得平衡;
2. 进行了前端组件化重构:将Home.vue(~1600行)拆分 FileUploader、TextShare、UploadConfig、QueryInput 四个子组件及useFileUpload composable;将Share.vue(~1225行)拆分为PasswordModal、FileList、TextViewer三个子组件;两个主页面分别精简至~378行和~407行,提升可维护性;
3. 修复了安全漏洞:文件上传路径遍历攻击防护(FileStorageService文件名清洗+路径校验)、密码哈希增加静态盐值防止彩虹表攻击(PasswordUtil)、ZIP打包下载使用BufferedOutputStream防止OOM、过期清理增加逐条异常恢复机制;
4. 完善了前端基础设施:全局错误边界(App.vue errorCaptured + main.js errorHandler)、CSS路由加载进度条(无额外依赖)、SCSS变量统一管理($transition-speed、$blur-amount等消除魔法数字);
5. 更新了.gitignore排除开发过程截图和临时文件。

weisijie 2 ماه پیش
والد
کامیت
2f95754ffc

+ 12 - 0
.gitignore

@@ -145,6 +145,18 @@ extracted-code-report.md
 # 开发计划/草稿(已在实现中或已完成的)
 *_IMPLEMENTATION_PLAN.md
 
+# 截图/截图文件
+*.png
+*.jpg
+*.jpeg
+*.gif
+*.bmp
+*.webp
+!.github/**/*.png
+
+# Prompt 文件
+prompt.txt
+
 # 其他过程文件
 *.jsonl
 all_extracted_files.json

+ 15 - 4
backend/src/main/java/com/snapshot/service/FileStorageService.java

@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.multipart.MultipartFile;
 
+import java.io.BufferedOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.nio.file.Files;
@@ -61,9 +62,18 @@ public class FileStorageService {
         Path storageDir = Paths.get(uploadDir, datePath);
         Files.createDirectories(storageDir);
 
-        // 生成存储路径
-        String storageFileName = fileId + "_" + file.getOriginalFilename();
-        Path storagePath = storageDir.resolve(storageFileName);
+        // 生成存储路径(防范路径遍历:仅取文件名部分)
+        String originalName = file.getOriginalFilename();
+        if (originalName == null || originalName.isEmpty()) {
+            originalName = "unnamed";
+        }
+        String safeName = Paths.get(originalName).getFileName().toString();
+        String storageFileName = fileId + "_" + safeName;
+        Path storagePath = storageDir.resolve(storageFileName).normalize();
+        // 验证路径仍在存储目录内
+        if (!storagePath.startsWith(storageDir.normalize())) {
+            throw new RuntimeException("非法文件路径");
+        }
 
         // 保存文件
         Files.copy(file.getInputStream(), storagePath, StandardCopyOption.REPLACE_EXISTING);
@@ -191,7 +201,8 @@ public class FileStorageService {
             Path tempZip = Files.createTempFile("snapshot-", ".zip");
 
             try (OutputStream os = Files.newOutputStream(tempZip);
-                 ZipOutputStream zos = new ZipOutputStream(os)) {
+                 BufferedOutputStream bos = new BufferedOutputStream(os);
+                 ZipOutputStream zos = new ZipOutputStream(bos)) {
 
                 for (FileEntry file : files) {
                     Path filePath = Paths.get(file.getStoragePath());

+ 8 - 2
backend/src/main/java/com/snapshot/service/UploadRecordService.java

@@ -212,11 +212,17 @@ public class UploadRecordService {
     public void cleanupExpiredRecords() {
         List<UploadRecord> expiredRecords = uploadRecordMapper.findByExpirationTimeBefore(LocalDateTime.now());
 
+        int success = 0;
         for (UploadRecord record : expiredRecords) {
-            deleteUploadRecord(record.getId());
+            try {
+                deleteUploadRecord(record.getId());
+                success++;
+            } catch (Exception e) {
+                log.error("清理过期记录失败: id={}, error={}", record.getId(), e.getMessage());
+            }
         }
 
-        log.info("清理过期记录完成,共删除 {} 条记录", expiredRecords.size());
+        log.info("清理过期记录完成,共 {} 条,成功 {} 条", expiredRecords.size(), success);
     }
 
     /**

+ 5 - 3
backend/src/main/java/com/snapshot/util/PasswordUtil.java

@@ -4,12 +4,14 @@ import org.apache.commons.codec.digest.DigestUtils;
 
 /**
  * 密码加密工具类
- * 使用SHA-256哈希算法
+ * 使用加盐SHA-256哈希算法,防止彩虹表攻击
  */
 public class PasswordUtil {
 
+    private static final String SALT = "snapshot-static-salt-2024";
+
     /**
-     * 生成密码哈希值
+     * 生成密码哈希值(加盐)
      *
      * @param password 原始密码
      * @return 哈希后的密码
@@ -18,7 +20,7 @@ public class PasswordUtil {
         if (password == null || password.isEmpty()) {
             return null;
         }
-        return DigestUtils.sha256Hex(password);
+        return DigestUtils.sha256Hex(SALT + password + SALT);
     }
 
     /**

+ 4 - 1
backend/src/main/resources/application.properties.example

@@ -66,7 +66,10 @@ spring.jackson.default-property-inclusion=non_null
 # ============================================
 # 日志配置
 # ============================================
-
+# 开发环境使用 DEBUG,生产环境建议改为 WARN 或 INFO
+# 生产环境示例:
+#   logging.level.com.snapshot=INFO
+#   logging.level.com.snapshot.mapper=WARN
 logging.level.root=INFO
 logging.level.com.snapshot=DEBUG
 logging.level.com.snapshot.mapper=DEBUG

+ 1 - 1
frontend/package.json

@@ -11,7 +11,7 @@
   "dependencies": {
     "axios": "^1.6.0",
     "core-js": "^3.8.3",
-    "dompurify": "^3.3.1",
+    "dompurify": "^3.4.9",
     "highlight.js": "^11.11.1",
     "marked": "^4.3.0",
     "pinia": "^2.1.0",

+ 9 - 1
frontend/src/App.vue

@@ -1,12 +1,20 @@
 <template>
+  <StarField />
   <div id="app">
     <router-view />
   </div>
 </template>
 
 <script>
+import StarField from './components/StarField.vue'
+
 export default {
-  name: 'App'
+  name: 'App',
+  components: { StarField },
+  errorCaptured(err, instance, info) {
+    console.error('Global error:', err, info)
+    return false // prevent propagation
+  }
 }
 </script>
 

+ 450 - 0
frontend/src/components/FileList.vue

@@ -0,0 +1,450 @@
+<template>
+  <div class="files-content card">
+    <div class="content-header">
+      <h2>📁 分享的文件</h2>
+      <span class="file-count">{{ files.length }} 个文件</span>
+    </div>
+
+    <div class="files-list">
+      <div
+        v-for="file in files"
+        :key="file.fileId"
+        class="file-item"
+      >
+        <div class="file-info">
+          <svg class="file-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
+            <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
+            <polyline points="13 2 13 9 20 9"></polyline>
+          </svg>
+          <div class="file-details">
+            <p class="file-name">{{ file.fileName }}</p>
+            <p class="file-size">{{ formatFileSize(file.fileSize) }}</p>
+          </div>
+        </div>
+        <div class="file-actions">
+          <!-- 预览按钮(仅图片和PDF) -->
+          <button
+            v-if="isPreviewable(file.mimeType)"
+            class="btn-action btn-preview"
+            @click="$emit('preview', file.fileId)"
+            title="在新窗口预览"
+          >
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
+              <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
+              <circle cx="12" cy="12" r="3"></circle>
+            </svg>
+            <span>预览</span>
+          </button>
+          <!-- 下载按钮 -->
+          <button
+            class="btn-action btn-download"
+            @click="$emit('download', file.fileId, file.fileName)"
+            title="下载文件"
+          >
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
+              <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
+              <polyline points="7 10 12 15 17 10"></polyline>
+              <line x1="12" y1="15" x2="12" y2="3"></line>
+            </svg>
+            <span>下载</span>
+          </button>
+        </div>
+      </div>
+    </div>
+
+    <!-- 打包下载按钮 -->
+    <button
+      class="btn-download-all btn btn-primary btn-lg"
+      @click="$emit('download-all')"
+      :disabled="downloadingAll"
+    >
+      {{ downloadingAll ? '⏳ 正在打包...' : '📦 打包下载所有文件' }}
+    </button>
+
+    <div class="content-info">
+      <p>创建时间:{{ formatTime(createdTime) }}</p>
+      <p>过期时间:{{ formatTime(expirationTime) }}</p>
+      <p>下载次数:{{ currentDownloadCount }}/{{ maxDownloadCount === -1 ? '无限制' : maxDownloadCount }}</p>
+    </div>
+
+    <!-- 删除资源按钮 -->
+    <div class="content-actions">
+      <button
+        class="btn-delete"
+        @click="$emit('delete')"
+        title="删除此分享资源"
+      >
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
+          <polyline points="3 6 5 6 21 6"></polyline>
+          <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
+        </svg>
+        <span>删除资源</span>
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  files: {
+    type: Array,
+    required: true
+  },
+  downloadingAll: {
+    type: Boolean,
+    default: false
+  },
+  createdTime: {
+    type: String,
+    default: ''
+  },
+  expirationTime: {
+    type: String,
+    default: ''
+  },
+  currentDownloadCount: {
+    type: Number,
+    default: 0
+  },
+  maxDownloadCount: {
+    type: Number,
+    default: -1
+  }
+})
+
+defineEmits(['download', 'download-all', 'preview', 'delete'])
+
+// 判断文件是否可预览(图片或PDF)
+function isPreviewable(mimeType) {
+  if (!mimeType) return false
+  const previewableTypes = [
+    // 图片类型
+    'image/jpeg',
+    'image/jpg',
+    'image/png',
+    'image/gif',
+    'image/webp',
+    'image/svg+xml',
+    'image/bmp',
+    'image/x-icon',
+    // PDF类型
+    'application/pdf'
+  ]
+  return previewableTypes.includes(mimeType)
+}
+
+function 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 Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
+}
+
+function formatTime(timeStr) {
+  return new Date(timeStr).toLocaleString('zh-CN')
+}
+</script>
+
+<style lang="scss" scoped>
+@import '@/styles/variables.scss';
+
+.files-content {
+  .content-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: $spacing-lg;
+
+    h2 {
+      color: $text-secondary;
+    }
+
+    .file-count {
+      font-size: $font-size-sm;
+      background: $bg-tertiary;
+      padding: $spacing-xs $spacing-sm;
+      border-radius: $radius-full;
+      color: $text-muted;
+    }
+  }
+}
+
+.files-list {
+  margin-bottom: $spacing-lg;
+}
+
+.file-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: $spacing-md;
+  background: $bg-tertiary;
+  border-radius: $radius-md;
+  margin-bottom: $spacing-sm;
+}
+
+.file-info {
+  display: flex;
+  align-items: center;
+  gap: $spacing-md;
+  flex: 1;
+}
+
+.file-icon {
+  width: 48px;
+  height: 48px;
+  color: $accent-color;
+}
+
+.file-details {
+  flex: 1;
+  min-width: 0; // 确保flex子项不会溢出
+
+  .file-name {
+    color: $text-primary;
+    margin-bottom: $spacing-xs;
+    word-break: break-word; // 长文件名自然换行
+    overflow-wrap: break-word;
+    white-space: normal; // 允许换行
+  }
+
+  .file-size {
+    font-size: $font-size-sm;
+    color: $text-muted;
+  }
+}
+
+// 文件操作按钮容器
+.file-actions {
+  display: flex;
+  gap: $spacing-sm;
+  align-items: center;
+  flex-shrink: 0;
+}
+
+// 现代化按钮样式
+.btn-action {
+  display: inline-flex;
+  align-items: center;
+  gap: $spacing-xs;
+  padding: $spacing-sm $spacing-md;
+  border-radius: $radius-md;
+  border: none;
+  font-size: $font-size-sm;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+  white-space: nowrap;
+
+  svg {
+    width: 18px;
+    height: 18px;
+    stroke-width: 2;
+  }
+
+  &:hover {
+    transform: translateY(-1px);
+  }
+
+  &:active {
+    transform: translateY(0);
+  }
+}
+
+.btn-preview {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+  box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
+
+  &:hover {
+    box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+    background: linear-gradient(135deg, #7c8efc 0%, #8a5db8 100%);
+  }
+}
+
+.btn-download {
+  background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
+  color: white;
+  box-shadow: 0 2px 8px rgba(17, 153, 142, 0.3);
+
+  &:hover {
+    box-shadow: 0 4px 12px rgba(17, 153, 142, 0.4);
+    background: linear-gradient(135deg, #14b89f 0%, #4fff8d 100%);
+  }
+}
+
+.btn-download-all {
+  width: 100%;
+  margin-bottom: $spacing-lg;
+}
+
+.content-info {
+  padding-top: $spacing-lg;
+  border-top: 1px solid $border-color;
+
+  p {
+    font-size: $font-size-sm;
+    color: $text-muted;
+    margin-bottom: $spacing-xs;
+  }
+}
+
+// 内容操作区
+.content-actions {
+  margin-top: $spacing-md;
+  display: flex;
+  justify-content: flex-end;
+}
+
+// 删除按钮
+.btn-delete {
+  display: inline-flex;
+  align-items: center;
+  gap: $spacing-xs;
+  padding: $spacing-sm $spacing-md;
+  background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); // 红色渐变
+  color: white;
+  border: none;
+  border-radius: $radius-md;
+  font-size: $font-size-sm;
+  font-weight: 500;
+  cursor: pointer;
+  box-shadow: 0 2px 8px rgba(238, 90, 111, 0.3);
+  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+
+  svg {
+    width: 18px;
+    height: 18px;
+    stroke-width: 2;
+  }
+
+  &:hover {
+    transform: translateY(-1px);
+    box-shadow: 0 4px 12px rgba(238, 90, 111, 0.4);
+    background: linear-gradient(135deg, #ff8787 0%, #ff6b6b 100%);
+  }
+
+  &:active {
+    transform: translateY(0);
+  }
+
+  &:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+    transform: none !important;
+  }
+}
+
+// 移动端优化
+@media (max-width: 768px) {
+  .files-content {
+    .content-header {
+      flex-direction: column;
+      align-items: flex-start;
+      gap: $spacing-sm;
+      margin-bottom: $spacing-md;
+
+      h2 {
+        font-size: $font-size-lg;
+      }
+    }
+  }
+
+  // 文件列表优化
+  .file-item {
+    flex-direction: column;
+    align-items: stretch;
+    gap: $spacing-sm;
+    padding: $spacing-sm;
+
+    .file-info {
+      gap: $spacing-sm;
+    }
+
+    .file-icon {
+      width: 36px;
+      height: 36px;
+    }
+
+    .file-details {
+      .file-name {
+        font-size: $font-size-sm;
+      }
+
+      .file-size {
+        font-size: $font-size-xs;
+      }
+    }
+
+    .file-actions {
+      justify-content: flex-end;
+      gap: $spacing-xs;
+    }
+  }
+
+  // 按钮优化
+  .btn-action {
+    padding: $spacing-xs $spacing-sm;
+    font-size: $font-size-xs;
+
+    svg {
+      width: 16px;
+      height: 16px;
+    }
+
+    span {
+      // 在极小屏幕上隐藏文字,只显示图标
+      @media (max-width: 480px) {
+        display: none;
+      }
+    }
+  }
+
+  // 删除按钮移动端优化
+  .btn-delete {
+    padding: $spacing-xs $spacing-sm;
+    font-size: $font-size-xs;
+
+    svg {
+      width: 16px;
+      height: 16px;
+    }
+
+    span {
+      // 在极小屏幕上隐藏文字,只显示图标
+      @media (max-width: 480px) {
+        display: none;
+      }
+    }
+  }
+
+  // 打包下载按钮优化
+  .btn-download-all {
+    padding: $spacing-sm $spacing-md;
+    font-size: $font-size-base;
+  }
+
+  // 内容信息优化
+  .content-info {
+    p {
+      font-size: $font-size-xs;
+    }
+  }
+}
+
+// 超小屏幕优化(<480px)
+@media (max-width: 480px) {
+  .file-actions {
+    .btn-action {
+      padding: $spacing-xs;
+      min-width: 36px; // 确保按钮可点击区域足够
+      justify-content: center;
+
+      span {
+        display: none; // 只显示图标
+      }
+    }
+  }
+}
+</style>

+ 257 - 0
frontend/src/components/FileUploader.vue

@@ -0,0 +1,257 @@
+<template>
+  <section class="upload-section">
+    <div class="section-header">
+      <h2>📁 文件上传</h2>
+      <span class="file-count">{{ files.length }}/{{ maxFiles }}</span>
+    </div>
+
+    <!-- 拖拽上传区域 -->
+    <div
+      class="drop-zone"
+      :class="{ active: isDragging }"
+      @dragover.prevent="isDragging = true"
+      @dragleave.prevent="isDragging = false"
+      @drop.prevent="onDrop"
+      @click="onSelectFiles"
+    >
+      <div class="drop-zone-content">
+        <svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
+          <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
+          <polyline points="17 8 12 3 7 8"></polyline>
+          <line x1="12" y1="3" x2="12" y2="15"></line>
+        </svg>
+        <p class="drop-text">拖拽文件到这里,或点击选择文件</p>
+        <p class="drop-hint">最多上传 {{ maxFiles }} 个文件,每个文件不超过 {{ maxSize }}MB</p>
+      </div>
+    </div>
+
+    <!-- 文件列表 -->
+    <div v-if="files.length > 0" class="file-list">
+      <div
+        v-for="file in files"
+        :key="file.id"
+        class="file-item"
+      >
+        <div class="file-info">
+          <svg class="file-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
+            <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
+            <polyline points="13 2 13 9 20 9"></polyline>
+          </svg>
+          <div class="file-details">
+            <p class="file-name">{{ file.name }}</p>
+            <p class="file-size">{{ formatFileSize(file.size) }}</p>
+          </div>
+        </div>
+        <button class="btn-remove" @click="$emit('remove', file.id)">×</button>
+      </div>
+    </div>
+
+    <!-- 清空按钮 -->
+    <button
+      v-if="files.length > 0"
+      class="btn-clear"
+      @click="$emit('clear')"
+    >
+      清空文件列表
+    </button>
+  </section>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import config from '@/config'
+
+const props = defineProps({
+  files: {
+    type: Array,
+    required: true
+  }
+})
+
+const emit = defineEmits(['remove', 'clear', 'files-selected', 'files-dropped'])
+
+const isDragging = ref(false)
+const maxFiles = computed(() => config.maxFiles)
+const maxSize = computed(() => config.uploadLimit / 1024 / 1024)
+
+function 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 Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
+}
+
+function onSelectFiles() {
+  const input = document.createElement('input')
+  input.type = 'file'
+  input.multiple = true
+  input.accept = config.allowedFileTypes.join(',')
+  input.onchange = (e) => {
+    const files = Array.from(e.target.files)
+    emit('files-selected', files)
+  }
+  input.click()
+}
+
+function onDrop(e) {
+  isDragging.value = false
+  const files = Array.from(e.dataTransfer.files)
+  emit('files-dropped', files)
+}
+</script>
+
+<style lang="scss" scoped>
+@import '@/styles/variables.scss';
+
+.upload-section {
+  @extend .card !optional;
+  min-width: 0;
+  overflow: hidden;
+}
+
+.section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: $spacing-lg;
+
+  h2 {
+    font-size: $font-size-xl;
+    color: $text-secondary;
+  }
+
+  .file-count {
+    font-size: $font-size-sm;
+    color: $text-muted;
+    background: $bg-tertiary;
+    padding: $spacing-xs $spacing-sm;
+    border-radius: $radius-full;
+  }
+}
+
+.drop-zone {
+  border: 2px dashed $border-color;
+  border-radius: $radius-lg;
+  padding: $spacing-2xl;
+  text-align: center;
+  cursor: pointer;
+  transition: all $transition-base ease;
+
+  &:hover {
+    border-color: $primary-color;
+    background: rgba($primary-color, 0.05);
+  }
+
+  &.active {
+    border-color: $secondary-color;
+    background: rgba($secondary-color, 0.1);
+  }
+}
+
+.drop-zone-content {
+  .upload-icon {
+    width: 64px;
+    height: 64px;
+    margin: 0 auto $spacing-md;
+    color: $primary-color;
+  }
+
+  .drop-text {
+    font-size: $font-size-lg;
+    color: $text-secondary;
+    margin-bottom: $spacing-sm;
+  }
+
+  .drop-hint {
+    font-size: $font-size-sm;
+    color: $text-muted;
+  }
+}
+
+.file-list {
+  margin-top: $spacing-lg;
+}
+
+.file-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: $spacing-md;
+  background: $bg-tertiary;
+  border-radius: $radius-md;
+  margin-bottom: $spacing-sm;
+}
+
+.file-info {
+  display: flex;
+  align-items: center;
+  gap: $spacing-md;
+  flex: 1;
+  min-width: 0;
+}
+
+.file-icon {
+  width: 40px;
+  height: 40px;
+  flex-shrink: 0;
+  color: $accent-color;
+}
+
+.file-details {
+  flex: 1;
+  min-width: 0;
+}
+
+.file-name {
+  font-size: $font-size-base;
+  color: $text-primary;
+  margin-bottom: $spacing-xs;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.file-size {
+  font-size: $font-size-sm;
+  color: $text-muted;
+}
+
+.btn-remove {
+  width: 32px;
+  height: 32px;
+  border-radius: $radius-md;
+  border: none;
+  background: $error-color;
+  color: white;
+  font-size: $font-size-xl;
+  cursor: pointer;
+  transition: all $transition-base ease;
+
+  &:hover {
+    background: darken($error-color, 10%);
+  }
+}
+
+.btn-clear {
+  width: 100%;
+  margin-top: $spacing-md;
+  padding: $spacing-sm $spacing-md;
+  background: $bg-tertiary;
+  border: 1px solid $border-color;
+  border-radius: $radius-md;
+  color: $text-primary;
+  cursor: pointer;
+  transition: all $transition-base ease;
+
+  &:hover {
+    background: $bg-hover;
+  }
+}
+
+@media (max-width: 768px) {
+  .section-header h2 {
+    font-size: $font-size-lg;
+  }
+}
+</style>

+ 201 - 0
frontend/src/components/PasswordModal.vue

@@ -0,0 +1,201 @@
+<template>
+  <!-- 密码验证对话框 -->
+  <div v-if="mode === 'access'" class="password-container card">
+    <h2>🔒 此内容需要密码</h2>
+    <input
+      v-model="password"
+      type="password"
+      class="password-input"
+      placeholder="请输入访问密码"
+      @keyup.enter="handleSubmit"
+    >
+    <button class="btn btn-primary btn-lg" @click="handleSubmit">
+      验证密码
+    </button>
+    <p v-if="error" class="error-message">{{ error }}</p>
+  </div>
+
+  <!-- 删除确认对话框 -->
+  <div v-else-if="mode === 'delete'" class="modal-overlay" @click="$emit('cancel')">
+    <div class="modal delete-confirm-modal" @click.stop>
+      <div class="modal-header">
+        <h3>⚠️ 确认删除</h3>
+      </div>
+      <div class="modal-body">
+        <p>确定要删除此分享资源吗?</p>
+        <p class="warning-text">
+          删除后将无法恢复,所有文件和文本内容将被永久删除。
+        </p>
+
+        <!-- 如果有密码保护,提示输入密码 -->
+        <div v-if="requiresPassword" class="password-verify">
+          <label>请输入密码以确认删除:</label>
+          <input
+            v-model="password"
+            type="password"
+            class="password-input"
+            placeholder="输入访问密码"
+            @keyup.enter="handleSubmit"
+          >
+          <p v-if="error" class="error-message">{{ error }}</p>
+        </div>
+      </div>
+      <div class="modal-footer">
+        <button class="btn btn-secondary" @click="$emit('cancel')">
+          取消
+        </button>
+        <button
+          class="btn btn-danger"
+          @click="handleSubmit"
+          :disabled="loading"
+        >
+          <span v-if="loading">删除中...</span>
+          <span v-else>确认删除</span>
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+
+const props = defineProps({
+  // 'access' = 访问密码验证, 'delete' = 删除确认对话框
+  mode: {
+    type: String,
+    required: true,
+    validator: (value) => ['access', 'delete'].includes(value)
+  },
+  requiresPassword: {
+    type: Boolean,
+    default: false
+  },
+  loading: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const emit = defineEmits(['submit', 'cancel'])
+
+const password = ref('')
+const error = ref('')
+
+function handleSubmit() {
+  error.value = ''
+
+  if (!password.value) {
+    error.value = '请输入密码'
+    return
+  }
+
+  emit('submit', password.value)
+}
+
+// 暴露 error 给父组件设置
+function setError(msg) {
+  error.value = msg
+}
+
+defineExpose({ setError })
+</script>
+
+<style lang="scss" scoped>
+@import '@/styles/variables.scss';
+
+.password-container {
+  max-width: 500px;
+  margin: $spacing-2xl auto;
+  text-align: center;
+
+  h2 {
+    margin-bottom: $spacing-xl;
+    color: $text-secondary;
+  }
+
+  .password-input {
+    margin-bottom: $spacing-lg;
+  }
+}
+
+.error-message {
+  color: $error-color;
+  margin-top: $spacing-md;
+}
+
+// 删除确认对话框
+.delete-confirm-modal {
+  max-width: 450px;
+
+  .warning-text {
+    color: $warning-color;
+    font-weight: 500;
+    margin: $spacing-md 0;
+  }
+
+  .password-verify {
+    margin-top: $spacing-lg;
+    padding: $spacing-md;
+    background: $bg-tertiary;
+    border-radius: $radius-md;
+
+    label {
+      display: block;
+      margin-bottom: $spacing-sm;
+      color: $text-secondary;
+    }
+
+    .password-input {
+      width: 100%;
+      padding: $spacing-sm $spacing-md;
+      background: $bg-secondary;
+      border: 1px solid $border-color;
+      border-radius: $radius-md;
+      color: $text-primary;
+
+      &:focus {
+        outline: none;
+        border-color: $primary-color;
+      }
+    }
+
+    .error-message {
+      color: $error-color;
+      margin-top: $spacing-sm;
+      font-size: $font-size-sm;
+    }
+  }
+}
+
+.btn-danger {
+  background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
+  box-shadow: 0 2px 8px rgba(238, 90, 111, 0.3);
+
+  &:hover:not(:disabled) {
+    background: linear-gradient(135deg, #ff8787 0%, #ff6b6b 100%);
+    box-shadow: 0 4px 12px rgba(238, 90, 111, 0.4);
+  }
+
+  &:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+  }
+}
+
+// 移动端优化
+@media (max-width: 768px) {
+  .password-container {
+    padding: $spacing-lg;
+    max-width: 100%;
+
+    h2 {
+      font-size: $font-size-xl;
+    }
+
+    .password-input {
+      font-size: $font-size-base;
+    }
+  }
+}
+</style>

+ 174 - 0
frontend/src/components/QueryInput.vue

@@ -0,0 +1,174 @@
+<template>
+  <div class="query-section">
+    <h3>🔍 查看分享内容</h3>
+    <div class="query-input-group">
+      <input
+        :value="queryId"
+        type="text"
+        class="query-input"
+        placeholder="输入分享内容ID..."
+        @input="$emit('update:queryId', $event.target.value)"
+        @keyup.enter="$emit('query')"
+      >
+      <button
+        class="btn-query"
+        :disabled="!isValid || isChecking"
+        :aria-disabled="!isValid || isChecking"
+        :aria-busy="isChecking"
+        @click="$emit('query')"
+      >
+        <svg v-if="isChecking" class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor">
+          <circle cx="12" cy="12" r="10" stroke-width="4" stroke-opacity="0.3"></circle>
+          <path d="M12 2a10 10 0 0 1 10 10" stroke-width="4" stroke-linecap="round"></path>
+        </svg>
+        <svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor">
+          <circle cx="11" cy="11" r="8"></circle>
+          <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
+        </svg>
+        <span>{{ isChecking ? '验证中...' : '查看' }}</span>
+      </button>
+    </div>
+    <p v-if="error" class="query-error">{{ error }}</p>
+    <p v-else class="query-hint">提示:输入ID的前几位即可自动跳转(如果前缀唯一)</p>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  queryId: {
+    type: String,
+    default: ''
+  },
+  isValid: {
+    type: Boolean,
+    default: false
+  },
+  isChecking: {
+    type: Boolean,
+    default: false
+  },
+  error: {
+    type: String,
+    default: ''
+  }
+})
+
+defineEmits(['update:queryId', 'query'])
+</script>
+
+<style lang="scss" scoped>
+@import '@/styles/variables.scss';
+
+.query-section {
+  @extend .card !optional;
+  text-align: center;
+
+  h3 {
+    margin-bottom: $spacing-lg;
+    color: $text-secondary;
+  }
+}
+
+.query-input-group {
+  display: flex;
+  gap: $spacing-md;
+  margin-bottom: $spacing-sm;
+}
+
+.query-input {
+  flex: 1;
+}
+
+.btn-query {
+  display: inline-flex;
+  align-items: center;
+  gap: $spacing-xs;
+  padding: $spacing-sm $spacing-xl;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+  border: none;
+  border-radius: $radius-md;
+  font-size: $font-size-base;
+  font-weight: 500;
+  cursor: pointer;
+  box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
+  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+  white-space: nowrap;
+
+  svg {
+    width: 18px;
+    height: 18px;
+    stroke-width: 2;
+  }
+
+  &:hover:not(:disabled) {
+    transform: translateY(-1px);
+    box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+    background: linear-gradient(135deg, #7c8efc 0%, #8a5db8 100%);
+  }
+
+  &:active:not(:disabled) {
+    transform: translateY(0);
+    box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
+  }
+
+  &:disabled {
+    background: #4a5568;
+    box-shadow: none;
+    cursor: not-allowed;
+    opacity: 0.6;
+    transform: none !important;
+
+    &:hover {
+      background: #4a5568;
+    }
+  }
+}
+
+.query-hint {
+  font-size: $font-size-sm;
+  color: $text-muted;
+}
+
+.query-error {
+  font-size: $font-size-sm;
+  color: $error-color;
+  margin-top: $spacing-xs;
+  animation: fadeIn 0.3s ease;
+}
+
+.spinner {
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+    transform: translateY(-5px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@media (max-width: 768px) {
+  .query-input-group {
+    flex-direction: column;
+  }
+
+  .btn-query {
+    padding: $spacing-sm $spacing-lg;
+    font-size: $font-size-sm;
+  }
+}
+</style>

+ 254 - 0
frontend/src/components/StarField.vue

@@ -0,0 +1,254 @@
+<template>
+  <canvas ref="canvas" class="starfield-canvas"></canvas>
+</template>
+
+<script>
+export default {
+  name: 'StarField',
+  data() {
+    return {
+      animationId: null,
+      stars: [],
+      mouse: { x: -9999, y: -9999 },
+      lastFrame: 0
+    }
+  },
+  mounted() {
+    this.initCanvas()
+    this.createStars()
+    this.bindEvents()
+
+    const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+    if (prefersReducedMotion) {
+      this.draw()
+    } else {
+      this.animate(0)
+    }
+  },
+  beforeUnmount() {
+    cancelAnimationFrame(this.animationId)
+    window.removeEventListener('resize', this.handleResize)
+    window.removeEventListener('mousemove', this.handleMouseMove)
+    window.removeEventListener('mouseleave', this.handleMouseLeave)
+  },
+  methods: {
+    initCanvas() {
+      const canvas = this.$refs.canvas
+      const dpr = Math.min(window.devicePixelRatio || 1, 2)
+      canvas.width = window.innerWidth * dpr
+      canvas.height = window.innerHeight * dpr
+      canvas.style.width = window.innerWidth + 'px'
+      canvas.style.height = window.innerHeight + 'px'
+      this.ctx = canvas.getContext('2d', { alpha: true })
+      this.ctx.scale(dpr, dpr)
+      this.width = window.innerWidth
+      this.height = window.innerHeight
+    },
+
+    createStars() {
+      // 减少数量,平衡美观与性能
+      const count = Math.min(Math.floor((this.width * this.height) / 8000), 200)
+      const palette = [
+        [255, 255, 255], [180, 200, 255], [255, 180, 200],
+        [200, 255, 220], [255, 220, 150], [150, 200, 255],
+        [255, 160, 230], [120, 220, 255], [255, 200, 120],
+        [180, 150, 255], [100, 255, 200], [255, 140, 140],
+      ]
+      this.stars = []
+      for (let i = 0; i < count; i++) {
+        const rgb = palette[Math.floor(Math.random() * palette.length)]
+        const r = rgb[0], g = rgb[1], b = rgb[2]
+        const radius = Math.random() * 2.0 + 0.6
+        const alpha = Math.random() * 0.4 + 0.6
+        // 预缓存颜色字符串,避免每帧拼接
+        this.stars.push({
+          x: Math.random() * this.width,
+          y: Math.random() * this.height,
+          vx: (Math.random() - 0.5) * 0.3,
+          vy: (Math.random() - 0.5) * 0.3,
+          radius,
+          r, g, b,
+          alpha,
+          twinkleSpeed: Math.random() * 0.03 + 0.008,
+          twinklePhase: Math.random() * Math.PI * 2,
+          // 预缓存:绘制时直接使用
+          coreColor: `rgba(${r},${g},${b},${alpha.toFixed(2)})`,
+          glowCache: this._buildGlowCache(r, g, b, alpha, radius)
+        })
+      }
+    },
+
+    // 预渲染星星光晕到离屏 Canvas,运行时 drawImage 即可
+    _buildGlowCache(r, g, b, alpha, radius) {
+      const size = Math.ceil(radius * 16)
+      const offscreen = document.createElement('canvas')
+      offscreen.width = size * 2
+      offscreen.height = size * 2
+      const octx = offscreen.getContext('2d')
+      const cx = size, cy = size
+
+      // 外层光晕
+      const grad = octx.createRadialGradient(cx, cy, 0, cx, cy, size)
+      grad.addColorStop(0, `rgba(${r},${g},${b},${(alpha * 0.4).toFixed(3)})`)
+      grad.addColorStop(0.25, `rgba(${r},${g},${b},${(alpha * 0.12).toFixed(3)})`)
+      grad.addColorStop(1, `rgba(${r},${g},${b},0)`)
+      octx.fillStyle = grad
+      octx.fillRect(0, 0, size * 2, size * 2)
+
+      // 核心亮点(用简单圆形代替 shadowBlur)
+      octx.beginPath()
+      octx.arc(cx, cy, radius, 0, Math.PI * 2)
+      octx.fillStyle = `rgba(${r},${g},${b},${alpha.toFixed(3)})`
+      octx.fill()
+
+      return { canvas: offscreen, halfSize: size }
+    },
+
+    bindEvents() {
+      this.handleResize = () => {
+        this.initCanvas()
+        this.createStars()
+      }
+      this.handleMouseMove = (e) => {
+        this.mouse.x = e.clientX
+        this.mouse.y = e.clientY
+      }
+      this.handleMouseLeave = () => {
+        this.mouse.x = -9999
+        this.mouse.y = -9999
+      }
+      window.addEventListener('resize', this.handleResize)
+      window.addEventListener('mousemove', this.handleMouseMove)
+      window.addEventListener('mouseleave', this.handleMouseLeave)
+    },
+
+    animate(timestamp) {
+      // 帧率限制 ~30fps,减少 CPU/GPU 负载
+      if (timestamp - this.lastFrame < 33) {
+        this.animationId = requestAnimationFrame((t) => this.animate(t))
+        return
+      }
+      this.lastFrame = timestamp
+      this.update()
+      this.draw()
+      this.animationId = requestAnimationFrame((t) => this.animate(t))
+    },
+
+    update() {
+      const { mouse, stars, width, height } = this
+      const mouseRadius = 180
+      const mouseStrength = 0.02
+      const gravRadius = 100
+      const gravRadius2 = gravRadius * gravRadius
+      const gravStrength = 0.0002
+      const damping = 0.997
+      const repelDist = 28
+
+      for (let i = 0; i < stars.length; i++) {
+        const s = stars[i]
+
+        // 鼠标吸引力
+        const dx = mouse.x - s.x
+        const dy = mouse.y - s.y
+        const dist2 = dx * dx + dy * dy
+        if (dist2 < mouseRadius * mouseRadius && dist2 > 1) {
+          const dist = Math.sqrt(dist2)
+          const force = mouseStrength * (1 - dist / mouseRadius)
+          s.vx += (dx / dist) * force
+          s.vy += (dy / dist) * force
+        }
+
+        // 星星间:近距斥力 + 远距引力
+        for (let j = i + 1; j < stars.length; j++) {
+          const o = stars[j]
+          const gx = o.x - s.x
+          const gy = o.y - s.y
+          const gd2 = gx * gx + gy * gy
+          if (gd2 < gravRadius2 && gd2 > 1) {
+            const gd = Math.sqrt(gd2)
+            const nx = gx / gd
+            const ny = gy / gd
+            let fx, fy
+            if (gd < repelDist) {
+              const repel = gravStrength * 40 * (1 - gd / repelDist)
+              fx = -nx * repel
+              fy = -ny * repel
+            } else {
+              fx = nx * gravStrength
+              fy = ny * gravStrength
+            }
+            s.vx += fx; s.vy += fy
+            o.vx -= fx; o.vy -= fy
+          }
+        }
+
+        s.vx *= damping
+        s.vy *= damping
+        s.x += s.vx
+        s.y += s.vy
+
+        if (s.x < -20) s.x += width + 40
+        if (s.x > width + 20) s.x -= width + 40
+        if (s.y < -20) s.y += height + 40
+        if (s.y > height + 20) s.y -= height + 40
+
+        s.twinklePhase += s.twinkleSpeed
+      }
+    },
+
+    draw() {
+      const ctx = this.ctx
+      const { stars, width, height } = this
+      ctx.clearRect(0, 0, width, height)
+
+      // 连线:合并为单次 beginPath/stroke 批量绘制
+      const linkDist = 100
+      const linkDist2 = linkDist * linkDist
+      ctx.lineWidth = 0.5
+      // 收集所有连线到同一个 path
+      ctx.beginPath()
+      for (let i = 0; i < stars.length; i++) {
+        const a = stars[i]
+        for (let j = i + 1; j < stars.length; j++) {
+          const b = stars[j]
+          const dx = a.x - b.x
+          const dy = a.y - b.y
+          const d2 = dx * dx + dy * dy
+          if (d2 < linkDist2) {
+            ctx.moveTo(a.x, a.y)
+            ctx.lineTo(b.x, b.y)
+          }
+        }
+      }
+      ctx.strokeStyle = 'rgba(200,210,230,0.12)'
+      ctx.stroke()
+
+      // 星星:用预渲染的离屏 Canvas 绘制(代替 shadowBlur + createRadialGradient)
+      for (const s of stars) {
+        const twinkle = 0.6 + 0.4 * Math.sin(s.twinklePhase)
+        // 通过 globalAlpha 控制闪烁
+        ctx.globalAlpha = twinkle
+        const cache = s.glowCache
+        ctx.drawImage(
+          cache.canvas,
+          s.x - cache.halfSize,
+          s.y - cache.halfSize
+        )
+      }
+      ctx.globalAlpha = 1
+    }
+  }
+}
+</script>
+
+<style scoped>
+.starfield-canvas {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 0;
+  pointer-events: none;
+}
+</style>

+ 95 - 0
frontend/src/components/TextShare.vue

@@ -0,0 +1,95 @@
+<template>
+  <section class="text-section">
+    <div class="section-header">
+      <h2>📝 文本分享</h2>
+    </div>
+
+    <textarea
+      :value="textContent"
+      class="text-input"
+      placeholder="在此输入要分享的文本内容..."
+      rows="10"
+      @input="$emit('update:textContent', $event.target.value)"
+    ></textarea>
+
+    <!-- 文本类型选择 -->
+    <div class="text-type-selector">
+      <label>文本类型:</label>
+      <select
+        :value="textType"
+        @change="$emit('update:textType', $event.target.value)"
+      >
+        <option v-for="type in textTypeOptions" :key="type.value" :value="type.value">
+          {{ type.label }}
+        </option>
+      </select>
+    </div>
+  </section>
+</template>
+
+<script setup>
+import config from '@/config'
+
+defineProps({
+  textContent: {
+    type: String,
+    default: ''
+  },
+  textType: {
+    type: String,
+    default: 'plain'
+  }
+})
+
+defineEmits(['update:textContent', 'update:textType'])
+
+const textTypeOptions = config.textTypeOptions
+</script>
+
+<style lang="scss" scoped>
+@import '@/styles/variables.scss';
+
+.text-section {
+  @extend .card !optional;
+  min-width: 0;
+  overflow: hidden;
+}
+
+.section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: $spacing-lg;
+
+  h2 {
+    font-size: $font-size-xl;
+    color: $text-secondary;
+  }
+}
+
+.text-input {
+  width: 100%;
+  min-height: 200px;
+  margin-bottom: $spacing-md;
+}
+
+.text-type-selector {
+  display: flex;
+  align-items: center;
+  gap: $spacing-sm;
+
+  label {
+    color: $text-secondary;
+  }
+
+  select {
+    flex: 1;
+  }
+}
+
+@media (max-width: 768px) {
+  .section-header h2 {
+    font-size: $font-size-lg;
+  }
+}
+</style>

+ 395 - 0
frontend/src/components/TextViewer.vue

@@ -0,0 +1,395 @@
+<template>
+  <div class="text-content card" :class="{ 'full-width': fullWidth }">
+    <div class="content-header">
+      <h2>📝 分享的文本</h2>
+      <div class="header-actions">
+        <span class="content-type">{{ textType }}</span>
+        <!-- Markdown切换按钮 -->
+        <button
+          v-if="textType === 'markdown'"
+          class="btn btn-secondary btn-sm"
+          @click="showMarkdownSource = !showMarkdownSource"
+        >
+          {{ showMarkdownSource ? '👁️ 查看预览' : '📝 查看源码' }}
+        </button>
+        <!-- 复制按钮 -->
+        <button
+          class="btn-copy-text"
+          :class="{ copied: copyStatus === 'copied' }"
+          @click="copyTextContent"
+          :title="copyStatus === 'copied' ? '已复制' : '复制内容'"
+        >
+          <svg v-if="copyStatus !== 'copied'" viewBox="0 0 24 24" fill="none" stroke="currentColor">
+            <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
+            <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
+          </svg>
+          <svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor">
+            <polyline points="20 6 9 17 4 12"></polyline>
+          </svg>
+          <span>{{ copyStatus === 'copied' ? '已复制' : '复制' }}</span>
+        </button>
+      </div>
+    </div>
+
+    <div class="text-display">
+      <Transition name="fade" mode="out-in">
+        <!-- Markdown渲染 -->
+        <MarkdownRenderer
+          v-if="textType === 'markdown' && !showMarkdownSource"
+          :content="textContent"
+          key="preview"
+        />
+
+        <!-- Markdown源码 -->
+        <pre v-else-if="textType === 'markdown' && showMarkdownSource" key="source">{{ textContent }}</pre>
+
+        <!-- 代码高亮 -->
+        <CodeHighlight
+          v-else-if="textType === 'code'"
+          :code="textContent"
+          key="code"
+        />
+
+        <!-- JSON格式化 -->
+        <CodeHighlight
+          v-else-if="textType === 'json'"
+          :code="formatJson(textContent)"
+          language="json"
+          key="json"
+        />
+
+        <!-- 纯文本 -->
+        <pre v-else key="plain">{{ textContent }}</pre>
+      </Transition>
+    </div>
+
+    <div class="content-info">
+      <p v-if="createdTime">创建时间:{{ formatTime(createdTime) }}</p>
+      <p v-if="expirationTime">过期时间:{{ formatTime(expirationTime) }}</p>
+      <p v-if="showDownloadCount">下载次数:{{ currentDownloadCount }}/{{ maxDownloadCount === -1 ? '无限制' : maxDownloadCount }}</p>
+    </div>
+
+    <!-- 删除资源按钮(仅在fullWidth模式下显示) -->
+    <div v-if="fullWidth" class="content-actions">
+      <button
+        class="btn-delete"
+        @click="$emit('delete')"
+        title="删除此分享资源"
+      >
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
+          <polyline points="3 6 5 6 21 6"></polyline>
+          <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
+        </svg>
+        <span>删除资源</span>
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import CodeHighlight from '@/components/CodeHighlight.vue'
+import MarkdownRenderer from '@/components/MarkdownRenderer.vue'
+import toast from '@/utils/toast'
+
+const props = defineProps({
+  textContent: {
+    type: String,
+    required: true
+  },
+  textType: {
+    type: String,
+    required: true
+  },
+  createdTime: {
+    type: String,
+    default: ''
+  },
+  expirationTime: {
+    type: String,
+    default: ''
+  },
+  currentDownloadCount: {
+    type: Number,
+    default: 0
+  },
+  maxDownloadCount: {
+    type: Number,
+    default: -1
+  },
+  showDownloadCount: {
+    type: Boolean,
+    default: false
+  },
+  fullWidth: {
+    type: Boolean,
+    default: false
+  }
+})
+
+defineEmits(['delete'])
+
+const showMarkdownSource = ref(false)
+const copyStatus = ref('idle')
+
+function formatJson(jsonStr) {
+  try {
+    const obj = JSON.parse(jsonStr)
+    return JSON.stringify(obj, null, 2)
+  } catch (e) {
+    return jsonStr
+  }
+}
+
+function formatTime(timeStr) {
+  return new Date(timeStr).toLocaleString('zh-CN')
+}
+
+// 复制文本内容到剪贴板
+async function copyTextContent() {
+  if (!props.textContent) return
+
+  try {
+    await navigator.clipboard.writeText(props.textContent)
+    copyStatus.value = 'copied'
+    toast.success('内容已复制到剪贴板')
+    setTimeout(() => { copyStatus.value = 'idle' }, 2000)
+  } catch (err) {
+    // clipboard API 不可用时回退方案
+    const textarea = document.createElement('textarea')
+    textarea.value = props.textContent
+    textarea.style.position = 'fixed'
+    textarea.style.opacity = '0'
+    document.body.appendChild(textarea)
+    textarea.select()
+    document.execCommand('copy')
+    document.body.removeChild(textarea)
+    copyStatus.value = 'copied'
+    toast.success('内容已复制到剪贴板')
+    setTimeout(() => { copyStatus.value = 'idle' }, 2000)
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+@import '@/styles/variables.scss';
+
+.text-content {
+  .content-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: $spacing-lg;
+
+    h2 {
+      color: $text-secondary;
+    }
+
+    .content-type {
+      font-size: $font-size-sm;
+      background: $bg-tertiary;
+      padding: $spacing-xs $spacing-sm;
+      border-radius: $radius-full;
+      color: $text-muted;
+    }
+  }
+}
+
+// 头部操作区
+.header-actions {
+  display: flex;
+  gap: $spacing-md;
+  align-items: center;
+}
+
+.text-display {
+  background: $bg-tertiary;
+  border-radius: $radius-md;
+  padding: $spacing-lg;
+  margin-bottom: $spacing-lg;
+  max-height: 500px;
+  overflow-y: auto;
+
+  pre {
+    white-space: pre-wrap;
+    word-wrap: break-word;
+    color: $text-primary;
+    font-family: 'Courier New', monospace;
+    line-height: 1.6;
+  }
+}
+
+.content-info {
+  padding-top: $spacing-lg;
+  border-top: 1px solid $border-color;
+
+  p {
+    font-size: $font-size-sm;
+    color: $text-muted;
+    margin-bottom: $spacing-xs;
+  }
+}
+
+// 内容操作区
+.content-actions {
+  margin-top: $spacing-md;
+  display: flex;
+  justify-content: flex-end;
+}
+
+// 删除按钮
+.btn-delete {
+  display: inline-flex;
+  align-items: center;
+  gap: $spacing-xs;
+  padding: $spacing-sm $spacing-md;
+  background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
+  color: white;
+  border: none;
+  border-radius: $radius-md;
+  font-size: $font-size-sm;
+  font-weight: 500;
+  cursor: pointer;
+  box-shadow: 0 2px 8px rgba(238, 90, 111, 0.3);
+  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+
+  svg {
+    width: 18px;
+    height: 18px;
+    stroke-width: 2;
+  }
+
+  &:hover {
+    transform: translateY(-1px);
+    box-shadow: 0 4px 12px rgba(238, 90, 111, 0.4);
+    background: linear-gradient(135deg, #ff8787 0%, #ff6b6b 100%);
+  }
+
+  &:active {
+    transform: translateY(0);
+  }
+
+  &:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+    transform: none !important;
+  }
+}
+
+.full-width {
+  max-width: 100%;
+}
+
+// 复制文本按钮
+.btn-copy-text {
+  display: inline-flex;
+  align-items: center;
+  gap: $spacing-xs;
+  padding: $spacing-xs $spacing-md;
+  border-radius: $radius-md;
+  border: 1px solid $border-color;
+  background: $bg-tertiary;
+  color: $text-secondary;
+  font-size: $font-size-sm;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+  white-space: nowrap;
+
+  svg {
+    width: 16px;
+    height: 16px;
+    stroke-width: 2;
+  }
+
+  &:hover {
+    border-color: $primary-color;
+    color: $primary-color;
+    background: rgba($primary-color, 0.08);
+  }
+
+  &:active {
+    transform: scale(0.97);
+  }
+
+  &.copied {
+    border-color: #11998e;
+    color: #38ef7d;
+    background: rgba(#11998e, 0.1);
+  }
+}
+
+// Markdown过渡动画
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.3s ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
+
+// 移动端优化
+@media (max-width: 768px) {
+  .text-content {
+    .content-header {
+      flex-direction: column;
+      align-items: flex-start;
+      gap: $spacing-sm;
+      margin-bottom: $spacing-md;
+
+      h2 {
+        font-size: $font-size-lg;
+      }
+
+      .header-actions {
+        width: 100%;
+        justify-content: space-between;
+      }
+    }
+  }
+
+  // 文本显示区优化
+  .text-display {
+    max-height: 400px;
+    padding: $spacing-md;
+    font-size: $font-size-sm;
+  }
+
+  // 内容信息优化
+  .content-info {
+    p {
+      font-size: $font-size-xs;
+    }
+  }
+
+  // 删除按钮移动端优化
+  .btn-delete {
+    padding: $spacing-xs $spacing-sm;
+    font-size: $font-size-xs;
+
+    svg {
+      width: 16px;
+      height: 16px;
+    }
+
+    span {
+      @media (max-width: 480px) {
+        display: none;
+      }
+    }
+  }
+}
+
+// 超小屏幕优化(<480px)
+@media (max-width: 480px) {
+  .text-display {
+    font-size: $font-size-xs;
+  }
+
+  .content-header h2 {
+    font-size: $font-size-base;
+  }
+}
+</style>

+ 390 - 0
frontend/src/components/UploadConfig.vue

@@ -0,0 +1,390 @@
+<template>
+  <div class="settings-container">
+    <!-- 过期时间设置 -->
+    <div class="setting-item">
+      <label>⏰ 过期时间:</label>
+      <div class="option-buttons">
+        <button
+          v-for="hours in expirationOptions"
+          :key="hours"
+          :class="['option-btn', { active: expirationHours === hours }]"
+          @click="$emit('update:expirationHours', hours)"
+        >
+          {{ getExpirationLabel(hours) }}
+        </button>
+        <input
+          :value="customHours"
+          type="number"
+          min="1"
+          max="24"
+          class="custom-input"
+          placeholder="自定义"
+          @input="onCustomHoursChange"
+        >
+      </div>
+    </div>
+
+    <!-- 下载次数限制 -->
+    <div class="setting-item">
+      <label>📥 下载次数:</label>
+      <select
+        :value="maxDownloadCount"
+        class="setting-select"
+        @change="$emit('update:maxDownloadCount', Number($event.target.value))"
+      >
+        <option v-for="option in downloadCountOptions" :key="option.value" :value="option.value">
+          {{ option.label }}
+        </option>
+      </select>
+    </div>
+
+    <!-- 密码设置 -->
+    <div class="setting-item">
+      <label>🔒 访问密码:</label>
+      <input
+        :value="password"
+        type="password"
+        class="password-input"
+        placeholder="选填,留空则无需密码"
+        @input="$emit('update:password', $event.target.value)"
+      >
+    </div>
+
+    <!-- 上传按钮 -->
+    <button
+      class="btn-upload"
+      :disabled="!canUpload || uploading"
+      @click="$emit('upload')"
+    >
+      <span v-if="uploading" class="spinner"></span>
+      <span v-else>🚀 立即上传</span>
+    </button>
+
+    <!-- 上传进度条 -->
+    <div v-if="uploading && uploadProgress > 0" class="upload-progress-container">
+      <div class="progress-info">
+        <span class="progress-percent">{{ uploadProgress }}%</span>
+        <span class="progress-speed">{{ formatSpeed(uploadSpeed) }}</span>
+        <span v-if="remainingTime > 0" class="progress-time">
+          剩余 {{ formatTime(remainingTime) }}
+        </span>
+      </div>
+      <div class="progress-bar">
+        <div class="progress-fill" :style="{ width: uploadProgress + '%' }"></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, watch } from 'vue'
+import config from '@/config'
+
+const props = defineProps({
+  expirationHours: {
+    type: Number,
+    default: 3
+  },
+  maxDownloadCount: {
+    type: Number,
+    default: -1
+  },
+  password: {
+    type: String,
+    default: ''
+  },
+  canUpload: {
+    type: Boolean,
+    default: false
+  },
+  uploading: {
+    type: Boolean,
+    default: false
+  },
+  uploadProgress: {
+    type: Number,
+    default: 0
+  },
+  uploadSpeed: {
+    type: Number,
+    default: 0
+  },
+  remainingTime: {
+    type: Number,
+    default: 0
+  }
+})
+
+const emit = defineEmits([
+  'upload',
+  'update:expirationHours',
+  'update:maxDownloadCount',
+  'update:password'
+])
+
+const expirationOptions = config.expirationOptions
+const downloadCountOptions = config.downloadCountOptions
+const customHours = ref(null)
+
+function getExpirationLabel(hours) {
+  return config.expirationLabels[hours] || `${hours}小时`
+}
+
+function onCustomHoursChange(e) {
+  const val = Number(e.target.value)
+  customHours.value = val
+  if (val && val >= 1) {
+    emit('update:expirationHours', val)
+  }
+}
+
+// 格式化上传速度
+function formatSpeed(bytesPerSecond) {
+  if (bytesPerSecond === 0) return '0 B/s'
+  const k = 1024
+  const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
+  const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k))
+  const speed = bytesPerSecond / Math.pow(k, i)
+  return Math.round(speed * 100) / 100 + ' ' + sizes[i]
+}
+
+// 格式化剩余时间
+function formatTime(seconds) {
+  if (seconds < 60) {
+    return `${seconds}秒`
+  } else if (seconds < 3600) {
+    const minutes = Math.floor(seconds / 60)
+    const remainingSeconds = seconds % 60
+    return remainingSeconds > 0 ? `${minutes}分${remainingSeconds}秒` : `${minutes}分钟`
+  } else {
+    const hours = Math.floor(seconds / 3600)
+    const minutes = Math.floor((seconds % 3600) / 60)
+    return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+@import '@/styles/variables.scss';
+
+.settings-container {
+  @extend .card !optional;
+  margin-bottom: $spacing-xl;
+}
+
+.setting-item {
+  display: flex;
+  align-items: center;
+  gap: $spacing-md;
+  margin-bottom: $spacing-lg;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+
+  label {
+    min-width: 120px;
+    color: $text-secondary;
+  }
+}
+
+.option-buttons {
+  display: flex;
+  gap: $spacing-sm;
+  flex-wrap: wrap;
+  flex: 1;
+}
+
+.option-btn {
+  padding: $spacing-sm $spacing-md;
+  background: $bg-tertiary;
+  border: 1px solid $border-color;
+  border-radius: $radius-md;
+  color: $text-primary;
+  cursor: pointer;
+  transition: all $transition-base ease;
+
+  &:hover {
+    background: $bg-hover;
+  }
+
+  &.active {
+    background: linear-gradient(135deg, $primary-color, $secondary-color);
+    border-color: transparent;
+  }
+}
+
+.custom-input {
+  width: 100px;
+  padding: $spacing-sm $spacing-md;
+  background: $bg-tertiary;
+  border: 1px solid $border-color;
+  border-radius: $radius-md;
+  color: $text-primary;
+  text-align: center;
+}
+
+.setting-select,
+.password-input {
+  flex: 1;
+}
+
+.btn-upload {
+  width: 100%;
+  margin-top: $spacing-lg;
+  padding: $spacing-lg;
+  font-size: $font-size-xl;
+  font-weight: 600;
+  border-radius: $radius-lg;
+}
+
+.spinner {
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+// 上传进度条样式
+.upload-progress-container {
+  margin-top: $spacing-md;
+  padding: $spacing-md;
+  background: rgba($bg-secondary, 0.6);
+  backdrop-filter: blur(10px);
+  border: 1px solid $border-color;
+  border-radius: $radius-lg;
+  animation: progressSlideIn 0.3s ease-out;
+}
+
+@keyframes progressSlideIn {
+  from {
+    opacity: 0;
+    transform: translateY(-10px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+.progress-info {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: $spacing-sm;
+  font-size: $font-size-sm;
+  color: $text-secondary;
+
+  .progress-percent {
+    font-weight: 600;
+    color: $primary-color;
+    font-size: $font-size-base;
+  }
+
+  .progress-speed {
+    color: $text-muted;
+  }
+
+  .progress-time {
+    color: $warning-color;
+    font-weight: 500;
+  }
+}
+
+.progress-bar {
+  width: 100%;
+  height: 8px;
+  background: $bg-tertiary;
+  border-radius: $radius-full;
+  overflow: hidden;
+  position: relative;
+}
+
+.progress-fill {
+  height: 100%;
+  background: linear-gradient(90deg, $primary-color 0%, $secondary-color 100%);
+  border-radius: $radius-full;
+  transition: width 0.3s ease;
+  position: relative;
+  overflow: hidden;
+
+  &::after {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    right: 0;
+    background: linear-gradient(
+      90deg,
+      transparent,
+      rgba(255, 255, 255, 0.3),
+      transparent
+    );
+    animation: progressShimmer 2s infinite;
+  }
+}
+
+@keyframes progressShimmer {
+  0% {
+    transform: translateX(-100%);
+  }
+  100% {
+    transform: translateX(100%);
+  }
+}
+
+@media (max-width: 768px) {
+  .option-buttons {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    gap: $spacing-xs;
+  }
+
+  .option-btn {
+    padding: $spacing-xs $spacing-sm;
+    font-size: $font-size-sm;
+    text-align: center;
+  }
+
+  .custom-input {
+    width: 100%;
+    grid-column: 1 / -1;
+  }
+
+  .setting-item {
+    flex-direction: column;
+    align-items: stretch;
+    gap: $spacing-xs;
+
+    label {
+      min-width: auto;
+      margin-bottom: $spacing-xs;
+    }
+  }
+
+  .upload-progress-container {
+    padding: $spacing-sm;
+
+    .progress-info {
+      flex-wrap: wrap;
+      gap: $spacing-xs;
+      font-size: $font-size-xs;
+
+      .progress-percent {
+        font-size: $font-size-sm;
+      }
+    }
+
+    .progress-bar {
+      height: 6px;
+    }
+  }
+}
+</style>

+ 178 - 0
frontend/src/composables/useFileUpload.js

@@ -0,0 +1,178 @@
+import { ref, computed } from 'vue'
+import { useUploadStore } from '@/stores/upload'
+import { createUpload, uploadFiles } from '@/api'
+import config from '@/config'
+import toast from '@/utils/toast'
+
+/**
+ * 文件上传逻辑的 composable
+ * 提取自 Home.vue,管理文件列表、上传流程、进度和错误处理
+ */
+export function useFileUpload() {
+  const uploadStore = useUploadStore()
+
+  // 响应式数据
+  const isDragging = ref(false)
+
+  // 计算属性
+  const maxFiles = computed(() => config.maxFiles)
+  const maxSize = computed(() => config.uploadLimit / 1024 / 1024)
+
+  // 选择文件(通过隐藏 input)
+  function selectFiles() {
+    const input = document.createElement('input')
+    input.type = 'file'
+    input.multiple = true
+    input.accept = config.allowedFileTypes.join(',')
+    input.onchange = (e) => {
+      const files = Array.from(e.target.files)
+      handleFilesAdded(files)
+    }
+    input.click()
+  }
+
+  // 拖拽放下处理
+  function handleDrop(e) {
+    isDragging.value = false
+    const files = Array.from(e.dataTransfer.files)
+    handleFilesAdded(files)
+  }
+
+  // 统一的文件添加与提示逻辑
+  function handleFilesAdded(files) {
+    const result = uploadStore.addFiles(files)
+    if (result.rejected.length > 0) {
+      result.rejected.forEach(({ file }) => {
+        const fileSizeMB = (file.size / 1024 / 1024).toFixed(2)
+        const maxSizeMB = (config.uploadLimit / 1024 / 1024).toFixed(0)
+        toast.error(`文件 "${file.name}" (${fileSizeMB}MB) 超过${maxSizeMB}MB限制,无法上传`)
+      })
+    }
+  }
+
+  // 删除单个文件
+  function removeFile(id) {
+    uploadStore.removeFile(id)
+  }
+
+  // 格式化文件大小
+  function 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 Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
+  }
+
+  // 格式化上传速度
+  function formatSpeed(bytesPerSecond) {
+    if (bytesPerSecond === 0) return '0 B/s'
+    const k = 1024
+    const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
+    const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k))
+    const speed = bytesPerSecond / Math.pow(k, i)
+    return Math.round(speed * 100) / 100 + ' ' + sizes[i]
+  }
+
+  // 格式化剩余时间
+  function formatTime(seconds) {
+    if (seconds < 60) {
+      return `${seconds}秒`
+    } else if (seconds < 3600) {
+      const minutes = Math.floor(seconds / 60)
+      const remainingSeconds = seconds % 60
+      return remainingSeconds > 0 ? `${minutes}分${remainingSeconds}秒` : `${minutes}分钟`
+    } else {
+      const hours = Math.floor(seconds / 3600)
+      const minutes = Math.floor((seconds % 3600) / 60)
+      return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
+    }
+  }
+
+  // 格式化日期时间
+  function formatDateTime(timeStr) {
+    return new Date(timeStr).toLocaleString('zh-CN')
+  }
+
+  // 执行上传
+  async function handleUpload() {
+    if (!uploadStore.canUpload) {
+      toast.warning('请先选择文件或输入文本内容')
+      return
+    }
+
+    uploadStore.setUploading(true)
+
+    try {
+      // 创建上传记录
+      const result = await createUpload({
+        expirationHours: uploadStore.expirationHours,
+        maxDownloadCount: uploadStore.maxDownloadCount,
+        password: uploadStore.password,
+        textContent: uploadStore.textContent,
+        textType: uploadStore.textType
+      })
+
+      // 如果有文件,上传文件(带进度回调)
+      if (uploadStore.hasFiles) {
+        const files = uploadStore.selectedFiles.map(f => f.file)
+        await uploadFiles(result.id, files, (percent, speed, loaded, total) => {
+          uploadStore.setUploadProgress(percent, speed, loaded, total)
+        })
+      }
+
+      // 设置上传结果
+      uploadStore.setUploadResult(result)
+    } catch (error) {
+      toast.error('上传失败:' + error.message)
+    } finally {
+      uploadStore.setUploading(false)
+      uploadStore.resetUploadProgress()
+    }
+  }
+
+  // 关闭结果弹窗
+  function closeModal() {
+    uploadStore.setUploadResult(null)
+  }
+
+  // 复制分享链接
+  function copyUrl() {
+    navigator.clipboard.writeText(uploadStore.uploadResult.shareUrl)
+    toast.success('链接已复制到剪贴板')
+  }
+
+  // 重置上传状态(继续上传)
+  function resetUpload() {
+    uploadStore.reset()
+    closeModal()
+  }
+
+  return {
+    // 状态
+    uploadStore,
+    isDragging,
+
+    // 计算属性
+    maxFiles,
+    maxSize,
+
+    // 文件操作方法
+    selectFiles,
+    handleDrop,
+    removeFile,
+    handleFilesAdded,
+
+    // 上传方法
+    handleUpload,
+    closeModal,
+    copyUrl,
+    resetUpload,
+
+    // 格式化工具方法
+    formatFileSize,
+    formatSpeed,
+    formatTime,
+    formatDateTime
+  }
+}

+ 4 - 0
frontend/src/main.js

@@ -12,5 +12,9 @@ fetchConfig().then(() => {
   app.use(createPinia())
   app.use(router)
 
+  app.config.errorHandler = (err, instance, info) => {
+    console.error('Unhandled error:', err, info)
+  }
+
   app.mount('#app')
 })

+ 5 - 0
frontend/src/router/index.js

@@ -35,7 +35,12 @@ const router = createRouter({
 // 路由标题
 router.beforeEach((to, from, next) => {
   document.title = to.meta.title || 'Snapshot'
+  document.body.classList.add('route-loading')
   next()
 })
 
+router.afterEach(() => {
+  document.body.classList.remove('route-loading')
+})
+
 export default router

+ 27 - 33
frontend/src/styles/global.scss

@@ -23,37 +23,7 @@ body {
   position: relative;
 }
 
-// 银河星空背景
-body::before {
-  content: '';
-  position: fixed;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  background-image:
-    radial-gradient(2px 2px at 20px 30px, #ffffff, rgba(0,0,0,0)),
-    radial-gradient(2px 2px at 40px 70px, #ffffff, rgba(0,0,0,0)),
-    radial-gradient(2px 2px at 50px 160px, #ffffff, rgba(0,0,0,0)),
-    radial-gradient(2px 2px at 90px 40px, #ffffff, rgba(0,0,0,0)),
-    radial-gradient(2px 2px at 130px 80px, #ffffff, rgba(0,0,0,0)),
-    radial-gradient(2px 2px at 160px 120px, #ffffff, rgba(0,0,0,0));
-  background-repeat: repeat;
-  background-size: 200px 200px;
-  animation: twinkle 5s ease-in-out infinite;
-  opacity: 0.5;
-  z-index: 0;
-  pointer-events: none;
-}
-
-@keyframes twinkle {
-  0%, 100% {
-    opacity: 0.5;
-  }
-  50% {
-    opacity: 0.8;
-  }
-}
+// 星空背景由 StarField.vue Canvas 组件渲染,此处仅保留渐变底色
 
 // 滚动条样式
 ::-webkit-scrollbar {
@@ -195,7 +165,7 @@ textarea {
 // 卡片
 .card {
   background: rgba($bg-secondary, 0.8);
-  backdrop-filter: blur(10px);
+  backdrop-filter: blur($blur-amount);
   border: 1px solid $border-color;
   border-radius: $radius-lg;
   padding: $spacing-xl;
@@ -213,7 +183,7 @@ textarea {
   border-radius: $radius-md;
   box-shadow: $shadow-lg;
   z-index: $z-tooltip;
-  animation: slideIn 0.3s ease;
+  animation: slideIn $transition-speed ease;
 
   &.toast-success {
     border-left: 4px solid $success-color;
@@ -264,3 +234,27 @@ textarea {
   position: relative;
   z-index: 1;
 }
+
+// 路由加载条
+body::before {
+  content: '';
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 0;
+  height: 3px;
+  background: linear-gradient(90deg, $primary-color, $secondary-color);
+  z-index: $z-tooltip;
+  transition: none;
+}
+
+body.route-loading::before {
+  animation: route-loading-bar 10s ease-out forwards;
+}
+
+@keyframes route-loading-bar {
+  0% { width: 0; }
+  20% { width: 40%; }
+  60% { width: 70%; }
+  100% { width: 90%; }
+}

+ 7 - 0
frontend/src/styles/variables.scss

@@ -58,6 +58,13 @@ $font-size-4xl: 2.25rem;
 $transition-fast: 150ms;
 $transition-base: 200ms;
 $transition-slow: 300ms;
+$transition-speed: 0.3s;
+
+// 效果
+$blur-amount: 10px;
+
+// 额外阴影
+$shadow-card: 0 8px 32px rgba(0, 0, 0, 0.3);
 
 // Z-index
 $z-dropdown: 1000;

+ 60 - 845
frontend/src/views/Home.vue

@@ -13,191 +13,48 @@
     <main class="main-content">
       <div class="upload-container">
         <!-- 左侧文件上传区 -->
-        <section class="upload-section">
-          <div class="section-header">
-            <h2>📁 文件上传</h2>
-            <span class="file-count">{{ uploadStore.selectedFiles.length }}/{{ maxFiles }}</span>
-          </div>
-
-          <!-- 拖拽上传区域 -->
-          <div
-            class="drop-zone"
-            :class="{ active: isDragging }"
-            @dragover.prevent="isDragging = true"
-            @dragleave.prevent="isDragging = false"
-            @drop.prevent="handleDrop"
-            @click="selectFiles"
-          >
-            <div class="drop-zone-content">
-              <svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
-                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
-                <polyline points="17 8 12 3 7 8"></polyline>
-                <line x1="12" y1="3" x2="12" y2="15"></line>
-              </svg>
-              <p class="drop-text">拖拽文件到这里,或点击选择文件</p>
-              <p class="drop-hint">最多上传 {{ maxFiles }} 个文件,每个文件不超过 {{ maxSize }}MB</p>
-            </div>
-          </div>
-
-          <!-- 文件列表 -->
-          <div v-if="uploadStore.selectedFiles.length > 0" class="file-list">
-            <div
-              v-for="file in uploadStore.selectedFiles"
-              :key="file.id"
-              class="file-item"
-            >
-              <div class="file-info">
-                <svg class="file-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
-                  <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
-                  <polyline points="13 2 13 9 20 9"></polyline>
-                </svg>
-                <div class="file-details">
-                  <p class="file-name">{{ file.name }}</p>
-                  <p class="file-size">{{ formatFileSize(file.size) }}</p>
-                </div>
-              </div>
-              <button class="btn-remove" @click="removeFile(file.id)">×</button>
-            </div>
-          </div>
-
-          <!-- 清空按钮 -->
-          <button
-            v-if="uploadStore.selectedFiles.length > 0"
-            class="btn-clear"
-            @click="uploadStore.clearFiles"
-          >
-            清空文件列表
-          </button>
-        </section>
+        <FileUploader
+          :files="uploadStore.selectedFiles"
+          @remove="removeFile"
+          @clear="uploadStore.clearFiles"
+          @files-selected="handleFilesAdded"
+          @files-dropped="handleFilesAdded"
+        />
 
         <!-- 右侧文本输入区 -->
-        <section class="text-section">
-          <div class="section-header">
-            <h2>📝 文本分享</h2>
-          </div>
-
-          <textarea
-            v-model="uploadStore.textContent"
-            class="text-input"
-            placeholder="在此输入要分享的文本内容..."
-            rows="10"
-          ></textarea>
-
-          <!-- 文本类型选择 -->
-          <div class="text-type-selector">
-            <label>文本类型:</label>
-            <select v-model="uploadStore.textType">
-              <option v-for="type in textTypeOptions" :key="type.value" :value="type.value">
-                {{ type.label }}
-              </option>
-            </select>
-          </div>
-        </section>
+        <TextShare
+          :text-content="uploadStore.textContent"
+          :text-type="uploadStore.textType"
+          @update:text-content="uploadStore.textContent = $event"
+          @update:text-type="uploadStore.textType = $event"
+        />
       </div>
 
       <!-- 底部设置和操作区 -->
-      <div class="settings-container">
-        <!-- 过期时间设置 -->
-        <div class="setting-item">
-          <label>⏰ 过期时间:</label>
-          <div class="option-buttons">
-            <button
-              v-for="hours in expirationOptions"
-              :key="hours"
-              :class="['option-btn', { active: uploadStore.expirationHours === hours }]"
-              @click="uploadStore.expirationHours = hours"
-            >
-              {{ getExpirationLabel(hours) }}
-            </button>
-            <input
-              v-model.number="customHours"
-              type="number"
-              min="1"
-              max="24"
-              class="custom-input"
-              placeholder="自定义"
-            >
-          </div>
-        </div>
-
-        <!-- 下载次数限制 -->
-        <div class="setting-item">
-          <label>📥 下载次数:</label>
-          <select v-model="uploadStore.maxDownloadCount" class="setting-select">
-            <option v-for="option in downloadCountOptions" :key="option.value" :value="option.value">
-              {{ option.label }}
-            </option>
-          </select>
-        </div>
-
-        <!-- 密码设置 -->
-        <div class="setting-item">
-          <label>🔒 访问密码:</label>
-          <input
-            v-model="uploadStore.password"
-            type="password"
-            class="password-input"
-            placeholder="选填,留空则无需密码"
-          >
-        </div>
-
-        <!-- 上传按钮 -->
-        <button
-          class="btn-upload"
-          :disabled="!uploadStore.canUpload || uploadStore.uploading"
-          @click="handleUpload"
-        >
-          <span v-if="uploadStore.uploading" class="spinner"></span>
-          <span v-else>🚀 立即上传</span>
-        </button>
-
-        <!-- 上传进度条 -->
-        <div v-if="uploadStore.uploading && uploadStore.uploadProgress > 0" class="upload-progress-container">
-          <div class="progress-info">
-            <span class="progress-percent">{{ uploadStore.uploadProgress }}%</span>
-            <span class="progress-speed">{{ formatSpeed(uploadStore.uploadSpeed) }}</span>
-            <span v-if="uploadStore.remainingTime > 0" class="progress-time">
-              剩余 {{ formatTime(uploadStore.remainingTime) }}
-            </span>
-          </div>
-          <div class="progress-bar">
-            <div class="progress-fill" :style="{ width: uploadStore.uploadProgress + '%' }"></div>
-          </div>
-        </div>
-      </div>
+      <UploadConfig
+        :expiration-hours="uploadStore.expirationHours"
+        :max-download-count="uploadStore.maxDownloadCount"
+        :password="uploadStore.password"
+        :can-upload="uploadStore.canUpload"
+        :uploading="uploadStore.uploading"
+        :upload-progress="uploadStore.uploadProgress"
+        :upload-speed="uploadStore.uploadSpeed"
+        :remaining-time="uploadStore.remainingTime"
+        @upload="handleUpload"
+        @update:expiration-hours="uploadStore.expirationHours = $event"
+        @update:max-download-count="uploadStore.maxDownloadCount = $event"
+        @update:password="uploadStore.password = $event"
+      />
 
       <!-- ID查询区 -->
-      <div class="query-section">
-        <h3>🔍 查看分享内容</h3>
-        <div class="query-input-group">
-          <input
-            v-model="queryId"
-            type="text"
-            class="query-input"
-            placeholder="输入分享内容ID..."
-            @keyup.enter="handleQuery"
-          >
-          <button
-            class="btn-query"
-            :disabled="!isQueryValid || isQueryChecking"
-            :aria-disabled="!isQueryValid || isQueryChecking"
-            :aria-busy="isQueryChecking"
-            @click="handleQuery"
-          >
-            <svg v-if="isQueryChecking" class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor">
-              <circle cx="12" cy="12" r="10" stroke-width="4" stroke-opacity="0.3"></circle>
-              <path d="M12 2a10 10 0 0 1 10 10" stroke-width="4" stroke-linecap="round"></path>
-            </svg>
-            <svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor">
-              <circle cx="11" cy="11" r="8"></circle>
-              <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
-            </svg>
-            <span>{{ isQueryChecking ? '验证中...' : '查看' }}</span>
-          </button>
-        </div>
-        <p v-if="queryError" class="query-error">{{ queryError }}</p>
-        <p v-else class="query-hint">提示:输入ID的前几位即可自动跳转(如果前缀唯一)</p>
-      </div>
+      <QueryInput
+        :query-id="queryId"
+        :is-valid="isQueryValid"
+        :is-checking="isQueryChecking"
+        :error="queryError"
+        @update:query-id="onQueryIdChange"
+        @query="handleQuery"
+      />
     </main>
 
     <!-- 上传成功弹窗 -->
@@ -230,44 +87,34 @@
 </template>
 
 <script setup>
-import { ref, computed, watch } from 'vue'
+import { ref, watch } from 'vue'
 import { useRouter } from 'vue-router'
 import { useUploadStore } from '@/stores/upload'
-import { createUpload, uploadFiles, resolveIdByPrefix } from '@/api'
-import config from '@/config'
+import { resolveIdByPrefix } from '@/api'
+import { useFileUpload } from '@/composables/useFileUpload'
+import FileUploader from '@/components/FileUploader.vue'
+import TextShare from '@/components/TextShare.vue'
+import UploadConfig from '@/components/UploadConfig.vue'
+import QueryInput from '@/components/QueryInput.vue'
 import toast from '@/utils/toast'
 
 const router = useRouter()
 const uploadStore = useUploadStore()
-
-// 响应式数据
-const isDragging = ref(false)
+const {
+  handleUpload,
+  closeModal,
+  copyUrl,
+  resetUpload,
+  removeFile,
+  handleFilesAdded,
+  formatDateTime
+} = useFileUpload()
+
+// ID查询状态
 const queryId = ref('')
-const customHours = ref(null)
-
-// ID查询验证状态
-const isQueryValid = ref(false)      // 前缀是否有效
-const isQueryChecking = ref(false)   // 是否正在验证
-const queryError = ref('')           // 错误提示信息
-
-// 计算属性
-const maxFiles = computed(() => config.maxFiles)
-const maxSize = computed(() => config.uploadLimit / 1024 / 1024)
-const expirationOptions = computed(() => config.expirationOptions)
-const downloadCountOptions = computed(() => config.downloadCountOptions)
-const textTypeOptions = computed(() => config.textTypeOptions)
-
-// 获取过期时间选项的显示标签
-function getExpirationLabel(hours) {
-  return config.expirationLabels[hours] || `${hours}小时`
-}
-
-// 监听自定义小时数
-watch(customHours, (newVal) => {
-  if (newVal && newVal >= 1) {
-    uploadStore.expirationHours = newVal
-  }
-})
+const isQueryValid = ref(false)
+const isQueryChecking = ref(false)
+const queryError = ref('')
 
 // 防抖函数
 function debounce(fn, delay) {
@@ -280,26 +127,20 @@ function debounce(fn, delay) {
 
 // 实时验证ID前缀
 const validatePrefix = debounce(async (prefix) => {
-  // 空输入或仅空格
   if (!prefix || prefix.trim().length === 0) {
     isQueryValid.value = false
     queryError.value = ''
     return
   }
 
-  // 显示加载状态
   isQueryChecking.value = true
 
   try {
-    // 调用后端API验证前缀
     await resolveIdByPrefix(prefix.trim())
-    // 验证成功
     isQueryValid.value = true
     queryError.value = ''
   } catch (error) {
-    // 验证失败
     isQueryValid.value = false
-    // 解析错误信息
     const errorMsg = error.message || '未知错误'
     if (errorMsg.includes('前缀匹配多个记录')) {
       queryError.value = '前缀匹配多个记录,请提供更完整的ID'
@@ -311,145 +152,14 @@ const validatePrefix = debounce(async (prefix) => {
   } finally {
     isQueryChecking.value = false
   }
-}, 300) // 300ms防抖延迟
-
-// 监听ID输入变化
-watch(queryId, (newVal) => {
-  validatePrefix(newVal)
-})
-
-// 方法
-function selectFiles() {
-  const input = document.createElement('input')
-  input.type = 'file'
-  input.multiple = true
-  input.accept = config.allowedFileTypes.join(',')
-  input.onchange = (e) => {
-    const files = Array.from(e.target.files)
-    const result = uploadStore.addFiles(files)
+}, 300)
 
-    // 显示被拒绝的文件提示
-    if (result.rejected.length > 0) {
-      result.rejected.forEach(({ file }) => {
-        const fileSizeMB = (file.size / 1024 / 1024).toFixed(2)
-        const maxSizeMB = (config.uploadLimit / 1024 / 1024).toFixed(0)
-        toast.error(`文件 "${file.name}" (${fileSizeMB}MB) 超过${maxSizeMB}MB限制,无法上传`)
-      })
-    }
-  }
-  input.click()
-}
-
-function handleDrop(e) {
-  isDragging.value = false
-  const files = Array.from(e.dataTransfer.files)
-  const result = uploadStore.addFiles(files)
-
-  // 显示被拒绝的文件提示
-  if (result.rejected.length > 0) {
-    result.rejected.forEach(({ file }) => {
-      const fileSizeMB = (file.size / 1024 / 1024).toFixed(2)
-      const maxSizeMB = (config.uploadLimit / 1024 / 1024).toFixed(0)
-      toast.error(`文件 "${file.name}" (${fileSizeMB}MB) 超过${maxSizeMB}MB限制,无法上传`)
-    })
-  }
-}
-
-function removeFile(id) {
-  uploadStore.removeFile(id)
-}
-
-function 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 Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
-}
-
-// 格式化上传速度(字节/秒转为可读格式)
-function formatSpeed(bytesPerSecond) {
-  if (bytesPerSecond === 0) return '0 B/s'
-  const k = 1024
-  const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
-  const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k))
-  const speed = bytesPerSecond / Math.pow(k, i)
-  return Math.round(speed * 100) / 100 + ' ' + sizes[i]
-}
-
-// 格式化剩余时间(秒转为可读格式)
-function formatTime(seconds) {
-  if (seconds < 60) {
-    return `${seconds}秒`
-  } else if (seconds < 3600) {
-    const minutes = Math.floor(seconds / 60)
-    const remainingSeconds = seconds % 60
-    return remainingSeconds > 0 ? `${minutes}分${remainingSeconds}秒` : `${minutes}分钟`
-  } else {
-    const hours = Math.floor(seconds / 3600)
-    const minutes = Math.floor((seconds % 3600) / 60)
-    return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
-  }
-}
-
-function formatDateTime(timeStr) {
-  return new Date(timeStr).toLocaleString('zh-CN')
-}
-
-async function handleUpload() {
-  if (!uploadStore.canUpload) {
-    toast.warning('请先选择文件或输入文本内容')
-    return
-  }
-
-  uploadStore.setUploading(true)
-
-  try {
-    // 创建上传记录
-    const result = await createUpload({
-      expirationHours: uploadStore.expirationHours,
-      maxDownloadCount: uploadStore.maxDownloadCount,
-      password: uploadStore.password,
-      textContent: uploadStore.textContent,
-      textType: uploadStore.textType
-    })
-
-    // 如果有文件,上传文件(带进度回调)
-    if (uploadStore.hasFiles) {
-      const files = uploadStore.selectedFiles.map(f => f.file)
-      await uploadFiles(result.id, files, (percent, speed, loaded, total) => {
-        // 更新进度状态
-        uploadStore.setUploadProgress(percent, speed, loaded, total)
-      })
-    }
-
-    // 设置上传结果
-    uploadStore.setUploadResult(result)
-
-  } catch (error) {
-    toast.error('上传失败:' + error.message)
-  } finally {
-    uploadStore.setUploading(false)
-    uploadStore.resetUploadProgress() // 重置进度
-  }
-}
-
-function closeModal() {
-  uploadStore.setUploadResult(null)
-}
-
-function copyUrl() {
-  navigator.clipboard.writeText(uploadStore.uploadResult.shareUrl)
-  toast.success('链接已复制到剪贴板')
-}
-
-function resetUpload() {
-  uploadStore.reset()
-  closeModal()
+function onQueryIdChange(val) {
+  queryId.value = val
+  validatePrefix(val)
 }
 
 async function handleQuery() {
-  // 按钮已禁用或正在验证,不执行操作
   if (!isQueryValid.value || isQueryChecking.value) {
     return
   }
@@ -462,20 +172,15 @@ async function handleQuery() {
   const prefix = queryId.value.trim()
 
   try {
-    // 调用后端前缀解析接口(此时已经验证过,应该会成功)
     const response = await resolveIdByPrefix(prefix)
 
-    // 如果成功返回,跳转到完整ID
     if (response.fullId) {
       router.push(`/s/${response.fullId}`)
     } else {
-      // 如果没有返回完整ID,直接使用输入的ID
       router.push(`/s/${prefix}`)
     }
   } catch (error) {
-    // 如果前缀解析失败(404或其他错误),提示用户
     toast.error(`未找到ID前缀为 "${prefix}" 的分享内容,请检查输入是否正确`)
-    // 重置验证状态
     isQueryValid.value = false
   }
 }
@@ -540,435 +245,6 @@ async function handleQuery() {
   margin-bottom: $spacing-xl;
 }
 
-.upload-section,
-.text-section {
-  @extend .card !optional;
-  min-width: 0; // 防止grid子项被内容撑大
-  overflow: hidden; // 防止内容溢出
-}
-
-.section-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: $spacing-lg;
-
-  h2 {
-    font-size: $font-size-xl;
-    color: $text-secondary;
-  }
-
-  .file-count {
-    font-size: $font-size-sm;
-    color: $text-muted;
-    background: $bg-tertiary;
-    padding: $spacing-xs $spacing-sm;
-    border-radius: $radius-full;
-  }
-}
-
-.drop-zone {
-  border: 2px dashed $border-color;
-  border-radius: $radius-lg;
-  padding: $spacing-2xl;
-  text-align: center;
-  cursor: pointer;
-  transition: all $transition-base ease;
-
-  &:hover {
-    border-color: $primary-color;
-    background: rgba($primary-color, 0.05);
-  }
-
-  &.active {
-    border-color: $secondary-color;
-    background: rgba($secondary-color, 0.1);
-  }
-}
-
-.drop-zone-content {
-  .upload-icon {
-    width: 64px;
-    height: 64px;
-    margin: 0 auto $spacing-md;
-    color: $primary-color;
-  }
-
-  .drop-text {
-    font-size: $font-size-lg;
-    color: $text-secondary;
-    margin-bottom: $spacing-sm;
-  }
-
-  .drop-hint {
-    font-size: $font-size-sm;
-    color: $text-muted;
-  }
-}
-
-.file-list {
-  margin-top: $spacing-lg;
-}
-
-.file-item {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  padding: $spacing-md;
-  background: $bg-tertiary;
-  border-radius: $radius-md;
-  margin-bottom: $spacing-sm;
-}
-
-.file-info {
-  display: flex;
-  align-items: center;
-  gap: $spacing-md;
-  flex: 1;
-  min-width: 0;
-}
-
-.file-icon {
-  width: 40px;
-  height: 40px;
-  flex-shrink: 0;
-  color: $accent-color;
-}
-
-.file-details {
-  flex: 1;
-  min-width: 0;
-}
-
-.file-name {
-  font-size: $font-size-base;
-  color: $text-primary;
-  margin-bottom: $spacing-xs;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-.file-size {
-  font-size: $font-size-sm;
-  color: $text-muted;
-}
-
-.btn-remove {
-  width: 32px;
-  height: 32px;
-  border-radius: $radius-md;
-  border: none;
-  background: $error-color;
-  color: white;
-  font-size: $font-size-xl;
-  cursor: pointer;
-  transition: all $transition-base ease;
-
-  &:hover {
-    background: darken($error-color, 10%);
-  }
-}
-
-.btn-clear {
-  width: 100%;
-  margin-top: $spacing-md;
-  padding: $spacing-sm $spacing-md;
-  background: $bg-tertiary;
-  border: 1px solid $border-color;
-  border-radius: $radius-md;
-  color: $text-primary;
-  cursor: pointer;
-  transition: all $transition-base ease;
-
-  &:hover {
-    background: $bg-hover;
-  }
-}
-
-.text-input {
-  width: 100%;
-  min-height: 200px;
-  margin-bottom: $spacing-md;
-}
-
-.text-type-selector {
-  display: flex;
-  align-items: center;
-  gap: $spacing-sm;
-
-  label {
-    color: $text-secondary;
-  }
-
-  select {
-    flex: 1;
-  }
-}
-
-.settings-container {
-  @extend .card !optional;
-  margin-bottom: $spacing-xl;
-}
-
-.setting-item {
-  display: flex;
-  align-items: center;
-  gap: $spacing-md;
-  margin-bottom: $spacing-lg;
-
-  &:last-child {
-    margin-bottom: 0;
-  }
-
-  label {
-    min-width: 120px;
-    color: $text-secondary;
-  }
-}
-
-.option-buttons {
-  display: flex;
-  gap: $spacing-sm;
-  flex-wrap: wrap;
-  flex: 1;
-}
-
-.option-btn {
-  padding: $spacing-sm $spacing-md;
-  background: $bg-tertiary;
-  border: 1px solid $border-color;
-  border-radius: $radius-md;
-  color: $text-primary;
-  cursor: pointer;
-  transition: all $transition-base ease;
-
-  &:hover {
-    background: $bg-hover;
-  }
-
-  &.active {
-    background: linear-gradient(135deg, $primary-color, $secondary-color);
-    border-color: transparent;
-  }
-}
-
-.custom-input {
-  width: 100px;
-  padding: $spacing-sm $spacing-md;
-  background: $bg-tertiary;
-  border: 1px solid $border-color;
-  border-radius: $radius-md;
-  color: $text-primary;
-  text-align: center;
-}
-
-.setting-select,
-.password-input {
-  flex: 1;
-}
-
-.btn-upload {
-  width: 100%;
-  margin-top: $spacing-lg;
-  padding: $spacing-lg;
-  font-size: $font-size-xl;
-  font-weight: 600;
-  border-radius: $radius-lg;
-}
-
-// 上传进度条样式
-.upload-progress-container {
-  margin-top: $spacing-md;
-  padding: $spacing-md;
-  background: rgba($bg-secondary, 0.6);
-  backdrop-filter: blur(10px);
-  border: 1px solid $border-color;
-  border-radius: $radius-lg;
-  animation: progressSlideIn 0.3s ease-out;
-}
-
-@keyframes progressSlideIn {
-  from {
-    opacity: 0;
-    transform: translateY(-10px);
-  }
-  to {
-    opacity: 1;
-    transform: translateY(0);
-  }
-}
-
-.progress-info {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: $spacing-sm;
-  font-size: $font-size-sm;
-  color: $text-secondary;
-
-  .progress-percent {
-    font-weight: 600;
-    color: $primary-color;
-    font-size: $font-size-base;
-  }
-
-  .progress-speed {
-    color: $text-muted;
-  }
-
-  .progress-time {
-    color: $warning-color;
-    font-weight: 500;
-  }
-}
-
-.progress-bar {
-  width: 100%;
-  height: 8px;
-  background: $bg-tertiary;
-  border-radius: $radius-full;
-  overflow: hidden;
-  position: relative;
-}
-
-.progress-fill {
-  height: 100%;
-  background: linear-gradient(90deg, $primary-color 0%, $secondary-color 100%);
-  border-radius: $radius-full;
-  transition: width 0.3s ease;
-  position: relative;
-  overflow: hidden;
-
-  // 添加闪烁动画效果
-  &::after {
-    content: '';
-    position: absolute;
-    top: 0;
-    left: 0;
-    bottom: 0;
-    right: 0;
-    background: linear-gradient(
-      90deg,
-      transparent,
-      rgba(255, 255, 255, 0.3),
-      transparent
-    );
-    animation: progressShimmer 2s infinite;
-  }
-}
-
-@keyframes progressShimmer {
-  0% {
-    transform: translateX(-100%);
-  }
-  100% {
-    transform: translateX(100%);
-  }
-}
-
-.query-section {
-  @extend .card !optional;
-  text-align: center;
-
-  h3 {
-    margin-bottom: $spacing-lg;
-    color: $text-secondary;
-  }
-}
-
-.query-input-group {
-  display: flex;
-  gap: $spacing-md;
-  margin-bottom: $spacing-sm;
-}
-
-.query-input {
-  flex: 1;
-}
-
-.btn-query {
-  display: inline-flex;
-  align-items: center;
-  gap: $spacing-xs;
-  padding: $spacing-sm $spacing-xl;
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-  color: white;
-  border: none;
-  border-radius: $radius-md;
-  font-size: $font-size-base;
-  font-weight: 500;
-  cursor: pointer;
-  box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
-  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
-  white-space: nowrap;
-
-  svg {
-    width: 18px;
-    height: 18px;
-    stroke-width: 2;
-  }
-
-  &:hover:not(:disabled) {
-    transform: translateY(-1px);
-    box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
-    background: linear-gradient(135deg, #7c8efc 0%, #8a5db8 100%);
-  }
-
-  &:active:not(:disabled) {
-    transform: translateY(0);
-    box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
-  }
-
-  &:disabled {
-    background: #4a5568;
-    box-shadow: none;
-    cursor: not-allowed;
-    opacity: 0.6;
-    transform: none !important;
-
-    &:hover {
-      background: #4a5568;
-    }
-  }
-}
-
-.query-hint {
-  font-size: $font-size-sm;
-  color: $text-muted;
-}
-
-.query-error {
-  font-size: $font-size-sm;
-  color: $error-color;
-  margin-top: $spacing-xs;
-  animation: fadeIn 0.3s ease;
-}
-
-@keyframes fadeIn {
-  from {
-    opacity: 0;
-    transform: translateY(-5px);
-  }
-  to {
-    opacity: 1;
-    transform: translateY(0);
-  }
-}
-
-.spinner {
-  animation: spin 1s linear infinite;
-}
-
-@keyframes spin {
-  from {
-    transform: rotate(0deg);
-  }
-  to {
-    transform: rotate(360deg);
-  }
-}
-
 // 模态框
 .modal-overlay {
   position: fixed;
@@ -1091,71 +367,10 @@ async function handleQuery() {
     grid-template-columns: 1fr;
     gap: $spacing-md;
   }
-
-  .section-header h2 {
-    font-size: $font-size-lg;
-  }
-
-  .option-buttons {
-    display: grid;
-    grid-template-columns: repeat(3, 1fr); // 3列网格布局
-    gap: $spacing-xs;
-  }
-
-  .option-btn {
-    padding: $spacing-xs $spacing-sm;
-    font-size: $font-size-sm; // 缩小字体
-    text-align: center;
-  }
-
-  .custom-input {
-    width: 100%; // 占满整行
-    grid-column: 1 / -1; // 跨越所有列
-  }
-
-  .setting-item {
-    flex-direction: column;
-    align-items: stretch;
-    gap: $spacing-xs;
-
-    label {
-      min-width: auto;
-      margin-bottom: $spacing-xs;
-    }
-  }
-
-  .query-input-group {
-    flex-direction: column;
-  }
-
-  .btn-query {
-    padding: $spacing-sm $spacing-lg;
-    font-size: $font-size-sm;
-  }
-
-  // 进度条移动端适配
-  .upload-progress-container {
-    padding: $spacing-sm;
-
-    .progress-info {
-      flex-wrap: wrap;
-      gap: $spacing-xs;
-      font-size: $font-size-xs;
-
-      .progress-percent {
-        font-size: $font-size-sm;
-      }
-    }
-
-    .progress-bar {
-      height: 6px;
-    }
-  }
 }
 </style>
 
 <script>
-import { watch } from 'vue'
 export default {
   name: 'HomePage'
 }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 69 - 852
frontend/src/views/Share.vue


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است