Files
MovieLog_Server/settings.php
T

840 lines
46 KiB
PHP
Raw Normal View History

<?php
// ============================================
// 文件: settings.php
// 说明: 系统设置 —— 图片存储方式(本地硬盘 / 阿里云 OSS / 腾讯云 COS / 七牛云)
// 含各驱动的凭据配置、连通性测试与历史图片迁移。
// ============================================
require_once 'config.php';
checkLogin();
$admin = getCurrentAdmin();
$message = '';
$error = '';
// 密钥打码显示
function maskSecret($value) {
$value = (string)$value;
$len = strlen($value);
if ($len === 0) return '';
if ($len <= 8) return str_repeat('*', $len);
return substr($value, 0, 4) . str_repeat('*', 6) . substr($value, -4);
}
// ---------- 保存设置 ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_storage') {
$driver = $_POST['storage_driver'] ?? 'local';
if (!in_array($driver, ['local', 'oss', 'cos', 'qiniu'], true)) {
$driver = 'local';
}
// 各驱动的字段定义:'secret' 表示敏感字段(留空则不修改)
$fieldMap = [
'oss' => [
'auth_mode' => 'plain',
'access_key_id' => 'plain',
'access_key_secret' => 'secret',
'bucket' => 'plain',
'endpoint' => 'plain',
'prefix' => 'plain',
'ram_role_name' => 'plain',
'custom_domain' => 'plain',
'internal_endpoint' => 'plain',
'https' => 'plain',
],
'cos' => [
'auth_mode' => 'plain',
'secret_id' => 'plain',
'secret_key' => 'secret',
'bucket' => 'plain',
'region' => 'plain',
'prefix' => 'plain',
'cam_role_name' => 'plain',
'custom_domain' => 'plain',
'https' => 'plain',
],
'qiniu' => [
'access_key' => 'plain',
'secret_key' => 'secret',
'bucket' => 'plain',
'region' => 'plain',
'prefix' => 'plain',
'custom_domain' => 'plain',
'https' => 'plain',
],
];
foreach ($fieldMap as $d => $fields) {
$old = storageConfigFor($d);
$in = isset($_POST[$d]) && is_array($_POST[$d]) ? $_POST[$d] : [];
$clearSecrets = !empty($in['clear_secrets']);
$new = [];
foreach ($fields as $f => $kind) {
$posted = isset($in[$f]) ? trim((string)$in[$f]) : '';
if ($kind === 'secret') {
if ($clearSecrets) {
$new[$f] = '';
} else {
// 留空表示保持原值
$new[$f] = $posted === '' ? (string)($old[$f] ?? '') : $posted;
}
} else {
$new[$f] = $posted !== '' ? $posted : (string)($old[$f] ?? '');
}
}
// 复选框型字段(未勾选时浏览器不提交该字段,必须显式归零而不是沿用旧值)
$new['https'] = ($new['https'] ?? '') === '0' ? '0' : '1';
if ($d === 'oss') {
$new['internal_endpoint'] = ($new['internal_endpoint'] ?? '') === '1' ? '1' : '0';
}
saveStorageConfig($d, $new);
}
setSetting('storage_driver', $driver);
// 图片访问方式:direct(直连存储)/ proxy(经 media.php 服务端代理读取)
$accessMode = ($_POST['storage_access_mode'] ?? 'direct') === 'proxy' ? 'proxy' : 'direct';
setSetting('storage_access_mode', $accessMode);
$message = '设置已保存。当前存储方式:' . (storageDriverLabels()[$driver] ?? $driver)
. ';图片访问方式:' . ($accessMode === 'proxy' ? '服务端代理' : '直连存储');
}
// ---------- 清除错误提示 ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'clear_storage_error') {
storageClearError();
$message = '已清除错误提示';
}
$drivers = storageDriverLabels();
$current = currentStorageDriver();
$configs = [];
foreach (['oss', 'cos', 'qiniu'] as $d) {
$configs[$d] = storageConfigFor($d);
}
$lastError = storageLastError();
$isRemote = storageIsRemote();
$remoteReady = $current !== 'local' && storageConfigComplete($current);
// 本地图片统计(只读目录,不查库)
function localImageStat() {
$count = 0; $bytes = 0;
$base = UPLOAD_DIR;
if (!is_dir($base)) return ['count' => 0, 'bytes' => 0];
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($it as $f) {
if (!$f->isFile()) continue;
if ($f->getFilename() === '.htaccess') continue;
$count++;
$bytes += $f->getSize();
}
return ['count' => $count, 'bytes' => $bytes];
}
$localStat = localImageStat();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统设置 - 电影日志</title>
<link rel="stylesheet" href="css/style.css">
<style>
.page-wrap { max-width: 880px; margin: 0 auto; padding: 20px; }
.card {
background: var(--card-bg); border-radius: 16px; padding: 28px 32px;
box-shadow: var(--shadow); margin-bottom: 24px;
}
.card h2 { font-size: 20px; font-weight: 700; margin: 0 0 6px; color: var(--text-primary); }
.card .sub { color: var(--text-secondary); font-size: 14px; margin-bottom: 18px; line-height: 1.6; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 5px; color: var(--text-primary); }
.form-group input[type=text], .form-group input[type=password], .form-group select {
width: 100%; padding: 10px 14px; border: 1px solid var(--border-color);
border-radius: 8px; font-size: 15px; box-sizing: border-box; transition: border-color .2s;
font-family: inherit;
}
.form-group input:focus, .form-group select:focus {
outline: none; border-color: var(--primary); box-shadow: 0 0 0 4px rgba(230,126,34,.12);
}
.hint { font-size: 12.5px; color: var(--text-secondary); margin-top: 5px; line-height: 1.6; }
.hint code { background: var(--bg-secondary); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
.driver-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.driver-opt {
border: 2px solid var(--border-color); border-radius: 12px; padding: 14px 16px;
cursor: pointer; transition: var(--transition); background: var(--card-bg);
}
.driver-opt:hover { border-color: var(--primary-light); }
.driver-opt.active { border-color: var(--primary); background: #fff8f0; }
.driver-opt .t { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 8px; }
.driver-opt .d { font-size: 12.5px; color: var(--text-secondary); margin-top: 4px; }
.driver-opt input { margin-right: 6px; }
.panel { display: none; margin-top: 20px; padding-top: 20px; border-top: 1px dashed var(--border-color); }
.panel.active { display: block; }
.panel-title { font-weight: 600; font-size: 15px; margin-bottom: 14px; color: var(--text-primary); }
.btn { padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 600;
border: none; cursor: pointer; transition: var(--transition); text-decoration: none; display: inline-block; }
.btn-primary { background: linear-gradient(135deg, var(--primary), var(--primary-light)); color: #fff; }
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 28px rgba(230,126,34,.35); }
.btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); }
.btn-outline:hover { background: var(--bg-secondary); }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn:disabled { opacity: .5; cursor: not-allowed; transform: none !important; box-shadow: none !important; }
.alert { padding: 12px 16px; border-radius: 10px; font-size: 14px; margin-bottom: 18px; line-height: 1.6; }
.alert-ok { background: #e8f8f0; color: #1e7e46; border: 1px solid #b6e6cd; }
.alert-err { background: #fdecea; color: #a93226; border: 1px solid #f5c6c0; }
.alert-info { background: #eaf4fd; color: #1b5f96; border: 1px solid #bcdcf5; }
.status-grid { display: grid; grid-template-columns: 130px 1fr; gap: 8px 16px; font-size: 14px; }
.status-grid .k { color: var(--text-secondary); }
.status-grid .v { color: var(--text-primary); font-weight: 500; word-break: break-all; }
.badge { display: inline-block; padding: 2px 9px; border-radius: 20px; font-size: 12.5px; font-weight: 600; }
.badge-ok { background: #e8f8f0; color: #1e7e46; }
.badge-off { background: var(--bg-secondary); color: var(--text-secondary); }
.badge-warn { background: #fef5e7; color: #a9711a; }
.progress { height: 10px; background: var(--bg-secondary); border-radius: 20px; overflow: hidden; margin: 14px 0 8px; }
.progress > i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--primary), var(--primary-light)); transition: width .3s; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }
.row-flex { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.chk { font-size: 13px; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; margin-top: 6px; }
.chk input { margin: 0; }
</style>
</head>
<body>
<?php include 'nav.php'; ?>
<div class="page-wrap">
<?php if ($message): ?><div class="alert alert-ok"><?= h($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="alert alert-err"><?= h($error) ?></div><?php endif; ?>
<?php if ($lastError && !empty($lastError['message'])): ?>
<div class="alert alert-err">
<strong>最近一次存储错误</strong>(<?= h($lastError['time'] ?? '') ?>,
驱动:<?= h($drivers[$lastError['driver'] ?? ''] ?? ($lastError['driver'] ?? '未知')) ?>)<br>
<span class="mono"><?= h($lastError['message']) ?></span>
<form method="post" style="margin-top:10px;">
<input type="hidden" name="action" value="clear_storage_error">
<button class="btn btn-outline btn-sm" type="submit">清除提示</button>
</form>
</div>
<?php endif; ?>
<div class="card">
<h2>图片存储方式</h2>
<div class="sub">
图片可以存放在本站硬盘上,也可以托管到对象存储。改用对象存储后,图片不再占用服务器磁盘,
以后搬迁网站时也不必搬运图片文件。<br>
切换存储方式<strong>不会影响已有图片</strong>:系统按"本地优先"读取,
本地存在的文件继续走本地,新上传的图片才走云端。
</div>
<form method="post" id="storageForm">
<input type="hidden" name="action" value="save_storage">
<div class="driver-grid">
<?php
$driverDesc = [
'local' => '默认方式,图片存在 uploads/ 目录',
'oss' => '阿里云对象存储,支持 ECS 免密钥',
'cos' => '腾讯云对象存储,支持 CVM 免密钥',
'qiniu' => '七牛云对象存储,需绑定域名',
];
foreach ($drivers as $id => $label): ?>
<label class="driver-opt <?= $current === $id ? 'active' : '' ?>" data-driver="<?= h($id) ?>">
<div class="t">
<input type="radio" name="storage_driver" value="<?= h($id) ?>"
<?= $current === $id ? 'checked' : '' ?>>
<?= h($label) ?>
</div>
<div class="d"><?= h($driverDesc[$id] ?? '') ?></div>
</label>
<?php endforeach; ?>
</div>
<!-- ========== 图片访问方式 ========== -->
<?php
$accessModeNow = storageAccessMode();
// 内网 endpoint 的推导与可用性判定(结论有缓存,不会每次翻页都探测)。
// 结论要显式展示:跨地域时内网会**静默拖慢**每次读取,不报出来无从察觉。
$ossInternalInfo = null;
if ($current === 'oss'
&& trim((string)($configs['oss']['endpoint'] ?? '')) !== ''
&& trim((string)($configs['oss']['internal_endpoint'] ?? '1')) === '1') {
$ossInternalInfo = storageDriverInstance('oss', $configs['oss'])->internalStatus();
}
?>
<div class="form-group" style="margin-top:22px;padding-top:18px;border-top:1px dashed var(--border-color);">
<label>图片访问方式</label>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:6px;">
<label class="chk" style="align-items:flex-start;gap:8px;">
<input type="radio" name="storage_access_mode" value="direct"
<?= $accessModeNow === 'direct' ? 'checked' : '' ?>>
<span>
<strong>直连存储</strong>
<span class="hint" style="display:block;margin-top:2px;">
图片 URL 直接指向对象存储。要求桶允许匿名读取(公共读),否则图片会 403 打不开。
</span>
</span>
</label>
<label class="chk" style="align-items:flex-start;gap:8px;">
<input type="radio" name="storage_access_mode" value="proxy"
<?= $accessModeNow === 'proxy' ? 'checked' : '' ?>>
<span>
<strong>服务端代理(更安全)</strong>
<span class="hint" style="display:block;margin-top:2px;">
图片经本站 <code>media.php</code> 鉴权后由服务器读取回吐。<strong>桶可以设为私有</strong>,
无需防盗链;且因同源交付,图片裁剪功能不再受跨域限制。
代价:图片流量走本服务器出网带宽。<br>
目前支持服务端读取的驱动:本地硬盘、阿里云 OSS。
</span>
</span>
</label>
</div>
</div>
<!-- ========== 阿里云 OSS ========== -->
<div class="panel <?= $current === 'oss' ? 'active' : '' ?>" data-panel="oss">
<div class="panel-title">阿里云 OSS 配置</div>
<div class="form-group">
<label>鉴权方式</label>
<select name="oss[auth_mode]" class="auth-mode" data-driver="oss">
<option value="ak" <?= $configs['oss']['auth_mode'] === 'ak' ? 'selected' : '' ?>>AccessKey(显式填写密钥)</option>
<option value="ecs_role" <?= $configs['oss']['auth_mode'] === 'ecs_role' ? 'selected' : '' ?>>ECS RAM 角色(免密钥,实例元数据自动获取)</option>
</select>
<div class="hint">服务器在阿里云 ECS 上并已绑定 RAM 角色时,选第二项可完全不填密钥。</div>
</div>
<div class="ak-fields" data-driver="oss">
<div class="form-group">
<label>AccessKey ID</label>
<input type="text" name="oss[access_key_id]" value="<?= h($configs['oss']['access_key_id']) ?>"
placeholder="LTAI..." autocomplete="off">
</div>
<div class="form-group">
<label>AccessKey Secret</label>
<input type="password" name="oss[access_key_secret]" value=""
placeholder="<?= $configs['oss']['access_key_secret'] !== '' ? '已保存(' . h(maskSecret($configs['oss']['access_key_secret'])) . '),留空则不修改' : '请输入 AccessKey Secret' ?>"
autocomplete="new-password">
<?php if ($configs['oss']['access_key_secret'] !== ''): ?>
<label class="chk"><input type="checkbox" name="oss[clear_secrets]" value="1"> 清除已保存的密钥</label>
<?php endif; ?>
</div>
</div>
<div class="role-fields" data-driver="oss" style="display:none;">
<div class="form-group">
<label>RAM 角色名称(可留空自动发现)</label>
<input type="text" name="oss[ram_role_name]" value="<?= h($configs['oss']['ram_role_name']) ?>"
placeholder="留空则自动读取实例绑定的角色">
<div class="hint">需要该角色被授予目标 Bucket 的读写权限。</div>
</div>
</div>
<div class="form-group">
<label>Bucket 名称</label>
<input type="text" name="oss[bucket]" value="<?= h($configs['oss']['bucket']) ?>" placeholder="例如 movielog">
</div>
<div class="form-group">
<label>Endpoint</label>
<input type="text" name="oss[endpoint]" value="<?= h($configs['oss']['endpoint']) ?>"
placeholder="例如 oss-cn-beijing.aliyuncs.com">
<div class="hint">在 OSS 控制台"概览"里查看,形如 <code>oss-cn-地域.aliyuncs.com</code>,不要带 <code>https://</code>。</div>
</div>
<div class="form-group">
<label class="chk">
<input type="checkbox" name="oss[internal_endpoint]" value="1"
<?= ($configs['oss']['internal_endpoint'] ?? '1') === '1' ? 'checked' : '' ?>>
服务端读取走内网 endpoint
</label>
<div class="hint">仅在「服务端代理」访问方式下生效。服务器<strong>与桶同地域</strong>时,
读取会改用 <code>oss-&lt;地域&gt;-internal.aliyuncs.com</code>——内网流量免费且更快。<br>
<strong>不同地域时内网地址连不通</strong>(DNS 能解析、TCP 建不起来),
硬走内网会让每次读取先白等一次连接超时。系统会自动判定地域、不可用就跳过并回退公网,
不会因此变慢;此处结论见下方「当前状态」。</div>
<?php if ($ossInternalInfo !== null && $ossInternalInfo['endpoint'] !== ''): ?>
<div class="hint" style="margin-top:6px;">
当前判定:
<?php if ($ossInternalInfo['ok']): ?>
<span class="badge badge-ok">内网可用</span>
<?php else: ?>
<span class="badge badge-warn">已跳过内网,走公网</span>
<?php endif; ?>
<span class="mono"><?= h($ossInternalInfo['endpoint']) ?></span>
<?php if ($ossInternalInfo['reason'] !== ''): ?>
—— <?= h($ossInternalInfo['reason']) ?>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<div class="form-group">
<label>对象前缀(可选)</label>
<input type="text" name="oss[prefix]" value="<?= h($configs['oss']['prefix']) ?>"
placeholder="例如 movielog">
<div class="hint">填写后图片会存到桶内该目录下,键变为 <code>前缀/uploads/posters/xxx.jpg</code>;
同一个桶存放多个站点时用它区分。留空则直接放在桶根目录。</div>
</div>
<div class="form-group">
<label>自定义域名 / CDN(可选)</label>
<input type="text" name="oss[custom_domain]" value="<?= h($configs['oss']['custom_domain']) ?>"
placeholder="例如 img.example.com">
<div class="hint">填写后图片 URL 会使用该域名;留空则使用 OSS 默认域名。</div>
</div>
<div class="form-group">
<label>协议</label>
<select name="oss[https]">
<option value="1" <?= $configs['oss']['https'] === '1' ? 'selected' : '' ?>>HTTPS</option>
<option value="0" <?= $configs['oss']['https'] === '0' ? 'selected' : '' ?>>HTTP</option>
</select>
</div>
</div>
<!-- ========== 腾讯云 COS ========== -->
<div class="panel <?= $current === 'cos' ? 'active' : '' ?>" data-panel="cos">
<div class="panel-title">腾讯云 COS 配置</div>
<div class="form-group">
<label>鉴权方式</label>
<select name="cos[auth_mode]" class="auth-mode" data-driver="cos">
<option value="ak" <?= $configs['cos']['auth_mode'] === 'ak' ? 'selected' : '' ?>>SecretId / SecretKey(显式填写密钥)</option>
<option value="cvm_role" <?= $configs['cos']['auth_mode'] === 'cvm_role' ? 'selected' : '' ?>>CVM CAM 角色(免密钥,实例元数据自动获取)</option>
</select>
<div class="hint">服务器在腾讯云 CVM 上并已绑定 CAM 角色时,选第二项可完全不填密钥。</div>
</div>
<div class="ak-fields" data-driver="cos">
<div class="form-group">
<label>SecretId</label>
<input type="text" name="cos[secret_id]" value="<?= h($configs['cos']['secret_id']) ?>"
placeholder="AKID..." autocomplete="off">
</div>
<div class="form-group">
<label>SecretKey</label>
<input type="password" name="cos[secret_key]" value=""
placeholder="<?= $configs['cos']['secret_key'] !== '' ? '已保存(' . h(maskSecret($configs['cos']['secret_key'])) . '),留空则不修改' : '请输入 SecretKey' ?>"
autocomplete="new-password">
<?php if ($configs['cos']['secret_key'] !== ''): ?>
<label class="chk"><input type="checkbox" name="cos[clear_secrets]" value="1"> 清除已保存的密钥</label>
<?php endif; ?>
</div>
</div>
<div class="role-fields" data-driver="cos" style="display:none;">
<div class="form-group">
<label>CAM 角色名称(可留空自动发现)</label>
<input type="text" name="cos[cam_role_name]" value="<?= h($configs['cos']['cam_role_name']) ?>"
placeholder="留空则自动读取实例绑定的角色">
</div>
</div>
<div class="form-group">
<label>存储桶名称(含 APPID)</label>
<input type="text" name="cos[bucket]" value="<?= h($configs['cos']['bucket']) ?>"
placeholder="例如 movielog-1250000000">
<div class="hint">COS 的桶名必须带 APPID 后缀。</div>
</div>
<div class="form-group">
<label>地域 Region</label>
<input type="text" name="cos[region]" value="<?= h($configs['cos']['region']) ?>"
placeholder="例如 ap-beijing">
<div class="hint">形如 <code>ap-地域</code>,在 COS 控制台桶的"概览"中可查。</div>
</div>
<div class="form-group">
<label>对象前缀(可选)</label>
<input type="text" name="cos[prefix]" value="<?= h($configs['cos']['prefix']) ?>"
placeholder="例如 movielog">
<div class="hint">填写后图片会存到桶内该目录下,键变为 <code>前缀/uploads/posters/xxx.jpg</code>;
同一个桶存放多个站点时用它区分。留空则直接放在桶根目录。</div>
</div>
<div class="form-group">
<label>自定义域名 / CDN(可选)</label>
<input type="text" name="cos[custom_domain]" value="<?= h($configs['cos']['custom_domain']) ?>"
placeholder="例如 img.example.com">
</div>
<div class="form-group">
<label>协议</label>
<select name="cos[https]">
<option value="1" <?= $configs['cos']['https'] === '1' ? 'selected' : '' ?>>HTTPS</option>
<option value="0" <?= $configs['cos']['https'] === '0' ? 'selected' : '' ?>>HTTP</option>
</select>
</div>
</div>
<!-- ========== 七牛云 ========== -->
<div class="panel <?= $current === 'qiniu' ? 'active' : '' ?>" data-panel="qiniu">
<div class="panel-title">七牛云 Kodo 配置</div>
<div class="form-group">
<label>AccessKey</label>
<input type="text" name="qiniu[access_key]" value="<?= h($configs['qiniu']['access_key']) ?>"
autocomplete="off">
</div>
<div class="form-group">
<label>SecretKey</label>
<input type="password" name="qiniu[secret_key]" value=""
placeholder="<?= $configs['qiniu']['secret_key'] !== '' ? '已保存(' . h(maskSecret($configs['qiniu']['secret_key'])) . '),留空则不修改' : '请输入 SecretKey' ?>"
autocomplete="new-password">
<?php if ($configs['qiniu']['secret_key'] !== ''): ?>
<label class="chk"><input type="checkbox" name="qiniu[clear_secrets]" value="1"> 清除已保存的密钥</label>
<?php endif; ?>
</div>
<div class="form-group">
<label>空间名称 Bucket</label>
<input type="text" name="qiniu[bucket]" value="<?= h($configs['qiniu']['bucket']) ?>">
</div>
<div class="form-group">
<label>存储区域</label>
<select name="qiniu[region]">
<?php
$regions = ['z0' => '华东 z0', 'z1' => '华北 z1', 'z2' => '华南 z2', 'na0' => '北美 na0', 'as0' => '亚太 as0'];
foreach ($regions as $rv => $rl): ?>
<option value="<?= h($rv) ?>" <?= $configs['qiniu']['region'] === $rv ? 'selected' : '' ?>><?= h($rl) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>绑定域名(必填)</label>
<input type="text" name="qiniu[custom_domain]" value="<?= h($configs['qiniu']['custom_domain']) ?>"
placeholder="例如 cdn.example.com">
<div class="hint">七牛云的对象必须通过绑定域名访问,未绑定域名则图片无法显示。</div>
</div>
<div class="form-group">
<label>对象前缀(可选)</label>
<input type="text" name="qiniu[prefix]" value="<?= h($configs['qiniu']['prefix']) ?>"
placeholder="例如 movielog">
<div class="hint">填写后图片会存到空间内该目录下,键变为 <code>前缀/uploads/posters/xxx.jpg</code>;
同一个空间存放多个站点时用它区分。留空则直接放在空间根目录。</div>
</div>
<div class="form-group">
<label>协议</label>
<select name="qiniu[https]">
<option value="1" <?= $configs['qiniu']['https'] === '1' ? 'selected' : '' ?>>HTTPS</option>
<option value="0" <?= $configs['qiniu']['https'] === '0' ? 'selected' : '' ?>>HTTP</option>
</select>
</div>
</div>
<div class="row-flex" style="margin-top:22px;">
<button class="btn btn-primary" type="submit">保存设置</button>
<button class="btn btn-outline" type="button" id="btnTest">测试连接</button>
<span id="testResult" class="hint" style="margin:0;"></span>
</div>
</form>
</div>
<div class="card">
<h2>当前状态</h2>
<div class="status-grid">
<div class="k">生效方式</div>
<div class="v">
<?= h($drivers[$current] ?? $current) ?>
<?php if ($current !== 'local'): ?>
<?php if ($remoteReady): ?>
<span class="badge badge-ok">配置完整</span>
<?php else: ?>
<span class="badge badge-warn">配置不完整,暂时按本地存储处理</span>
<?php endif; ?>
<?php else: ?>
<span class="badge badge-off">本地存储</span>
<?php endif; ?>
</div>
<div class="k">图片访问方式</div>
<div class="v">
<?php if ($accessModeNow === 'proxy'): ?>
<span class="badge badge-ok">服务端代理</span>
<span class="hint" style="margin-left:8px;">经 media.php 鉴权读取,桶可设为私有</span>
<?php else: ?>
<span class="badge badge-off">直连存储</span>
<span class="hint" style="margin-left:8px;">图片 URL 直接指向存储</span>
<?php endif; ?>
</div>
<?php if ($accessModeNow === 'proxy' && $ossInternalInfo !== null && $ossInternalInfo['endpoint'] !== ''): ?>
<div class="k">服务端读取通道</div>
<div class="v">
<?php if ($ossInternalInfo['ok']): ?>
<span class="badge badge-ok">内网</span>
<?php else: ?>
<span class="badge badge-off">公网(已跳过内网)</span>
<?php endif; ?>
<span class="hint mono" style="margin-left:8px;"><?= h($ossInternalInfo['endpoint']) ?></span>
<?php if ($ossInternalInfo['reason'] !== ''): ?>
<span class="hint" style="margin-left:8px;"><?= h($ossInternalInfo['reason']) ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="k">本地图片</div>
<div class="v"><?= $localStat['count'] ?> 张,占用 <?= h(formatBytes($localStat['bytes'])) ?></div>
<div class="k">数据目录</div>
<div class="v mono"><?= h(str_replace(ROOT_DIR . '/', '', UPLOAD_DIR)) ?></div>
<?php if ($current !== 'local'): ?>
<div class="k">对象前缀</div>
<div class="v">
<?php $curPrefix = trim((string)($configs[$current]['prefix'] ?? ''), '/'); ?>
<?php if ($curPrefix !== ''): ?>
<span class="mono"><?= h($curPrefix) ?>/</span>
<?php else: ?>
<span class="hint" style="margin:0;">未设置,图片直接放在桶根目录</span>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="k">凭据加密</div>
<div class="v">
<?php if (!settingsEncryptionAvailable()): ?>
<span class="badge badge-warn">加密不可用</span>
<span class="hint" style="margin-left:8px;">
未启用 openssl 或 db/ 目录不可写,云厂商密钥将以明文保存
</span>
<?php elseif (settingsDecryptFailed()): ?>
<span class="badge badge-warn">解密失败</span>
<span class="hint" style="margin-left:8px;">
密钥文件 db/.settings.key 丢失或损坏,请在各云厂商表单中重新填写密钥
</span>
<?php else: ?>
<span class="badge badge-ok">已加密</span>
<span class="hint" style="margin-left:8px;">
<?= h(settingsCipherLabel()) ?>,密钥单独存放于 db/.settings.key(不在数据库内)
</span>
<?php endif; ?>
</div>
<?php if ($current === 'oss' && $configs['oss']['auth_mode'] === 'ecs_role'): ?>
<div class="k">ECS 角色</div>
<div class="v">
<button class="btn btn-outline btn-sm" type="button" id="btnDetectCloud">探测云主机身份</button>
<span id="detectResult" class="hint" style="margin-left:8px;"></span>
</div>
<?php elseif ($current === 'cos' && $configs['cos']['auth_mode'] === 'cvm_role'): ?>
<div class="k">CVM 角色</div>
<div class="v">
<button class="btn btn-outline btn-sm" type="button" id="btnDetectCloud">探测云主机身份</button>
<span id="detectResult" class="hint" style="margin-left:8px;"></span>
</div>
<?php endif; ?>
</div>
</div>
<div class="card">
<h2>把已有图片迁移到云端</h2>
<div class="sub">
迁移会把 <code>uploads/</code> 目录下的图片逐个上传到当前对象存储,并<strong>按内容指纹重命名</strong>
(<code>&lt;md5前12位&gt;_&lt;随机6位&gt;.扩展名</code>),同时自动改写数据库里对应的图片引用,
因此已有记录不会裂图。已经是本内容指纹名的文件会跳过改名,重复迁移是安全的。<br>
执行顺序固定为<strong>先上传 → 再改库 → 最后才删本地</strong>:任一步失败都会停下并保留本地文件,
加上"本地优先"渲染,页面不会出现裂图。云端若已存在旧名对象,会保留不删(不影响使用)。
</div>
<?php if (!$remoteReady): ?>
<div class="alert alert-info">
当前未启用对象存储(或配置不完整),迁移功能暂不可用。请先选择云端存储并保存。
<?php if (storageIsRemote()): ?>(提示:当前驱动配置不完整,无法上传)<?php endif; ?>
</div>
<?php else: ?>
<div class="row-flex">
<button class="btn btn-outline btn-sm" type="button" id="btnStat">统计待迁移</button>
<button class="btn btn-primary btn-sm" type="button" id="btnMigrate">开始迁移</button>
<label class="chk" style="margin:0;">
<input type="checkbox" id="delLocal" checked> 迁移后删除本地文件(真正释放磁盘)
</label>
</div>
<div class="progress"><i id="bar"></i></div>
<div class="hint" id="migrateMsg">尚未开始。</div>
<div class="hint mono" id="migrateErrors" style="max-height:140px;overflow:auto;"></div>
<?php endif; ?>
</div>
</div>
<script>
(function () {
var API = 'storage_api.php';
// ---------- 驱动切换 ----------
function syncPanels() {
var picked = document.querySelector('input[name=storage_driver]:checked');
var driver = picked ? picked.value : 'local';
document.querySelectorAll('.driver-opt').forEach(function (el) {
el.classList.toggle('active', el.dataset.driver === driver);
});
document.querySelectorAll('.panel').forEach(function (el) {
el.classList.toggle('active', el.dataset.panel === driver);
});
}
document.querySelectorAll('input[name=storage_driver]').forEach(function (r) {
r.addEventListener('change', syncPanels);
});
// ---------- 鉴权方式切换 ----------
function syncAuthMode(driver) {
var sel = document.querySelector('.auth-mode[data-driver="' + driver + '"]');
if (!sel) return;
var isRole = sel.value === 'ecs_role' || sel.value === 'cvm_role';
var ak = document.querySelector('.ak-fields[data-driver="' + driver + '"]');
var role = document.querySelector('.role-fields[data-driver="' + driver + '"]');
if (ak) ak.style.display = isRole ? 'none' : '';
if (role) role.style.display = isRole ? '' : 'none';
}
document.querySelectorAll('.auth-mode').forEach(function (sel) {
sel.addEventListener('change', function () { syncAuthMode(sel.dataset.driver); });
syncAuthMode(sel.dataset.driver);
});
// ---------- 测试连接 ----------
var btnTest = document.getElementById('btnTest');
if (btnTest) {
btnTest.addEventListener('click', function () {
var out = document.getElementById('testResult');
var picked = document.querySelector('input[name=storage_driver]:checked');
var driver = picked ? picked.value : 'local';
if (driver === 'local') {
out.style.color = 'var(--text-secondary)';
out.textContent = '本地存储无需测试。';
return;
}
btnTest.disabled = true;
out.style.color = 'var(--text-secondary)';
out.textContent = '正在测试,请稍候…';
// 直接把当前表单(含未保存的修改)发去测试
var fd = new FormData(document.getElementById('storageForm'));
fd.append('action', 'test');
fd.append('driver', driver);
fetch(API, { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (j) {
out.style.color = j.ok ? '#1e7e46' : '#a93226';
out.textContent = (j.ok ? '✔ ' : '✘ ') + (j.message || j.error || '');
})
.catch(function (e) {
out.style.color = '#a93226';
out.textContent = '✘ 请求失败:' + e;
})
.finally(function () { btnTest.disabled = false; });
});
}
// ---------- 探测云主机 ----------
var btnDetect = document.getElementById('btnDetectCloud');
if (btnDetect) {
btnDetect.addEventListener('click', function () {
var out = document.getElementById('detectResult');
btnDetect.disabled = true;
out.textContent = '探测中…';
fetch(API + '?action=detect_cloud')
.then(function (r) { return r.json(); })
.then(function (j) {
out.textContent = j.message || j.error || '';
})
.catch(function (e) { out.textContent = '探测失败:' + e; })
.finally(function () { btnDetect.disabled = false; });
});
}
// ---------- 迁移 ----------
var btnStat = document.getElementById('btnStat');
var btnMigrate = document.getElementById('btnMigrate');
var bar = document.getElementById('bar');
var msg = document.getElementById('migrateMsg');
var errBox = document.getElementById('migrateErrors');
var delLocal = document.getElementById('delLocal');
if (btnStat) {
btnStat.addEventListener('click', function () {
btnStat.disabled = true;
msg.textContent = '统计中…';
fetch(API + '?action=stat')
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.ok) { msg.textContent = '✘ ' + (j.error || '统计失败'); return; }
msg.textContent = '本地共有 ' + j.local_total + ' 张图片,占用 '
+ j.local_bytes_h + '。迁移会把它们上传到云端;'
+ '勾选"迁移后删除本地文件"才会真正释放磁盘空间。';
})
.catch(function (e) { msg.textContent = '✘ ' + e; })
.finally(function () { btnStat.disabled = false; });
});
}
if (btnMigrate) {
var running = false;
btnMigrate.addEventListener('click', function () {
if (running) return;
running = true;
btnMigrate.disabled = true;
btnStat.disabled = true;
errBox.textContent = '';
bar.style.width = '0%';
var total = 0, done = 0, migrated = 0, renamed = 0, failed = 0, cursor = '';
var errors = [];
function step() {
var fd = new FormData();
fd.append('action', 'migrate');
fd.append('cursor', cursor);
fd.append('limit', '10');
fd.append('delete_local', delLocal.checked ? '1' : '0');
fetch(API, { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.ok) {
errors.push('批次失败:' + (j.error || '未知错误'));
finish();
return;
}
total = j.total;
migrated += j.migrated;
renamed += (j.renamed || 0);
failed += j.failed;
if (j.errors && j.errors.length) errors = errors.concat(j.errors);
done += j.processed || 0;
if (total > 0) {
bar.style.width = Math.min(100, Math.round(done * 100 / total)) + '%';
}
msg.textContent = '进度 ' + done + '/' + total
+ ',成功 ' + migrated + '(改名 ' + renamed + '),失败 ' + failed + '…';
errBox.textContent = errors.join('\n');
cursor = j.cursor;
if (j.has_more) {
step();
} else {
finish();
}
})
.catch(function (e) {
errors.push('请求异常:' + e);
finish();
});
}
function finish() {
running = false;
btnMigrate.disabled = false;
btnStat.disabled = false;
bar.style.width = '100%';
msg.textContent = '迁移结束:共处理 ' + done + ' 张,成功 ' + migrated
+ '(其中按指纹改名 ' + renamed + ' 张),失败 ' + failed + '。';
errBox.textContent = errors.join('\n');
}
step();
});
}
})();
</script>
</body>
</html>