22333Misaka commited on
Commit
3dd7b99
·
verified ·
1 Parent(s): 91c3ea2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +431 -157
app.py CHANGED
@@ -3,24 +3,28 @@ import time
3
  import threading
4
  import requests
5
  import logging
 
 
6
  from datetime import datetime
7
- from flask import Flask, render_template_string, request, jsonify
8
  from flask_sqlalchemy import SQLAlchemy
9
 
10
  # ================= 配置区域 =================
11
- # 优先从环境变量获取数据库连接字符串 (适配 Docker)
12
- # 如果没有环境变量,则使用默认值 (请修改下方的默认值)
13
  DEFAULT_DB_URI = 'postgresql://postgres:password@192.168.1.10:5432/alist_sync'
14
  DB_URI = os.environ.get('DB_URI', DEFAULT_DB_URI)
15
 
 
 
 
 
16
  PORT = int(os.environ.get('PORT', 5000))
17
  # ===========================================
18
 
19
- # 初始化
20
  app = Flask(__name__)
 
21
  app.config['SQLALCHEMY_DATABASE_URI'] = DB_URI
22
  app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
23
- app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {'pool_pre_ping': True} # 自动检测断开的连接
24
 
25
  db = SQLAlchemy(app)
26
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')
@@ -33,10 +37,11 @@ class AppConfig(db.Model):
33
  url = db.Column(db.String(255), default="http://localhost:5244")
34
  username = db.Column(db.String(255), default="admin")
35
  password = db.Column(db.String(255), default="")
36
- path1 = db.Column(db.String(500), default="") # 源路径
37
- path2 = db.Column(db.String(500), default="") # 目标路径
38
- interval = db.Column(db.Integer, default=60) # 分钟
39
  auto_sync = db.Column(db.Boolean, default=False)
 
40
 
41
  class AppLog(db.Model):
42
  __tablename__ = 'logs'
@@ -44,22 +49,29 @@ class AppLog(db.Model):
44
  timestamp = db.Column(db.DateTime, default=datetime.now)
45
  message = db.Column(db.Text)
46
 
47
- # --- 工具函数 ---
 
 
 
 
 
 
 
 
48
  def add_log(msg):
49
- """写入日志到数据库 (线程安全)"""
50
- print(f"[系统日志] {msg}")
51
  try:
52
  with app.app_context():
53
  log = AppLog(message=str(msg), timestamp=datetime.now())
54
  db.session.add(log)
55
  db.session.commit()
56
- # 清理旧日志 (保留最近500条)
57
- last_id = db.session.query(db.func.max(AppLog.id)).scalar()
58
- if last_id and last_id > 1000:
59
- AppLog.query.filter(AppLog.id < (last_id - 500)).delete()
60
  db.session.commit()
61
- except Exception as e:
62
- print(f"日志写入失败: {e}")
63
 
64
  def get_token(base_url, username, password):
65
  try:
@@ -72,17 +84,15 @@ def get_token(base_url, username, password):
72
  return None
73
 
74
  def list_files(base_url, token, path):
75
- files = []
76
  try:
77
  res = requests.post(f"{base_url.rstrip('/')}/api/fs/list",
78
  headers={'Authorization': token},
79
  json={"path": path, "password": "", "page": 1, "per_page": 0, "refresh": True}, timeout=30)
80
  if res.status_code == 200:
81
- data = res.json().get('data', {}).get('content', [])
82
- if data: files = data
83
  except Exception as e:
84
- add_log(f"获取列表失败: {path} - {e}")
85
- return files
86
 
87
  def copy_files_api(base_url, token, src_dir, dst_dir, file_names):
88
  try:
@@ -90,168 +100,431 @@ def copy_files_api(base_url, token, src_dir, dst_dir, file_names):
90
  headers={'Authorization': token},
91
  json={"src_dir": src_dir, "dst_dir": dst_dir, "names": file_names}, timeout=30)
92
  return res.json()
93
- except Exception as e:
94
- return {"code": 500, "message": str(e)}
95
 
96
- # --- 后台守护线程 ---
97
  def background_worker():
98
- """后台执行自动同步任务"""
99
- add_log("✅ 后台守护线程已启动")
100
  while True:
101
  try:
102
  with app.app_context():
103
  cfg = AppConfig.query.first()
104
- if not cfg: # 初始化配���
105
- cfg = AppConfig()
106
- db.session.add(cfg)
107
  db.session.commit()
108
-
109
- if not cfg.auto_sync:
110
- time.sleep(10)
111
  continue
112
 
113
- # 开始任务
114
- interval = cfg.interval if cfg.interval > 0 else 60
115
- add_log(f"⏳ 开始执行定时同步任务 (间隔: {interval}分钟)...")
116
-
117
- token = get_token(cfg.url, cfg.username, cfg.password)
118
- if token:
119
- src_list = list_files(cfg.url, token, cfg.path1)
120
- dst_list = list_files(cfg.url, token, cfg.path2)
121
-
122
- if src_list:
123
- dst_names = {f['name'] for f in dst_list}
124
- missing_files = [item['name'] for item in src_list if item['name'] not in dst_names]
125
-
126
- if missing_files:
127
- add_log(f"发现 {len(missing_files)} 个缺失文件,开始推送任务...")
128
- # 分批处理,每批20个
129
- batch_size = 20
130
- for i in range(0, len(missing_files), batch_size):
131
- batch = missing_files[i:i+batch_size]
132
- res = copy_files_api(cfg.url, token, cfg.path1, cfg.path2, batch)
133
- if res.get('code') == 200:
134
- add_log(f"✅ 指令发送成功: {batch}")
135
- else:
136
- add_log(f"❌ 指令发送失败: {res.get('message')}")
137
- else:
138
- add_log("无差异文件,无需同步")
139
-
140
- add_log(f"💤 任务结束,休眠 {interval} 分钟")
141
- time.sleep(interval * 60)
142
-
143
  except Exception as e:
144
- add_log(f" 后台线程异常: {e}")
145
  time.sleep(60)
146
 
147
- # --- Web 路由 ---
148
- HTML_TEMPLATE = """
149
- <!DOCTYPE html>
150
- <html lang="zh-CN">
 
 
151
  <head>
152
  <meta charset="UTF-8">
153
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
154
- <title>Alist 自动同步 (PostgreSQL版)</title>
155
- <link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css" rel="stylesheet">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  <style>
157
- body { background: #f4f7f6; padding-top: 30px; }
158
- .log-box { height: 400px; overflow-y: scroll; background: #1e1e1e; color: #00ff00; padding: 10px; font-family: 'Consolas', monospace; font-size: 13px; border-radius: 5px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  </style>
160
  </head>
 
 
 
161
  <body>
162
- <div class="container">
163
- <div class="row">
164
- <div class="col-lg-8 offset-lg-2">
165
- <div class="card shadow">
166
- <div class="card-header bg-dark text-white d-flex justify-content-between">
167
- <h5 class="mb-0">Alist 增量同步控制台</h5>
168
- <span class="badge bg-info">DB: PostgreSQL/MySQL</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  </div>
170
- <div class="card-body">
171
- <form id="cfgForm">
172
- <div class="row g-3">
173
- <div class="col-md-4"><label class="form-label">Alist URL</label><input type="text" class="form-control" name="url"></div>
174
- <div class="col-md-4"><label class="form-label">用户名</label><input type="text" class="form-control" name="username"></div>
175
- <div class="col-md-4"><label class="form-label">密码</label><input type="password" class="form-control" name="password"></div>
176
- <div class="col-12"><hr></div>
177
- <div class="col-md-6"><label class="form-label">源路径 (Source)</label><input type="text" class="form-control" name="path1" placeholder="/阿里云盘/电影"></div>
178
- <div class="col-md-6"><label class="form-label">目标路径 (Target)</label><input type="text" class="form-control" name="path2" placeholder="/本地存储/电影"></div>
179
- <div class="col-md-6"><label class="form-label">同步间隔 (分钟)</label><input type="number" class="form-control" name="interval"></div>
180
- <div class="col-md-6 d-flex align-items-end">
181
- <div class="form-check form-switch mb-2">
182
- <input class="form-check-input" type="checkbox" id="auto_sync" name="auto_sync">
183
- <label class="form-check-label fw-bold" for="auto_sync">开启后台自动同步</label>
184
- </div>
185
- </div>
186
- </div>
187
- <div class="mt-4 text-end">
188
- <button type="button" class="btn btn-success" onclick="saveConfig()">💾 保存配置并应用</button>
189
- </div>
190
- </form>
191
  </div>
192
  </div>
193
 
194
- <div class="card shadow mt-4">
195
- <div class="card-header bg-secondary text-white">
196
- 运行日志 <button class="btn btn-sm btn-light float-end" onclick="loadLogs()">刷新</button>
 
 
 
 
 
 
 
 
197
  </div>
198
- <div class="card-body p-0">
199
- <div class="log-box" id="logContainer">加载中...</div>
 
 
 
 
 
 
 
200
  </div>
 
 
 
 
 
 
201
  </div>
202
  </div>
 
 
 
 
 
203
  </div>
204
- </div>
205
- <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
206
- <script>
207
- function loadData() {
208
- $.get('/api/config', function(res) {
209
- $('input[name="url"]').val(res.url);
210
- $('input[name="username"]').val(res.username);
211
- $('input[name="password"]').val(res.password);
212
- $('input[name="path1"]').val(res.path1);
213
- $('input[name="path2"]').val(res.path2);
214
- $('input[name="interval"]').val(res.interval);
215
- $('#auto_sync').prop('checked', res.auto_sync);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  });
217
- loadLogs();
218
- }
219
- function saveConfig() {
220
- const data = {
221
- url: $('input[name="url"]').val(),
222
- username: $('input[name="username"]').val(),
223
- password: $('input[name="password"]').val(),
224
- path1: $('input[name="path1"]').val(),
225
- path2: $('input[name="path2"]').val(),
226
- interval: parseInt($('input[name="interval"]').val()),
227
- auto_sync: $('#auto_sync').is(':checked')
228
- };
229
- $.ajax({ url: '/api/config', type: 'POST', contentType: 'application/json', data: JSON.stringify(data),
230
- success: function() { alert('保存成功!后台将在下一周期应用新配置。'); loadLogs(); }});
231
- }
232
- function loadLogs() {
233
- $.get('/api/logs', function(res) {
234
- let html = '';
235
- res.forEach(l => { html += `<div>[${l.timestamp}] ${l.message}</div>`; });
236
- $('#logContainer').html(html);
237
  });
238
- }
239
- loadData();
240
- setInterval(loadLogs, 10000);
241
- </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  </body>
243
  </html>
244
  """
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  @app.route('/')
 
247
  def index():
248
- return render_template_string(HTML_TEMPLATE)
249
 
250
  @app.route('/api/config', methods=['GET', 'POST'])
 
251
  def api_config():
252
  cfg = AppConfig.query.first()
253
  if not cfg:
254
- cfg = AppConfig()
255
  db.session.add(cfg)
256
  db.session.commit()
257
 
@@ -263,11 +536,8 @@ def api_config():
263
  cfg.path1 = data.get('path1')
264
  cfg.path2 = data.get('path2')
265
  cfg.interval = data.get('interval')
266
- status_change = data.get('auto_sync') != cfg.auto_sync
267
  cfg.auto_sync = data.get('auto_sync')
268
  db.session.commit()
269
- if status_change:
270
- add_log(f"用户手动{'开启' if cfg.auto_sync else '关闭'}了自动同步")
271
  return jsonify({'success': True})
272
 
273
  return jsonify({
@@ -276,25 +546,29 @@ def api_config():
276
  'interval': cfg.interval, 'auto_sync': cfg.auto_sync
277
  })
278
 
 
 
 
 
 
 
 
 
 
 
 
279
  @app.route('/api/logs')
 
280
  def api_logs():
281
- logs = AppLog.query.order_by(AppLog.timestamp.desc()).limit(50).all()
282
- return jsonify([{'timestamp': l.timestamp.strftime("%m-%d %H:%M:%S"), 'message': l.message} for l in logs])
283
 
284
- # --- 启动逻辑 ---
285
  if __name__ == '__main__':
286
- # 等待数据库准备好 (针对 Docker compose 启动慢的情况)
287
  with app.app_context():
288
  try:
289
  db.create_all()
290
- print("✅ 数据库连接成功,表结构已就绪。")
291
- except Exception as e:
292
- print(f"❌ 数据库连接失败,请检查配置: {e}")
293
- # 不退出,让用户看报错信息
294
 
295
- # 启动后台线程
296
  t = threading.Thread(target=background_worker, daemon=True)
297
  t.start()
298
-
299
- # 启动 Flask
300
  app.run(host='0.0.0.0', port=PORT, debug=False, use_reloader=False)
 
3
  import threading
4
  import requests
5
  import logging
6
+ import secrets
7
+ from functools import wraps
8
  from datetime import datetime
9
+ from flask import Flask, render_template_string, request, jsonify, session, redirect, url_for
10
  from flask_sqlalchemy import SQLAlchemy
11
 
12
  # ================= 配置区域 =================
 
 
13
  DEFAULT_DB_URI = 'postgresql://postgres:password@192.168.1.10:5432/alist_sync'
14
  DB_URI = os.environ.get('DB_URI', DEFAULT_DB_URI)
15
 
16
+ # 兼容性修复
17
+ if DB_URI and DB_URI.startswith("postgres://"):
18
+ DB_URI = DB_URI.replace("postgres://", "postgresql://", 1)
19
+
20
  PORT = int(os.environ.get('PORT', 5000))
21
  # ===========================================
22
 
 
23
  app = Flask(__name__)
24
+ app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(16))
25
  app.config['SQLALCHEMY_DATABASE_URI'] = DB_URI
26
  app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
27
+ app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {'pool_pre_ping': True}
28
 
29
  db = SQLAlchemy(app)
30
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')
 
37
  url = db.Column(db.String(255), default="http://localhost:5244")
38
  username = db.Column(db.String(255), default="admin")
39
  password = db.Column(db.String(255), default="")
40
+ path1 = db.Column(db.String(500), default="")
41
+ path2 = db.Column(db.String(500), default="")
42
+ interval = db.Column(db.Integer, default=60)
43
  auto_sync = db.Column(db.Boolean, default=False)
44
+ web_password = db.Column(db.String(255), default="123456")
45
 
46
  class AppLog(db.Model):
47
  __tablename__ = 'logs'
 
49
  timestamp = db.Column(db.DateTime, default=datetime.now)
50
  message = db.Column(db.Text)
51
 
52
+ # --- 装饰器与逻辑 ---
53
+ def login_required(f):
54
+ @wraps(f)
55
+ def decorated_function(*args, **kwargs):
56
+ if 'logged_in' not in session:
57
+ return redirect(url_for('login'))
58
+ return f(*args, **kwargs)
59
+ return decorated_function
60
+
61
  def add_log(msg):
62
+ print(f"[Log] {msg}")
 
63
  try:
64
  with app.app_context():
65
  log = AppLog(message=str(msg), timestamp=datetime.now())
66
  db.session.add(log)
67
  db.session.commit()
68
+ # 自动清理日志
69
+ last = db.session.query(db.func.max(AppLog.id)).scalar()
70
+ if last and last > 1000:
71
+ AppLog.query.filter(AppLog.id < (last - 500)).delete()
72
  db.session.commit()
73
+ except:
74
+ pass
75
 
76
  def get_token(base_url, username, password):
77
  try:
 
84
  return None
85
 
86
  def list_files(base_url, token, path):
 
87
  try:
88
  res = requests.post(f"{base_url.rstrip('/')}/api/fs/list",
89
  headers={'Authorization': token},
90
  json={"path": path, "password": "", "page": 1, "per_page": 0, "refresh": True}, timeout=30)
91
  if res.status_code == 200:
92
+ return res.json().get('data', {}).get('content', [])
 
93
  except Exception as e:
94
+ add_log(f"获取列表失败: {e}")
95
+ return []
96
 
97
  def copy_files_api(base_url, token, src_dir, dst_dir, file_names):
98
  try:
 
100
  headers={'Authorization': token},
101
  json={"src_dir": src_dir, "dst_dir": dst_dir, "names": file_names}, timeout=30)
102
  return res.json()
103
+ except:
104
+ return {"code": 500}
105
 
106
+ # --- 后台任务 ---
107
  def background_worker():
 
 
108
  while True:
109
  try:
110
  with app.app_context():
111
  cfg = AppConfig.query.first()
112
+ if not cfg:
113
+ db.session.add(AppConfig(web_password="123456"))
 
114
  db.session.commit()
 
 
 
115
  continue
116
 
117
+ if cfg.auto_sync:
118
+ interval = max(1, cfg.interval)
119
+ token = get_token(cfg.url, cfg.username, cfg.password)
120
+ if token:
121
+ src = list_files(cfg.url, token, cfg.path1)
122
+ dst = list_files(cfg.url, token, cfg.path2)
123
+ if src:
124
+ dst_names = {f['name'] for f in dst}
125
+ missing = [i['name'] for i in src if i['name'] not in dst_names]
126
+ if missing:
127
+ add_log(f"自动同步: 发现 {len(missing)} 个文件,开始复制")
128
+ for i in range(0, len(missing), 20):
129
+ copy_files_api(cfg.url, token, cfg.path1, cfg.path2, missing[i:i+20])
130
+ time.sleep(interval * 60)
131
+ else:
132
+ time.sleep(10)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  except Exception as e:
134
+ print(f"后台错误: {e}")
135
  time.sleep(60)
136
 
137
+ # ==========================================
138
+ # Material 3 风格前端模板
139
+ # ==========================================
140
+
141
+ # 通用头部,包含 Material Web 组件加载
142
+ HEAD_COMMON = """
143
  <head>
144
  <meta charset="UTF-8">
145
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
146
+ <title>Alist Sync Panel</title>
147
+ <!-- Roboto Font & Icons -->
148
+ <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
149
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet">
150
+
151
+ <!-- Material Web Components (ESM) -->
152
+ <script type="importmap">
153
+ {
154
+ "imports": {
155
+ "@material/web/": "https://esm.run/@material/web/"
156
+ }
157
+ }
158
+ </script>
159
+ <script type="module">
160
+ import '@material/web/all.js';
161
+ import {styles as typescaleStyles} from '@material/web/typography/md-typescale-styles.js';
162
+ document.adoptedStyleSheets.push(typescaleStyles.styleSheet);
163
+ </script>
164
+
165
  <style>
166
+ :root {
167
+ /* M3 Color Tokens (Baseline Purple) */
168
+ --md-sys-color-primary: #6750A4;
169
+ --md-sys-color-on-primary: #FFFFFF;
170
+ --md-sys-color-primary-container: #EADDFF;
171
+ --md-sys-color-on-primary-container: #21005D;
172
+ --md-sys-color-secondary: #625B71;
173
+ --md-sys-color-surface: #FEF7FF;
174
+ --md-sys-color-surface-container: #F3EDF7;
175
+ --md-sys-color-on-surface: #1D1B20;
176
+ --md-sys-color-outline: #79747E;
177
+ --md-sys-color-error: #B3261E;
178
+
179
+ --md-ref-typeface-plain: 'Roboto', sans-serif;
180
+ }
181
+
182
+ body {
183
+ font-family: var(--md-ref-typeface-plain);
184
+ background-color: var(--md-sys-color-surface);
185
+ color: var(--md-sys-color-on-surface);
186
+ margin: 0;
187
+ padding: 0;
188
+ display: flex;
189
+ flex-direction: column;
190
+ height: 100vh;
191
+ overflow: hidden;
192
+ }
193
+
194
+ /* Utilities */
195
+ .d-flex { display: flex; }
196
+ .flex-column { flex-direction: column; }
197
+ .align-center { align-items: center; }
198
+ .justify-center { justify_content: center; }
199
+ .gap-1 { gap: 8px; }
200
+ .gap-2 { gap: 16px; }
201
+ .w-100 { width: 100%; }
202
+ .mt-2 { margin-top: 16px; }
203
+
204
+ /* Card Style using CSS (Lightweight) */
205
+ .m3-card {
206
+ background: var(--md-sys-color-surface-container);
207
+ border-radius: 16px;
208
+ padding: 24px;
209
+ box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
210
+ }
211
+
212
+ /* Scrollbar */
213
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
214
+ ::-webkit-scrollbar-track { background: transparent; }
215
+ ::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
216
+ ::-webkit-scrollbar-thumb:hover { background: #aaa; }
217
  </style>
218
  </head>
219
+ """
220
+
221
+ LOGIN_HTML = HEAD_COMMON + """
222
  <body>
223
+ <div style="display:flex; justify-content:center; align-items:center; height:100%; width:100%;">
224
+ <div class="m3-card" style="width: 100%; max-width: 400px; display:flex; flex-direction:column; gap:24px;">
225
+ <div style="text-align: center;">
226
+ <span class="material-symbols-outlined" style="font-size: 48px; color: var(--md-sys-color-primary);">sync_lock</span>
227
+ <h1 class="md-typescale-headline-small" style="margin: 8px 0;">Alist Sync</h1>
228
+ <p class="md-typescale-body-medium" style="color: var(--md-sys-color-secondary); margin:0;">请输入面板访问密码</p>
229
+ </div>
230
+
231
+ {% if error %}
232
+ <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;">
233
+ <span class="material-symbols-outlined" style="font-size:18px;">error</span> {{ error }}
234
+ </div>
235
+ {% endif %}
236
+
237
+ <form method="post" style="display:flex; flex-direction:column; gap:16px;">
238
+ <md-outlined-text-field
239
+ label="Password"
240
+ type="password"
241
+ name="password"
242
+ required
243
+ style="width: 100%;">
244
+ <md-icon slot="leading-icon">key</md-icon>
245
+ </md-outlined-text-field>
246
+
247
+ <md-filled-button type="submit" style="width: 100%;">
248
+ 登 录
249
+ </md-filled-button>
250
+ </form>
251
+
252
+ <div style="text-align:center;">
253
+ <span class="md-typescale-label-small" style="color: var(--md-sys-color-outline);">Default: 123456</span>
254
+ </div>
255
+ </div>
256
+ </div>
257
+ </body>
258
+ </html>
259
+ """
260
+
261
+ INDEX_HTML = HEAD_COMMON + """
262
+ <body style="overflow-y: auto;">
263
+ <!-- App Bar -->
264
+ <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);">
265
+ <div class="d-flex align-center gap-2">
266
+ <span class="material-symbols-outlined" style="color: var(--md-sys-color-primary); font-size: 28px;">cloud_sync</span>
267
+ <span class="md-typescale-title-large">Alist Sync Panel</span>
268
+ </div>
269
+ <div>
270
+ <md-text-button href="/logout" onclick="window.location.href='/logout'">
271
+ <md-icon slot="icon">logout</md-icon>
272
+ 退出
273
+ </md-text-button>
274
+ </div>
275
+ </header>
276
+
277
+ <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;">
278
+
279
+ <!-- Config Card -->
280
+ <div class="m3-card" style="grid-column: span 2;">
281
+ <div class="d-flex align-center gap-2" style="margin-bottom: 24px;">
282
+ <md-icon style="color: var(--md-sys-color-primary);">settings</md-icon>
283
+ <span class="md-typescale-title-medium">同步配置</span>
284
+ </div>
285
+
286
+ <form id="configForm" style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
287
+ <!-- Full Width -->
288
+ <md-outlined-text-field label="Alist URL" id="url" style="grid-column: span 2;">
289
+ <md-icon slot="leading-icon">link</md-icon>
290
+ </md-outlined-text-field>
291
+
292
+ <!-- Half Width -->
293
+ <md-outlined-text-field label="Username" id="username">
294
+ <md-icon slot="leading-icon">person</md-icon>
295
+ </md-outlined-text-field>
296
+
297
+ <md-outlined-text-field label="Password" id="password" type="password">
298
+ <md-icon slot="leading-icon">lock</md-icon>
299
+ </md-outlined-text-field>
300
+
301
+ <div style="grid-column: span 2; height: 1px; background: var(--md-sys-color-outline); opacity: 0.2; margin: 8px 0;"></div>
302
+
303
+ <md-outlined-text-field label="Source Path (源路径)" id="path1" placeholder="/阿里云盘/Video">
304
+ <md-icon slot="leading-icon">folder_open</md-icon>
305
+ </md-outlined-text-field>
306
+
307
+ <md-outlined-text-field label="Target Path (目标路径)" id="path2" placeholder="/本地存储/Video">
308
+ <md-icon slot="leading-icon">save_alt</md-icon>
309
+ </md-outlined-text-field>
310
+
311
+ <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;">
312
+ <div class="d-flex align-center gap-2">
313
+ <md-outlined-text-field label="间隔 (分钟)" id="interval" type="number" style="width: 120px;" value="60"></md-outlined-text-field>
314
+
315
+ <label style="display:flex; align-items:center; gap:12px; cursor:pointer;">
316
+ <md-switch id="auto_sync" icons></md-switch>
317
+ <span class="md-typescale-body-large">开启后台自动同步</span>
318
+ </label>
319
+ </div>
320
+
321
+ <md-filled-button type="button" id="saveBtn">
322
+ <md-icon slot="icon">save</md-icon>
323
+ 保存并应用
324
+ </md-filled-button>
325
  </div>
326
+ </form>
327
+ </div>
328
+
329
+ <!-- Status & Security Card -->
330
+ <div class="d-flex flex-column gap-2">
331
+ <!-- Status -->
332
+ <div class="m3-card" style="flex: 1;">
333
+ <div class="d-flex align-center gap-2" style="margin-bottom: 16px;">
334
+ <md-icon style="color: var(--md-sys-color-primary);">analytics</md-icon>
335
+ <span class="md-typescale-title-medium">运行状态</span>
336
+ </div>
337
+ <div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height: 120px;">
338
+ <div id="status-indicator" style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">
339
+ <md-icon id="status-icon" style="font-size: 48px;">stop_circle</md-icon>
340
+ </div>
341
+ <span id="status-text" class="md-typescale-headline-small">已停止</span>
 
 
 
 
 
342
  </div>
343
  </div>
344
 
345
+ <!-- Security -->
346
+ <div class="m3-card">
347
+ <div class="d-flex align-center gap-2" style="margin-bottom: 16px;">
348
+ <md-icon style="color: var(--md-sys-color-primary);">security</md-icon>
349
+ <span class="md-typescale-title-medium">修改面板密码</span>
350
+ </div>
351
+ <div class="d-flex gap-2">
352
+ <md-outlined-text-field label="新密码" id="new_pass" type="password" style="flex:1;"></md-outlined-text-field>
353
+ <md-tonal-button id="changePassBtn">
354
+ 修改
355
+ </md-tonal-button>
356
  </div>
357
+ </div>
358
+ </div>
359
+
360
+ <!-- Logs Card -->
361
+ <div class="m3-card" style="grid-column: span 2; display: flex; flex-direction: column; height: 500px;">
362
+ <div class="d-flex align-center justify-between" style="margin-bottom: 16px; justify-content: space-between;">
363
+ <div class="d-flex align-center gap-2">
364
+ <md-icon style="color: var(--md-sys-color-primary);">terminal</md-icon>
365
+ <span class="md-typescale-title-medium">系统日志</span>
366
  </div>
367
+ <md-icon-button id="refreshLogBtn">
368
+ <md-icon>refresh</md-icon>
369
+ </md-icon-button>
370
+ </div>
371
+ <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;">
372
+ 加载中...
373
  </div>
374
  </div>
375
+ </main>
376
+
377
+ <!-- Toast Notification -->
378
+ <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);">
379
+ Notification
380
  </div>
381
+
382
+ <script>
383
+ // Helper for Toast
384
+ function showToast(msg) {
385
+ const t = document.getElementById('toast');
386
+ t.textContent = msg;
387
+ t.style.opacity = '1';
388
+ setTimeout(() => t.style.opacity = '0', 3000);
389
+ }
390
+
391
+ // API Helpers
392
+ async function fetchAPI(url, method='GET', data=null) {
393
+ const opts = { method: method, headers: {'Content-Type': 'application/json'} };
394
+ if (data) opts.body = JSON.stringify(data);
395
+ const res = await fetch(url, opts);
396
+ return res.json();
397
+ }
398
+
399
+ // Load Data
400
+ async function loadConfig() {
401
+ try {
402
+ const data = await fetchAPI('/api/config');
403
+
404
+ // Populate Fields
405
+ document.getElementById('url').value = data.url;
406
+ document.getElementById('username').value = data.username;
407
+ document.getElementById('password').value = data.password;
408
+ document.getElementById('path1').value = data.path1;
409
+ document.getElementById('path2').value = data.path2;
410
+ document.getElementById('interval').value = data.interval;
411
+ document.getElementById('auto_sync').selected = data.auto_sync;
412
+
413
+ updateStatusUI(data.auto_sync);
414
+ } catch(e) {
415
+ console.error(e);
416
+ }
417
+ }
418
+
419
+ function updateStatusUI(isRunning) {
420
+ const icon = document.getElementById('status-icon');
421
+ const text = document.getElementById('status-text');
422
+
423
+ if (isRunning) {
424
+ icon.textContent = 'sync';
425
+ icon.style.color = '#00c853'; // Green
426
+ icon.style.animation = 'spin 2s linear infinite';
427
+ text.textContent = '后台运行中';
428
+ text.style.color = '#00c853';
429
+ } else {
430
+ icon.textContent = 'pause_circle';
431
+ icon.style.color = '#b00020'; // Red
432
+ icon.style.animation = 'none';
433
+ text.textContent = '已停止';
434
+ text.style.color = '#b00020';
435
+ }
436
+ }
437
+
438
+ // Events
439
+ document.getElementById('saveBtn').addEventListener('click', async () => {
440
+ const payload = {
441
+ url: document.getElementById('url').value,
442
+ username: document.getElementById('username').value,
443
+ password: document.getElementById('password').value,
444
+ path1: document.getElementById('path1').value,
445
+ path2: document.getElementById('path2').value,
446
+ interval: parseInt(document.getElementById('interval').value),
447
+ auto_sync: document.getElementById('auto_sync').selected
448
+ };
449
+
450
+ await fetchAPI('/api/config', 'POST', payload);
451
+ showToast('配置已保存并应用');
452
+ updateStatusUI(payload.auto_sync);
453
+ loadLogs();
454
  });
455
+
456
+ document.getElementById('changePassBtn').addEventListener('click', async () => {
457
+ const pass = document.getElementById('new_pass').value;
458
+ if(!pass) return showToast('密码不能为空');
459
+ const res = await fetch('/api/change_pass', {
460
+ method: 'POST',
461
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'},
462
+ body: 'password=' + encodeURIComponent(pass)
463
+ });
464
+ const json = await res.json();
465
+ showToast(json.msg);
466
+ document.getElementById('new_pass').value = '';
 
 
 
 
 
 
 
 
467
  });
468
+
469
+ async function loadLogs() {
470
+ const logs = await fetchAPI('/api/logs');
471
+ const container = document.getElementById('logContainer');
472
+ container.innerHTML = logs.map(l =>
473
+ `<div style="margin-bottom:4px; border-bottom: 1px solid #333; padding-bottom: 2px;">
474
+ <span style="color:#888;">[${l.timestamp}]</span> ${l.message}
475
+ </div>`
476
+ ).join('');
477
+ }
478
+
479
+ document.getElementById('refreshLogBtn').addEventListener('click', loadLogs);
480
+
481
+ // Add CSS Animation for spinner
482
+ const styleSheet = document.createElement("style");
483
+ styleSheet.innerText = `@keyframes spin { 100% { transform: rotate(360deg); } }`;
484
+ document.head.appendChild(styleSheet);
485
+
486
+ // Init
487
+ loadConfig();
488
+ loadLogs();
489
+ setInterval(loadLogs, 10000);
490
+ </script>
491
  </body>
492
  </html>
493
  """
494
 
495
+ # --- 路由逻辑 (保持不变) ---
496
+ @app.route('/login', methods=['GET', 'POST'])
497
+ def login():
498
+ error = None
499
+ if request.method == 'POST':
500
+ input_pass = request.form.get('password')
501
+ with app.app_context():
502
+ cfg = AppConfig.query.first()
503
+ real_pass = cfg.web_password if cfg else "123456"
504
+
505
+ if input_pass == real_pass:
506
+ session['logged_in'] = True
507
+ return redirect(url_for('index'))
508
+ else:
509
+ error = "密码错误 (默认为 123456)"
510
+ return render_template_string(LOGIN_HTML, error=error)
511
+
512
+ @app.route('/logout')
513
+ def logout():
514
+ session.pop('logged_in', None)
515
+ return redirect(url_for('login'))
516
+
517
  @app.route('/')
518
+ @login_required
519
  def index():
520
+ return render_template_string(INDEX_HTML)
521
 
522
  @app.route('/api/config', methods=['GET', 'POST'])
523
+ @login_required
524
  def api_config():
525
  cfg = AppConfig.query.first()
526
  if not cfg:
527
+ cfg = AppConfig(web_password="123456")
528
  db.session.add(cfg)
529
  db.session.commit()
530
 
 
536
  cfg.path1 = data.get('path1')
537
  cfg.path2 = data.get('path2')
538
  cfg.interval = data.get('interval')
 
539
  cfg.auto_sync = data.get('auto_sync')
540
  db.session.commit()
 
 
541
  return jsonify({'success': True})
542
 
543
  return jsonify({
 
546
  'interval': cfg.interval, 'auto_sync': cfg.auto_sync
547
  })
548
 
549
+ @app.route('/api/change_pass', methods=['POST'])
550
+ @login_required
551
+ def api_change_pass():
552
+ new_pass = request.form.get('password')
553
+ if new_pass:
554
+ cfg = AppConfig.query.first()
555
+ cfg.web_password = new_pass
556
+ db.session.commit()
557
+ return jsonify({'msg': '面板密码已修改'})
558
+ return jsonify({'msg': '无效密码'})
559
+
560
  @app.route('/api/logs')
561
+ @login_required
562
  def api_logs():
563
+ logs = AppLog.query.order_by(AppLog.timestamp.desc()).limit(100).all()
564
+ return jsonify([{'timestamp': l.timestamp.strftime("%H:%M:%S"), 'message': l.message} for l in logs])
565
 
 
566
  if __name__ == '__main__':
 
567
  with app.app_context():
568
  try:
569
  db.create_all()
570
+ except: pass
 
 
 
571
 
 
572
  t = threading.Thread(target=background_worker, daemon=True)
573
  t.start()
 
 
574
  app.run(host='0.0.0.0', port=PORT, debug=False, use_reloader=False)