Loading... ### nodejs版本 ```bash #!/bin/bash # --- 配置 --- PROJECT_DIR="ink-uploader" TARGET_PATH="/home/bosaidon/downloads/qbittorrent" LOG_FILE="/home/bosaidon/ink_upload.log" # 颜色定义 GREEN='\033[0;32m' CYAN='\033[0;36m' RED='\033[0;31m' NC='\033[0m' echo -e "${CYAN}🚀 开始全新安装 Ink Rclone 上传助手...${NC}" # 1. 清理旧环境 if [ -d "$PROJECT_DIR" ]; then echo -e "${RED}发现旧目录,正在删除...${NC}" rm -rf "$PROJECT_DIR" fi echo -e "${GREEN}创建目录 $PROJECT_DIR ...${NC}" mkdir -p "$PROJECT_DIR" cd "$PROJECT_DIR" || exit # 2. 写入 package.json (使用 tsc 编译 + node 运行模式) echo -e "${GREEN}📄 创建 package.json ...${NC}" cat << 'EOF' > package.json { "name": "ink-uploader", "version": "1.0.0", "type": "module", "scripts": { "build": "tsc", "start": "node dist/index.js" }, "dependencies": { "chalk": "^5.3.0", "ink": "^4.4.1", "react": "^18.2.0", "typescript": "^5.3.3", "@types/react": "^18.2.48", "@types/node": "^20.10.5" } } EOF # 3. 写入 tsconfig.json (配置编译输出到 dist 目录) echo -e "${GREEN}📄 创建 tsconfig.json ...${NC}" cat << 'EOF' > tsconfig.json { "compilerOptions": { "target": "ESNext", "module": "NodeNext", "moduleResolution": "NodeNext", "jsx": "react-jsx", "strict": false, "outDir": "./dist", "rootDir": "./", "esModuleInterop": true, "skipLibCheck": true, "allowSyntheticDefaultImports": true }, "include": ["*.ts", "*.tsx"], "exclude": ["node_modules", "dist"] } EOF # 4. 写入 config.ts (硬编码路径) echo -e "${GREEN}📄 创建 config.ts ...${NC}" cat << EOF > config.ts export const CONFIG = { // Rclone 配置名称 (根据你的实际情况修改) REMOTE_NAME: 'alist', // 你的硬编码扫描路径 LOCAL_ROOT: '${TARGET_PATH}', // 阿里云盘路径 ALIYUN_PATH: '/aliyun', // 百度网盘路径 BAIDU_PATH: '/baidu', // 日志文件路径 LOG_FILE: '${LOG_FILE}' }; EOF # 5. 写入 index.tsx (修复按键映射 + 编译模式兼容) echo -e "${GREEN}📄 创建 index.tsx ...${NC}" cat << 'EOF' > index.tsx import React, { useState, useEffect } from 'react'; import { render, Box, Text, useInput, useApp } from 'ink'; import fs from 'fs'; import path from 'path'; import { spawn } from 'child_process'; import { CONFIG } from './config.js'; // 编译后引用的是 .js // --- 工具函数:获取目录内容 --- const getDirContent = (dirPath: string) => { try { if (!fs.existsSync(dirPath)) return []; const items = fs.readdirSync(dirPath, { withFileTypes: true }); return items.sort((a, b) => { if (a.isDirectory() && !b.isDirectory()) return -1; if (!a.isDirectory() && b.isDirectory()) return 1; return a.name.localeCompare(b.name); }); } catch (e) { return []; } }; // --- 组件:文件列表项 --- const FileItem = ({ item, isSelected, isCursor }: any) => { const icon = item.isDirectory() ? '📂' : '📄'; const color = isCursor ? 'cyan' : (isSelected ? 'green' : 'white'); return ( <Box> <Text color={color} bold={isCursor}> {isCursor ? '> ' : ' '} {isSelected ? '[✔] ' : '[ ] '} {icon} {item.name} </Text> </Box> ); }; // --- 主应用组件 --- const App = () => { const { exit } = useApp(); // 确保初始路径存在,不存在则使用配置的根目录(即便它可能为空或报错) const initialPath = CONFIG.LOCAL_ROOT; const [currentPath, setCurrentPath] = useState(initialPath); const [cursor, setCursor] = useState(0); const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set()); const [files, setFiles] = useState<fs.Dirent[]>([]); const VISIBLE_ROWS = 10; const [scrollOffset, setScrollOffset] = useState(0); useEffect(() => { setFiles(getDirContent(currentPath)); setCursor(0); setScrollOffset(0); }, [currentPath]); useInput((input, key) => { if (input === 'q') exit(); // Ink v4+ 使用 upArrow / downArrow if (key.upArrow) { const newCursor = Math.max(0, cursor - 1); setCursor(newCursor); if (newCursor < scrollOffset) setScrollOffset(newCursor); } if (key.downArrow) { const newCursor = Math.min(files.length - 1, cursor + 1); setCursor(newCursor); if (newCursor >= scrollOffset + VISIBLE_ROWS) setScrollOffset(newCursor - VISIBLE_ROWS + 1); } if (key.return) { const file = files[cursor]; if (file && file.isDirectory()) { setCurrentPath(path.join(currentPath, file.name)); } } if (key.delete || key.backspace) { const parent = path.dirname(currentPath); // 防止退回到 LOCAL_ROOT 及其父级以外(可选,这里允许自由浏览) if (parent !== currentPath) { setCurrentPath(parent); } } if (input === ' ') { const file = files[cursor]; if (file) { const fullPath = path.join(currentPath, file.name); const newSet = new Set(selectedPaths); if (newSet.has(fullPath)) newSet.delete(fullPath); else newSet.add(fullPath); setSelectedPaths(newSet); } } if (input === 'd' && selectedPaths.size > 0) { exit(); // 延时启动后台任务,防止 Ink 退出时的输出冲突 setTimeout(() => startBackgroundProcess(Array.from(selectedPaths)), 100); } }); const visibleFiles = files.slice(scrollOffset, scrollOffset + VISIBLE_ROWS); const isEmpty = files.length === 0; return ( <Box flexDirection="column" padding={1} borderStyle="round" borderColor="cyan"> <Box marginBottom={1}> <Text bold backgroundColor="blue"> Ink Rclone Uploader </Text> </Box> <Box marginBottom={1}> <Text color="gray">位置: {currentPath}</Text> </Box> <Box flexDirection="column" minHeight={VISIBLE_ROWS}> {isEmpty ? ( <Text color="red" italic>目录为空 或 路径不存在</Text> ) : ( visibleFiles.map((file, index) => ( <FileItem key={file.name} item={file} isCursor={cursor === index + scrollOffset} isSelected={selectedPaths.has(path.join(currentPath, file.name))} /> )) )} </Box> <Box marginTop={1} borderStyle="single" borderColor="gray"> <Text>已选: {selectedPaths.size} | [Space]选择 [d]上传 [q]退出</Text> </Box> </Box> ); }; // --- 后台任务逻辑 --- function startBackgroundProcess(items: string[]) { console.log('正在启动后台上传任务...'); const script = ` const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const LOG = '${CONFIG.LOG_FILE}'; const REMOTE = '${CONFIG.REMOTE_NAME}'; const ALIYUN = '${CONFIG.ALIYUN_PATH}'; const BAIDU = '${CONFIG.BAIDU_PATH}'; const items = ${JSON.stringify(items)}; function log(msg) { try { fs.appendFileSync(LOG, new Date().toLocaleString() + ' - ' + msg + '\\n'); } catch(e) {} } function runRclone(source, destBase, label) { const name = path.basename(source); let finalDest = destBase; // 如果是目录,保持目录结构 if (fs.existsSync(source) && fs.statSync(source).isDirectory()) { finalDest = destBase + '/' + name; try { spawnSync('rclone', ['mkdir', REMOTE + ':' + finalDest]); } catch(e){} } log('开始 [' + label + ']: ' + name); const res = spawnSync('rclone', [ 'copy', source, REMOTE + ':' + finalDest, '--transfers=4', '--checkers=8', '--ignore-existing', '--log-file=' + LOG, '--log-level=INFO' ]); if (res.status === 0) log('✅ 成功 [' + label + ']: ' + name); else log('❌ 失败 [' + label + ']: ' + name); } log('=== 批量任务启动 (' + items.length + ' 项) ==='); items.forEach(item => { runRclone(item, ALIYUN, '阿里云盘'); runRclone(item, BAIDU, '百度网盘'); }); log('=== 任务结束 ==='); `; const child = spawn(process.execPath, ['-e', script], { detached: true, stdio: 'ignore' }); child.unref(); console.log(`✅ 后台任务已分离 PID: ${child.pid}`); console.log(`📄 日志文件: ${CONFIG.LOG_FILE}`); } render(<App />); EOF # 6. 安装与构建 echo -e "${CYAN}📦 正在安装依赖 (这可能需要一点时间)...${NC}" npm install echo -e "${CYAN}🔨 正在编译 TypeScript (解决内存溢出问题)...${NC}" npm run build echo -e "\n${CYAN}==============================================${NC}" echo -e "${GREEN}✅ 安装完毕!${NC}" echo -e "${CYAN}请进入目录并启动:${NC}" echo -e "" echo -e "${GREEN} cd $PROJECT_DIR${NC}" echo -e "${GREEN} npm start${NC}" echo -e "" echo -e "${CYAN}==============================================${NC}" ``` ### python版本 ```bash #!/bin/bash # ================= 配置区域 ================= PROJECT_NAME="rclone-uploader-py" # 硬编码扫描路径 TARGET_PATH="/home/bosaidon/downloads/qbittorrent" # =========================================== # 检查 Python3 是否安装 if ! command -v python3 &> /dev/null; then echo "❌ 错误: 未检测到 Python3,请先安装。" exit 1 fi echo "🔄 正在准备 Python 环境..." # 1. 清理旧项目 (如果存在) if [ -d "$PROJECT_NAME" ]; then echo "🗑️ 检测到旧目录,正在清理..." rm -rf "$PROJECT_NAME" fi # 2. 创建目录并进入 mkdir "$PROJECT_NAME" cd "$PROJECT_NAME" # 3. 创建虚拟环境 (venv) # 这是现代 Python 开发的标准做法,避免权限问题和系统冲突 echo "📦 正在创建 Python 虚拟环境..." python3 -m venv venv # 4. 激活环境并安装依赖 echo "⬇️ 正在安装依赖 (questionary)..." # 使用虚拟环境中的 pip ./venv/bin/pip install questionary --quiet --disable-pip-version-check # 5. 写入 Python 脚本文件 echo "📝 正在生成 upload.py..." cat << 'EOF' > upload.py #!/usr/bin/env python3 # -*- coding: utf-8 -*- import os import subprocess import sys import time from pathlib import Path import questionary from questionary import Style # ================= 配置区域 ================= # 1. Rclone 配置的名称 RCLONE_REMOTE = "alist" # 2. 本地下载目录 LOCAL_DIR_ROOT = Path("/home/bosaidon/downloads/qbittorrent") # 3. 阿里云盘在 Alist 中的路径 ALIYUN_PATH = "/aliyun" # 4. 百度网盘在 Alist 中的路径 BAIDU_PATH = "/baidu" # 5. 日志文件路径 LOG_FILE = Path("/home/bosaidon/py_rclone_upload.log") # =========================================== # 自定义界面样式 custom_style = Style([ ('qmark', 'fg:#673ab7 bold'), # 问题标记的颜色 ('question', 'bold'), # 问题文本的颜色 ('answer', 'fg:#f44336 bold'), # 回答的颜色 ('pointer', 'fg:#673ab7 bold'), # 指针选择时的颜色 ('highlighted', 'fg:#673ab7 bold'), # 高亮选项的颜色 ('selected', 'fg:#cc5454'), # 选中项的颜色 ('separator', 'fg:#cc5454'), # 分隔符颜色 ('instruction', '',), # 指引说明文字 ]) def rclone_cmd(args, background=False): """执行 rclone 命令的包装器""" cmd = ["rclone", *args, f"--log-file={LOG_FILE}", "--log-level=INFO"] if background: # 在后台执行,完全脱离当前进程 # 使用 Popen 并重定向标准输入输出到 /dev/null with open(os.devnull, 'r+b', 0) as DEVNULL: subprocess.Popen( cmd, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL, start_new_session=True, # 关键:创建新会话,脱离终端 close_fds=True ) return True else: # 前台阻塞执行(用于 mkdir 等需要立即知道结果的操作) try: # 为了防止 mkdir 输出错误信息到屏幕,捕捉 stderr subprocess.run(cmd, check=True, stderr=subprocess.PIPE) return True except subprocess.CalledProcessError: return False def write_log(message, level="INFO"): timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) with open(LOG_FILE, "a", encoding='utf-8') as f: f.write(f"{timestamp} - [{level}] - {message}\n") def perform_upload_task(item_path: Path, cloud_base_path, drive_name): """实际执行单个上传任务的逻辑""" item_name = item_path.name write_log(f"开始处理: {item_name} -> {drive_name}", "START") final_dest = cloud_base_path # 如果是目录,需要特殊处理: # 1. 目标路径要加上目录名 # 2. 需要预先创建云端目录以规避 WebDAV 405 错误 if item_path.is_dir(): final_dest = f"{cloud_base_path}/{item_name}" # 尝试创建目录 (前台运行,因为必须先完成) rclone_cmd(["mkdir", f"{RCLONE_REMOTE}:{final_dest}"]) # 执行上传命令 (后台运行) # 使用 copy,rclone 会自动处理文件到目录,或者目录到目录的复制 rclone_cmd([ "copy", str(item_path), f"{RCLONE_REMOTE}:{final_dest}", "--transfers=4", "--checkers=8", "--ignore-existing" ], background=True) write_log(f"已提交后台上传: {item_name} -> {drive_name}", "SUBMIT") # ================= 主交互逻辑 ================= def main(): if not LOCAL_DIR_ROOT.exists(): print(f"❌ 错误: 找不到根目录 {LOCAL_DIR_ROOT}") return current_path = LOCAL_DIR_ROOT # 使用 Set 存储已选择的路径,避免重复,且方便增删 upload_queue = set() while True: # 1. 获取当前目录下的内容,并排序(文件夹在前) try: items = sorted(list(current_path.iterdir()), key=lambda p: (p.is_file(), p.name.lower())) except PermissionError: print("❌ 权限不足,无法访问该目录。") current_path = current_path.parent continue # 2. 构建菜单选项 choices = [] # 如果不在根目录,添加“返回上一级” if current_path != LOCAL_DIR_ROOT: choices.append(questionary.Choice( title="[..] 🔙 返回上一级", value="GO_UP" )) # 添加核心功能选项 queue_count = len(upload_queue) status_text = f"✅ 完成选择并上传 (已选 {queue_count} 项)" if queue_count > 0 else "❌ 退出 (未选择)" choices.append(questionary.Choice( title=status_text, value="DONE" )) choices.append(questionary.Separator('-- 文件列表 --')) # 遍历文件和目录并添加到选项 for item in items: prefix = "📁" if item.is_dir() else "📄" # 判断是否已在队列中,显示不同状态 if item in upload_queue: title = f"[{prefix} 已选] {item.name}" else: title = f"{prefix} {item.name}" choices.append(questionary.Choice( title=title, value=item )) # 3. 显示交互菜单 # 使用 clear=True 可以在每次刷新时清屏,体验更好 os.system('cls' if os.name == 'nt' else 'clear') print(f"\n当前位置: 📂 {current_path}") print(f"待上传清单: {len(upload_queue)} 个项目\n") selection = questionary.select( "请浏览目录,选中文件/文件夹加入清单,或进入目录:", choices=choices, style=custom_style, qmark="▶", pointer="→", use_indicator=True ).ask() # 4. 处理用户的选择 if selection is None: # 用户按了 Ctrl+C print("已取消。") return if selection == "DONE": break # 跳出循环,开始上传 elif selection == "GO_UP": current_path = current_path.parent elif isinstance(selection, Path): # 如果选择的是一个路径对象 if selection.is_dir(): # 关键交互设计:如果是目录,询问是进入还是加入清单 action = questionary.select( f"对目录 '{selection.name}' 执行什么操作?", choices=[ questionary.Choice("↪️ 进入此目录浏览", value="ENTER"), questionary.Choice("➕ 将整个目录加入上传清单", value="ADD"), questionary.Choice("🔙 取消", value="CANCEL"), ], style=custom_style ).ask() if action == "ENTER": current_path = selection elif action == "ADD": upload_queue.add(selection) else: # 如果是文件,直接切换选中状态 if selection in upload_queue: upload_queue.remove(selection) else: upload_queue.add(selection) # ================= 后台上传处理 ================= if not upload_queue: print("未选择任何文件,退出。") return print("\n" + "="*40) print(f"准备开始后台上传 {len(upload_queue)} 个项目...") write_log("=== 新的上传会话开始 ===", "SESSION_START") # 将 Set 转换为 List 进行迭代 final_list = list(upload_queue) for item_path in final_list: # 对每个项目,分别触发上传到阿里云和百度云的任务 perform_upload_task(item_path, ALIYUN_PATH, "阿里云盘") perform_upload_task(item_path, BAIDU_PATH, "百度网盘") print(f"✅ 已成功将所有任务提交至后台。") print(f"📄 请通过日志文件跟踪进度: tail -f {LOG_FILE}") print("="*40) print("脚本即将退出,终端可安全关闭。") time.sleep(1) if __name__ == "__main__": if not LOG_FILE.exists(): LOG_FILE.touch() main() EOF echo "✅ Python 环境部署完成!正在启动..." echo "----------------------------------------" # 6. 使用虚拟环境中的 python 运行脚本 ./venv/bin/python3 upload.py ``` 最后修改:2025 年 12 月 16 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 如果觉得我的文章对你有用,请随意赞赏