feat: WFJ Panel v0.1.0 - 完整功能版
- SSH 服务器连接管理 + 登录页自动预填 - 服务器概览监控(CPU/内存/硬盘/网络/进程,进程卡片支持排序和端口显示) - 远程文件管理(浏览/上传/下载/新建/删除,实时进度条) - 交互式终端(xterm.js + ssh2 PTY) - Docker 容器管理(三连接架构,概览/容器列表/镜像/应用商店/任务中心/配置/网络/卷) - 应用商店一键部署(Redis/MySQL/Halo,在线模板同步,数据驱动 options/showWhen/extraArgs) - Docker 镜像源配置(daemon.json 编辑 + 自动重启) - Docker 强制停止修复(三步强制停止 + 轮询确认) - 系统防火墙管理(firewalld/ufw/iptables 自动检测 + GUI 管理) - 客户端版本检查(/api/version 接口 + 更新弹窗) - 品牌名统一为 WFJ Panel - electron-builder 打包配置(NSIS 安装包,国内镜像) - .npmrc 配置 Electron 国内镜像
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
应用商店 HTTP API 服务
|
||||
从 MySQL 读取应用模板数据,返回 JSON 给 Electron 客户端
|
||||
所有数据库连接参数通过环境变量配置
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from flask import Flask, jsonify, request
|
||||
from flask_cors import CORS
|
||||
import mysql.connector
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# 允许跨域(Electron 渲染进程直接 fetch 时需要)
|
||||
CORS(app)
|
||||
|
||||
# ─── 数据库配置(环境变量) ───
|
||||
DB_HOST = os.environ.get('DB_HOST', '127.0.0.1')
|
||||
DB_PORT = int(os.environ.get('DB_PORT', '3306'))
|
||||
DB_USER = os.environ.get('DB_USER', 'root')
|
||||
DB_PASSWORD = os.environ.get('DB_PASSWORD', '123456')
|
||||
DB_NAME = os.environ.get('DB_NAME', 'appstore')
|
||||
|
||||
# API 监听端口
|
||||
API_PORT = int(os.environ.get('API_PORT', '3001'))
|
||||
|
||||
# API 鉴权 token(可选,客户端通过 Authorization header 传递)
|
||||
API_TOKEN = os.environ.get('API_TOKEN', '')
|
||||
|
||||
|
||||
def get_db():
|
||||
"""获取数据库连接"""
|
||||
return mysql.connector.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
database=DB_NAME,
|
||||
charset='utf8mb4'
|
||||
)
|
||||
|
||||
|
||||
def check_token():
|
||||
"""校验 API token,未配置 token 时跳过"""
|
||||
if not API_TOKEN:
|
||||
return True
|
||||
auth = request.headers.get('Authorization', '')
|
||||
return auth == f'Bearer {API_TOKEN}'
|
||||
|
||||
|
||||
def fetch_template_list():
|
||||
"""查询所有模板(列表摘要)"""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
'SELECT id, app_key, name, description, icon, category, '
|
||||
'image, container_name, sort_order, is_active '
|
||||
'FROM app_templates WHERE is_active = 1 ORDER BY sort_order, id'
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
|
||||
def fetch_template_detail(app_key):
|
||||
"""查询单个模板的完整详情(含子表)"""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 主表
|
||||
cursor.execute('SELECT * FROM app_templates WHERE app_key = %s AND is_active = 1', (app_key,))
|
||||
tpl = cursor.fetchone()
|
||||
if not tpl:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
# 子表批量查询
|
||||
sub_tables = ['app_ports', 'app_volumes', 'app_envs', 'app_mkdirs', 'app_write_files', 'app_params']
|
||||
for table in sub_tables:
|
||||
cursor.execute(f'SELECT * FROM {table} WHERE template_id = %s ORDER BY sort_order', (tpl['id'],))
|
||||
rows = cursor.fetchall()
|
||||
# 序列化:去掉 template_id 和 id,JSON 友好
|
||||
cleaned = []
|
||||
for r in rows:
|
||||
item = {k: v for k, v in r.items() if k not in ('id', 'template_id')}
|
||||
cleaned.append(item)
|
||||
# 子表名映射到 deploy 对应字段
|
||||
field_map = {
|
||||
'app_ports': 'ports',
|
||||
'app_volumes': 'volumes',
|
||||
'app_envs': 'env',
|
||||
'app_mkdirs': 'mkdirs',
|
||||
'app_write_files': 'writeFiles',
|
||||
'app_params': 'params'
|
||||
}
|
||||
tpl[field_map[table]] = cleaned
|
||||
|
||||
# 删除不需要的主表字段
|
||||
tpl.pop('id', None)
|
||||
tpl.pop('is_active', None)
|
||||
tpl.pop('sort_order', None)
|
||||
tpl.pop('created_at', None)
|
||||
tpl.pop('updated_at', None)
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return tpl
|
||||
|
||||
|
||||
def fetch_all_templates_full():
|
||||
"""一次性查询所有模板的完整详情"""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 所有主表记录
|
||||
cursor.execute(
|
||||
'SELECT * FROM app_templates WHERE is_active = 1 ORDER BY sort_order, id'
|
||||
)
|
||||
templates = cursor.fetchall()
|
||||
|
||||
sub_tables = ['app_ports', 'app_volumes', 'app_envs', 'app_mkdirs', 'app_write_files', 'app_params']
|
||||
field_map = {
|
||||
'app_ports': 'ports',
|
||||
'app_volumes': 'volumes',
|
||||
'app_envs': 'env',
|
||||
'app_mkdirs': 'mkdirs',
|
||||
'app_write_files': 'writeFiles',
|
||||
'app_params': 'params'
|
||||
}
|
||||
|
||||
for tpl in templates:
|
||||
tpl_id = tpl['id']
|
||||
for table in sub_tables:
|
||||
cursor.execute(
|
||||
f'SELECT * FROM {table} WHERE template_id = %s ORDER BY sort_order',
|
||||
(tpl_id,)
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
cleaned = []
|
||||
for r in rows:
|
||||
item = {k: v for k, v in r.items() if k not in ('id', 'template_id')}
|
||||
cleaned.append(item)
|
||||
tpl[field_map[table]] = cleaned
|
||||
|
||||
# 清理主表多余字段
|
||||
tpl.pop('id', None)
|
||||
tpl.pop('is_active', None)
|
||||
tpl.pop('sort_order', None)
|
||||
tpl.pop('created_at', None)
|
||||
tpl.pop('updated_at', None)
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return templates
|
||||
|
||||
|
||||
# ─── API 路由 ───
|
||||
|
||||
@app.route('/api/apps', methods=['GET'])
|
||||
def list_apps():
|
||||
"""获取所有应用模板列表(完整详情)"""
|
||||
if not check_token():
|
||||
return jsonify({'ok': False, 'error': '未授权'}), 401
|
||||
|
||||
try:
|
||||
templates = fetch_all_templates_full()
|
||||
return jsonify({'ok': True, 'data': templates, 'count': len(templates)})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/apps/<app_key>', methods=['GET'])
|
||||
def get_app(app_key):
|
||||
"""获取单个应用模板详情"""
|
||||
if not check_token():
|
||||
return jsonify({'ok': False, 'error': '未授权'}), 401
|
||||
|
||||
try:
|
||||
tpl = fetch_template_detail(app_key)
|
||||
if not tpl:
|
||||
return jsonify({'ok': False, 'error': '模板不存在'}), 404
|
||||
return jsonify({'ok': True, 'data': tpl})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/health', methods=['GET'])
|
||||
def health():
|
||||
"""健康检查"""
|
||||
return jsonify({'ok': True, 'status': 'running'})
|
||||
|
||||
|
||||
@app.route('/api/version', methods=['GET'])
|
||||
def get_version():
|
||||
"""获取客户端最新版本信息"""
|
||||
if not check_token():
|
||||
return jsonify({'ok': False, 'error': '未授权'}), 401
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
'SELECT version, download_url, release_notes, is_latest, '
|
||||
'created_at FROM app_version WHERE is_latest = 1 ORDER BY id DESC LIMIT 1'
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return jsonify({'ok': False, 'error': '暂无版本信息'}), 404
|
||||
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'data': {
|
||||
'version': row['version'],
|
||||
'downloadUrl': row['download_url'],
|
||||
'releaseNotes': row['release_notes'] or '',
|
||||
'isLatest': bool(row['is_latest']),
|
||||
'createdAt': row['created_at'].isoformat() if row['created_at'] else None,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(f'[AppStore API] DB: {DB_HOST}:{DB_PORT}/{DB_NAME}')
|
||||
print(f'[AppStore API] Listen on :{API_PORT}')
|
||||
print(f'[AppStore API] Token auth: {"on" if API_TOKEN else "off"}')
|
||||
app.run(host='0.0.0.0', port=API_PORT, debug=False)
|
||||
Reference in New Issue
Block a user