commit 2a67f5651293a593ae39f82e8e6a4fd16385c942 Author: Nelson <1475262689@qq.com> Date: Sat Apr 18 15:08:57 2026 +0800 init diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5a44afb Binary files /dev/null and b/.DS_Store differ diff --git a/NAS图片上传系统_开发文档.md b/NAS图片上传系统_开发文档.md new file mode 100644 index 0000000..40e5182 --- /dev/null +++ b/NAS图片上传系统_开发文档.md @@ -0,0 +1,382 @@ +# NAS图片上传系统 - 开发文档 + +> **版本**:v0.3 +> **日期**:2026-04-17 +> **状态**:需求已确认,待开发 + +--- + +## 1. 项目概述 + +### 1.1 项目背景 + +需要搭建一个简单的Web应用,允许用户通过手机或电脑访问网站,将图片上传到本地NAS存储中。网站部署在公网实例上,通过内网穿透转发请求到本地NAS。 + +### 1.2 核心需求 + +- 用户通过手机/电脑浏览器访问网站,选择目标文件夹后上传图片 +- 支持多选图片上传,带上传进度条显示 +- 上传完成后显示成功上传的图片列表,方便用户确认是否全部上传成功 +- 文件夹结构:训练营N期 → 25个班级文件夹,用户只能选择不能创建 +- 文件夹自动创建:首次上传时若目标文件夹不存在则自动创建 +- 每个训练营文件夹下有一个MD文档,统计图片数量 +- 无需登录,公开使用(后续考虑加密码保护) +- 仅上传功能,不提供图片浏览/下载 +- 图片主要为屏幕截图 + +### 1.3 网络架构 + +公网实例通过内网穿透(frp)将请求转发到本地NAS上的服务。服务端直接运行在群晖NAS上。 + +``` +用户手机/电脑 → 公网实例(frp) → 内网穿透 → NAS服务端 +``` + +--- + +## 2. 技术方案 + +### 2.1 技术栈 + +| 层次 | 技术选型 | 说明 | +|------|----------|------| +| 后端框架 | Python + Flask | 轻量、开发快、文件操作方便,NAS环境通常支持Python | +| 前端 | HTML + CSS + JavaScript | 纯静态页面,无需前端构建工具,部署简单 | +| 图片上传 | XMLHttpRequest | 原生支持上传进度事件 | +| 文件存储 | NAS本地文件系统 | 直接读写NAS磁盘目录 | +| 内网穿透 | frp | 将公网请求转发到内网服务 | +| 配置管理 | .env 文件 | 部署时填写NAS路径、端口等配置 | + +--- + +## 3. 系统架构 + +### 3.1 整体架构 + +``` +┬── 用户手机/电脑(浏览器) +│ +├── 公网实例(frp server) +│ +├── frp client → 内网穿透 +│ +└── 群晖 NAS(Flask + 静态文件) + ├── /api/folders 获取文件夹列表 + ├── /api/upload 上传图片 + ├── /static/ 前端静态资源 + └── {NAS_DATA_PATH}/ NAS存储目录(.env配置) +``` + +### 3.2 NAS目录结构 + +``` +{NAS_DATA_PATH}/ ← 通过 .env 配置 +├── 训练营13期/ +│ ├── 统计.md ← 图片数量统计文档 +│ ├── 1班/ +│ ├── 2班/ +│ ├── ... +│ └── 25班/ +├── 训练营14期/ +│ ├── 统计.md +│ ├── 1班/ +│ ├── ... +│ └── 25班/ +└── ... +``` + +### 3.3 服务端项目结构 + +``` +nas-uploader/ +├── app.py ← Flask主应用 +├── config.py ← 读取 .env 配置 +├── .env ← 部署配置(NAS路径、端口等) +├── .env.example ← 配置示例文件 +├── requirements.txt ← Python依赖 +├── static/ +│ ├── css/style.css ← 样式文件 +│ ├── js/app.js ← 前端逻辑 +│ └── img/ ← 图标等静态资源 +├── templates/ +│ └── index.html ← 主页面 +└── start.sh ← 启动脚本 +``` + +### 3.4 .env 配置文件 + +```env +# NAS图片存储根目录(部署时修改为实际路径) +NAS_DATA_PATH=/volume1/nas_data + +# 服务端口 +PORT=5000 + +# 允许的图片格式(小写,逗号分隔) +ALLOWED_EXTENSIONS=jpg,jpeg,png,gif,webp + +# 单次上传最大文件数量 +MAX_FILES_COUNT=100 + +# 单张图片最大大小(MB) +MAX_FILE_SIZE_MB=20 +``` + +--- + +## 4. 功能设计 + +### 4.1 文件夹选择功能 + +#### 4.1.1 文件夹获取规则 + +- 后端扫描NAS根目录(`NAS_DATA_PATH`),读取所有"训练营*期"格式的文件夹 +- 每个训练营文件夹下,读取所有"*班"格式的子文件夹 +- 前端以两级联动选择器展示:第一级选训练营,第二级选班级 +- 文件夹列表按名称排序(数字自然排序) +- **不显示**各班级已有图片数量 + +#### 4.1.2 文件夹自动创建 + +- 当用户选择某个班级并上传图片时,若对应的文件夹不存在,则自动创建 +- 创建逻辑:检查训练营文件夹 → 检查班级文件夹 → 不存在则创建 +- 同时创建对应的统计.md文档(若训练营文件夹是新建的) +- **新的"训练营N期"文件夹由管理员在NAS上手动创建**,用户不能通过网页创建新训练营 + +### 4.2 图片上传功能 + +#### 4.2.1 上传流程 + +``` +用户选择文件夹 → 点击"选择图片"按钮 → 多选图片 → 点击"开始上传" +``` + +- 支持的图片格式:JPG、JPEG、PNG、GIF、WEBP(不支持HEIC) +- 单次上传数量限制:最多100张 +- 单张大小限制:20MB +- 采用逐张上传策略,每张图片独立显示进度,同时显示总体进度 + +#### 4.2.2 图片命名规则 + +上传后图片按以下格式重命名: + +``` +训练营{n}期-{x}班-{时间戳}-{随机数}.{扩展名} +``` + +示例: +``` +训练营13期-1班-20260417143022-a3f8b2.png +训练营13期-1班-20260417143022-7c2e91.jpg +``` + +- `{n}`:训练营期数 +- `{x}`:班级编号 +- `{时间戳}`:精确到秒,格式 `YYYYMMDDHHmmss` +- `{随机数}`:6位十六进制随机字符串 +- `{扩展名}`:保留原始文件的扩展名 +- 若文件名已存在(极低概率),自动在末尾追加序号:`-a3f8b2-1.png` + +#### 4.2.3 上传进度显示 + +- 总体进度条:显示已上传完成的数量 / 总数量 +- 单张进度条:每张图片的上传百分比 +- 状态标识:等待中 / 上传中 / 成功 / 失败 + +#### 4.2.4 上传完成反馈 + +- 上传成功后,页面底部显示"已成功上传"图片缩略图列表 +- 每张图片显示:缩略图 + 新文件名 + 上传状态 +- 失败的图片明确标红,显示失败原因,支持"重试"操作 +- 显示摘要信息:"成功上传15张,失败 1张" + +### 4.3 图片统计功能 + +#### 4.3.1 统计.md格式 + +每个训练营文件夹下的统计.md内容格式如下: + +```markdown +# 训练营XX期 图片统计 + +更新时间:2026-04-17 14:30:00 +图片总数:156 + +| 班级 | 图片数量 | +|------|----------| +| 1班 | 12 | +| 2班 | 8 | +| ... | ... | +| 25班 | 6 | +``` + +#### 4.3.2 统计更新触发 + +- 每次上传成功后,自动更新对应训练营的统计.md +- 统计方式:扫描训练营文件夹及其所有子目录,统计图片文件数量 +- 图片文件判定:根据文件扩展名(jpg、jpeg、png、gif、webp) +- 更新时间记录在文档头部 + +--- + +## 5. API设计 + +### 5.1 获取文件夹列表 + +**GET /api/folders** + +返回所有训练营及其下的班级文件夹。 + +**响应示例:** + +```json +{ + "folders": [ + { + "name": "训练营13期", + "classes": ["1班", "2班", "3班", "4班", "5班", "...", "25班"] + }, + { + "name": "训练营14期", + "classes": ["1班", "3班"] + } + ] +} +``` + +### 5.2 上传图片 + +**POST /api/upload** + +接受multipart/form-data格式的图片上传。 + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| folder | string | 是 | 训练营名称,如"训练营13期" | +| class | string | 是 | 班级名称,如"1班" | +| files | File[] | 是 | 图片文件数组,支持多文件 | + +**响应示例:** + +```json +{ + "success": true, + "uploaded": [ + { "filename": "训练营13期-1班-20260417143022-a3f8b2.png", "status": "success" }, + { "filename": "训练营13期-1班-20260417143023-7c2e91.jpg", "status": "success" } + ], + "failed": [ + { "filename": "photo3.bmp", "status": "failed", "reason": "文件格式不支持" } + ] +} +``` + +--- + +## 6. 前端页面设计 + +### 6.1 页面布局 + +页面采用简洁的单页设计,适配手机和电脑访问(响应式布局)。 + +页面从上到下分为三个区域: + +#### 6.1.1 顶部:文件夹选择区 + +- 第一行:下拉选择"训练营"(如"训练营13期") +- 第二行:下拉选择"班级"(如"1班") +- 班级列表根据选中的训练营动态加载 + +#### 6.1.2 中部:图片选择和上传区 + +- "选择图片"按钮:触发文件选择器,支持多选 +- 已选择的图片列表:显示缩略图、文件名、可删除 +- "开始上传"按钮:触发上传操作 +- 进度条:总体进度 + 单张进度 + +#### 6.1.3 底部:上传结果区 + +- 显示成功上传的图片缩略图网格 +- 失败的图片显示红色标记和"重试"按钮 +- 显示摘要信息:"成功上传15张,失败 1张" + +### 6.2 移动端适配 + +- 采用响应式设计,自动适配手机和电脑屏幕 +- 手机端优化:按钮大小适中触摸操作,图片缩略图网格自动调整列数 +- 支持手机原生图片选择器(accept="image/*") + +--- + +## 7. 部署方案 + +### 7.1 frp内网穿透配置 + +详见 [frp内网穿透_原理与部署指南.md](./frp内网穿透_原理与部署指南.md) + +**frpc.toml(NAS侧)关键配置:** + +```toml +serverAddr = "你的公网IP" +serverPort = 7000 +auth.token = "你的认证密码" + +[[proxies]] +name = "nas-uploader" +type = "tcp" +localIP = "127.0.0.1" +localPort = 5000 +remotePort = 8080 +``` + +### 7.2 服务启动 + +```bash +#!/bin/bash +# start.sh +cd /path/to/nas-uploader +source venv/bin/activate # 可选:使用虚拟环境 +python app.py +``` + +### 7.3 部署步骤 + +1. 将项目文件上传到群晖NAS +2. 复制 `.env.example` 为 `.env`,修改 `NAS_DATA_PATH` 为实际的共享文件夹路径 +3. 安装Python依赖:`pip install -r requirements.txt` +4. 启动服务:`bash start.sh` +5. 配置frpc客户端连接到公网服务器 +6. 浏览器访问 `http://公网IP:8080` 验证 + +--- + +## 8. 开发计划 + +| 阶段 | 内容 | 预估工时 | +|------|------|----------| +| 第一阶段 | 后端基础:Flask服务 + .env配置 + API + 文件操作 | 2-3小时 | +| 第二阶段 | 前端页面:文件夹选择 + 图片上传 + 进度条 + 结果展示 | 2-3小时 | +| 第三阶段 | 统计功能:自动更新统计.md | 1小时 | +| 第四阶段 | 优化:移动端适配 + 异常处理 + 测试 | 1-2小时 | +| 第五阶段 | 部署:frp配置 + 群晖NAS部署 + 服务上线 | 0.5小时 | + +--- + +## 9. 风险与注意事项 + +- **并发上传**:多用户同时上传到同一文件夹时,文件名含时间戳+随机数,冲突概率极低;若冲突则自动追加序号 +- **存储空间**:需关注NAS磁盘空间,建议在上传前检查剩余空间 +- **网络稳定性**:内网穿透可能因网络波动导致上传中断,需支持失败重试 +- **安全性**:当前公开访问无需登录,后续考虑加密码保护;后端必须验证文件类型防止恶意上传 +- **文件类型验证**:后端严格校验文件扩展名和MIME类型,防止上传非图片文件 + +--- + +## 10. 后续规划(暂不实现) + +- 访问密码保护 +- HTTPS 加密传输 +- 图片浏览/下载功能 +- 管理后台 diff --git a/frp内网穿透_原理与部署指南.md b/frp内网穿透_原理与部署指南.md new file mode 100644 index 0000000..9d202e0 --- /dev/null +++ b/frp内网穿透_原理与部署指南.md @@ -0,0 +1,525 @@ +# frp 内网穿透 - 原理与部署指南 + +> **适用对象**:没有技术背景的人员 +> **阅读时间**:约 15 分钟 +> **最后更新**:2026-04-17 + +--- + +## 1. 什么是 frp?为什么需要它? + +### 1.1 一句话解释 + +**frp 就是一座"桥"**,让外面的人可以通过公网访问到你家里(或公司内网)的设备。 + +### 1.2 生活类比 + +想象一下这个场景: + +``` +你家有一台 NAS(网络存储器),里面存了很多照片。 +你想让朋友们通过互联网访问你的 NAS 上传照片。 + +但是问题来了: + - 你家的 NAS 没有"公网身份证"(公网IP),外面的人找不到它 + - 就像你家住在一个封闭小区里,快递员进不来 + +怎么办? + - 你在小区门口找了一个"门卫"(frp 服务端),他站在公网上 + - 你在家里装了一个"对讲机"(frp 客户端),和门卫保持通话 + - 朋友来找你时,先到门卫那里,门卫通过对讲机通知你,然后帮你把东西转达过去 + +这就是 frp 的工作原理! +``` + +### 1.3 专业术语对照表 + +| 你会看到的术语 | 大白话解释 | +|---------------|-----------| +| 公网 IP | 你在互联网上的"门牌号",全球唯一,任何人都能找到 | +| 内网 IP | 你家内部的"房间号",只有家里的人能找到 | +| 内网穿透 | 让外面的人能绕过小区门禁,找到你家里的设备 | +| frps(frp server) | frp 服务端,装在公网服务器上,相当于"门卫" | +| frpc(frp client) | frp 客户端,装在你家里的 NAS 上,相当于"对讲机" | +| 端口(Port) | 设备上的"窗口编号",比如 80 窗口用来传网页,22 窗口用来远程控制 | +| 反向代理 | 代替别人收快递,然后转交给你 | + +--- + +## 2. frp 是怎么工作的? + +### 2.1 整体流程图 + +``` + 互联网(公网) + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + 用户手机/电脑 公网服务器(frps) 其他网站 + "我要上传照片" "门卫"在这里等着 (互不干扰) + │ │ + │ ① 请求到达公网服务器 │ + │ ──────────────────────→ │ + │ │ + │ ② 服务器转发给家里的NAS │ + │ │ ──────────────────────→ + │ │ │ + │ 你家的 NAS(frpc) │ + │ "对讲机"收到消息 │ + │ 处理上传请求 │ + │ │ + │ ③ NAS 处理完,原路返回 │ + │ ←────────────────────── │ ←────────────────────── + │ │ │ + 收到"上传成功!" │ │ +``` + +### 2.2 用大白话描述整个过程 + +1. **你买了一台公网服务器**(比如阿里云、腾讯云的云服务器),它有一个公网 IP,全世界都能访问到它 +2. **在公网服务器上安装 frps**(服务端),它就像一个24小时值班的门卫 +3. **在你家里的 NAS 上安装 frpc**(客户端),它主动连接到公网服务器,说:"嘿,我是某某某家的 NAS,如果有人找我,请转接给我" +4. **当用户访问你的网站时**,请求先到达公网服务器,服务器再通过已经建立好的连接,把请求转发到你家里的 NAS +5. **NAS 处理完后**,结果原路返回给用户 + +> **关键点**:是 NAS 主动"打电话"给公网服务器的,所以不需要你家有公网 IP。只要你的 NAS 能上网,就能用 frp。 + +--- + +## 3. 你需要准备什么? + +### 3.1 硬件/服务清单 + +| 需要的东西 | 说明 | 大概费用 | +|-----------|------|---------| +| 一台公网服务器 | 阿里云/腾讯云等云服务器,最便宜的就行 | 约 50-100 元/月 | +| NAS 设备 | 你已有的 NAS(群晖、威联通等) | 已有 | +| 家里的网络 | NAS 需要能正常上网 | 已有 | + +### 3.2 需要提前获取的信息 + +在开始安装之前,请准备好以下信息: + +``` +□ 公网服务器的 IP 地址(例如:123.45.67.89) +□ 公网服务器的登录密码 +□ NAS 的登录密码(或 SSH 权限) +□ NAS 在家里的内网 IP(例如:192.168.1.100) +□ 你想给网站分配的端口号(建议用 8080) +``` + +> **怎么看 NAS 的内网 IP?** +> 登录 NAS 的管理界面,一般在"网络设置"或"控制面板 → 网络"里能看到,通常格式是 `192.168.x.x` + +--- + +## 4. 安装步骤 + +### 4.1 第一步:在公网服务器上安装 frps(服务端) + +> 这一步通常由技术人员或有服务器经验的人操作。如果你是租的云服务器,可以请服务商帮忙。 + +#### 4.1.1 下载 frp + +登录到你的公网服务器,执行以下命令: + +```bash +# 下载 frp(以 Linux 64位 为例) +wget https://github.com/fatedier/frp/releases/download/v0.61.1/frp_0.61.1_linux_amd64.tar.gz + +# 解压 +tar -xzf frp_0.61.1_linux_amd64.tar.gz + +# 进入目录 +cd frp_0.61.1_linux_amd64 +``` + +#### 4.1.2 创建配置文件 + +用文本编辑器创建一个配置文件 `frps.toml`: + +```bash +nano frps.toml +``` + +写入以下内容(直接复制粘贴即可): + +```toml +# frp 服务端配置文件 + +# 绑定端口 - frpc 客户端通过这个端口连接到服务端 +bindPort = 7000 + +# 认证令牌 - 相当于暗号,客户端和服务端必须一致才能连接 +# 请把下面的 your_secret_token 改成一个只有你知道的密码! +auth.token = "your_secret_token" + +# frp 管理面板(可选,方便查看运行状态) +webServer.addr = "0.0.0.0" +webServer.port = 7500 +webServer.user = "admin" +webServer.password = "admin123" +``` + +> ⚠️ **重要**:请把 `your_secret_token` 改成一个强密码!这个密码用于验证客户端身份,防止别人盗用你的服务器。 + +#### 4.1.3 启动 frps + +```bash +# 启动服务端 +./frps -c frps.toml +``` + +如果看到类似 `frps started successfully` 的提示,说明启动成功了! + +#### 4.1.4 设置开机自启(推荐) + +让 frps 在服务器重启后自动运行: + +```bash +# 创建系统服务 +sudo nano /etc/systemd/system/frps.service +``` + +写入以下内容: + +```ini +[Unit] +Description=frp server +After=network.target + +[Service] +Type=simple +ExecStart=/root/frp_0.61.1_linux_amd64/frps -c /root/frp_0.61.1_linux_amd64/frps.toml +Restart=always +RestartSec=5s + +[Install] +WantedBy=multi-user.target +``` + +> ⚠️ 注意:把路径替换成你实际的 frp 安装路径。 + +```bash +# 启用并启动服务 +sudo systemctl enable frps +sudo systemctl start frps + +# 查看运行状态 +sudo systemctl status frps +``` + +--- + +### 4.2 第二步:在 NAS 上安装 frpc(客户端) + +> 这一步在 NAS 上操作。不同品牌的 NAS 操作方式略有不同,下面以通用方式说明。 + +#### 4.2.1 下载 frp + +SSH 登录到你的 NAS,执行: + +```bash +# 如果是 x86 架构的 NAS(大多数群晖/威联通是这种) +wget https://github.com/fatedier/frp/releases/download/v0.61.1/frp_0.61.1_linux_amd64.tar.gz + +# 如果是 ARM 架构的 NAS(部分 NAS 是这种) +# wget https://github.com/fatedier/frp/releases/download/v0.61.1/frp_0.61.1_linux_arm64.tar.gz + +# 解压 +tar -xzf frp_0.61.1_linux_amd64.tar.gz +cd frp_0.61.1_linux_amd64 +``` + +#### 4.2.2 创建配置文件 + +创建配置文件 `frpc.toml`: + +```bash +nano frpc.toml +``` + +写入以下内容: + +```toml +# frp 客户端配置文件 + +# 公网服务器的地址和端口 +serverAddr = "123.45.67.89" # ← 改成你的公网服务器 IP +serverPort = 7000 + +# 认证令牌 - 必须和服务端设置的一致! +auth.token = "your_secret_token" # ← 改成和服务端一样的密码 + +# 图片上传服务的代理配置 +[[proxies]] +name = "nas-uploader" +type = "tcp" +localIP = "127.0.0.1" # NAS 本机地址 +localPort = 5000 # NAS 上网站运行的端口 +remotePort = 8080 # 公网服务器上对外暴露的端口 +``` + +**参数解释:** + +| 参数 | 含义 | 怎么填 | +|------|------|--------| +| serverAddr | 公网服务器的 IP | 填你的云服务器 IP | +| serverPort | 服务端的连接端口 | 默认 7000,和服务端配置一致 | +| auth.token | 认证密码 | 和服务端填一样的 | +| localPort | NAS 上网站的端口 | 5000(网站默认端口) | +| remotePort | 对外开放的端口 | 8080(用户通过这个端口访问网站) | + +#### 4.2.3 启动 frpc + +```bash +./frpc -c frpc.toml +``` + +看到 `start proxy success` 就说明连接成功了! + +#### 4.2.4 设置开机自启 + +```bash +sudo nano /etc/systemd/system/frpc.service +``` + +写入: + +```ini +[Unit] +Description=frp client +After=network.target + +[Service] +Type=simple +ExecStart=/root/frp_0.61.1_linux_amd64/frpc -c /root/frp_0.61.1_linux_amd64/frpc.toml +Restart=always +RestartSec=5s + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable frpc +sudo systemctl start frpc +sudo systemctl status frpc +``` + +--- + +### 4.3 第三步:验证是否成功 + +打开浏览器,访问: + +``` +http://你的公网服务器IP:8080 +``` + +例如:`http://123.45.67.89:8080` + +如果能看到网站页面,恭喜你,frp 配置成功了!🎉 + +--- + +## 5. 群晖 NAS 特别说明 + +如果你使用的是**群晖 NAS**,操作方式稍有不同: + +### 5.1 通过 SSH 连接群晖 + +1. 打开群晖控制面板 → 终端机和 SNMP → 勾选"启动 SSH 功能" +2. 在电脑上用终端工具连接: + ```bash + ssh 你的NAS用户名@192.168.1.100 + ``` + +### 5.2 使用 Docker 安装 frpc(推荐) + +群晖支持 Docker,用 Docker 安装更方便管理: + +1. 打开群晖的 **Docker** 套件(或 Container Manager) +2. 在 **注册表** 中搜索 `frpc`,下载镜像 +3. 在 **映像** 中双击下载好的镜像,创建容器 +4. 在 **卷** 设置中,挂载配置文件 +5. 在 **网络** 设置中,勾选"使用与 Docker Host 相同的网络" + +或者通过命令行: + +```bash +# 创建配置文件目录 +mkdir -p /volume1/docker/frpc + +# 把 frpc.toml 放到这个目录下 +# 然后启动容器 +docker run -d \ + --name frpc \ + --restart always \ + -v /volume1/docker/frpc/frpc.toml:/etc/frp/frpc.toml \ + --network host \ + snowdreamtech/frpc +``` + +--- + +## 6. 常见问题排查 + +### 6.1 连接不上怎么办? + +按以下顺序检查: + +``` +第1步:检查公网服务器上的 frps 是否在运行 + → 在服务器上执行:systemctl status frps + → 如果没运行,执行:systemctl start frps + +第2步:检查 NAS 上的 frpc 是否在运行 + → 在 NAS 上执行:systemctl status frpc + → 如果没运行,执行:systemctl start frpc + +第3步:检查防火墙是否开放了端口 + → 公网服务器的 7000 端口(frp 通信)和 8080 端口(网站访问)必须开放 + → 在云服务器管理控制台的"安全组"中添加规则 + +第4步:检查 token 是否一致 + → frps 和 frpc 的 auth.token 必须完全一样 + → 注意大小写和空格 + +第5步:检查服务器的 IP 地址是否正确 + → frpc.toml 中的 serverAddr 是否填对了 +``` + +### 6.2 端口被占用怎么办? + +如果提示端口被占用,说明有其他程序已经在用这个端口了: + +```bash +# 查看哪个程序占用了端口 +lsof -i :7000 # 查看 7000 端口 +lsof -i :8080 # 查看 8080 端口 + +# 解决方法:换一个端口号 +# 比如把 remotePort = 8080 改成 remotePort = 9090 +``` + +### 6.3 网站能访问但上传失败? + +``` +→ 检查 NAS 上的网站服务(Flask)是否在运行 +→ 检查 localPort 是否和网站实际运行的端口一致 +→ 检查 NAS 的存储目录是否有写入权限 +``` + +### 6.4 速度很慢怎么办? + +``` +→ frp 的速度取决于公网服务器的带宽和 NAS 的上传带宽 +→ 建议选择国内云服务器(阿里云/腾讯云),延迟更低 +→ 如果只是传图片,一般带宽足够了 +``` + +--- + +## 7. 安全注意事项 + +### 7.1 一定要做的事 + +- ✅ **修改默认 token**:不要用示例中的 `your_secret_token`,改成强密码 +- ✅ **设置防火墙规则**:只开放必要的端口(7000 和 8080),关闭其他端口 +- ✅ **定期更新 frp**:关注 frp 的版本更新,及时升级修复安全漏洞 + +### 7.2 建议做的事 + +- 🔶 **使用 HTTPS**:通过 Nginx 配置 SSL 证书,让传输更安全 +- 🔶 **限制访问 IP**:如果只有特定的人需要访问,可以在防火墙中限制来源 IP +- 🔶 **定期查看日志**:检查是否有异常访问记录 + +### 7.3 不要做的事 + +- ❌ 不要把 token 写在公开的地方(比如 GitHub) +- ❌ 不要使用过于简单的 token(比如 `123456`) +- ❌ 不要开放不必要的端口 + +--- + +## 8. 速查表 + +### 8.1 常用命令 + +| 操作 | 命令 | +|------|------| +| 启动 frps | `./frps -c frps.toml` | +| 启动 frpc | `./frpc -c frpc.toml` | +| 查看服务状态 | `systemctl status frps` 或 `systemctl status frpc` | +| 重启服务 | `systemctl restart frps` 或 `systemctl restart frpc` | +| 查看日志 | `journalctl -u frps -f` 或 `journalctl -u frpc -f` | + +### 8.2 文件位置速查 + +| 文件 | 位置 | 说明 | +|------|------|------| +| frps 配置 | 公网服务器上的 `frps.toml` | 服务端配置 | +| frpc 配置 | NAS 上的 `frpc.toml` | 客户端配置 | +| frps 服务文件 | `/etc/systemd/system/frps.service` | 开机自启配置 | +| frpc 服务文件 | `/etc/systemd/system/frpc.service` | 开机自启配置 | + +### 8.3 端口速查 + +| 端口 | 用途 | 在哪里 | +|------|------|--------| +| 7000 | frp 通信端口 | 公网服务器 | +| 7500 | frp 管理面板(可选) | 公网服务器 | +| 5000 | 网站服务端口 | NAS | +| 8080 | 对外访问端口 | 公网服务器 | + +--- + +## 9. 附录:完整配置文件模板 + +### 9.1 frps.toml(公网服务器) + +```toml +# ============================================ +# frp 服务端配置 - 放在公网服务器上 +# ============================================ + +# frp 通信端口(客户端通过此端口连接) +bindPort = 7000 + +# 认证令牌(请修改为你自己的强密码!) +auth.token = "请改成你自己的强密码" + +# 管理面板(可选,方便查看状态) +webServer.addr = "0.0.0.0" +webServer.port = 7500 +webServer.user = "admin" +webServer.password = "请改成管理面板密码" +``` + +### 9.2 frpc.toml(NAS 客户端) + +```toml +# ============================================ +# frp 客户端配置 - 放在 NAS 上 +# ============================================ + +# 公网服务器地址(请修改为你的服务器 IP) +serverAddr = "123.45.67.89" +serverPort = 7000 + +# 认证令牌(必须和服务端一致!) +auth.token = "请改成你自己的强密码" + +# 图片上传网站代理 +[[proxies]] +name = "nas-uploader" +type = "tcp" +localIP = "127.0.0.1" +localPort = 5000 +remotePort = 8080 +``` + +--- + +> **有问题?** 把报错信息截图发给技术人员,或者查看 frp 官方文档:https://gofrp.org/zh-cn/ diff --git a/nas-uploader/.env.example b/nas-uploader/.env.example new file mode 100644 index 0000000..4269bd5 --- /dev/null +++ b/nas-uploader/.env.example @@ -0,0 +1,14 @@ +# NAS图片存储根目录(部署时修改为实际路径) +NAS_DATA_PATH=/volume1/nas_data + +# 服务端口 +PORT=5000 + +# 允许的图片格式(小写,逗号分隔) +ALLOWED_EXTENSIONS=jpg,jpeg,png,gif,webp + +# 单次上传最大文件数量 +MAX_FILES_COUNT=100 + +# 单张图片最大大小(MB) +MAX_FILE_SIZE_MB=20 diff --git a/nas-uploader/.gitignore b/nas-uploader/.gitignore new file mode 100644 index 0000000..2a144a8 --- /dev/null +++ b/nas-uploader/.gitignore @@ -0,0 +1,31 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.egg-info/ +dist/ +build/ +*.egg +venv/ +.venv/ + +# 环境配置(包含敏感信息,不上传) +.env + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# 日志 +*.log +nohup.out + +# 测试数据 +test_*/ diff --git a/nas-uploader/app.py b/nas-uploader/app.py new file mode 100644 index 0000000..a2fad70 --- /dev/null +++ b/nas-uploader/app.py @@ -0,0 +1,287 @@ +import os +import re +import uuid +import shutil +from datetime import datetime +from flask import Flask, request, jsonify, render_template +from config import ( + NAS_DATA_PATH, PORT, ALLOWED_EXTENSIONS, + MAX_FILES_COUNT, MAX_FILE_SIZE_MB, ALLOWED_MIME_TYPES +) + +app = Flask(__name__) + + +# ========== 工具函数 ========== + +def get_camp_folders(): + """获取所有训练营文件夹列表,按期数排序""" + if not os.path.exists(NAS_DATA_PATH): + os.makedirs(NAS_DATA_PATH, exist_ok=True) + return [] + + folders = [] + for name in os.listdir(NAS_DATA_PATH): + full_path = os.path.join(NAS_DATA_PATH, name) + if os.path.isdir(full_path) and re.match(r'^训练营\d+期$', name): + folders.append(name) + + # 按期数数字排序 + folders.sort(key=lambda x: int(re.search(r'\d+', x).group()) if re.search(r'\d+', x) else 0) + return folders + + +def get_class_folders(camp_name): + """获取某个训练营下的所有班级文件夹,按班号排序""" + camp_path = os.path.join(NAS_DATA_PATH, camp_name) + if not os.path.exists(camp_path): + return [] + + classes = [] + for name in os.listdir(camp_path): + full_path = os.path.join(camp_path, name) + if os.path.isdir(full_path) and re.match(r'^\d+班$', name): + classes.append(name) + + # 按班号数字排序 + classes.sort(key=lambda x: int(re.search(r'\d+', x).group()) if re.search(r'\d+', x) else 0) + return classes + + +def validate_file(file): + """验证上传文件是否合法,返回 (是否合法, 错误原因)""" + if not file or not file.filename: + return False, "空文件" + + # 检查文件大小 + file.seek(0, os.SEEK_END) + size = file.tell() + file.seek(0) + if size > MAX_FILE_SIZE_MB * 1024 * 1024: + return False, f"文件超过 {MAX_FILE_SIZE_MB}MB 限制" + + if size == 0: + return False, "空文件" + + # 检查扩展名 + ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else '' + if ext not in ALLOWED_EXTENSIONS: + return False, f"不支持的格式: {ext}" + + # 检查MIME类型 + mime = file.mimetype + expected_mime = ALLOWED_MIME_TYPES.get(ext) + if expected_mime and mime and not mime.startswith(expected_mime.split('/')[0]): + return False, f"MIME类型不匹配: {mime}" + + return True, None + + +def generate_filename(original_name, camp_name, class_name): + """生成新文件名:训练营{n}期-{x}班-{时间戳}-{随机数}.{扩展名}""" + camp_match = re.search(r'(\d+)', camp_name) + class_match = re.search(r'(\d+)', class_name) + camp_num = camp_match.group(1) if camp_match else '?' + class_num = class_match.group(1) if class_match else '?' + + now = datetime.now() + timestamp = now.strftime('%Y%m%d%H%M%S') + random_str = uuid.uuid4().hex[:6] + ext = original_name.rsplit('.', 1)[-1].lower() if '.' in original_name else 'jpg' + + return f"训练营{camp_num}期-{class_num}班-{timestamp}-{random_str}.{ext}" + + +def get_unique_filepath(dir_path, filename): + """获取不冲突的文件路径,若存在则追加序号""" + filepath = os.path.join(dir_path, filename) + if not os.path.exists(filepath): + return filepath + + # 分离文件名和扩展名 + name, ext = os.path.splitext(filename) + counter = 1 + while True: + new_name = f"{name}-{counter}{ext}" + new_path = os.path.join(dir_path, new_name) + if not os.path.exists(new_path): + return new_path + counter += 1 + + +def ensure_folders(camp_name, class_name): + """确保训练营和班级文件夹存在,不存在则创建""" + camp_path = os.path.join(NAS_DATA_PATH, camp_name) + class_path = os.path.join(camp_path, class_name) + stat_file = os.path.join(camp_path, '统计.md') + + created_camp = False + if not os.path.exists(camp_path): + os.makedirs(camp_path, exist_ok=True) + created_camp = True + + if not os.path.exists(class_path): + os.makedirs(class_path, exist_ok=True) + + # 如果训练营是新创建的,初始化统计文件 + if created_camp and not os.path.exists(stat_file): + update_statistics(camp_name) + + return camp_path, class_path + + +def update_statistics(camp_name): + """更新某个训练营的统计.md文件""" + camp_path = os.path.join(NAS_DATA_PATH, camp_name) + stat_file = os.path.join(camp_path, '统计.md') + + if not os.path.exists(camp_path): + return + + # 扫描所有班级文件夹 + class_stats = [] + total = 0 + + for name in os.listdir(camp_path): + full_path = os.path.join(camp_path, name) + if not os.path.isdir(full_path): + continue + if not re.match(r'^\d+班$', name): + continue + + # 统计该班级下的图片数量 + count = 0 + for fname in os.listdir(full_path): + fpath = os.path.join(full_path, fname) + if os.path.isfile(fpath): + ext = fname.rsplit('.', 1)[-1].lower() if '.' in fname else '' + if ext in ALLOWED_EXTENSIONS: + count += 1 + + class_stats.append((name, count)) + total += count + + # 按班号排序 + class_stats.sort(key=lambda x: int(re.search(r'\d+', x[0]).group()) if re.search(r'\d+', x[0]) else 0) + + # 写入统计文件 + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + lines = [ + f"# {camp_name} 图片统计", + "", + f"更新时间:{now}", + f"图片总数:{total}", + "", + "| 班级 | 图片数量 |", + "|------|----------|", + ] + for class_name, count in class_stats: + lines.append(f"| {class_name} | {count} |") + + with open(stat_file, 'w', encoding='utf-8') as f: + f.write('\n'.join(lines) + '\n') + + +# ========== 页面路由 ========== + +@app.route('/') +def index(): + return render_template('index.html') + + +# ========== API 路由 ========== + +@app.route('/api/folders', methods=['GET']) +def api_folders(): + """获取所有训练营及其下的班级文件夹""" + folders = [] + for camp_name in get_camp_folders(): + classes = get_class_folders(camp_name) + folders.append({ + 'name': camp_name, + 'classes': classes, + }) + return jsonify({'folders': folders}) + + +@app.route('/api/upload', methods=['POST']) +def api_upload(): + """上传图片""" + camp_name = request.form.get('folder', '').strip() + class_name = request.form.get('class', '').strip() + files = request.files.getlist('files') + + # 参数校验 + if not camp_name or not re.match(r'^训练营\d+期$', camp_name): + return jsonify({'success': False, 'error': '无效的训练营名称'}), 400 + + if not class_name or not re.match(r'^\d+班$', class_name): + return jsonify({'success': False, 'error': '无效的班级名称'}), 400 + + if not files: + return jsonify({'success': False, 'error': '未选择文件'}), 400 + + if len(files) > MAX_FILES_COUNT: + return jsonify({'success': False, 'error': f'单次最多上传 {MAX_FILES_COUNT} 张'}), 400 + + # 确保文件夹存在 + camp_path, class_path = ensure_folders(camp_name, class_name) + + # 逐个处理文件 + uploaded = [] + failed = [] + has_new_upload = False + + for file in files: + # 验证文件 + valid, reason = validate_file(file) + if not valid: + failed.append({ + 'filename': file.filename if file.filename else '未知文件', + 'status': 'failed', + 'reason': reason, + }) + continue + + # 生成文件名并保存 + new_filename = generate_filename(file.filename, camp_name, class_name) + filepath = get_unique_filepath(class_path, new_filename) + + try: + file.save(filepath) + uploaded.append({ + 'filename': os.path.basename(filepath), + 'status': 'success', + }) + has_new_upload = True + except Exception as e: + failed.append({ + 'filename': file.filename, + 'status': 'failed', + 'reason': str(e), + }) + + # 如果有新文件上传成功,更新统计 + if has_new_upload: + try: + update_statistics(camp_name) + except Exception: + pass # 统计更新失败不影响上传结果 + + return jsonify({ + 'success': len(uploaded) > 0, + 'uploaded': uploaded, + 'failed': failed, + }) + + +# ========== 启动 ========== + +if __name__ == '__main__': + # 确保存储目录存在 + if not os.path.exists(NAS_DATA_PATH): + os.makedirs(NAS_DATA_PATH, exist_ok=True) + print(f"已创建存储目录: {NAS_DATA_PATH}") + + print(f"服务启动在 http://0.0.0.0:{PORT}") + app.run(host='0.0.0.0', port=PORT, debug=False) diff --git a/nas-uploader/config.py b/nas-uploader/config.py new file mode 100644 index 0000000..29e1aa4 --- /dev/null +++ b/nas-uploader/config.py @@ -0,0 +1,29 @@ +import os +from dotenv import load_dotenv + +# 加载 .env 文件 +load_dotenv() + +# NAS图片存储根目录 +NAS_DATA_PATH = os.getenv('NAS_DATA_PATH', '/volume1/nas_data') + +# 服务端口 +PORT = int(os.getenv('PORT', '5000')) + +# 允许的图片格式 +ALLOWED_EXTENSIONS = os.getenv('ALLOWED_EXTENSIONS', 'jpg,jpeg,png,gif,webp').split(',') + +# 单次上传最大文件数量 +MAX_FILES_COUNT = int(os.getenv('MAX_FILES_COUNT', '100')) + +# 单张图片最大大小(MB) +MAX_FILE_SIZE_MB = int(os.getenv('MAX_FILE_SIZE_MB', '20')) + +# 允许的MIME类型映射 +ALLOWED_MIME_TYPES = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'webp': 'image/webp', +} diff --git a/nas-uploader/requirements.txt b/nas-uploader/requirements.txt new file mode 100644 index 0000000..e7f8e59 --- /dev/null +++ b/nas-uploader/requirements.txt @@ -0,0 +1,2 @@ +flask==3.0.0 +python-dotenv==1.0.0 diff --git a/nas-uploader/start.sh b/nas-uploader/start.sh new file mode 100644 index 0000000..ed2f669 --- /dev/null +++ b/nas-uploader/start.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# NAS图片上传系统 - 启动脚本 + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# 检查 .env 文件是否存在 +if [ ! -f .env ]; then + echo "错误:未找到 .env 配置文件" + echo "请复制 .env.example 为 .env 并修改配置:" + echo " cp .env.example .env" + exit 1 +fi + +# 检查是否在虚拟环境中 +if [ -z "$VIRTUAL_ENV" ]; then + # 尝试激活本地虚拟环境 + if [ -d "venv" ]; then + source venv/bin/activate + echo "已激活虚拟环境: venv" + fi +fi + +# 检查依赖 +python3 -c "import flask" 2>/dev/null +if [ $? -ne 0 ]; then + echo "正在安装依赖..." + pip3 install -r requirements.txt --break-system-packages +fi + +echo "启动服务..." +python3 app.py diff --git a/nas-uploader/static/css/style.css b/nas-uploader/static/css/style.css new file mode 100644 index 0000000..58be91b --- /dev/null +++ b/nas-uploader/static/css/style.css @@ -0,0 +1,537 @@ +/* ========== Reset & Base ========== */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif; + background: #f0f2f5; + color: #333; + line-height: 1.6; + min-height: 100vh; +} + +.container { + max-width: 720px; + margin: 0 auto; + padding: 20px 16px 40px; +} + +/* ========== Header ========== */ +.header { + text-align: center; + padding: 24px 0 20px; +} + +.header h1 { + font-size: 24px; + font-weight: 600; + color: #1a1a2e; +} + +.subtitle { + color: #888; + font-size: 14px; + margin-top: 6px; +} + +/* ========== Folder Section ========== */ +.folder-section { + background: #fff; + border-radius: 12px; + padding: 20px; + margin-bottom: 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.form-group { + margin-bottom: 14px; +} + +.form-group:last-child { + margin-bottom: 0; +} + +.form-group label { + display: block; + font-size: 14px; + font-weight: 500; + color: #555; + margin-bottom: 6px; +} + +.form-group select { + width: 100%; + height: 44px; + padding: 0 14px; + border: 1.5px solid #d9d9d9; + border-radius: 8px; + font-size: 15px; + color: #333; + background: #fafafa; + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-group select:focus { + outline: none; + border-color: #4f6ef7; + box-shadow: 0 0 0 3px rgba(79, 110, 247, 0.1); + background-color: #fff; +} + +.form-group select:disabled { + background: #f5f5f5; + color: #bbb; + cursor: not-allowed; +} + +/* ========== Upload Section ========== */ +.upload-section { + background: #fff; + border-radius: 12px; + padding: 20px; + margin-bottom: 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +/* Select Area */ +.select-area { + text-align: center; + padding: 30px 20px; + border: 2px dashed #d9d9d9; + border-radius: 10px; + transition: border-color 0.2s, background 0.2s; +} + +.select-area:hover { + border-color: #4f6ef7; + background: #f8f9ff; +} + +.select-icon { + font-size: 40px; + margin-bottom: 10px; +} + +.select-area p { + color: #666; + font-size: 14px; +} + +.select-area .hint { + color: #aaa; + font-size: 12px; + margin-top: 4px; +} + +/* ========== Buttons ========== */ +.btn { + display: inline-block; + padding: 10px 24px; + border: none; + border-radius: 8px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + user-select: none; + -webkit-user-select: none; +} + +.btn:active { + transform: scale(0.97); +} + +.btn-primary { + background: #4f6ef7; + color: #fff; +} + +.btn-primary:hover { + background: #3d5ce0; +} + +.btn-primary:disabled { + background: #b0bfee; + cursor: not-allowed; + transform: none; +} + +.btn-secondary { + background: #f0f2f5; + color: #555; + margin-top: 12px; +} + +.btn-secondary:hover { + background: #e4e6eb; +} + +.btn-danger { + background: #ff4d4f; + color: #fff; + padding: 4px 12px; + font-size: 12px; +} + +.btn-danger:hover { + background: #e63e40; +} + +.btn-small { + padding: 4px 12px; + font-size: 12px; + border-radius: 6px; +} + +.btn-large { + width: 100%; + padding: 14px; + font-size: 16px; + margin-top: 16px; +} + +/* ========== Preview Section ========== */ +.preview-section { + margin-top: 20px; +} + +.preview-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + font-size: 14px; + color: #555; +} + +.preview-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 10px; + max-height: 300px; + overflow-y: auto; + padding: 4px; +} + +.preview-item { + position: relative; + aspect-ratio: 1; + border-radius: 8px; + overflow: hidden; + border: 1px solid #eee; +} + +.preview-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.preview-item .remove-btn { + position: absolute; + top: 4px; + right: 4px; + width: 22px; + height: 22px; + background: rgba(0, 0, 0, 0.5); + color: #fff; + border: none; + border-radius: 50%; + font-size: 14px; + line-height: 22px; + text-align: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s; +} + +.preview-item:hover .remove-btn { + opacity: 1; +} + +.preview-item .file-name { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 10px; + padding: 2px 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ========== Progress Section ========== */ +.progress-section { + margin-top: 20px; +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 14px; + color: #555; +} + +.progress-percent { + font-weight: 600; + color: #4f6ef7; +} + +.progress-bar { + width: 100%; + height: 8px; + background: #eee; + border-radius: 4px; + overflow: hidden; + margin-bottom: 16px; +} + +.progress-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, #4f6ef7, #7c8cf8); + border-radius: 4px; + transition: width 0.3s ease; +} + +.file-progress-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 250px; + overflow-y: auto; +} + +.file-progress-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: #fafafa; + border-radius: 8px; + font-size: 13px; +} + +.file-progress-item .status-icon { + font-size: 16px; + flex-shrink: 0; + width: 20px; + text-align: center; +} + +.file-progress-item .file-info { + flex: 1; + min-width: 0; +} + +.file-progress-item .file-info .name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #333; +} + +.file-progress-item .file-info .detail { + font-size: 11px; + color: #999; +} + +.file-progress-item .mini-progress { + width: 60px; + height: 4px; + background: #eee; + border-radius: 2px; + overflow: hidden; + flex-shrink: 0; +} + +.file-progress-item .mini-progress .mini-fill { + height: 100%; + background: #4f6ef7; + border-radius: 2px; + transition: width 0.2s; +} + +.file-progress-item.status-success .status-icon { + color: #52c41a; +} + +.file-progress-item.status-failed .status-icon { + color: #ff4d4f; +} + +.file-progress-item.status-failed { + background: #fff2f0; +} + +/* ========== Result Section ========== */ +.result-section { + background: #fff; + border-radius: 12px; + padding: 20px; + margin-bottom: 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.result-header { + margin-bottom: 16px; +} + +.result-header h2 { + font-size: 18px; + font-weight: 600; + color: #1a1a2e; + margin-bottom: 4px; +} + +.result-summary { + font-size: 14px; + color: #888; +} + +.result-summary .success-count { + color: #52c41a; + font-weight: 600; +} + +.result-summary .fail-count { + color: #ff4d4f; + font-weight: 600; +} + +.result-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 10px; + max-height: 400px; + overflow-y: auto; +} + +.result-item { + position: relative; + aspect-ratio: 1; + border-radius: 8px; + overflow: hidden; + border: 2px solid transparent; +} + +.result-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.result-item.success { + border-color: #52c41a; +} + +.result-item.failed { + border-color: #ff4d4f; +} + +.result-item .result-name { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 10px; + padding: 4px 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.result-item .result-status { + position: absolute; + top: 4px; + right: 4px; + font-size: 14px; +} + +/* ========== Responsive ========== */ +@media (max-width: 480px) { + .container { + padding: 12px 10px 30px; + } + + .header h1 { + font-size: 20px; + } + + .folder-section, + .upload-section, + .result-section { + padding: 14px; + border-radius: 10px; + } + + .select-area { + padding: 20px 14px; + } + + .preview-grid { + grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); + gap: 8px; + } + + .result-grid { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 8px; + } + + .btn-large { + padding: 12px; + font-size: 15px; + } + + /* 移动端始终显示删除按钮 */ + .preview-item .remove-btn { + opacity: 1; + } +} + +@media (min-width: 481px) and (max-width: 768px) { + .preview-grid { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + } +} + +/* ========== Scrollbar ========== */ +.preview-grid::-webkit-scrollbar, +.file-progress-list::-webkit-scrollbar, +.result-grid::-webkit-scrollbar { + width: 4px; +} + +.preview-grid::-webkit-scrollbar-thumb, +.file-progress-list::-webkit-scrollbar-thumb, +.result-grid::-webkit-scrollbar-thumb { + background: #ddd; + border-radius: 2px; +} + +/* ========== Loading animation ========== */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.uploading { + animation: pulse 1.5s ease-in-out infinite; +} diff --git a/nas-uploader/static/js/app.js b/nas-uploader/static/js/app.js new file mode 100644 index 0000000..2804834 --- /dev/null +++ b/nas-uploader/static/js/app.js @@ -0,0 +1,413 @@ +// ========== 写死的选项数据 ========== + +const ALL_CAMPS = []; +for (let i = 13; i <= 26; i++) { + ALL_CAMPS.push(`训练营${i}期`); +} + +const ALL_CLASSES = []; +for (let i = 1; i <= 25; i++) { + ALL_CLASSES.push(`${i}班`); +} + +const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']; +const MAX_FILE_SIZE_MB = 20; +const MAX_FILES_COUNT = 100; + +// ========== 状态管理 ========== + +let state = { + selectedCamp: '', + selectedClass: '', + selectedFiles: [], // { file, id, previewUrl } + uploadResults: [], // { file, id, status, filename, reason, previewUrl } + isUploading: false, +}; + +// ========== DOM 元素 ========== + +const $ = (sel) => document.querySelector(sel); +const campSelect = $('#camp-select'); +const classSelect = $('#class-select'); +const fileInput = $('#file-input'); +const uploadSection = $('.upload-section'); +const selectArea = $('#select-area'); +const previewSection = $('#preview-section'); +const previewGrid = $('#preview-grid'); +const selectedCount = $('#selected-count'); +const btnUpload = $('#btn-upload'); +const btnClearAll = $('#btn-clear-all'); +const progressSection = $('#progress-section'); +const progressText = $('#progress-text'); +const progressPercent = $('#progress-percent'); +const progressFill = $('#progress-fill'); +const fileProgressList = $('#file-progress-list'); +const resultSection = $('#result-section'); +const resultSummary = $('#result-summary'); +const resultGrid = $('#result-grid'); +const btnRetry = $('#btn-retry'); +const btnNewUpload = $('#btn-new-upload'); + +// ========== 初始化 ========== + +function init() { + loadFolders(); + bindEvents(); +} + +// ========== 文件夹加载(前端写死选项) ========== + +function loadFolders() { + campSelect.innerHTML = ''; + ALL_CAMPS.forEach(camp => { + const opt = document.createElement('option'); + opt.value = camp; + opt.textContent = camp; + campSelect.appendChild(opt); + }); +} + +function onCampChange() { + const campName = campSelect.value; + state.selectedCamp = campName; + + classSelect.innerHTML = ''; + classSelect.disabled = true; + state.selectedClass = ''; + + if (!campName) { + classSelect.innerHTML = ''; + return; + } + + classSelect.disabled = false; + classSelect.innerHTML = ''; + ALL_CLASSES.forEach(cls => { + const opt = document.createElement('option'); + opt.value = cls; + opt.textContent = cls; + classSelect.appendChild(opt); + }); +} + +function onClassChange() { + state.selectedClass = classSelect.value; +} + +// ========== 文件选择 ========== + +function onFileSelect(e) { + const files = Array.from(e.target.files); + if (!files.length) return; + + const remaining = MAX_FILES_COUNT - state.selectedFiles.length; + if (remaining <= 0) { + alert(`最多只能选择 ${MAX_FILES_COUNT} 张图片`); + fileInput.value = ''; + return; + } + + const filesToAdd = files.slice(0, remaining); + let rejected = 0; + + filesToAdd.forEach(file => { + if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) { + rejected++; + return; + } + + const ext = file.name.split('.').pop().toLowerCase(); + if (!ALLOWED_EXTENSIONS.includes(ext)) { + rejected++; + return; + } + + const id = Date.now() + '_' + Math.random().toString(36).substr(2, 6); + const previewUrl = URL.createObjectURL(file); + state.selectedFiles.push({ file, id, previewUrl }); + }); + + if (rejected > 0) { + alert(`已跳过 ${rejected} 个文件(格式不支持或超过 20MB)`); + } + + fileInput.value = ''; + renderPreview(); +} + +function removeFile(id) { + const idx = state.selectedFiles.findIndex(f => f.id === id); + if (idx !== -1) { + URL.revokeObjectURL(state.selectedFiles[idx].previewUrl); + state.selectedFiles.splice(idx, 1); + } + renderPreview(); +} + +function clearAllFiles() { + state.selectedFiles.forEach(f => URL.revokeObjectURL(f.previewUrl)); + state.selectedFiles = []; + renderPreview(); +} + +function renderPreview() { + const count = state.selectedFiles.length; + + if (count === 0) { + previewSection.style.display = 'none'; + selectArea.style.display = 'block'; + return; + } + + selectArea.style.display = 'none'; + previewSection.style.display = 'block'; + selectedCount.textContent = count; + + btnUpload.disabled = !state.selectedCamp || !state.selectedClass || state.isUploading; + + previewGrid.innerHTML = ''; + state.selectedFiles.forEach(item => { + const div = document.createElement('div'); + div.className = 'preview-item'; + div.innerHTML = ` + ${item.file.name} + +
${item.file.name}
+ `; + previewGrid.appendChild(div); + }); +} + +// ========== 上传逻辑(真实API) ========== + +function uploadFile(file, campName, className, onProgress) { + return new Promise((resolve) => { + const formData = new FormData(); + formData.append('folder', campName); + formData.append('class', className); + formData.append('files', file); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/upload'); + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + onProgress(Math.round((e.loaded / e.total) * 100)); + } + }; + + xhr.onload = () => { + try { + const data = JSON.parse(xhr.responseText); + if (xhr.status === 200 && data.uploaded && data.uploaded.length > 0) { + resolve({ type: 'success', filename: data.uploaded[0].filename }); + } else if (data.failed && data.failed.length > 0) { + resolve({ type: 'failed', reason: data.failed[0].reason }); + } else { + resolve({ type: 'failed', reason: data.error || '上传失败' }); + } + } catch (e) { + resolve({ type: 'failed', reason: '服务器响应异常' }); + } + }; + + xhr.onerror = () => { + resolve({ type: 'failed', reason: '网络错误' }); + }; + + xhr.ontimeout = () => { + resolve({ type: 'failed', reason: '请求超时' }); + }; + + xhr.timeout = 120000; // 2分钟超时 + xhr.send(formData); + }); +} + +async function startUpload() { + if (!state.selectedCamp || !state.selectedClass) { + alert('请先选择训练营和班级'); + return; + } + if (state.selectedFiles.length === 0) { + alert('请先选择图片'); + return; + } + + state.isUploading = true; + btnUpload.disabled = true; + btnUpload.textContent = '上传中...'; + btnUpload.classList.add('uploading'); + + // 切换到进度视图 + previewSection.style.display = 'none'; + resultSection.style.display = 'none'; + progressSection.style.display = 'block'; + + // 初始化进度列表 + fileProgressList.innerHTML = ''; + state.selectedFiles.forEach(item => { + const div = document.createElement('div'); + div.className = 'file-progress-item'; + div.id = `progress-${item.id}`; + div.innerHTML = ` + +
+
${item.file.name}
+
等待上传...
+
+
+ `; + fileProgressList.appendChild(div); + }); + + // 逐张上传 + let completed = 0; + const total = state.selectedFiles.length; + state.uploadResults = []; + + for (const item of state.selectedFiles) { + const el = $(`#progress-${item.id}`); + const icon = el.querySelector('.status-icon'); + const detail = el.querySelector('.detail'); + const miniFill = el.querySelector('.mini-fill'); + + // 上传中 + icon.textContent = '📤'; + detail.textContent = '上传中...'; + + // 调用真实API + const result = await uploadFile(item.file, state.selectedCamp, state.selectedClass, (percent) => { + miniFill.style.width = `${percent}%`; + }); + + if (result.type === 'success') { + icon.textContent = '✅'; + detail.textContent = result.filename; + el.classList.add('status-success'); + miniFill.style.width = '100%'; + miniFill.style.background = '#52c41a'; + state.uploadResults.push({ + file: item.file, + id: item.id, + previewUrl: item.previewUrl, + status: 'success', + filename: result.filename, + }); + } else if (result.type === 'failed') { + icon.textContent = '❌'; + detail.textContent = `上传失败:${result.reason}`; + el.classList.add('status-failed'); + miniFill.style.width = '100%'; + miniFill.style.background = '#ff4d4f'; + state.uploadResults.push({ + file: item.file, + id: item.id, + previewUrl: item.previewUrl, + status: 'failed', + filename: item.file.name, + reason: result.reason, + }); + } + + completed++; + const percent = Math.round((completed / total) * 100); + progressText.textContent = `上传中... ${completed} / ${total}`; + progressPercent.textContent = `${percent}%`; + progressFill.style.width = `${percent}%`; + } + + // 上传完成 + state.isUploading = false; + btnUpload.textContent = '开始上传'; + btnUpload.classList.remove('uploading'); + + setTimeout(() => { + progressSection.style.display = 'none'; + uploadSection.style.display = 'none'; + showResults(); + }, 500); +} + +// ========== 结果展示 ========== + +function showResults() { + resultSection.style.display = 'block'; + + const successList = state.uploadResults.filter(r => r.status === 'success'); + const failedList = state.uploadResults.filter(r => r.status === 'failed'); + + let summaryHtml = `共上传 ${state.uploadResults.length} 张图片,`; + summaryHtml += `成功 ${successList.length} 张`; + if (failedList.length > 0) { + summaryHtml += `,失败 ${failedList.length} 张`; + } + resultSummary.innerHTML = summaryHtml; + + btnRetry.style.display = failedList.length > 0 ? 'inline-block' : 'none'; + + resultGrid.innerHTML = ''; + state.uploadResults.forEach(item => { + const div = document.createElement('div'); + div.className = `result-item ${item.status}`; + div.innerHTML = ` + ${item.filename} +
${item.status === 'success' ? item.filename : item.filename}
+ ${item.status === 'success' ? '✅' : '❌'} + `; + resultGrid.appendChild(div); + }); +} + +function retryFailed() { + const failedItems = state.uploadResults.filter(r => r.status === 'failed'); + state.selectedFiles = failedItems.map(item => ({ + file: item.file, + id: item.id, + previewUrl: item.previewUrl, + })); + state.uploadResults = []; + + resultSection.style.display = 'none'; + progressSection.style.display = 'none'; + uploadSection.style.display = 'block'; + renderPreview(); +} + +function newUpload() { + state.selectedFiles.forEach(f => URL.revokeObjectURL(f.previewUrl)); + state.selectedFiles = []; + state.uploadResults = []; + state.isUploading = false; + + resultSection.style.display = 'none'; + progressSection.style.display = 'none'; + uploadSection.style.display = 'block'; + previewSection.style.display = 'none'; + selectArea.style.display = 'block'; + btnUpload.disabled = true; + btnUpload.textContent = '开始上传'; +} + +// ========== 事件绑定 ========== + +function bindEvents() { + campSelect.addEventListener('change', onCampChange); + classSelect.addEventListener('change', onClassChange); + fileInput.addEventListener('change', onFileSelect); + btnUpload.addEventListener('click', startUpload); + btnClearAll.addEventListener('click', clearAllFiles); + btnRetry.addEventListener('click', retryFailed); + btnNewUpload.addEventListener('click', newUpload); + + campSelect.addEventListener('change', () => { + if (state.selectedFiles.length > 0) renderPreview(); + }); + classSelect.addEventListener('change', () => { + if (state.selectedFiles.length > 0) renderPreview(); + }); +} + +// ========== 启动 ========== + +document.addEventListener('DOMContentLoaded', init); diff --git a/nas-uploader/templates/index.html b/nas-uploader/templates/index.html new file mode 100644 index 0000000..eeb8522 --- /dev/null +++ b/nas-uploader/templates/index.html @@ -0,0 +1,88 @@ + + + + + + 训练营图片上传 + + + +
+ +
+

📷 训练营图片上传

+

选择训练营和班级,上传截图文件

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
📁
+

点击下方按钮选择图片

+

支持 JPG / PNG / GIF / WEBP,单张不超过 20MB,最多 100 张

+ + +
+ + + + + + +
+ + + +
+ + + + diff --git a/部署文档.md b/部署文档.md new file mode 100644 index 0000000..43e5a12 --- /dev/null +++ b/部署文档.md @@ -0,0 +1,389 @@ +# NAS图片上传系统 - 部署文档 + +> **适用环境**:群晖NAS + 公网服务器(frp内网穿透) +> **最后更新**:2026-04-17 + +--- + +## 1. 部署前准备 + +### 1.1 你需要的东西 + +| 项目 | 说明 | +|------|------| +| 群晖NAS | 已开启SSH,能通过终端访问 | +| 公网服务器 | 有公网IP,已安装frps(frp服务端) | +| NAS存储路径 | 决定图片存放在哪个共享文件夹下 | + +### 1.2 项目文件清单 + +部署前确认以下文件完整: + +``` +nas-uploader/ +├── app.py ← Flask后端主程序 +├── config.py ← 配置读取模块 +├── .env.example ← 配置模板(需复制为.env) +├── requirements.txt ← Python依赖 +├── start.sh ← 启动脚本 +├── static/ +│ ├── css/style.css ← 页面样式 +│ ├── js/app.js ← 前端逻辑 +│ └── img/ ← 图标资源(可选) +└── templates/ + └── index.html ← 主页面 +``` + +--- + +## 2. 群晖NAS上的部署步骤 + +### 2.1 上传项目文件 + +**方法一:通过群晖网页上传** + +1. 打开群晖 File Station +2. 进入你想存放项目的共享文件夹(如 `web`) +3. 上传整个 `nas-uploader` 文件夹 + +**方法二:通过SSH上传** + +```bash +# 在你的电脑上执行(假设NAS内网IP为 192.168.1.100) +scp -r nas-uploader/ your_admin@192.168.1.100:/volume1/web/ +``` + +### 2.2 SSH登录群晖NAS + +```bash +ssh your_admin@192.168.1.100 +``` + +> 如果提示"connection refused",需要在群晖控制面板 → 终端机和SNMP → 开启SSH功能 + +### 2.3 配置环境 + +```bash +# 进入项目目录(根据你的实际路径调整) +cd /volume1/web/nas-uploader + +# 复制配置文件 +cp .env.example .env + +# 编辑配置文件 +vi .env +``` + +**必须修改的配置项:** + +```env +# 改成你实际的NAS存储路径,例如: +NAS_DATA_PATH=/volume1/photo/训练营图片 + +# 其他配置一般不需要改 +PORT=5000 +ALLOWED_EXTENSIONS=jpg,jpeg,png,gif,webp +MAX_FILES_COUNT=100 +MAX_FILE_SIZE_MB=20 +``` + +> 💡 **路径说明**:`NAS_DATA_PATH` 是图片存储的根目录,上传的图片会保存在这个目录下的 `训练营N期/X班/` 子文件夹中。建议使用群晖已有的共享文件夹路径。 + +### 2.4 安装Python依赖 + +群晖NAS自带Python 3,但可能缺少pip。按以下步骤操作: + +```bash +# 检查Python版本 +python3 --version + +# 如果有pip,直接安装 +pip3 install flask python-dotenv + +# 如果提示权限问题 +pip3 install flask python-dotenv --break-system-packages + +# 如果没有pip,先安装 +# 群晖DS718+等型号可以直接通过套件中心安装Python3 +``` + +### 2.5 首次启动测试 + +```bash +cd /volume1/web/nas-uploader +python3 app.py +``` + +看到以下输出说明启动成功: + +``` +服务启动在 http://0.0.0.0:5000 + * Running on http://127.0.0.1:5000 +``` + +此时在浏览器访问 `http://NAS内网IP:5000` 应该能看到上传页面。 + +### 2.6 验证上传功能 + +1. 打开 `http://NAS内网IP:5000` +2. 选择训练营和班级 +3. 选择几张图片上传 +4. 检查 `NAS_DATA_PATH` 目录下是否自动创建了对应的文件夹和图片文件 +5. 检查统计.md是否正确生成 + +--- + +## 3. 配置frp内网穿透 + +> 详细原理请参考 [frp内网穿透_原理与部署指南.md](./frp内网穿透_原理与部署指南.md) + +### 3.1 在NAS上配置frpc + +如果NAS上还没有frpc,先安装: + +```bash +# 下载frp(根据NAS架构选择) +# x86架构(大多数群晖) +wget https://github.com/fatedier/frp/releases/download/v0.61.1/frp_0.61.1_linux_amd64.tar.gz +tar -xzf frp_0.61.1_linux_amd64.tar.gz +cd frp_0.61.1_linux_amd64 +``` + +创建配置文件 `frpc.toml`: + +```toml +serverAddr = "你的公网服务器IP" +serverPort = 7000 +auth.token = "你的frp认证密码" + +[[proxies]] +name = "nas-uploader" +type = "tcp" +localIP = "127.0.0.1" +localPort = 5000 +remotePort = 8080 +``` + +启动frpc: + +```bash +./frpc -c frpc.toml +``` + +### 3.2 公网服务器上的frps配置 + +确保公网服务器上的 `frps.toml` 已配置好: + +```toml +bindPort = 7000 +auth.token = "你的frp认证密码" # 和frpc保持一致 +``` + +### 3.3 验证公网访问 + +在手机或电脑上(使用非内网网络)访问: + +``` +http://公网服务器IP:8080 +``` + +如果能打开上传页面,说明穿透配置成功。 + +--- + +## 4. 设置开机自启(推荐) + +### 4.1 Flask服务自启 + +在NAS上创建systemd服务: + +```bash +sudo vi /etc/systemd/system/nas-uploader.service +``` + +写入以下内容(**注意修改路径**): + +```ini +[Unit] +Description=NAS Image Uploader +After=network.target + +[Service] +Type=simple +WorkingDirectory=/volume1/web/nas-uploader +ExecStart=/usr/bin/python3 /volume1/web/nas-uploader/app.py +Restart=always +RestartSec=5s + +[Install] +WantedBy=multi-user.target +``` + +启用服务: + +```bash +sudo systemctl enable nas-uploader +sudo systemctl start nas-uploader + +# 查看状态 +sudo systemctl status nas-uploader +``` + +> ⚠️ **注意**:部分群晖型号可能不支持systemd,可以用 `supervisor` 替代,或者通过群晖的"任务计划"功能设置开机脚本。 + +### 4.2 frpc自启 + +```bash +sudo vi /etc/systemd/system/frpc.service +``` + +```ini +[Unit] +Description=frp client +After=network.target + +[Service] +Type=simple +ExecStart=/volume1/web/frp/frpc -c /volume1/web/frp/frpc.toml +Restart=always +RestartSec=5s + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable frpc +sudo systemctl start frpc +``` + +### 4.3 使用群晖任务计划(替代方案) + +如果systemd不可用,可以通过群晖控制面板设置: + +1. 控制面板 → 任务计划 → 新增 → 触发的任务 → 用户自定义脚本 +2. 常规:设置任务名称"启动图片上传服务" +3. 事件:开机 +4. 任务设置:用户账号选root,脚本内容: + +```bash +#!/bin/bash +cd /volume1/web/nas-uploader +nohup python3 app.py > /volume1/web/nas-uploader/app.log 2>&1 & +cd /volume1/web/frp +nohup ./frpc -c frpc.toml > /volume1/web/frp/frpc.log 2>&1 & +``` + +--- + +## 5. 群晖Docker部署(可选) + +如果你的群晖支持Docker(Container Manager套件),可以用Docker部署: + +### 5.1 创建Dockerfile + +在项目根目录创建 `Dockerfile`: + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["python3", "app.py"] +``` + +### 5.2 构建和运行 + +```bash +cd /volume1/web/nas-uploader + +# 构建镜像 +docker build -t nas-uploader . + +# 运行容器 +docker run -d \ + --name nas-uploader \ + --restart always \ + -p 5000:5000 \ + -v /volume1/photo/训练营图片:/app/data \ + -e NAS_DATA_PATH=/app/data \ + nas-uploader +``` + +> `-v` 参数将NAS的存储目录挂载到容器内 + +--- + +## 6. 常见问题排查 + +### 6.1 页面打不开 + +``` +检查步骤: +1. Flask服务是否在运行? + → systemctl status nas-uploader 或 ps aux | grep python +2. 端口是否被占用? + → netstat -tlnp | grep 5000 +3. 防火墙是否放行? + → 群晖控制面板 → 安全性 → 防火墙 → 编辑规则 +``` + +### 6.2 公网打不开 + +``` +检查步骤: +1. frpc是否在运行? → ps aux | grep frpc +2. frpc日志有无报错? → cat /path/to/frpc.log +3. 公网服务器防火墙是否开放了8080端口? +4. frp的token是否一致? +``` + +### 6.3 上传失败 + +``` +检查步骤: +1. NAS_DATA_PATH路径是否正确? → ls -la 路径 +2. 目录是否有写入权限? → touch 路径/test && rm 路径/test +3. 磁盘空间是否充足? → df -h +4. 查看Flask日志 → cat /path/to/nas-uploader/app.log +``` + +### 6.4 图片上传后找不到文件 + +``` +检查步骤: +1. 确认.env中的NAS_DATA_PATH路径 +2. 查看该路径下是否生成了训练营文件夹 +3. 文件夹名称格式必须是"训练营N期" +``` + +--- + +## 7. 维护命令速查 + +| 操作 | 命令 | +|------|------| +| 启动服务 | `cd /volume1/web/nas-uploader && python3 app.py` | +| 停止服务 | `sudo systemctl stop nas-uploader` | +| 重启服务 | `sudo systemctl restart nas-uploader` | +| 查看日志 | `sudo journalctl -u nas-uploader -f` | +| 查看存储目录 | `ls -la $NAS_DATA_PATH` | +| 查看磁盘空间 | `df -h` | +| 手动更新统计 | `python3 -c "from app import update_statistics; update_statistics('训练营13期')"` | + +--- + +## 8. 安全建议 + +- **当前版本无密码保护**,任何人知道网址都可以上传 +- 建议不要将网址公开分享给无关人员 +- 后续版本会添加访问密码功能 +- 建议定期检查上传的文件,防止滥用 +- 如果发现异常,可以暂时停止Flask服务:`sudo systemctl stop nas-uploader`