Forráskód Böngészése

1. 实现了临时文件分享平台,支持多文件上传和一键打包下载;2. 实现了文本内容分享功能,支持纯文本、Markdown渲染、代码高亮和JSON格式化四种展示模式;3. 实现了灵活的过期策略,支持1小时至7天或永不过期,可自定义下载次数限制;4. 实现了密码保护机制,使用SHA-256哈希存储访问密码;5. 实现了ID前缀智能匹配功能,基于字典树算法输入前几位即可自动跳转到目标分享;6. 实现了双数据库支持,通过Spring Profile一键切换SQLite(默认零配置)和MySQL;7. 实现了后端驱动的前端配置机制,文件大小和数量限制统一在后端管理,前端启动时自动获取;8. 实现了过期内容自动清理,定时扫描并级联删除关联文件、文本和字典树节点;9. 实现了一体化部署方案,前端构建产物打包进后端JAR,单进程运行于7626端口;10. 实现了银河星空动态背景UI和响应式布局,分享页支持文本一键复制到剪贴板;11. 新增了配置模板文件(.example)机制,敏感信息通过.gitignore排除,符合开源项目规范;12. 新增了Windows和Linux一体化部署脚本、数据库初始化脚本及完整的RESTful API文档。

weisijie 4 hónapja
commit
25b999ef84
84 módosított fájl, 9940 hozzáadás és 0 törlés
  1. 6 0
      .github/ISSUE_TEMPLATE/feature_request.md
  2. 45 0
      .github/PULL_REQUEST_TEMPLATE.md
  3. 157 0
      .gitignore
  4. 388 0
      CLAUDE.md
  5. 275 0
      CONTRIBUTING.md
  6. 21 0
      LICENSE
  7. 200 0
      README.md
  8. 23 0
      backend/README.md
  9. 141 0
      backend/config/application.properties.example
  10. 102 0
      backend/deploy.bat
  11. 149 0
      backend/deploy.sh
  12. 52 0
      backend/init-db.bat.example
  13. 149 0
      backend/pom.xml
  14. 25 0
      backend/src/main/java/com/snapshot/SnapshotApplication.java
  15. 38 0
      backend/src/main/java/com/snapshot/config/CorsConfig.java
  16. 162 0
      backend/src/main/java/com/snapshot/config/DatabaseInitializer.java
  17. 34 0
      backend/src/main/java/com/snapshot/config/MyBatisConfig.java
  18. 157 0
      backend/src/main/java/com/snapshot/config/SQLiteDialect.java
  19. 42 0
      backend/src/main/java/com/snapshot/config/ScheduledConfig.java
  20. 52 0
      backend/src/main/java/com/snapshot/config/WebConfig.java
  21. 35 0
      backend/src/main/java/com/snapshot/controller/ConfigController.java
  22. 206 0
      backend/src/main/java/com/snapshot/controller/ShareController.java
  23. 68 0
      backend/src/main/java/com/snapshot/controller/SpaFallbackController.java
  24. 166 0
      backend/src/main/java/com/snapshot/controller/UploadController.java
  25. 85 0
      backend/src/main/java/com/snapshot/dto/ApiResponse.java
  26. 27 0
      backend/src/main/java/com/snapshot/dto/AppConfigResponse.java
  27. 49 0
      backend/src/main/java/com/snapshot/dto/UploadRequest.java
  28. 109 0
      backend/src/main/java/com/snapshot/dto/UploadResponse.java
  29. 113 0
      backend/src/main/java/com/snapshot/entity/FileEntry.java
  30. 65 0
      backend/src/main/java/com/snapshot/entity/TextContent.java
  31. 60 0
      backend/src/main/java/com/snapshot/entity/TrieNode.java
  32. 90 0
      backend/src/main/java/com/snapshot/entity/UploadRecord.java
  33. 83 0
      backend/src/main/java/com/snapshot/exception/GlobalExceptionHandler.java
  34. 60 0
      backend/src/main/java/com/snapshot/mapper/FileEntryMapper.java
  35. 44 0
      backend/src/main/java/com/snapshot/mapper/TextContentMapper.java
  36. 61 0
      backend/src/main/java/com/snapshot/mapper/TrieNodeMapper.java
  37. 61 0
      backend/src/main/java/com/snapshot/mapper/UploadRecordMapper.java
  38. 29 0
      backend/src/main/java/com/snapshot/repository/FileEntryRepository.java
  39. 24 0
      backend/src/main/java/com/snapshot/repository/TextContentRepository.java
  40. 40 0
      backend/src/main/java/com/snapshot/repository/TrieNodeRepository.java
  41. 54 0
      backend/src/main/java/com/snapshot/repository/UploadRecordRepository.java
  42. 224 0
      backend/src/main/java/com/snapshot/service/FileStorageService.java
  43. 280 0
      backend/src/main/java/com/snapshot/service/UploadRecordService.java
  44. 71 0
      backend/src/main/java/com/snapshot/util/IdGenerator.java
  45. 38 0
      backend/src/main/java/com/snapshot/util/PasswordUtil.java
  46. 22 0
      backend/src/main/resources/application-mysql.properties.example
  47. 22 0
      backend/src/main/resources/application-sqlite.properties
  48. 120 0
      backend/src/main/resources/application.properties.example
  49. 101 0
      backend/src/main/resources/mapper/FileEntryMapper.xml
  50. 66 0
      backend/src/main/resources/mapper/TextContentMapper.xml
  51. 127 0
      backend/src/main/resources/mapper/TrieNodeMapper.xml
  52. 97 0
      backend/src/main/resources/mapper/UploadRecordMapper.xml
  53. 18 0
      backend/src/main/resources/sql/check_tables.sql
  54. 65 0
      backend/src/main/resources/sql/create-sqlite.sql
  55. 93 0
      backend/src/main/resources/sql/create.sql
  56. 117 0
      backend/status.sh
  57. 78 0
      backend/stop.sh
  58. 75 0
      deploy.bat
  59. 71 0
      deploy.sh
  60. 26 0
      frontend/.eslintrc.js
  61. 21 0
      frontend/README.md
  62. 5 0
      frontend/babel.config.js
  63. 48 0
      frontend/config.example.js
  64. 95 0
      frontend/deploy.bat
  65. 165 0
      frontend/deploy.sh
  66. 60 0
      frontend/package.json
  67. 18 0
      frontend/public/index.html
  68. 17 0
      frontend/src/App.vue
  69. 70 0
      frontend/src/components/CodeHighlight.vue
  70. 222 0
      frontend/src/components/MarkdownRenderer.vue
  71. 231 0
      frontend/src/components/Toast.vue
  72. 96 0
      frontend/src/config.js
  73. 16 0
      frontend/src/main.js
  74. 41 0
      frontend/src/router/index.js
  75. 163 0
      frontend/src/stores/upload.js
  76. 266 0
      frontend/src/styles/global.scss
  77. 69 0
      frontend/src/styles/variables.scss
  78. 70 0
      frontend/src/utils/toast.js
  79. 1162 0
      frontend/src/views/Home.vue
  80. 55 0
      frontend/src/views/NotFound.vue
  81. 1224 0
      frontend/src/views/Share.vue
  82. 103 0
      frontend/stop.sh
  83. 26 0
      frontend/vue.config.js
  84. 239 0
      snapshot-prompts.txt

+ 6 - 0
.github/ISSUE_TEMPLATE/feature_request.md

@@ -0,0 +1,6 @@
+---
+name: 功能建议
+about: 为项目提出一个想法或功能建议
+title: '[FEATURE] '
+labels: enhancement
+assignees: ''

+ 45 - 0
.github/PULL_REQUEST_TEMPLATE.md

@@ -0,0 +1,45 @@
+## 变更描述
+简要描述此PR的变更内容
+
+## 变更类型
+请删除不相关的选项:
+
+- [ ] Bug修复 (不破坏现有功能的修复)
+- [ ] 新功能 (不破坏现有功能的新增)
+- [ ] 代码重构 (既不修复bug也不添加功能的代码变更)
+- [ ] 文档更新 (文档的添加或更新)
+- [ ] 性能优化 (提升性能的代码变更)
+- [ ] 代码风格调整 (不影响代码功能的格式调整)
+
+## 相关Issue
+关闭 #(issue编号)
+
+## 测试
+描述你如何测试这些变更:
+- [ ] 单元测试
+- [ ] 集成测试
+- [ ] 手动测试
+
+**测试步骤**:
+1.
+2.
+3.
+
+## 截图(如适用)
+请附上变更前后的对比截图
+
+## Checklist
+提交PR前请确认:
+
+- [ ] 我的代码遵循此项目的代码规范
+- [ ] 我已进行自我审查代码
+- [ ] 我已添加必要的注释(特别是复杂逻辑)
+- [ ] 我已更新相关文档
+- [ ] 我的更改没有产生新的警告
+- [ ] 我已添加能够演示此修复或新功能的测试
+- [ ] 所有现有和新测试均已通过
+- [ ] 我的变更不会破坏现有功能
+- [ ] 我理解并同意我的贡献将按照项目的[MIT许可证](LICENSE)进行许可
+
+## 其他信息
+添加其他有助于审查此PR的信息

+ 157 - 0
.gitignore

@@ -0,0 +1,157 @@
+# ============================================
+# Snapshot 项目 .gitignore
+# https://github.com/github/gitignore
+# ============================================
+
+# ============================================
+# Java
+# ============================================
+*.class
+*.jar
+*.war
+*.ear
+*.nar
+hs_err_pid*
+replay_pid*
+
+# ============================================
+# Maven
+# ============================================
+target/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+pom.xml.next
+release.properties
+dependency-reduced-pom.xml
+buildNumber.properties
+.mvn/timing.properties
+.mvn/wrapper/maven-wrapper.jar
+
+# ============================================
+# Node.js / npm
+# ============================================
+node_modules/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+package-lock.json
+yarn.lock
+
+# ============================================
+# 前端构建产物
+# ============================================
+dist/
+dist-ssr/
+*.local
+
+# 一体化部署生成的前端静态资源(由 deploy 脚本自动复制)
+backend/src/main/resources/static/
+
+# ============================================
+# IDE / 编辑器
+# ============================================
+.idea/
+*.iml
+*.iws
+*.ipr
+.vscode/
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+*.code-workspace
+.playwright-mcp/
+
+# ============================================
+# 操作系统
+# ============================================
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+Desktop.ini
+NVIDIA*
+
+# ============================================
+# 运行时生成文件
+# ============================================
+
+# 上传文件目录
+uploads/
+
+# 日志
+logs/
+*.log
+
+# SQLite 数据库文件
+data/
+*.db
+*.db-wal
+*.db-shm
+
+# ============================================
+# 敏感配置文件(含密码等敏感信息,切勿提交)
+# ============================================
+
+# 后端主配置(含 CORS 域名等部署相关信息)
+backend/src/main/resources/application.properties
+
+# MySQL 配置(含数据库密码)
+backend/src/main/resources/application-mysql.properties
+
+# 数据库初始化脚本(含数据库密码)
+backend/init-db.bat
+
+# 环境变量文件
+.env
+.env.*
+!.env.example
+
+# 其他敏感 Profile
+application-local.properties
+application-dev.properties
+application-prod.properties
+
+# ============================================
+# 开发工具(不应进入仓库)
+# ============================================
+.claude/
+.codeartsdoer/
+.spec-workflow/
+.clinerules
+
+# 测试覆盖率
+coverage/
+.nyc_output/
+
+# 构建输出
+build/
+out/
+
+# ============================================
+# 开发过程中的临时文件
+# ============================================
+
+# AI 工具生成的临时报告
+extracted-code-report.md
+
+# 开发计划/草稿(已在实现中或已完成的)
+*_IMPLEMENTATION_PLAN.md
+
+# 其他过程文件
+*.jsonl
+all_extracted_files.json
+extracted_files.json
+LINUX_DEPLOY.md
+*.bak
+*_report.*
+debug_regex.py
+nul
+api/

+ 388 - 0
CLAUDE.md

@@ -0,0 +1,388 @@
+# CLAUDE.md
+
+本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。
+
+## 项目概述
+
+**Snapshot** 是一个临时文件分享平台,提供文件和文本内容的安全临时分享服务。
+
+- **前端**: Vue 3.3 + Vue Router 4 + Pinia + Axios
+- **后端**: Spring Boot 2.7.18 + Java 17(注意:原始需求是Java 1.8,但实际使用Java 17)
+- **数据库**: MySQL 8.0+ (数据库名: snapshot)
+- **部署平台**: Windows(不使用Docker)
+
+### 核心特性
+- 支持多文件上传(单个≤50MB,最多10个)
+- 文本内容分享(纯文本/Markdown/代码/JSON)
+- 自定义过期时间(1-24小时)和下载次数限制
+- 密码保护功能
+- **ID前缀智能匹配**(字典树算法)
+- 银河星空动态背景UI
+- 自动清理过期内容
+
+---
+
+## 开发命令
+
+### 后端开发
+
+```bash
+# 进入后端目录
+cd backend
+
+# 编译打包
+mvn clean package -DskipTests
+
+# 运行应用
+java -jar target/snapshot-backend-1.0.0.jar
+
+# 或使用部署脚本(Windows)
+deploy.bat
+```
+
+**数据库初始化**:
+```bash
+# 首次运行前,在MySQL中执行
+mysql -uroot -p < backend/src/main/resources/sql/create.sql
+```
+
+### 前端开发
+
+```bash
+# 进入前端目录
+cd frontend
+
+# 安装依赖(首次)
+npm install
+
+# 开发模式运行
+npm run serve
+
+# 生产构建
+npm run build
+
+# 代码检查
+npm run lint
+
+# 或使用部署脚本(Windows)
+deploy.bat
+```
+
+### 测试与验证
+
+```bash
+# 访问 http://localhost:7626 查看页面
+```
+
+---
+
+## 项目架构
+
+### 整体结构
+
+```
+snapshot/
+├── backend/                     # Spring Boot后端
+│   ├── src/main/java/com/snapshot/
+│   │   ├── entity/             # JPA实体类
+│   │   ├── repository/         # 数据访问层
+│   │   ├── service/            # 业务逻辑层
+│   │   ├── controller/         # REST API控制器
+│   │   ├── dto/                # 数据传输对象
+│   │   ├── config/             # 配置类(CORS、定时任务)
+│   │   ├── util/               # 工具类(ID生成、密码哈希)
+│   │   └── exception/          # 全局异常处理
+│   ├── src/main/resources/
+│   │   ├── sql/create.sql      # 数据库初始化脚本
+│   │   └── application.properties
+│   └── pom.xml
+│
+├── frontend/                    # Vue 3前端
+│   ├── src/
+│   │   ├── api/                # API接口封装(Axios)
+│   │   ├── stores/             # Pinia状态管理
+│   │   ├── router/             # 路由配置
+│   │   ├── views/              # 页面组件(Home.vue, Share.vue)
+│   │   ├── components/         # 可复用组件
+│   │   ├── styles/             # 全局样式
+│   │   ├── config.js           # 前端配置(API地址、限制)
+│   │   └── main.js
+│   └── package.json
+│
+└── api/
+    └── API_DOCUMENT.md         # RESTful API接口文档
+```
+
+### 后端分层架构
+
+1. **Controller层**: 处理HTTP请求,调用Service层
+   - `UploadController`: 文件/文本上传、删除
+   - `ShareController`: 内容访问、下载、密码验证
+
+2. **Service层**: 核心业务逻辑
+   - `UploadRecordService`: 上传记录管理、字典树构建、过期清理
+   - `FileStorageService`: 文件存储管理(文件读写、删除)
+
+3. **Repository层**: JPA数据访问
+   - `UploadRecordRepository`: 上传记录CRUD
+   - `FileEntryRepository`: 文件元数据管理
+   - `TextContentRepository`: 文本内容管理
+   - `TrieNodeRepository`: 字典树节点管理(前缀查询)
+
+4. **Entity层**: 数据库实体映射
+   - `UploadRecord`: 上传记录(ID、过期时间、下载次数、密码哈希)
+   - `FileEntry`: 文件元数据(文件名、大小、MIME类型、存储路径)
+   - `TextContent`: 文本内容
+   - `TrieNode`: 字典树节点(nodeKey、parentId、depth、uploadId)
+
+5. **工具类**:
+   - `IdGenerator`: 随机ID生成(可配置长度和字符集,默认8位)
+   - `PasswordUtil`: 密码SHA-256哈希
+
+### 前端架构
+
+1. **页面路由** (Vue Router):
+   - `/`: 首页 - 文件上传和文本分享
+   - `/s/:id`: 分享页面 - 查看和下载内容
+   - `/*`: 404页面
+
+2. **状态管理** (Pinia):
+   - `uploadStore`: 管理上传状态(文件列表、进度、配置)
+
+3. **API通信**:
+   - 统一使用Axios,baseURL配置在 `config.js`
+   - 响应拦截器处理错误
+
+4. **UI特点**:
+   - 银河星空动态背景(CSS动画)
+   - 左右分栏:左侧文件上传,右侧文本输入
+   - 底部ID查询(支持前缀自动跳转)
+
+---
+
+## 核心业务逻辑
+
+### ID前缀智能匹配(字典树算法)
+
+**问题**: 用户输入ID前几位时,如何快速定位到完整内容?
+
+**解决方案**: 使用字典树(Trie)数据结构
+
+**实现原理**:
+1. 每创建一个上传记录(如ID为 "a1b2c3d4"),在后端构建字典树
+2. 将ID的每个字符作为树节点:`a → 1 → b → 2 → c → 3 → d → 4`
+3. 用户输入前缀 "a1b" 时,查询字典树找到以该路径开头的所有节点
+4. 如果只有一个匹配,自动返回完整ID;如果有多个,提示用户提供更多字符
+
+**关键代码**:
+- `UploadRecordService.buildTrie()`: 构建字典树(创建上传记录时自动调用)
+- `UploadRecordService.resolveIdByPrefix()`: 前缀解析
+- `TrieNodeRepository.findByPrefix()`: 数据库查询前缀匹配节点
+
+**表结构** (trie_nodes):
+```
+node_id (主键) | node_key | parent_id | depth | upload_id
+```
+
+### 上传流程
+
+1. **初始化上传**: `POST /api/upload/init`
+   - 生成唯一8位ID
+   - 计算过期时间
+   - 保存密码哈希(如果有)
+   - 构建字典树
+
+2. **上传文件**: `POST /api/upload/{uploadId}/files`
+   - 分片上传支持(chunkIndex、totalChunks)
+   - 验证文件大小(≤50MB)和数量(≤10个)
+   - 保存到 `./uploads` 目录
+
+3. **更新文本**: `PUT /api/upload/{uploadId}/text`
+   - 保存文本内容和类型
+
+4. **完成上传**: `POST /api/upload/{uploadId}/complete`
+   - 返回分享链接 `http://localhost:7626/s/{id}`
+
+### 下载与访问控制
+
+1. **内容访问**: `GET /api/content/{id}`
+   - 检查过期时间
+   - 检查下载次数限制
+   - 如有密码保护,返回403要求验证
+
+2. **密码验证**: `POST /api/content/{id}/verify-password`
+   - 使用SHA-256哈希比对
+
+3. **下载文件**: `GET /api/download/file/{fileId}`
+   - 流式传输文件
+   - 增加下载计数
+
+4. **打包下载**: `GET /api/download/bundle/{uploadId}`
+   - 动态生成ZIP文件(不存储在服务器)
+   - 使用Java ZipOutputStream
+
+### 定时清理任务
+
+**配置**: `app.cleanup.cron=0 0 */2 * * ?`(每2小时执行一次)
+
+**逻辑**: `UploadRecordService.cleanupExpiredRecords()`
+- 删除过期记录(`expirationTime < now`)
+- 级联删除关联文件、文本内容、字典树节点
+- 删除物理文件(`FileStorageService`)
+
+---
+
+## 配置文件
+
+### 后端配置 (`application.properties`)
+
+**关键配置项**:
+```properties
+# 服务端口
+server.port=7626
+
+# 文件上传限制
+spring.servlet.multipart.max-file-size=50MB
+spring.servlet.multipart.max-request-size=500MB
+
+# 数据库连接
+spring.datasource.url=jdbc:mysql://localhost:3306/snapshot
+spring.datasource.username=root
+spring.datasource.password=<YOUR_MYSQL_PASSWORD>
+
+# 文件存储路径
+app.file.upload-dir=./uploads
+
+# ID生成器
+app.id.length=8
+app.id.charset=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
+
+# 过期清理任务(cron表达式)
+app.cleanup.cron=0 0 */2 * * ?
+```
+
+### 前端配置 (`config.js`)
+
+```javascript
+{
+  baseURL: 'http://localhost:7626',
+  uploadLimit: 50 * 1024 * 1024,  // 50MB
+  maxFiles: 10,
+  expirationOptions: [1, 2, 3, 6, 12, 24]
+}
+```
+
+---
+
+## 数据库设计
+
+### 核心表
+
+1. **upload_records**: 上传记录主表
+   - `id` (主键): 8位随机字符串
+   - `expiration_time`: 过期时间
+   - `max_download_count`: 最大下载次数(-1表示无限制)
+   - `current_download_count`: 当前下载次数
+   - `password_hash`: 密码SHA-256哈希(可选)
+
+2. **file_entries**: 文件元数据
+   - `file_id`: 文件唯一ID
+   - `upload_id`: 关联上传记录
+   - `file_name`, `file_size`, `mime_type`
+   - `storage_path`: 服务器文件路径
+   - `upload_status`: 上传状态(uploading/completed/failed)
+   - `upload_progress`: 上传进度(0-100)
+
+3. **text_contents**: 文本内容
+   - `upload_id`: 关联上传记录
+   - `text_content`, `text_type`
+
+4. **trie_nodes**: 字典树节点(用于前缀查询)
+   - `node_key`: 字符
+   - `parent_id`: 父节点ID
+   - `depth`: 深度(从1开始)
+   - `upload_id`: 完整ID(仅叶子节点存储)
+
+**初始化脚本**: `backend/src/main/resources/sql/create.sql`
+
+---
+
+## 安全措施
+
+1. **密码保护**: SHA-256哈希存储(不存储明文)
+2. **文件验证**: 文件大小限制、MIME类型检查
+3. **SQL注入防护**: 使用JPA参数化查询
+4. **XSS防护**: Vue自动转义输出
+5. **CORS配置**: 仅允许指定域名跨域访问
+6. **文件存储**: 上传文件存储在Web根目录外(`./uploads`)
+7. **定期清理**: 自动删除过期内容和文件
+
+---
+
+## 重要提示
+
+### Java版本说明
+- **原始需求**: Java 1.8
+- **实际实现**: Java 17(pom.xml中配置为Java 17)
+- **注意**: 如果需要使用Java 1.8,需修改pom.xml中的java.version为1.8,并调整代码语法(如不使用var关键字、Text Blocks等)
+
+### 文件存储
+- 上传文件默认存储在 `./uploads` 目录(项目根目录下)
+- 部署时确保该目录有读写权限
+
+### 字典树性能
+- 当前实现为数据库持久化字典树(trie_nodes表)
+- 每次前缀查询需要多次数据库查询
+- 如需高性能,可考虑内存缓存(Redis或本地缓存)
+
+### ID生成策略
+- 默认8位长度,字符集包含62个字符(0-9, a-z, A-Z)
+- 理论上可生成 62^8 ≈ 218万亿个不重复ID
+- 通过do-while循环确保唯一性(数据库检查)
+
+---
+
+## API接口文档
+
+详细的RESTful API接口文档请参考: `api/API_DOCUMENT.md`
+
+**主要接口**:
+- 上传管理: `POST /api/upload/init`, `POST /api/upload/{id}/files`, `PUT /api/upload/{id}/text`
+- 内容访问: `GET /api/content/{id}`, `POST /api/content/{id}/verify-password`
+- 文件下载: `GET /api/download/file/{fileId}`, `GET /api/download/bundle/{id}`
+- 查询服务: `GET /api/query/lookup?prefix=xxx`
+
+---
+
+## 常见开发任务
+
+### 添加新的API接口
+1. 在 `backend/src/main/java/com/snapshot/controller/` 创建或修改Controller
+2. 在 `service/` 实现业务逻辑
+3. 在 `repository/` 添加数据访问方法(如需要)
+4. 在 `frontend/src/api/` 添加API调用封装
+
+### 修改数据库表结构
+1. 更新 `backend/src/main/resources/sql/create.sql`
+2. 修改对应的 `entity/` 实体类
+3. 考虑数据迁移方案(已有数据情况)
+
+### 前端添加新页面
+1. 在 `frontend/src/views/` 创建Vue组件
+2. 在 `router/index.js` 添加路由配置
+3. 在 `api/` 添加API调用方法(如需要)
+
+### 调整文件大小限制
+- 后端: 修改 `application.properties` 中的 `spring.servlet.multipart.max-file-size`
+- 前端: 修改 `config.js` 中的 `uploadLimit`
+- 确保前后端配置一致
+
+---
+
+## 项目规范
+
+- **后端**: 遵循Spring Boot最佳实践,使用Lombok减少样板代码
+- **前端**: 遵循Vue 3 Composition API风格
+- **命名**: 实体类使用名词复数(如UploadRecords),方法使用动词开头(如createUploadRecord)
+- **异常处理**: 全局异常处理器 `GlobalExceptionHandler` 统一处理异常并返回标准格式
+- **日志**: 使用SLF4J日志,关键操作记录info级别,异常记录error级别

+ 275 - 0
CONTRIBUTING.md

@@ -0,0 +1,275 @@
+# 贡献指南
+
+感谢您对 Snapshot 项目的关注!我们欢迎任何形式的贡献。
+
+## 🤝 如何贡献
+
+### 报告问题
+
+如果您发现了 bug 或有功能建议:
+
+1. 检查 [Issues](../../issues) 确保问题未被报告
+2. 创建新 Issue,使用合适的模板
+3. 提供详细的信息:
+   - Bug 报告:复现步骤、预期行为、实际行为、环境信息
+   - 功能建议:使用场景、预期效果、可能的实现方案
+
+### 提交代码
+
+#### 开发流程
+
+1. **Fork 仓库**
+   ```bash
+   # 在 GitHub 上点击 Fork 按钮
+   git clone https://github.com/<YOUR_USERNAME>/snapshot.git
+   cd snapshot
+   ```
+
+2. **创建特性分支**
+   ```bash
+   git checkout -b feature/AmazingFeature
+   # 或修复 bug: git checkout -b fix/bug-description
+   ```
+
+3. **进行开发**
+   - 遵循代码规范(见下文)
+   - 添加必要的测试
+   - 更新相关文档
+
+4. **提交更改**
+   ```bash
+   git add .
+   git commit -m "feat: 添加文件批量下载功能"
+   ```
+
+5. **推送到分支**
+   ```bash
+   git push origin feature/AmazingFeature
+   ```
+
+6. **创建 Pull Request**
+   - 在 GitHub 上创建 PR
+   - 填写 PR 模板
+   - 等待代码审查
+
+## 📝 代码规范
+
+### 后端 (Java)
+
+**遵循阿里巴巴Java开发规范**
+
+- **命名规范**
+  - 类名:PascalCase (例如:`UploadRecordService`)
+  - 方法名:camelCase (例如:`getUploadRecordById`)
+  - 常量:UPPER_SNAKE_CASE (例如:`MAX_FILE_SIZE`)
+  - 包名:全小写 (例如:`com.snapshot.service`)
+
+- **注释规范**
+  ```java
+  /**
+   * 根据ID获取上传记录
+   *
+   * @param id 上传记录ID
+   * @return 上传记录对象,不存在返回null
+   * @throws IllegalArgumentException 如果ID为空
+   */
+  public UploadRecord getUploadRecordById(String id) {
+      // 实现代码
+  }
+  ```
+
+- **异常处理**
+  - 使用 `GlobalExceptionHandler` 统一处理异常
+  - 自定义异常继承 `RuntimeException`
+  - 避免捕获 `Exception` 基类
+
+- **MyBatis 规范**
+  - 使用 `@Mapper` 注解
+  - SQL 语句放在 XML 文件中
+  - 使用参数化查询防止 SQL 注入
+
+### 前端 (Vue.js)
+
+**遵循 Vue 官方风格指南**
+
+- **组件命名**
+  ```vue
+  <!-- ✅ 推荐 -->
+  <script>
+  export default {
+    name: 'UploadFileList'  // PascalCase
+  }
+  </script>
+
+  <!-- ❌ 避免 -->
+  <script>
+  export default {
+    name: 'upload-file-list'  // kebab-case
+  }
+  </script>
+  ```
+
+- **Props 定义**
+  ```vue
+  <script>
+  export default {
+    props: {
+      fileList: {
+        type: Array,
+        required: true,
+        default: () => []
+      },
+      maxSize: {
+        type: Number,
+        default: 50 * 1024 * 1024
+      }
+    }
+  }
+  </script>
+  ```
+
+- **变量命名**
+  ```javascript
+  // ✅ 推荐
+  const uploadRecord = ref(null)
+  const MAX_FILE_COUNT = 10
+
+  // ❌ 避免
+  const upload_record = ref(null)
+  const max_file_count = 10
+  ```
+
+- **组件结构**
+  ```vue
+  <template>
+    <!-- 模板内容 -->
+  </template>
+
+  <script>
+  import { ref, computed, onMounted } from 'vue'
+
+  export default {
+    name: 'ComponentName',
+
+    props: { /* ... */ },
+
+    emits: ['update', 'delete'],
+
+    setup(props, { emit }) {
+      // 1. 响应式数据
+      const count = ref(0)
+
+      // 2. 计算属性
+      const doubleCount = computed(() => count.value * 2)
+
+      // 3. 方法
+      const increment = () => {
+        count.value++
+        emit('update', count.value)
+      }
+
+      // 4. 生命周期
+      onMounted(() => {
+        console.log('Component mounted')
+      })
+
+      return {
+        count,
+        doubleCount,
+        increment
+      }
+    }
+  }
+  </script>
+
+  <style scoped>
+  /* 组件样式 */
+  </style>
+  ```
+
+## 📋 提交信息规范
+
+使用 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 格式:
+
+### 格式
+
+```
+<类型>(<范围>): <简短描述>
+
+<详细描述>
+
+<页脚>
+```
+
+### 类型
+
+- `feat`: 新功能
+- `fix`: Bug 修复
+- `docs`: 文档更新
+- `style`: 代码格式调整(不影响功能)
+- `refactor`: 重构(既不是新功能也不是修复)
+- `perf`: 性能优化
+- `test`: 添加或修改测试
+- `chore`: 构建/工具相关
+- `ci`: CI/CD 配置
+
+### 示例
+
+```bash
+# 简单提交
+git commit -m "feat: 添加文件批量下载功能"
+
+# 详细提交
+git commit -m "fix(upload): 修复大文件上传超时问题
+
+- 增加连接超时配置到30分钟
+- 添加上传进度重试机制
+- 优化内存使用
+
+Closes #123"
+```
+
+## ✅ Pull Request 检查清单
+
+提交 PR 前请确认:
+
+- [ ] 代码遵循项目规范
+- [ ] 已进行自我审查(self-review)
+- [ ] 已添加必要的注释(特别是复杂逻辑)
+- [ ] 已更新相关文档
+- [ ] 所有测试通过
+- [ ] 没有引入新的警告
+- [ ] 提交信息符合规范
+- [ ] PR 描述清晰,说明了变更内容和原因
+
+## 🧪 测试
+
+### 后端测试
+
+```bash
+cd backend
+mvn test
+```
+
+### 前端测试
+
+```bash
+cd frontend
+npm run test
+```
+
+### E2E 测试
+
+```bash
+npm run test:e2e
+```
+
+## 📧 联系方式
+
+如有问题:
+
+- GitHub Issues: [../../issues]
+
+## 📄 许可证
+
+提交代码即表示您同意您的贡献将按照项目的 [MIT 许可证](LICENSE) 进行许可。

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Snapshot Team
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 200 - 0
README.md

@@ -0,0 +1,200 @@
+# Snapshot
+
+一个简洁、安全的临时文件分享平台。无需注册,上传即分享。
+
+## 特性
+
+- **文件上传** — 支持多文件上传,单文件最大 500MB,最多 10 个文件
+- **文本分享** — 支持纯文本、Markdown、代码、JSON 四种格式,代码高亮显示
+- **灵活过期** — 1 小时 ~ 7 天,或永不过期;自定义下载次数限制
+- **密码保护** — 可选访问密码,SHA-256 哈希存储
+- **ID 前缀匹配** — 输入 ID 前几位即可自动跳转(字典树算法)
+- **打包下载** — 一键 ZIP 打包下载所有文件
+- **自动清理** — 定时清理过期内容,释放存储空间
+- **双数据库** — SQLite(零配置开箱即用)/ MySQL 一键切换
+- **一体化部署** — 前端打包进后端 JAR,单进程运行
+- **后端驱动配置** — 文件大小、数量限制统一在后端配置,前端自动获取
+
+## 技术栈
+
+| 层级 | 技术 |
+|------|------|
+| 前端 | Vue 3.3 + Vue Router 4 + Pinia + Axios |
+| 后端 | Spring Boot 2.7.18 + MyBatis + JPA + Java 17 |
+| 数据库 | SQLite(默认)/ MySQL 8.0+ |
+| 构建 | Maven + npm |
+
+## 快速开始
+
+### 环境要求
+
+- Java 17+
+- Maven 3.6+
+- Node.js 16+ / npm 8+(仅开发或一体化部署时需要)
+
+### 一体化部署(推荐)
+
+1. 复制配置模板并按需修改:
+```bash
+cd backend/src/main/resources
+cp application.properties.example application.properties
+# 如需 MySQL,还需:
+cp application-mysql.properties.example application-mysql.properties
+# 编辑 application-mysql.properties 填入数据库密码
+```
+
+2. 构建并启动:
+
+**Windows:**
+```bash
+deploy.bat
+```
+
+**Linux / macOS:**
+```bash
+chmod +x deploy.sh && ./deploy.sh
+```
+
+构建完成后启动:
+```bash
+cd backend
+java -jar target/snapshot-backend-1.0.0.jar
+```
+
+访问 http://localhost:7626 即可使用。
+
+> 首次启动默认使用 SQLite,无需安装任何数据库。
+
+## 数据库配置
+
+项目通过 Spring Profile 切换数据库,配置位于 `backend/src/main/resources/application.properties`:
+
+```properties
+# 使用 SQLite(默认,零配置)
+spring.profiles.active=dev,sqlite
+
+# 使用 MySQL
+# spring.profiles.active=dev,mysql
+```
+
+### SQLite(默认)
+
+无需任何额外配置,启动时自动在 `data/` 目录创建数据库文件。适合个人使用或轻量部署。
+
+### MySQL
+
+1. 创建数据库:
+```bash
+mysql -uroot -p < backend/src/main/resources/sql/create.sql
+```
+
+2. 修改 `application-mysql.properties` 中的数据库连接信息:
+```properties
+spring.datasource.url=jdbc:mysql://localhost:3306/snapshot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
+spring.datasource.username=root
+spring.datasource.password=your_password
+```
+
+3. 切换 Profile 为 `dev,mysql`
+
+## 项目结构
+
+```
+snapshot/
+├── backend/                          # Spring Boot 后端
+│   ├── src/main/java/com/snapshot/
+│   │   ├── config/                   # 配置(CORS、SQLite方言、MyBatis、数据库初始化)
+│   │   ├── controller/               # REST 控制器
+│   │   ├── dto/                      # 数据传输对象
+│   │   ├── entity/                   # JPA 实体类
+│   │   ├── exception/                # 全局异常处理
+│   │   ├── mapper/                   # MyBatis Mapper 接口
+│   │   ├── repository/               # JPA Repository
+│   │   ├── service/                  # 业务逻辑
+│   │   └── util/                     # 工具类(ID生成、密码哈希)
+│   ├── src/main/resources/
+│   │   ├── mapper/                   # MyBatis XML 映射
+│   │   ├── sql/                      # 数据库初始化脚本
+│   │   ├── static/                   # 前端构建产物(一体化部署)
+│   │   ├── application.properties            # 主配置
+│   │   ├── application-mysql.properties      # MySQL 配置
+│   │   └── application-sqlite.properties     # SQLite 配置
+│   └── pom.xml
+│
+├── frontend/                         # Vue 3 前端
+│   ├── src/
+│   │   ├── api/                      # API 接口封装
+│   │   ├── components/               # 组件(Markdown渲染、代码高亮、Toast)
+│   │   ├── router/                   # 路由配置
+│   │   ├── stores/                   # Pinia 状态管理
+│   │   ├── styles/                   # 全局样式
+│   │   ├── views/                    # 页面(Home、Share、NotFound)
+│   │   ├── config.js                 # 前端配置(启动时从后端获取)
+│   │   └── main.js
+│   └── package.json
+│
+├── api/
+│   └── API_DOCUMENT.md               # RESTful API 接口文档
+└── deploy.bat / deploy.sh            # 一体化部署脚本
+```
+
+## API 概览
+
+完整接口文档请参考 [API_DOCUMENT.md](api/API_DOCUMENT.md)。
+
+| 分类 | 方法 | 端点 | 说明 |
+|------|------|------|------|
+| 配置 | GET | `/api/config` | 获取前端配置 |
+| 上传 | POST | `/api/upload` | 创建上传记录 |
+| 上传 | POST | `/api/upload/{id}/files` | 上传文件 |
+| 上传 | GET | `/api/upload/{id}` | 获取上传详情 |
+| 上传 | DELETE | `/api/upload/{id}` | 删除上传记录 |
+| 上传 | GET | `/api/upload/resolve/{prefix}` | 前缀解析ID |
+| 分享 | GET | `/api/share/{id}` | 访问分享内容 |
+| 分享 | POST | `/api/share/{id}/verify` | 密码验证 |
+| 分享 | GET | `/api/share/{id}/file/{fileId}` | 下载单个文件 |
+| 分享 | GET | `/api/share/{id}/download-all` | ZIP打包下载 |
+
+## 配置说明
+
+所有核心配置集中在 `application.properties`,前端在启动时通过 `/api/config` 自动获取:
+
+```properties
+# 服务端口
+server.port=7626
+
+# 文件限制(前端自动读取)
+app.file.max-size=524288000          # 单文件最大 500MB
+app.file.max-count=10                # 最多 10 个文件
+
+# 存储路径
+app.file.upload-dir=./uploads
+
+# ID生成
+app.id.length=8
+app.id.charset=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
+
+# 过期清理(每2小时)
+app.cleanup.cron=0 0 */2 * * ?
+app.cleanup.enabled=true
+```
+
+修改配置后重启后端即可生效,无需重新构建前端。
+
+## 安全设计
+
+- **密码哈希** — SHA-256 存储密码,不保存明文
+- **SQL 注入防护** — JPA 参数化查询
+- **XSS 防护** — Vue 自动转义 + DOMPurify 清理 Markdown 输出
+- **CORS 限制** — 可配置允许的跨域来源
+- **文件隔离** — 上传文件存储在 Web 根目录外
+- **自动清理** — 过期内容和物理文件定时删除
+
+## 文档
+
+- [API 接口文档](api/API_DOCUMENT.md) — 完整的 RESTful API 说明
+- [贡献指南](CONTRIBUTING.md) — 开发规范和 PR 流程
+
+## 许可证
+
+[MIT License](LICENSE)

+ 23 - 0
backend/README.md

@@ -0,0 +1,23 @@
+# Snapshot Backend
+
+Snapshot 临时文件分享平台后端服务
+
+## 技术栈
+
+- Java 1.8
+- Spring Boot 2.7.18
+- Spring Data JPA
+- MySQL 8.0+
+- Maven
+
+## 快速开始
+
+### 环境要求
+
+- Java 1.8+
+- Maven 3.6+
+- MySQL 8.0+
+
+### 配置数据库
+
+1. 创建数据库:

+ 141 - 0
backend/config/application.properties.example

@@ -0,0 +1,141 @@
+# ============================================
+# Snapshot 后端服务配置模板
+# ============================================
+# 使用说明:
+# 1. 复制此文件到 src/main/resources/application.properties
+# 2. 修改数据库密码等敏感信息
+# 3. 不要将包含真实密码的配置文件提交到Git
+# ============================================
+
+# 服务器配置
+server.port=7626
+server.servlet.context-path=/
+server.tomcat.uri-encoding=UTF-8
+server.tomcat.max-swallow-size=500MB
+
+# ============================================
+# 连接超时配置(解决上传卡顿问题)
+# ============================================
+
+# Tomcat连接超时(毫秒)- 30分钟,适应慢速网络
+server.tomcat.connection-timeout=1800000
+
+# Tomcat keep-alive超时(毫秒)- 30分钟
+server.tomcat.keep-alive-timeout=1800000
+
+# 最大线程数(增加并发处理能力)
+server.tomcat.max-threads=200
+server.tomcat.min-spare-threads=10
+
+# 最大连接数
+server.tomcat.max-connections=8192
+
+# 接受队列大小
+server.tomcat.accept-count=100
+
+# ============================================
+# 文件上传配置
+# ============================================
+
+spring.servlet.multipart.enabled=true
+spring.servlet.multipart.max-file-size=500MB
+spring.servlet.multipart.max-request-size=500MB
+spring.servlet.multipart.file-size-threshold=10MB
+
+# ============================================
+# 数据源配置
+# ============================================
+
+spring.datasource.url=jdbc:mysql://localhost:3306/snapshot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
+spring.datasource.username=root
+# ⚠️ 请修改为你的MySQL密码
+spring.datasource.password=your_mysql_password_here
+spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
+
+# 激活开发环境配置(启用自动数据库初始化)
+spring.profiles.active=dev
+
+# ============================================
+# HikariCP 连接池配置
+# ============================================
+
+spring.datasource.hikari.connection-timeout=30000
+spring.datasource.hikari.maximum-pool-size=10
+spring.datasource.hikari.minimum-idle=5
+spring.datasource.hikari.idle-timeout=600000
+spring.datasource.hikari.max-lifetime=1800000
+spring.datasource.hikari.pool-name=SnapshotHikariCP
+
+# ============================================
+# MyBatis 配置
+# ============================================
+
+mybatis.mapper-locations=classpath:mapper/*.xml
+mybatis.type-aliases-package=com.snapshot.entity
+mybatis.configuration.map-underscore-to-camel-case=true
+mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
+
+# ============================================
+# JSON序列化配置
+# ============================================
+
+spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
+spring.jackson.time-zone=Asia/Shanghai
+spring.jackson.serialization.write-dates-as-timestamps=false
+spring.jackson.default-property-inclusion=non_null
+
+# ============================================
+# 日志配置
+# ============================================
+
+logging.level.root=INFO
+logging.level.com.snapshot=DEBUG
+logging.level.com.snapshot.mapper=DEBUG
+logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
+logging.file.name=logs/snapshot.log
+logging.file.max-size=10MB
+logging.file.max-history=30
+
+# ============================================
+# 应用自定义配置
+# ============================================
+
+# 文件存储路径
+app.file.upload-dir=./uploads
+app.file.max-size=524288000
+app.file.max-count=10
+
+# ID生成器配置
+app.id.length=8
+app.id.charset=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
+
+# 过期清理任务配置
+app.cleanup.cron=0 0 */2 * * ?
+app.cleanup.enabled=true
+
+# ============================================
+# 一体化部署配置
+# ============================================
+
+# 服务访问地址配置(用于生成分享链接)
+# 格式:协议://域名:端口,如 http://snapshot.lemonai.online:7626
+# 如果不配置,则使用请求头中的Host和协议
+app.server.base-url=http://localhost:7626
+
+# ============================================
+# CORS配置(一体化部署后允许同源访问)
+# ============================================
+
+app.cors.allowed-origins=http://localhost:7626,http://127.0.0.1:7626
+app.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS,PATCH
+app.cors.allowed-headers=*
+app.cors.allow-credentials=true
+app.cors.max-age=3600
+
+# ============================================
+# 静态资源配置
+# ============================================
+
+# 前端构建文件放置在 src/main/resources/static/ 目录
+# Spring Boot会自动提供静态资源服务
+app.static.resource.location=classpath:/static/

+ 102 - 0
backend/deploy.bat

@@ -0,0 +1,102 @@
+@echo off
+chcp 65001 >nul
+echo ============================================
+echo   Snapshot 后端部署脚本
+echo ============================================
+echo.
+
+REM 检查Java环境
+echo [1/6] 检查Java环境...
+java -version >nul 2>&1
+if %errorlevel% neq 0 (
+    echo [ERROR] 未检测到Java环境,请安装Java 17或更高版本
+    pause
+    exit /b 1
+)
+echo [OK] Java环境检查通过
+echo.
+
+REM 检查Maven环境
+echo [2/6] 检查Maven环境...
+call mvn -version >nul 2>&1
+if %errorlevel% neq 0 (
+    echo [ERROR] 未检测到Maven环境,请安装Maven
+    pause
+    exit /b 1
+)
+echo [OK] Maven环境检查通过
+echo.
+
+REM 初始化数据库(仅在使用 MySQL 时需要)
+echo [3/6] 数据库初始化...
+echo 注意:默认使用 SQLite,无需初始化数据库。
+echo 如需使用 MySQL,请先修改 application-mysql.properties 中的连接信息,
+echo 然后运行 init-db.bat 进行数据库初始化。
+echo.
+set /p confirm="是否跳过数据库初始化?(Y/n): "
+if /i not "%confirm%"=="n" (
+    echo 跳过数据库初始化
+) else (
+    echo 请使用 init-db.bat 进行数据库初始化
+    pause
+    exit /b 0
+)
+echo.
+
+REM 清理之前的构建
+echo [4/6] 清理之前的构建...
+call mvn clean
+if %errorlevel% neq 0 (
+    echo [ERROR] 清理失败
+    pause
+    exit /b 1
+)
+echo [OK] 清理完成
+echo.
+
+REM 编译打包
+echo [5/6] 编译打包项目...
+call mvn clean package -DskipTests
+if %errorlevel% neq 0 (
+    echo [ERROR] 编译打包失败
+    pause
+    exit /b 1
+)
+echo [OK] 编译打包完成
+echo.
+
+REM 启动应用
+echo [6/6] 启动应用...
+echo 正在启动Spring Boot应用...
+echo.
+
+REM 检测是否已经有进程在运行
+tasklist /FI "IMAGENAME eq java.exe" 2>NUL | find /I /N "java.exe">NUL
+if "%ERRORLEVEL%"=="0" (
+    echo [WARN] 检测到Java进程正在运行
+    set /p kill="是否停止现有进程?(y/N): "
+    if /i "!kill!"=="y" (
+        taskkill /F /IM java.exe
+        timeout /t 2 /nobreak >nul
+    )
+)
+
+REM 启动应用
+echo 启动命令:java -jar target\snapshot-backend-1.0.0.jar
+echo.
+start /B java -jar "target\snapshot-backend-1.0.0.jar"
+
+REM 等待应用启动
+echo 等待应用启动...
+timeout /t 10 /nobreak >nul
+
+REM 检查应用是否启动成功
+echo.
+echo ============================================
+echo [OK] 部署完成!
+echo ============================================
+echo.
+echo 应用访问地址: http://localhost:7626
+echo.
+echo 按任意键关闭此窗口...
+pause >nul

+ 149 - 0
backend/deploy.sh

@@ -0,0 +1,149 @@
+#!/bin/bash
+
+##############################################
+# Snapshot 后端部署脚本
+# 适用于 Linux 环境
+##############################################
+
+# 颜色输出
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# 打印带颜色的消息
+print_info() {
+    echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+print_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+print_warning() {
+    echo -e "${YELLOW}[WARNING]${NC} $1"
+}
+
+# 检查命令是否存在
+command_exists() {
+    command -v "$1" >/dev/null 2>&1
+}
+
+echo "============================================"
+echo "Snapshot 后端部署脚本"
+echo "============================================"
+echo
+
+# 1. 检查Java环境
+echo "[1/6] 检查Java环境..."
+if ! command_exists java; then
+    print_error "未检测到Java环境"
+    echo "请安装 Java 17 或更高版本"
+    exit 1
+fi
+
+JAVA_VERSION=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}' | cut -d'.' -f1)
+if [ "$JAVA_VERSION" -lt 17 ]; then
+    print_warning "Java版本较低,建议使用 Java 17+"
+fi
+
+print_info "Java环境检查通过"
+java -version | head -n 1
+echo
+
+# 2. 检查Maven环境
+echo "[2/6] 检查Maven环境..."
+if ! command_exists mvn; then
+    print_error "未检测到Maven环境"
+    echo "请安装 Maven"
+    exit 1
+fi
+print_info "Maven环境检查通过"
+mvn -version | head -n 1
+echo
+
+# 3. 初始化数据库
+echo "[3/6] 数据库初始化..."
+print_info "默认使用 SQLite,无需初始化数据库。"
+echo "如需使用 MySQL,请先修改 application-mysql.properties 中的连接信息。"
+echo
+read -p "是否跳过数据库初始化?(Y/n): " confirm
+
+if [ "$confirm" = "n" ] || [ "$confirm" = "N" ]; then
+    print_info "请先配置 application-mysql.properties,然后手动执行:"
+    echo "  mysql -u<USER> -p<PASSWORD> < src/main/resources/sql/create.sql"
+    exit 0
+fi
+print_info "跳过数据库初始化"
+echo
+
+# 4. 清理之前的构建
+echo "[4/6] 清理之前的构建..."
+mvn clean
+if [ $? -neq 0 ]; then
+    print_error "清理失败"
+    exit 1
+fi
+print_info "清理完成"
+echo
+
+# 5. 编译打包
+echo "[5/6] 编译打包项目..."
+mvn clean package -DskipTests
+if [ $? -neq 0 ]; then
+    print_error "编译打包失败"
+    exit 1
+fi
+print_info "编译打包完成"
+echo
+
+# 6. 启动应用
+echo "[6/6] 启动应用..."
+echo "正在启动Spring Boot应用..."
+echo
+
+# 检查是否已经在运行
+if pgrep -f "snapshot-backend-1.0.0.jar" > /dev/null; then
+    print_warning "检测到Java进程正在运行"
+    read -p "是否停止现有进程?(y/n): " kill
+    if [ "$kill" = "y" ] || [ "$kill" = "Y" ]; then
+        pkill -f "snapshot-backend-1.0.0.jar"
+        sleep 2
+    fi
+fi
+
+# 启动应用
+JAR_FILE="target/snapshot-backend-1.0.0.jar"
+if [ ! -f "$JAR_FILE" ]; then
+    print_error "JAR文件不存在: $JAR_FILE"
+    exit 1
+fi
+
+print_info "启动命令: nohup java -jar $JAR_FILE > app.log 2>&1 &"
+nohup java -jar "$JAR_FILE" > app.log 2>&1 &
+PID=$!
+
+# 等待应用启动
+print_info "等待应用启动..."
+sleep 10
+
+# 检查应用是否启动成功
+if ps -p $PID > /dev/null; then
+    echo
+    echo "============================================"
+    print_info "部署完成!"
+    echo "============================================"
+    echo
+    echo "应用PID: $PID"
+    echo "应用访问地址: http://localhost:7626"
+    echo "日志文件: app.log"
+    echo
+    echo "查看日志: tail -f app.log"
+    echo "停止应用: kill $PID"
+    echo
+    echo "应用正在后台运行..."
+else
+    print_error "应用启动失败"
+    echo "请查看日志文件: cat app.log"
+    exit 1
+fi

+ 52 - 0
backend/init-db.bat.example

@@ -0,0 +1,52 @@
+@echo off
+REM ============================================
+REM Snapshot 数据库初始化脚本
+REM 使用前请修改 MySQL 连接信息
+REM ============================================
+
+echo [INFO] 正在初始化 Snapshot 数据库...
+
+REM 设置MySQL连接信息(请根据实际环境修改)
+set MYSQL_HOST=localhost
+set MYSQL_PORT=3306
+set MYSQL_USER=root
+set MYSQL_PASS=YOUR_MYSQL_PASSWORD
+set DB_NAME=snapshot
+
+REM 检查 MySQL 是否可用
+echo [INFO] 检查 MySQL 连接...
+mysql -h%MYSQL_HOST% -P%MYSQL_PORT% -u%MYSQL_USER% -p%MYSQL_PASS% -e "SELECT 1;" >nul 2>&1
+if errorlevel 1 (
+    echo [ERROR] 无法连接到 MySQL,请检查:
+    echo        - MySQL 服务是否启动
+    echo        - 用户名密码是否正确
+    echo        - 配置文件:application-mysql.properties
+    pause
+    exit /b 1
+)
+
+echo [OK] MySQL 连接成功
+
+REM 创建数据库(如果不存在)
+echo [INFO] 正在创建数据库...
+mysql -h%MYSQL_HOST% -P%MYSQL_PORT% -u%MYSQL_USER% -p%MYSQL_PASS% -e "CREATE DATABASE IF NOT EXISTS %DB_NAME% DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
+
+REM 执行初始化脚本
+echo [INFO] 正在执行数据库初始化脚本...
+mysql -h%MYSQL_HOST% -P%MYSQL_PORT% -u%MYSQL_USER% -p%MYSQL_PASS% %DB_NAME% < src\main\resources\sql\create.sql
+
+if errorlevel 1 (
+    echo [ERROR] 数据库初始化失败
+    pause
+    exit /b 1
+)
+
+echo [OK] 数据库初始化完成
+echo.
+echo ============================================
+echo 数据库表创建成功!
+echo 数据库名: %DB_NAME%
+echo ============================================
+echo.
+
+pause

+ 149 - 0
backend/pom.xml

@@ -0,0 +1,149 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
+         http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>2.7.18</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>com.snapshot</groupId>
+    <artifactId>snapshot-backend</artifactId>
+    <version>1.0.0</version>
+    <name>Snapshot Backend</name>
+    <description>临时文件分享平台后端服务</description>
+
+    <properties>
+        <java.version>17</java.version>
+        <maven.compiler.source>17</maven.compiler.source>
+        <maven.compiler.target>17</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+    </properties>
+
+    <dependencies>
+        <!-- Spring Boot Web Starter -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <!-- Spring Boot JPA -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-jpa</artifactId>
+        </dependency>
+
+        <!-- MyBatis Spring Boot Starter -->
+        <dependency>
+            <groupId>org.mybatis.spring.boot</groupId>
+            <artifactId>mybatis-spring-boot-starter</artifactId>
+            <version>2.3.2</version>
+        </dependency>
+
+        <!-- Spring Boot Validation -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+
+        <!-- MySQL Driver -->
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+            <version>8.0.33</version>
+        </dependency>
+
+        <!-- SQLite JDBC Driver -->
+        <dependency>
+            <groupId>org.xerial</groupId>
+            <artifactId>sqlite-jdbc</artifactId>
+            <version>3.45.1.0</version>
+        </dependency>
+
+        <!-- HikariCP Connection Pool -->
+        <dependency>
+            <groupId>com.zaxxer</groupId>
+            <artifactId>HikariCP</artifactId>
+        </dependency>
+
+        <!-- Lombok -->
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- Apache Commons Lang3 -->
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+        </dependency>
+
+        <!-- Apache Commons Codec (for hashing) -->
+        <dependency>
+            <groupId>commons-codec</groupId>
+            <artifactId>commons-codec</artifactId>
+        </dependency>
+
+        <!-- Spring Boot Test -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- JSON Web Token (optional, for future authentication) -->
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-api</artifactId>
+            <version>0.11.5</version>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-impl</artifactId>
+            <version>0.11.5</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-jackson</artifactId>
+            <version>0.11.5</version>
+            <scope>runtime</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <!-- Spring Boot Maven Plugin -->
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <configuration>
+                    <excludes>
+                        <exclude>
+                            <groupId>org.projectlombok</groupId>
+                            <artifactId>lombok</artifactId>
+                        </exclude>
+                    </excludes>
+                </configuration>
+            </plugin>
+
+            <!-- Maven Compiler Plugin -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <configuration>
+                    <source>17</source>
+                    <target>17</target>
+                    <encoding>UTF-8</encoding>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 25 - 0
backend/src/main/java/com/snapshot/SnapshotApplication.java

@@ -0,0 +1,25 @@
+package com.snapshot;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+/**
+ * Snapshot 临时文件分享平台 - 主启动类
+ *
+ * @author Snapshot Team
+ * @version 1.0.0
+ */
+@SpringBootApplication
+@ConfigurationPropertiesScan
+@EnableScheduling
+public class SnapshotApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(SnapshotApplication.class, args);
+        System.out.println("\n=================================");
+        System.out.println("Snapshot 服务启动成功!");
+        System.out.println("=================================\n");
+    }
+}

+ 38 - 0
backend/src/main/java/com/snapshot/config/CorsConfig.java

@@ -0,0 +1,38 @@
+package com.snapshot.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * 跨域配置
+ */
+@Configuration
+public class CorsConfig implements WebMvcConfigurer {
+
+    @Value("${app.cors.allowed-origins}")
+    private String[] allowedOrigins;
+
+    @Value("${app.cors.allowed-methods}")
+    private String[] allowedMethods;
+
+    @Value("${app.cors.allowed-headers}")
+    private String allowedHeaders;
+
+    @Value("${app.cors.allow-credentials}")
+    private boolean allowCredentials;
+
+    @Value("${app.cors.max-age}")
+    private long maxAge;
+
+    @Override
+    public void addCorsMappings(CorsRegistry registry) {
+        registry.addMapping("/**")
+                .allowedOrigins(allowedOrigins)
+                .allowedMethods(allowedMethods)
+                .allowedHeaders(allowedHeaders.split(","))
+                .allowCredentials(allowCredentials)
+                .maxAge(maxAge);
+    }
+}

+ 162 - 0
backend/src/main/java/com/snapshot/config/DatabaseInitializer.java

@@ -0,0 +1,162 @@
+package com.snapshot.config;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.context.annotation.Profile;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.StringJoiner;
+
+/**
+ * 数据库自动初始化器
+ * 在开发环境下自动检查并创建表结构
+ * 根据当前数据库类型选择对应的初始化脚本
+ */
+@Slf4j
+@Component
+@Profile("dev")
+@RequiredArgsConstructor
+public class DatabaseInitializer implements CommandLineRunner {
+
+    private final JdbcTemplate jdbcTemplate;
+
+    @Override
+    public void run(String... args) throws Exception {
+        // SQLite: 确保数据目录存在
+        ensureDataDirectoryExists();
+
+        log.info("检查数据库表结构...");
+
+        boolean tablesExist = checkTablesExist();
+
+        if (!tablesExist) {
+            log.warn("数据库表不存在,开始自动创建...");
+            initializeDatabase();
+        } else {
+            log.info("数据库表已存在,跳过初始化");
+        }
+    }
+
+    /**
+     * 确保 SQLite 数据库文件的父目录存在
+     * SQLite JDBC 驱动不会自动创建父目录
+     */
+    private void ensureDataDirectoryExists() {
+        try {
+            String url = jdbcTemplate.getDataSource().getConnection().getMetaData().getURL();
+            if (url.startsWith("jdbc:sqlite:")) {
+                String dbPath = url.substring("jdbc:sqlite:".length());
+                Path parentDir = Paths.get(dbPath).getParent();
+                if (parentDir != null && !Files.exists(parentDir)) {
+                    Files.createDirectories(parentDir);
+                    log.info("已创建 SQLite 数据目录: {}", parentDir.toAbsolutePath());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("检查数据目录时出错(非致命): {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 检查必要的表是否存在
+     */
+    private boolean checkTablesExist() {
+        try (Connection connection = jdbcTemplate.getDataSource().getConnection()) {
+            DatabaseMetaData metaData = connection.getMetaData();
+            String dbName = metaData.getDatabaseProductName().toLowerCase();
+
+            String[] requiredTables = {
+                "upload_records",
+                "file_entries",
+                "text_contents",
+                "trie_nodes"
+            };
+
+            for (String tableName : requiredTables) {
+                // SQLite 的 getTables 需要大写表名匹配,MySQL 不区分大小写
+                ResultSet tables = metaData.getTables(null, null, tableName, null);
+                if (!tables.next()) {
+                    // SQLite 可能需要尝试不同大小写
+                    tables.close();
+                    tables = metaData.getTables(null, null, tableName.toUpperCase(), null);
+                    if (!tables.next()) {
+                        log.warn("表 {} 不存在", tableName);
+                        tables.close();
+                        return false;
+                    }
+                }
+                tables.close();
+            }
+
+            return true;
+        } catch (Exception e) {
+            log.error("检查表结构失败", e);
+            return false;
+        }
+    }
+
+    /**
+     * 执行数据库初始化脚本
+     * 根据数据库类型选择对应的 SQL 脚本
+     */
+    private void initializeDatabase() {
+        try (Connection connection = jdbcTemplate.getDataSource().getConnection()) {
+            String dbName = connection.getMetaData().getDatabaseProductName().toLowerCase();
+            String scriptPath;
+
+            if (dbName.contains("sqlite")) {
+                scriptPath = "sql/create-sqlite.sql";
+                log.info("检测到 SQLite 数据库,使用初始化脚本: {}", scriptPath);
+            } else {
+                scriptPath = "sql/create.sql";
+                log.info("检测到 MySQL 数据库,使用初始化脚本: {}", scriptPath);
+            }
+
+            ClassPathResource resource = new ClassPathResource(scriptPath);
+            BufferedReader reader = new BufferedReader(
+                new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8)
+            );
+
+            StringJoiner sqlJoiner = new StringJoiner("\n");
+            String line;
+            while ((line = reader.readLine()) != null) {
+                if (!line.trim().startsWith("--")) {
+                    sqlJoiner.add(line);
+                }
+            }
+            reader.close();
+
+            String fullSql = sqlJoiner.toString();
+            String[] statements = fullSql.split(";");
+
+            try (Statement statement = connection.createStatement()) {
+                for (String sql : statements) {
+                    String trimmedSql = sql.trim();
+                    if (!trimmedSql.isEmpty()) {
+                        log.debug("执行SQL: {}", trimmedSql.substring(0, Math.min(80, trimmedSql.length())));
+                        statement.execute(trimmedSql);
+                    }
+                }
+            }
+
+            log.info("数据库初始化完成!");
+
+        } catch (Exception e) {
+            log.error("数据库初始化失败", e);
+            throw new RuntimeException("数据库初始化失败: " + e.getMessage(), e);
+        }
+    }
+}

+ 34 - 0
backend/src/main/java/com/snapshot/config/MyBatisConfig.java

@@ -0,0 +1,34 @@
+package com.snapshot.config;
+
+import org.apache.ibatis.mapping.DatabaseIdProvider;
+import org.apache.ibatis.mapping.VendorDatabaseIdProvider;
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.Properties;
+
+/**
+ * MyBatis 配置类
+ */
+@Configuration
+@MapperScan("com.snapshot.mapper")
+public class MyBatisConfig {
+
+    /**
+     * 数据库标识提供者
+     * 用于 Mapper XML 中的 databaseId 属性,实现多数据库 SQL 兼容
+     *
+     * MySQL  → databaseId = "mysql"
+     * SQLite → databaseId = "sqlite"
+     */
+    @Bean
+    public DatabaseIdProvider databaseIdProvider() {
+        VendorDatabaseIdProvider provider = new VendorDatabaseIdProvider();
+        Properties properties = new Properties();
+        properties.setProperty("MySQL", "mysql");
+        properties.setProperty("SQLite", "sqlite");
+        provider.setProperties(properties);
+        return provider;
+    }
+}

+ 157 - 0
backend/src/main/java/com/snapshot/config/SQLiteDialect.java

@@ -0,0 +1,157 @@
+package com.snapshot.config;
+
+import org.hibernate.dialect.Dialect;
+import org.hibernate.dialect.function.SQLFunctionTemplate;
+import org.hibernate.dialect.function.StandardSQLFunction;
+import org.hibernate.dialect.function.VarArgsSQLFunction;
+import org.hibernate.dialect.identity.IdentityColumnSupport;
+import org.hibernate.dialect.identity.IdentityColumnSupportImpl;
+import org.hibernate.type.StandardBasicTypes;
+
+import java.sql.Types;
+
+/**
+ * SQLite Hibernate 方言
+ * 适配 Hibernate 5.6.x(Spring Boot 2.7.x)
+ */
+public class SQLiteDialect extends Dialect {
+
+    public SQLiteDialect() {
+        registerColumnType(Types.BIT, "integer");
+        registerColumnType(Types.TINYINT, "integer");
+        registerColumnType(Types.SMALLINT, "integer");
+        registerColumnType(Types.INTEGER, "integer");
+        registerColumnType(Types.BIGINT, "integer");
+        registerColumnType(Types.FLOAT, "real");
+        registerColumnType(Types.REAL, "real");
+        registerColumnType(Types.DOUBLE, "real");
+        registerColumnType(Types.NUMERIC, "real");
+        registerColumnType(Types.DECIMAL, "real");
+        registerColumnType(Types.CHAR, "text");
+        registerColumnType(Types.VARCHAR, "text");
+        registerColumnType(Types.LONGVARCHAR, "text");
+        registerColumnType(Types.DATE, "text");
+        registerColumnType(Types.TIME, "text");
+        registerColumnType(Types.TIMESTAMP, "text");
+        registerColumnType(Types.BINARY, "blob");
+        registerColumnType(Types.VARBINARY, "blob");
+        registerColumnType(Types.LONGVARBINARY, "blob");
+        registerColumnType(Types.BLOB, "blob");
+        registerColumnType(Types.CLOB, "text");
+        registerColumnType(Types.BOOLEAN, "integer");
+
+        registerFunction("concat", new VarArgsSQLFunction(StandardBasicTypes.STRING, "", "||", ""));
+        registerFunction("mod", new SQLFunctionTemplate(StandardBasicTypes.INTEGER, "?1 % ?2"));
+        registerFunction("substr", new StandardSQLFunction("substr", StandardBasicTypes.STRING));
+        registerFunction("substring", new StandardSQLFunction("substr", StandardBasicTypes.STRING));
+    }
+
+    @Override
+    public IdentityColumnSupport getIdentityColumnSupport() {
+        return new IdentityColumnSupportImpl() {
+            @Override
+            public boolean supportsIdentityColumns() {
+                return true;
+            }
+
+            @Override
+            public boolean hasDataTypeInIdentityColumn() {
+                return false;
+            }
+
+            @Override
+            public String getIdentitySelectString(String table, String column, int type) {
+                return "select last_insert_rowid()";
+            }
+
+            @Override
+            public String getIdentityColumnString(int type) {
+                return "integer";
+            }
+        };
+    }
+
+    @Override
+    public String getAddColumnString() {
+        return "add column";
+    }
+
+    @Override
+    public boolean hasAlterTable() {
+        return false;
+    }
+
+    @Override
+    public boolean dropConstraints() {
+        return false;
+    }
+
+    @Override
+    public String getDropForeignKeyString() {
+        return "";
+    }
+
+    @Override
+    public String getAddForeignKeyConstraintString(String constraintName,
+                                                    String[] foreignKey,
+                                                    String referencedTable,
+                                                    String[] primaryKey,
+                                                    boolean referencesPrimaryKey) {
+        return "";
+    }
+
+    @Override
+    public String getAddPrimaryKeyConstraintString(String constraintName) {
+        return "";
+    }
+
+    @Override
+    public String getForUpdateString() {
+        return "";
+    }
+
+    @Override
+    public boolean supportsCurrentTimestampSelection() {
+        return true;
+    }
+
+    @Override
+    public boolean isCurrentTimestampSelectStringCallable() {
+        return false;
+    }
+
+    @Override
+    public String getCurrentTimestampSelectString() {
+        return "select datetime('now')";
+    }
+
+    @Override
+    public boolean supportsUnionAll() {
+        return true;
+    }
+
+    @Override
+    public boolean supportsLimit() {
+        return true;
+    }
+
+    @Override
+    public boolean supportsLimitOffset() {
+        return true;
+    }
+
+    @Override
+    public String getLimitString(String query, int offset, int limit) {
+        return query + " limit " + limit + " offset " + offset;
+    }
+
+    @Override
+    public boolean supportsCascadeDelete() {
+        return true;
+    }
+
+    @Override
+    public boolean supportsNotNullUnique() {
+        return false;
+    }
+}

+ 42 - 0
backend/src/main/java/com/snapshot/config/ScheduledConfig.java

@@ -0,0 +1,42 @@
+package com.snapshot.config;
+
+import com.snapshot.service.UploadRecordService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 定时任务配置
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ScheduledConfig {
+
+    private final UploadRecordService uploadRecordService;
+
+    @Value("${app.cleanup.enabled:true}")
+    private boolean cleanupEnabled;
+
+    /**
+     * 清理过期记录
+     * 默认每2小时执行一次
+     * cron表达式:秒 分 时 日 月 周
+     */
+    @Scheduled(cron = "${app.cleanup.cron:0 0 */2 * * ?}")
+    public void cleanupExpiredRecords() {
+        if (!cleanupEnabled) {
+            return;
+        }
+
+        log.info("开始清理过期记录...");
+        try {
+            uploadRecordService.cleanupExpiredRecords();
+            log.info("清理过期记录完成");
+        } catch (Exception e) {
+            log.error("清理过期记录失败", e);
+        }
+    }
+}

+ 52 - 0
backend/src/main/java/com/snapshot/config/WebConfig.java

@@ -0,0 +1,52 @@
+package com.snapshot.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.Resource;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import org.springframework.web.servlet.resource.PathResourceResolver;
+
+import java.io.IOException;
+
+/**
+ * Web配置 - 处理静态资源和SPA路由fallback
+ */
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+    /**
+     * 配置静态资源处理和SPA路由fallback
+     *
+     * 对于一体化部署:
+     * 1. 前端构建文件放在 src/main/resources/static/ 目录
+     * 2. API请求以 /api 开头,由Controller处理
+     * 3. 其他请求(如 /s/xxx)返回index.html,由前端Vue Router处理
+     */
+    @Override
+    public void addResourceHandlers(ResourceHandlerRegistry registry) {
+        // 静态资源处理
+        registry.addResourceHandler("/**")
+                .addResourceLocations("classpath:/static/")
+                .resourceChain(true)
+                .addResolver(new PathResourceResolver() {
+                    @Override
+                    protected Resource getResource(String resourcePath, Resource location) throws IOException {
+                        Resource resource = location.createRelative(resourcePath);
+
+                        // 如果请求的资源存在且可读,直接返回
+                        if (resource.exists() && resource.isReadable()) {
+                            return resource;
+                        }
+
+                        // 如果是API请求,返回null(由Controller处理)
+                        if (resourcePath.startsWith("api/")) {
+                            return null;
+                        }
+
+                        // 其他请求返回index.html(SPA路由fallback)
+                        return new ClassPathResource("/static/index.html");
+                    }
+                });
+    }
+}

+ 35 - 0
backend/src/main/java/com/snapshot/controller/ConfigController.java

@@ -0,0 +1,35 @@
+package com.snapshot.controller;
+
+import com.snapshot.dto.ApiResponse;
+import com.snapshot.dto.AppConfigResponse;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 配置控制器
+ * 将后端配置暴露给前端,实现统一配置管理
+ */
+@RestController
+@RequestMapping("/api/config")
+public class ConfigController {
+
+    @Value("${app.file.max-size:524288000}")
+    private Long maxFileSize;
+
+    @Value("${app.file.max-count:10}")
+    private Integer maxFileCount;
+
+    /**
+     * 获取前端配置
+     */
+    @GetMapping
+    public ApiResponse<AppConfigResponse> getConfig() {
+        AppConfigResponse config = AppConfigResponse.builder()
+                .maxFileSize(maxFileSize)
+                .maxFileCount(maxFileCount)
+                .build();
+        return ApiResponse.success(config);
+    }
+}

+ 206 - 0
backend/src/main/java/com/snapshot/controller/ShareController.java

@@ -0,0 +1,206 @@
+package com.snapshot.controller;
+
+import com.snapshot.dto.UploadResponse;
+import com.snapshot.entity.FileEntry;
+import com.snapshot.service.FileStorageService;
+import com.snapshot.service.UploadRecordService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 分享控制器
+ * 处理分享链接和文件下载
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/share")
+@RequiredArgsConstructor
+public class ShareController {
+
+    private final UploadRecordService uploadRecordService;
+    private final FileStorageService fileStorageService;
+
+    /**
+     * 访问分享链接
+     * GET /api/share/{id}
+     */
+    @GetMapping("/{id}")
+    public ResponseEntity<?> getShareContent(@PathVariable String id) {
+        try {
+            log.info("访问分享链接: {}", id);
+
+            // 获取上传记录
+            UploadResponse response = uploadRecordService.getUploadRecord(id);
+
+            // 如果有密码,返回验证提示
+            if (response.getRequiresPassword()) {
+                Map<String, Object> result = new HashMap<>();
+                result.put("id", response.getId());
+                result.put("requiresPassword", true);
+                result.put("message", "需要输入密码");
+                return ResponseEntity.ok(result);
+            }
+
+            // 增加下载次数
+            uploadRecordService.incrementDownloadCount(id);
+
+            // 返回完整内容
+            return ResponseEntity.ok(response);
+
+        } catch (RuntimeException e) {
+            log.error("访问分享链接失败: {}", e.getMessage());
+            return ResponseEntity.status(404)
+                    .body(Map.of("error", e.getMessage()));
+        }
+    }
+
+    /**
+     * 验证密码并获取内容
+     * POST /api/share/{id}/verify
+     */
+    @PostMapping("/{id}/verify")
+    public ResponseEntity<?> verifyAndGetContent(
+            @PathVariable String id,
+            @RequestBody Map<String, String> request) {
+
+        try {
+            String password = request.get("password");
+
+            // 验证密码
+            boolean valid = uploadRecordService.verifyPassword(id, password);
+
+            if (!valid) {
+                return ResponseEntity.status(401)
+                        .body(Map.of("error", "密码错误"));
+            }
+
+            // 获取上传记录
+            UploadResponse response = uploadRecordService.getUploadRecord(id);
+
+            // 增加下载次数
+            uploadRecordService.incrementDownloadCount(id);
+
+            return ResponseEntity.ok(response);
+
+        } catch (RuntimeException e) {
+            log.error("验证密码失败: {}", e.getMessage());
+            return ResponseEntity.status(404)
+                    .body(Map.of("error", e.getMessage()));
+        }
+    }
+
+    /**
+     * 下载单个文件
+     * GET /api/share/{id}/file/{fileId}
+     */
+    @GetMapping("/{id}/file/{fileId}")
+    public ResponseEntity<Resource> downloadFile(
+            @PathVariable String id,
+            @PathVariable String fileId) {
+
+        try {
+            log.info("下载文件: uploadId={}, fileId={}", id, fileId);
+
+            // 获取文件记录
+            FileEntry fileEntry = fileStorageService.getFile(fileId);
+
+            // 验证文件属于该上传记录
+            if (!fileEntry.getUploadId().equals(id)) {
+                return ResponseEntity.status(403).build();
+            }
+
+            // 获取文件路径
+            Path filePath = fileStorageService.getFilePath(fileEntry);
+
+            // 检查文件是否存在
+            if (!Files.exists(filePath)) {
+                return ResponseEntity.notFound().build();
+            }
+
+            // 创建资源
+            Resource resource = new FileSystemResource(filePath);
+
+            // 设置响应头
+            HttpHeaders headers = new HttpHeaders();
+            headers.add(HttpHeaders.CONTENT_DISPOSITION,
+                    "attachment; filename=\"" + fileEntry.getFileName() + "\"");
+            headers.setContentType(MediaType.parseMediaType(fileEntry.getMimeType()));
+
+            return ResponseEntity.ok()
+                    .headers(headers)
+                    .body(resource);
+
+        } catch (RuntimeException e) {
+            log.error("下载文件失败: {}", e.getMessage());
+            return ResponseEntity.notFound().build();
+        }
+    }
+
+    /**
+     * 打包下载所有文件
+     * GET /api/share/{id}/download-all
+     */
+    @GetMapping("/{id}/download-all")
+    public ResponseEntity<Resource> downloadAllFiles(@PathVariable String id) {
+        try {
+            log.info("打包下载所有文件: {}", id);
+
+            // 创建ZIP文件
+            Resource zipResource = fileStorageService.createZipFile(id);
+
+            // 增加下载次数
+            uploadRecordService.incrementDownloadCount(id);
+
+            // 设置响应头
+            HttpHeaders headers = new HttpHeaders();
+            headers.add(HttpHeaders.CONTENT_DISPOSITION,
+                    "attachment; filename=\"" + id + "_files.zip\"");
+            headers.setContentType(MediaType.parseMediaType("application/zip"));
+
+            return ResponseEntity.ok()
+                    .headers(headers)
+                    .body(zipResource);
+
+        } catch (RuntimeException e) {
+            log.error("打包下载失败: {}", e.getMessage());
+            return ResponseEntity.status(404).build();
+        } catch (Exception e) {
+            log.error("打包下载异常: {}", e.getMessage(), e);
+            return ResponseEntity.status(500).build();
+        }
+    }
+
+    /**
+     * 根据前缀跳转到完整ID
+     * GET /api/share/redirect?prefix=xxx
+     */
+    @GetMapping("/redirect")
+    public ResponseEntity<Map<String, String>> redirectByPrefix(@RequestParam String prefix) {
+        try {
+            String fullId = uploadRecordService.resolveIdByPrefix(prefix);
+
+            Map<String, String> result = new HashMap<>();
+            result.put("prefix", prefix);
+            result.put("fullId", fullId);
+            result.put("redirectUrl", "/s/" + fullId); // 前端路由,保持/s/{id}格式
+
+            return ResponseEntity.ok(result);
+
+        } catch (RuntimeException e) {
+            return ResponseEntity.status(404)
+                    .body(Map.of("error", e.getMessage()));
+        }
+    }
+}

+ 68 - 0
backend/src/main/java/com/snapshot/controller/SpaFallbackController.java

@@ -0,0 +1,68 @@
+package com.snapshot.controller;
+
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * SPA路由fallback控制器
+ * 
+ * 处理前端路由(如 /s/xxx),返回index.html,让Vue Router接管路由处理
+ * 
+ * 优先级说明:
+ * - 具体的Controller映射优先(如 @RequestMapping("/api/share"))
+ * - 只有当没有其他Controller匹配时,才会fallback到这里
+ */
+@Controller
+public class SpaFallbackController {
+
+    /**
+     * 处理分享链接路径
+     * 匹配:/s/xxx
+     */
+    @GetMapping("/s/**")
+    public ResponseEntity<String> shareFallback() {
+        return getIndexHtml();
+    }
+    
+    /**
+     * 处理根路径
+     */
+    @GetMapping("/")
+    public ResponseEntity<String> index() {
+        return getIndexHtml();
+    }
+    
+    /**
+     * 读取并返回index.html的内容
+     * 
+     * 注意:必须使用 getInputStream() 而不是 getURI().getPath()
+     * 因为在JAR包中,资源URI是 jar:file://... 协议,不能用Paths.get()读取
+     */
+    private ResponseEntity<String> getIndexHtml() {
+        try {
+            // 读取index.html(从classpath)
+            ClassPathResource resource = new ClassPathResource("/static/index.html");
+            
+            if (!resource.exists()) {
+                return ResponseEntity.notFound().build();
+            }
+            
+            // 使用 getInputStream() 读取,兼容JAR包和文件系统
+            byte[] contentBytes = resource.getInputStream().readAllBytes();
+            String content = new String(contentBytes, StandardCharsets.UTF_8);
+            
+            return ResponseEntity.ok()
+                    .contentType(MediaType.TEXT_HTML)
+                    .body(content);
+                    
+        } catch (IOException e) {
+            return ResponseEntity.status(500).build();
+        }
+    }
+}

+ 166 - 0
backend/src/main/java/com/snapshot/controller/UploadController.java

@@ -0,0 +1,166 @@
+package com.snapshot.controller;
+
+import com.snapshot.dto.ApiResponse;
+import com.snapshot.dto.UploadRequest;
+import com.snapshot.dto.UploadResponse;
+import com.snapshot.entity.FileEntry;
+import com.snapshot.service.FileStorageService;
+import com.snapshot.service.UploadRecordService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.validation.Valid;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 上传控制器
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/upload")
+@RequiredArgsConstructor
+public class UploadController {
+
+    private final UploadRecordService uploadRecordService;
+    private final FileStorageService fileStorageService;
+
+    @Value("${app.file.max-size:52428800}")
+    private long maxFileSize;
+
+    @Value("${app.file.max-count:10}")
+    private int maxFileCount;
+
+    /**
+     * 创建上传记录
+     */
+    @PostMapping
+    public ResponseEntity<ApiResponse<UploadResponse>> createUpload(
+            @Valid @RequestBody UploadRequest request) {
+
+        log.info("创建上传记录: 过期时间={}小时, 最大下载次数={}",
+                request.getExpirationHours(), request.getMaxDownloadCount());
+
+        UploadResponse response = uploadRecordService.createUploadRecord(request);
+
+        return ResponseEntity.ok(ApiResponse.success(response));
+    }
+
+    /**
+     * 上传文件
+     */
+    @PostMapping(value = "/{id}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public ResponseEntity<ApiResponse<Map<String, Object>>> uploadFiles(
+            @PathVariable String id,
+            @RequestParam("files") List<MultipartFile> files) {
+
+        log.info("上传文件到记录 {}: 文件数量={}", id, files.size());
+
+        // 验证文件数量
+        if (!fileStorageService.isValidFileCount(files, maxFileCount)) {
+            return ResponseEntity.badRequest()
+                    .body(ApiResponse.error(400, "文件数量超过限制,最多" + maxFileCount + "个文件"));
+        }
+
+        // 验证文件大小
+        for (MultipartFile file : files) {
+            if (!fileStorageService.isValidFileSize(file, maxFileSize)) {
+                return ResponseEntity.badRequest()
+                        .body(ApiResponse.error(400,
+                                "文件 " + file.getOriginalFilename() + " 超过大小限制,最大" + (maxFileSize / 1024 / 1024) + "MB"));
+            }
+        }
+
+        try {
+            List<FileEntry> fileEntries = fileStorageService.saveFiles(id, files);
+
+            Map<String, Object> result = new HashMap<>();
+            result.put("uploadId", id);
+            result.put("fileCount", fileEntries.size());
+            result.put("files", fileEntries);
+
+            return ResponseEntity.ok(ApiResponse.success("文件上传成功", result));
+
+        } catch (IOException e) {
+            log.error("文件上传失败: {}", e.getMessage(), e);
+            return ResponseEntity.status(500)
+                    .body(ApiResponse.error("文件上传失败: " + e.getMessage()));
+        }
+    }
+
+    /**
+     * 获取上传记录详情
+     */
+    @GetMapping("/{id}")
+    public ResponseEntity<ApiResponse<UploadResponse>> getUploadRecord(@PathVariable String id) {
+        try {
+            log.info("查询上传记录: {}", id);
+
+            UploadResponse response = uploadRecordService.getUploadRecord(id);
+
+            return ResponseEntity.ok(ApiResponse.success(response));
+
+        } catch (RuntimeException e) {
+            log.error("查询上传记录失败: {}", e.getMessage());
+            return ResponseEntity.status(404)
+                    .body(ApiResponse.error(404, e.getMessage()));
+        }
+    }
+
+    /**
+     * 验证密码
+     */
+    @PostMapping("/{id}/verify-password")
+    public ResponseEntity<ApiResponse<Boolean>> verifyPassword(
+            @PathVariable String id,
+            @RequestBody Map<String, String> request) {
+
+        String password = request.get("password");
+
+        boolean valid = uploadRecordService.verifyPassword(id, password);
+
+        return ResponseEntity.ok(ApiResponse.success(valid));
+    }
+
+    /**
+     * 删除上传记录
+     */
+    @DeleteMapping("/{id}")
+    public ResponseEntity<ApiResponse<Void>> deleteUploadRecord(@PathVariable String id) {
+        log.info("删除上传记录: {}", id);
+
+        uploadRecordService.deleteUploadRecord(id);
+
+        return ResponseEntity.ok(ApiResponse.success("删除成功", null));
+    }
+
+    /**
+     * 根据前缀查找ID
+     */
+    @GetMapping("/resolve/{prefix}")
+    public ResponseEntity<ApiResponse<Map<String, String>>> resolveByPrefix(@PathVariable String prefix) {
+        try {
+            String fullId = uploadRecordService.resolveIdByPrefix(prefix);
+
+            Map<String, String> result = new HashMap<>();
+            result.put("prefix", prefix);
+            result.put("fullId", fullId);
+
+            return ResponseEntity.ok(ApiResponse.success(result));
+
+        } catch (RuntimeException e) {
+            return ResponseEntity.status(404)
+                    .body(ApiResponse.error(404, e.getMessage()));
+        }
+    }
+}

+ 85 - 0
backend/src/main/java/com/snapshot/dto/ApiResponse.java

@@ -0,0 +1,85 @@
+package com.snapshot.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 统一API响应格式
+ *
+ * @param <T> 响应数据类型
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ApiResponse<T> {
+
+    /**
+     * 响应状态码
+     */
+    private Integer code;
+
+    /**
+     * 响应消息
+     */
+    private String message;
+
+    /**
+     * 响应数据
+     */
+    private T data;
+
+    /**
+     * 成功响应(无数据)
+     */
+    public static <T> ApiResponse<T> success() {
+        return ApiResponse.<T>builder()
+                .code(200)
+                .message("操作成功")
+                .build();
+    }
+
+    /**
+     * 成功响应(带数据)
+     */
+    public static <T> ApiResponse<T> success(T data) {
+        return ApiResponse.<T>builder()
+                .code(200)
+                .message("操作成功")
+                .data(data)
+                .build();
+    }
+
+    /**
+     * 成功响应(带消息和数据)
+     */
+    public static <T> ApiResponse<T> success(String message, T data) {
+        return ApiResponse.<T>builder()
+                .code(200)
+                .message(message)
+                .data(data)
+                .build();
+    }
+
+    /**
+     * 失败响应
+     */
+    public static <T> ApiResponse<T> error(String message) {
+        return ApiResponse.<T>builder()
+                .code(500)
+                .message(message)
+                .build();
+    }
+
+    /**
+     * 失败响应(带状态码)
+     */
+    public static <T> ApiResponse<T> error(Integer code, String message) {
+        return ApiResponse.<T>builder()
+                .code(code)
+                .message(message)
+                .build();
+    }
+}

+ 27 - 0
backend/src/main/java/com/snapshot/dto/AppConfigResponse.java

@@ -0,0 +1,27 @@
+package com.snapshot.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 前端配置响应DTO
+ * 将后端 application.properties 中的配置暴露给前端
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AppConfigResponse {
+
+    /**
+     * 单个文件最大大小(字节)
+     */
+    private Long maxFileSize;
+
+    /**
+     * 最大文件数量
+     */
+    private Integer maxFileCount;
+}

+ 49 - 0
backend/src/main/java/com/snapshot/dto/UploadRequest.java

@@ -0,0 +1,49 @@
+package com.snapshot.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.validation.constraints.Max;
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotNull;
+
+/**
+ * 上传请求DTO
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UploadRequest {
+
+    /**
+     * 过期时间(小时,-1表示无限制)
+     */
+    @NotNull(message = "过期时间不能为空")
+    @Min(value = -1, message = "过期时间不能小于-1")
+    private Integer expirationHours;
+
+    /**
+     * 最大下载次数(-1表示无限制,0表示不限次数)
+     */
+    @NotNull(message = "下载次数不能为空")
+    @Min(value = -1, message = "下载次数不能小于-1")
+    private Integer maxDownloadCount;
+
+    /**
+     * 密码(可选)
+     */
+    private String password;
+
+    /**
+     * 文本内容(如果上传的是文本而不是文件)
+     */
+    private String textContent;
+
+    /**
+     * 文本类型(plain/markdown/code/json等)
+     */
+    private String textType;
+}

+ 109 - 0
backend/src/main/java/com/snapshot/dto/UploadResponse.java

@@ -0,0 +1,109 @@
+package com.snapshot.dto;
+
+import com.snapshot.entity.FileEntry;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 上传响应DTO
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UploadResponse {
+
+    /**
+     * 上传内容ID
+     */
+    private String id;
+
+    /**
+     * 分享链接
+     */
+    private String shareUrl;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createdTime;
+
+    /**
+     * 过期时间
+     */
+    private LocalDateTime expirationTime;
+
+    /**
+     * 最大下载次数
+     */
+    private Integer maxDownloadCount;
+
+    /**
+     * 当前下载次数
+     */
+    private Integer currentDownloadCount;
+
+    /**
+     * 是否需要密码
+     */
+    private Boolean requiresPassword;
+
+    /**
+     * 文本内容(如果是文本上传)
+     */
+    private String textContent;
+
+    /**
+     * 文本类型
+     */
+    private String textType;
+
+    /**
+     * 文件列表(如果是文件上传)
+     */
+    private List<FileItem> files;
+
+    /**
+     * 文件项DTO
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class FileItem {
+        /**
+         * 文件ID
+         */
+        private String fileId;
+
+        /**
+         * 文件名
+         */
+        private String fileName;
+
+        /**
+         * 文件大小
+         */
+        private Long fileSize;
+
+        /**
+         * MIME类型
+         */
+        private String mimeType;
+
+        /**
+         * 上传状态
+         */
+        private String uploadStatus;
+
+        /**
+         * 上传进度
+         */
+        private Integer uploadProgress;
+    }
+}

+ 113 - 0
backend/src/main/java/com/snapshot/entity/FileEntry.java

@@ -0,0 +1,113 @@
+package com.snapshot.entity;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.*;
+import java.time.LocalDateTime;
+
+/**
+ * 文件记录实体类
+ * 对应表:file_entries
+ */
+@Entity
+@Table(name = "file_entries")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class FileEntry {
+
+    /**
+     * 文件ID(主键)
+     */
+    @Id
+    @Column(length = 32)
+    private String fileId;
+
+    /**
+     * 关联的上传记录ID
+     */
+    @Column(name = "upload_id", nullable = false, length = 32)
+    private String uploadId;
+
+    /**
+     * 原始文件名
+     */
+    @Column(name = "file_name", nullable = false)
+    private String fileName;
+
+    /**
+     * 服务器存储路径
+     */
+    @Column(name = "storage_path", nullable = false)
+    private String storagePath;
+
+    /**
+     * 文件大小(字节)
+     */
+    @Column(name = "file_size", nullable = false)
+    private Long fileSize;
+
+    /**
+     * MIME类型
+     */
+    @Column(name = "mime_type", nullable = false, length = 100)
+    private String mimeType;
+
+    /**
+     * 上传状态:pending/uploading/completed/failed
+     */
+    @Column(name = "upload_status", nullable = false)
+    @Builder.Default
+    private String uploadStatus = "pending";
+
+    /**
+     * 已上传字节数(用于断点续传)
+     */
+    @Column(name = "upload_offset", nullable = false)
+    @Builder.Default
+    private Long uploadOffset = 0L;
+
+    /**
+     * 上传时间
+     */
+    @Column(name = "created_time", nullable = false, updatable = false)
+    private LocalDateTime createdTime;
+
+    /**
+     * 生命周期回调 - 创建前自动设置创建时间
+     */
+    @PrePersist
+    protected void onCreate() {
+        if (createdTime == null) {
+            createdTime = LocalDateTime.now();
+        }
+    }
+
+    /**
+     * 检查文件是否上传完成
+     */
+    public boolean isUploadCompleted() {
+        return "completed".equals(uploadStatus);
+    }
+
+    /**
+     * 检查文件是否正在上传
+     */
+    public boolean isUploading() {
+        return "uploading".equals(uploadStatus);
+    }
+
+    /**
+     * 获取上传进度百分比
+     */
+    public int getUploadProgress() {
+        if (fileSize == null || fileSize == 0) {
+            return 0;
+        }
+        return (int) ((uploadOffset * 100) / fileSize);
+    }
+}

+ 65 - 0
backend/src/main/java/com/snapshot/entity/TextContent.java

@@ -0,0 +1,65 @@
+package com.snapshot.entity;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.*;
+import java.time.LocalDateTime;
+
+/**
+ * 文本内容实体类
+ * 对应表:text_contents
+ */
+@Entity
+@Table(name = "text_contents")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TextContent {
+
+    /**
+     * 文本内容ID(主键,自增)
+     */
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long contentId;
+
+    /**
+     * 关联的上传记录ID
+     */
+    @Column(name = "upload_id", nullable = false, unique = true, length = 32)
+    private String uploadId;
+
+    /**
+     * 文本内容(使用 @Lob 注解,MySQL 映射为 LONGTEXT,SQLite 映射为 TEXT)
+     */
+    @Lob
+    @Column(name = "text_content", nullable = false)
+    private String textContent;
+
+    /**
+     * 文本类型:plain/markdown/code/json等
+     */
+    @Column(name = "text_type", nullable = false)
+    @Builder.Default
+    private String textType = "plain";
+
+    /**
+     * 创建时间
+     */
+    @Column(name = "created_time", nullable = false, updatable = false)
+    private LocalDateTime createdTime;
+
+    /**
+     * 生命周期回调 - 创建前自动设置创建时间
+     */
+    @PrePersist
+    protected void onCreate() {
+        if (createdTime == null) {
+            createdTime = LocalDateTime.now();
+        }
+    }
+}

+ 60 - 0
backend/src/main/java/com/snapshot/entity/TrieNode.java

@@ -0,0 +1,60 @@
+package com.snapshot.entity;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.*;
+
+/**
+ * 字典树节点实体类
+ * 用于快速ID前缀查询
+ * 对应表:trie_nodes
+ */
+@Entity
+@Table(name = "trie_nodes")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TrieNode {
+
+    /**
+     * 节点ID(主键,自增)
+     */
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long nodeId;
+
+    /**
+     * 节点字符
+     */
+    @Column(name = "node_key", nullable = false)
+    private String nodeKey;
+
+    /**
+     * 父节点ID
+     */
+    @Column(name = "parent_id")
+    private Long parentId;
+
+    /**
+     * 关联的上传记录ID(仅在叶子节点存在)
+     */
+    @Column(name = "upload_id", length = 32)
+    private String uploadId;
+
+    /**
+     * 节点深度
+     */
+    @Column(name = "depth", nullable = false)
+    private Integer depth;
+
+    /**
+     * 检查是否为叶子节点
+     */
+    public boolean isLeaf() {
+        return uploadId != null && !uploadId.isEmpty();
+    }
+}

+ 90 - 0
backend/src/main/java/com/snapshot/entity/UploadRecord.java

@@ -0,0 +1,90 @@
+package com.snapshot.entity;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.*;
+import java.time.LocalDateTime;
+
+/**
+ * 上传记录实体类
+ * 对应表:upload_records
+ */
+@Entity
+@Table(name = "upload_records")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UploadRecord {
+
+    /**
+     * 上传内容ID(主键)
+     */
+    @Id
+    @Column(length = 32)
+    private String id;
+
+    /**
+     * 密码哈希值(NULL表示无密码)
+     */
+    @Column(name = "password_hash", length = 64)
+    private String passwordHash;
+
+    /**
+     * 创建时间
+     */
+    @Column(name = "created_time", nullable = false, updatable = false)
+    private LocalDateTime createdTime;
+
+    /**
+     * 过期时间
+     */
+    @Column(name = "expiration_time", nullable = false)
+    private LocalDateTime expirationTime;
+
+    /**
+     * 最大下载次数(-1表示无限制)
+     */
+    @Column(name = "max_download_count", nullable = false)
+    private Integer maxDownloadCount;
+
+    /**
+     * 当前下载次数
+     */
+    @Column(name = "current_download_count", nullable = false)
+    private Integer currentDownloadCount;
+
+    /**
+     * 生命周期回调 - 创建前自动设置创建时间
+     */
+    @PrePersist
+    protected void onCreate() {
+        if (createdTime == null) {
+            createdTime = LocalDateTime.now();
+        }
+    }
+
+    /**
+     * 检查是否已过期
+     */
+    public boolean isExpired() {
+        return LocalDateTime.now().isAfter(expirationTime);
+    }
+
+    /**
+     * 检查是否达到下载次数限制
+     */
+    public boolean isDownloadLimitReached() {
+        return maxDownloadCount != -1 && currentDownloadCount >= maxDownloadCount;
+    }
+
+    /**
+     * 检查是否需要密码
+     */
+    public boolean requiresPassword() {
+        return passwordHash != null && !passwordHash.isEmpty();
+    }
+}

+ 83 - 0
backend/src/main/java/com/snapshot/exception/GlobalExceptionHandler.java

@@ -0,0 +1,83 @@
+package com.snapshot.exception;
+
+import com.snapshot.dto.ApiResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.multipart.MaxUploadSizeExceededException;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 全局异常处理器
+ */
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    /**
+     * 处理参数验证异常
+     */
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public ResponseEntity<ApiResponse<Map<String, String>>> handleValidationException(
+            MethodArgumentNotValidException ex) {
+
+        Map<String, String> errors = new HashMap<>();
+        ex.getBindingResult().getAllErrors().forEach(error -> {
+            String fieldName = ((FieldError) error).getField();
+            String errorMessage = error.getDefaultMessage();
+            errors.put(fieldName, errorMessage);
+        });
+
+        log.warn("参数验证失败: {}", errors);
+
+        return ResponseEntity
+                .status(HttpStatus.BAD_REQUEST)
+                .body(ApiResponse.error(400, "参数验证失败"));
+    }
+
+    /**
+     * 处理文件大小超限异常
+     */
+    @ExceptionHandler(MaxUploadSizeExceededException.class)
+    public ResponseEntity<ApiResponse<Void>> handleMaxUploadSizeExceededException(
+            MaxUploadSizeExceededException ex) {
+
+        log.warn("文件大小超限: {}", ex.getMessage());
+
+        return ResponseEntity
+                .status(HttpStatus.BAD_REQUEST)
+                .body(ApiResponse.error(400, "文件大小超过限制"));
+    }
+
+    /**
+     * 处理运行时异常
+     */
+    @ExceptionHandler(RuntimeException.class)
+    public ResponseEntity<ApiResponse<Void>> handleRuntimeException(RuntimeException ex) {
+
+        log.error("运行时异常: {}", ex.getMessage(), ex);
+
+        return ResponseEntity
+                .status(HttpStatus.INTERNAL_SERVER_ERROR)
+                .body(ApiResponse.error(500, ex.getMessage()));
+    }
+
+    /**
+     * 处理通用异常
+     */
+    @ExceptionHandler(Exception.class)
+    public ResponseEntity<ApiResponse<Void>> handleException(Exception ex) {
+
+        log.error("系统异常: {}", ex.getMessage(), ex);
+
+        return ResponseEntity
+                .status(HttpStatus.INTERNAL_SERVER_ERROR)
+                .body(ApiResponse.error(500, "系统内部错误"));
+    }
+}

+ 60 - 0
backend/src/main/java/com/snapshot/mapper/FileEntryMapper.java

@@ -0,0 +1,60 @@
+package com.snapshot.mapper;
+
+import com.snapshot.entity.FileEntry;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 文件记录Mapper接口
+ */
+@Mapper
+public interface FileEntryMapper {
+
+    /**
+     * 根据文件ID查找文件记录
+     */
+    FileEntry findById(@Param("fileId") String fileId);
+
+    /**
+     * 保存文件记录
+     */
+    int save(FileEntry fileEntry);
+
+    /**
+     * 更新文件记录
+     */
+    int updateById(FileEntry fileEntry);
+
+    /**
+     * 根据文件ID删除记录
+     */
+    int deleteById(@Param("fileId") String fileId);
+
+    /**
+     * 检查文件是否存在
+     */
+    boolean existsById(@Param("fileId") String fileId);
+
+    /**
+     * 根据上传记录ID查找所有文件
+     */
+    List<FileEntry> findByUploadId(@Param("uploadId") String uploadId);
+
+    /**
+     * 根据上传记录ID和上传状态查找文件
+     */
+    List<FileEntry> findByUploadIdAndUploadStatus(@Param("uploadId") String uploadId,
+                                                   @Param("uploadStatus") String uploadStatus);
+
+    /**
+     * 删除指定上传记录的所有文件
+     */
+    int deleteByUploadId(@Param("uploadId") String uploadId);
+
+    /**
+     * 批量保存文件记录
+     */
+    int batchInsert(@Param("list") List<FileEntry> fileEntries);
+}

+ 44 - 0
backend/src/main/java/com/snapshot/mapper/TextContentMapper.java

@@ -0,0 +1,44 @@
+package com.snapshot.mapper;
+
+import com.snapshot.entity.TextContent;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Optional;
+
+/**
+ * 文本内容Mapper接口
+ */
+@Mapper
+public interface TextContentMapper {
+
+    /**
+     * 根据ID查找文本内容
+     */
+    TextContent findById(@Param("contentId") Long contentId);
+
+    /**
+     * 保存文本内容
+     */
+    int save(TextContent textContent);
+
+    /**
+     * 更新文本内容
+     */
+    int updateById(TextContent textContent);
+
+    /**
+     * 根据ID删除文本内容
+     */
+    int deleteById(@Param("contentId") Long contentId);
+
+    /**
+     * 根据上传记录ID查找文本内容
+     */
+    TextContent findByUploadId(@Param("uploadId") String uploadId);
+
+    /**
+     * 删除指定上传记录的文本内容
+     */
+    int deleteByUploadId(@Param("uploadId") String uploadId);
+}

+ 61 - 0
backend/src/main/java/com/snapshot/mapper/TrieNodeMapper.java

@@ -0,0 +1,61 @@
+package com.snapshot.mapper;
+
+import com.snapshot.entity.TrieNode;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 字典树节点Mapper接口
+ */
+@Mapper
+public interface TrieNodeMapper {
+
+    /**
+     * 根据节点ID查找
+     */
+    TrieNode findById(@Param("nodeId") Long nodeId);
+
+    /**
+     * 保存字典树节点
+     */
+    int save(TrieNode trieNode);
+
+    /**
+     * 更新字典树节点
+     */
+    int updateById(TrieNode trieNode);
+
+    /**
+     * 根据节点ID删除
+     */
+    int deleteById(@Param("nodeId") Long nodeId);
+
+    /**
+     * 根据父节点ID和节点字符查找子节点
+     */
+    TrieNode findByParentIdAndNodeKey(@Param("parentId") Long parentId,
+                                      @Param("nodeKey") String nodeKey);
+
+    /**
+     * 查找指定上传记录的所有节点
+     */
+    List<TrieNode> findByUploadId(@Param("uploadId") String uploadId);
+
+    /**
+     * 根据前缀查找匹配的叶子节点(完整ID)
+     */
+    List<TrieNode> findByPrefix(@Param("prefix") String prefix);
+
+    /**
+     * 删除指定上传记录的所有字典树节点
+     */
+    int deleteByUploadId(@Param("uploadId") String uploadId);
+
+    /**
+     * 批量保存字典树节点
+     */
+    int batchInsert(@Param("list") List<TrieNode> trieNodes);
+}

+ 61 - 0
backend/src/main/java/com/snapshot/mapper/UploadRecordMapper.java

@@ -0,0 +1,61 @@
+package com.snapshot.mapper;
+
+import com.snapshot.entity.UploadRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 上传记录Mapper接口
+ */
+@Mapper
+public interface UploadRecordMapper {
+
+    /**
+     * 根据ID查找上传记录
+     */
+    UploadRecord findById(@Param("id") String id);
+
+    /**
+     * 保存上传记录
+     */
+    int save(UploadRecord uploadRecord);
+
+    /**
+     * 更新上传记录
+     */
+    int updateById(UploadRecord uploadRecord);
+
+    /**
+     * 根据ID删除上传记录
+     */
+    int deleteById(@Param("id") String id);
+
+    /**
+     * 检查记录是否存在
+     */
+    boolean existsById(@Param("id") String id);
+
+    /**
+     * 查找有效的记录(未过期且未达到下载次数限制)
+     */
+    UploadRecord findValidById(@Param("id") String id, @Param("now") LocalDateTime now);
+
+    /**
+     * 查找过期的记录
+     */
+    List<UploadRecord> findByExpirationTimeBefore(@Param("expirationTime") LocalDateTime expirationTime);
+
+    /**
+     * 查找达到下载次数限制的记录
+     */
+    List<UploadRecord> findDownloadLimitReached();
+
+    /**
+     * 增加下载次数
+     */
+    int incrementDownloadCount(@Param("id") String id);
+}

+ 29 - 0
backend/src/main/java/com/snapshot/repository/FileEntryRepository.java

@@ -0,0 +1,29 @@
+package com.snapshot.repository;
+
+import com.snapshot.entity.FileEntry;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+/**
+ * 文件记录Repository接口
+ */
+@Repository
+public interface FileEntryRepository extends JpaRepository<FileEntry, String> {
+
+    /**
+     * 根据上传记录ID查找所有文件
+     */
+    List<FileEntry> findByUploadId(String uploadId);
+
+    /**
+     * 根据上传记录ID和上传状态查找文件
+     */
+    List<FileEntry> findByUploadIdAndUploadStatus(String uploadId, String uploadStatus);
+
+    /**
+     * 删除指定上传记录的所有文件
+     */
+    void deleteByUploadId(String uploadId);
+}

+ 24 - 0
backend/src/main/java/com/snapshot/repository/TextContentRepository.java

@@ -0,0 +1,24 @@
+package com.snapshot.repository;
+
+import com.snapshot.entity.TextContent;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+/**
+ * 文本内容Repository接口
+ */
+@Repository
+public interface TextContentRepository extends JpaRepository<TextContent, Long> {
+
+    /**
+     * 根据上传记录ID查找文本内容
+     */
+    Optional<TextContent> findByUploadId(String uploadId);
+
+    /**
+     * 删除指定上传记录的文本内容
+     */
+    void deleteByUploadId(String uploadId);
+}

+ 40 - 0
backend/src/main/java/com/snapshot/repository/TrieNodeRepository.java

@@ -0,0 +1,40 @@
+package com.snapshot.repository;
+
+import com.snapshot.entity.TrieNode;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 字典树节点Repository接口
+ */
+@Repository
+public interface TrieNodeRepository extends JpaRepository<TrieNode, Long> {
+
+    /**
+     * 根据父节点ID和节点字符查找子节点
+     */
+    Optional<TrieNode> findByParentIdAndNodeKey(Long parentId, String nodeKey);
+
+    /**
+     * 查找指定上传记录的所有节点
+     */
+    List<TrieNode> findByUploadId(String uploadId);
+
+    /**
+     * 根据前缀查找匹配的叶子节点(完整ID)
+     */
+    @Query("SELECT tn FROM TrieNode tn WHERE tn.uploadId IS NOT NULL " +
+           "AND tn.uploadId LIKE CONCAT(:prefix, '%') " +
+           "ORDER BY tn.uploadId")
+    List<TrieNode> findByPrefix(@Param("prefix") String prefix);
+
+    /**
+     * 删除指定上传记录的所有字典树节点
+     */
+    void deleteByUploadId(String uploadId);
+}

+ 54 - 0
backend/src/main/java/com/snapshot/repository/UploadRecordRepository.java

@@ -0,0 +1,54 @@
+package com.snapshot.repository;
+
+import com.snapshot.entity.UploadRecord;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 上传记录Repository接口
+ */
+@Repository
+public interface UploadRecordRepository extends JpaRepository<UploadRecord, String> {
+
+    /**
+     * 查找未过期且未达到下载次数限制的记录
+     */
+    @Query("SELECT ur FROM UploadRecord ur WHERE ur.id = :id " +
+           "AND ur.expirationTime > :now " +
+           "AND (ur.maxDownloadCount = -1 OR ur.currentDownloadCount < ur.maxDownloadCount)")
+    Optional<UploadRecord> findValidById(@Param("id") String id, @Param("now") LocalDateTime now);
+
+    /**
+     * 查找过期的记录
+     */
+    List<UploadRecord> findByExpirationTimeBefore(LocalDateTime expirationTime);
+
+    /**
+     * 查找达到下载次数限制的记录
+     */
+    @Query("SELECT ur FROM UploadRecord ur WHERE ur.maxDownloadCount != -1 " +
+           "AND ur.currentDownloadCount >= ur.maxDownloadCount")
+    List<UploadRecord> findDownloadLimitReached();
+
+    /**
+     * 检查记录是否存在且有效
+     */
+    @Query("SELECT CASE WHEN COUNT(ur) > 0 THEN true ELSE false END FROM UploadRecord ur " +
+           "WHERE ur.id = :id " +
+           "AND ur.expirationTime > :now " +
+           "AND (ur.maxDownloadCount = -1 OR ur.currentDownloadCount < ur.maxDownloadCount)")
+    boolean existsValidById(@Param("id") String id, @Param("now") LocalDateTime now);
+
+    /**
+     * 增加下载次数
+     */
+    @Query("UPDATE UploadRecord ur SET ur.currentDownloadCount = ur.currentDownloadCount + 1 " +
+           "WHERE ur.id = :id")
+    int incrementDownloadCount(@Param("id") String id);
+}

+ 224 - 0
backend/src/main/java/com/snapshot/service/FileStorageService.java

@@ -0,0 +1,224 @@
+package com.snapshot.service;
+
+import com.snapshot.entity.FileEntry;
+import com.snapshot.mapper.FileEntryMapper;
+import com.snapshot.util.IdGenerator;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+
+/**
+ * 文件存储服务类
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class FileStorageService {
+
+    private final FileEntryMapper fileEntryMapper;
+    private final IdGenerator idGenerator;
+
+    @Value("${app.file.upload-dir:./uploads}")
+    private String uploadDir;
+
+    /**
+     * 保存文件
+     */
+    @Transactional
+    public FileEntry saveFile(String uploadId, MultipartFile file) throws IOException {
+        // 验证文件
+        if (file.isEmpty()) {
+            throw new RuntimeException("文件不能为空");
+        }
+
+        // 生成文件ID
+        String fileId;
+        do {
+            fileId = idGenerator.generateId();
+        } while (fileEntryMapper.existsById(fileId));
+
+        // 创建存储目录(按日期组织:uploads/2025/01/30/)
+        String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
+        Path storageDir = Paths.get(uploadDir, datePath);
+        Files.createDirectories(storageDir);
+
+        // 生成存储路径
+        String storageFileName = fileId + "_" + file.getOriginalFilename();
+        Path storagePath = storageDir.resolve(storageFileName);
+
+        // 保存文件
+        Files.copy(file.getInputStream(), storagePath, StandardCopyOption.REPLACE_EXISTING);
+
+        // 创建文件记录
+        FileEntry fileEntry = FileEntry.builder()
+                .fileId(fileId)
+                .uploadId(uploadId)
+                .fileName(file.getOriginalFilename())
+                .storagePath(storagePath.toString())
+                .fileSize(file.getSize())
+                .mimeType(file.getContentType())
+                .uploadStatus("completed")
+                .uploadOffset(file.getSize())
+                .createdTime(LocalDateTime.now())
+                .build();
+
+        fileEntryMapper.save(fileEntry);
+
+        log.info("文件保存成功: {} -> {}", file.getOriginalFilename(), storagePath);
+
+        return fileEntry;
+    }
+
+    /**
+     * 批量保存文件
+     */
+    @Transactional
+    public List<FileEntry> saveFiles(String uploadId, List<MultipartFile> files) throws IOException {
+        List<FileEntry> fileEntries = new ArrayList<>();
+
+        for (MultipartFile file : files) {
+            if (!file.isEmpty()) {
+                FileEntry entry = saveFile(uploadId, file);
+                fileEntries.add(entry);
+            }
+        }
+
+        return fileEntries;
+    }
+
+    /**
+     * 获取文件
+     */
+    public FileEntry getFile(String fileId) {
+        FileEntry fileEntry = fileEntryMapper.findById(fileId);
+        if (fileEntry == null) {
+            throw new RuntimeException("文件不存在");
+        }
+        return fileEntry;
+    }
+
+    /**
+     * 获取文件路径
+     */
+    public Path getFilePath(FileEntry fileEntry) {
+        return Paths.get(fileEntry.getStoragePath());
+    }
+
+    /**
+     * 删除文件
+     */
+    @Transactional
+    public void deleteFile(String fileId) {
+        FileEntry fileEntry = getFile(fileId);
+
+        try {
+            Path filePath = Paths.get(fileEntry.getStoragePath());
+            Files.deleteIfExists(filePath);
+            log.info("文件删除成功: {}", filePath);
+        } catch (IOException e) {
+            log.error("删除文件失败: {}", fileEntry.getStoragePath(), e);
+        }
+
+        fileEntryMapper.deleteById(fileId);
+    }
+
+    /**
+     * 删除上传记录的所有文件
+     */
+    @Transactional
+    public void deleteFilesByUploadId(String uploadId) {
+        List<FileEntry> files = fileEntryMapper.findByUploadId(uploadId);
+
+        for (FileEntry file : files) {
+            try {
+                Path filePath = Paths.get(file.getStoragePath());
+                Files.deleteIfExists(filePath);
+            } catch (IOException e) {
+                log.error("删除文件失败: {}", file.getStoragePath(), e);
+            }
+        }
+
+        fileEntryMapper.deleteByUploadId(uploadId);
+
+        log.info("删除上传记录 {} 的所有文件,共 {} 个", uploadId, files.size());
+    }
+
+    /**
+     * 验证文件大小
+     */
+    public boolean isValidFileSize(MultipartFile file, long maxSize) {
+        return file.getSize() <= maxSize;
+    }
+
+    /**
+     * 验证文件数量
+     */
+    public boolean isValidFileCount(List<MultipartFile> files, int maxCount) {
+        return files.size() <= maxCount;
+    }
+
+    /**
+     * 创建ZIP文件(打包下载)
+     */
+    public Resource createZipFile(String uploadId) {
+        try {
+            // 获取上传记录的所有文件
+            List<FileEntry> files = fileEntryMapper.findByUploadId(uploadId);
+            if (files.isEmpty()) {
+                throw new RuntimeException("没有可下载的文件");
+            }
+
+            // 创建临时ZIP文件
+            Path tempZip = Files.createTempFile("snapshot-", ".zip");
+
+            try (OutputStream os = Files.newOutputStream(tempZip);
+                 ZipOutputStream zos = new ZipOutputStream(os)) {
+
+                for (FileEntry file : files) {
+                    Path filePath = Paths.get(file.getStoragePath());
+                    if (Files.exists(filePath)) {
+                        // 添加ZIP条目
+                        ZipEntry entry = new ZipEntry(file.getFileName());
+                        zos.putNextEntry(entry);
+
+                        // 写入文件内容
+                        Files.copy(filePath, zos);
+                        zos.closeEntry();
+
+                        log.debug("添加文件到ZIP: {}", file.getFileName());
+                    } else {
+                        log.warn("文件不存在,跳过: {}", file.getStoragePath());
+                    }
+                }
+            }
+
+            log.info("ZIP文件创建成功: {} (包含 {} 个文件)", tempZip, files.size());
+
+            // 返回Resource
+            return new FileSystemResource(tempZip.toFile());
+
+        } catch (IOException e) {
+            log.error("创建ZIP文件失败: {}", e.getMessage(), e);
+            throw new RuntimeException("创建ZIP文件失败: " + e.getMessage());
+        }
+    }
+}

+ 280 - 0
backend/src/main/java/com/snapshot/service/UploadRecordService.java

@@ -0,0 +1,280 @@
+package com.snapshot.service;
+
+import com.snapshot.dto.UploadRequest;
+import com.snapshot.dto.UploadResponse;
+import com.snapshot.entity.FileEntry;
+import com.snapshot.entity.TextContent;
+import com.snapshot.entity.TrieNode;
+import com.snapshot.entity.UploadRecord;
+import com.snapshot.mapper.*;
+import com.snapshot.util.IdGenerator;
+import com.snapshot.util.PasswordUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 上传记录服务类
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UploadRecordService {
+
+    private final UploadRecordMapper uploadRecordMapper;
+    private final FileEntryMapper fileEntryMapper;
+    private final TextContentMapper textContentMapper;
+    private final TrieNodeMapper trieNodeMapper;
+    private final IdGenerator idGenerator;
+
+    @Value("${server.port:7626}")
+    private String serverPort;
+
+    @Value("${server.address:localhost}")
+    private String serverAddress;
+
+    private String frontendUrl = "http://localhost:7626";
+
+    /**
+     * 创建上传记录
+     */
+    @Transactional
+    public UploadResponse createUploadRecord(UploadRequest request) {
+        // 生成唯一ID
+        String id;
+        do {
+            id = idGenerator.generateId();
+        } while (uploadRecordMapper.existsById(id));
+
+        // 计算过期时间(-1 表示无限制,设置为 100 年后)
+        LocalDateTime now = LocalDateTime.now();
+        int hours = request.getExpirationHours();
+        LocalDateTime expirationTime = hours == -1
+                ? now.plusYears(100)
+                : now.plusHours(hours);
+
+        // 创建上传记录
+        UploadRecord record = UploadRecord.builder()
+                .id(id)
+                .createdTime(now)
+                .expirationTime(expirationTime)
+                .maxDownloadCount(request.getMaxDownloadCount() == 0 ? -1 : request.getMaxDownloadCount())
+                .currentDownloadCount(0)
+                .passwordHash(request.getPassword() != null && !request.getPassword().isEmpty()
+                        ? PasswordUtil.hashPassword(request.getPassword())
+                        : null)
+                .build();
+
+        uploadRecordMapper.save(record);
+
+        // 如果有文本内容,保存文本
+        if (request.getTextContent() != null && !request.getTextContent().isEmpty()) {
+            TextContent textContent = TextContent.builder()
+                    .uploadId(id)
+                    .textContent(request.getTextContent())
+                    .textType(request.getTextType() != null ? request.getTextType() : "plain")
+                    .createdTime(LocalDateTime.now())
+                    .build();
+            textContentMapper.save(textContent);
+        }
+
+        // 构建字典树
+        buildTrie(id);
+
+        // 构建响应(使用前端URL生成分享链接)
+        String shareUrl = String.format("%s/s/%s", frontendUrl, id);
+
+        return UploadResponse.builder()
+                .id(id)
+                .shareUrl(shareUrl)
+                .createdTime(record.getCreatedTime())
+                .expirationTime(record.getExpirationTime())
+                .maxDownloadCount(record.getMaxDownloadCount())
+                .currentDownloadCount(0)
+                .requiresPassword(record.requiresPassword())
+                .textContent(request.getTextContent())
+                .textType(request.getTextType())
+                .build();
+    }
+
+    /**
+     * 获取上传记录详情
+     */
+    public UploadResponse getUploadRecord(String id) {
+        UploadRecord record = uploadRecordMapper.findById(id);
+        if (record == null) {
+            throw new RuntimeException("上传记录不存在");
+        }
+
+        // 检查是否过期
+        if (record.isExpired()) {
+            throw new RuntimeException("上传记录已过期");
+        }
+
+        // 检查下载次数
+        if (record.isDownloadLimitReached()) {
+            throw new RuntimeException("已达到最大下载次数");
+        }
+
+        // 获取文件列表
+        List<FileEntry> files = fileEntryMapper.findByUploadId(id);
+        List<UploadResponse.FileItem> fileItems = files.stream()
+                .map(file -> UploadResponse.FileItem.builder()
+                        .fileId(file.getFileId())
+                        .fileName(file.getFileName())
+                        .fileSize(file.getFileSize())
+                        .mimeType(file.getMimeType())
+                        .uploadStatus(file.getUploadStatus())
+                        .uploadProgress(file.getUploadProgress())
+                        .build())
+                .collect(Collectors.toList());
+
+        // 获取文本内容(始终获取,支持同时显示文件和文本)
+        String textContent = null;
+        String textType = null;
+        TextContent tc = textContentMapper.findByUploadId(id);
+        if (tc != null) {
+            textContent = tc.getTextContent();
+            textType = tc.getTextType();
+        }
+
+        String shareUrl = String.format("%s/s/%s", frontendUrl, id);
+
+        return UploadResponse.builder()
+                .id(id)
+                .shareUrl(shareUrl)
+                .createdTime(record.getCreatedTime())
+                .expirationTime(record.getExpirationTime())
+                .maxDownloadCount(record.getMaxDownloadCount())
+                .currentDownloadCount(record.getCurrentDownloadCount())
+                .requiresPassword(record.requiresPassword())
+                .textContent(textContent)
+                .textType(textType)
+                .files(fileItems)
+                .build();
+    }
+
+    /**
+     * 验证密码
+     */
+    public boolean verifyPassword(String id, String password) {
+        UploadRecord record = uploadRecordMapper.findById(id);
+        if (record == null) {
+            throw new RuntimeException("上传记录不存在");
+        }
+
+        if (!record.requiresPassword()) {
+            return true; // 无密码保护
+        }
+
+        return PasswordUtil.verifyPassword(password, record.getPasswordHash());
+    }
+
+
+    /**
+     * 增加下载次数
+     */
+    @Transactional
+    public void incrementDownloadCount(String id) {
+        uploadRecordMapper.incrementDownloadCount(id);
+    }
+
+    /**
+     * 删除上传记录
+     */
+    @Transactional
+    public void deleteUploadRecord(String id) {
+        // 删除文件记录
+        fileEntryMapper.deleteByUploadId(id);
+
+        // 删除文本内容
+        textContentMapper.deleteByUploadId(id);
+
+        // 删除字典树节点
+        trieNodeMapper.deleteByUploadId(id);
+
+        // 删除上传记录
+        uploadRecordMapper.deleteById(id);
+
+        log.info("已删除上传记录: {}", id);
+    }
+
+    /**
+     * 清理过期记录
+     */
+    @Transactional
+    public void cleanupExpiredRecords() {
+        List<UploadRecord> expiredRecords = uploadRecordMapper.findByExpirationTimeBefore(LocalDateTime.now());
+
+        for (UploadRecord record : expiredRecords) {
+            deleteUploadRecord(record.getId());
+        }
+
+        log.info("清理过期记录完成,共删除 {} 条记录", expiredRecords.size());
+    }
+
+    /**
+     * 根据前缀查找ID
+     */
+    public String resolveIdByPrefix(String prefix) {
+        List<TrieNode> nodes = trieNodeMapper.findByPrefix(prefix);
+
+        if (nodes.isEmpty()) {
+            throw new RuntimeException("未找到匹配的上传记录");
+        }
+
+        if (nodes.size() > 1) {
+            throw new RuntimeException("前缀匹配多个记录,请提供更完整的ID");
+        }
+
+        return nodes.get(0).getUploadId();
+    }
+
+    /**
+     * 构建字典树
+     */
+    private void buildTrie(String id) {
+        Long parentId = null;
+
+        for (int i = 0; i < id.length(); i++) {
+            String key = String.valueOf(id.charAt(i));
+            int depth = i + 1;
+
+            // 查找或创建节点
+            TrieNode node;
+            if (parentId == null) {
+                // 第一层节点
+                node = trieNodeMapper.findByParentIdAndNodeKey(null, key);
+            } else {
+                node = trieNodeMapper.findByParentIdAndNodeKey(parentId, key);
+            }
+
+            if (node == null) {
+                // 创建新节点
+                TrieNode newNode = TrieNode.builder()
+                        .nodeKey(key)
+                        .parentId(parentId)
+                        .depth(depth)
+                        .uploadId(i == id.length() - 1 ? id : null) // 只有最后一个字符关联完整ID
+                        .build();
+                trieNodeMapper.save(newNode);
+                // 重新查询获取自动生成的ID
+                if (parentId == null) {
+                    node = trieNodeMapper.findByParentIdAndNodeKey(null, key);
+                } else {
+                    node = trieNodeMapper.findByParentIdAndNodeKey(parentId, key);
+                }
+            }
+
+            parentId = node.getNodeId();
+        }
+
+        log.debug("字典树构建完成: {}", id);
+    }
+}

+ 71 - 0
backend/src/main/java/com/snapshot/util/IdGenerator.java

@@ -0,0 +1,71 @@
+package com.snapshot.util;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.security.SecureRandom;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * ID生成器
+ * 生成指定长度的随机字符串ID
+ */
+@Component
+public class IdGenerator {
+
+    @Value("${app.id.length:8}")
+    private int idLength;
+
+    @Value("${app.id.charset:0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ}")
+    private String charset;
+
+    private final SecureRandom random = new SecureRandom();
+
+    /**
+     * 生成新的唯一ID
+     * 注意:调用方需要确保唯一性(通过数据库检查)
+     */
+    public String generateId() {
+        StringBuilder sb = new StringBuilder(idLength);
+        for (int i = 0; i < idLength; i++) {
+            int index = random.nextInt(charset.length());
+            sb.append(charset.charAt(index));
+        }
+        return sb.toString();
+    }
+
+    /**
+     * 生成指定长度的ID
+     */
+    public String generateId(int length) {
+        StringBuilder sb = new StringBuilder(length);
+        for (int i = 0; i < length; i++) {
+            int index = random.nextInt(charset.length());
+            sb.append(charset.charAt(index));
+        }
+        return sb.toString();
+    }
+
+    /**
+     * 验证ID格式是否有效
+     */
+    public boolean isValidId(String id) {
+        if (id == null || id.isEmpty()) {
+            return false;
+        }
+        for (char c : id.toCharArray()) {
+            if (charset.indexOf(c) == -1) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 获取ID长度
+     */
+    public int getIdLength() {
+        return idLength;
+    }
+}

+ 38 - 0
backend/src/main/java/com/snapshot/util/PasswordUtil.java

@@ -0,0 +1,38 @@
+package com.snapshot.util;
+
+import org.apache.commons.codec.digest.DigestUtils;
+
+/**
+ * 密码加密工具类
+ * 使用SHA-256哈希算法
+ */
+public class PasswordUtil {
+
+    /**
+     * 生成密码哈希值
+     *
+     * @param password 原始密码
+     * @return 哈希后的密码
+     */
+    public static String hashPassword(String password) {
+        if (password == null || password.isEmpty()) {
+            return null;
+        }
+        return DigestUtils.sha256Hex(password);
+    }
+
+    /**
+     * 验证密码
+     *
+     * @param password       原始密码
+     * @param passwordHash   密码哈希值
+     * @return 是否匹配
+     */
+    public static boolean verifyPassword(String password, String passwordHash) {
+        if (password == null || passwordHash == null) {
+            return false;
+        }
+        String hash = hashPassword(password);
+        return hash.equals(passwordHash);
+    }
+}

+ 22 - 0
backend/src/main/resources/application-mysql.properties.example

@@ -0,0 +1,22 @@
+# ============================================
+# MySQL 数据源配置
+# 激活方式: spring.profiles.active=dev,mysql
+# ============================================
+
+# MySQL 数据源
+# 请根据实际环境修改以下连接信息
+spring.datasource.url=jdbc:mysql://localhost:3306/snapshot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
+spring.datasource.username=root
+spring.datasource.password=YOUR_MYSQL_PASSWORD
+spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
+
+# JPA - MySQL 方言
+spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
+
+# HikariCP 连接池配置
+spring.datasource.hikari.connection-timeout=30000
+spring.datasource.hikari.maximum-pool-size=10
+spring.datasource.hikari.minimum-idle=5
+spring.datasource.hikari.idle-timeout=600000
+spring.datasource.hikari.max-lifetime=1800000
+spring.datasource.hikari.pool-name=SnapshotHikariCP-MySQL

+ 22 - 0
backend/src/main/resources/application-sqlite.properties

@@ -0,0 +1,22 @@
+# ============================================
+# SQLite 数据源配置
+# 激活方式: spring.profiles.active=dev,sqlite
+# ============================================
+
+# SQLite 数据源(文件数据库,无需安装数据库服务)
+# 数据库文件位于 ./data/snapshot.db,首次运行自动创建
+spring.datasource.url=jdbc:sqlite:data/snapshot.db
+spring.datasource.username=
+spring.datasource.password=
+spring.datasource.driver-class-name=org.sqlite.JDBC
+
+# JPA - SQLite 自定义方言
+spring.jpa.database-platform=com.snapshot.config.SQLiteDialect
+
+# HikariCP 连接池配置(SQLite 单文件写入,连接池不宜过大)
+spring.datasource.hikari.connection-timeout=30000
+spring.datasource.hikari.maximum-pool-size=3
+spring.datasource.hikari.minimum-idle=1
+spring.datasource.hikari.idle-timeout=600000
+spring.datasource.hikari.max-lifetime=1800000
+spring.datasource.hikari.pool-name=SnapshotHikariCP-SQLite

+ 120 - 0
backend/src/main/resources/application.properties.example

@@ -0,0 +1,120 @@
+# ============================================
+# Snapshot 后端服务配置
+# ============================================
+
+# 服务器配置
+server.port=7626
+server.servlet.context-path=/
+server.tomcat.uri-encoding=UTF-8
+server.tomcat.max-swallow-size=500MB
+
+# ============================================
+# 连接超时配置(解决上传卡顿问题)
+# ============================================
+
+# Tomcat连接超时(毫秒)- 30分钟,适应慢速网络
+server.tomcat.connection-timeout=1800000
+
+# Tomcat keep-alive超时(毫秒)- 30分钟
+server.tomcat.keep-alive-timeout=1800000
+
+# 最大线程数(增加并发处理能力)
+server.tomcat.max-threads=200
+server.tomcat.min-spare-threads=10
+
+# 最大连接数
+server.tomcat.max-connections=8192
+
+# 接受队列大小
+server.tomcat.accept-count=100
+
+# ============================================
+# 文件上传配置
+# ============================================
+
+spring.servlet.multipart.enabled=true
+spring.servlet.multipart.max-file-size=500MB
+spring.servlet.multipart.max-request-size=500MB
+spring.servlet.multipart.file-size-threshold=10MB
+
+# ============================================
+# 数据源配置(通过 Profile 切换数据库类型)
+# ============================================
+# 使用 MySQL: spring.profiles.active=dev,mysql
+# 使用 SQLite: spring.profiles.active=dev,sqlite
+# 默认使用 SQLite(零配置,无需安装数据库服务)
+spring.profiles.active=dev,sqlite
+
+# ============================================
+# MyBatis 配置
+# ============================================
+
+mybatis.mapper-locations=classpath:mapper/*.xml
+mybatis.type-aliases-package=com.snapshot.entity
+mybatis.configuration.map-underscore-to-camel-case=true
+mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
+
+# ============================================
+# JSON序列化配置
+# ============================================
+
+spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
+spring.jackson.time-zone=Asia/Shanghai
+spring.jackson.serialization.write-dates-as-timestamps=false
+spring.jackson.default-property-inclusion=non_null
+
+# ============================================
+# 日志配置
+# ============================================
+
+logging.level.root=INFO
+logging.level.com.snapshot=DEBUG
+logging.level.com.snapshot.mapper=DEBUG
+logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
+logging.file.name=logs/snapshot.log
+logging.file.max-size=10MB
+logging.file.max-history=30
+
+# ============================================
+# 应用自定义配置
+# ============================================
+
+# 文件存储路径
+app.file.upload-dir=./uploads
+app.file.max-size=524288000
+app.file.max-count=10
+
+# ID生成器配置
+app.id.length=8
+app.id.charset=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
+
+# 过期清理任务配置
+app.cleanup.cron=0 0 */2 * * ?
+app.cleanup.enabled=true
+
+# ============================================
+# 一体化部署配置
+# ============================================
+
+# 服务访问地址(用于生成分享链接)
+app.server.base-url=http://localhost:7626
+
+# 前端访问地址
+app.frontend.url=http://localhost:7626
+
+# ============================================
+# CORS配置
+# ============================================
+
+# 请根据实际部署域名修改
+app.cors.allowed-origins=http://localhost:7626,http://127.0.0.1:7626
+app.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS,PATCH
+app.cors.allowed-headers=*
+app.cors.allow-credentials=true
+app.cors.max-age=3600
+
+# ============================================
+# 静态资源配置
+# ============================================
+
+app.static.resource.location=classpath:/static/

+ 101 - 0
backend/src/main/resources/mapper/FileEntryMapper.xml

@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.snapshot.mapper.FileEntryMapper">
+
+    <!-- 结果映射 -->
+    <resultMap id="BaseResultMap" type="com.snapshot.entity.FileEntry">
+        <id column="file_id" property="fileId"/>
+        <result column="upload_id" property="uploadId"/>
+        <result column="file_name" property="fileName"/>
+        <result column="storage_path" property="storagePath"/>
+        <result column="file_size" property="fileSize"/>
+        <result column="mime_type" property="mimeType"/>
+        <result column="upload_status" property="uploadStatus"/>
+        <result column="upload_offset" property="uploadOffset"/>
+        <result column="created_time" property="createdTime"/>
+    </resultMap>
+
+    <!-- 基础列 -->
+    <sql id="Base_Column_List">
+        file_id, upload_id, file_name, storage_path, file_size,
+        mime_type, upload_status, upload_offset, created_time
+    </sql>
+
+    <!-- 根据文件ID查找 -->
+    <select id="findById" resultMap="BaseResultMap">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM file_entries
+        WHERE file_id = #{fileId}
+    </select>
+
+    <!-- 保存文件记录 -->
+    <insert id="save" parameterType="com.snapshot.entity.FileEntry">
+        INSERT INTO file_entries (
+        file_id, upload_id, file_name, storage_path, file_size,
+        mime_type, upload_status, upload_offset, created_time
+        ) VALUES (
+        #{fileId}, #{uploadId}, #{fileName}, #{storagePath}, #{fileSize},
+        #{mimeType}, #{uploadStatus}, #{uploadOffset}, #{createdTime}
+        )
+    </insert>
+
+    <!-- 更新文件记录 -->
+    <update id="updateById" parameterType="com.snapshot.entity.FileEntry">
+        UPDATE file_entries
+        <set>
+            <if test="uploadStatus != null">upload_status = #{uploadStatus},</if>
+            <if test="uploadOffset != null">upload_offset = #{uploadOffset},</if>
+        </set>
+        WHERE file_id = #{fileId}
+    </update>
+
+    <!-- 根据文件ID删除 -->
+    <delete id="deleteById">
+        DELETE FROM file_entries WHERE file_id = #{fileId}
+    </delete>
+
+    <!-- 检查文件是否存在 -->
+    <select id="existsById" resultType="boolean">
+        SELECT COUNT(*) > 0 FROM file_entries WHERE file_id = #{fileId}
+    </select>
+
+    <!-- 根据上传记录ID查找所有文件 -->
+    <select id="findByUploadId" resultMap="BaseResultMap">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM file_entries
+        WHERE upload_id = #{uploadId}
+        ORDER BY created_time ASC
+    </select>
+
+    <!-- 根据上传记录ID和上传状态查找文件 -->
+    <select id="findByUploadIdAndUploadStatus" resultMap="BaseResultMap">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM file_entries
+        WHERE upload_id = #{uploadId}
+        AND upload_status = #{uploadStatus}
+        ORDER BY created_time ASC
+    </select>
+
+    <!-- 删除指定上传记录的所有文件 -->
+    <delete id="deleteByUploadId">
+        DELETE FROM file_entries WHERE upload_id = #{uploadId}
+    </delete>
+
+    <!-- 批量保存文件记录 -->
+    <insert id="batchInsert" parameterType="java.util.List">
+        INSERT INTO file_entries (
+        file_id, upload_id, file_name, storage_path, file_size,
+        mime_type, upload_status, upload_offset, created_time
+        ) VALUES
+        <foreach collection="list" item="item" separator=",">
+            (#{item.fileId}, #{item.uploadId}, #{item.fileName}, #{item.storagePath},
+            #{item.fileSize}, #{item.mimeType}, #{item.uploadStatus},
+            #{item.uploadOffset}, #{item.createdTime})
+        </foreach>
+    </insert>
+
+</mapper>

+ 66 - 0
backend/src/main/resources/mapper/TextContentMapper.xml

@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.snapshot.mapper.TextContentMapper">
+
+    <!-- 结果映射 -->
+    <resultMap id="BaseResultMap" type="com.snapshot.entity.TextContent">
+        <id column="content_id" property="contentId"/>
+        <result column="upload_id" property="uploadId"/>
+        <result column="text_content" property="textContent"/>
+        <result column="text_type" property="textType"/>
+        <result column="created_time" property="createdTime"/>
+    </resultMap>
+
+    <!-- 基础列 -->
+    <sql id="Base_Column_List">
+        content_id, upload_id, text_content, text_type, created_time
+    </sql>
+
+    <!-- 根据ID查找 -->
+    <select id="findById" resultMap="BaseResultMap">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM text_contents
+        WHERE content_id = #{contentId}
+    </select>
+
+    <!-- 保存文本内容 -->
+    <insert id="save" parameterType="com.snapshot.entity.TextContent"
+            useGeneratedKeys="true" keyProperty="contentId" keyColumn="content_id">
+        INSERT INTO text_contents (
+        upload_id, text_content, text_type, created_time
+        ) VALUES (
+        #{uploadId}, #{textContent}, #{textType}, #{createdTime}
+        )
+    </insert>
+
+    <!-- 更新文本内容 -->
+    <update id="updateById" parameterType="com.snapshot.entity.TextContent">
+        UPDATE text_contents
+        <set>
+            <if test="textContent != null">text_content = #{textContent},</if>
+            <if test="textType != null">text_type = #{textType},</if>
+        </set>
+        WHERE content_id = #{contentId}
+    </update>
+
+    <!-- 根据ID删除 -->
+    <delete id="deleteById">
+        DELETE FROM text_contents WHERE content_id = #{contentId}
+    </delete>
+
+    <!-- 根据上传记录ID查找文本内容 -->
+    <select id="findByUploadId" resultMap="BaseResultMap">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM text_contents
+        WHERE upload_id = #{uploadId}
+    </select>
+
+    <!-- 删除指定上传记录的文本内容 -->
+    <delete id="deleteByUploadId">
+        DELETE FROM text_contents WHERE upload_id = #{uploadId}
+    </delete>
+
+</mapper>

+ 127 - 0
backend/src/main/resources/mapper/TrieNodeMapper.xml

@@ -0,0 +1,127 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.snapshot.mapper.TrieNodeMapper">
+
+    <!-- 结果映射 -->
+    <resultMap id="BaseResultMap" type="com.snapshot.entity.TrieNode">
+        <id column="node_id" property="nodeId"/>
+        <result column="node_key" property="nodeKey"/>
+        <result column="parent_id" property="parentId"/>
+        <result column="upload_id" property="uploadId"/>
+        <result column="depth" property="depth"/>
+    </resultMap>
+
+    <!-- 基础列 -->
+    <sql id="Base_Column_List">
+        node_id, node_key, parent_id, upload_id, depth
+    </sql>
+
+    <!-- 根据节点ID查找 -->
+    <select id="findById" resultMap="BaseResultMap">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM trie_nodes
+        WHERE node_id = #{nodeId}
+    </select>
+
+    <!-- 保存字典树节点 -->
+    <insert id="save" parameterType="com.snapshot.entity.TrieNode"
+            useGeneratedKeys="true" keyProperty="nodeId" keyColumn="node_id">
+        INSERT INTO trie_nodes (
+        node_key, parent_id, upload_id, depth
+        ) VALUES (
+        #{nodeKey}, #{parentId}, #{uploadId}, #{depth}
+        )
+    </insert>
+
+    <!-- 更新字典树节点 -->
+    <update id="updateById" parameterType="com.snapshot.entity.TrieNode">
+        UPDATE trie_nodes
+        <set>
+            <if test="nodeKey != null">node_key = #{nodeKey},</if>
+            <if test="parentId != null">parent_id = #{parentId},</if>
+            <if test="uploadId != null">upload_id = #{uploadId},</if>
+            <if test="depth != null">depth = #{depth},</if>
+        </set>
+        WHERE node_id = #{nodeId}
+    </update>
+
+    <!-- 根据节点ID删除 -->
+    <delete id="deleteById">
+        DELETE FROM trie_nodes WHERE node_id = #{nodeId}
+    </delete>
+
+    <!-- 根据父节点ID和节点字符查找子节点(MySQL 版本,使用 LIMIT) -->
+    <select id="findByParentIdAndNodeKey" resultMap="BaseResultMap" databaseId="mysql">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM trie_nodes
+        WHERE parent_id
+        <choose>
+            <when test="parentId == null">IS NULL</when>
+            <otherwise>= #{parentId}</otherwise>
+        </choose>
+        AND node_key = #{nodeKey}
+        LIMIT 1
+    </select>
+
+    <!-- 根据父节点ID和节点字符查找子节点(SQLite 版本,使用 LIMIT) -->
+    <select id="findByParentIdAndNodeKey" resultMap="BaseResultMap" databaseId="sqlite">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM trie_nodes
+        WHERE parent_id
+        <choose>
+            <when test="parentId == null">IS NULL</when>
+            <otherwise>= #{parentId}</otherwise>
+        </choose>
+        AND node_key = #{nodeKey}
+        LIMIT 1
+    </select>
+
+    <!-- 查找指定上传记录的所有节点 -->
+    <select id="findByUploadId" resultMap="BaseResultMap">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM trie_nodes
+        WHERE upload_id = #{uploadId}
+        ORDER BY depth ASC
+    </select>
+
+    <!-- 根据前缀查找匹配的叶子节点(MySQL 版本,使用 CONCAT) -->
+    <select id="findByPrefix" resultMap="BaseResultMap" databaseId="mysql">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM trie_nodes
+        WHERE upload_id IS NOT NULL
+        AND upload_id LIKE CONCAT(#{prefix}, '%')
+        ORDER BY upload_id
+    </select>
+
+    <!-- 根据前缀查找匹配的叶子节点(SQLite 版本,使用 || 拼接) -->
+    <select id="findByPrefix" resultMap="BaseResultMap" databaseId="sqlite">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM trie_nodes
+        WHERE upload_id IS NOT NULL
+        AND upload_id LIKE #{prefix} || '%'
+        ORDER BY upload_id
+    </select>
+
+    <!-- 删除指定上传记录的所有字典树节点 -->
+    <delete id="deleteByUploadId">
+        DELETE FROM trie_nodes WHERE upload_id = #{uploadId}
+    </delete>
+
+    <!-- 批量保存字典树节点 -->
+    <insert id="batchInsert" parameterType="java.util.List">
+        INSERT INTO trie_nodes (
+        node_key, parent_id, upload_id, depth
+        ) VALUES
+        <foreach collection="list" item="item" separator=",">
+            (#{item.nodeKey}, #{item.parentId}, #{item.uploadId}, #{item.depth})
+        </foreach>
+    </insert>
+
+</mapper>

+ 97 - 0
backend/src/main/resources/mapper/UploadRecordMapper.xml

@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.snapshot.mapper.UploadRecordMapper">
+
+    <!-- 结果映射 -->
+    <resultMap id="BaseResultMap" type="com.snapshot.entity.UploadRecord">
+        <id column="id" property="id"/>
+        <result column="password_hash" property="passwordHash"/>
+        <result column="created_time" property="createdTime"/>
+        <result column="expiration_time" property="expirationTime"/>
+        <result column="max_download_count" property="maxDownloadCount"/>
+        <result column="current_download_count" property="currentDownloadCount"/>
+    </resultMap>
+
+    <!-- 基础列 -->
+    <sql id="Base_Column_List">
+        id, password_hash, created_time, expiration_time,
+        max_download_count, current_download_count
+    </sql>
+
+    <!-- 根据ID查找 -->
+    <select id="findById" resultMap="BaseResultMap">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM upload_records
+        WHERE id = #{id}
+    </select>
+
+    <!-- 保存上传记录 -->
+    <insert id="save" parameterType="com.snapshot.entity.UploadRecord">
+        INSERT INTO upload_records (
+        id, password_hash, created_time, expiration_time,
+        max_download_count, current_download_count
+        ) VALUES (
+        #{id}, #{passwordHash}, #{createdTime}, #{expirationTime},
+        #{maxDownloadCount}, #{currentDownloadCount}
+        )
+    </insert>
+
+    <!-- 更新上传记录 -->
+    <update id="updateById" parameterType="com.snapshot.entity.UploadRecord">
+        UPDATE upload_records
+        <set>
+            <if test="passwordHash != null">password_hash = #{passwordHash},</if>
+            <if test="expirationTime != null">expiration_time = #{expirationTime},</if>
+            <if test="maxDownloadCount != null">max_download_count = #{maxDownloadCount},</if>
+            <if test="currentDownloadCount != null">current_download_count = #{currentDownloadCount},</if>
+        </set>
+        WHERE id = #{id}
+    </update>
+
+    <!-- 根据ID删除 -->
+    <delete id="deleteById">
+        DELETE FROM upload_records WHERE id = #{id}
+    </delete>
+
+    <!-- 检查是否存在 -->
+    <select id="existsById" resultType="boolean">
+        SELECT COUNT(*) > 0 FROM upload_records WHERE id = #{id}
+    </select>
+
+    <!-- 查找有效的记录 -->
+    <select id="findValidById" resultMap="BaseResultMap">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM upload_records
+        WHERE id = #{id}
+        AND expiration_time > #{now}
+        AND (max_download_count = -1 OR current_download_count &lt; max_download_count)
+    </select>
+
+    <!-- 查找过期的记录 -->
+    <select id="findByExpirationTimeBefore" resultMap="BaseResultMap">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM upload_records
+        WHERE expiration_time &lt; #{expirationTime}
+    </select>
+
+    <!-- 查找达到下载次数限制的记录 -->
+    <select id="findDownloadLimitReached" resultMap="BaseResultMap">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM upload_records
+        WHERE max_download_count != -1
+        AND current_download_count >= max_download_count
+    </select>
+
+    <!-- 增加下载次数 -->
+    <update id="incrementDownloadCount">
+        UPDATE upload_records
+        SET current_download_count = current_download_count + 1
+        WHERE id = #{id}
+    </update>
+
+</mapper>

+ 18 - 0
backend/src/main/resources/sql/check_tables.sql

@@ -0,0 +1,18 @@
+-- ============================================
+-- 检查表结构诊断脚本
+-- ============================================
+
+USE snapshot;
+
+-- 检查 upload_records 表结构
+DESCRIBE upload_records;
+
+-- 检查表是否存在
+SHOW TABLES;
+
+-- 如果表结构不正确,删除旧表
+-- DROP TABLE IF EXISTS file_entries;
+-- DROP TABLE IF EXISTS text_contents;
+-- DROP TABLE IF EXISTS trie_nodes;
+-- DROP TABLE IF EXISTS download_logs;
+-- DROP TABLE IF EXISTS upload_records;

+ 65 - 0
backend/src/main/resources/sql/create-sqlite.sql

@@ -0,0 +1,65 @@
+-- ============================================
+-- Snapshot 临时文件分享平台 - SQLite 初始化脚本
+-- 适配 SQLite 语法(无 ENGINE、CHARSET、AUTO_INCREMENT 等 MySQL 特有语法)
+-- ============================================
+
+-- 1. 上传记录表
+CREATE TABLE IF NOT EXISTS upload_records (
+    id TEXT PRIMARY KEY,
+    password_hash TEXT DEFAULT NULL,
+    created_time TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
+    expiration_time TEXT NOT NULL,
+    max_download_count INTEGER NOT NULL DEFAULT -1,
+    current_download_count INTEGER NOT NULL DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS idx_expiration ON upload_records(expiration_time);
+CREATE INDEX IF NOT EXISTS idx_created ON upload_records(created_time);
+
+-- 2. 文件记录表
+CREATE TABLE IF NOT EXISTS file_entries (
+    file_id TEXT PRIMARY KEY,
+    upload_id TEXT NOT NULL,
+    file_name TEXT NOT NULL,
+    storage_path TEXT NOT NULL,
+    file_size INTEGER NOT NULL,
+    mime_type TEXT NOT NULL,
+    upload_status TEXT NOT NULL DEFAULT 'pending',
+    upload_offset INTEGER NOT NULL DEFAULT 0,
+    created_time TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
+    FOREIGN KEY (upload_id) REFERENCES upload_records(id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_upload_id ON file_entries(upload_id);
+CREATE INDEX IF NOT EXISTS idx_status ON file_entries(upload_status);
+
+-- 3. 文本内容表
+CREATE TABLE IF NOT EXISTS text_contents (
+    content_id INTEGER PRIMARY KEY AUTOINCREMENT,
+    upload_id TEXT NOT NULL,
+    text_content TEXT NOT NULL,
+    text_type TEXT NOT NULL DEFAULT 'plain',
+    created_time TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
+    FOREIGN KEY (upload_id) REFERENCES upload_records(id) ON DELETE CASCADE,
+    UNIQUE(upload_id)
+);
+
+-- 4. 字典树节点表
+CREATE TABLE IF NOT EXISTS trie_nodes (
+    node_id INTEGER PRIMARY KEY AUTOINCREMENT,
+    node_key TEXT NOT NULL,
+    parent_id INTEGER DEFAULT NULL,
+    upload_id TEXT DEFAULT NULL,
+    depth INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_parent ON trie_nodes(parent_id);
+CREATE INDEX IF NOT EXISTS idx_upload ON trie_nodes(upload_id);
+
+-- 5. 下载日志表(可选统计)
+CREATE TABLE IF NOT EXISTS download_logs (
+    log_id INTEGER PRIMARY KEY AUTOINCREMENT,
+    upload_id TEXT NOT NULL,
+    download_time TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
+    ip_address TEXT DEFAULT NULL,
+    user_agent TEXT DEFAULT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_dl_upload_id ON download_logs(upload_id);
+CREATE INDEX IF NOT EXISTS idx_dl_download_time ON download_logs(download_time);

+ 93 - 0
backend/src/main/resources/sql/create.sql

@@ -0,0 +1,93 @@
+-- ============================================
+-- Snapshot 临时文件分享平台 - 数据库初始化脚本
+-- ============================================
+
+-- 创建数据库
+CREATE DATABASE IF NOT EXISTS snapshot CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+USE snapshot;
+
+-- ============================================
+-- 1. 上传记录表 (upload_records)
+-- ============================================
+-- 存储每次上传的基本信息
+CREATE TABLE IF NOT EXISTS upload_records (
+    id VARCHAR(32) PRIMARY KEY COMMENT '上传内容ID(唯一标识符)',
+    password_hash VARCHAR(64) DEFAULT NULL COMMENT '密码哈希值(NULL表示无密码)',
+    created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    expiration_time DATETIME NOT NULL COMMENT '过期时间',
+    max_download_count INT NOT NULL DEFAULT -1 COMMENT '最大下载次数(-1表示无限制)',
+    current_download_count INT NOT NULL DEFAULT 0 COMMENT '当前下载次数',
+    INDEX idx_expiration (expiration_time) COMMENT '过期时间索引,用于清理任务',
+    INDEX idx_created (created_time) COMMENT '创建时间索引'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='上传记录表';
+
+-- ============================================
+-- 2. 文件记录表 (file_entries)
+-- ============================================
+-- 存储每个上传记录下的文件信息
+CREATE TABLE IF NOT EXISTS file_entries (
+    file_id VARCHAR(32) PRIMARY KEY COMMENT '文件ID',
+    upload_id VARCHAR(32) NOT NULL COMMENT '关联的上传记录ID',
+    file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
+    storage_path VARCHAR(512) NOT NULL COMMENT '服务器存储路径',
+    file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
+    mime_type VARCHAR(100) NOT NULL COMMENT 'MIME类型',
+    upload_status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '上传状态:pending/uploading/completed/failed',
+    upload_offset BIGINT NOT NULL DEFAULT 0 COMMENT '已上传字节数(用于断点续传)',
+    created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
+    FOREIGN KEY (upload_id) REFERENCES upload_records(id) ON DELETE CASCADE,
+    INDEX idx_upload_id (upload_id) COMMENT '上传记录ID索引',
+    INDEX idx_status (upload_status) COMMENT '上传状态索引'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件记录表';
+
+-- ============================================
+-- 3. 文本内容表 (text_contents)
+-- ============================================
+-- 存储文本分享内容
+CREATE TABLE IF NOT EXISTS text_contents (
+    content_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '文本内容ID',
+    upload_id VARCHAR(32) NOT NULL COMMENT '关联的上传记录ID',
+    text_content LONGTEXT NOT NULL COMMENT '文本内容',
+    text_type VARCHAR(50) NOT NULL DEFAULT 'plain' COMMENT '文本类型:plain/markdown/code/json等',
+    created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    FOREIGN KEY (upload_id) REFERENCES upload_records(id) ON DELETE CASCADE,
+    UNIQUE KEY uk_upload_id (upload_id) COMMENT '确保每个上传记录只有一条文本记录'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文本内容表';
+
+-- ============================================
+-- 4. 字典树节点表 (trie_nodes)
+-- ============================================
+-- 用于快速ID前缀查询的字典树结构
+CREATE TABLE IF NOT EXISTS trie_nodes (
+    node_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '节点ID',
+    node_key VARCHAR(1) NOT NULL COMMENT '节点字符',
+    parent_id BIGINT DEFAULT NULL COMMENT '父节点ID',
+    upload_id VARCHAR(32) DEFAULT NULL COMMENT '关联的上传记录ID(仅在叶子节点存在)',
+    depth INT NOT NULL COMMENT '节点深度',
+    INDEX idx_parent (parent_id) COMMENT '父节点索引',
+    INDEX idx_upload (upload_id) COMMENT '上传记录索引'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='字典树节点表';
+
+-- ============================================
+-- 5. 下载日志表 (download_logs)
+-- ============================================
+-- 记录下载行为(可选,用于统计分析)
+CREATE TABLE IF NOT EXISTS download_logs (
+    log_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '日志ID',
+    upload_id VARCHAR(32) NOT NULL COMMENT '上传记录ID',
+    download_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下载时间',
+    ip_address VARCHAR(45) DEFAULT NULL COMMENT '下载者IP地址',
+    user_agent VARCHAR(512) DEFAULT NULL COMMENT '用户代理',
+    INDEX idx_upload_id (upload_id) COMMENT '上传记录ID索引',
+    INDEX idx_download_time (download_time) COMMENT '下载时间索引'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='下载日志表';
+
+-- ============================================
+-- 初始化说明
+-- ============================================
+-- 1. 本脚本会创建所有必要的表和索引
+-- 2. 数据库名:snapshot
+-- 3. 字符集:utf8mb4(支持emoji和特殊字符)
+-- 4. 所有外键设置了CASCADE删除,确保数据一致性
+-- 5. 定期清理过期数据的任务建议使用Spring Boot @Scheduled实现

+ 117 - 0
backend/status.sh

@@ -0,0 +1,117 @@
+#!/bin/bash
+
+##############################################
+# Snapshot 后端服务状态检查脚本
+# 适用于 Linux 环境
+##############################################
+
+# 颜色输出
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+print_info() {
+    echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+print_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+print_warning() {
+    echo -e "${YELLOW}[WARNING]${NC} $1"
+}
+
+echo "============================================"
+echo "Snapshot 后端服务状态"
+echo "============================================"
+echo
+
+# 检查进程
+echo "[1] 进程状态"
+echo "--------------------------------------------"
+PIDS=$(pgrep -f "snapshot-backend-1.0.0.jar")
+if [ -n "$PIDS" ]; then
+    print_info "服务正在运行"
+    echo "进程ID: $PIDS"
+    echo
+    ps -p $PIDS -o pid,ppid,cmd,%mem,%cpu,etime
+else
+    print_warning "服务未运行"
+fi
+echo
+
+# 检查端口占用
+echo "[2] 端口状态"
+echo "--------------------------------------------"
+if command_exists netstat; then
+    PORT_INFO=$(netstat -tuln 2>/dev/null | grep :7626)
+    if [ -n "$PORT_INFO" ]; then
+        print_info "端口 7626 已被占用"
+        echo "$PORT_INFO"
+    else
+        print_warning "端口 7626 未被占用"
+    fi
+elif command_exists ss; then
+    PORT_INFO=$(ss -tuln 2>/dev/null | grep :7626)
+    if [ -n "$PORT_INFO" ]; then
+        print_info "端口 7626 已被占用"
+        echo "$PORT_INFO"
+    else
+        print_warning "端口 7626 未被占用"
+    fi
+else
+    print_warning "无法检查端口状态(缺少netstat和ss命令)"
+fi
+echo
+
+# 检查日志
+echo "[3] 日志信息"
+echo "--------------------------------------------"
+if [ -f "app.log" ]; then
+    print_info "日志文件存在: app.log"
+    LOG_SIZE=$(du -h app.log | cut -f1)
+    echo "文件大小: $LOG_SIZE"
+    echo
+    echo "最近20行日志:"
+    echo "--------------------------------------------"
+    tail -n 20 app.log
+    echo "--------------------------------------------"
+    echo
+    echo "查看完整日志: tail -f app.log"
+else
+    print_warning "日志文件不存在"
+fi
+echo
+
+# 健康检查
+echo "[4] 健康检查"
+echo "--------------------------------------------"
+if command_exists curl; then
+    HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:7626 2>/dev/null)
+    if [ "$HTTP_CODE" = "200" ]; then
+        print_info "HTTP健康检查通过 (200)"
+    elif [ -n "$HTTP_CODE" ] && [ "$HTTP_CODE" != "000" ]; then
+        print_warning "HTTP响应: $HTTP_CODE"
+    else
+        print_error "无法连接到服务"
+    fi
+else
+    print_warning "未找到curl命令,跳过HTTP健康检查"
+fi
+echo
+
+# JVM信息(如果进程在运行)
+if [ -n "$PIDS" ]; then
+    echo "[5] JVM信息"
+    echo "--------------------------------------------"
+    if command_exists jps; then
+        print_info "Java进程信息:"
+        jps -lvm | grep snapshot
+    fi
+    echo
+fi
+
+echo "============================================"

+ 78 - 0
backend/stop.sh

@@ -0,0 +1,78 @@
+#!/bin/bash
+
+##############################################
+# Snapshot 后端停止服务脚本
+# 适用于 Linux 环境
+##############################################
+
+# 颜色输出
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+print_info() {
+    echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+print_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+print_warning() {
+    echo -e "${YELLOW}[WARNING]${NC} $1"
+}
+
+echo "============================================"
+echo "Snapshot 后端停止服务"
+echo "============================================"
+echo
+
+# 查找运行中的Snapshot进程
+PIDS=$(pgrep -f "snapshot-backend-1.0.0.jar")
+
+if [ -z "$PIDS" ]; then
+    print_warning "未找到运行中的Snapshot后端服务"
+    exit 0
+fi
+
+print_info "找到运行中的服务进程:"
+echo "$PIDS"
+echo
+
+# 尝试优雅停止
+print_info "正在停止服务..."
+
+for PID in $PIDS; do
+    if ps -p $PID > /dev/null; then
+        kill $PID
+        print_info "已发送停止信号到进程: $PID"
+    fi
+done
+
+# 等待进程结束
+sleep 3
+
+# 检查是否还有进程运行
+REMAINING_PIDS=$(pgrep -f "snapshot-backend-1.0.0.jar")
+if [ -n "$REMAINING_PIDS" ]; then
+    print_warning "服务未响应,强制停止..."
+    for PID in $REMAINING_PIDS; do
+        if ps -p $PID > /dev/null; then
+            kill -9 $PID
+            print_info "已强制停止进程: $PID"
+        fi
+    done
+    sleep 1
+fi
+
+# 最终检查
+if pgrep -f "snapshot-backend-1.0.0.jar" > /dev/null; then
+    print_error "停止失败,请手动检查"
+    exit 1
+else
+    print_info "服务已成功停止"
+fi
+
+echo
+print_info "Snapshot 后端服务已停止"

+ 75 - 0
deploy.bat

@@ -0,0 +1,75 @@
+@echo off
+setlocal enabledelayedexpansion
+
+echo ============================================
+echo   Snapshot 一体化部署脚本 (Windows)
+echo ============================================
+echo.
+
+REM 获取脚本所在目录
+set "ROOT_DIR=%~dp0"
+set "FRONTEND_DIR=%ROOT_DIR%frontend"
+set "BACKEND_DIR=%ROOT_DIR%backend"
+set "STATIC_DIR=%BACKEND_DIR%\src\main\resources\static"
+
+REM 步骤1: 构建前端
+echo [1/4] 构建前端...
+cd /d "%FRONTEND_DIR%"
+call npm install
+if %errorlevel% neq 0 (
+    echo 错误: 前端依赖安装失败
+    exit /b 1
+)
+call npm run build
+if %errorlevel% neq 0 (
+    echo 错误: 前端构建失败
+    exit /b 1
+)
+echo 前端构建完成
+echo.
+
+REM 步骤2: 清理旧的静态资源
+echo [2/4] 清理旧的静态资源...
+if exist "%STATIC_DIR%" (
+    rd /s /q "%STATIC_DIR%"
+)
+mkdir "%STATIC_DIR%"
+echo 静态资源目录已清理
+echo.
+
+REM 步骤3: 复制前端构建产物到后端
+echo [3/4] 复制前端文件到后端静态资源目录...
+xcopy "%FRONTEND_DIR%\dist\*" "%STATIC_DIR%\" /e /i /y
+if %errorlevel% neq 0 (
+    echo 错误: 文件复制失败
+    exit /b 1
+)
+echo 前端文件已复制到 %STATIC_DIR%
+echo.
+
+REM 步骤4: 构建后端
+echo [4/4] 构建后端 JAR...
+cd /d "%BACKEND_DIR%"
+call mvn clean package -DskipTests
+if %errorlevel% neq 0 (
+    echo 错误: 后端构建失败
+    exit /b 1
+)
+echo 后端构建完成
+echo.
+
+echo ============================================
+echo   部署完成!
+echo ============================================
+echo.
+echo JAR 文件: %BACKEND_DIR%\target\snapshot-backend-1.0.0.jar
+echo.
+echo 启动命令:
+echo   java -jar "%BACKEND_DIR%\target\snapshot-backend-1.0.0.jar"
+echo.
+echo 或使用以下命令直接启动:
+echo   cd /d "%BACKEND_DIR%"
+echo   mvn spring-boot:run
+echo ============================================
+
+endlocal

+ 71 - 0
deploy.sh

@@ -0,0 +1,71 @@
+#!/bin/bash
+
+# ============================================
+# Snapshot 一体化部署脚本 (Linux/Mac)
+# ============================================
+
+set -e  # 遇到错误立即退出
+
+# 颜色定义
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# 设置项目根目录
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+FRONTEND_DIR="$PROJECT_ROOT/frontend"
+BACKEND_DIR="$PROJECT_ROOT/backend"
+STATIC_TARGET="$BACKEND_DIR/src/main/resources/static"
+
+echo -e "${BLUE}============================================${NC}"
+echo -e "${BLUE}Snapshot 一体化部署脚本${NC}"
+echo -e "${BLUE}============================================${NC}"
+echo ""
+echo "项目根目录: $PROJECT_ROOT"
+echo "前端目录: $FRONTEND_DIR"
+echo "后端目录: $BACKEND_DIR"
+echo "静态资源目标: $STATIC_TARGET"
+echo ""
+
+# 步骤1: 构建前端
+echo -e "${YELLOW}[1/4] 构建前端...${NC}"
+cd "$FRONTEND_DIR"
+npm install
+npm run build
+echo -e "${GREEN}前端构建完成${NC}"
+echo ""
+
+# 步骤2: 清理旧的静态资源
+echo -e "${YELLOW}[2/4] 清理旧的静态资源...${NC}"
+rm -rf "$STATIC_TARGET"
+mkdir -p "$STATIC_TARGET"
+echo -e "${GREEN}静态资源目录已清理${NC}"
+echo ""
+
+# 步骤3: 复制前端构建产物到后端
+echo -e "${YELLOW}[3/4] 复制前端文件到后端静态资源目录...${NC}"
+cp -r "$FRONTEND_DIR/dist/"* "$STATIC_TARGET/"
+echo -e "${GREEN}前端文件已复制到 $STATIC_TARGET${NC}"
+echo ""
+
+# 步骤4: 构建后端
+echo -e "${YELLOW}[4/4] 构建后端 JAR...${NC}"
+cd "$BACKEND_DIR"
+mvn clean package -DskipTests
+echo -e "${GREEN}后端构建完成${NC}"
+echo ""
+
+echo -e "${BLUE}============================================${NC}"
+echo -e "${GREEN}  部署完成!${NC}"
+echo -e "${BLUE}============================================${NC}"
+echo ""
+echo "JAR 文件: $BACKEND_DIR/target/snapshot-backend-1.0.0.jar"
+echo ""
+echo "启动命令:"
+echo "  java -jar $BACKEND_DIR/target/snapshot-backend-1.0.0.jar"
+echo ""
+echo "或使用以下命令直接启动:"
+echo "  cd $BACKEND_DIR && mvn spring-boot:run"
+echo -e "${BLUE}============================================${NC}"

+ 26 - 0
frontend/.eslintrc.js

@@ -0,0 +1,26 @@
+module.exports = {
+  root: true,
+  env: {
+    node: true,
+    browser: true,
+    es2021: true
+  },
+  extends: [
+    'plugin:vue/vue3-essential',
+    'eslint:recommended'
+  ],
+  parser: 'vue-eslint-parser',
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module',
+    ecmaFeatures: {
+      jsx: true
+    },
+    requireConfigFile: false
+  },
+  rules: {
+    'vue/multi-word-component-names': 'off',
+    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
+    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
+  }
+}

+ 21 - 0
frontend/README.md

@@ -0,0 +1,21 @@
+# Snapshot Frontend
+
+Snapshot 临时文件分享平台前端应用
+
+## 技术栈
+
+- Vue 3.3
+- Vue Router 4
+- Pinia
+- Axios
+- SCSS
+- Vue CLI 5
+
+## 快速开始
+
+### 环境要求
+
+- Node.js 16+
+- npm 8+
+
+### 安装依赖

+ 5 - 0
frontend/babel.config.js

@@ -0,0 +1,5 @@
+module.exports = {
+  presets: [
+    '@vue/cli-plugin-babel/preset'
+  ]
+}

+ 48 - 0
frontend/config.example.js

@@ -0,0 +1,48 @@
+/**
+ * Snapshot 前端配置模板
+ *
+ * 使用说明:
+ * 1. 复制此文件到 src/config.js
+ * 2. 修改后端API地址和其他配置
+ * 3. 不要将包含真实配置的文件提交到Git
+ */
+
+export default {
+  // 后端API地址
+  // 开发环境:http://localhost:7626
+  // 生产环境:修改为你的实际部署地址,如 https://snapshot.example.com
+  baseURL: 'http://localhost:7626',
+
+  // 文件上传限制(字节)
+  uploadLimit: 500 * 1024 * 1024,  // 500MB
+
+  // 最大文件数量
+  maxFiles: 10,
+
+  // 过期时间选项(小时)
+  expirationOptions: [1, 2, 3, 6, 12, 24],
+
+  // 请求超时时间(毫秒)
+  timeout: 30000,  // 30秒
+
+  // 上传分片大小(字节)- 用于大文件分片上传
+  chunkSize: 5 * 1024 * 1024,  // 5MB
+
+  // 是否启用上传进度显示
+  showUploadProgress: true,
+
+  // 是否启用文件预检(前端验证)
+  enableFilePrecheck: true,
+
+  // 密码最小长度
+  minPasswordLength: 4,
+
+  // 密码最大长度
+  maxPasswordLength: 32,
+
+  // Toast通知持续时间(毫秒)
+  toastDuration: 3000,
+
+  // 是否开启调试模式
+  debug: process.env.NODE_ENV === 'development'
+}

+ 95 - 0
frontend/deploy.bat

@@ -0,0 +1,95 @@
+@echo off
+chcp 65001 >nul
+echo ============================================
+echo Snapshot 前端部署脚本
+echo ============================================
+echo.
+
+REM 检查Node.js环境
+echo [1/5] 检查Node.js环境...
+node -v >nul 2>&1
+if %errorlevel% neq 0 (
+    echo ❌ 错误:未检测到Node.js环境
+    echo 请安装Node.js 16或更高版本
+    pause
+    exit /b 1
+)
+echo ✅ Node.js环境检查通过
+node -v
+echo.
+
+REM 检查npm环境
+echo [2/5] 检查npm环境...
+call npm -v >nul 2>&1
+if %errorlevel% neq 0 (
+    echo ❌ 错误:未检测到npm环境
+    pause
+    exit /b 1
+)
+echo ✅ npm环境检查通过
+npm -v
+echo.
+
+REM 安装依赖
+echo [3/5] 安装项目依赖...
+if not exist "node_modules" (
+    echo 正在安装依赖,请稍候...
+    call npm install
+    if %errorlevel% neq 0 (
+        echo ❌ 依赖安装失败
+        pause
+        exit /b 1
+    )
+    echo ✅ 依赖安装完成
+) else (
+    echo ✅ 依赖已存在,跳过安装
+)
+echo.
+
+REM 构建项目
+echo [4/5] 构建项目...
+call npm run build
+if %errorlevel% neq 0 (
+    echo ❌ 项目构建失败
+    pause
+    exit /b 1
+)
+echo ✅ 项目构建完成
+echo.
+
+REM 启动开发服务器
+echo [5/5] 启动开发服务器...
+echo.
+echo ============================================
+echo 选择启动模式:
+echo ============================================
+echo 1. 开发模式(热重载,端口8081)
+echo 2. 生产模式(预览构建结果)
+echo 3. 仅构建(不启动服务器)
+echo.
+set /p mode="请输入选项 (1/2/3): "
+
+if "%mode%"=="1" (
+    echo.
+    echo 启动开发服务器...
+    echo 访问地址: http://localhost:8081
+    echo.
+    call npm run serve
+) else if "%mode%"=="2" (
+    echo.
+    echo 启动生产服务器...
+    echo 访问地址: http://localhost:8081
+    echo.
+    call npx http-server dist -p 8081 -o
+) else if "%mode%"=="3" (
+    echo.
+    echo ✅ 构建完成!
+    echo 构建文件位于: dist目录
+    echo.
+    echo 您可以将dist目录部署到任何静态文件服务器
+    pause
+) else (
+    echo ❌ 无效的选项
+    pause
+    exit /b 1
+)

+ 165 - 0
frontend/deploy.sh

@@ -0,0 +1,165 @@
+#!/bin/bash
+
+##############################################
+# Snapshot 前端部署脚本
+# 适用于 Linux 环境
+##############################################
+
+# 颜色输出
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# 打印带颜色的消息
+print_info() {
+    echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+print_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+print_warning() {
+    echo -e "${YELLOW}[WARNING]${NC} $1"
+}
+
+# 检查命令是否存在
+command_exists() {
+    command -v "$1" >/dev/null 2>&1
+}
+
+echo "============================================"
+echo "Snapshot 前端部署脚本"
+echo "============================================"
+echo
+
+# 1. 检查Node.js环境
+echo "[1/5] 检查Node.js环境..."
+if ! command_exists node; then
+    print_error "未检测到Node.js环境"
+    echo "请安装 Node.js 16 或更高版本"
+    exit 1
+fi
+
+NODE_VERSION=$(node -v)
+NPM_VERSION=$(npm -v)
+print_info "Node.js环境检查通过"
+echo "Node版本: $NODE_VERSION"
+echo "NPM版本: $NPM_VERSION"
+echo
+
+# 2. 检查npm环境
+echo "[2/5] 检查npm环境..."
+if ! command_exists npm; then
+    print_error "未检测到npm环境"
+    exit 1
+fi
+print_info "npm环境检查通过"
+echo
+
+# 3. 安装项目依赖
+echo "[3/5] 安装项目依赖..."
+if [ ! -d "node_modules" ]; then
+    print_info "正在安装依赖,请稍候..."
+    npm install
+    if [ $? -neq 0 ]; then
+        print_error "依赖安装失败"
+        exit 1
+    fi
+    print_info "依赖安装完成"
+else
+    print_info "依赖已存在,跳过安装"
+fi
+echo
+
+# 4. 构建项目
+echo "[4/5] 构建项目..."
+npm run build
+if [ $? -neq 0 ]; then
+    print_error "项目构建失败"
+    exit 1
+fi
+print_info "项目构建完成"
+echo
+
+# 5. 启动服务
+echo "[5/5] 启动服务..."
+echo
+echo "============================================"
+echo "选择启动模式:"
+echo "============================================"
+echo "1. 开发模式(热重载,端口8081)"
+echo "2. 生产模式(使用nginx等服务器)"
+echo "3. 仅构建(不启动服务器)"
+echo
+read -p "请输入选项 (1/2/3): " mode
+
+case $mode in
+    1)
+        echo
+        print_info "启动开发服务器..."
+        echo "访问地址: http://localhost:8081"
+        echo
+        echo "按 Ctrl+C 停止服务器"
+        echo
+        npm run serve
+        ;;
+    2)
+        echo
+        print_info "启动生产服务器..."
+
+        # 检查dist目录
+        if [ ! -d "dist" ]; then
+            print_error "dist目录不存在,请先执行构建"
+            exit 1
+        fi
+
+        # 使用Python的简单HTTP服务器(如果可用)
+        if command_exists python3; then
+            echo "使用Python HTTP服务器"
+            echo "访问地址: http://localhost:8081"
+            cd dist && python3 -m http.server 8081
+        # 使用npx http-server
+        elif command_exists npx; then
+            echo "使用http-server"
+            echo "访问地址: http://localhost:8081"
+            npx http-server dist -p 8081
+        else
+            print_warning "未找到HTTP服务器工具"
+            echo "请安装nginx或使用以下命令:"
+            echo "  npm install -g http-server"
+            echo "  http-server dist -p 8081"
+            echo
+            print_info "构建文件位于: dist目录"
+            print_info "您可以将dist目录部署到任何静态文件服务器"
+            exit 0
+        fi
+        ;;
+    3)
+        echo
+        print_info "构建完成!"
+        echo "构建文件位于: dist目录"
+        echo
+        echo "部署到生产环境:"
+        echo "1. 将dist目录复制到Web服务器"
+        echo "2. 配置nginx指向dist目录"
+        echo "3. 或使用任何静态文件托管服务"
+        echo
+        echo "nginx配置示例:"
+        echo "server {"
+        echo "    listen 80;"
+        echo "    server_name localhost;"
+        echo "    root /path/to/dist;"
+        echo "    index index.html;"
+        echo "    location / {"
+        echo "        try_files \$uri \$uri/ /index.html;"
+        echo "    }"
+        echo "}"
+        ;;
+    *)
+        print_error "无效的选项"
+        exit 1
+        ;;
+esac

+ 60 - 0
frontend/package.json

@@ -0,0 +1,60 @@
+{
+  "name": "snapshot-frontend",
+  "version": "1.0.0",
+  "description": "Snapshot 临时文件分享平台前端",
+  "private": true,
+  "scripts": {
+    "serve": "vue-cli-service serve",
+    "build": "vue-cli-service build",
+    "lint": "vue-cli-service lint"
+  },
+  "dependencies": {
+    "axios": "^1.6.0",
+    "core-js": "^3.8.3",
+    "dompurify": "^3.3.1",
+    "highlight.js": "^11.11.1",
+    "marked": "^4.3.0",
+    "pinia": "^2.1.0",
+    "vue": "^3.3.0",
+    "vue-router": "^4.2.0"
+  },
+  "devDependencies": {
+    "@babel/core": "^7.12.16",
+    "@babel/eslint-parser": "^7.12.16",
+    "@babel/plugin-proposal-private-methods": "^7.18.6",
+    "@vue/cli-plugin-babel": "~5.0.0",
+    "@vue/cli-plugin-eslint": "~5.0.0",
+    "@vue/cli-plugin-router": "~5.0.0",
+    "@vue/cli-service": "~5.0.0",
+    "eslint": "^7.32.0",
+    "eslint-plugin-vue": "^8.0.3",
+    "sass": "^1.69.0",
+    "sass-loader": "^13.3.0"
+  },
+  "eslintConfig": {
+    "root": true,
+    "env": {
+      "node": true
+    },
+    "extends": [
+      "plugin:vue/vue3-essential",
+      "eslint:recommended"
+    ],
+    "parserOptions": {
+      "parser": "@babel/eslint-parser",
+      "requireConfigFile": false
+    },
+    "rules": {
+      "no-console": "off",
+      "no-debugger": "off",
+      "no-unused-vars": "warn",
+      "vue/multi-word-component-names": "off"
+    }
+  },
+  "browserslist": [
+    "> 1%",
+    "last 2 versions",
+    "not dead",
+    "not ie 11"
+  ]
+}

+ 18 - 0
frontend/public/index.html

@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width,initial-scale=1.0">
+    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
+    <title>Snapshot - 临时文件分享平台</title>
+    <meta name="description" content="快速、安全、简单地分享您的文件和文本内容">
+    <meta name="keywords" content="文件分享,临时文件,文本分享,快速分享">
+  </head>
+  <body>
+    <noscript>
+      <strong>We're sorry but this application doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
+    </noscript>
+    <div id="app"></div>
+  </body>
+</html>

+ 17 - 0
frontend/src/App.vue

@@ -0,0 +1,17 @@
+<template>
+  <div id="app">
+    <router-view />
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'App'
+}
+</script>
+
+<style lang="scss">
+#app {
+  min-height: 100vh;
+}
+</style>

+ 70 - 0
frontend/src/components/CodeHighlight.vue

@@ -0,0 +1,70 @@
+<template>
+  <div class="code-highlight-wrapper">
+    <pre><code :class="codeClass" ref="codeRef">{{ code }}</code></pre>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, computed } from 'vue'
+import hljs from 'highlight.js'
+import 'highlight.js/styles/atom-one-dark.css'
+
+// Props
+const props = defineProps({
+  code: {
+    type: String,
+    required: true
+  },
+  language: {
+    type: String,
+    default: ''
+  }
+})
+
+const codeRef = ref(null)
+
+// 计算代码类名
+const codeClass = computed(() => {
+  return props.language ? `language-${props.language}` : ''
+})
+
+// 高亮代码
+onMounted(() => {
+  if (codeRef.value) {
+    if (props.language) {
+      try {
+        const result = hljs.highlight(props.code, { language: props.language })
+        codeRef.value.innerHTML = result.value
+      } catch {
+        // 如果指定语言失败,使用自动检测
+        const result = hljs.highlightAuto(props.code)
+        codeRef.value.innerHTML = result.value
+      }
+    } else {
+      const result = hljs.highlightAuto(props.code)
+      codeRef.value.innerHTML = result.value
+    }
+  }
+})
+</script>
+
+<style scoped>
+.code-highlight-wrapper {
+  width: 100%;
+  overflow-x: auto;
+}
+
+pre {
+  margin: 0;
+  padding: 1rem;
+  background: #282c34;
+  border-radius: 8px;
+  overflow-x: auto;
+}
+
+code {
+  font-family: 'Fira Code', 'Consolas', 'Monaco', monospace;
+  font-size: 14px;
+  line-height: 1.5;
+}
+</style>

+ 222 - 0
frontend/src/components/MarkdownRenderer.vue

@@ -0,0 +1,222 @@
+<template>
+  <div class="markdown-renderer" ref="markdownRef" v-html="renderedHtml"></div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, watch } from 'vue'
+import { marked } from 'marked'
+import DOMPurify from 'dompurify'
+import hljs from 'highlight.js'
+import 'highlight.js/styles/atom-one-dark.css'
+
+// Props
+const props = defineProps({
+  content: {
+    type: String,
+    required: true
+  },
+  sanitize: {
+    type: Boolean,
+    default: true
+  }
+})
+
+// Refs
+const markdownRef = ref(null)
+
+// 配置marked(v4版本)
+marked.use({
+  renderer: {
+    code(code, language) {
+      const validLang = language && hljs.getLanguage(language) ? language : 'plaintext'
+      const highlighted = hljs.highlight(code, { language: validLang }).value
+      return `<pre><code class="hljs language-${validLang}">${highlighted}</code></pre>`
+    }
+  },
+  breaks: true,
+  gfm: true
+})
+
+// Computed
+const renderedHtml = computed(() => {
+  // 解析Markdown
+  let html = marked.parse(props.content)
+
+  // 净化HTML(防止XSS攻击)
+  if (props.sanitize) {
+    html = DOMPurify.sanitize(html)
+  }
+
+  return html
+})
+
+// Lifecycle
+onMounted(() => {
+  // 代码高亮已经在marked配置中自动处理
+})
+
+// Watch
+watch(() => props.content, () => {
+  // 当内容变化时,滚动到顶部
+  if (markdownRef.value) {
+    markdownRef.value.scrollTop = 0
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.markdown-renderer {
+  line-height: 1.8;
+  color: #e0e6ed;
+  word-wrap: break-word;
+
+  // 标题样式
+  :deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
+    margin-top: 1.5em;
+    margin-bottom: 0.5em;
+    font-weight: 600;
+    line-height: 1.25;
+    color: #fff;
+  }
+
+  :deep(h1) {
+    font-size: 2em;
+    border-bottom: 2px solid #3b4252;
+    padding-bottom: 0.3em;
+  }
+
+  :deep(h2) {
+    font-size: 1.5em;
+    border-bottom: 1px solid #3b4252;
+    padding-bottom: 0.3em;
+  }
+
+  :deep(h3) {
+    font-size: 1.25em;
+  }
+
+  // 段落样式
+  :deep(p) {
+    margin-bottom: 1em;
+  }
+
+  // 列表样式
+  :deep(ul), :deep(ol) {
+    padding-left: 2em;
+    margin-bottom: 1em;
+  }
+
+  :deep(li) {
+    margin-bottom: 0.25em;
+  }
+
+  // 引用样式
+  :deep(blockquote) {
+    margin: 1em 0;
+    padding: 0.5em 1em;
+    border-left: 4px solid #61afef;
+    background: #2c313a;
+    color: #a0a7b4;
+  }
+
+  // 代码块样式
+  :deep(pre) {
+    margin: 1em 0;
+    padding: 1rem;
+    background: #282c34;
+    border-radius: 8px;
+    overflow-x: auto;
+
+    &::-webkit-scrollbar {
+      height: 8px;
+    }
+
+    &::-webkit-scrollbar-track {
+      background: #1e2127;
+    }
+
+    &::-webkit-scrollbar-thumb {
+      background: #4b5263;
+      border-radius: 4px;
+
+      &:hover {
+        background: #5c6370;
+      }
+    }
+  }
+
+  :deep(code) {
+    font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
+    font-size: 0.9em;
+    background: rgba(110, 115, 130, 0.2);
+    padding: 0.2em 0.4em;
+    border-radius: 4px;
+    color: #e5c07b;
+  }
+
+  :deep(pre code) {
+    background: transparent;
+    padding: 0;
+    color: #abb2bf;
+  }
+
+  // 表格样式
+  :deep(table) {
+    width: 100%;
+    border-collapse: collapse;
+    margin: 1em 0;
+    background: #2c313a;
+
+    th, td {
+      padding: 0.75em;
+      border: 1px solid #3b4252;
+      text-align: left;
+    }
+
+    th {
+      background: #3b4252;
+      font-weight: 600;
+    }
+
+    tr:nth-child(even) {
+      background: #262930;
+    }
+  }
+
+  // 链接样式
+  :deep(a) {
+    color: #61afef;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+
+  // 图片样式
+  :deep(img) {
+    max-width: 100%;
+    height: auto;
+    border-radius: 8px;
+    margin: 1em 0;
+  }
+
+  // 分隔线样式
+  :deep(hr) {
+    border: none;
+    border-top: 2px solid #3b4252;
+    margin: 2em 0;
+  }
+
+  // 强调样式
+  :deep(strong) {
+    color: #fff;
+    font-weight: 600;
+  }
+
+  :deep(em) {
+    color: #c678dd;
+    font-style: italic;
+  }
+}
+</style>

+ 231 - 0
frontend/src/components/Toast.vue

@@ -0,0 +1,231 @@
+<template>
+  <Transition name="toast-fade">
+    <div v-if="visible" :class="['toast', `toast-${type}`]">
+      <div class="toast-icon">
+        <!-- 成功图标 -->
+        <svg v-if="type === 'success'" viewBox="0 0 24 24" fill="none" stroke="currentColor">
+          <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
+          <polyline points="22 4 12 14.01 9 11.01"></polyline>
+        </svg>
+
+        <!-- 错误图标 -->
+        <svg v-else-if="type === 'error'" viewBox="0 0 24 24" fill="none" stroke="currentColor">
+          <circle cx="12" cy="12" r="10"></circle>
+          <line x1="15" y1="9" x2="9" y2="15"></line>
+          <line x1="9" y1="9" x2="15" y2="15"></line>
+        </svg>
+
+        <!-- 警告图标 -->
+        <svg v-else-if="type === 'warning'" viewBox="0 0 24 24" fill="none" stroke="currentColor">
+          <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
+          <line x1="12" y1="9" x2="12" y2="13"></line>
+          <line x1="12" y1="17" x2="12.01" y2="17"></line>
+        </svg>
+
+        <!-- 信息图标 -->
+        <svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor">
+          <circle cx="12" cy="12" r="10"></circle>
+          <line x1="12" y1="16" x2="12" y2="12"></line>
+          <line x1="12" y1="8" x2="12.01" y2="8"></line>
+        </svg>
+      </div>
+
+      <div class="toast-content">
+        <p class="toast-message">{{ message }}</p>
+      </div>
+
+      <button class="toast-close" @click="close">
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
+          <line x1="18" y1="6" x2="6" y2="18"></line>
+          <line x1="6" y1="6" x2="18" y2="18"></line>
+        </svg>
+      </button>
+    </div>
+  </Transition>
+</template>
+
+<script setup>
+import { ref, watch } from 'vue'
+
+const props = defineProps({
+  message: {
+    type: String,
+    required: true
+  },
+  type: {
+    type: String,
+    default: 'info',
+    validator: (value) => ['success', 'error', 'warning', 'info'].includes(value)
+  },
+  duration: {
+    type: Number,
+    default: 3000
+  },
+  visible: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const emit = defineEmits(['close'])
+
+let timer = null
+
+const close = () => {
+  emit('close')
+}
+
+watch(() => props.visible, (newVal) => {
+  if (newVal && props.duration > 0) {
+    if (timer) clearTimeout(timer)
+    timer = setTimeout(() => {
+      close()
+    }, props.duration)
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+@import '@/styles/variables.scss';
+
+.toast {
+  position: fixed;
+  top: $spacing-xl;
+  left: 50%;
+  transform: translateX(-50%);
+  min-width: 320px;
+  max-width: 500px;
+  padding: $spacing-md $spacing-lg;
+  background: white;
+  border-radius: $radius-lg;
+  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
+  display: flex;
+  align-items: center;
+  gap: $spacing-md;
+  z-index: 9999;
+  backdrop-filter: blur(10px);
+
+  &.toast-success {
+    background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
+    border-left: 4px solid #28a745;
+
+    .toast-icon {
+      color: #28a745;
+    }
+  }
+
+  &.toast-error {
+    background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%);
+    border-left: 4px solid #dc3545;
+
+    .toast-icon {
+      color: #dc3545;
+    }
+  }
+
+  &.toast-warning {
+    background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
+    border-left: 4px solid #ffc107;
+
+    .toast-icon {
+      color: #ffc107;
+    }
+  }
+
+  &.toast-info {
+    background: linear-gradient(135deg, #d1ecf1 0%, #bee5eb 100%);
+    border-left: 4px solid #17a2b8;
+
+    .toast-icon {
+      color: #17a2b8;
+    }
+  }
+}
+
+.toast-icon {
+  flex-shrink: 0;
+  width: 24px;
+  height: 24px;
+
+  svg {
+    width: 100%;
+    height: 100%;
+    stroke-width: 2;
+  }
+}
+
+.toast-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.toast-message {
+  margin: 0;
+  color: $text-primary;
+  font-size: $font-size-base;
+  font-weight: 500;
+  line-height: 1.5;
+}
+
+.toast-close {
+  flex-shrink: 0;
+  width: 20px;
+  height: 20px;
+  padding: 0;
+  border: none;
+  background: transparent;
+  color: currentColor;
+  cursor: pointer;
+  opacity: 0.6;
+  transition: opacity 0.2s;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  svg {
+    width: 100%;
+    height: 100%;
+    stroke-width: 2;
+  }
+
+  &:hover {
+    opacity: 1;
+  }
+}
+
+// 过渡动画
+.toast-fade-enter-active,
+.toast-fade-leave-active {
+  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.toast-fade-enter-from {
+  opacity: 0;
+  transform: translate(-50%, -20px);
+}
+
+.toast-fade-leave-to {
+  opacity: 0;
+  transform: translate(-50%, -20px);
+}
+
+// 移动端适配
+@media (max-width: 768px) {
+  .toast {
+    min-width: auto;
+    left: $spacing-md;
+    right: $spacing-md;
+    transform: none;
+    max-width: none;
+
+    &.toast-fade-enter-from,
+    &.toast-fade-leave-to {
+      transform: translateY(-20px);
+    }
+  }
+
+  .toast-message {
+    font-size: $font-size-sm;
+  }
+}
+</style>

+ 96 - 0
frontend/src/config.js

@@ -0,0 +1,96 @@
+/**
+ * Snapshot 前端配置文件
+ */
+
+import axios from 'axios'
+
+const config = {
+  // API基础URL - 使用相对路径以适配一体化部署
+  // 在一体化部署中,前端和后端在同一域名下,直接使用相对路径即可
+  // 开发环境可通过环境变量 VUE_APP_API_BASE_URL 覆盖
+  baseURL: process.env.VUE_APP_API_BASE_URL || '',
+
+  // 服务器配置(仅用于生成分享链接)
+  server: {
+    // 使用当前页面协议和域名,端口从配置读取
+    // 一体化部署时前后端同端口,开发环境可通过环境变量覆盖
+    port: process.env.VUE_APP_SERVER_PORT || '7626',
+    // 域名配置(可选,用于生产环境自定义域名)
+    domain: process.env.VUE_APP_SERVER_DOMAIN || '' // 留空则自动使用当前域名
+  },
+
+  // 文件上传限制(默认值,会被后端配置覆盖)
+  uploadLimit: 500 * 1024 * 1024, // 500MB
+  maxFiles: 10,
+
+  // 过期时间选项(小时,-1 表示无限制)
+  expirationOptions: [1, 2, 3, 6, 12, 24, 48, 72, 168, -1],
+
+  // 过期时间选项标签映射
+  expirationLabels: {
+    1: '1小时',
+    2: '2小时',
+    3: '3小时',
+    6: '6小时',
+    12: '12小时',
+    24: '1天',
+    48: '2天',
+    72: '3天',
+    168: '7天',
+    [-1]: '无限制'
+  },
+
+  // 下载次数选项
+  downloadCountOptions: [
+    { label: '无限制', value: -1 },
+    { label: '1次', value: 1 },
+    { label: '3次', value: 3 },
+    { label: '5次', value: 5 },
+    { label: '10次', value: 10 }
+  ],
+
+  // 文本类型选项
+  textTypeOptions: [
+    { label: '纯文本', value: 'plain' },
+    { label: 'Markdown', value: 'markdown' },
+    { label: '代码', value: 'code' },
+    { label: 'JSON', value: 'json' }
+  ],
+
+  // 允许的文件类型(默认不限制,接受所有文件)
+  allowedFileTypes: ['*']
+}
+
+/**
+ * 从后端获取配置并更新本地配置
+ * 在应用启动前调用,确保使用后端统一配置
+ */
+export async function fetchConfig() {
+  try {
+    const baseURL = config.baseURL
+    const response = await axios.get(`${baseURL}/api/config`)
+    const res = response.data
+
+    // 处理统一响应格式 { code: 200, data: {...} }
+    const data = res.code !== undefined ? res.data : res
+
+    if (data) {
+      if (data.maxFileSize) {
+        config.uploadLimit = data.maxFileSize
+      }
+      if (data.maxFileCount) {
+        config.maxFiles = data.maxFileCount
+      }
+    }
+
+    console.log('[Config] 后端配置加载成功:', {
+      maxFileSize: `${(config.uploadLimit / 1024 / 1024).toFixed(0)}MB`,
+      maxFileCount: config.maxFiles
+    })
+  } catch (error) {
+    // 获取失败时使用默认值,不影响应用启动
+    console.warn('[Config] 后端配置获取失败,使用默认值:', error.message)
+  }
+}
+
+export default config

+ 16 - 0
frontend/src/main.js

@@ -0,0 +1,16 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+import router from './router'
+import { createPinia } from 'pinia'
+import { fetchConfig } from './config'
+import './styles/global.scss'
+
+// 先获取后端配置,再初始化应用
+fetchConfig().then(() => {
+  const app = createApp(App)
+
+  app.use(createPinia())
+  app.use(router)
+
+  app.mount('#app')
+})

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

@@ -0,0 +1,41 @@
+import { createRouter, createWebHistory } from 'vue-router'
+
+const routes = [
+  {
+    path: '/',
+    name: 'Home',
+    component: () => import('@/views/Home.vue'),
+    meta: {
+      title: 'Snapshot - 临时文件分享平台'
+    }
+  },
+  {
+    path: '/s/:id',
+    name: 'Share',
+    component: () => import('@/views/Share.vue'),
+    meta: {
+      title: '查看分享内容'
+    }
+  },
+  {
+    path: '/:pathMatch(.*)*',
+    name: 'NotFound',
+    component: () => import('@/views/NotFound.vue'),
+    meta: {
+      title: '页面未找到'
+    }
+  }
+]
+
+const router = createRouter({
+  history: createWebHistory(process.env.BASE_URL),
+  routes
+})
+
+// 路由标题
+router.beforeEach((to, from, next) => {
+  document.title = to.meta.title || 'Snapshot'
+  next()
+})
+
+export default router

+ 163 - 0
frontend/src/stores/upload.js

@@ -0,0 +1,163 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import config from '../config'
+
+export const useUploadStore = defineStore('upload', () => {
+  // 状态
+  const selectedFiles = ref([])
+  const textContent = ref('')
+  const textType = ref('plain')
+  const expirationHours = ref(3)
+  const maxDownloadCount = ref(-1)
+  const password = ref('')
+  const uploadResult = ref(null)
+  const uploading = ref(false)
+
+  // 上传进度状态
+  const uploadProgress = ref(0) // 0-100
+  const uploadSpeed = ref(0) // 字节/秒
+  const uploadedBytes = ref(0) // 已上传字节数
+  const totalBytes = ref(0) // 总字节数
+
+  // 计算属性
+  const hasFiles = computed(() => selectedFiles.value.length > 0)
+  const hasText = computed(() => textContent.value.trim().length > 0)
+  const canUpload = computed(() => hasFiles.value || hasText.value)
+  const totalFileSize = computed(() => {
+    return selectedFiles.value.reduce((sum, file) => sum + file.size, 0)
+  })
+
+  // 计算剩余时间(秒)
+  const remainingTime = computed(() => {
+    if (uploadSpeed.value === 0 || uploadProgress.value >= 100) {
+      return 0
+    }
+    const remainingBytes = totalBytes.value - uploadedBytes.value
+    return Math.ceil(remainingBytes / uploadSpeed.value)
+  })
+
+  // 方法
+  function addFiles(files) {
+    const remainingSlots = config.maxFiles - selectedFiles.value.length
+    const filesToAdd = []
+    const rejectedFiles = []
+
+    // 文件大小预检
+    for (const file of files.slice(0, remainingSlots)) {
+      // 检查文件大小
+      if (file.size > config.uploadLimit) {
+        rejectedFiles.push({
+          file,
+          reason: 'size',
+          maxSize: config.uploadLimit
+        })
+        continue
+      }
+
+      // 检查是否重复
+      if (!selectedFiles.value.some(f => f.name === file.name && f.size === file.size)) {
+        filesToAdd.push(file)
+      }
+    }
+
+    // 添加有效的文件
+    filesToAdd.forEach(file => {
+      selectedFiles.value.push({
+        id: Date.now() + Math.random(),
+        file,
+        name: file.name,
+        size: file.size,
+        type: file.type,
+        progress: 0
+      })
+    })
+
+    return {
+      added: filesToAdd.length,
+      rejected: rejectedFiles
+    }
+  }
+
+  function removeFile(id) {
+    const index = selectedFiles.value.findIndex(f => f.id === id)
+    if (index > -1) {
+      selectedFiles.value.splice(index, 1)
+    }
+  }
+
+  function clearFiles() {
+    selectedFiles.value = []
+  }
+
+  function reset() {
+    selectedFiles.value = []
+    textContent.value = ''
+    textType.value = 'plain'
+    expirationHours.value = 3
+    maxDownloadCount.value = -1
+    password.value = ''
+    uploadResult.value = null
+    uploading.value = false
+    resetUploadProgress() // 重置进度
+  }
+
+  function setUploadResult(result) {
+    uploadResult.value = result
+  }
+
+  function setUploading(status) {
+    uploading.value = status
+  }
+
+  function updateFileProgress(id, progress) {
+    const file = selectedFiles.value.find(f => f.id === id)
+    if (file) {
+      file.progress = progress
+    }
+  }
+
+  // 更新上传进度
+  function setUploadProgress(percent, speed, loaded, total) {
+    uploadProgress.value = percent
+    uploadSpeed.value = speed
+    uploadedBytes.value = loaded
+    totalBytes.value = total
+  }
+
+  // 重置上传进度
+  function resetUploadProgress() {
+    uploadProgress.value = 0
+    uploadSpeed.value = 0
+    uploadedBytes.value = 0
+    totalBytes.value = 0
+  }
+
+  return {
+    // 状态
+    selectedFiles,
+    textContent,
+    textType,
+    expirationHours,
+    maxDownloadCount,
+    password,
+    uploadResult,
+    uploading,
+
+    // 计算属性
+    hasFiles,
+    hasText,
+    canUpload,
+    totalFileSize,
+
+    // 方法
+    addFiles,
+    removeFile,
+    clearFiles,
+    reset,
+    setUploadResult,
+    setUploading,
+    updateFileProgress,
+    setUploadProgress,
+    resetUploadProgress
+  }
+})

+ 266 - 0
frontend/src/styles/global.scss

@@ -0,0 +1,266 @@
+@import './variables.scss';
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+html {
+  font-size: 16px;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
+    'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  background: linear-gradient(135deg, #0c1222 0%, #1a1d3d 50%, #2a1f3d 100%);
+  color: $text-primary;
+  line-height: 1.6;
+  min-height: 100vh;
+  overflow-x: hidden;
+  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;
+  }
+}
+
+// 滚动条样式
+::-webkit-scrollbar {
+  width: 8px;
+  height: 8px;
+}
+
+::-webkit-scrollbar-track {
+  background: $bg-secondary;
+}
+
+::-webkit-scrollbar-thumb {
+  background: $bg-tertiary;
+  border-radius: $radius-md;
+}
+
+::-webkit-scrollbar-thumb:hover {
+  background: $bg-hover;
+}
+
+// 通用类
+.container {
+  max-width: 1200px;
+  margin: 0 auto;
+  padding: 0 $spacing-lg;
+}
+
+.text-center {
+  text-align: center;
+}
+
+.text-left {
+  text-align: left;
+}
+
+.text-right {
+  text-align: right;
+}
+
+// 按钮样式
+.btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: $spacing-sm;
+  padding: $spacing-sm $spacing-lg;
+  font-size: $font-size-base;
+  font-weight: 500;
+  border-radius: $radius-md;
+  border: none;
+  cursor: pointer;
+  transition: all $transition-base ease;
+  user-select: none;
+
+  &:hover {
+    transform: translateY(-1px);
+    box-shadow: $shadow-lg;
+  }
+
+  &:active {
+    transform: translateY(0);
+  }
+
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+
+  &.btn-primary {
+    background: linear-gradient(135deg, $primary-color, $secondary-color);
+    color: white;
+
+    &:hover {
+      background: linear-gradient(135deg, darken($primary-color, 10%), darken($secondary-color, 10%));
+    }
+  }
+
+  &.btn-secondary {
+    background: $bg-tertiary;
+    color: $text-primary;
+
+    &:hover {
+      background: $bg-hover;
+    }
+  }
+
+  &.btn-danger {
+    background: $error-color;
+    color: white;
+
+    &:hover {
+      background: darken($error-color, 10%);
+    }
+  }
+
+  &.btn-lg {
+    padding: $spacing-md $spacing-xl;
+    font-size: $font-size-lg;
+  }
+
+  &.btn-sm {
+    padding: $spacing-xs $spacing-sm;
+    font-size: $font-size-sm;
+  }
+}
+
+// 表单元素
+input[type='text'],
+input[type='password'],
+input[type='email'],
+input[type='number'],
+textarea,
+select {
+  width: 100%;
+  padding: $spacing-sm $spacing-md;
+  font-size: $font-size-base;
+  color: $text-primary;
+  background: $bg-secondary;
+  border: 1px solid $border-color;
+  border-radius: $radius-md;
+  transition: all $transition-base ease;
+
+  &:focus {
+    outline: none;
+    border-color: $primary-color;
+    box-shadow: 0 0 0 3px rgba($primary-color, 0.1);
+  }
+
+  &::placeholder {
+    color: $text-muted;
+  }
+}
+
+textarea {
+  resize: vertical;
+  min-height: 100px;
+}
+
+// 卡片
+.card {
+  background: rgba($bg-secondary, 0.8);
+  backdrop-filter: blur(10px);
+  border: 1px solid $border-color;
+  border-radius: $radius-lg;
+  padding: $spacing-xl;
+  box-shadow: $shadow-xl;
+}
+
+// Toast提示
+.toast {
+  position: fixed;
+  top: $spacing-xl;
+  right: $spacing-xl;
+  padding: $spacing-md $spacing-lg;
+  background: $bg-secondary;
+  border: 1px solid $border-color;
+  border-radius: $radius-md;
+  box-shadow: $shadow-lg;
+  z-index: $z-tooltip;
+  animation: slideIn 0.3s ease;
+
+  &.toast-success {
+    border-left: 4px solid $success-color;
+  }
+
+  &.toast-error {
+    border-left: 4px solid $error-color;
+  }
+
+  &.toast-warning {
+    border-left: 4px solid $warning-color;
+  }
+
+  &.toast-info {
+    border-left: 4px solid $info-color;
+  }
+}
+
+@keyframes slideIn {
+  from {
+    transform: translateX(100%);
+    opacity: 0;
+  }
+  to {
+    transform: translateX(0);
+    opacity: 1;
+  }
+}
+
+// 加载动画
+.spinner {
+  display: inline-block;
+  width: 20px;
+  height: 20px;
+  border: 3px solid rgba(255, 255, 255, 0.3);
+  border-radius: 50%;
+  border-top-color: white;
+  animation: spin 1s ease-in-out infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+#app {
+  position: relative;
+  z-index: 1;
+}

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

@@ -0,0 +1,69 @@
+// 颜色变量
+$primary-color: #6366f1;
+$secondary-color: #8b5cf6;
+$accent-color: #ec4899;
+$success-color: #10b981;
+$warning-color: #f59e0b;
+$error-color: #ef4444;
+$info-color: #3b82f6;
+
+// 背景颜色
+$bg-primary: #0f172a;
+$bg-secondary: #1e293b;
+$bg-tertiary: #334155;
+$bg-hover: #475569;
+
+// 文字颜色
+$text-primary: #f8fafc;
+$text-secondary: #e2e8f0;
+$text-tertiary: #cbd5e1;
+$text-muted: #94a3b8;
+
+// 边框颜色
+$border-color: #334155;
+$border-light: #475569;
+
+// 阴影
+$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
+$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
+
+// 间距
+$spacing-xs: 0.5rem;
+$spacing-sm: 0.75rem;
+$spacing-md: 1rem;
+$spacing-lg: 1.5rem;
+$spacing-xl: 2rem;
+$spacing-2xl: 3rem;
+
+// 圆角
+$radius-sm: 0.375rem;
+$radius-md: 0.5rem;
+$radius-lg: 0.75rem;
+$radius-xl: 1rem;
+$radius-full: 9999px;
+
+// 字体
+$font-size-xs: 0.75rem;
+$font-size-sm: 0.875rem;
+$font-size-base: 1rem;
+$font-size-lg: 1.125rem;
+$font-size-xl: 1.25rem;
+$font-size-2xl: 1.5rem;
+$font-size-3xl: 1.875rem;
+$font-size-4xl: 2.25rem;
+
+// 过渡
+$transition-fast: 150ms;
+$transition-base: 200ms;
+$transition-slow: 300ms;
+
+// Z-index
+$z-dropdown: 1000;
+$z-sticky: 1020;
+$z-fixed: 1030;
+$z-modal-backdrop: 1040;
+$z-modal: 1050;
+$z-popover: 1060;
+$z-tooltip: 1070;

+ 70 - 0
frontend/src/utils/toast.js

@@ -0,0 +1,70 @@
+import { createApp, ref } from 'vue'
+
+// 全局 Toast 状态
+const toastState = ref({
+  visible: false,
+  message: '',
+  type: 'info',
+  duration: 3000
+})
+
+// Toast 组件容器
+let toastApp = null
+let toastContainer = null
+
+// 显示 Toast
+function showToast(options) {
+  if (typeof options === 'string') {
+    options = { message: options, type: 'info' }
+  }
+
+  // 更新状态
+  toastState.value = {
+    visible: true,
+    message: options.message || '',
+    type: options.type || 'info',
+    duration: options.duration ?? 3000
+  }
+
+  return {
+    close: () => {
+      toastState.value.visible = false
+    }
+  }
+}
+
+// 快捷方法
+const toast = {
+  success(message, duration) {
+    return showToast({ message, type: 'success', duration })
+  },
+
+  error(message, duration) {
+    return showToast({ message, type: 'error', duration })
+  },
+
+  warning(message, duration) {
+    return showToast({ message, type: 'warning', duration })
+  },
+
+  info(message, duration) {
+    return showToast({ message, type: 'info', duration })
+  }
+}
+
+// 初始化 Toast 容器
+function initToast() {
+  if (toastContainer) return
+
+  // 创建容器
+  toastContainer = document.createElement('div')
+  toastContainer.id = 'toast-container'
+  document.body.appendChild(toastContainer)
+
+  // 返回状态供组件使用
+  return toastState
+}
+
+// 导出
+export default toast
+export { initToast, toastState }

+ 1162 - 0
frontend/src/views/Home.vue

@@ -0,0 +1,1162 @@
+<template>
+  <div class="home-page">
+    <!-- 标题区域 -->
+    <header class="header">
+      <h1 class="title">Snapshot</h1>
+      <p class="subtitle">致力于做最好用的临时文件分享平台</p>
+      <p class="description">
+        快速、安全、简单地分享您的文件和文本内容,无需注册,即时生成分享链接
+      </p>
+    </header>
+
+    <!-- 主要内容区 -->
+    <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>
+
+        <!-- 右侧文本输入区 -->
+        <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>
+      </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>
+
+      <!-- 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>
+    </main>
+
+    <!-- 上传成功弹窗 -->
+    <div v-if="uploadStore.uploadResult" class="modal-overlay" @click="closeModal">
+      <div class="modal" @click.stop>
+        <div class="modal-header">
+          <h3>✅ 上传成功!</h3>
+          <button class="btn-close" @click="closeModal">×</button>
+        </div>
+        <div class="modal-body">
+          <div class="result-item">
+            <label>分享链接:</label>
+            <div class="url-container">
+              <input :value="uploadStore.uploadResult.shareUrl" readonly class="result-url">
+              <button class="btn-copy" @click="copyUrl">复制</button>
+            </div>
+          </div>
+          <div class="result-info">
+            <p>过期时间:{{ formatDateTime(uploadStore.uploadResult.expirationTime) }}</p>
+            <p>最大下载次数:{{ uploadStore.uploadResult.maxDownloadCount === -1 ? '无限制' : uploadStore.uploadResult.maxDownloadCount }}次</p>
+          </div>
+        </div>
+        <div class="modal-footer">
+          <button class="btn btn-secondary" @click="resetUpload">继续上传</button>
+          <a :href="uploadStore.uploadResult.shareUrl" target="_blank" class="btn btn-primary">打开链接</a>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch } from 'vue'
+import { useRouter } from 'vue-router'
+import { useUploadStore } from '@/stores/upload'
+import { createUpload, uploadFiles, resolveIdByPrefix } from '@/api'
+import config from '@/config'
+import toast from '@/utils/toast'
+
+const router = useRouter()
+const uploadStore = useUploadStore()
+
+// 响应式数据
+const isDragging = ref(false)
+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
+  }
+})
+
+// 防抖函数
+function debounce(fn, delay) {
+  let timeoutId = null
+  return function (...args) {
+    clearTimeout(timeoutId)
+    timeoutId = setTimeout(() => fn.apply(this, args), 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'
+    } else if (errorMsg.includes('未找到匹配')) {
+      queryError.value = '未找到匹配的分享内容'
+    } else {
+      queryError.value = errorMsg
+    }
+  } 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)
+
+    // 显示被拒绝的文件提示
+    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()
+}
+
+async function handleQuery() {
+  // 按钮已禁用或正在验证,不执行操作
+  if (!isQueryValid.value || isQueryChecking.value) {
+    return
+  }
+
+  if (!queryId.value.trim()) {
+    toast.warning('请输入分享内容ID')
+    return
+  }
+
+  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
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+@import '@/styles/variables.scss';
+
+.home-page {
+  min-height: 100vh;
+  padding: $spacing-2xl $spacing-lg;
+}
+
+.header {
+  text-align: center;
+  margin-bottom: $spacing-2xl;
+
+  .title {
+    font-size: $font-size-4xl;
+    font-weight: 700;
+    background: linear-gradient(135deg, #a855f7, #6366f1, #ec4899);
+    -webkit-background-clip: text;
+    -webkit-text-fill-color: transparent;
+    background-clip: text;
+    margin-bottom: $spacing-md;
+    animation: glow 3s ease-in-out infinite;
+  }
+
+  .subtitle {
+    font-size: $font-size-2xl;
+    font-weight: 600;
+    margin-bottom: $spacing-sm;
+    color: $text-secondary;
+  }
+
+  .description {
+    font-size: $font-size-base;
+    color: $text-muted;
+    max-width: 600px;
+    margin: 0 auto;
+  }
+}
+
+@keyframes glow {
+  0%, 100% {
+    filter: brightness(1);
+  }
+  50% {
+    filter: brightness(1.2);
+  }
+}
+
+.main-content {
+  max-width: 1200px;
+  margin: 0 auto;
+}
+
+.upload-container {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: $spacing-xl;
+  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;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: rgba(0, 0, 0, 0.7);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: $z-modal;
+  backdrop-filter: blur(5px);
+}
+
+.modal {
+  background: $bg-secondary;
+  border: 1px solid $border-color;
+  border-radius: $radius-xl;
+  max-width: 500px;
+  width: 90%;
+  box-shadow: $shadow-xl;
+}
+
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: $spacing-lg;
+  border-bottom: 1px solid $border-color;
+
+  h3 {
+    color: $success-color;
+  }
+}
+
+.btn-close {
+  width: 32px;
+  height: 32px;
+  border-radius: $radius-md;
+  border: none;
+  background: $bg-tertiary;
+  color: $text-primary;
+  font-size: $font-size-2xl;
+  cursor: pointer;
+  line-height: 1;
+}
+
+.modal-body {
+  padding: $spacing-lg;
+}
+
+.result-item {
+  margin-bottom: $spacing-lg;
+
+  label {
+    display: block;
+    margin-bottom: $spacing-sm;
+    color: $text-secondary;
+  }
+}
+
+.url-container {
+  display: flex;
+  gap: $spacing-sm;
+}
+
+.result-url {
+  flex: 1;
+  padding: $spacing-sm $spacing-md;
+  background: $bg-tertiary;
+  border: 1px solid $border-color;
+  border-radius: $radius-md;
+  color: $text-primary;
+}
+
+.btn-copy {
+  padding: $spacing-sm $spacing-lg;
+  @extend .btn !optional;
+  @extend .btn-primary !optional;
+}
+
+.result-info {
+  p {
+    font-size: $font-size-sm;
+    color: $text-muted;
+    margin-bottom: $spacing-xs;
+  }
+}
+
+.modal-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: $spacing-md;
+  padding: $spacing-lg;
+  border-top: 1px solid $border-color;
+
+  .btn {
+    padding: $spacing-sm $spacing-xl;
+  }
+}
+
+// 响应式设计
+@media (max-width: 768px) {
+  .home-page {
+    padding: $spacing-lg $spacing-md;
+  }
+
+  .header {
+    .title {
+      font-size: $font-size-3xl;
+    }
+
+    .subtitle {
+      font-size: $font-size-xl;
+    }
+  }
+
+  .upload-container {
+    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'
+}
+</script>

+ 55 - 0
frontend/src/views/NotFound.vue

@@ -0,0 +1,55 @@
+<template>
+  <div class="not-found-page">
+    <div class="container">
+      <div class="not-found-content card">
+        <h1 class="error-code">404</h1>
+        <h2 class="error-title">页面未找到</h2>
+        <p class="error-message">抱歉,您访问的页面不存在</p>
+        <router-link to="/" class="btn btn-primary btn-lg">返回首页</router-link>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+// 无需额外逻辑
+</script>
+
+<style lang="scss" scoped>
+@import '@/styles/variables.scss';
+
+.not-found-page {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: $spacing-xl;
+}
+
+.not-found-content {
+  text-align: center;
+  padding: $spacing-2xl;
+}
+
+.error-code {
+  font-size: 120px;
+  font-weight: 700;
+  background: linear-gradient(135deg, #a855f7, #6366f1, #ec4899);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  background-clip: text;
+  line-height: 1;
+  margin-bottom: $spacing-lg;
+}
+
+.error-title {
+  font-size: $font-size-2xl;
+  color: $text-secondary;
+  margin-bottom: $spacing-md;
+}
+
+.error-message {
+  color: $text-muted;
+  margin-bottom: $spacing-xl;
+}
+</style>

+ 1224 - 0
frontend/src/views/Share.vue

@@ -0,0 +1,1224 @@
+<template>
+  <div class="share-page">
+    <div class="container">
+      <!-- 加载状态 -->
+      <div v-if="loading" class="loading-container">
+        <div class="spinner large"></div>
+        <p>加载中...</p>
+      </div>
+
+      <!-- 密码验证 -->
+      <div v-else-if="requiresPassword && !passwordVerified" class="password-container card">
+        <h2>🔒 此内容需要密码</h2>
+        <input
+          v-model="password"
+          type="password"
+          class="password-input"
+          placeholder="请输入访问密码"
+          @keyup.enter="verifyPassword"
+        >
+        <button class="btn btn-primary btn-lg" @click="verifyPassword">
+          验证密码
+        </button>
+        <p v-if="passwordError" class="error-message">{{ passwordError }}</p>
+      </div>
+
+      <!-- 分享内容 -->
+      <div v-else-if="shareData" class="share-content">
+        <!-- 左右分栏布局容器 -->
+        <div v-if="shareData.files && shareData.files.length > 0" class="content-split">
+          <!-- 左侧:文件下载区 -->
+          <div class="left-column">
+            <div class="files-content card">
+              <div class="content-header">
+                <h2>📁 分享的文件</h2>
+                <span class="file-count">{{ shareData.files.length }} 个文件</span>
+              </div>
+
+              <div class="files-list">
+                <div
+                  v-for="file in shareData.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="previewFile(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="downloadFile(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="downloadAll"
+                :disabled="downloadingAll"
+              >
+                {{ downloadingAll ? '⏳ 正在打包...' : '📦 打包下载所有文件' }}
+              </button>
+
+              <div class="content-info">
+                <p>创建时间:{{ formatTime(shareData.createdTime) }}</p>
+                <p>过期时间:{{ formatTime(shareData.expirationTime) }}</p>
+                <p>下载次数:{{ shareData.currentDownloadCount }}/{{ shareData.maxDownloadCount === -1 ? '无限制' : shareData.maxDownloadCount }}</p>
+              </div>
+
+              <!-- 删除资源按钮 -->
+              <div class="content-actions">
+                <button
+                  class="btn-delete"
+                  @click="confirmDelete"
+                  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>
+          </div>
+
+          <!-- 右侧:文本内容区 -->
+          <div v-if="shareData.textContent" class="right-column">
+            <div class="text-content card">
+              <div class="content-header">
+                <h2>📝 分享的文本</h2>
+                <div class="header-actions">
+                  <span class="content-type">{{ shareData.textType }}</span>
+                  <!-- Markdown切换按钮 -->
+                  <button
+                    v-if="shareData.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="shareData.textType === 'markdown' && !showMarkdownSource"
+                    :content="shareData.textContent"
+                    key="preview"
+                  />
+
+                  <!-- Markdown源码 -->
+                  <pre v-else-if="shareData.textType === 'markdown' && showMarkdownSource" key="source">{{ shareData.textContent }}</pre>
+
+                  <!-- 代码高亮 -->
+                  <CodeHighlight
+                    v-else-if="shareData.textType === 'code'"
+                    :code="shareData.textContent"
+                    key="code"
+                  />
+
+                  <!-- JSON格式化 -->
+                  <CodeHighlight
+                    v-else-if="shareData.textType === 'json'"
+                    :code="formatJson(shareData.textContent)"
+                    language="json"
+                    key="json"
+                  />
+
+                  <!-- 纯文本 -->
+                  <pre v-else key="plain">{{ shareData.textContent }}</pre>
+                </Transition>
+              </div>
+
+              <div class="content-info">
+                <p>创建时间:{{ formatTime(shareData.createdTime) }}</p>
+                <p>过期时间:{{ formatTime(shareData.expirationTime) }}</p>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- 单独显示文本内容(无文件时) -->
+        <div v-else-if="shareData.textContent" class="text-content card full-width">
+          <div class="content-header">
+            <h2>📝 分享的文本</h2>
+            <div class="header-actions">
+              <span class="content-type">{{ shareData.textType }}</span>
+              <!-- Markdown切换按钮 -->
+              <button
+                v-if="shareData.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="shareData.textType === 'markdown' && !showMarkdownSource"
+                :content="shareData.textContent"
+                key="preview"
+              />
+
+              <!-- Markdown源码 -->
+              <pre v-else-if="shareData.textType === 'markdown' && showMarkdownSource" key="source">{{ shareData.textContent }}</pre>
+
+              <!-- 代码高亮 -->
+              <CodeHighlight
+                v-else-if="shareData.textType === 'code'"
+                :code="shareData.textContent"
+                key="code"
+              />
+
+              <!-- JSON格式化 -->
+              <CodeHighlight
+                v-else-if="shareData.textType === 'json'"
+                :code="formatJson(shareData.textContent)"
+                language="json"
+                key="json"
+              />
+
+              <!-- 纯文本 -->
+              <pre v-else key="plain">{{ shareData.textContent }}</pre>
+            </Transition>
+          </div>
+
+          <div class="content-info">
+            <p>创建时间:{{ formatTime(shareData.createdTime) }}</p>
+            <p>过期时间:{{ formatTime(shareData.expirationTime) }}</p>
+            <p>下载次数:{{ shareData.currentDownloadCount }}/{{ shareData.maxDownloadCount === -1 ? '无限制' : shareData.maxDownloadCount }}</p>
+          </div>
+
+          <!-- 删除资源按钮 -->
+          <div class="content-actions">
+            <button
+              class="btn-delete"
+              @click="confirmDelete"
+              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>
+
+        <!-- 内容不存在或已过期 -->
+        <div v-else class="error-container card">
+          <h2>😔 内容不存在</h2>
+          <p>该分享内容可能已被删除或已过期</p>
+          <router-link to="/" class="btn btn-primary">返回首页</router-link>
+        </div>
+      </div>
+
+      <!-- 错误状态 -->
+      <div v-else-if="error" class="error-container card">
+        <h2>❌ 加载失败</h2>
+        <p>{{ error }}</p>
+        <router-link to="/" class="btn btn-primary">返回首页</router-link>
+      </div>
+    </div>
+
+    <!-- 浮动返回首页按钮 -->
+    <router-link to="/" class="floating-back-btn" title="返回首页">
+      返回首页
+    </router-link>
+
+    <!-- 删除确认对话框 -->
+    <div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
+      <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="shareData && shareData.requiresPassword" class="password-verify">
+            <label>请输入密码以确认删除:</label>
+            <input
+              v-model="deletePassword"
+              type="password"
+              class="password-input"
+              placeholder="输入访问密码"
+              @keyup.enter="executeDelete"
+            >
+            <p v-if="deleteError" class="error-message">{{ deleteError }}</p>
+          </div>
+        </div>
+        <div class="modal-footer">
+          <button class="btn btn-secondary" @click="showDeleteConfirm = false">
+            取消
+          </button>
+          <button
+            class="btn btn-danger"
+            @click="executeDelete"
+            :disabled="deleting"
+          >
+            <span v-if="deleting">删除中...</span>
+            <span v-else>确认删除</span>
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { getShareContent, verifyAndGetShareContent, downloadFile as downloadFileApi, deleteShare } from '@/api'
+import config from '@/config'
+import CodeHighlight from '@/components/CodeHighlight.vue'
+import MarkdownRenderer from '@/components/MarkdownRenderer.vue'
+import toast from '@/utils/toast'
+
+const route = useRoute()
+const router = useRouter()
+const id = route.params.id
+
+// 状态
+const loading = ref(true)
+const shareData = ref(null)
+const requiresPassword = ref(false)
+const passwordVerified = ref(false)
+const password = ref('')
+const passwordError = ref('')
+const error = ref('')
+const downloadingAll = ref(false)
+const showMarkdownSource = ref(false) // Markdown源码/预览切换状态
+const copyStatus = ref('idle') // 'idle' | 'copied'
+
+// 删除相关状态
+const showDeleteConfirm = ref(false)
+const deletePassword = ref('')
+const deleteError = ref('')
+const deleting = ref(false)
+
+// 生命周期
+onMounted(() => {
+  loadShareContent()
+})
+
+// 方法
+async function loadShareContent() {
+  loading.value = true
+  error.value = ''
+
+  try {
+    const response = await getShareContent(id)
+
+    if (response.requiresPassword) {
+      requiresPassword.value = true
+    } else {
+      shareData.value = response
+    }
+  } catch (err) {
+    error.value = err.message || '加载失败'
+  } finally {
+    loading.value = false
+  }
+}
+
+async function verifyPassword() {
+  passwordError.value = ''
+
+  if (!password.value) {
+    passwordError.value = '请输入密码'
+    return
+  }
+
+  loading.value = true
+
+  try {
+    const response = await verifyAndGetShareContent(id, password.value)
+    shareData.value = response
+    passwordVerified.value = true
+  } catch (err) {
+    passwordError.value = '密码错误'
+  } finally {
+    loading.value = false
+  }
+}
+
+function downloadFile(fileId, fileName) {
+  const url = downloadFileApi(id, fileId)
+  const link = document.createElement('a')
+  link.href = url
+  link.download = fileName
+  link.click()
+}
+
+// 判断文件是否可预览(图片或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 previewFile(fileId) {
+  const url = downloadFileApi(id, fileId)
+  window.open(url, '_blank', 'noopener,noreferrer')
+}
+
+async function downloadAll() {
+  if (downloadingAll.value) return
+
+  downloadingAll.value = true
+
+  try {
+    const url = `${config.baseURL}/api/share/${id}/download-all`
+    const link = document.createElement('a')
+    link.href = url
+    link.download = `${id}_files.zip`
+    document.body.appendChild(link)
+    link.click()
+    document.body.removeChild(link)
+  } catch (err) {
+    error.value = '打包下载失败:' + (err.message || '未知错误')
+  } finally {
+    downloadingAll.value = false
+  }
+}
+
+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')
+}
+
+function formatJson(jsonStr) {
+  try {
+    const obj = JSON.parse(jsonStr)
+    return JSON.stringify(obj, null, 2)
+  } catch (e) {
+    return jsonStr
+  }
+}
+
+// 复制文本内容到剪贴板
+async function copyTextContent() {
+  if (!shareData.value?.textContent) return
+
+  try {
+    await navigator.clipboard.writeText(shareData.value.textContent)
+    copyStatus.value = 'copied'
+    toast.success('内容已复制到剪贴板')
+    setTimeout(() => { copyStatus.value = 'idle' }, 2000)
+  } catch (err) {
+    // clipboard API 不可用时回退方案
+    const textarea = document.createElement('textarea')
+    textarea.value = shareData.value.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)
+  }
+}
+
+// 显示删除确认对话框
+function confirmDelete() {
+  showDeleteConfirm.value = true
+  deletePassword.value = ''
+  deleteError.value = ''
+}
+
+// 执行删除
+async function executeDelete() {
+  deleting.value = true
+  deleteError.value = ''
+
+  try {
+    // 如果有密码保护,先验证密码
+    if (shareData.value.requiresPassword) {
+      if (!deletePassword.value) {
+        deleteError.value = '请输入密码'
+        return
+      }
+
+      // 验证密码
+      try {
+        await verifyAndGetShareContent(id, deletePassword.value)
+      } catch (err) {
+        deleteError.value = '密码错误'
+        return
+      }
+    }
+
+    // 调用删除API
+    await deleteShare(id)
+
+    // 显示成功提示
+    toast.success('资源已成功删除')
+
+    // 跳转回首页
+    router.push('/')
+
+  } catch (err) {
+    deleteError.value = err.message || '删除失败,请稍后重试'
+  } finally {
+    deleting.value = false
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+@import '@/styles/variables.scss';
+
+.share-page {
+  min-height: 100vh;
+  padding: $spacing-2xl $spacing-lg;
+}
+
+.loading-container {
+  @extend .card !optional;
+  text-align: center;
+  padding: $spacing-2xl;
+
+  .spinner {
+    width: 60px;
+    height: 60px;
+    margin: 0 auto $spacing-lg;
+    border-width: 4px;
+
+    &.large {
+      width: 80px;
+      height: 80px;
+      border-width: 6px;
+    }
+  }
+
+  p {
+    color: $text-secondary;
+  }
+}
+
+.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;
+}
+
+.share-content {
+  max-width: 1400px; // 增加宽度以适应左右分栏
+  margin: 0 auto;
+
+  // 左右分栏时增加宽度
+  &.content-split {
+    display: flex;
+  }
+}
+
+.text-content,
+.files-content {
+  .content-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: $spacing-lg;
+
+    h2 {
+      color: $text-secondary;
+    }
+
+    .content-type,
+    .file-count {
+      font-size: $font-size-sm;
+      background: $bg-tertiary;
+      padding: $spacing-xs $spacing-sm;
+      border-radius: $radius-full;
+      color: $text-muted;
+    }
+  }
+}
+
+.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;
+  }
+}
+
+.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;
+  }
+}
+
+.error-container {
+  max-width: 500px;
+  margin: $spacing-2xl auto;
+  text-align: center;
+
+  h2 {
+    margin-bottom: $spacing-lg;
+    color: $error-color;
+  }
+
+  p {
+    color: $text-muted;
+    margin-bottom: $spacing-xl;
+  }
+}
+
+// ==================== 新增样式 ====================
+
+// 删除确认对话框
+.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;
+  }
+}
+
+// 左右分栏布局
+.content-split {
+  display: flex;
+  gap: $spacing-xl;
+  align-items: stretch;
+
+  @media (max-width: 968px) {
+    flex-direction: column;
+  }
+}
+
+.left-column,
+.right-column {
+  flex: 1;
+  min-width: 0; // 防止flex子项溢出
+}
+
+.full-width {
+  max-width: 100%;
+}
+
+// 头部操作区
+.header-actions {
+  display: flex;
+  gap: $spacing-md;
+  align-items: center;
+}
+
+// 复制文本按钮
+.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;
+}
+
+// 浮动返回按钮(文字样式 - 蓝色)
+.floating-back-btn {
+  position: fixed;
+  right: $spacing-xl;
+  bottom: $spacing-xl;
+  padding: $spacing-md $spacing-xl;
+  border-radius: $radius-lg;
+  background: #3b82f6; // 蓝色纯色
+  color: white;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: $font-size-base;
+  font-weight: 700; // 黑体
+  text-decoration: none;
+  box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4); // 蓝色阴影
+  z-index: 1030;
+  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+  cursor: pointer;
+  border: 1px solid rgba(255, 255, 255, 0.2);
+
+  &:hover {
+    transform: translateY(-2px);
+    box-shadow: 0 6px 24px rgba(59, 130, 246, 0.5); // 更亮的蓝色阴影
+    background: #2563eb; // hover时变深一点的蓝色
+    border-color: rgba(255, 255, 255, 0.3);
+  }
+
+  &:active {
+    transform: translateY(0);
+    box-shadow: 0 2px 12px rgba(59, 130, 246, 0.3);
+  }
+
+  // 移动端适配
+  @media (max-width: 768px) {
+    padding: $spacing-sm $spacing-lg;
+    font-size: $font-size-sm;
+    right: $spacing-lg;
+    bottom: $spacing-lg;
+  }
+}
+
+// ==================== 移动端优化 ====================
+@media (max-width: 768px) {
+  .share-page {
+    padding: $spacing-lg $spacing-md;
+  }
+
+  // 卡片通用优化
+  .card {
+    padding: $spacing-md;
+  }
+
+  // 内容头部优化
+  .text-content,
+  .files-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;
+      }
+    }
+  }
+
+  // 文件列表优化
+  .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;
+  }
+
+  // 文本显示区优化
+  .text-display {
+    max-height: 400px;
+    padding: $spacing-md;
+    font-size: $font-size-sm;
+  }
+
+  // 内容信息优化
+  .content-info {
+    p {
+      font-size: $font-size-xs;
+    }
+  }
+
+  // 错误容器优化
+  .error-container {
+    h2 {
+      font-size: $font-size-xl;
+    }
+
+    p {
+      font-size: $font-size-sm;
+    }
+  }
+
+  // 加载容器优化
+  .loading-container {
+    padding: $spacing-xl;
+
+    .spinner {
+      width: 50px;
+      height: 50px;
+      border-width: 4px;
+    }
+
+    p {
+      font-size: $font-size-sm;
+    }
+  }
+
+  // 密码容器优化
+  .password-container {
+    padding: $spacing-lg;
+    max-width: 100%;
+
+    h2 {
+      font-size: $font-size-xl;
+    }
+
+    .password-input {
+      font-size: $font-size-base;
+    }
+  }
+
+  // 左右分栏在移动端的额外优化
+  .content-split {
+    gap: $spacing-md;
+  }
+
+  .left-column,
+  .right-column {
+    min-width: 0;
+  }
+}
+
+// 超小屏幕优化(<480px)
+@media (max-width: 480px) {
+  .share-page {
+    padding: $spacing-md $spacing-sm;
+  }
+
+  .file-actions {
+    .btn-action {
+      padding: $spacing-xs;
+      min-width: 36px; // 确保按钮可点击区域足够
+      justify-content: center;
+
+      span {
+        display: none; // 只显示图标
+      }
+    }
+  }
+
+  .text-display {
+    font-size: $font-size-xs;
+  }
+
+  .content-header h2 {
+    font-size: $font-size-base;
+  }
+}
+</style>

+ 103 - 0
frontend/stop.sh

@@ -0,0 +1,103 @@
+#!/bin/bash
+
+##############################################
+# Snapshot 前端停止服务脚本
+# 适用于 Linux 环境
+##############################################
+
+# 颜色输出
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+print_info() {
+    echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+print_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+print_warning() {
+    echo -e "${YELLOW}[WARNING]${NC} $1"
+}
+
+echo "============================================"
+echo "Snapshot 前端停止服务"
+echo "============================================"
+echo
+
+# 查找npm/node相关进程
+PIDS=$(pgrep -f "vue-cli-service serve")
+if [ -z "$PIDS" ]; then
+    PIDS=$(pgrep -f "npm run serve")
+fi
+
+if [ -z "$PIDS" ]; then
+    PIDS=$(pgrep -f "node.*8081")
+fi
+
+if [ -z "$PIDS" ]; then
+    print_warning "未找到运行中的Snapshot前端服务"
+    exit 0
+fi
+
+print_info "找到运行中的服务进程:"
+echo "$PIDS"
+echo
+
+# 尝试优雅停止
+print_info "正在停止服务..."
+
+for PID in $PIDS; do
+    if ps -p $PID > /dev/null; then
+        kill $PID
+        print_info "已发送停止信号到进程: $PID"
+    fi
+done
+
+# 等待进程结束
+sleep 3
+
+# 检查是否还有进程运行
+REMAINING_PIDS=""
+if pgrep -f "vue-cli-service serve" > /dev/null; then
+    REMAINING_PIDS=$(pgrep -f "vue-cli-service serve")
+elif pgrep -f "npm run serve" > /dev/null; then
+    REMAINING_PIDS=$(pgrep -f "npm run serve")
+elif pgrep -f "node.*8081" > /dev/null; then
+    REMAINING_PIDS=$(pgrep -f "node.*8081")
+fi
+
+if [ -n "$REMAINING_PIDS" ]; then
+    print_warning "服务未响应,强制停止..."
+    for PID in $REMAINING_PIDS; do
+        if ps -p $PID > /dev/null; then
+            kill -9 $PID
+            print_info "已强制停止进程: $PID"
+        fi
+    done
+    sleep 1
+fi
+
+# 最终检查
+FINAL_CHECK=""
+if pgrep -f "vue-cli-service serve" > /dev/null; then
+    FINAL_CHECK="running"
+elif pgrep -f "npm run serve" > /dev/null; then
+    FINAL_CHECK="running"
+elif pgrep -f "node.*8081" > /dev/null; then
+    FINAL_CHECK="running"
+fi
+
+if [ "$FINAL_CHECK" = "running" ]; then
+    print_error "停止失败,请手动检查"
+    echo "尝试手动执行: pkill -f 'vue-cli-service serve'"
+    exit 1
+else
+    print_info "服务已成功停止"
+fi
+
+echo
+print_info "Snapshot 前端服务已停止"

+ 26 - 0
frontend/vue.config.js

@@ -0,0 +1,26 @@
+const { defineConfig } = require('@vue/cli-service')
+
+module.exports = defineConfig({
+  transpileDependencies: true,
+  lintOnSave: false,
+
+  // 公共路径配置为绝对路径,确保从根路径加载静态资源
+  publicPath: '/',
+
+  // 输出目录
+  outputDir: 'dist',
+
+  // 静态资源目录
+  assetsDir: 'static',
+
+  devServer: {
+    port: 7627,
+    // 开发环境代理后端API
+    proxy: {
+      '/api': {
+        target: 'http://localhost:7626',
+        changeOrigin: true
+      }
+    }
+  }
+})

+ 239 - 0
snapshot-prompts.txt

@@ -0,0 +1,239 @@
+我正在使用Claude Code。请帮我在F:\projects/下创建一个项目,项目名为snapshot,作用是提供一个临时的文件、文本等的中转服务。其中区分两个文件夹,一个前端项目,使用Vue3编写,一个后端项目,使用Java1.8.0+Spring Boot 2编写。前后端项目由你自己命名。
+
+我对项目的需求如下:
+
+1. 前端页面标题"Snapshot:致力于做最好用的临时文件分享平台"位于最上方居中,下方为一段描述,为使用者描绘使用场景,激发使用者使用的兴趣。再下方,左右分栏,左侧为临时文件上传区域,可上传文件,每个文件不大于50MB,文件列表选择后也可以删除调整和继续上传,列表中最多包含10个文件;右侧为文本输入区域,可以粘贴文本。下方有一个可选的密码设置输入框,再下方有一个统一的"上传"按钮,用于将上面的文件和文本上传至服务器,点击后弹出临时文件有效期选择按钮,可选择1小时、2小时、3小时、6小时、12小时、24小时,也可自定义小时数;同时可以设置文件被打包下载多少次后自动删除文件。再下方是一个上传内容ID输入框,通过输入ID,点击"查看"按钮,可查看自己或他人分享的临时文件和文本,其中文件可以打包下载,也可单个文件下载。
+
+2. 上传完成后,系统自动生成一个分享链接,并显示在页面上,链接格式为"http://ip:port/s/上传内容ID",ip、port可在前端项目的config.js中配置。用户可将链接分享给他人。
+
+3. 在上传内容ID输入框中,输入ID,点击"查看"按钮,也可跳转至对应内容的分享链接。此处为方便用户查阅,若输入框中输入内容只包含ID的前几位,但已经可以通过字典树唯一确定一个上传内容,则也可成功跳转至对应分享链接。
+
+4. 上传过程无需等待,其他用户可在上传者上传完成之前就开始下载;
+
+5. 前端页面背景做成酷炫的银河星空背景,整个页面美观大方;
+
+6. **整个项目需要在Windows项目部署**,我要求部署简单,包含内容简单的前端和后端部署脚本,**不使用docker部署**;
+
+7. **使用Spring boot 2和Java 1.8语法进行后端开发**;
+
+8. 使用MySQL作为数据库,数据库名为snapshot,访问地址为localhost:3306,登录名root,密码"asdqwe123!";**生成名为create.sql的建表语句文件,**在后端服务启动时,若无数据表,则创建,否则不做操作**;
+
+9. 保证整个项目的安全性,杜绝通过网页和接口入侵服务器的漏洞;
+
+10. **在前后端项目编码之前,务必在api目录下生成供前后端交互的RESTful API接口文档,并由我确认**。
+
+
+# Snapshot项目开发任务说明书
+
+<project_overview>
+本项目旨在创建一个名为"Snapshot"的临时文件分享平台,包含Vue3前端和Spring Boot 2后端。项目位于F:\projects\snapshot目录,提供安全的临时文件、文本中转服务。
+</project_overview>
+
+<technical_requirements>
+1. 前端技术栈:Vue3 + JavaScript/TypeScript
+2. 后端技术栈:Spring Boot 2 + Java 1.8
+3. 数据库:MySQL 8.0+,数据库名snapshot,连接地址localhost:3306,用户名root,密码asdqwe123!
+4. 部署环境:Windows系统,不使用Docker
+5. 文件限制:单个文件≤50MB,最多10个文件
+</technical_requirements>
+
+<project_structure>
+F:\projects\snapshot/
+├── frontend/                 # Vue3前端项目 (命名为snapshot-frontend)
+│   ├── public/
+│   ├── src/
+│   │   ├── assets/          # 静态资源
+│   │   ├── components/      # 可复用组件
+│   │   ├── views/           # 页面视图
+│   │   ├── router/          # 路由配置
+│   │   ├── utils/           # 工具函数
+│   │   ├── config.js        # 配置文件(IP、端口等)
+│   │   └── main.js          # 入口文件
+│   ├── package.json
+│   └── deploy.bat           # 前端部署脚本
+├── backend/                  # Spring Boot后端项目 (命名为snapshot-backend)
+│   ├── src/main/java/
+│   │   └── com/snapshot/
+│   │       ├── Application.java           # 启动类
+│   │       ├── controller/                # 控制器层
+│   │       ├── service/                   # 业务逻辑层
+│   │       ├── repository/                # 数据访问层
+│   │       ├── entity/                    # 实体类
+│   │       ├── dto/                       # 数据传输对象
+│   │       ├── config/                    # 配置类
+│   │       └── util/                      # 工具类
+│   ├── src/main/resources/
+│   │   ├── application.properties         # 应用配置
+│   │   └── create.sql                     # 数据库初始化脚本
+│   └── deploy.bat                         # 后端部署脚本
+└── README.md                              # 项目说明文档
+</project_structure>
+
+<frontend_detailed_requirements>
+
+## 页面布局与样式要求
+<layout>
+1. 顶部标题:居中显示"Snapshot:致力于做最好用的临时文件分享平台",使用大号字体加粗
+2. 描述区域:标题下方添加吸引人的使用场景描述,激发使用兴趣
+3. 主要内容区:左右分栏布局,左侧文件上传区,右侧文本输入区
+4. 底部功能区:密码设置、上传按钮、ID查询区域
+5. 背景效果:银河星空动态背景,使用深色主题配合星星、星系动画效果
+</layout>
+
+## 文件上传区域功能
+<file_upload_section>
+1. 拖拽上传:支持文件拖拽和点击选择两种方式
+2. 文件列表:实时显示已选文件列表,包含文件名、大小、上传进度
+3. 文件管理:支持删除单个文件、清空全部、重新选择
+4. 限制验证:文件大小限制(50MB)、数量限制(10个)、类型提示
+5. 实时预览:图片文件支持缩略图预览
+</file_upload_section>
+
+## 文本输入区域功能
+<text_input_section>
+1. 大文本区域:支持多行文本输入,带行号显示
+2. 语法高亮:支持常见编程语言语法高亮
+3. 字数统计:实时显示文本长度
+4. 自动保存:防止意外丢失输入内容
+</text_input_section>
+
+## 上传配置选项
+<upload_options>
+1. 密码保护:可选密码输入框,支持显示/隐藏密码
+2. 有效期选择:下拉选择(1/2/3/6/12/24小时) + 自定义小时数输入
+3. 下载次数限制:设置最大下载次数(1-100次)
+4. 上传按钮:醒目设计,点击后弹出配置对话框
+</upload_options>
+
+## 内容查看与下载功能
+<content_viewer>
+1. ID输入查询:支持完整ID或前缀模糊匹配(字典树优化)
+2. 内容展示:文件列表+文本内容分别展示
+3. 下载选项:打包下载所有文件或单个文件下载
+4. 分享链接:自动生成格式为"http://ip:port/s/ID"的链接,一键复制
+</content_viewer>
+
+## 交互体验优化
+<ux_requirements>
+1. 实时反馈:上传进度条、操作成功/失败提示
+2. 响应式设计:适配不同屏幕尺寸
+3. 加载状态:异步操作时的加载动画
+4. 错误处理:友好的错误提示和恢复指导
+</ux_requirements>
+</frontend_detailed_requirements>
+
+<backend_detailed_requirements>
+
+## 数据库设计
+<database_schema>
+1. 创建snapshot数据库,字符集utf8mb4
+2. 主要数据表:
+   - upload_records: 上传记录表(主键ID、密码哈希、创建时间、过期时间、最大下载次数、当前下载次数)
+   - file_entries: 文件记录表(文件ID、上传记录ID、文件名、存储路径、文件大小、MIME类型)
+   - text_contents: 文本内容表(上传记录ID、文本内容、文本类型)
+3. 索引优化:为ID前缀查询创建合适索引
+</database_schema>
+
+## API接口设计
+<api_endpoints>
+### 文件上传相关
+1. POST /api/upload - 创建上传记录
+2. POST /api/upload/{id}/files - 分片上传文件
+3. GET /api/upload/{id} - 获取上传记录详情
+4. DELETE /api/upload/{id} - 删除上传记录(管理员功能)
+
+### 内容访问相关
+1. GET /s/{id} - 分享链接访问入口(前端路由)
+2. GET /api/content/{id} - 获取分享内容
+3. GET /api/download/{fileId} - 下载单个文件
+4. GET /api/download/bundle/{id} - 打包下载所有文件
+</api_endpoints>
+
+## 核心业务逻辑
+<business_logic>
+1. 文件分片上传:支持大文件断点续传
+2. 过期清理机制:定时任务清理过期文件
+3. 下载次数统计:达到限制后自动删除
+4. ID生成策略:短ID生成,支持前缀匹配
+5. 存储优化:文件分目录存储,防止单目录文件过多
+</business_logic>
+
+## 安全防护措施
+<security_measures>
+1. 文件类型校验:防止恶意文件上传
+2. 大小限制验证:防止服务器资源耗尽
+3. 频率限制:API调用频率限制
+4. XSS防护:输入内容转义处理
+5. SQL注入防护:使用参数化查询
+6. 路径遍历防护:文件名安全处理
+</security_measures>
+</backend_detailed_requirements>
+
+<deployment_requirements>
+
+## 前端部署脚本(deploy.bat)
+<frontend_deployment>
+1. 环境检查:验证Node.js和npm版本
+2. 依赖安装:npm install安装依赖
+3. 构建项目:npm run build生成生产版本
+4. 配置服务:使用nginx或简单HTTP服务器
+5. 启动服务:指定端口和IP地址
+</frontend_deployment>
+
+## 后端部署脚本(deploy.bat)
+<backend_deployment>
+1. 环境检查:Java 1.8验证
+2. 数据库初始化:自动执行create.sql
+3. 应用打包:Maven打包为可执行JAR
+4. 服务启动:后台运行Spring Boot应用
+5. 健康检查:验证服务正常启动
+</backend_deployment>
+
+## 配置文件示例
+<configuration_files>
+### 前端config.js
+```javascript
+const config = {
+  baseURL: 'http://localhost:7626',
+  uploadLimit: 50 * 1024 * 1024, // 50MB
+  maxFiles: 10,
+  expirationOptions: [1, 2, 3, 6, 12, 24],
+  // 可配置的服务器地址
+  server: {
+    ip: '127.0.0.1',
+    port: '7626'
+  }
+};
+```
+### 后端application.properties
+```
+server.port=7626
+spring.datasource.url=jdbc:
+  spring.datasource.username=root
+  spring.datasource.password=asdqwe123!
+  spring.servlet.multipart.max-file-size=50MB
+  spring.servlet.multipart.max-request-size=50MB
+```
+</configuration_files>
+</deployment_requirements>
+
+<implementation_priority>
+## 开发优先级建议
+1. **第一阶段**:搭建项目基础结构,创建数据库表
+2. **第二阶段**:实现后端核心API和文件处理逻辑
+3. **第三阶段**:开发前端基础界面和组件
+4. **第四阶段**:实现前后端联调和交互功能
+5. **第五阶段**:添加高级功能(前缀匹配、银河背景等)
+6. **第六阶段**:安全加固和部署脚本编写
+</implementation_priority>
+
+<claude_specific_instructions>
+## 给Claude Code的特别指示
+1. **代码风格**:使用清晰的命名规范,添加必要的注释
+2. **错误处理**:全面的异常捕获和日志记录
+3. **性能优化**:文件上传下载使用流式处理,避免内存溢出
+4. **代码复用**:提取公共组件和工具函数
+5. **文档完整**:为每个主要函数和组件添加文档注释
+
+请按照以上结构化要求,分别生成前端和后端的完整代码文件,确保项目可以正常运行和部署。
+</claude_specific_instructions>