Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caa0325cc9 | ||
|
|
502b9c99b2 |
6
.env copy.development
Normal file
6
.env copy.development
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 基础环境变量示例
|
||||||
|
VITE_API_BASE_URL=http://www.yishi.com
|
||||||
|
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
|
||||||
|
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
|
||||||
|
# VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||||
|
VITE_APP_TITLE=存客宝
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 基础环境变量示例
|
# 基础环境变量示例
|
||||||
VITE_API_BASE_URL=http://www.yishi.com
|
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||||
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
|
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
|
||||||
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
|
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
|
||||||
# VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
# VITE_API_BASE_URL=http://www.yishi.com
|
||||||
VITE_APP_TITLE=存客宝
|
VITE_APP_TITLE=存客宝
|
||||||
|
|||||||
95
devlop.py
95
devlop.py
@@ -1,95 +0,0 @@
|
|||||||
import os
|
|
||||||
import zipfile
|
|
||||||
import paramiko
|
|
||||||
|
|
||||||
# 配置
|
|
||||||
local_dir = './dist' # 本地要打包的目录
|
|
||||||
zip_name = 'dist.zip'
|
|
||||||
# 上传到服务器的 zip 路径
|
|
||||||
remote_path = '/www/wwwroot/auto-devlop/ckb-operation/dist.zip' # 服务器上的临时zip路径
|
|
||||||
server_ip = '42.194.245.239'
|
|
||||||
server_port = 6523
|
|
||||||
server_user = 'yongpxu'
|
|
||||||
server_pwd = 'Aa123456789.'
|
|
||||||
# 服务器 dist 相关目录
|
|
||||||
remote_base_dir = '/www/wwwroot/auto-devlop/ckb-operation'
|
|
||||||
dist_dir = f'{remote_base_dir}/dist'
|
|
||||||
dist1_dir = f'{remote_base_dir}/dist1'
|
|
||||||
dist2_dir = f'{remote_base_dir}/dist2'
|
|
||||||
|
|
||||||
# 美化输出用的函数
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
def info(msg):
|
|
||||||
print(f"\033[36m[INFO {datetime.now().strftime('%H:%M:%S')}] {msg}\033[0m")
|
|
||||||
|
|
||||||
def success(msg):
|
|
||||||
print(f"\033[32m[SUCCESS] {msg}\033[0m")
|
|
||||||
|
|
||||||
def error(msg):
|
|
||||||
print(f"\033[31m[ERROR] {msg}\033[0m")
|
|
||||||
|
|
||||||
def step(msg):
|
|
||||||
print(f"\n\033[35m==== {msg} ====" + "\033[0m")
|
|
||||||
|
|
||||||
# 1. 先运行 pnpm build
|
|
||||||
step('Step 1: 构建项目 (pnpm build)')
|
|
||||||
info('开始执行 pnpm build...')
|
|
||||||
ret = os.system('pnpm build')
|
|
||||||
if ret != 0:
|
|
||||||
error('pnpm build 失败,终止部署!')
|
|
||||||
exit(1)
|
|
||||||
success('pnpm build 完成')
|
|
||||||
|
|
||||||
# 2. 打包
|
|
||||||
step('Step 2: 打包 dist 目录为 zip')
|
|
||||||
info('开始打包 dist 目录...')
|
|
||||||
with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
|
||||||
for root, dirs, files in os.walk(local_dir):
|
|
||||||
for file in files:
|
|
||||||
filepath = os.path.join(root, file)
|
|
||||||
arcname = os.path.relpath(filepath, local_dir)
|
|
||||||
zipf.write(filepath, arcname)
|
|
||||||
success('本地打包完成')
|
|
||||||
|
|
||||||
# 3. 上传
|
|
||||||
step('Step 3: 上传 zip 包到服务器')
|
|
||||||
info('开始上传 zip 包...')
|
|
||||||
transport = paramiko.Transport((server_ip, server_port))
|
|
||||||
transport.connect(username=server_user, password=server_pwd)
|
|
||||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
|
||||||
sftp.put(zip_name, remote_path)
|
|
||||||
sftp.close()
|
|
||||||
transport.close()
|
|
||||||
success('上传到服务器完成')
|
|
||||||
|
|
||||||
# 删除本地 dist.zip
|
|
||||||
try:
|
|
||||||
os.remove(zip_name)
|
|
||||||
success('本地 dist.zip 已删除')
|
|
||||||
except Exception as e:
|
|
||||||
error(f'本地 dist.zip 删除失败: {e}')
|
|
||||||
|
|
||||||
# 4. 远程解压并覆盖
|
|
||||||
step('Step 4: 服务器端解压、切换目录')
|
|
||||||
ssh = paramiko.SSHClient()
|
|
||||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
ssh.connect(server_ip, server_port, server_user, server_pwd)
|
|
||||||
commands = [
|
|
||||||
f'unzip -oq {remote_path} -d {dist2_dir}', # 静默解压
|
|
||||||
f'rm {remote_path}',
|
|
||||||
f'if [ -d {dist_dir} ]; then mv {dist_dir} {dist1_dir}; fi',
|
|
||||||
f'mv {dist2_dir} {dist_dir}',
|
|
||||||
f'rm -rf {dist1_dir}'
|
|
||||||
]
|
|
||||||
for i, cmd in enumerate(commands, 1):
|
|
||||||
info(f'执行第{i}步: {cmd}')
|
|
||||||
stdin, stdout, stderr = ssh.exec_command(cmd)
|
|
||||||
out, err = stdout.read().decode(), stderr.read().decode()
|
|
||||||
# 只打印非 unzip 命令的输出
|
|
||||||
if i != 1 and out.strip():
|
|
||||||
print(out.strip())
|
|
||||||
if err.strip():
|
|
||||||
error(err.strip())
|
|
||||||
ssh.close()
|
|
||||||
success('服务器解压并覆盖完成,部署成功!')
|
|
||||||
596
master.py
Normal file
596
master.py
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
CKB 运营端静态站点部署(统一主入口,全程宝塔面板 API,无需 SSH)。
|
||||||
|
默认宝塔面板主机(最新):43.139.27.93;站点根:/www/wwwroot/auto-devlop/ckb-operation。
|
||||||
|
未设置 BT_PANEL_URL 时,面板地址为 https://{DEPLOY_HOST 或默认 IP}:9988。
|
||||||
|
|
||||||
|
流程与 soul-api/master.py 对齐:
|
||||||
|
探活(GetDiskInfo) → 本地 pnpm 构建 dist → 打包 zip → /files?action=upload
|
||||||
|
→ /files?action=UnZip 到 dist2 → MvFile 切换 dist/dist2(等价原 dist→dist1→删)→ DeleteFile 删包
|
||||||
|
|
||||||
|
环境变量(与 soul-api 共用宝塔相关项):
|
||||||
|
BT_PANEL_URL、BT_API_KEY、BT_UPLOAD_CHUNK_MB(可选,默认 4)
|
||||||
|
DEPLOY_HOST(未设 BT_PANEL_URL 时默认 https://{DEPLOY_HOST}:9988)
|
||||||
|
DEPLOY_BASE_PATH / DEPLOY_BASE_PATH_PROD / DEPLOY_BASE_PATH_DEV
|
||||||
|
|
||||||
|
deploy.py 作为 dev 包装入口:default_profile=dev。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import print_function
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
import urllib3
|
||||||
|
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except ImportError:
|
||||||
|
requests = None
|
||||||
|
|
||||||
|
# 与 soul-api/master.py 一致,便于同一套面板密钥
|
||||||
|
BT_API_KEY_DEFAULT = "qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT"
|
||||||
|
|
||||||
|
# 默认宝塔面板主机(最新 IP);可用环境变量 DEPLOY_HOST 或 BT_PANEL_URL 覆盖
|
||||||
|
DEFAULT_DEPLOY_HOST = "43.139.27.93"
|
||||||
|
DEFAULT_BASE_PATH = "/www/wwwroot/auto-devlop/ckb-operation"
|
||||||
|
|
||||||
|
PROFILE_PRESETS = {
|
||||||
|
"prod": {
|
||||||
|
"title": "ckb-operation",
|
||||||
|
"default_base_path": DEFAULT_BASE_PATH,
|
||||||
|
"build_cmd": ["pnpm", "build"],
|
||||||
|
"build_desc": "pnpm build(正式环境)",
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"title": "ckb-operation-dev",
|
||||||
|
"default_base_path": DEFAULT_BASE_PATH,
|
||||||
|
"build_cmd": ["pnpm", "run", "build:dev"],
|
||||||
|
"build_desc": "pnpm run build:dev(测试环境)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_profile(profile_name):
|
||||||
|
profile = (profile_name or "prod").strip().lower()
|
||||||
|
if profile not in PROFILE_PRESETS:
|
||||||
|
raise ValueError("不支持的 profile: %s" % profile_name)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_base_path(profile, cli_base_path=None):
|
||||||
|
preset = PROFILE_PRESETS[profile]
|
||||||
|
if cli_base_path:
|
||||||
|
return cli_base_path.rstrip("/")
|
||||||
|
|
||||||
|
profile_env_key = "DEPLOY_BASE_PATH_%s" % profile.upper()
|
||||||
|
base = (os.environ.get(profile_env_key) or "").strip()
|
||||||
|
if not base:
|
||||||
|
base = (os.environ.get("DEPLOY_BASE_PATH") or "").strip()
|
||||||
|
if not base:
|
||||||
|
base = preset["default_base_path"]
|
||||||
|
return base.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def get_cfg(profile, cli_base_path=None):
|
||||||
|
base = _resolve_base_path(profile, cli_base_path=cli_base_path)
|
||||||
|
host = os.environ.get("DEPLOY_HOST", DEFAULT_DEPLOY_HOST)
|
||||||
|
bt_url = (os.environ.get("BT_PANEL_URL") or "").strip().rstrip("/")
|
||||||
|
if not bt_url:
|
||||||
|
bt_url = "https://%s:9988" % host
|
||||||
|
return {
|
||||||
|
"profile": profile,
|
||||||
|
"title": PROFILE_PRESETS[profile]["title"],
|
||||||
|
"build_cmd": list(PROFILE_PRESETS[profile]["build_cmd"]),
|
||||||
|
"build_desc": PROFILE_PRESETS[profile]["build_desc"],
|
||||||
|
"host": host,
|
||||||
|
"base_path": base,
|
||||||
|
"dist_path": base + "/dist",
|
||||||
|
"dist2_path": base + "/dist2",
|
||||||
|
"dist1_path": base + "/dist1",
|
||||||
|
"bt_panel_url": bt_url,
|
||||||
|
"bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_project_root():
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if os.path.isfile(os.path.join(script_dir, "package.json")):
|
||||||
|
return script_dir
|
||||||
|
return os.path.dirname(script_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dist_ready(root):
|
||||||
|
return os.path.isdir(os.path.join(root, "dist")) and os.path.isfile(
|
||||||
|
os.path.join(root, "dist", "index.html")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_build(root, cfg):
|
||||||
|
print("[1/5] 本地构建 %s ..." % cfg["build_desc"])
|
||||||
|
use_shell = sys.platform == "win32"
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
cfg["build_cmd"],
|
||||||
|
cwd=root,
|
||||||
|
shell=use_shell,
|
||||||
|
timeout=300,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(" [失败] 构建失败,退出码:", r.returncode)
|
||||||
|
for line in (r.stdout or "").strip().split("\n")[-10:]:
|
||||||
|
if line:
|
||||||
|
print(" " + line)
|
||||||
|
for line in (r.stderr or "").strip().split("\n")[-10:]:
|
||||||
|
if line:
|
||||||
|
print(" " + line)
|
||||||
|
return False
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print(" [失败] 构建超时")
|
||||||
|
return False
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(" [失败] 未找到 pnpm,请安装: npm install -g pnpm")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(" [失败] 构建异常:", str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not ensure_dist_ready(root):
|
||||||
|
print(" [失败] 未找到 dist/index.html")
|
||||||
|
return False
|
||||||
|
print(" [成功] 构建完成")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def pack_dist_zip(root, profile):
|
||||||
|
print("[2/5] 打包 dist 为 zip ...")
|
||||||
|
dist_dir = os.path.join(root, "dist")
|
||||||
|
if not os.path.isdir(dist_dir):
|
||||||
|
print(" [失败] 未找到 dist 目录")
|
||||||
|
return None
|
||||||
|
|
||||||
|
index_html = os.path.join(dist_dir, "index.html")
|
||||||
|
if not os.path.isfile(index_html):
|
||||||
|
print(" [失败] 未找到 dist/index.html,请先执行构建")
|
||||||
|
return None
|
||||||
|
|
||||||
|
zip_name = "ckb_operation_%s_deploy.zip" % profile
|
||||||
|
zip_path = os.path.join(tempfile.gettempdir(), zip_name)
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for dirpath, _dirs, filenames in os.walk(dist_dir):
|
||||||
|
for name in filenames:
|
||||||
|
full = os.path.join(dirpath, name)
|
||||||
|
arcname = os.path.relpath(full, dist_dir).replace("\\", "/")
|
||||||
|
zf.write(full, arcname)
|
||||||
|
print(
|
||||||
|
" [成功] 打包完成: %s (%.2f MB)"
|
||||||
|
% (zip_path, os.path.getsize(zip_path) / 1024 / 1024)
|
||||||
|
)
|
||||||
|
return zip_path
|
||||||
|
except Exception as e:
|
||||||
|
print(" [失败] 打包异常:", str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 宝塔 API(与 soul-api/master.py 同源精简) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _bt_signed_post(base_url, key, path, extra_data, timeout=20):
|
||||||
|
req_time = int(time.time())
|
||||||
|
sk_md5 = hashlib.md5(key.encode()).hexdigest()
|
||||||
|
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
|
||||||
|
data = {"request_time": req_time, "request_token": req_token}
|
||||||
|
data.update(extra_data or {})
|
||||||
|
return requests.post(base_url + path, data=data, timeout=timeout, verify=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _bt_parse_json_response(r):
|
||||||
|
if r is None or r.status_code != 200:
|
||||||
|
return None
|
||||||
|
ct = (r.headers.get("content-type") or "").lower()
|
||||||
|
if "json" in ct:
|
||||||
|
try:
|
||||||
|
return r.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
t = (r.text or "").lstrip()
|
||||||
|
if t.startswith("{"):
|
||||||
|
try:
|
||||||
|
return json.loads(r.text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def bt_panel_ping(cfg):
|
||||||
|
if not requests:
|
||||||
|
return True
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
if not url or not key:
|
||||||
|
print("[宝塔API 探活] 缺少 BT_PANEL_URL 或 BT_API_KEY,已跳过")
|
||||||
|
return True
|
||||||
|
req_time = int(time.time())
|
||||||
|
sk_md5 = hashlib.md5(key.encode()).hexdigest()
|
||||||
|
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
url + "/system?action=GetDiskInfo",
|
||||||
|
data={"request_time": req_time, "request_token": req_token},
|
||||||
|
timeout=20,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
print("[宝塔API 探活] HTTP %s" % r.status_code)
|
||||||
|
return False
|
||||||
|
ct = (r.headers.get("content-type") or "").lower()
|
||||||
|
j = {}
|
||||||
|
if "json" in ct:
|
||||||
|
try:
|
||||||
|
j = r.json()
|
||||||
|
except Exception:
|
||||||
|
j = {}
|
||||||
|
else:
|
||||||
|
t = (r.text or "").lstrip()
|
||||||
|
if t.startswith("{"):
|
||||||
|
try:
|
||||||
|
j = json.loads(r.text)
|
||||||
|
except Exception:
|
||||||
|
j = {}
|
||||||
|
if isinstance(j, dict) and j.get("status") is False:
|
||||||
|
print("[宝塔API 探活] %s" % (j.get("msg") or j))
|
||||||
|
return False
|
||||||
|
print("[宝塔API 探活] 成功(GetDiskInfo)", flush=True)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print("[宝塔API 探活] 异常: %s" % e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _bt_upload_headers():
|
||||||
|
return {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bt_parse_upload_response(r):
|
||||||
|
if r is None or r.status_code != 200:
|
||||||
|
return "err", None, "HTTP %s" % (r.status_code if r else "?")
|
||||||
|
raw = (r.text or "").strip()
|
||||||
|
if raw.isdigit():
|
||||||
|
return "progress", int(raw), None
|
||||||
|
j = _bt_parse_json_response(r)
|
||||||
|
if isinstance(j, dict):
|
||||||
|
if j.get("status") is True:
|
||||||
|
return "ok", None, None
|
||||||
|
return "err", None, j.get("msg") or str(j)
|
||||||
|
return "err", None, raw[:300] if raw else "empty body"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_upload_file_resumable(cfg, local_path, remote_dir, remote_name):
|
||||||
|
if not requests:
|
||||||
|
print(" [失败] 需要 requests:pip install requests")
|
||||||
|
return False
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
if not url or not key:
|
||||||
|
print(" [失败] 缺少 BT_PANEL_URL 或 BT_API_KEY")
|
||||||
|
return False
|
||||||
|
total = os.path.getsize(local_path)
|
||||||
|
chunk_mb = int(os.environ.get("BT_UPLOAD_CHUNK_MB", "4"))
|
||||||
|
chunk_size = max(1, chunk_mb) * 1024 * 1024
|
||||||
|
f_start = 0
|
||||||
|
last_start = -1
|
||||||
|
upload_url = url + "/files?action=upload"
|
||||||
|
headers = _bt_upload_headers()
|
||||||
|
print(
|
||||||
|
" [宝塔API] 上传 %s → %s/%s(共 %.2f MB,每块 %d MB)…"
|
||||||
|
% (
|
||||||
|
os.path.basename(local_path),
|
||||||
|
remote_dir,
|
||||||
|
remote_name,
|
||||||
|
total / 1024 / 1024,
|
||||||
|
chunk_mb,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with open(local_path, "rb") as fp:
|
||||||
|
while f_start < total:
|
||||||
|
if f_start == last_start:
|
||||||
|
print(" [失败] 上传停滞 offset=%s(请删服务器上同名 .upload.tmp 后重试)" % f_start)
|
||||||
|
return False
|
||||||
|
last_start = f_start
|
||||||
|
fp.seek(f_start)
|
||||||
|
buf = fp.read(min(chunk_size, total - f_start))
|
||||||
|
if not buf:
|
||||||
|
print(" [失败] 读取本地文件失败 offset=%s" % f_start)
|
||||||
|
return False
|
||||||
|
req_time = int(time.time())
|
||||||
|
sk_md5 = hashlib.md5(key.encode()).hexdigest()
|
||||||
|
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
|
||||||
|
data = {
|
||||||
|
"request_time": str(req_time),
|
||||||
|
"request_token": req_token,
|
||||||
|
"f_path": remote_dir,
|
||||||
|
"f_name": remote_name,
|
||||||
|
"f_size": str(total),
|
||||||
|
"f_start": str(f_start),
|
||||||
|
}
|
||||||
|
files = {"blob": ("blob", buf, "application/octet-stream")}
|
||||||
|
r = requests.post(
|
||||||
|
upload_url,
|
||||||
|
data=data,
|
||||||
|
files=files,
|
||||||
|
headers=headers,
|
||||||
|
timeout=600,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
kind, prog, err = _bt_parse_upload_response(r)
|
||||||
|
if kind == "ok":
|
||||||
|
print(" [成功] 宝塔上传完成")
|
||||||
|
return True
|
||||||
|
if kind == "progress":
|
||||||
|
if prog <= f_start:
|
||||||
|
print(" [失败] 上传偏移未前进: server=%s local=%s" % (prog, f_start))
|
||||||
|
return False
|
||||||
|
f_start = prog
|
||||||
|
continue
|
||||||
|
print(" [失败] 上传接口: %s" % (err or kind))
|
||||||
|
return False
|
||||||
|
print(" [失败] 上传未正常结束(offset=%s size=%s)" % (f_start, total))
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(" [失败] 上传异常: %s" % e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def bt_delete_file(cfg, remote_path):
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
if not url or not key or not remote_path:
|
||||||
|
return False
|
||||||
|
j = _bt_parse_json_response(
|
||||||
|
_bt_signed_post(url, key, "/files?action=DeleteFile", {"path": remote_path})
|
||||||
|
)
|
||||||
|
if isinstance(j, dict) and j.get("status") is True:
|
||||||
|
return True
|
||||||
|
if isinstance(j, dict) and j.get("msg"):
|
||||||
|
print(" [宝塔API] DeleteFile: %s" % j.get("msg"))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def bt_delete_dir(cfg, remote_dir):
|
||||||
|
"""递归删除目录(不存在时可能返回失败,调用方可忽略)。"""
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
if not url or not key or not remote_dir:
|
||||||
|
return False
|
||||||
|
j = _bt_parse_json_response(
|
||||||
|
_bt_signed_post(
|
||||||
|
url,
|
||||||
|
key,
|
||||||
|
"/files?action=DeleteDir",
|
||||||
|
{"path": remote_dir},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if isinstance(j, dict) and j.get("status") is True:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def bt_create_dir(cfg, path):
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
if not url or not key or not path:
|
||||||
|
return False
|
||||||
|
j = _bt_parse_json_response(
|
||||||
|
_bt_signed_post(url, key, "/files?action=CreateDir", {"path": path})
|
||||||
|
)
|
||||||
|
if isinstance(j, dict) and j.get("status") is True:
|
||||||
|
return True
|
||||||
|
msg = (j.get("msg") if isinstance(j, dict) else "") or ""
|
||||||
|
if msg and ("已存在" in msg or "exist" in msg.lower()):
|
||||||
|
return True
|
||||||
|
if isinstance(j, dict) and j.get("msg"):
|
||||||
|
print(" [宝塔API] CreateDir: %s" % j.get("msg"))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def bt_mv_file(cfg, sfile, dfile):
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
if not url or not key:
|
||||||
|
return False, "no url/key"
|
||||||
|
j = _bt_parse_json_response(
|
||||||
|
_bt_signed_post(
|
||||||
|
url,
|
||||||
|
key,
|
||||||
|
"/files?action=MvFile",
|
||||||
|
{"sfile": sfile, "dfile": dfile},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if isinstance(j, dict) and j.get("status") is True:
|
||||||
|
return True, None
|
||||||
|
msg = j.get("msg") if isinstance(j, dict) else str(j)
|
||||||
|
return False, msg
|
||||||
|
|
||||||
|
|
||||||
|
def bt_unzip_remote_zip(cfg, sfile, dfile):
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
coding = "UTF-8"
|
||||||
|
for type1 in ("zip", "ZIP"):
|
||||||
|
j = _bt_parse_json_response(
|
||||||
|
_bt_signed_post(
|
||||||
|
url,
|
||||||
|
key,
|
||||||
|
"/files?action=UnZip",
|
||||||
|
{"sfile": sfile, "dfile": dfile, "type1": type1, "coding": coding},
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if isinstance(j, dict) and j.get("status") is True:
|
||||||
|
print(" [成功] 宝塔解压完成 (type1=%s)" % type1)
|
||||||
|
return True
|
||||||
|
if isinstance(j, dict) and j.get("msg"):
|
||||||
|
print(" [宝塔API] UnZip type1=%s: %s" % (type1, j.get("msg")))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_via_btapi(cfg, zip_path):
|
||||||
|
print("[3/5] 宝塔 API:准备 dist2、上传、解压 …")
|
||||||
|
if not requests:
|
||||||
|
print(" [失败] 需要 requests:pip install requests")
|
||||||
|
return False
|
||||||
|
|
||||||
|
base = cfg["base_path"].rstrip("/")
|
||||||
|
dist = cfg["dist_path"].rstrip("/")
|
||||||
|
dist2 = cfg["dist2_path"].rstrip("/")
|
||||||
|
dist1 = cfg["dist1_path"].rstrip("/")
|
||||||
|
remote_zip_name = "ckb_operation_deploy.zip"
|
||||||
|
remote_zip = base + "/" + remote_zip_name
|
||||||
|
dfile_dist2 = dist2 + "/"
|
||||||
|
|
||||||
|
bt_delete_dir(cfg, dist1)
|
||||||
|
bt_delete_dir(cfg, dist2)
|
||||||
|
if not bt_create_dir(cfg, dist2):
|
||||||
|
print(" [失败] 无法创建 dist2: %s" % dist2)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not bt_upload_file_resumable(cfg, zip_path, base, remote_zip_name):
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(" [宝塔API] 解压 %s → %s …" % (remote_zip, dfile_dist2))
|
||||||
|
if not bt_unzip_remote_zip(cfg, remote_zip, dfile_dist2):
|
||||||
|
print(" [提示] 解压失败时可检查面板「文件」权限与压缩包格式")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if bt_delete_file(cfg, remote_zip):
|
||||||
|
print(" [已清理] 远程 %s" % remote_zip)
|
||||||
|
else:
|
||||||
|
print(" [提示] 未能删除远程压缩包,可在面板手动删: %s" % remote_zip)
|
||||||
|
|
||||||
|
print("[4/5] 宝塔 API:目录切换(等价原 dist→dist1→dist2→dist)…")
|
||||||
|
bt_delete_dir(cfg, dist1)
|
||||||
|
|
||||||
|
ok_move_old, err_old = bt_mv_file(cfg, dist, dist1)
|
||||||
|
if not ok_move_old:
|
||||||
|
if err_old and isinstance(err_old, str):
|
||||||
|
low = err_old.lower()
|
||||||
|
if "不存在" not in err_old and "not exist" not in low and "不存在" not in low:
|
||||||
|
print(" [宝塔API] 移动 dist→dist1: %s" % err_old)
|
||||||
|
|
||||||
|
ok_swap, err_swap = bt_mv_file(cfg, dist2, dist)
|
||||||
|
if not ok_swap:
|
||||||
|
print(" [失败] 将 dist2 切换为 dist 失败: %s" % err_swap)
|
||||||
|
print(" [提示] 可到面板「文件」手动把 %s 改名为 dist" % dist2)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if ok_move_old:
|
||||||
|
if not bt_delete_dir(cfg, dist1):
|
||||||
|
print(" [警告] 未能删除临时目录 dist1: %s(可面板手动删)" % dist1)
|
||||||
|
|
||||||
|
print(" [成功] 新版本已生效: %s" % dist)
|
||||||
|
print("[5/5] 完成(纯 API;若 403 请在面板核对站点目录属主 www)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def create_parser(default_profile):
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="CKB 静态站点部署(宝塔 API:上传 zip、解压、目录切换)",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--profile",
|
||||||
|
choices=tuple(PROFILE_PRESETS.keys()),
|
||||||
|
default=default_profile,
|
||||||
|
help="部署档位:prod=正式环境,dev=测试环境",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--base-path",
|
||||||
|
default="",
|
||||||
|
help="覆盖部署目录(默认按 profile 取 DEPLOY_BASE_PATH 或预设路径)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--no-build", action="store_true", help="跳过本地构建,直接上传现有 dist")
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-bt-ping",
|
||||||
|
action="store_true",
|
||||||
|
help="跳过宝塔面板 API 探活(GetDiskInfo)",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None, default_profile="prod"):
|
||||||
|
default_profile = resolve_profile(default_profile)
|
||||||
|
parser = create_parser(default_profile=default_profile)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
profile = resolve_profile(args.profile)
|
||||||
|
root = resolve_project_root()
|
||||||
|
cfg = get_cfg(profile=profile, cli_base_path=(args.base_path or "").strip())
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print(" %s 部署(宝塔 API)" % cfg["title"])
|
||||||
|
print("=" * 60)
|
||||||
|
print(" profile: %s" % cfg["profile"])
|
||||||
|
print(" 面板: %s" % cfg["bt_panel_url"])
|
||||||
|
print(" 站点根: %s" % cfg["base_path"])
|
||||||
|
print(" 静态目录: %s" % cfg["dist_path"])
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if not args.skip_bt_ping:
|
||||||
|
if not bt_panel_ping(cfg):
|
||||||
|
print("[失败] 宝塔 API 探活未通过(可用 --skip-bt-ping 跳过)")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not args.no_build:
|
||||||
|
if not run_build(root, cfg):
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
if not ensure_dist_ready(root):
|
||||||
|
print("[错误] 未找到 dist/index.html,请先执行构建或去掉 --no-build")
|
||||||
|
return 1
|
||||||
|
print("[1/5] 跳过本地构建")
|
||||||
|
|
||||||
|
zip_path = pack_dist_zip(root, profile=profile)
|
||||||
|
if not zip_path:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not deploy_via_btapi(cfg, zip_path):
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if zip_path and os.path.isfile(zip_path):
|
||||||
|
os.remove(zip_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print(" 部署完成!站点目录: %s" % cfg["dist_path"])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -9,6 +9,7 @@ export interface Task {
|
|||||||
today_customers?: number;
|
today_customers?: number;
|
||||||
lastUpdated?: string;
|
lastUpdated?: string;
|
||||||
planType?: number; // 0-全局计划, 1-独立计划
|
planType?: number; // 0-全局计划, 1-独立计划
|
||||||
|
config?: { planType?: number; apiKey?: string; api_key?: string };
|
||||||
stats?: {
|
stats?: {
|
||||||
devices?: number;
|
devices?: number;
|
||||||
acquired?: number;
|
acquired?: number;
|
||||||
@@ -22,6 +23,8 @@ export interface Task {
|
|||||||
addedCount?: number;
|
addedCount?: number;
|
||||||
passRate?: number;
|
passRate?: number;
|
||||||
passCount?: number;
|
passCount?: number;
|
||||||
|
apiKey?: string;
|
||||||
|
textUrl?: { apiKey?: string; api_key?: string; fullUrl?: string; full_url?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiSettings {
|
export interface ApiSettings {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import DeviceListModal from "./components/DeviceListModal";
|
|||||||
import AccountListModal from "./components/AccountListModal";
|
import AccountListModal from "./components/AccountListModal";
|
||||||
import OreadyAdd from "./components/OreadyAdd";
|
import OreadyAdd from "./components/OreadyAdd";
|
||||||
import PoolListModal from "./components/PoolListModal";
|
import PoolListModal from "./components/PoolListModal";
|
||||||
|
import { extractApiKeyFromPlanLike, pickTextUrl } from "./planFields";
|
||||||
|
|
||||||
const ScenarioList: React.FC = () => {
|
const ScenarioList: React.FC = () => {
|
||||||
const { scenarioId, scenarioName } = useParams<{
|
const { scenarioId, scenarioName } = useParams<{
|
||||||
@@ -102,10 +103,15 @@ const ScenarioList: React.FC = () => {
|
|||||||
|
|
||||||
if (response && response.list) {
|
if (response && response.list) {
|
||||||
// 处理 planType 字段
|
// 处理 planType 字段
|
||||||
const processedList = response.list.map((task: any) => ({
|
const processedList = response.list.map((task: any) => {
|
||||||
...task,
|
const planType = task.planType ?? task.config?.planType ?? 1;
|
||||||
planType: task.planType ?? task.config?.planType ?? 1, // 默认独立计划
|
const apiKey = extractApiKeyFromPlanLike(task);
|
||||||
}));
|
return {
|
||||||
|
...task,
|
||||||
|
planType,
|
||||||
|
...(apiKey ? { apiKey } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoadMore) {
|
if (isLoadMore) {
|
||||||
// 加载更多时,追加数据
|
// 加载更多时,追加数据
|
||||||
@@ -214,19 +220,28 @@ const ScenarioList: React.FC = () => {
|
|||||||
const handleOpenApiSettings = async (taskId: string) => {
|
const handleOpenApiSettings = async (taskId: string) => {
|
||||||
try {
|
try {
|
||||||
const response: PlanDetail = await getPlanDetail(taskId);
|
const response: PlanDetail = await getPlanDetail(taskId);
|
||||||
if (response) {
|
if (!response) return;
|
||||||
// 处理webhook URL,使用工具函数构建完整地址
|
|
||||||
const webhookUrl = buildApiUrl(
|
|
||||||
response.textUrl?.fullUrl || `webhook/${taskId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
setCurrentApiSettings({
|
const apiKey = extractApiKeyFromPlanLike(response);
|
||||||
apiKey: response.apiKey || "demo-api-key-123456",
|
if (!apiKey) {
|
||||||
webhookUrl: webhookUrl,
|
Toast.show({
|
||||||
taskId: taskId,
|
content: "未获取到 API 密钥,请稍后重试或联系管理员",
|
||||||
|
position: "top",
|
||||||
});
|
});
|
||||||
setShowApiDialog(true);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tu = pickTextUrl(response);
|
||||||
|
const webhookUrl = buildApiUrl(
|
||||||
|
tu?.fullUrl || response.textUrl?.fullUrl || `webhook/${taskId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
setCurrentApiSettings({
|
||||||
|
apiKey,
|
||||||
|
webhookUrl,
|
||||||
|
taskId,
|
||||||
|
});
|
||||||
|
setShowApiDialog(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.show({
|
Toast.show({
|
||||||
content: "获取计划接口失败",
|
content: "获取计划接口失败",
|
||||||
@@ -315,9 +330,14 @@ const ScenarioList: React.FC = () => {
|
|||||||
await fetchPlanList(1, false);
|
await fetchPlanList(1, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredTasks = tasks.filter(task =>
|
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
const filteredTasks = tasks.filter(task => {
|
||||||
);
|
if (!normalizedSearch) return true;
|
||||||
|
const nameMatch = task.name.toLowerCase().includes(normalizedSearch);
|
||||||
|
const keyStr = extractApiKeyFromPlanLike(task).toLowerCase();
|
||||||
|
const keyMatch = keyStr.includes(normalizedSearch);
|
||||||
|
return nameMatch || keyMatch;
|
||||||
|
});
|
||||||
|
|
||||||
// 分隔全局计划和独立计划
|
// 分隔全局计划和独立计划
|
||||||
const globalPlans = filteredTasks.filter(task => task.planType === 0);
|
const globalPlans = filteredTasks.filter(task => task.planType === 0);
|
||||||
@@ -397,7 +417,7 @@ const ScenarioList: React.FC = () => {
|
|||||||
<div className={style["search-bar"]}>
|
<div className={style["search-bar"]}>
|
||||||
<div className={style["search-input-wrapper"]}>
|
<div className={style["search-input-wrapper"]}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索计划名称"
|
placeholder="搜索计划名称或 API Key"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
|
|||||||
30
src/pages/mobile/scenarios/plan/list/planFields.ts
Normal file
30
src/pages/mobile/scenarios/plan/list/planFields.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/** 兼容后端 camelCase / snake_case 及嵌套字段,避免推广侧拿到错误密钥 */
|
||||||
|
|
||||||
|
export function extractApiKeyFromPlanLike(record: any): string {
|
||||||
|
if (!record || typeof record !== "object") return "";
|
||||||
|
const cfg = record.config;
|
||||||
|
const tu = record.textUrl ?? record.text_url;
|
||||||
|
const candidates = [
|
||||||
|
record.apiKey,
|
||||||
|
record.api_key,
|
||||||
|
tu?.apiKey,
|
||||||
|
tu?.api_key,
|
||||||
|
cfg?.apiKey,
|
||||||
|
cfg?.api_key,
|
||||||
|
];
|
||||||
|
for (const v of candidates) {
|
||||||
|
if (v != null && String(v).trim() !== "") return String(v).trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickTextUrl(record: any):
|
||||||
|
| { fullUrl?: string; apiKey?: string }
|
||||||
|
| undefined {
|
||||||
|
const tu = record?.textUrl ?? record?.text_url;
|
||||||
|
if (!tu || typeof tu !== "object") return undefined;
|
||||||
|
return {
|
||||||
|
fullUrl: tu.fullUrl ?? tu.full_url,
|
||||||
|
apiKey: tu.apiKey ?? tu.api_key,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
open: true,
|
open: true,
|
||||||
port: 3000,
|
port: 3800,
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -306,6 +306,10 @@ Cunkebao/
|
|||||||
- **预览命令**: `pnpm preview`
|
- **预览命令**: `pnpm preview`
|
||||||
- **开发服务器**: `pnpm dev` (端口3000)
|
- **开发服务器**: `pnpm dev` (端口3000)
|
||||||
- **输出目录**: `dist/`
|
- **输出目录**: `dist/`
|
||||||
|
- **宝塔面板(静态发布脚本 `master.py`)**
|
||||||
|
- **默认面板主机(最新)**: `43.139.27.93`;未设置 `BT_PANEL_URL` 时默认访问 `https://43.139.27.93:9988`
|
||||||
|
- **默认站点根目录**: `/www/wwwroot/auto-devlop/ckb-operation`(可用环境变量 `DEPLOY_BASE_PATH` / `DEPLOY_BASE_PATH_PROD` / `DEPLOY_BASE_PATH_DEV` 覆盖)
|
||||||
|
- **常用环境变量**: `BT_PANEL_URL`、`BT_API_KEY`、`DEPLOY_HOST`(覆盖默认 IP)、`BT_UPLOAD_CHUNK_MB`(上传分块,默认 4MB)
|
||||||
|
|
||||||
## 📝 总结
|
## 📝 总结
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user