Spaces:
Running
Running
| import os | |
| import time | |
| import threading | |
| import requests | |
| import logging | |
| import secrets | |
| from functools import wraps | |
| from datetime import datetime | |
| from flask import Flask, render_template_string, request, jsonify, session, redirect, url_for | |
| from flask_sqlalchemy import SQLAlchemy | |
| # ================= 配置区域 ================= | |
| DEFAULT_DB_URI = 'postgresql://postgres:password@192.168.1.10:5432/alist_sync' | |
| DB_URI = os.environ.get('DB_URI', DEFAULT_DB_URI) | |
| # 兼容性修复 | |
| if DB_URI and DB_URI.startswith("postgres://"): | |
| DB_URI = DB_URI.replace("postgres://", "postgresql://", 1) | |
| PORT = int(os.environ.get('PORT', 5000)) | |
| # =========================================== | |
| app = Flask(__name__) | |
| app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(16)) | |
| app.config['SQLALCHEMY_DATABASE_URI'] = DB_URI | |
| app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False | |
| app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {'pool_pre_ping': True} | |
| db = SQLAlchemy(app) | |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s') | |
| logger = logging.getLogger(__name__) | |
| # --- 数据库模型 --- | |
| class AppConfig(db.Model): | |
| __tablename__ = 'config' | |
| id = db.Column(db.Integer, primary_key=True) | |
| url = db.Column(db.String(255), default="http://localhost:5244") | |
| username = db.Column(db.String(255), default="admin") | |
| password = db.Column(db.String(255), default="") | |
| path1 = db.Column(db.String(500), default="") | |
| path2 = db.Column(db.String(500), default="") | |
| interval = db.Column(db.Integer, default=60) | |
| auto_sync = db.Column(db.Boolean, default=False) | |
| web_password = db.Column(db.String(255), default="123456") | |
| class AppLog(db.Model): | |
| __tablename__ = 'logs' | |
| id = db.Column(db.Integer, primary_key=True) | |
| timestamp = db.Column(db.DateTime, default=datetime.now) | |
| message = db.Column(db.Text) | |
| # --- 装饰器与逻辑 --- | |
| def login_required(f): | |
| def decorated_function(*args, **kwargs): | |
| if 'logged_in' not in session: | |
| return redirect(url_for('login')) | |
| return f(*args, **kwargs) | |
| return decorated_function | |
| def add_log(msg): | |
| print(f"[Log] {msg}") | |
| try: | |
| with app.app_context(): | |
| log = AppLog(message=str(msg), timestamp=datetime.now()) | |
| db.session.add(log) | |
| db.session.commit() | |
| # 自动清理日志 | |
| last = db.session.query(db.func.max(AppLog.id)).scalar() | |
| if last and last > 1000: | |
| AppLog.query.filter(AppLog.id < (last - 500)).delete() | |
| db.session.commit() | |
| except: | |
| pass | |
| def get_token(base_url, username, password): | |
| try: | |
| res = requests.post(f"{base_url.rstrip('/')}/api/auth/login", | |
| json={'username': username, 'password': password}, timeout=10) | |
| if res.status_code == 200: | |
| return res.json().get('data', {}).get('token') | |
| except Exception as e: | |
| add_log(f"登录失败: {e}") | |
| return None | |
| def list_files(base_url, token, path): | |
| try: | |
| res = requests.post(f"{base_url.rstrip('/')}/api/fs/list", | |
| headers={'Authorization': token}, | |
| json={"path": path, "password": "", "page": 1, "per_page": 0, "refresh": True}, timeout=30) | |
| if res.status_code == 200: | |
| return res.json().get('data', {}).get('content', []) | |
| except Exception as e: | |
| add_log(f"获取列表失败: {e}") | |
| return [] | |
| def copy_files_api(base_url, token, src_dir, dst_dir, file_names): | |
| try: | |
| res = requests.post(f"{base_url.rstrip('/')}/api/fs/copy", | |
| headers={'Authorization': token}, | |
| json={"src_dir": src_dir, "dst_dir": dst_dir, "names": file_names}, timeout=30) | |
| return res.json() | |
| except: | |
| return {"code": 500} | |
| # --- 后台任务 --- | |
| def background_worker(): | |
| while True: | |
| try: | |
| with app.app_context(): | |
| cfg = AppConfig.query.first() | |
| if not cfg: | |
| db.session.add(AppConfig(web_password="123456")) | |
| db.session.commit() | |
| continue | |
| if cfg.auto_sync: | |
| interval = max(1, cfg.interval) | |
| token = get_token(cfg.url, cfg.username, cfg.password) | |
| if token: | |
| src = list_files(cfg.url, token, cfg.path1) | |
| dst = list_files(cfg.url, token, cfg.path2) | |
| if src: | |
| dst_names = {f['name'] for f in dst} | |
| missing = [i['name'] for i in src if i['name'] not in dst_names] | |
| if missing: | |
| add_log(f"自动同步: 发现 {len(missing)} 个文件,开始复制") | |
| for i in range(0, len(missing), 20): | |
| copy_files_api(cfg.url, token, cfg.path1, cfg.path2, missing[i:i+20]) | |
| time.sleep(interval * 60) | |
| else: | |
| time.sleep(10) | |
| except Exception as e: | |
| print(f"后台错误: {e}") | |
| time.sleep(60) | |
| # ========================================== | |
| # Material 3 风格前端模板 | |
| # ========================================== | |
| # 通用头部,包含 Material Web 组件加载 | |
| HEAD_COMMON = """ | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Alist Sync Panel</title> | |
| <!-- Roboto Font & Icons --> | |
| <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet"> | |
| <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet"> | |
| <!-- Material Web Components (ESM) --> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "@material/web/": "https://esm.run/@material/web/" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import '@material/web/all.js'; | |
| import {styles as typescaleStyles} from '@material/web/typography/md-typescale-styles.js'; | |
| document.adoptedStyleSheets.push(typescaleStyles.styleSheet); | |
| </script> | |
| <style> | |
| :root { | |
| /* M3 Color Tokens (Baseline Purple) */ | |
| --md-sys-color-primary: #6750A4; | |
| --md-sys-color-on-primary: #FFFFFF; | |
| --md-sys-color-primary-container: #EADDFF; | |
| --md-sys-color-on-primary-container: #21005D; | |
| --md-sys-color-secondary: #625B71; | |
| --md-sys-color-surface: #FEF7FF; | |
| --md-sys-color-surface-container: #F3EDF7; | |
| --md-sys-color-on-surface: #1D1B20; | |
| --md-sys-color-outline: #79747E; | |
| --md-sys-color-error: #B3261E; | |
| --md-ref-typeface-plain: 'Roboto', sans-serif; | |
| } | |
| body { | |
| font-family: var(--md-ref-typeface-plain); | |
| background-color: var(--md-sys-color-surface); | |
| color: var(--md-sys-color-on-surface); | |
| margin: 0; | |
| padding: 0; | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| /* Utilities */ | |
| .d-flex { display: flex; } | |
| .flex-column { flex-direction: column; } | |
| .align-center { align-items: center; } | |
| .justify-center { justify_content: center; } | |
| .gap-1 { gap: 8px; } | |
| .gap-2 { gap: 16px; } | |
| .w-100 { width: 100%; } | |
| .mt-2 { margin-top: 16px; } | |
| /* Card Style using CSS (Lightweight) */ | |
| .m3-card { | |
| background: var(--md-sys-color-surface-container); | |
| border-radius: 16px; | |
| padding: 24px; | |
| box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.15); | |
| } | |
| /* Scrollbar */ | |
| ::-webkit-scrollbar { width: 8px; height: 8px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #aaa; } | |
| </style> | |
| </head> | |
| """ | |
| LOGIN_HTML = HEAD_COMMON + """ | |
| <body> | |
| <div style="display:flex; justify-content:center; align-items:center; height:100%; width:100%;"> | |
| <div class="m3-card" style="width: 100%; max-width: 400px; display:flex; flex-direction:column; gap:24px;"> | |
| <div style="text-align: center;"> | |
| <span class="material-symbols-outlined" style="font-size: 48px; color: var(--md-sys-color-primary);">sync_lock</span> | |
| <h1 class="md-typescale-headline-small" style="margin: 8px 0;">Alist Sync</h1> | |
| <p class="md-typescale-body-medium" style="color: var(--md-sys-color-secondary); margin:0;">请输入面板访问密码</p> | |
| </div> | |
| {% if error %} | |
| <div style="color: var(--md-sys-color-error); background: #f9dedc; padding: 10px; border-radius: 8px; font-size: 14px; display:flex; align-items:center; gap:8px;"> | |
| <span class="material-symbols-outlined" style="font-size:18px;">error</span> {{ error }} | |
| </div> | |
| {% endif %} | |
| <form method="post" style="display:flex; flex-direction:column; gap:16px;"> | |
| <md-outlined-text-field | |
| label="Password" | |
| type="password" | |
| name="password" | |
| required | |
| style="width: 100%;"> | |
| <md-icon slot="leading-icon">key</md-icon> | |
| </md-outlined-text-field> | |
| <md-filled-button type="submit" style="width: 100%;"> | |
| 登 录 | |
| </md-filled-button> | |
| </form> | |
| <div style="text-align:center;"> | |
| <span class="md-typescale-label-small" style="color: var(--md-sys-color-outline);">Default: 123456</span> | |
| </div> | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| INDEX_HTML = HEAD_COMMON + """ | |
| <body style="overflow-y: auto;"> | |
| <!-- App Bar --> | |
| <header style="background: var(--md-sys-color-surface-container); padding: 16px 24px; display:flex; justify-content:space-between; align-items:center; position:sticky; top:0; z-index:1000; box-shadow: 0 2px 4px rgba(0,0,0,0.05);"> | |
| <div class="d-flex align-center gap-2"> | |
| <span class="material-symbols-outlined" style="color: var(--md-sys-color-primary); font-size: 28px;">cloud_sync</span> | |
| <span class="md-typescale-title-large">Alist Sync Panel</span> | |
| </div> | |
| <div> | |
| <md-text-button href="/logout" onclick="window.location.href='/logout'"> | |
| <md-icon slot="icon">logout</md-icon> | |
| 退出 | |
| </md-text-button> | |
| </div> | |
| </header> | |
| <main style="padding: 24px; max-width: 1200px; margin: 0 auto; width: 100%; box-sizing: border-box; display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 24px;"> | |
| <!-- Config Card --> | |
| <div class="m3-card" style="grid-column: span 2;"> | |
| <div class="d-flex align-center gap-2" style="margin-bottom: 24px;"> | |
| <md-icon style="color: var(--md-sys-color-primary);">settings</md-icon> | |
| <span class="md-typescale-title-medium">同步配置</span> | |
| </div> | |
| <form id="configForm" style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;"> | |
| <!-- Full Width --> | |
| <md-outlined-text-field label="Alist URL" id="url" style="grid-column: span 2;"> | |
| <md-icon slot="leading-icon">link</md-icon> | |
| </md-outlined-text-field> | |
| <!-- Half Width --> | |
| <md-outlined-text-field label="Username" id="username"> | |
| <md-icon slot="leading-icon">person</md-icon> | |
| </md-outlined-text-field> | |
| <md-outlined-text-field label="Password" id="password" type="password"> | |
| <md-icon slot="leading-icon">lock</md-icon> | |
| </md-outlined-text-field> | |
| <div style="grid-column: span 2; height: 1px; background: var(--md-sys-color-outline); opacity: 0.2; margin: 8px 0;"></div> | |
| <md-outlined-text-field label="Source Path (源路径)" id="path1" placeholder="/阿里云盘/Video"> | |
| <md-icon slot="leading-icon">folder_open</md-icon> | |
| </md-outlined-text-field> | |
| <md-outlined-text-field label="Target Path (目标路径)" id="path2" placeholder="/本地存储/Video"> | |
| <md-icon slot="leading-icon">save_alt</md-icon> | |
| </md-outlined-text-field> | |
| <div class="d-flex align-center gap-2" style="grid-column: span 2; margin-top: 16px; background: rgba(0,0,0,0.03); padding: 16px; border-radius: 12px; justify-content: space-between;"> | |
| <div class="d-flex align-center gap-2"> | |
| <md-outlined-text-field label="间隔 (分钟)" id="interval" type="number" style="width: 120px;" value="60"></md-outlined-text-field> | |
| <label style="display:flex; align-items:center; gap:12px; cursor:pointer;"> | |
| <md-switch id="auto_sync" icons></md-switch> | |
| <span class="md-typescale-body-large">开启后台自动同步</span> | |
| </label> | |
| </div> | |
| <md-filled-button type="button" id="saveBtn"> | |
| <md-icon slot="icon">save</md-icon> | |
| 保存并应用 | |
| </md-filled-button> | |
| </div> | |
| </form> | |
| </div> | |
| <!-- Status & Security Card --> | |
| <div class="d-flex flex-column gap-2"> | |
| <!-- Status --> | |
| <div class="m3-card" style="flex: 1;"> | |
| <div class="d-flex align-center gap-2" style="margin-bottom: 16px;"> | |
| <md-icon style="color: var(--md-sys-color-primary);">analytics</md-icon> | |
| <span class="md-typescale-title-medium">运行状态</span> | |
| </div> | |
| <div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height: 120px;"> | |
| <div id="status-indicator" style="display:flex; align-items:center; gap:8px; margin-bottom:8px;"> | |
| <md-icon id="status-icon" style="font-size: 48px;">stop_circle</md-icon> | |
| </div> | |
| <span id="status-text" class="md-typescale-headline-small">已停止</span> | |
| </div> | |
| </div> | |
| <!-- Security --> | |
| <div class="m3-card"> | |
| <div class="d-flex align-center gap-2" style="margin-bottom: 16px;"> | |
| <md-icon style="color: var(--md-sys-color-primary);">security</md-icon> | |
| <span class="md-typescale-title-medium">修改面板密码</span> | |
| </div> | |
| <div class="d-flex gap-2"> | |
| <md-outlined-text-field label="新密码" id="new_pass" type="password" style="flex:1;"></md-outlined-text-field> | |
| <md-tonal-button id="changePassBtn"> | |
| 修改 | |
| </md-tonal-button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Logs Card --> | |
| <div class="m3-card" style="grid-column: span 2; display: flex; flex-direction: column; height: 500px;"> | |
| <div class="d-flex align-center justify-between" style="margin-bottom: 16px; justify-content: space-between;"> | |
| <div class="d-flex align-center gap-2"> | |
| <md-icon style="color: var(--md-sys-color-primary);">terminal</md-icon> | |
| <span class="md-typescale-title-medium">系统日志</span> | |
| </div> | |
| <md-icon-button id="refreshLogBtn"> | |
| <md-icon>refresh</md-icon> | |
| </md-icon-button> | |
| </div> | |
| <div id="logContainer" style="flex: 1; background: #1e1e1e; border-radius: 8px; padding: 16px; overflow-y: auto; font-family: 'Roboto Mono', monospace; font-size: 13px; color: #00e676;"> | |
| 加载中... | |
| </div> | |
| </div> | |
| </main> | |
| <!-- Toast Notification --> | |
| <div id="toast" style="position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--md-sys-color-on-surface); color: var(--md-sys-color-surface); padding: 12px 24px; border-radius: 24px; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 2000; box-shadow: 0 4px 8px rgba(0,0,0,0.2);"> | |
| Notification | |
| </div> | |
| <script> | |
| // Helper for Toast | |
| function showToast(msg) { | |
| const t = document.getElementById('toast'); | |
| t.textContent = msg; | |
| t.style.opacity = '1'; | |
| setTimeout(() => t.style.opacity = '0', 3000); | |
| } | |
| // API Helpers | |
| async function fetchAPI(url, method='GET', data=null) { | |
| const opts = { method: method, headers: {'Content-Type': 'application/json'} }; | |
| if (data) opts.body = JSON.stringify(data); | |
| const res = await fetch(url, opts); | |
| return res.json(); | |
| } | |
| // Load Data | |
| async function loadConfig() { | |
| try { | |
| const data = await fetchAPI('/api/config'); | |
| // Populate Fields | |
| document.getElementById('url').value = data.url; | |
| document.getElementById('username').value = data.username; | |
| document.getElementById('password').value = data.password; | |
| document.getElementById('path1').value = data.path1; | |
| document.getElementById('path2').value = data.path2; | |
| document.getElementById('interval').value = data.interval; | |
| document.getElementById('auto_sync').selected = data.auto_sync; | |
| updateStatusUI(data.auto_sync); | |
| } catch(e) { | |
| console.error(e); | |
| } | |
| } | |
| function updateStatusUI(isRunning) { | |
| const icon = document.getElementById('status-icon'); | |
| const text = document.getElementById('status-text'); | |
| if (isRunning) { | |
| icon.textContent = 'sync'; | |
| icon.style.color = '#00c853'; // Green | |
| icon.style.animation = 'spin 2s linear infinite'; | |
| text.textContent = '后台运行中'; | |
| text.style.color = '#00c853'; | |
| } else { | |
| icon.textContent = 'pause_circle'; | |
| icon.style.color = '#b00020'; // Red | |
| icon.style.animation = 'none'; | |
| text.textContent = '已停止'; | |
| text.style.color = '#b00020'; | |
| } | |
| } | |
| // Events | |
| document.getElementById('saveBtn').addEventListener('click', async () => { | |
| const payload = { | |
| url: document.getElementById('url').value, | |
| username: document.getElementById('username').value, | |
| password: document.getElementById('password').value, | |
| path1: document.getElementById('path1').value, | |
| path2: document.getElementById('path2').value, | |
| interval: parseInt(document.getElementById('interval').value), | |
| auto_sync: document.getElementById('auto_sync').selected | |
| }; | |
| await fetchAPI('/api/config', 'POST', payload); | |
| showToast('配置已保存并应用'); | |
| updateStatusUI(payload.auto_sync); | |
| loadLogs(); | |
| }); | |
| document.getElementById('changePassBtn').addEventListener('click', async () => { | |
| const pass = document.getElementById('new_pass').value; | |
| if(!pass) return showToast('密码不能为空'); | |
| const res = await fetch('/api/change_pass', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/x-www-form-urlencoded'}, | |
| body: 'password=' + encodeURIComponent(pass) | |
| }); | |
| const json = await res.json(); | |
| showToast(json.msg); | |
| document.getElementById('new_pass').value = ''; | |
| }); | |
| async function loadLogs() { | |
| const logs = await fetchAPI('/api/logs'); | |
| const container = document.getElementById('logContainer'); | |
| container.innerHTML = logs.map(l => | |
| `<div style="margin-bottom:4px; border-bottom: 1px solid #333; padding-bottom: 2px;"> | |
| <span style="color:#888;">[${l.timestamp}]</span> ${l.message} | |
| </div>` | |
| ).join(''); | |
| } | |
| document.getElementById('refreshLogBtn').addEventListener('click', loadLogs); | |
| // Add CSS Animation for spinner | |
| const styleSheet = document.createElement("style"); | |
| styleSheet.innerText = `@keyframes spin { 100% { transform: rotate(360deg); } }`; | |
| document.head.appendChild(styleSheet); | |
| // Init | |
| loadConfig(); | |
| loadLogs(); | |
| setInterval(loadLogs, 10000); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # --- 路由逻辑 (保持不变) --- | |
| def login(): | |
| error = None | |
| if request.method == 'POST': | |
| input_pass = request.form.get('password') | |
| with app.app_context(): | |
| cfg = AppConfig.query.first() | |
| real_pass = cfg.web_password if cfg else "123456" | |
| if input_pass == real_pass: | |
| session['logged_in'] = True | |
| return redirect(url_for('index')) | |
| else: | |
| error = "密码错误 (默认为 123456)" | |
| return render_template_string(LOGIN_HTML, error=error) | |
| def logout(): | |
| session.pop('logged_in', None) | |
| return redirect(url_for('login')) | |
| def index(): | |
| return render_template_string(INDEX_HTML) | |
| def api_config(): | |
| cfg = AppConfig.query.first() | |
| if not cfg: | |
| cfg = AppConfig(web_password="123456") | |
| db.session.add(cfg) | |
| db.session.commit() | |
| if request.method == 'POST': | |
| data = request.json | |
| cfg.url = data.get('url') | |
| cfg.username = data.get('username') | |
| cfg.password = data.get('password') | |
| cfg.path1 = data.get('path1') | |
| cfg.path2 = data.get('path2') | |
| cfg.interval = data.get('interval') | |
| cfg.auto_sync = data.get('auto_sync') | |
| db.session.commit() | |
| return jsonify({'success': True}) | |
| return jsonify({ | |
| 'url': cfg.url, 'username': cfg.username, 'password': cfg.password, | |
| 'path1': cfg.path1, 'path2': cfg.path2, | |
| 'interval': cfg.interval, 'auto_sync': cfg.auto_sync | |
| }) | |
| def api_change_pass(): | |
| new_pass = request.form.get('password') | |
| if new_pass: | |
| cfg = AppConfig.query.first() | |
| cfg.web_password = new_pass | |
| db.session.commit() | |
| return jsonify({'msg': '面板密码已修改'}) | |
| return jsonify({'msg': '无效密码'}) | |
| def api_logs(): | |
| logs = AppLog.query.order_by(AppLog.timestamp.desc()).limit(100).all() | |
| return jsonify([{'timestamp': l.timestamp.strftime("%H:%M:%S"), 'message': l.message} for l in logs]) | |
| if __name__ == '__main__': | |
| with app.app_context(): | |
| try: | |
| db.create_all() | |
| except: pass | |
| t = threading.Thread(target=background_worker, daemon=True) | |
| t.start() | |
| app.run(host='0.0.0.0', port=PORT, debug=False, use_reloader=False) |