c / app.py
22333Misaka's picture
Update app.py
3dd7b99 verified
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):
@wraps(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>
"""
# --- 路由逻辑 (保持不变) ---
@app.route('/login', methods=['GET', 'POST'])
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)
@app.route('/logout')
def logout():
session.pop('logged_in', None)
return redirect(url_for('login'))
@app.route('/')
@login_required
def index():
return render_template_string(INDEX_HTML)
@app.route('/api/config', methods=['GET', 'POST'])
@login_required
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
})
@app.route('/api/change_pass', methods=['POST'])
@login_required
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': '无效密码'})
@app.route('/api/logs')
@login_required
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)