系统设置里多一块「🎨 我的界面主题」,点卡片即时预览、保存后全站生效,不用重新登录。 - lib/theme.php(新增):主题注册表 ticket/indigo/forest/wine/midnight, 分别是「复古票根(暖橙,默认)/ 靛蓝 / 松林 / 绛红 / 暗夜(深色)」。 currentThemeKey() 以 session 为准,session 里没有就从 admins.theme 补读一次 —— 升级前就已经登录着的会话也能立刻用上自己账号存的主题。库里是脏值时回落默认主题。 - css/themes.css(新增):5 套调色板,全部以 [data-theme="<key>"] 规则块给出, 每套 16 个变量一个不少。**必须排在 css/style.css 之后加载**:两者优先级相同 (都是 0,1,0),靠后出现者胜。另加 --primary-rgb("R, G, B" 三元组), 页面里的淡色底/光晕一律 rgba(var(--primary-rgb), α),所以加一套主题只需动 CSS, 页面与 JS 一行都不用改;每套还要声明 color-scheme,否则暗色主题下原生控件 (日期选择器、下拉、滚动条)仍是刺眼的白底。 - css/style.css::root 保留默认主题那一份作兜底;导航底色改走 --nav-bg, 滚动条改走变量,按钮光晕改走 --primary-rgb。 - lib/db.php / lib/auth.php:admins 增列 theme(幂等 ALTER,老库自动补,空值 = 默认主题); setAdminTheme() 校验 key、写库、同步 session。 - settings.php:主题卡片区。每张预览卡自己套一层 data-theme,用的就是那套主题真实的 变量,因此「预览所见」与「换上去之后」必然一致,不必再抄一份色值;另有一小段 JS 在点选时改 <html data-theme> 做即时预览(没脚本时选中后提交同样生效)。 - 14 个页面:<html> 上输出 data-theme,并在 style.css 之后引入 themes.css; 内联样式里写死的橙色 rgba(230,126,34,…) 全部改走 rgba(var(--primary-rgb),…); 影院/观影人色板选中态的描边由 #333 改为 var(--text-primary)(暗色主题下才看得见)。 - annual_report.php:报告本身仍是深色「海报」版式,但点缀色跟随主题; Chart.js 画在 canvas 上读不到 CSS 变量,改为运行时从 <html> 的计算样式取色。 顺手修的两个坑: 1. 注释里**不能**出现 PHP 起止标签。PHP 的结束标签在 // 注释里同样会关闭 PHP 模式, 之后若再出现短标签就会被真的执行 —— lib/theme.php 的用法说明里写了完整标签示例, 导致该文件被 require 时直接 Fatal error、整站白屏,而 php -l 查不出来(只看语法)。 现在部署脚本里加了一道静态断言专门挡这个。 2. themesCssLink() 的返回值末尾补一个换行:PHP 会吃掉结束标签后面那一个换行, 不补的话渲染结果里这一行会和下一行黏成一行。 回归:theme_e2e 111/111(含老库自动补列、脏值回落、5 个选项渲染、切换后落库+落 session+ 立刻生效、跨账号隔离、非法 key 与注入被拒、themes.css 完整性、:root 与 ticket 一致、 无残留写死主色、无悬空 CSS 变量);reviews_e2e 84/84、fetch_movie 76/76、storage 196/196。 A/B 48 个响应:把「新增 head 行 / data-theme 属性 / 橙色→变量」三类等价替换归一化、 并把设置页新增区块整块删掉之后,**其余部分逐字节一致,零残留差异**。 线上 PHP 7.4.30 / 8.1.7 双版本冒烟全通过(迁移、5 套主题切换、跨账号隔离均验证, 写测试后已把 admin #1 的主题原样还原、其它账号一字未动)。
961 lines
53 KiB
PHP
961 lines
53 KiB
PHP
<?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 = '已清除错误提示';
|
||
}
|
||
|
||
// ---------- 切换界面主题(个人偏好,只影响当前账号)----------
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_theme') {
|
||
$themeKey = (string)($_POST['theme'] ?? '');
|
||
if (!isValidTheme($themeKey)) {
|
||
$error = '没有这套主题,请从下面重新选一个。';
|
||
} elseif (setAdminTheme((int)$admin['id'], $themeKey)) {
|
||
// setAdminTheme 已经把 session 一起更新了,所以本次请求往下渲染时就已经是新主题
|
||
$message = '界面主题已切换为「' . themeInfo($themeKey)['name'] . '」。';
|
||
} else {
|
||
$error = '主题保存失败,请稍后再试。';
|
||
}
|
||
}
|
||
|
||
$themes = themeCatalog();
|
||
$curThemeKey = currentThemeKey();
|
||
|
||
$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"<?= themeAttr() ?>>
|
||
<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">
|
||
<?= themesCssLink() ?>
|
||
<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(var(--primary-rgb),.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: rgba(var(--primary-rgb), .07); }
|
||
.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(var(--primary-rgb),.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; }
|
||
|
||
/* ===== 主题选择卡片 =====
|
||
每张预览卡片自己带一个 data-theme,用的就是那套主题真实的那组变量,
|
||
所以缩略图里的颜色与换上之后的页面必然一致(不必在这里再抄一遍色值)。
|
||
卡片内部一律用 var(--xxx) 取色 → 暗色主题的卡片自然也是深底浅字。 */
|
||
.theme-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(178px, 1fr)); gap: 14px; }
|
||
.theme-opt { display: block; margin: 0; cursor: pointer; position: relative; }
|
||
/* 单选圈本身隐藏,但保留在文档流里且可聚焦(键盘 Tab + 方向键照常可用) */
|
||
.theme-opt > input {
|
||
position: absolute; width: 1px; height: 1px; opacity: 0;
|
||
margin: 0; pointer-events: none;
|
||
}
|
||
.theme-card {
|
||
display: block; border: 2px solid var(--border-color); border-radius: 12px;
|
||
overflow: hidden; background: var(--bg-primary); transition: var(--transition);
|
||
}
|
||
.theme-opt:hover .theme-card { border-color: var(--primary-light); transform: translateY(-2px); }
|
||
/* 选中的那张:描边用「这张卡片自己的」主色,一眼能认出选的是哪套 */
|
||
.theme-opt > input:checked + .theme-card {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 4px rgba(var(--primary-rgb), .14);
|
||
}
|
||
.theme-opt > input:focus-visible + .theme-card {
|
||
outline: 2px solid var(--primary); outline-offset: 2px;
|
||
}
|
||
/* 缩略图:一条主色横条 + 两行灰字 + 一个主色按钮,够看出这套配色的性格 */
|
||
.theme-stage { display: block; padding: 11px 12px 12px; background: var(--bg-primary); }
|
||
.ts-bar {
|
||
display: block; height: 7px; border-radius: 4px; margin-bottom: 9px;
|
||
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
||
}
|
||
.ts-line {
|
||
display: block; height: 5px; border-radius: 3px; margin-bottom: 5px;
|
||
background: var(--text-secondary); opacity: .35;
|
||
}
|
||
.ts-line.ts-short { width: 58%; }
|
||
.ts-btn {
|
||
display: block; width: 46px; height: 14px; border-radius: 7px; margin-top: 10px;
|
||
background: var(--primary); box-shadow: 0 3px 10px rgba(var(--primary-rgb), .4);
|
||
}
|
||
.theme-meta { display: block; padding: 10px 12px 12px; background: var(--card-bg); }
|
||
.theme-name {
|
||
display: flex; align-items: center; gap: 7px;
|
||
font-size: 14px; font-weight: 600; color: var(--text-primary);
|
||
}
|
||
.theme-cur {
|
||
padding: 1px 7px; border-radius: 20px; font-size: 11px; font-weight: 600;
|
||
color: #fff; background: var(--primary);
|
||
}
|
||
.theme-desc { display: block; margin-top: 3px; font-size: 12px; line-height: 1.5; color: var(--text-secondary); }
|
||
</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">
|
||
挑一套配色,只作用于<strong>你自己的账号</strong>——别人登录看到的还是他们各自选的那套。<br>
|
||
想先看看效果就点一下主题卡片,整页会立刻换色(只是预览,按「保存主题」才真正记住)。
|
||
按钮、链接、卡片边框、评分徽标与年度报告的图表颜色都会一起换。
|
||
</div>
|
||
|
||
<form method="post" id="themeForm">
|
||
<input type="hidden" name="action" value="save_theme">
|
||
<div class="theme-grid" id="themeGrid">
|
||
<?php foreach ($themes as $tkey => $t): ?>
|
||
<label class="theme-opt">
|
||
<input type="radio" name="theme" value="<?= h($tkey) ?>"
|
||
<?= $tkey === $curThemeKey ? 'checked' : '' ?>>
|
||
<!-- 预览卡片自己套一层 data-theme,用的就是该主题真实的那组变量,
|
||
所以「预览所见」与「换上去之后」必然一致,不必在 PHP 里再抄一份色值。 -->
|
||
<span class="theme-card" data-theme="<?= h($tkey) ?>">
|
||
<span class="theme-stage">
|
||
<span class="ts-bar"></span>
|
||
<span class="ts-line"></span>
|
||
<span class="ts-line ts-short"></span>
|
||
<span class="ts-btn"></span>
|
||
</span>
|
||
<span class="theme-meta">
|
||
<span class="theme-name"><?= h($t['name']) ?><?php
|
||
if ($tkey === $curThemeKey): ?><span class="theme-cur">当前</span><?php
|
||
endif; ?></span>
|
||
<span class="theme-desc"><?= h($t['desc']) ?></span>
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<div class="row-flex" style="margin-top: 18px;">
|
||
<button class="btn btn-primary" type="submit">保存主题</button>
|
||
<span class="hint" style="margin: 0;">保存后立刻生效,全站页面都用这套配色;不用重新登录。</span>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<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-<地域>-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><md5前12位>_<随机6位>.扩展名</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 () {
|
||
// ---------- 主题即时预览 ----------
|
||
// 点一下卡片就把 <html data-theme> 换成那套主题 —— 整页(含导航、卡片、按钮、
|
||
// 表单控件)立刻换色,所见即保存后的样子。这只是预览,真正记住要靠「保存主题」;
|
||
// 没脚本也能用:选中单选后提交同样生效。
|
||
var grid = document.getElementById('themeGrid');
|
||
if (grid) {
|
||
grid.querySelectorAll('input[name=theme]').forEach(function (r) {
|
||
r.addEventListener('change', function () {
|
||
if (r.checked) document.documentElement.setAttribute('data-theme', r.value);
|
||
});
|
||
});
|
||
}
|
||
|
||
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>
|