Files
MovieLog_Server/settings.php
T
laoyang ee256adc4b 界面主题:5 套经典配色,按账号记住
系统设置里多一块「🎨 我的界面主题」,点卡片即时预览、保存后全站生效,不用重新登录。

- 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 的主题原样还原、其它账号一字未动)。
2026-09-22 20:33:47 +08:00

961 lines
53 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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-&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 () {
// ---------- 主题即时预览 ----------
// 点一下卡片就把 <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>