1245 lines
52 KiB
Plaintext
1245 lines
52 KiB
Plaintext
<?php
|
||
/**
|
||
* 网站源码一键压缩备份工具(增强版 v2)
|
||
*
|
||
* 兼容性:PHP 5.6+ / 7.x / 8.x 全系列
|
||
*
|
||
* v2 修复:
|
||
* - 修复 PHP 7.x 下 str_ends_with() / SIGKILL / fn() / ?? 等语法导致的 500
|
||
* - 修复大文件下载时 readfile() 撑爆 memory_limit 导致的 500
|
||
* - 下载前检查文件是否正在被写入(防下载不完整文件)
|
||
* - 关闭输出缓冲器 + 流式传输,支持 GB 级文件下载
|
||
*
|
||
* v3 改进:
|
||
* - 超时逻辑改为「自适应」:文件还在增长就不杀,文件不增长(卡住)才杀
|
||
* - 不再硬性限制压缩时间,大站压缩 30 分钟、1 小时都能正常完成
|
||
* - 前端显示「文件是否在增长」「写入速度」「距离上次写入过了多久」
|
||
* - 只有文件连续 STALL_TIMEOUT(60秒)不增长时才判定为卡死并终止
|
||
*/
|
||
|
||
// ======================== PHP 版本兼容 ========================
|
||
|
||
// SIGKILL 常量 — 不依赖 pcntl 扩展
|
||
if (!defined('SIGKILL')) {
|
||
define('SIGKILL', 9);
|
||
}
|
||
|
||
// ======================== 基础设置 ========================
|
||
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
set_time_limit(0);
|
||
ignore_user_abort(true);
|
||
$api_action = isset($_GET['action']) ? $_GET['action'] : '';
|
||
if ($api_action === 'status' || $api_action === 'backup' || $api_action === 'reset') {
|
||
// API 场景下关闭错误直出,避免 warning/notice 污染 JSON 导致前端“响应解析失败”
|
||
@ini_set('display_errors', '0');
|
||
}
|
||
|
||
// ======================== 可配置参数 ========================
|
||
|
||
$MAX_COMPRESSED_MB = 300; // 压缩文件大小上限(MB):压缩过程中实时监测,超过则终止
|
||
$MAX_RUNTIME = 3600; // 软超时(秒):超过后开始检测是否卡住,但不立即终止
|
||
$STALL_TIMEOUT = 60; // 卡死判定(秒):文件连续这么久不增长,才判定为卡死并终止
|
||
$POLL_INTERVAL = 3; // 前端轮询间隔(秒)
|
||
$backup_dir = 'site_backups'; // 备份目录(相对网站根目录)
|
||
|
||
// 要压缩的文件类型
|
||
$INCLUDE_PATTERNS = '*.php,*.html,*.htm,*.js,*.json,*.xml,*.yml,*.yaml,*.ini,*.conf,.env,*.config,*.txt,*.md,*.css,*.sql,*.sh,*.bat,*.py';
|
||
|
||
// ======================== 路径初始化 ========================
|
||
|
||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443)) ? 'https://' : 'http://';
|
||
$host = $_SERVER['HTTP_HOST'];
|
||
$base_url = $protocol . $host . rtrim(dirname($_SERVER['PHP_SELF']), '/\\') . '/';
|
||
$backup_web_base = $protocol . $host . '/' . trim($backup_dir, '/');
|
||
$root_path = realpath($_SERVER['DOCUMENT_ROOT']);
|
||
if (!$root_path) { $root_path = getcwd(); }
|
||
$domain = explode(':', $host)[0];
|
||
|
||
// 备份目录
|
||
$backup_dir_full = rtrim($root_path, '/\\') . '/' . $backup_dir;
|
||
if (!is_dir($backup_dir_full)) {
|
||
@mkdir($backup_dir_full, 0755, true);
|
||
}
|
||
|
||
// 状态文件
|
||
$status_file = $backup_dir_full . '/.backup_status.json';
|
||
|
||
// ======================== 工具函数 ========================
|
||
|
||
/**
|
||
* 读取状态文件
|
||
*/
|
||
function readStatus($file) {
|
||
if (!file_exists($file)) {
|
||
return array('status' => 'idle', 'message' => '');
|
||
}
|
||
$content = file_get_contents($file);
|
||
if ($content === false) {
|
||
return array('status' => 'idle', 'message' => '');
|
||
}
|
||
$data = json_decode($content, true);
|
||
if (!$data || !isset($data['status'])) {
|
||
return array('status' => 'idle', 'message' => '');
|
||
}
|
||
return $data;
|
||
}
|
||
|
||
/**
|
||
* 写入状态文件(带文件锁)
|
||
*/
|
||
function writeStatus($file, $status) {
|
||
$status['updated_at'] = date('Y-m-d H:i:s');
|
||
file_put_contents($file, json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX);
|
||
}
|
||
|
||
/**
|
||
* 检查进程是否存活
|
||
*/
|
||
function isProcessAlive($pid) {
|
||
if (empty($pid) || !is_numeric($pid)) return false;
|
||
$pid = (int)$pid;
|
||
|
||
// 方法 1: posix_kill(非阻塞信号检测)
|
||
if (function_exists('posix_kill')) {
|
||
return @posix_kill($pid, 0);
|
||
}
|
||
|
||
// 方法 2: 检查 /proc 文件系统(Linux)
|
||
if (file_exists("/proc/{$pid}")) {
|
||
return true;
|
||
}
|
||
|
||
// 方法 3: 通过 ps 命令检测
|
||
$check = trim(@shell_exec("ps -p {$pid} -o pid= 2>/dev/null"));
|
||
if (empty($check)) {
|
||
$check = trim(@shell_exec("/bin/ps -p {$pid} -o pid= 2>/dev/null"));
|
||
}
|
||
return !empty($check);
|
||
}
|
||
|
||
/**
|
||
* 尝试终止进程(兼容无 posix 扩展的主机)
|
||
*/
|
||
function killProcess($pid) {
|
||
if (empty($pid) || !is_numeric($pid)) return false;
|
||
$pid = (int)$pid;
|
||
|
||
if (function_exists('posix_kill')) {
|
||
return @posix_kill($pid, SIGKILL);
|
||
}
|
||
|
||
// 兼容 Linux / BusyBox / 受限环境:尝试调用 kill
|
||
$out = @shell_exec("kill -9 {$pid} >/dev/null 2>&1; echo $?");
|
||
if ($out !== null) {
|
||
return trim($out) === '0';
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 格式化秒数为可读字符串
|
||
*/
|
||
function formatSeconds($sec) {
|
||
if ($sec < 60) return $sec . ' 秒';
|
||
if ($sec < 3600) return floor($sec / 60) . ' 分 ' . ($sec % 60) . ' 秒';
|
||
return floor($sec / 3600) . ' 小时 ' . floor(($sec % 3600) / 60) . ' 分';
|
||
}
|
||
|
||
/**
|
||
* 清理临时 wrapper 脚本
|
||
*/
|
||
function cleanupWrapper($path) {
|
||
if (!empty($path) && file_exists($path)) {
|
||
@unlink($path);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 写入停止标记,通知 wrapper 主动停机
|
||
*/
|
||
function requestStop($stop_marker, $reason) {
|
||
if (empty($stop_marker)) return;
|
||
@file_put_contents($stop_marker, $reason . ' @ ' . date('Y-m-d H:i:s'));
|
||
}
|
||
|
||
/**
|
||
* 读取日志最后 N 行(兼容旧版 PHP)
|
||
*/
|
||
function readLogTail($path, $lines) {
|
||
if (empty($path) || !file_exists($path) || !is_readable($path)) {
|
||
return '';
|
||
}
|
||
$content = @file_get_contents($path);
|
||
if ($content === false || $content === '') {
|
||
return '';
|
||
}
|
||
$arr = preg_split("/\r\n|\n|\r/", $content);
|
||
if (!$arr) return '';
|
||
$lines = max(1, (int)$lines);
|
||
$tail = array_slice($arr, -$lines);
|
||
return trim(implode("\n", $tail));
|
||
}
|
||
|
||
/**
|
||
* 补齐状态输出字段(下载链接、日志链接、错误摘要)
|
||
*/
|
||
function decorateStatus($st, $backup_web_base) {
|
||
if (!is_array($st)) return $st;
|
||
if (!isset($st['file']) && isset($st['backup_file'])) {
|
||
$st['file'] = $st['backup_file'];
|
||
}
|
||
if (!isset($st['download_url']) && !empty($st['file'])) {
|
||
$st['download_url'] = $backup_web_base . '/' . rawurlencode($st['file']);
|
||
}
|
||
if (!empty($st['log_path'])) {
|
||
if (!isset($st['log_download_url'])) {
|
||
$st['log_download_url'] = $backup_web_base . '/' . rawurlencode(basename($st['log_path']));
|
||
}
|
||
if (isset($st['status']) && $st['status'] === 'error' && !isset($st['log_tail'])) {
|
||
$tail = readLogTail($st['log_path'], 20);
|
||
if ($tail !== '') {
|
||
$st['log_tail'] = $tail;
|
||
}
|
||
}
|
||
}
|
||
return $st;
|
||
}
|
||
|
||
/**
|
||
* 文件名是否命中配置的模式
|
||
*/
|
||
function matchByPatterns($filename, $patterns_csv) {
|
||
$filename = (string)$filename;
|
||
$patterns = explode(',', (string)$patterns_csv);
|
||
foreach ($patterns as $p) {
|
||
$p = trim($p);
|
||
if ($p === '') continue;
|
||
if (function_exists('fnmatch')) {
|
||
if (@fnmatch($p, $filename)) return true;
|
||
} else {
|
||
$regex = '/^' . str_replace(
|
||
array('\*', '\?'),
|
||
array('.*', '.'),
|
||
preg_quote($p, '/')
|
||
) . '$/i';
|
||
if (preg_match($regex, $filename)) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 纯 PHP 方式压缩(命令函数被禁用时兜底)
|
||
*/
|
||
function compressWithPhpZip($root_path, $backup_dir_name, $backup_path, $patterns_csv, $max_bytes, $log_path) {
|
||
$log = array();
|
||
$log[] = '[' . date('Y-m-d H:i:s') . '] PHP zip mode start';
|
||
|
||
if (!class_exists('ZipArchive')) {
|
||
$log[] = 'ZipArchive extension is not available.';
|
||
@file_put_contents($log_path, implode("\n", $log) . "\n");
|
||
return array(false, '服务器未安装 ZipArchive 扩展,无法使用纯 PHP 压缩。', 0);
|
||
}
|
||
|
||
if (file_exists($backup_path)) {
|
||
@unlink($backup_path);
|
||
}
|
||
|
||
$zip = new ZipArchive();
|
||
$open_ret = $zip->open($backup_path, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||
if ($open_ret !== true) {
|
||
$log[] = 'Zip open failed: ' . $open_ret;
|
||
@file_put_contents($log_path, implode("\n", $log) . "\n");
|
||
return array(false, '创建压缩包失败(ZipArchive::open 返回 ' . $open_ret . ')。', 0);
|
||
}
|
||
|
||
$root_real = realpath($root_path);
|
||
$backup_real = realpath(rtrim($root_path, '/\\') . '/' . $backup_dir_name);
|
||
$added = 0;
|
||
|
||
try {
|
||
$it = new RecursiveIteratorIterator(
|
||
new RecursiveDirectoryIterator($root_real, RecursiveDirectoryIterator::SKIP_DOTS),
|
||
RecursiveIteratorIterator::LEAVES_ONLY
|
||
);
|
||
|
||
foreach ($it as $fileInfo) {
|
||
if (!$fileInfo->isFile()) continue;
|
||
$full = $fileInfo->getPathname();
|
||
|
||
// 排除备份目录本身
|
||
if ($backup_real && strpos($full, $backup_real . DIRECTORY_SEPARATOR) === 0) {
|
||
continue;
|
||
}
|
||
|
||
$base = $fileInfo->getFilename();
|
||
if (!matchByPatterns($base, $patterns_csv)) continue;
|
||
|
||
$rel = substr($full, strlen($root_real) + 1);
|
||
$rel = str_replace('\\', '/', $rel);
|
||
if ($rel === '' || $rel === false) continue;
|
||
|
||
if (@$zip->addFile($full, $rel)) {
|
||
$added++;
|
||
}
|
||
|
||
if ($added % 20 === 0) {
|
||
clearstatcache(true, $backup_path);
|
||
$size_now = file_exists($backup_path) ? (int)filesize($backup_path) : 0;
|
||
if ($size_now > $max_bytes) {
|
||
$zip->close();
|
||
$log[] = 'Size limit exceeded: ' . $size_now . ' bytes';
|
||
@file_put_contents($log_path, implode("\n", $log) . "\n");
|
||
return array(false, '压缩文件超过上限,已终止(当前 ' . round($size_now / 1024 / 1024, 2) . ' MB)。', $size_now);
|
||
}
|
||
}
|
||
}
|
||
|
||
$zip->close();
|
||
} catch (Exception $e) {
|
||
@file_put_contents($log_path, implode("\n", $log) . "\nException: " . $e->getMessage() . "\n");
|
||
return array(false, '纯 PHP 压缩异常:' . $e->getMessage(), 0);
|
||
}
|
||
|
||
clearstatcache(true, $backup_path);
|
||
$final_size = file_exists($backup_path) ? (int)filesize($backup_path) : 0;
|
||
$log[] = 'Added files: ' . $added;
|
||
$log[] = 'Final size: ' . $final_size . ' bytes';
|
||
@file_put_contents($log_path, implode("\n", $log) . "\n");
|
||
|
||
if ($final_size <= 0) {
|
||
return array(false, '压缩完成但文件为空,请检查目录权限。', 0);
|
||
}
|
||
if ($final_size > $max_bytes) {
|
||
return array(false, '压缩完成但超过上限(' . round($final_size / 1024 / 1024, 2) . ' MB)。', $final_size);
|
||
}
|
||
return array(true, 'ok', $final_size);
|
||
}
|
||
|
||
/**
|
||
* 构建 tar 压缩命令的 find 部分
|
||
*/
|
||
function buildFindArgs($patterns) {
|
||
$name_args = array();
|
||
$pattern_list = explode(',', $patterns);
|
||
foreach ($pattern_list as $p) {
|
||
$p = trim($p);
|
||
if ($p === '') continue;
|
||
$name_args[] = '-name "' . addcslashes($p, '"\\$`') . '"';
|
||
}
|
||
return implode(' -o ', $name_args);
|
||
}
|
||
|
||
/**
|
||
* 是否具备至少一种命令执行能力
|
||
*/
|
||
function canLaunchBackground() {
|
||
$disabled_str = ini_get('disable_functions');
|
||
$disabled = array();
|
||
if ($disabled_str) {
|
||
$disabled = array_map('trim', explode(',', $disabled_str));
|
||
}
|
||
$funcs = array('shell_exec', 'exec', 'system', 'passthru', 'proc_open', 'popen');
|
||
foreach ($funcs as $f) {
|
||
if (function_exists($f) && !in_array($f, $disabled)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 安全执行后台命令,返回 array(成功?, PID 或 null)
|
||
*/
|
||
function launchBackground($cmd) {
|
||
$disabled_str = ini_get('disable_functions');
|
||
$disabled = array();
|
||
if ($disabled_str) {
|
||
$disabled = array_map('trim', explode(',', $disabled_str));
|
||
}
|
||
|
||
// 优先使用 shell_exec(最可靠的 PID 获取方式)
|
||
if (function_exists('shell_exec') && !in_array('shell_exec', $disabled)) {
|
||
$output = @shell_exec($cmd);
|
||
if ($output !== null) {
|
||
$pid = trim($output);
|
||
if (is_numeric($pid) && (int)$pid > 0) {
|
||
return array(true, (int)$pid);
|
||
}
|
||
return array(true, null);
|
||
}
|
||
}
|
||
|
||
// 回退方案
|
||
$fallback_funcs = array('exec', 'system', 'passthru', 'proc_open', 'popen');
|
||
foreach ($fallback_funcs as $func) {
|
||
if (function_exists($func) && !in_array($func, $disabled)) {
|
||
$silent_cmd = str_replace('& echo $!', '> /dev/null 2>&1 &', $cmd);
|
||
if ($func === 'exec') {
|
||
@exec($silent_cmd, $out, $ret);
|
||
// 某些环境后台命令启动成功但返回码并不可靠,这里按“已调用”视为提交成功
|
||
return array(true, null);
|
||
}
|
||
if ($func === 'system' || $func === 'passthru') {
|
||
@system($silent_cmd, $ret);
|
||
return array(true, null);
|
||
}
|
||
if ($func === 'proc_open') {
|
||
$descriptors = array(1 => array('pipe', 'w'), 2 => array('pipe', 'w'));
|
||
$process = @proc_open($silent_cmd, $descriptors, $pipes);
|
||
if (is_resource($process)) {
|
||
proc_close($process);
|
||
return array(true, null);
|
||
}
|
||
}
|
||
if ($func === 'popen') {
|
||
$handle = @popen($silent_cmd, 'r');
|
||
if ($handle) {
|
||
pclose($handle);
|
||
return array(true, null);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return array(false, null);
|
||
}
|
||
|
||
// ======================== API: 状态查询(自适应超时) ========================
|
||
|
||
if (isset($_GET['action']) && $_GET['action'] === 'status') {
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
$st = decorateStatus(readStatus($status_file), $backup_web_base);
|
||
|
||
// 仅在 running 状态时进行进程检测
|
||
if ($st['status'] === 'running') {
|
||
$started_at = isset($st['started_at']) ? $st['started_at'] : 'now';
|
||
$started = strtotime($started_at);
|
||
$elapsed = time() - $started;
|
||
$bp = isset($st['backup_path']) ? $st['backup_path'] : '';
|
||
$wp = isset($st['wrapper_path']) ? $st['wrapper_path'] : '';
|
||
$done_marker = isset($st['done_marker']) ? $st['done_marker'] : '';
|
||
$fail_marker = isset($st['fail_marker']) ? $st['fail_marker'] : '';
|
||
$log_path = isset($st['log_path']) ? $st['log_path'] : '';
|
||
$stop_marker = isset($st['stop_marker']) ? $st['stop_marker'] : '';
|
||
|
||
// 统一给前端的字段(避免前端/后端字段名不一致导致“要刷新才看到文件名”)
|
||
if (!isset($st['file']) && isset($st['backup_file'])) {
|
||
$st['file'] = $st['backup_file'];
|
||
}
|
||
|
||
// ---- 检测 0: 是否已有明确完成/失败标记(不依赖 PID)----
|
||
if (!empty($done_marker) && file_exists($done_marker)) {
|
||
if (!empty($bp) && file_exists($bp) && filesize($bp) > 0) {
|
||
clearstatcache(true, $bp);
|
||
$actual_size = filesize($bp);
|
||
$st['status'] = 'done';
|
||
$st['actual_size'] = round($actual_size / 1024 / 1024, 2);
|
||
$st['elapsed'] = $elapsed;
|
||
$st['message'] = '压缩完成,耗时约 ' . formatSeconds($elapsed)
|
||
. ',文件大小 ' . $st['actual_size'] . ' MB';
|
||
writeStatus($status_file, $st);
|
||
} else {
|
||
$st['status'] = 'error';
|
||
$st['elapsed'] = $elapsed;
|
||
$st['message'] = '压缩已结束,但备份文件不存在或大小为 0。'
|
||
. (!empty($log_path) ? '可查看日志:' . basename($log_path) : '');
|
||
writeStatus($status_file, $st);
|
||
}
|
||
cleanupWrapper($wp);
|
||
}
|
||
elseif (!empty($fail_marker) && file_exists($fail_marker)) {
|
||
$st['status'] = 'error';
|
||
$st['elapsed'] = $elapsed;
|
||
$st['message'] = '压缩失败(脚本返回非 0)。'
|
||
. (!empty($log_path) ? '可查看日志:' . basename($log_path) : '');
|
||
writeStatus($status_file, $st);
|
||
cleanupWrapper($wp);
|
||
}
|
||
// 如果已经不是 running(上面已落盘改写),后续无需再走 PID/大小检测
|
||
if ($st['status'] !== 'running') {
|
||
$st = decorateStatus($st, $backup_web_base);
|
||
echo json_encode($st, JSON_UNESCAPED_UNICODE);
|
||
exit;
|
||
}
|
||
|
||
// ---- 检测 1: 进程是否还活着 ----
|
||
$pid = isset($st['pid']) ? $st['pid'] : null;
|
||
$alive = ($pid !== null) ? isProcessAlive($pid) : true;
|
||
|
||
if (!$alive) {
|
||
// 进程已退出 — 检查备份文件
|
||
if (!empty($bp) && file_exists($bp) && filesize($bp) > 0) {
|
||
clearstatcache(true, $bp);
|
||
$actual_size = filesize($bp);
|
||
$st['status'] = 'done';
|
||
$st['actual_size'] = round($actual_size / 1024 / 1024, 2);
|
||
$st['elapsed'] = $elapsed;
|
||
$st['message'] = '压缩完成,耗时约 ' . formatSeconds($elapsed)
|
||
. ',文件大小 ' . $st['actual_size'] . ' MB';
|
||
writeStatus($status_file, $st);
|
||
cleanupWrapper($wp);
|
||
} else {
|
||
$st['status'] = 'error';
|
||
$st['elapsed'] = $elapsed;
|
||
$st['message'] = '压缩进程意外终止(PID ' . $pid . '),备份文件未生成。'
|
||
. '可能原因:磁盘空间不足、文件权限问题或命令执行被中断。';
|
||
writeStatus($status_file, $st);
|
||
cleanupWrapper($wp);
|
||
}
|
||
}
|
||
// ---- 检测 2: 压缩文件大小是否超限 / 是否卡住 ----
|
||
else {
|
||
clearstatcache(true, $bp);
|
||
$current_file_size = (!empty($bp) && file_exists($bp)) ? filesize($bp) : 0;
|
||
$current_size_mb = round($current_file_size / 1024 / 1024, 2);
|
||
|
||
// ★ 核心检测 A:压缩文件大小超过上限 → 立即终止
|
||
if ($current_file_size > $MAX_COMPRESSED_MB * 1024 * 1024) {
|
||
requestStop($stop_marker, 'size_limit_exceeded');
|
||
if ($pid !== null) { killProcess($pid); }
|
||
$st['status'] = 'error';
|
||
$st['elapsed'] = $elapsed;
|
||
$st['actual_size'] = $current_size_mb;
|
||
$st['message'] = '压缩文件已超过 ' . $MAX_COMPRESSED_MB . ' MB 上限(当前 '
|
||
. $current_size_mb . ' MB),已终止压缩进程。'
|
||
. '备份文件已保留,你可以下载已压缩的部分。'
|
||
. '如果需要完整备份,请调大 MAX_COMPRESSED_MB 后重试。';
|
||
writeStatus($status_file, $st);
|
||
cleanupWrapper($wp);
|
||
}
|
||
// ★ 核心检测 B:文件是否卡住(自适应超时)
|
||
|
||
// 取上次记录的文件大小和时间
|
||
$last_size = isset($st['last_file_size']) ? (int)$st['last_file_size'] : 0;
|
||
$last_active_at = isset($st['last_active_at']) ? $st['last_active_at'] : $started_at;
|
||
$last_active_sec = time() - strtotime($last_active_at);
|
||
|
||
// 判断文件是否在增长
|
||
$file_growing = ($current_file_size > $last_size);
|
||
if ($file_growing) {
|
||
// 文件在增长 → 更新活跃时间戳和大小
|
||
$st['last_file_size'] = $current_file_size;
|
||
$st['last_active_at'] = date('Y-m-d H:i:s');
|
||
$last_active_sec = 0; // 刚刚写入,重置
|
||
}
|
||
|
||
// 核心判断 B:文件连续 STALL_TIMEOUT 秒不增长 = 卡死
|
||
if ($last_active_sec >= $STALL_TIMEOUT && $elapsed > 60) {
|
||
// 真正卡住了 → 终止
|
||
requestStop($stop_marker, 'stall_timeout');
|
||
if ($pid !== null) { killProcess($pid); }
|
||
$st['status'] = 'error';
|
||
$st['elapsed'] = $elapsed;
|
||
$st['message'] = '压缩卡死:文件连续 ' . formatSeconds($last_active_sec)
|
||
. ' 没有增长(已运行 ' . formatSeconds($elapsed)
|
||
. '),已自动终止。可能原因:磁盘 I/O 挂起、NFS 断开或服务器资源耗尽。';
|
||
writeStatus($status_file, $st);
|
||
cleanupWrapper($wp);
|
||
}
|
||
else {
|
||
// 仍在正常运行 — 返回详细状态
|
||
$st['elapsed'] = $elapsed;
|
||
$st['current_size'] = $current_size_mb;
|
||
$st['file_growing'] = $file_growing;
|
||
$st['stall_seconds'] = $last_active_sec;
|
||
$st['over_soft_limit'] = ($elapsed > $MAX_RUNTIME);
|
||
$st['size_limit_mb'] = $MAX_COMPRESSED_MB;
|
||
$st['size_percent'] = ($MAX_COMPRESSED_MB > 0)
|
||
? min(100, round($current_size_mb / $MAX_COMPRESSED_MB * 100, 1))
|
||
: 0;
|
||
|
||
// 如果超过软超时,提示但不终止
|
||
if ($elapsed > $MAX_RUNTIME && $file_growing) {
|
||
$st['message'] = '压缩已超过 ' . formatSeconds($MAX_RUNTIME)
|
||
. ' 软限制,但文件仍在增长中,不会终止。请耐心等待。';
|
||
} elseif ($elapsed > $MAX_RUNTIME && !$file_growing) {
|
||
$st['message'] = '压缩已超过软限制,文件暂未增长。'
|
||
. '如果连续 ' . formatSeconds($STALL_TIMEOUT) . ' 不恢复写入,将自动终止(当前已停 ' . formatSeconds($last_active_sec) . ')。';
|
||
}
|
||
elseif ($current_size_mb > $MAX_COMPRESSED_MB * 0.8) {
|
||
$st['message'] = '压缩文件已 ' . $current_size_mb . ' MB,接近 '
|
||
. $MAX_COMPRESSED_MB . ' MB 上限。超过上限将自动终止(文件已保留,可下载已压缩部分)。';
|
||
}
|
||
else {
|
||
$st['message'] = '压缩任务正在运行';
|
||
}
|
||
|
||
// 保存更新的状态(活跃时间等)
|
||
writeStatus($status_file, $st);
|
||
}
|
||
}
|
||
}
|
||
|
||
$st = decorateStatus($st, $backup_web_base);
|
||
echo json_encode($st, JSON_UNESCAPED_UNICODE);
|
||
exit;
|
||
}
|
||
|
||
// ======================== API: 重置状态 ========================
|
||
|
||
if (isset($_GET['action']) && $_GET['action'] === 'reset') {
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
$st = readStatus($status_file);
|
||
|
||
if ($st['status'] === 'running') {
|
||
$pid = isset($st['pid']) ? $st['pid'] : null;
|
||
$wp = isset($st['wrapper_path']) ? $st['wrapper_path'] : '';
|
||
$stop_marker = isset($st['stop_marker']) ? $st['stop_marker'] : '';
|
||
requestStop($stop_marker, 'manual_reset');
|
||
if ($pid !== null) {
|
||
killProcess($pid);
|
||
}
|
||
cleanupWrapper($wp);
|
||
}
|
||
|
||
writeStatus($status_file, array('status' => 'idle', 'message' => '状态已手动重置'));
|
||
echo json_encode(array('status' => 'idle', 'message' => '状态已重置'), JSON_UNESCAPED_UNICODE);
|
||
exit;
|
||
}
|
||
|
||
// ======================== API: 发起备份 ========================
|
||
|
||
if (isset($_GET['action']) && $_GET['action'] === 'backup') {
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
|
||
// ---- 防重复:检查当前状态 ----
|
||
$current = readStatus($status_file);
|
||
if ($current['status'] === 'running') {
|
||
$started_at = isset($current['started_at']) ? $current['started_at'] : 'now';
|
||
$started = strtotime($started_at);
|
||
$elapsed = time() - $started;
|
||
$cur_file = isset($current['backup_file']) ? $current['backup_file'] : '';
|
||
echo json_encode(array(
|
||
'status' => 'running',
|
||
'message' => '已有压缩任务正在运行中(已运行 ' . formatSeconds($elapsed) . '),请勿重复提交。'
|
||
. '如需重新开始,请先重置状态。',
|
||
'file' => $cur_file,
|
||
), JSON_UNESCAPED_UNICODE);
|
||
exit;
|
||
}
|
||
|
||
// ---- 准备文件名 ----
|
||
$timestamp = date('Ymd_His');
|
||
$use_shell_mode = canLaunchBackground() && DIRECTORY_SEPARATOR !== '\\';
|
||
$backup_file = $domain . '_' . $timestamp . ($use_shell_mode ? '.tar.gz' : '.zip');
|
||
$backup_path = $backup_dir_full . '/' . $backup_file;
|
||
$wrapper_path = $backup_dir_full . '/._backup_' . $timestamp . '.sh';
|
||
$done_marker = $backup_dir_full . '/._backup_' . $timestamp . '.done';
|
||
$fail_marker = $backup_dir_full . '/._backup_' . $timestamp . '.fail';
|
||
$stop_marker = $backup_dir_full . '/._backup_' . $timestamp . '.stop';
|
||
$log_path = $backup_dir_full . '/backup_' . $timestamp . '.log';
|
||
|
||
// ---- 写入 running 状态 ----
|
||
writeStatus($status_file, array(
|
||
'status' => 'running',
|
||
'backup_file' => $backup_file,
|
||
'backup_path' => $backup_path,
|
||
'wrapper_path' => $wrapper_path,
|
||
'done_marker' => $done_marker,
|
||
'fail_marker' => $fail_marker,
|
||
'stop_marker' => $stop_marker,
|
||
'log_path' => $log_path,
|
||
'size_limit_mb' => $MAX_COMPRESSED_MB,
|
||
'mode' => $use_shell_mode ? 'shell' : 'php',
|
||
'started_at' => date('Y-m-d H:i:s'),
|
||
'pid' => null,
|
||
'message' => '压缩任务已启动',
|
||
));
|
||
|
||
if ($use_shell_mode) {
|
||
// ---- shell 后台执行 ----
|
||
$find_args = buildFindArgs($INCLUDE_PATTERNS);
|
||
$wrapper_cmd = "set -e\n"
|
||
. "cd " . escapeshellarg($root_path) . "\n"
|
||
. "rm -f " . escapeshellarg($done_marker) . " " . escapeshellarg($fail_marker) . " " . escapeshellarg($stop_marker) . "\n"
|
||
. "( find . -type f \\( " . $find_args . " \\) "
|
||
. " ! -path \"./" . $backup_dir . "/*\" -print0 "
|
||
. " | tar --null -T - -czf " . escapeshellarg($backup_path) . " ) >" . escapeshellarg($log_path) . " 2>&1 &\n"
|
||
. "tar_pid=$!\n"
|
||
. "while kill -0 $tar_pid 2>/dev/null; do\n"
|
||
. " if [ -f " . escapeshellarg($stop_marker) . " ]; then\n"
|
||
. " kill -9 $tar_pid >/dev/null 2>&1 || true\n"
|
||
. " wait $tar_pid >/dev/null 2>&1 || true\n"
|
||
. " echo 124 > " . escapeshellarg($fail_marker) . "\n"
|
||
. " exit 124\n"
|
||
. " fi\n"
|
||
. " sleep 1\n"
|
||
. "done\n"
|
||
. "wait $tar_pid\n"
|
||
. "rc=$?\n"
|
||
. "if [ $rc -ne 0 ]; then\n"
|
||
. " echo $rc > " . escapeshellarg($fail_marker) . "\n"
|
||
. " exit $rc\n"
|
||
. "fi\n"
|
||
. "touch " . escapeshellarg($done_marker) . "\n"
|
||
. "exit 0\n";
|
||
file_put_contents($wrapper_path, $wrapper_cmd);
|
||
chmod($wrapper_path, 0755);
|
||
|
||
$launch_cmd = "sh -c " . escapeshellarg("sh " . escapeshellarg($wrapper_path) . " >/dev/null 2>&1 & echo \$!");
|
||
$result = launchBackground($launch_cmd);
|
||
$success = $result[0];
|
||
$pid = $result[1];
|
||
|
||
if ($success) {
|
||
if ($pid !== null) {
|
||
$st = readStatus($status_file);
|
||
$st['pid'] = $pid;
|
||
writeStatus($status_file, $st);
|
||
}
|
||
echo json_encode(array(
|
||
'status' => 'running',
|
||
'message' => '压缩任务已提交到后台运行(压缩文件上限 ' . $MAX_COMPRESSED_MB . ' MB)',
|
||
'file' => $backup_file,
|
||
'download_url' => $backup_web_base . '/' . rawurlencode($backup_file),
|
||
'size_limit_mb' => $MAX_COMPRESSED_MB,
|
||
), JSON_UNESCAPED_UNICODE);
|
||
} else {
|
||
writeStatus($status_file, array(
|
||
'status' => 'error',
|
||
'message' => '后台压缩启动失败。请检查命令执行权限或系统 tar/find 可用性。',
|
||
'log_path'=> $log_path,
|
||
));
|
||
echo json_encode(array(
|
||
'status' => 'error',
|
||
'message' => '后台压缩启动失败。请检查命令执行权限或系统 tar/find 可用性。',
|
||
), JSON_UNESCAPED_UNICODE);
|
||
cleanupWrapper($wrapper_path);
|
||
}
|
||
} else {
|
||
// ---- 纯 PHP 压缩兜底(无命令函数环境)----
|
||
$max_bytes = $MAX_COMPRESSED_MB * 1024 * 1024;
|
||
$ret = compressWithPhpZip($root_path, $backup_dir, $backup_path, $INCLUDE_PATTERNS, $max_bytes, $log_path);
|
||
$ok = $ret[0];
|
||
$msg = $ret[1];
|
||
$final_size = $ret[2];
|
||
$st_now = readStatus($status_file);
|
||
$started_at = isset($st_now['started_at']) ? $st_now['started_at'] : date('Y-m-d H:i:s');
|
||
$elapsed = max(0, time() - strtotime($started_at));
|
||
if ($ok) {
|
||
$done = $st_now;
|
||
$done['status'] = 'done';
|
||
$done['actual_size'] = round($final_size / 1024 / 1024, 2);
|
||
$done['elapsed'] = $elapsed;
|
||
$done['message'] = '纯 PHP 压缩完成,文件大小 ' . $done['actual_size'] . ' MB';
|
||
writeStatus($status_file, $done);
|
||
echo json_encode(decorateStatus($done, $backup_web_base), JSON_UNESCAPED_UNICODE);
|
||
} else {
|
||
$err = $st_now;
|
||
$err['status'] = 'error';
|
||
$err['actual_size'] = round($final_size / 1024 / 1024, 2);
|
||
$err['elapsed'] = $elapsed;
|
||
$err['message'] = $msg;
|
||
writeStatus($status_file, $err);
|
||
echo json_encode(decorateStatus($err, $backup_web_base), JSON_UNESCAPED_UNICODE);
|
||
}
|
||
}
|
||
exit;
|
||
}
|
||
|
||
// ======================== 渲染 HTML 页面 ========================
|
||
|
||
$initial_status = decorateStatus(readStatus($status_file), $backup_web_base);
|
||
|
||
// 文件排序比较函数(PHP 5.6 兼容)
|
||
function sortFilesByTime($a, $b) {
|
||
return filemtime($b) - filemtime($a);
|
||
}
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>网站源码压缩备份工具(增强版)</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
background: #f0f2f5; color: #1a1a2e; min-height: 100vh; display: flex;
|
||
flex-direction: column; align-items: center; padding: 40px 16px;
|
||
}
|
||
.container {
|
||
max-width: 860px; width: 100%; background: #fff; border-radius: 16px;
|
||
box-shadow: 0 4px 24px rgba(0,0,0,0.08); padding: 40px; margin-bottom: 40px;
|
||
}
|
||
h1 { font-size: 24px; font-weight: 700; margin-bottom: 24px; }
|
||
h2 { font-size: 18px; font-weight: 600; margin: 28px 0 14px; }
|
||
.info-grid {
|
||
display: grid; grid-template-columns: auto 1fr; gap: 8px 16px;
|
||
background: #f8f9fa; padding: 20px; border-radius: 10px; font-size: 14px; line-height: 1.8;
|
||
}
|
||
.info-grid .label { font-weight: 600; color: #555; white-space: nowrap; }
|
||
|
||
/* 状态卡片 */
|
||
.status-card {
|
||
padding: 20px 24px; border-radius: 10px; margin: 20px 0;
|
||
display: none; line-height: 1.7; font-size: 14px;
|
||
}
|
||
.status-card.visible { display: block; }
|
||
.status-card.running { background: #e8f4fd; border: 1px solid #b3d9f2; color: #0c5460; }
|
||
.status-card.done { background: #d4edda; border: 1px solid #a3d9b1; color: #155724; }
|
||
.status-card.error { background: #f8d7da; border: 1px solid #f0a0a8; color: #721c24; }
|
||
|
||
.status-card .title {
|
||
font-weight: 700; font-size: 15px; margin-bottom: 6px;
|
||
display: flex; align-items: center; gap: 6px;
|
||
}
|
||
.status-card .detail { font-size: 13px; opacity: 0.85; }
|
||
.status-card .detail strong { opacity: 1; }
|
||
|
||
/* 进度条 */
|
||
.progress-bar {
|
||
width: 100%; height: 6px; background: #cce5ff; border-radius: 3px;
|
||
margin-top: 12px; overflow: hidden;
|
||
}
|
||
.progress-bar .fill {
|
||
height: 100%; background: #0288d1; border-radius: 3px;
|
||
width: 0%; transition: width 1s linear;
|
||
}
|
||
|
||
/* 按钮 */
|
||
.btn-group { display: flex; gap: 12px; margin: 24px 0; flex-wrap: wrap; }
|
||
.btn {
|
||
display: inline-flex; align-items: center; gap: 8px;
|
||
padding: 14px 32px; font-size: 16px; font-weight: 600;
|
||
border: none; border-radius: 10px; cursor: pointer;
|
||
text-decoration: none; transition: all 0.2s;
|
||
}
|
||
.btn-primary { background: #28a745; color: #fff; }
|
||
.btn-primary:hover { background: #218838; }
|
||
.btn-primary:disabled {
|
||
background: #94d3a0; cursor: not-allowed; opacity: 0.7;
|
||
}
|
||
.btn-outline {
|
||
background: transparent; color: #555; border: 1px solid #ddd;
|
||
}
|
||
.btn-outline:hover { background: #f5f5f5; }
|
||
.btn-sm { padding: 8px 16px; font-size: 13px; border-radius: 8px; }
|
||
.btn-danger { background: #dc3545; color: #fff; }
|
||
.btn-danger:hover { background: #c82333; }
|
||
|
||
/* 文件列表 */
|
||
.file-list { list-style: none; }
|
||
.file-item {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 12px 0; border-bottom: 1px solid #f0f0f0; font-size: 14px;
|
||
}
|
||
.file-item:last-child { border-bottom: none; }
|
||
.file-item a {
|
||
color: #0366d6; text-decoration: none; font-family: monospace;
|
||
word-break: break-all;
|
||
}
|
||
.file-item a:hover { text-decoration: underline; }
|
||
.file-item .meta { color: #888; font-size: 12px; white-space: nowrap; margin-left: 12px; }
|
||
.file-item .writing-tag {
|
||
display: inline-block; background: #fff3cd; color: #856404;
|
||
padding: 2px 8px; border-radius: 4px; font-size: 11px; margin-left: 8px;
|
||
}
|
||
.empty-text { color: #999; font-size: 14px; padding: 12px 0; }
|
||
|
||
.notes {
|
||
margin-top: 28px; padding-top: 20px; border-top: 1px solid #eee;
|
||
font-size: 13px; color: #666; line-height: 1.8;
|
||
}
|
||
.notes code {
|
||
background: #f4f4f4; padding: 2px 6px; border-radius: 4px;
|
||
font-size: 12px; color: #c7254e;
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.container { padding: 24px 18px; }
|
||
h1 { font-size: 20px; }
|
||
.btn { padding: 12px 20px; font-size: 14px; }
|
||
.file-item { flex-direction: column; align-items: flex-start; gap: 4px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>🚀 网站源码压缩备份工具(增强版 v2)</h1>
|
||
|
||
<div class="info-grid">
|
||
<span class="label">基础网址:</span>
|
||
<span><?php echo htmlspecialchars($base_url); ?></span>
|
||
<span class="label">网站路径:</span>
|
||
<span><?php echo htmlspecialchars($root_path); ?></span>
|
||
<span class="label">域名:</span>
|
||
<span><?php echo htmlspecialchars($domain); ?></span>
|
||
<span class="label">压缩文件上限:</span>
|
||
<span><?php echo $MAX_COMPRESSED_MB; ?> MB(压缩过程中实时监测)</span>
|
||
<span class="label">软超时:</span>
|
||
<span><?php echo formatSeconds($MAX_RUNTIME); ?>(超过后仅监控,不终止)</span>
|
||
<span class="label">卡死判定:</span>
|
||
<span><?php echo formatSeconds($STALL_TIMEOUT); ?> 不增长才终止</span>
|
||
<span class="label">PHP 版本:</span>
|
||
<span><?php echo PHP_VERSION; ?></span>
|
||
</div>
|
||
|
||
<!-- 动态状态卡片 -->
|
||
<div id="statusCard" class="status-card"></div>
|
||
|
||
<div class="btn-group">
|
||
<button id="btnBackup" class="btn btn-primary" onclick="startBackup()">
|
||
🚀 开始压缩备份
|
||
</button>
|
||
<button id="btnReset" class="btn btn-outline btn-sm" onclick="resetStatus()" style="display:none;">
|
||
🔄 重置状态
|
||
</button>
|
||
</div>
|
||
|
||
<h2>📦 已生成的备份文件</h2>
|
||
<?php
|
||
$show_files = array();
|
||
$entries = @scandir($backup_dir_full);
|
||
if ($entries !== false) {
|
||
foreach ($entries as $entry) {
|
||
if ($entry === '.' || $entry === '..') continue;
|
||
$f = $backup_dir_full . '/' . $entry;
|
||
if (!is_file($f)) continue;
|
||
// 仅隐藏内部临时控制文件,其他文件全部展示(含 .backup_status.json / 日志 / 压缩包)
|
||
if (strpos($entry, '._backup_') === 0) continue;
|
||
$show_files[] = $f;
|
||
}
|
||
}
|
||
if (!empty($show_files)) {
|
||
usort($show_files, 'sortFilesByTime');
|
||
echo '<ul class="file-list" id="fileList">';
|
||
foreach ($show_files as $f) {
|
||
$name = basename($f);
|
||
$size_mb = round(filesize($f) / 1024 / 1024, 2);
|
||
$mtime = date('Y-m-d H:i', filemtime($f));
|
||
$download_url = $backup_web_base . '/' . rawurlencode($name);
|
||
echo '<li class="file-item">'
|
||
. '<div><a href="' . htmlspecialchars($download_url) . '">' . htmlspecialchars($name) . '</a>'
|
||
. '</div>'
|
||
. '<span class="meta">' . $size_mb . ' MB · ' . $mtime . '</span>'
|
||
. '</li>';
|
||
}
|
||
echo '</ul>';
|
||
} else {
|
||
echo '<p class="empty-text" id="fileList">备份目录暂无文件。</p>';
|
||
}
|
||
?>
|
||
|
||
<div class="notes">
|
||
<strong>注意事项:</strong><br>
|
||
• 压缩过程完全在后台运行(nohup),关闭页面不会中断<br>
|
||
• 已自动排除 <code><?php echo htmlspecialchars($backup_dir); ?></code> 目录,避免重复备份<br>
|
||
• <strong>大小限制:</strong>不再提前拒绝,而是在压缩过程中实时监测,压缩文件超过 <?php echo $MAX_COMPRESSED_MB; ?> MB 时自动终止并保留已压缩部分<br>
|
||
• <strong>超时策略:</strong>超过 <?php echo formatSeconds($MAX_RUNTIME); ?> 后不会立即终止,只要文件还在增长就继续等待<br>
|
||
• 只有文件连续 <?php echo formatSeconds($STALL_TIMEOUT); ?> 不增长(真正卡死)才会自动终止<br>
|
||
• 如状态卡在"压缩中",可点击"重置状态"按钮手动清除<br>
|
||
• <strong>v2 修复:</strong>兼容 PHP 5.6+ 全系列,下载使用流式传输不再撑爆内存
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function() {
|
||
'use strict';
|
||
|
||
var POLL_INTERVAL = <?php echo $POLL_INTERVAL; ?> * 1000;
|
||
var MAX_RUNTIME = <?php echo $MAX_RUNTIME; ?>;
|
||
var STALL_TIMEOUT = <?php echo $STALL_TIMEOUT; ?>;
|
||
var MAX_COMPRESSED_MB = <?php echo $MAX_COMPRESSED_MB; ?>;
|
||
var pollTimer = null;
|
||
|
||
var btnBackup = document.getElementById('btnBackup');
|
||
var btnReset = document.getElementById('btnReset');
|
||
var card = document.getElementById('statusCard');
|
||
|
||
// ---- 渲染状态卡片 ----
|
||
function renderCard(data) {
|
||
if (!data || data.status === 'idle') {
|
||
card.className = 'status-card';
|
||
card.innerHTML = '';
|
||
btnReset.style.display = 'none';
|
||
btnBackup.disabled = false;
|
||
btnBackup.innerHTML = '🚀 开始压缩备份';
|
||
return;
|
||
}
|
||
|
||
var html = '';
|
||
var cls = data.status;
|
||
|
||
if (cls === 'running') {
|
||
var elapsed = (data.elapsed || 0);
|
||
var stallSec = (data.stall_seconds || 0);
|
||
var growing = data.file_growing;
|
||
var overLimit = data.over_soft_limit;
|
||
var growIcon = growing ? '✅' : '⏸️';
|
||
var growText = growing ? '正在写入' : '暂时停顿';
|
||
|
||
// 进度条:用压缩文件大小 / 上限做进度(更直观)
|
||
var sizeLimitMb = data.size_limit_mb || MAX_COMPRESSED_MB;
|
||
var pct = 0;
|
||
if (data.current_size && sizeLimitMb > 0) {
|
||
pct = Math.min(99, (data.current_size / sizeLimitMb) * 100);
|
||
} else {
|
||
pct = 0;
|
||
}
|
||
|
||
// 大小占比文字
|
||
var sizePercent = data.size_percent || 0;
|
||
var sizeInfo = data.current_size
|
||
? '已生成 <strong>' + data.current_size + ' MB</strong> / ' + sizeLimitMb + ' MB(' + sizePercent.toFixed(1) + '%)'
|
||
: '尚未生成文件';
|
||
|
||
// 进度条颜色:正常青色,>80% 黄色,>95% 红色
|
||
var barColor = '#0288d1';
|
||
if (sizePercent > 95) barColor = '#dc3545';
|
||
else if (sizePercent > 80) barColor = '#f0ad4e';
|
||
|
||
// 超时提示 / 大小提示
|
||
var warnHtml = '';
|
||
if (sizePercent > 95 && growing) {
|
||
warnHtml = '<div style="background:#f8d7da;color:#721c24;padding:8px 12px;border-radius:6px;margin-top:10px;font-size:12px;">'
|
||
+ '🔴 压缩文件即将达到 ' + sizeLimitMb + ' MB 上限(' + sizePercent.toFixed(1) + '%),超限将自动终止。</div>';
|
||
}
|
||
else if (sizePercent > 80) {
|
||
warnHtml = '<div style="background:#fff3cd;color:#856404;padding:8px 12px;border-radius:6px;margin-top:10px;font-size:12px;">'
|
||
+ '⏰ 压缩文件已 ' + sizePercent.toFixed(1) + '%,接近 ' + sizeLimitMb + ' MB 上限。</div>';
|
||
}
|
||
else if (overLimit && growing) {
|
||
warnHtml = '<div style="background:#fff3cd;color:#856404;padding:8px 12px;border-radius:6px;margin-top:10px;font-size:12px;">'
|
||
+ '⏰ 已超过 ' + formatSec(MAX_RUNTIME) + ' 软限制,但文件仍在增长,不会终止。请耐心等待。</div>';
|
||
} else if (overLimit && !growing) {
|
||
var stallPct = Math.min(100, (stallSec / STALL_TIMEOUT) * 100);
|
||
warnHtml = '<div style="background:#f8d7da;color:#721c24;padding:8px 12px;border-radius:6px;margin-top:10px;font-size:12px;">'
|
||
+ '⚠️ 文件已停顿 ' + formatSec(stallSec) + '(连续 ' + formatSec(STALL_TIMEOUT) + ' 不增长将自动终止)'
|
||
+ '<div style="background:rgba(0,0,0,0.1);border-radius:3px;height:4px;margin-top:6px;">'
|
||
+ '<div style="background:#dc3545;height:100%;border-radius:3px;width:' + stallPct + '%;transition:width 1s;"></div>'
|
||
+ '</div></div>';
|
||
}
|
||
|
||
html = '<div class="title">⏳ 正在压缩中...</div>'
|
||
+ '<div class="detail">'
|
||
+ '文件:<strong>' + escHtml(data.file || '') + '</strong><br>'
|
||
+ sizeInfo + '(' + growIcon + ' ' + growText + ')<br>'
|
||
+ '已运行:' + formatSec(elapsed) + '</div>'
|
||
+ '<div class="progress-bar"><div class="fill" style="width:' + pct + '%;background:' + barColor + ';"></div></div>'
|
||
+ warnHtml;
|
||
|
||
btnBackup.disabled = true;
|
||
btnBackup.innerHTML = '⏳ 压缩进行中...';
|
||
btnReset.style.display = 'inline-flex';
|
||
}
|
||
else if (cls === 'done') {
|
||
var doneSize = (data.actual_size || '?');
|
||
var downloadHtml = '';
|
||
if (data.download_url) {
|
||
downloadHtml = '<br>下载地址:<a href="' + escHtml(data.download_url) + '" target="_blank" rel="noopener noreferrer">'
|
||
+ escHtml(data.download_url) + '</a>';
|
||
}
|
||
html = '<div class="title">✅ 压缩成功</div>'
|
||
+ '<div class="detail">文件:<strong>' + escHtml(data.file || '') + '</strong><br>'
|
||
+ '压缩大小:<strong>' + doneSize + ' MB</strong><br>'
|
||
+ '耗时:' + formatSec(data.elapsed || 0) + downloadHtml + '</div>';
|
||
|
||
// 如果有 size_percent,显示大小占比
|
||
if (data.size_percent) {
|
||
html += '<div style="background:#e8f5e9;color:#2e7d32;padding:6px 12px;border-radius:6px;margin-top:8px;font-size:12px;">'
|
||
+ '📊 占上限 ' + data.size_percent.toFixed(1) + '%</div>';
|
||
}
|
||
|
||
btnBackup.disabled = false;
|
||
btnBackup.innerHTML = '🚀 再次备份';
|
||
btnReset.style.display = 'inline-flex';
|
||
stopPolling();
|
||
refreshFileList();
|
||
}
|
||
else if (cls === 'error') {
|
||
var logHtml = '';
|
||
if (data.log_download_url) {
|
||
logHtml += '<br>日志下载:<a href="' + escHtml(data.log_download_url) + '" target="_blank" rel="noopener noreferrer">'
|
||
+ escHtml(data.log_download_url) + '</a>';
|
||
}
|
||
if (data.log_tail) {
|
||
logHtml += '<div style="margin-top:10px;background:#fff5f5;border:1px solid #f1b0b7;border-radius:8px;padding:10px;">'
|
||
+ '<div style="font-weight:600;margin-bottom:6px;">日志摘要(最后几行)</div>'
|
||
+ '<pre style="white-space:pre-wrap;word-break:break-word;margin:0;font-size:12px;line-height:1.5;">'
|
||
+ escHtml(data.log_tail) + '</pre></div>';
|
||
}
|
||
html = '<div class="title">❌ 压缩失败</div>'
|
||
+ '<div class="detail">' + (data.message || '未知错误') + logHtml + '</div>';
|
||
|
||
btnBackup.disabled = false;
|
||
btnBackup.innerHTML = '🚀 重新备份';
|
||
btnReset.style.display = 'inline-flex';
|
||
stopPolling();
|
||
}
|
||
|
||
card.className = 'status-card visible ' + cls;
|
||
card.innerHTML = html;
|
||
}
|
||
|
||
// ---- 轮询状态 ----
|
||
function startPolling() {
|
||
stopPolling();
|
||
pollTimer = setInterval(checkStatus, POLL_INTERVAL);
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||
}
|
||
|
||
function checkStatus() {
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('GET', '?action=status&_t=' + Date.now(), true);
|
||
xhr.timeout = 10000;
|
||
xhr.ontimeout = function() {
|
||
// 网络波动时不打断 UI,只在控制台提示
|
||
console.warn('状态查询超时');
|
||
};
|
||
xhr.onerror = function() {
|
||
console.warn('状态查询网络错误');
|
||
};
|
||
xhr.onreadystatechange = function() {
|
||
if (xhr.readyState !== 4) return;
|
||
if (xhr.status === 200) {
|
||
try {
|
||
var data = JSON.parse(xhr.responseText);
|
||
renderCard(data);
|
||
if (data.status === 'done' || data.status === 'error') {
|
||
stopPolling();
|
||
}
|
||
} catch (e) {
|
||
console.error('JSON 解析失败:', e, xhr.responseText);
|
||
}
|
||
}
|
||
};
|
||
xhr.send(null);
|
||
}
|
||
|
||
// ---- 发起备份 ----
|
||
window.startBackup = function() {
|
||
btnBackup.disabled = true;
|
||
btnBackup.innerHTML = '⏳ 提交中...';
|
||
|
||
function recoverByStatusAfterSubmit() {
|
||
var s = new XMLHttpRequest();
|
||
s.open('GET', '?action=status&_t=' + Date.now(), true);
|
||
s.timeout = 8000;
|
||
s.onreadystatechange = function() {
|
||
if (s.readyState !== 4) return;
|
||
if (s.status === 200) {
|
||
try {
|
||
var st = JSON.parse(s.responseText);
|
||
if (st && st.status === 'running') {
|
||
renderCard(st);
|
||
startPolling();
|
||
return;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
btnBackup.disabled = false;
|
||
btnBackup.innerHTML = '🚀 开始压缩备份';
|
||
};
|
||
s.onerror = function() {
|
||
btnBackup.disabled = false;
|
||
btnBackup.innerHTML = '🚀 开始压缩备份';
|
||
};
|
||
s.ontimeout = function() {
|
||
btnBackup.disabled = false;
|
||
btnBackup.innerHTML = '🚀 开始压缩备份';
|
||
};
|
||
s.send(null);
|
||
}
|
||
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('GET', '?action=backup&_t=' + Date.now(), true);
|
||
xhr.timeout = 30000;
|
||
xhr.ontimeout = function() {
|
||
recoverByStatusAfterSubmit();
|
||
};
|
||
xhr.onerror = function() {
|
||
recoverByStatusAfterSubmit();
|
||
};
|
||
xhr.onreadystatechange = function() {
|
||
if (xhr.readyState !== 4) return;
|
||
if (xhr.status === 200) {
|
||
try {
|
||
var data = JSON.parse(xhr.responseText);
|
||
renderCard(data);
|
||
if (data.status === 'running') {
|
||
startPolling();
|
||
}
|
||
} catch (e) {
|
||
var raw = (xhr.responseText || '').toString();
|
||
if (raw.length > 500) raw = raw.slice(0, 500) + '...';
|
||
alert('响应解析失败。\n\n服务器返回内容:\n' + (raw || '[空响应]'));
|
||
recoverByStatusAfterSubmit();
|
||
}
|
||
} else {
|
||
var text = (xhr.responseText || '').toString();
|
||
if (text.length > 300) text = text.slice(0, 300) + '...';
|
||
if (text) {
|
||
alert('请求失败(HTTP ' + xhr.status + ')。\n\n服务端返回:\n' + text);
|
||
}
|
||
recoverByStatusAfterSubmit();
|
||
}
|
||
};
|
||
xhr.send(null);
|
||
};
|
||
|
||
// ---- 重置状态 ----
|
||
window.resetStatus = function() {
|
||
if (!confirm('确定要重置当前状态吗?\n\n如果正在压缩,将尝试终止后台进程。')) return;
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('GET', '?action=reset&_t=' + Date.now(), true);
|
||
xhr.onreadystatechange = function() {
|
||
if (xhr.readyState !== 4) return;
|
||
stopPolling();
|
||
renderCard({ status: 'idle' });
|
||
};
|
||
xhr.send(null);
|
||
};
|
||
|
||
// ---- 刷新文件列表 ----
|
||
function refreshFileList() {
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('GET', '?_t=' + Date.now(), true);
|
||
xhr.onreadystatechange = function() {
|
||
if (xhr.readyState !== 4 || xhr.status !== 200) return;
|
||
try {
|
||
var parser = new DOMParser();
|
||
var doc = parser.parseFromString(xhr.responseText, 'text/html');
|
||
var newList = doc.getElementById('fileList');
|
||
if (newList) {
|
||
var oldList = document.getElementById('fileList');
|
||
if (oldList) oldList.outerHTML = newList.outerHTML;
|
||
}
|
||
} catch (e) {}
|
||
};
|
||
xhr.send(null);
|
||
}
|
||
|
||
// ---- 工具函数 ----
|
||
function escHtml(s) {
|
||
var d = document.createElement('div');
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function formatSec(s) {
|
||
s = Math.max(0, Math.round(s));
|
||
if (s < 60) return s + ' 秒';
|
||
if (s < 3600) return Math.floor(s / 60) + ' 分 ' + (s % 60) + ' 秒';
|
||
return Math.floor(s / 3600) + ' 小时 ' + Math.floor((s % 3600) / 60) + ' 分';
|
||
}
|
||
|
||
// ---- 页面加载时:初始化状态 ----
|
||
var initialData = <?php echo json_encode($initial_status, JSON_UNESCAPED_UNICODE); ?>;
|
||
renderCard(initialData);
|
||
|
||
// 如果当前状态是 running,自动开始轮询
|
||
if (initialData && initialData.status === 'running') {
|
||
startPolling();
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|