删除过时的批处理脚本和部署Python文件
- 删除了macOS虚拟机的安装和迁移批处理脚本,因为它们已不再需要。 - 删除了macOS虚拟机的导出脚本,以简化项目流程。 - 删除了soul-admin项目的部署Python脚本,以简化代码库。 - 更新了小程序,以反映环境配置的变化并提升用户体验。
This commit is contained in:
@@ -1,20 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||
$src = "C:\Users\29195\Mycontent\macos-vm\OneClick-macOS-Simple-KVM"
|
||||
$dst = "C:\Users\29195\Mycontent\macos-vm\macos-vm-snapshot-$timestamp.zip"
|
||||
|
||||
Write-Host "打包目录: $src"
|
||||
Write-Host "目标文件: $dst"
|
||||
|
||||
if (-not (Test-Path $src)) {
|
||||
Write-Error "源目录不存在:$src"
|
||||
}
|
||||
|
||||
if (Test-Path $dst) {
|
||||
Remove-Item $dst -Force
|
||||
}
|
||||
|
||||
Compress-Archive -Path $src -DestinationPath $dst -Force
|
||||
Write-Host '打包完成.'
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title 一键启动 macOS 虚拟机 + TightVNC
|
||||
|
||||
echo ========================================
|
||||
echo 一键启动 macOS 虚拟机(龙虾方案)
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
set "VM_DIR=%USERPROFILE%\Mycontent\macos-vm\OneClick-macOS-Simple-KVM"
|
||||
if not exist "%VM_DIR%\basic.sh" (
|
||||
echo [错误] 未找到虚拟机目录: %VM_DIR%
|
||||
echo 请先运行龙虾安装流程完成首次部署。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [1] 在新窗口启动 macOS 虚拟机(8G 内存 / 4 核)...
|
||||
start "macOS 虚拟机 - 勿关此窗口" wsl -d Ubuntu-24.04 -e bash -lc "cd /mnt/c/Users/%USERNAME%/Mycontent/macos-vm/OneClick-macOS-Simple-KVM && sudo HEADLESS=1 ./basic.sh"
|
||||
|
||||
echo [2] 等待约 10 秒后自动打开 TightVNC Viewer 连接 localhost:5900 ...
|
||||
timeout /t 10 /nobreak >nul
|
||||
|
||||
set "TVN=%ProgramFiles%\TightVNC\tvnviewer.exe"
|
||||
if not exist "%TVN%" set "TVN=%ProgramFiles(x86)%\TightVNC\tvnviewer.exe"
|
||||
if exist "%TVN%" (
|
||||
start "" "%TVN%" localhost::5900
|
||||
echo [3] 已启动 TightVNC Viewer,连接 localhost:5900
|
||||
) else (
|
||||
echo [3] 未找到 TightVNC,请手动打开 VNC 客户端连接: localhost:5900
|
||||
)
|
||||
|
||||
echo.
|
||||
echo 虚拟机在「macOS 虚拟机 - 勿关此窗口」中运行,关闭该窗口会关闭虚拟机。
|
||||
echo 本窗口可以关闭。
|
||||
echo ========================================
|
||||
pause
|
||||
@@ -1,62 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title 安装 macOS 虚拟机(龙虾方案)
|
||||
|
||||
setlocal
|
||||
|
||||
echo ============================================
|
||||
echo 安装 macOS 虚拟机(WSL2 + QEMU + OneClick)
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
rem 1. 定位 Python 安装脚本(仓库内已有)
|
||||
set "ROOT=%~dp0.."
|
||||
set "PY_SCRIPT=%ROOT%开发文档\服务器管理\scripts\lobster_macos_vm.py"
|
||||
|
||||
if not exist "%PY_SCRIPT%" (
|
||||
echo [错误] 找不到 Python 安装脚本:
|
||||
echo %PY_SCRIPT%
|
||||
echo 请确认仓库已完整同步。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
rem 2. 检查 Python
|
||||
where python >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
where py >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [错误] 未检测到 Python 3,请先安装 Python 3 再运行本脚本。
|
||||
echo 将打开 Python 官网下載頁面,請安裝完後重新運行本腳本。
|
||||
start "" "https://www.python.org/downloads/windows/"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
rem 3. 检查 / 引导安装 TightVNC
|
||||
echo [1] 检查 TightVNC Viewer ...
|
||||
set "TVN=%ProgramFiles%\TightVNC\tvnviewer.exe"
|
||||
if not exist "%TVN%" set "TVN=%ProgramFiles(x86)%\TightVNC\tvnviewer.exe"
|
||||
|
||||
if exist "%TVN%" (
|
||||
echo 已检测到 TightVNC: %TVN%
|
||||
) else (
|
||||
echo 未检测到 TightVNC,將打開官網請手動下載並安裝。
|
||||
start "" "https://www.tightvnc.com/download.php"
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [2] 開始調用 Python 腳本,一鍵安裝 macOS 虛擬機 ...
|
||||
echo 腳本將檢查 WSL/Ubuntu、下載 OneClick、安裝 QEMU、下載 macOS 恢復鏡像等。
|
||||
echo.
|
||||
|
||||
python "%PY_SCRIPT%" || py -3 "%PY_SCRIPT%"
|
||||
|
||||
echo.
|
||||
echo [3] 安裝流程結束(若上方無紅色錯誤信息,即表示完成)。
|
||||
echo 之後可使用同目錄下的「一键启动-macOS虚拟机.bat」啟動虛擬機。
|
||||
echo ============================================
|
||||
echo.
|
||||
pause
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title 迁移 macOS 虚拟机 到 E:\Gongsi\Mycontent\macos-vm
|
||||
|
||||
echo ============================================
|
||||
echo 迁移 macOS 虚拟机目录 到 E:\Gongsi\Mycontent\macos-vm
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
setlocal
|
||||
|
||||
set "SRC=C:\Users\%USERNAME%\Mycontent\macos-vm"
|
||||
set "DST=E:\Gongsi\Mycontent\macos-vm"
|
||||
|
||||
echo [1] 源目录: %SRC%
|
||||
echo [1] 目标目录: %DST%
|
||||
echo.
|
||||
|
||||
if not exist "%SRC%" (
|
||||
echo [错误] 未找到源目录: %SRC%
|
||||
echo 请确认当前用户下已存在 macos-vm 目录。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
rem 尝试关闭正在运行的 QEMU(通过 WSL)
|
||||
echo [2] 尝试关闭正在运行的 macOS 虚拟机(如有)...
|
||||
wsl -d Ubuntu-24.04 -e bash -lc "ps -ef | grep qemu-system-x86_64 | grep -v grep || true" >nul 2>&1
|
||||
for /f "tokens=2" %%P in ('wsl -d Ubuntu-24.04 -e bash -lc "ps -ef | grep qemu-system-x86_64 | grep -v grep || true" ^| find "qemu-system-x86_64"') do (
|
||||
echo 检测到 QEMU 进程 PID=%%P,正在尝试结束...
|
||||
wsl -d Ubuntu-24.04 -e bash -lc "sudo kill -TERM %%P; sleep 2; ps -p %%P >/dev/null && sudo kill -KILL %%P || true" >nul 2>&1
|
||||
)
|
||||
|
||||
echo [3] 创建目标目录(如不存在)...
|
||||
mkdir "%DST%" 2>nul
|
||||
|
||||
echo [4] 使用 robocopy 迁移所有文件(可能需要一段时间)...
|
||||
echo.
|
||||
robocopy "%SRC%" "%DST%" /MIR
|
||||
|
||||
if errorlevel 8 (
|
||||
echo.
|
||||
echo [警告] robocopy 返回代码 %errorlevel%(>=8),可能有错误,請檢查上方輸出。
|
||||
echo 不會自動刪除源目錄,請手動確認。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [5] 迁移完成。
|
||||
echo 源目录: %SRC%
|
||||
echo 目标目录: %DST%
|
||||
echo.
|
||||
echo 如確認新位置可以正常啟動虛擬機,可手動刪除源目錄以釋放 C 盤空間。
|
||||
echo ============================================
|
||||
pause
|
||||
|
||||
@@ -8,8 +8,8 @@ const { parseScene } = require('./utils/scene.js')
|
||||
App({
|
||||
globalData: {
|
||||
// API 基础地址(切换环境时注释/取消注释)
|
||||
baseUrl: 'https://soulapi.quwanzhi.com',
|
||||
// baseUrl: 'http://localhost:8080', // 本地调试
|
||||
// baseUrl: 'https://soulapi.quwanzhi.com',
|
||||
baseUrl: 'http://localhost:8080', // 本地调试
|
||||
// baseUrl: 'https://souldev.quwanzhi.com', // 测试环境
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Page({
|
||||
isVip: false,
|
||||
avatar: '',
|
||||
nickname: '',
|
||||
shareCardPath: '', // 分享名片封面图(预生成)
|
||||
mbti: '',
|
||||
mbtiIndex: 0,
|
||||
region: '',
|
||||
@@ -40,8 +41,15 @@ Page({
|
||||
showAvatarModal: false,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
onLoad(options) {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
|
||||
wx.showShareMenu({ withShareTimeline: true })
|
||||
// 从朋友圈/分享打开且带 id:跳转到名片详情(member-detail)
|
||||
if (options?.id) {
|
||||
const ref = options.ref ? `&ref=${options.ref}` : ''
|
||||
wx.redirectTo({ url: `/pages/member-detail/member-detail?id=${options.id}${ref}` })
|
||||
return
|
||||
}
|
||||
this.loadProfile()
|
||||
},
|
||||
|
||||
@@ -85,6 +93,7 @@ Page({
|
||||
projectIntro: v('projectIntro'),
|
||||
loading: false,
|
||||
})
|
||||
setTimeout(() => this.generateShareCard(), 200)
|
||||
} else {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
@@ -95,17 +104,128 @@ Page({
|
||||
|
||||
goBack() { getApp().goBackOrToHome() },
|
||||
|
||||
// 生成分享名片封面图(头像+昵称,5:4 比例)
|
||||
async generateShareCard() {
|
||||
const { avatar, nickname } = this.data
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) return
|
||||
try {
|
||||
const ctx = wx.createCanvasContext('shareCardCanvas', this)
|
||||
const w = 500
|
||||
const h = 400
|
||||
// 背景渐变(Soul 深色风格)
|
||||
const grd = ctx.createLinearGradient(0, 0, w, h)
|
||||
grd.addColorStop(0, '#0F172A')
|
||||
grd.addColorStop(0.5, '#050B14')
|
||||
grd.addColorStop(1, '#0F172A')
|
||||
ctx.setFillStyle(grd)
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
// 顶部装饰条
|
||||
ctx.setFillStyle('#5EEAD4')
|
||||
ctx.fillRect(0, 0, w, 4)
|
||||
// 头像:居中偏上,圆形 120px
|
||||
const avatarSize = 120
|
||||
const avatarX = (w - avatarSize) / 2
|
||||
const avatarY = 80
|
||||
const avatarRadius = avatarSize / 2
|
||||
const drawAvatar = () => new Promise((resolve) => {
|
||||
if (avatar && avatar.startsWith('http')) {
|
||||
wx.downloadFile({
|
||||
url: avatar,
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.arc(avatarX + avatarRadius, avatarY + avatarRadius, avatarRadius, 0, Math.PI * 2)
|
||||
ctx.clip()
|
||||
ctx.drawImage(res.tempFilePath, avatarX, avatarY, avatarSize, avatarSize)
|
||||
ctx.restore()
|
||||
} else {
|
||||
this.drawAvatarPlaceholder(ctx, avatarX, avatarY, avatarSize, nickname)
|
||||
}
|
||||
resolve()
|
||||
},
|
||||
fail: () => {
|
||||
this.drawAvatarPlaceholder(ctx, avatarX, avatarY, avatarSize, nickname)
|
||||
resolve()
|
||||
},
|
||||
})
|
||||
} else {
|
||||
this.drawAvatarPlaceholder(ctx, avatarX, avatarY, avatarSize, nickname)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
await drawAvatar()
|
||||
// 头像外圈描边
|
||||
ctx.setStrokeStyle('#5EEAD4')
|
||||
ctx.setLineWidth(3)
|
||||
ctx.beginPath()
|
||||
ctx.arc(avatarX + avatarRadius, avatarY + avatarRadius, avatarRadius, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
// 昵称
|
||||
const displayName = (nickname || '').trim() || '创业者'
|
||||
ctx.setFillStyle('#ffffff')
|
||||
ctx.setFontSize(24)
|
||||
ctx.setTextAlign('center')
|
||||
ctx.fillText(displayName, w / 2, avatarY + avatarSize + 50)
|
||||
// 副标题
|
||||
ctx.setFillStyle('rgba(94,234,212,0.9)')
|
||||
ctx.setFontSize(14)
|
||||
ctx.fillText('Soul创业派对 · 名片', w / 2, avatarY + avatarSize + 78)
|
||||
ctx.draw(true, () => {
|
||||
wx.canvasToTempFilePath({
|
||||
canvasId: 'shareCardCanvas',
|
||||
destWidth: 500,
|
||||
destHeight: 400,
|
||||
success: (res) => {
|
||||
this.setData({ shareCardPath: res.tempFilePath })
|
||||
},
|
||||
}, this)
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[ShareCard] 生成失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
drawAvatarPlaceholder(ctx, x, y, size, nickname) {
|
||||
ctx.setFillStyle('rgba(94,234,212,0.25)')
|
||||
ctx.beginPath()
|
||||
ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.setFillStyle('#5EEAD4')
|
||||
ctx.setFontSize(size * 0.45)
|
||||
ctx.setTextAlign('center')
|
||||
ctx.fillText((nickname || '?')[0], x + size / 2, y + size / 2 + size * 0.15)
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const ref = app.getMyReferralCode()
|
||||
return {
|
||||
title: 'Soul创业派对 - 编辑资料',
|
||||
path: ref ? `/pages/profile-edit/profile-edit?ref=${ref}` : '/pages/profile-edit/profile-edit'
|
||||
const userId = app.globalData.userInfo?.id
|
||||
const nickname = (this.data.nickname || '').trim() || '我'
|
||||
const path = userId
|
||||
? (ref ? `/pages/member-detail/member-detail?id=${userId}&ref=${ref}` : `/pages/member-detail/member-detail?id=${userId}`)
|
||||
: (ref ? `/pages/profile-edit/profile-edit?ref=${ref}` : '/pages/profile-edit/profile-edit')
|
||||
const result = {
|
||||
title: `${nickname}为您分享名片`,
|
||||
path,
|
||||
}
|
||||
if (this.data.shareCardPath) result.imageUrl = this.data.shareCardPath
|
||||
return result
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
const ref = app.getMyReferralCode()
|
||||
return { title: 'Soul创业派对 - 编辑资料', query: ref ? `ref=${ref}` : '' }
|
||||
const userId = app.globalData.userInfo?.id
|
||||
const nickname = (this.data.nickname || '').trim() || '我'
|
||||
const query = userId
|
||||
? (ref ? `id=${userId}&ref=${ref}` : `id=${userId}`)
|
||||
: (ref ? `ref=${ref}` : '')
|
||||
const result = {
|
||||
title: `${nickname}为您分享名片`,
|
||||
query: query || '',
|
||||
}
|
||||
if (this.data.shareCardPath) result.imageUrl = this.data.shareCardPath
|
||||
return result
|
||||
},
|
||||
|
||||
onNicknameInput(e) { this.setData({ nickname: e.detail.value }) },
|
||||
@@ -186,6 +306,7 @@ Page({
|
||||
}
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '头像已更新', icon: 'success' })
|
||||
setTimeout(() => this.generateShareCard(), 200)
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: e.message || '上传失败', icon: 'none' })
|
||||
@@ -234,6 +355,7 @@ Page({
|
||||
}
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '头像已更新', icon: 'success' })
|
||||
setTimeout(() => this.generateShareCard(), 200)
|
||||
} catch (err) {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: err.message || '上传失败,请重试', icon: 'none' })
|
||||
|
||||
@@ -146,6 +146,9 @@
|
||||
<view class="bottom-space"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 分享名片 canvas(隐藏,用于生成分享图 5:4) -->
|
||||
<canvas canvas-id="shareCardCanvas" class="share-card-canvas" style="width: 500px; height: 400px;"></canvas>
|
||||
|
||||
<!-- 头像弹窗:通过 button 获取微信头像 -->
|
||||
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
|
||||
<view class="modal-content avatar-modal" catchtap="stopPropagation">
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
/* 资料编辑 - comprehensive_profile_editor_v1_1 | 配色 enhanced,input/textarea 用 view 包裹 */
|
||||
|
||||
/* 分享名片 canvas:隐藏,仅用于生成图片 */
|
||||
.share-card-canvas {
|
||||
position: fixed; left: -9999px; top: 0; width: 500px; height: 400px;
|
||||
}
|
||||
.page {
|
||||
background: #050B14; min-height: 100vh; color: #fff;
|
||||
width: 100%; box-sizing: border-box; overflow-x: hidden;
|
||||
|
||||
@@ -23,12 +23,19 @@
|
||||
"condition": {
|
||||
"miniprogram": {
|
||||
"list": [
|
||||
{
|
||||
"name": "pages/read/read",
|
||||
"pathName": "pages/read/read",
|
||||
"query": "mid=219",
|
||||
"scene": null,
|
||||
"launchMode": "default"
|
||||
},
|
||||
{
|
||||
"name": "唤醒",
|
||||
"pathName": "pages/read/read",
|
||||
"query": "mid=209",
|
||||
"scene": null,
|
||||
"launchMode": "default"
|
||||
"launchMode": "default",
|
||||
"scene": null
|
||||
},
|
||||
{
|
||||
"name": "pages/my/my",
|
||||
|
||||
318
soul-admin/deploy.py
Normal file
318
soul-admin/deploy.py
Normal file
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
soul-admin-dev 静态站点部署:打包 dist → 上传 → 解压到 dist2 → dist/dist2 互换实现无缝切换。
|
||||
不安装依赖、不重启、不调用宝塔 API。
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shlex
|
||||
import tempfile
|
||||
import argparse
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError:
|
||||
print("错误: 请先安装 paramiko")
|
||||
print(" pip install paramiko")
|
||||
sys.exit(1)
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
# 站点根目录(Nginx 等指向的目录的上一级,即 dist 的父目录)
|
||||
DEPLOY_BASE_PATH = "/www/wwwroot/self/soul-admin-dev"
|
||||
# 切换后 chown 的属主,宝塔一般为 www:www,空则跳过
|
||||
DEPLOY_WWW_USER = os.environ.get("DEPLOY_WWW_USER", "www:www")
|
||||
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
|
||||
|
||||
|
||||
def get_cfg():
|
||||
base = os.environ.get("DEPLOY_BASE_PATH", DEPLOY_BASE_PATH).rstrip("/")
|
||||
return {
|
||||
"host": os.environ.get("DEPLOY_HOST", "43.139.27.93"),
|
||||
"user": os.environ.get("DEPLOY_USER", "root"),
|
||||
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
|
||||
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
|
||||
"base_path": base,
|
||||
"dist_path": base + "/dist",
|
||||
"dist2_path": base + "/dist2",
|
||||
"www_user": os.environ.get("DEPLOY_WWW_USER", DEPLOY_WWW_USER).strip(),
|
||||
}
|
||||
|
||||
|
||||
# ==================== 本地构建 ====================
|
||||
|
||||
|
||||
def run_build(root):
|
||||
"""执行本地 pnpm build(使用 .env.development 测试环境配置)"""
|
||||
use_shell = sys.platform == "win32"
|
||||
dist_dir = os.path.join(root, "dist")
|
||||
index_html = os.path.join(dist_dir, "index.html")
|
||||
|
||||
try:
|
||||
r = __import__("subprocess").run(
|
||||
["pnpm", "run", "build:dev"],
|
||||
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:]:
|
||||
print(" " + line)
|
||||
return False
|
||||
except __import__("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 os.path.isfile(index_html):
|
||||
print(" [失败] 未找到 dist/index.html")
|
||||
return False
|
||||
print(" [成功] 构建完成")
|
||||
return True
|
||||
|
||||
|
||||
# ==================== 打包 dist 为 zip ====================
|
||||
|
||||
|
||||
def pack_dist_zip(root):
|
||||
"""将本地 dist 目录打包为 zip(解压到 dist2 后即为站点根内容)"""
|
||||
print("[2/4] 打包 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,请先执行 pnpm build")
|
||||
return None
|
||||
|
||||
zip_path = os.path.join(tempfile.gettempdir(), "soul_admin_deploy.zip")
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for dirpath, _dirs, filenames in os.walk(dist_dir):
|
||||
for f in filenames:
|
||||
full = os.path.join(dirpath, f)
|
||||
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
|
||||
|
||||
|
||||
# ==================== SSH 上传并解压到 dist2 ====================
|
||||
|
||||
|
||||
def upload_zip_and_extract_to_dist2(cfg, zip_path):
|
||||
"""上传 zip 到服务器并解压到 dist2"""
|
||||
print("[3/4] SSH 上传 zip 并解压到 dist2 ...")
|
||||
sys.stdout.flush()
|
||||
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||||
return False
|
||||
zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||
sys.stdout.flush()
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(
|
||||
cfg["host"],
|
||||
port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"],
|
||||
key_filename=cfg["ssh_key"],
|
||||
timeout=30,
|
||||
banner_timeout=30,
|
||||
)
|
||||
else:
|
||||
client.connect(
|
||||
cfg["host"],
|
||||
port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"],
|
||||
password=cfg["password"],
|
||||
timeout=30,
|
||||
banner_timeout=30,
|
||||
)
|
||||
print(" [OK] SSH 已连接,正在上传 zip(%.1f MB)..." % zip_size_mb)
|
||||
sys.stdout.flush()
|
||||
remote_zip = cfg["base_path"] + "/soul_admin_deploy.zip"
|
||||
sftp = client.open_sftp()
|
||||
chunk_mb = 5.0
|
||||
last_reported = [0]
|
||||
|
||||
def _progress(transferred, total):
|
||||
if total and total > 0:
|
||||
now_mb = transferred / (1024 * 1024)
|
||||
if now_mb - last_reported[0] >= chunk_mb or transferred >= total:
|
||||
last_reported[0] = now_mb
|
||||
print("\r 上传进度: %.1f / %.1f MB" % (now_mb, total / (1024 * 1024)), end="")
|
||||
sys.stdout.flush()
|
||||
|
||||
sftp.put(zip_path, remote_zip, callback=_progress)
|
||||
if zip_size_mb >= chunk_mb:
|
||||
print("")
|
||||
print(" [OK] zip 已上传,正在服务器解压到 dist2 ...")
|
||||
sys.stdout.flush()
|
||||
sftp.close()
|
||||
dist2 = cfg["dist2_path"]
|
||||
cmd = "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" % (
|
||||
dist2,
|
||||
dist2,
|
||||
remote_zip,
|
||||
dist2,
|
||||
remote_zip,
|
||||
)
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=120)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||
if err:
|
||||
print(" 服务器 stderr: %s" % err[:500])
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
if exit_status != 0 or "OK" not in out:
|
||||
print(" [失败] 解压失败,退出码: %s" % exit_status)
|
||||
if out:
|
||||
print(" stdout: %s" % out[:300])
|
||||
return False
|
||||
print(" [成功] 已解压到: %s" % dist2)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误: %s" % str(e))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
# ==================== 服务器目录切换:dist → dist1,dist2 → dist ====================
|
||||
|
||||
|
||||
def remote_swap_dist(cfg):
|
||||
"""服务器上:dist→dist1,dist2→dist,删除 dist1,实现无缝切换"""
|
||||
print("[4/4] 服务器切换目录: dist→dist1, dist2→dist ...")
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(
|
||||
cfg["host"],
|
||||
port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"],
|
||||
key_filename=cfg["ssh_key"],
|
||||
timeout=15,
|
||||
)
|
||||
else:
|
||||
client.connect(
|
||||
cfg["host"],
|
||||
port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"],
|
||||
password=cfg["password"],
|
||||
timeout=15,
|
||||
)
|
||||
base = cfg["base_path"]
|
||||
# 若当前没有 dist(首次部署),则 dist2 直接改名为 dist;若有 dist 则先备份再替换
|
||||
cmd = "cd %s && (test -d dist && (mv dist dist1 && mv dist2 dist && rm -rf dist1) || mv dist2 dist) && echo OK" % base
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
if exit_status != 0 or "OK" not in out:
|
||||
print(" [失败] 切换失败 (退出码: %s)" % exit_status)
|
||||
if err:
|
||||
print(" 服务器 stderr: %s" % err)
|
||||
if out and "OK" not in out:
|
||||
print(" 服务器 stdout: %s" % out)
|
||||
return False
|
||||
print(" [成功] 新版本已切换至: %s" % cfg["dist_path"])
|
||||
# 切换后设置 www 访问权限,否则 Nginx 无法读文件导致无法访问
|
||||
www_user = cfg.get("www_user")
|
||||
if www_user:
|
||||
dist_path = cfg["dist_path"]
|
||||
chown_cmd = "chown -R %s %s && echo OK" % (www_user, shlex.quote(dist_path))
|
||||
stdin, stdout, stderr = client.exec_command(chown_cmd, timeout=60)
|
||||
chown_out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
chown_err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||
if stdout.channel.recv_exit_status() != 0 or "OK" not in chown_out:
|
||||
print(" [警告] chown 失败,站点可能无法访问: %s" % (chown_err or chown_out))
|
||||
else:
|
||||
print(" [成功] 已设置属主: %s" % www_user)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误: %s" % str(e))
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="soul-admin-dev 静态站点部署(dist2 解压后目录切换,无缝更新)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="不安装依赖、不重启、不调用宝塔 API。站点路径: " + DEPLOY_BASE_PATH + "/dist",
|
||||
)
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地 pnpm build")
|
||||
args = parser.parse_args()
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if os.path.isfile(os.path.join(script_dir, "package.json")):
|
||||
root = script_dir
|
||||
else:
|
||||
root = os.path.dirname(script_dir)
|
||||
|
||||
cfg = get_cfg()
|
||||
print("=" * 60)
|
||||
print(" soul-admin-dev 部署(dist/dist2 无缝切换)")
|
||||
print("=" * 60)
|
||||
print(" 服务器: %s@%s 站点目录: %s" % (cfg["user"], cfg["host"], cfg["dist_path"]))
|
||||
print("=" * 60)
|
||||
|
||||
if not args.no_build:
|
||||
print("[1/4] 本地构建 pnpm build ...")
|
||||
if not run_build(root):
|
||||
return 1
|
||||
else:
|
||||
if not os.path.isdir(os.path.join(root, "dist")) or not os.path.isfile(
|
||||
os.path.join(root, "dist", "index.html")
|
||||
):
|
||||
print("[错误] 未找到 dist/index.html,请先执行 pnpm build 或去掉 --no-build")
|
||||
return 1
|
||||
print("[1/4] 跳过本地构建")
|
||||
|
||||
zip_path = pack_dist_zip(root)
|
||||
if not zip_path:
|
||||
return 1
|
||||
if not upload_zip_and_extract_to_dist2(cfg, zip_path):
|
||||
return 1
|
||||
try:
|
||||
os.remove(zip_path)
|
||||
except Exception:
|
||||
pass
|
||||
if not remote_swap_dist(cfg):
|
||||
return 1
|
||||
print("")
|
||||
print(" 部署完成!站点目录: %s" % cfg["dist_path"])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because one or more lines are too long
2
soul-admin/dist/index.html
vendored
2
soul-admin/dist/index.html
vendored
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理后台 - Soul创业派对</title>
|
||||
<script type="module" crossorigin src="/assets/index-BQTdJZ32.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-C2K6IQif.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BHi-SnBy.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -48,7 +48,7 @@ def get_cfg():
|
||||
|
||||
|
||||
def run_build(root):
|
||||
"""执行本地 pnpm build"""
|
||||
"""执行本地 pnpm build(使用 .env.production 正式环境配置)"""
|
||||
use_shell = sys.platform == "win32"
|
||||
dist_dir = os.path.join(root, "dist")
|
||||
index_html = os.path.join(dist_dir, "index.html")
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:dev": "tsc -b && vite build --mode development",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx"
|
||||
},
|
||||
|
||||
@@ -125,6 +125,11 @@ interface EditingSection {
|
||||
editionPremium?: boolean
|
||||
}
|
||||
|
||||
/** 去除名字中的括号及内容,名字不带符号(如 南风(管理) → 南风) */
|
||||
function sanitizeNameOrLabel(s: string): string {
|
||||
return s.replace(/\s*[((][^))]*(\)|))?/g, '').trim()
|
||||
}
|
||||
|
||||
// 在保存前自动把纯文本中的 @用户 / #标签 转成带 token / 配置的节点
|
||||
function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagItem[]): string {
|
||||
if (!html || (!html.includes('@') && !html.includes('#'))) return html
|
||||
@@ -134,10 +139,10 @@ function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagI
|
||||
container.innerHTML = html
|
||||
|
||||
const matchPerson = (name: string): PersonItem | undefined =>
|
||||
persons.find((p) => p.name === name)
|
||||
persons.find((p) => p.name === name || sanitizeNameOrLabel(p.name) === sanitizeNameOrLabel(name))
|
||||
|
||||
const matchTag = (label: string): LinkTagItem | undefined =>
|
||||
linkTags.find((t) => t.label === label)
|
||||
linkTags.find((t) => t.label === label || sanitizeNameOrLabel(t.label) === sanitizeNameOrLabel(label))
|
||||
|
||||
const processTextNode = (node: Text) => {
|
||||
const text = node.textContent || ''
|
||||
@@ -147,7 +152,8 @@ function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagI
|
||||
if (!parent) return
|
||||
|
||||
const frag = document.createDocumentFragment()
|
||||
const regex = /(@[^\s@#]+|#[^\s@#]+)/g
|
||||
// 排除 <> 避免把 HTML 标签带入 @/# 匹配(如 @远志</span> 只匹配 @远志)
|
||||
const regex = /(@[^\s@#<>]+|#[^\s@#<>]+)/g
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
@@ -160,8 +166,8 @@ function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagI
|
||||
}
|
||||
|
||||
if (full.startsWith('@')) {
|
||||
const name = full.slice(1)
|
||||
const person = matchPerson(name)
|
||||
const rawName = full.slice(1)
|
||||
const person = matchPerson(rawName)
|
||||
if (person) {
|
||||
const span = document.createElement('span')
|
||||
span.setAttribute('data-type', 'mention')
|
||||
@@ -173,8 +179,8 @@ function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagI
|
||||
frag.appendChild(document.createTextNode(full))
|
||||
}
|
||||
} else if (full.startsWith('#')) {
|
||||
const label = full.slice(1)
|
||||
const tag = matchTag(label)
|
||||
const rawLabel = full.slice(1)
|
||||
const tag = matchTag(rawLabel)
|
||||
if (tag) {
|
||||
const span = document.createElement('span')
|
||||
span.setAttribute('data-type', 'linkTag')
|
||||
@@ -210,8 +216,17 @@ function autoLinkContent(html: string, persons: PersonItem[], linkTags: LinkTagI
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement
|
||||
const dataType = el.getAttribute('data-type')
|
||||
// 已经是 mention/linkTag 的节点不再解析
|
||||
if (dataType === 'mention' || dataType === 'linkTag') return
|
||||
// mention 节点:若 data-id 为空,按昵称匹配 persons 回填(修复 TipTap 插入时 id 丢失导致小程序 data-user-id 为空)
|
||||
if (dataType === 'mention') {
|
||||
const existingId = el.getAttribute('data-id')
|
||||
const nickname = (el.getAttribute('data-label') || el.textContent || '').replace(/^@/, '').trim()
|
||||
if ((!existingId || !existingId.trim()) && nickname) {
|
||||
const person = matchPerson(nickname)
|
||||
if (person?.id) el.setAttribute('data-id', person.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (dataType === 'linkTag') return
|
||||
node.childNodes.forEach((child) => walk(child))
|
||||
return
|
||||
}
|
||||
@@ -591,19 +606,20 @@ export function ContentPage() {
|
||||
/** 文章编辑时自动创建不存在的 @人物 和 #标签,返回合并后的列表供 autoLinkContent 使用 */
|
||||
const ensureMentionsAndTags = useCallback(
|
||||
async (content: string): Promise<{ persons: PersonItem[]; linkTags: LinkTagItem[] }> => {
|
||||
const regex = /(@[^\s@#]+|#[^\s@#]+)/g
|
||||
// 排除 <> 避免把 HTML 标签带入 @/# 匹配
|
||||
const regex = /(@[^\s@#<>]+|#[^\s@#<>]+)/g
|
||||
const names = new Set<string>()
|
||||
const labels = new Set<string>()
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = regex.exec(content)) !== null) {
|
||||
const full = m[0]
|
||||
if (full.startsWith('@')) names.add(full.slice(1).trim())
|
||||
else if (full.startsWith('#')) labels.add(full.slice(1).trim())
|
||||
if (full.startsWith('@')) names.add(sanitizeNameOrLabel(full.slice(1)))
|
||||
else if (full.startsWith('#')) labels.add(sanitizeNameOrLabel(full.slice(1)))
|
||||
}
|
||||
let personsCopy = [...persons]
|
||||
let linkTagsCopy = [...linkTags]
|
||||
for (const name of names) {
|
||||
if (!name || personsCopy.some((p) => p.name === name)) continue
|
||||
if (!name || personsCopy.some((p) => p.name === name || sanitizeNameOrLabel(p.name) === name)) continue
|
||||
try {
|
||||
const res = await post<{
|
||||
success?: boolean
|
||||
@@ -625,7 +641,7 @@ export function ContentPage() {
|
||||
}
|
||||
}
|
||||
for (const label of labels) {
|
||||
if (!label || linkTagsCopy.some((t) => t.label === label)) continue
|
||||
if (!label || linkTagsCopy.some((t) => t.label === label || sanitizeNameOrLabel(t.label) === label)) continue
|
||||
try {
|
||||
const res = await post<{
|
||||
success?: boolean
|
||||
@@ -2488,6 +2504,7 @@ export function ContentPage() {
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 h-6 px-2" title="删除(同时删除存客宝对应获客计划)" onClick={async () => {
|
||||
if (!confirm(`确定删除「SOUL链接人与事-${p.name}」?将同时删除存客宝对应获客计划。`)) return
|
||||
if (!confirm(`二次确认:删除后无法恢复,文章中的 @${p.name} 将无法正常跳转。确定删除?`)) return
|
||||
await del(`/api/db/persons?personId=${p.personId}`)
|
||||
loadPersons()
|
||||
}}>
|
||||
|
||||
@@ -38,10 +38,11 @@ func main() {
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// 预热 all-chapters 缓存,避免首请求冷启动 502
|
||||
// 预热 all-chapters、book/parts 缓存,避免首请求冷启动 502
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second) // 等 DB 完全就绪
|
||||
handler.WarmAllChaptersCache()
|
||||
handler.WarmBookPartsCache()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
soulApp (soul-api) Go 项目一键部署到宝塔(正式环境)
|
||||
- 打包使用 .env.production 作为服务器 .env
|
||||
soul-api Go 项目一键部署到宝塔(测试环境),重启的是宝塔里的 soulDev 项目
|
||||
- 打包使用 .env.development 作为服务器 .env
|
||||
- 本地交叉编译 Linux 二进制
|
||||
- 上传到 /www/wwwroot/self/soul-api
|
||||
- 重启:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动
|
||||
- 上传到 /www/wwwroot/self/soul-dev
|
||||
- 重启 soulDev:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动
|
||||
|
||||
宝塔 API 重启(可选):在环境变量或 .env 中设置
|
||||
BT_PANEL_URL = https://你的面板地址:9988
|
||||
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
|
||||
BT_GO_PROJECT_NAME = soulApi (与宝塔 Go 项目列表里名称一致)
|
||||
BT_GO_PROJECT_NAME = soulDev (与宝塔 Go 项目列表里名称一致)
|
||||
并安装 requests: pip install requests
|
||||
"""
|
||||
|
||||
@@ -45,7 +45,7 @@ except ImportError:
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-api"
|
||||
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-dev"
|
||||
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ def get_cfg():
|
||||
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
|
||||
"bt_panel_url": bt_url,
|
||||
"bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT),
|
||||
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulApi"),
|
||||
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulDev"),
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ def run_build(root):
|
||||
|
||||
# ==================== 打包 ====================
|
||||
|
||||
DEPLOY_PORT = 8080
|
||||
DEPLOY_PORT = 8081
|
||||
|
||||
|
||||
def set_env_port(env_path, port=DEPLOY_PORT):
|
||||
@@ -171,11 +171,11 @@ def pack_deploy(root, binary_path, include_env=True):
|
||||
staging = tempfile.mkdtemp(prefix="soul_api_deploy_")
|
||||
try:
|
||||
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
|
||||
env_src = os.path.join(root, ".env.production")
|
||||
env_src = os.path.join(root, ".env.development")
|
||||
staging_env = os.path.join(staging, ".env")
|
||||
if include_env and os.path.isfile(env_src):
|
||||
shutil.copy2(env_src, staging_env)
|
||||
print(" [已包含] .env.production -> .env")
|
||||
print(" [已包含] .env.development -> .env")
|
||||
else:
|
||||
env_example = os.path.join(root, ".env.example")
|
||||
if os.path.isfile(env_example):
|
||||
@@ -183,8 +183,8 @@ def pack_deploy(root, binary_path, include_env=True):
|
||||
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
|
||||
if os.path.isfile(staging_env):
|
||||
set_env_port(staging_env, DEPLOY_PORT)
|
||||
set_env_mini_program_state(staging_env, "formal")
|
||||
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=formal(正式环境)" % DEPLOY_PORT)
|
||||
set_env_mini_program_state(staging_env, "developer")
|
||||
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=developer(测试环境)" % DEPLOY_PORT)
|
||||
tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz")
|
||||
with tarfile.open(tarball, "w:gz") as tf:
|
||||
for name in os.listdir(staging):
|
||||
@@ -205,7 +205,7 @@ def restart_via_bt_api(cfg):
|
||||
"""通过宝塔 API 重启 Go 项目(需配置 BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME)"""
|
||||
url = cfg.get("bt_panel_url") or ""
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
name = cfg.get("bt_go_project_name", "soulApi")
|
||||
name = cfg.get("bt_go_project_name", "soulDev")
|
||||
if not url or not key:
|
||||
return False
|
||||
if not requests:
|
||||
@@ -295,7 +295,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
print(" [成功] 已解压到: %s" % project_path)
|
||||
|
||||
if not no_restart:
|
||||
print("[4/4] 重启 soulApp 服务 ...")
|
||||
print("[4/4] 重启 soulDev 服务 ...")
|
||||
ok = False
|
||||
if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")):
|
||||
ok = restart_via_bt_api(cfg)
|
||||
@@ -315,7 +315,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
print(" [stderr] %s" % err[:200])
|
||||
ok = "RESTART_OK" in out
|
||||
if ok:
|
||||
print(" [成功] soulApp 已通过 SSH 重启")
|
||||
print(" [成功] soulDev 已通过 SSH 重启")
|
||||
else:
|
||||
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path)
|
||||
else:
|
||||
@@ -334,7 +334,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="soulApp (soul-api) Go 项目一键部署到宝塔",
|
||||
description="soul-api 一键部署到宝塔,重启 soulDev 项目",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地编译(使用已有 soul-api 二进制)")
|
||||
@@ -353,7 +353,7 @@ def main():
|
||||
|
||||
cfg = get_cfg()
|
||||
print("=" * 60)
|
||||
print(" soulApp 一键部署到宝塔")
|
||||
print(" soul-api 部署到宝塔,重启 soulDev")
|
||||
print("=" * 60)
|
||||
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||
print(" 目标目录: %s" % cfg["project_path"])
|
||||
|
||||
@@ -54,6 +54,30 @@ var allChaptersCache struct {
|
||||
|
||||
const allChaptersCacheTTL = 30 * time.Second
|
||||
|
||||
// bookPartsCache 目录接口内存缓存,30 秒 TTL,减轻 DB 压力
|
||||
type cachedPartRow struct {
|
||||
PartID string `json:"id"`
|
||||
PartTitle string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
ChapterCount int `json:"chapterCount"`
|
||||
MinSortOrder int `json:"minSortOrder"`
|
||||
}
|
||||
type cachedFixedItem struct {
|
||||
ID string `json:"id"`
|
||||
MID int `json:"mid"`
|
||||
SectionTitle string `json:"title"`
|
||||
}
|
||||
|
||||
var bookPartsCache struct {
|
||||
mu sync.RWMutex
|
||||
parts []cachedPartRow
|
||||
total int64
|
||||
fixed []cachedFixedItem
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
const bookPartsCacheTTL = 30 * time.Second
|
||||
|
||||
// WarmAllChaptersCache 启动时预热缓存,避免首请求冷启动 502
|
||||
func WarmAllChaptersCache() {
|
||||
db := database.DB()
|
||||
@@ -79,6 +103,86 @@ func WarmAllChaptersCache() {
|
||||
allChaptersCache.mu.Unlock()
|
||||
}
|
||||
|
||||
// fetchAndCacheBookParts 执行 DB 查询并更新缓存,供 BookParts 与 WarmBookPartsCache 复用
|
||||
func fetchAndCacheBookParts() (parts []cachedPartRow, total int64, fixed []cachedFixedItem) {
|
||||
db := database.DB()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
conds := make([]string, len(excludeParts))
|
||||
args := make([]interface{}, len(excludeParts))
|
||||
for i, p := range excludeParts {
|
||||
conds[i] = "part_title LIKE ?"
|
||||
args[i] = "%" + p + "%"
|
||||
}
|
||||
where := "(" + strings.Join(conds, " OR ") + ")"
|
||||
var rows []model.Chapter
|
||||
if err := db.Model(&model.Chapter{}).Select("id", "mid", "section_title", "sort_order").
|
||||
Where(where, args...).
|
||||
Order("COALESCE(sort_order, 999999) ASC, id ASC").
|
||||
Find(&rows).Error; err == nil {
|
||||
sortChaptersByNaturalID(rows)
|
||||
for _, r := range rows {
|
||||
fixed = append(fixed, cachedFixedItem{r.ID, r.MID, r.SectionTitle})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
where := "1=1"
|
||||
args := []interface{}{}
|
||||
for _, p := range excludeParts {
|
||||
where += " AND part_title NOT LIKE ?"
|
||||
args = append(args, "%"+p+"%")
|
||||
}
|
||||
sql := `SELECT part_id, part_title, '' as subtitle,
|
||||
COUNT(DISTINCT chapter_id) as chapter_count,
|
||||
MIN(COALESCE(sort_order, 999999)) as min_sort
|
||||
FROM chapters WHERE ` + where + `
|
||||
GROUP BY part_id, part_title ORDER BY min_sort ASC, part_id ASC`
|
||||
var raw []struct {
|
||||
PartID string `gorm:"column:part_id"`
|
||||
PartTitle string `gorm:"column:part_title"`
|
||||
Subtitle string `gorm:"column:subtitle"`
|
||||
ChapterCount int `gorm:"column:chapter_count"`
|
||||
MinSortOrder int `gorm:"column:min_sort"`
|
||||
}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Raw(sql, args...).Scan(&raw)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.Chapter{}).Count(&total)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
parts = make([]cachedPartRow, len(raw))
|
||||
for i, r := range raw {
|
||||
parts[i] = cachedPartRow{
|
||||
PartID: r.PartID, PartTitle: r.PartTitle, Subtitle: r.Subtitle,
|
||||
ChapterCount: r.ChapterCount, MinSortOrder: r.MinSortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
bookPartsCache.mu.Lock()
|
||||
bookPartsCache.parts = parts
|
||||
bookPartsCache.total = total
|
||||
bookPartsCache.fixed = fixed
|
||||
bookPartsCache.expires = time.Now().Add(bookPartsCacheTTL)
|
||||
bookPartsCache.mu.Unlock()
|
||||
|
||||
return parts, total, fixed
|
||||
}
|
||||
|
||||
// WarmBookPartsCache 启动时预热目录缓存,避免首请求慢
|
||||
func WarmBookPartsCache() {
|
||||
fetchAndCacheBookParts()
|
||||
}
|
||||
|
||||
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
|
||||
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
|
||||
// 免费判断:system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
|
||||
@@ -144,78 +248,30 @@ func BookChapterByID(c *gin.Context) {
|
||||
|
||||
// BookParts GET /api/miniprogram/book/parts 目录懒加载:仅返回篇章列表,不含章节详情
|
||||
// 返回 parts(排除序言/尾声/附录)、totalSections、fixedSections(id, mid, title 供序言/尾声/附录跳转用 mid)
|
||||
// 带 30 秒内存缓存,固定模块合并为 1 次查询,三路并行执行
|
||||
func BookParts(c *gin.Context) {
|
||||
db := database.DB()
|
||||
// 固定模块(序言、尾声、附录)的 id、mid、title,供 goToRead 传 data-mid
|
||||
var fixedList []struct {
|
||||
ID string `json:"id"`
|
||||
MID int `json:"mid"`
|
||||
SectionTitle string `json:"title"`
|
||||
}
|
||||
for _, p := range excludeParts {
|
||||
var rows []model.Chapter
|
||||
if err := db.Model(&model.Chapter{}).Select("id", "mid", "section_title", "sort_order").
|
||||
Where("part_title LIKE ?", "%"+p+"%").
|
||||
Order("COALESCE(sort_order, 999999) ASC, id ASC").
|
||||
Find(&rows).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
sortChaptersByNaturalID(rows)
|
||||
for _, r := range rows {
|
||||
fixedList = append(fixedList, struct {
|
||||
ID string `json:"id"`
|
||||
MID int `json:"mid"`
|
||||
SectionTitle string `json:"title"`
|
||||
}{r.ID, r.MID, r.SectionTitle})
|
||||
}
|
||||
}
|
||||
|
||||
// 中间篇章:轻量聚合,不拉取 content
|
||||
type partRow struct {
|
||||
PartID string `json:"id"`
|
||||
PartTitle string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
ChapterCount int `json:"chapterCount"`
|
||||
MinSortOrder int `json:"minSortOrder"`
|
||||
}
|
||||
where := "1=1"
|
||||
args := []interface{}{}
|
||||
for _, p := range excludeParts {
|
||||
where += " AND part_title NOT LIKE ?"
|
||||
args = append(args, "%"+p+"%")
|
||||
}
|
||||
var raw []struct {
|
||||
PartID string `gorm:"column:part_id"`
|
||||
PartTitle string `gorm:"column:part_title"`
|
||||
Subtitle string `gorm:"column:subtitle"`
|
||||
ChapterCount int `gorm:"column:chapter_count"`
|
||||
MinSortOrder int `gorm:"column:min_sort"`
|
||||
}
|
||||
sql := `SELECT part_id, part_title, '' as subtitle,
|
||||
COUNT(DISTINCT chapter_id) as chapter_count,
|
||||
MIN(COALESCE(sort_order, 999999)) as min_sort
|
||||
FROM chapters WHERE ` + where + `
|
||||
GROUP BY part_id, part_title ORDER BY min_sort ASC, part_id ASC`
|
||||
if err := db.Raw(sql, args...).Scan(&raw).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "parts": []interface{}{}, "totalSections": 0, "fixedSections": fixedList})
|
||||
bookPartsCache.mu.RLock()
|
||||
if time.Now().Before(bookPartsCache.expires) {
|
||||
parts := bookPartsCache.parts
|
||||
total := bookPartsCache.total
|
||||
fixed := bookPartsCache.fixed
|
||||
bookPartsCache.mu.RUnlock()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"parts": parts,
|
||||
"totalSections": total,
|
||||
"fixedSections": fixed,
|
||||
})
|
||||
return
|
||||
}
|
||||
parts := make([]partRow, len(raw))
|
||||
for i, r := range raw {
|
||||
parts[i] = partRow{
|
||||
PartID: r.PartID, PartTitle: r.PartTitle, Subtitle: r.Subtitle,
|
||||
ChapterCount: r.ChapterCount, MinSortOrder: r.MinSortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
var total int64
|
||||
db.Model(&model.Chapter{}).Count(&total)
|
||||
bookPartsCache.mu.RUnlock()
|
||||
|
||||
parts, total, fixed := fetchAndCacheBookParts()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"parts": parts,
|
||||
"totalSections": total,
|
||||
"fixedSections": fixedList,
|
||||
"fixedSections": fixed,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
soul-api Go 项目一键部署到宝塔(测试环境),重启的是宝塔里的 soulDev 项目
|
||||
- 打包使用 .env.development 作为服务器 .env
|
||||
soulApp (soul-api) Go 项目一键部署到宝塔(正式环境)
|
||||
- 打包使用 .env.production 作为服务器 .env
|
||||
- 本地交叉编译 Linux 二进制
|
||||
- 上传到 /www/wwwroot/self/soul-dev
|
||||
- 重启 soulDev:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动
|
||||
- 上传到 /www/wwwroot/self/soul-api
|
||||
- 重启:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动
|
||||
|
||||
宝塔 API 重启(可选):在环境变量或 .env 中设置
|
||||
BT_PANEL_URL = https://你的面板地址:9988
|
||||
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
|
||||
BT_GO_PROJECT_NAME = soulDev (与宝塔 Go 项目列表里名称一致)
|
||||
BT_GO_PROJECT_NAME = soulApi (与宝塔 Go 项目列表里名称一致)
|
||||
并安装 requests: pip install requests
|
||||
"""
|
||||
|
||||
@@ -45,7 +45,7 @@ except ImportError:
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-dev"
|
||||
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-api"
|
||||
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ def get_cfg():
|
||||
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
|
||||
"bt_panel_url": bt_url,
|
||||
"bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT),
|
||||
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulDev"),
|
||||
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulApi"),
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ def run_build(root):
|
||||
|
||||
# ==================== 打包 ====================
|
||||
|
||||
DEPLOY_PORT = 8081
|
||||
DEPLOY_PORT = 8080
|
||||
|
||||
|
||||
def set_env_port(env_path, port=DEPLOY_PORT):
|
||||
@@ -171,11 +171,11 @@ def pack_deploy(root, binary_path, include_env=True):
|
||||
staging = tempfile.mkdtemp(prefix="soul_api_deploy_")
|
||||
try:
|
||||
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
|
||||
env_src = os.path.join(root, ".env.development")
|
||||
env_src = os.path.join(root, ".env.production")
|
||||
staging_env = os.path.join(staging, ".env")
|
||||
if include_env and os.path.isfile(env_src):
|
||||
shutil.copy2(env_src, staging_env)
|
||||
print(" [已包含] .env.development -> .env")
|
||||
print(" [已包含] .env.production -> .env")
|
||||
else:
|
||||
env_example = os.path.join(root, ".env.example")
|
||||
if os.path.isfile(env_example):
|
||||
@@ -183,8 +183,8 @@ def pack_deploy(root, binary_path, include_env=True):
|
||||
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
|
||||
if os.path.isfile(staging_env):
|
||||
set_env_port(staging_env, DEPLOY_PORT)
|
||||
set_env_mini_program_state(staging_env, "developer")
|
||||
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=developer(测试环境)" % DEPLOY_PORT)
|
||||
set_env_mini_program_state(staging_env, "formal")
|
||||
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=formal(正式环境)" % DEPLOY_PORT)
|
||||
tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz")
|
||||
with tarfile.open(tarball, "w:gz") as tf:
|
||||
for name in os.listdir(staging):
|
||||
@@ -205,7 +205,7 @@ def restart_via_bt_api(cfg):
|
||||
"""通过宝塔 API 重启 Go 项目(需配置 BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME)"""
|
||||
url = cfg.get("bt_panel_url") or ""
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
name = cfg.get("bt_go_project_name", "soulDev")
|
||||
name = cfg.get("bt_go_project_name", "soulApi")
|
||||
if not url or not key:
|
||||
return False
|
||||
if not requests:
|
||||
@@ -255,32 +255,57 @@ def restart_via_bt_api(cfg):
|
||||
# ==================== SSH 上传 ====================
|
||||
|
||||
|
||||
def _connect_ssh(cfg):
|
||||
"""建立 SSH 连接,启用 keepalive 防大文件上传时断连"""
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(
|
||||
cfg["host"], port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"], key_filename=cfg["ssh_key"],
|
||||
timeout=15,
|
||||
)
|
||||
else:
|
||||
client.connect(
|
||||
cfg["host"], port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"], password=cfg["password"],
|
||||
timeout=15,
|
||||
)
|
||||
transport = client.get_transport()
|
||||
if transport:
|
||||
transport.set_keepalive(15)
|
||||
return client
|
||||
|
||||
|
||||
def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"):
|
||||
"""上传 tar.gz 到服务器并解压、重启"""
|
||||
print("[3/4] SSH 上传并解压 ...")
|
||||
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||||
return False
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
remote_tar = "/tmp/soul_api_deploy.tar.gz"
|
||||
project_path = cfg["project_path"]
|
||||
client = None
|
||||
try:
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(
|
||||
cfg["host"], port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"], key_filename=cfg["ssh_key"],
|
||||
timeout=15,
|
||||
)
|
||||
else:
|
||||
client.connect(
|
||||
cfg["host"], port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"], password=cfg["password"],
|
||||
timeout=15,
|
||||
)
|
||||
sftp = client.open_sftp()
|
||||
remote_tar = "/tmp/soul_api_deploy.tar.gz"
|
||||
project_path = cfg["project_path"]
|
||||
sftp.put(tarball_path, remote_tar)
|
||||
sftp.close()
|
||||
# SFTP 上传易因网络抖动 EOF,失败时重连并重试最多 3 次
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
if client:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
client = _connect_ssh(cfg)
|
||||
sftp = client.open_sftp()
|
||||
sftp.put(tarball_path, remote_tar)
|
||||
sftp.close()
|
||||
break
|
||||
except (EOFError, ConnectionResetError, OSError) as e:
|
||||
if attempt < 3:
|
||||
print(" [重试 %d/3] 上传中断: %s,5 秒后重连 ..." % (attempt, e))
|
||||
time.sleep(5)
|
||||
else:
|
||||
raise
|
||||
|
||||
cmd = (
|
||||
"mkdir -p %s && cd %s && tar -xzf %s && "
|
||||
@@ -295,7 +320,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
print(" [成功] 已解压到: %s" % project_path)
|
||||
|
||||
if not no_restart:
|
||||
print("[4/4] 重启 soulDev 服务 ...")
|
||||
print("[4/4] 重启 soulApp 服务 ...")
|
||||
ok = False
|
||||
if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")):
|
||||
ok = restart_via_bt_api(cfg)
|
||||
@@ -315,7 +340,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
print(" [stderr] %s" % err[:200])
|
||||
ok = "RESTART_OK" in out
|
||||
if ok:
|
||||
print(" [成功] soulDev 已通过 SSH 重启")
|
||||
print(" [成功] soulApp 已通过 SSH 重启")
|
||||
else:
|
||||
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path)
|
||||
else:
|
||||
@@ -323,10 +348,17 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误:", str(e))
|
||||
err_msg = str(e) or repr(e) or type(e).__name__
|
||||
print(" [失败] SSH 错误:", err_msg)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
if client:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
@@ -334,7 +366,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="soul-api 一键部署到宝塔,重启 soulDev 项目",
|
||||
description="soulApp (soul-api) Go 项目一键部署到宝塔",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地编译(使用已有 soul-api 二进制)")
|
||||
@@ -353,7 +385,7 @@ def main():
|
||||
|
||||
cfg = get_cfg()
|
||||
print("=" * 60)
|
||||
print(" soul-api 部署到宝塔,重启 soulDev")
|
||||
print(" soulApp 一键部署到宝塔")
|
||||
print("=" * 60)
|
||||
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||
print(" 目标目录: %s" % cfg["project_path"])
|
||||
Reference in New Issue
Block a user