阶段 0 完整版:名册 + 账号 + 安装向导 + 设置(宣纸主题)
这一版把「家」的地基铺完: 功能 - 安装向导 install.php:建四张表 → 建家庭 → 建第一人(兼管理员),装完自删; 「是否已装」以数据库四张表为准,不以文件是否存在为准 - 家庭名册:成员增删改查、头像上传(居中裁方 512×512 转 JPEG、finfo 校验、 随机文件名)、生日自动算年龄与倒数天数 - 敏感信息加密存储:手机号双写(SHA-256 哈希用于登录 + AES-256-CBC 密文用于显示)、 身份证只存密文;页面上永远只显示脱敏后的 138****8000 / 前 6 后 4 - 账号体系:人与账号分离(孩子可入名册但无需密码)、开通/停用/角色、 防爆破(5 次失败锁 60 秒)、修改密码、最后一个管理员不可移除 - 设置页:家庭名称与座右铭、加密密钥状态 - 宣纸主题 UI:砖红取自图标,扁平、留白充足;移动端顶栏收紧不换行 安全 - 所有 POST 走 CSRF;输出全量 e() 转义;uploads/ 禁执行脚本 - app/ 与 templates/ 加 .htaccess 整体拒绝直连(直接请求模板会 Fatal 并 把服务器绝对路径打在页面上 —— 这是线上真实抓到过的问题) - 已安装的 install.php 返回 404;display_errors 线上关闭 工程 - 无框架无 Composer,query-string 路由(index.php?r=模块/动作),不依赖 rewrite - 静态资源带 mtime+size 版本号,改版后浏览器不会吃旧缓存 - 配套部署与冒烟(_tooling/,不入库):部署脚本容忍 tar 对非属主目录的 元数据回填失败;冒烟断言「状态码 + 正文无 PHP 错误 + </html> 收尾 + 无绝对路径泄露 + 断言总数下限」—— 只看状态码会把「200 但正文中间 Fatal」当成通过,这一版在线上真实抓到过一次
This commit is contained in:
@@ -1,141 +1,410 @@
|
||||
<?php
|
||||
/**
|
||||
* FamilyBook · 家书 —— 阶段 0 引导页
|
||||
* 家书 FamilyBook · 前端控制器
|
||||
*
|
||||
* 这一页不是首页。它的职责是:部署之后先看这里,确认
|
||||
* 「PHP 版本 / 扩展 / 配置文件 / 数据库连接」四件事都通了,再往上盖功能。
|
||||
* 正式首页做好之后会替换掉它。
|
||||
*
|
||||
* ⚠️ 线上请把 config.php 里的 debug 设为 false:
|
||||
* 连接报错时,详情里可能含数据库账号名,不该公开展示。
|
||||
* 路由走 query-string(index.php?r=模块/动作),不依赖 Apache 重写,
|
||||
* 换服务器、换 Nginx 都不用改配置。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
define('FB_ROOT', __DIR__);
|
||||
require __DIR__ . '/app/bootstrap.php';
|
||||
|
||||
$configFile = FB_ROOT . '/config.php';
|
||||
$hasConfig = is_file($configFile);
|
||||
$config = $hasConfig ? require $configFile : null;
|
||||
$debug = is_array($config) && !empty($config['debug']);
|
||||
use App\Account;
|
||||
use App\Auth;
|
||||
use App\Crypto;
|
||||
use App\Database;
|
||||
use App\Family;
|
||||
use App\Person;
|
||||
use App\Setting;
|
||||
use App\Upload;
|
||||
use App\View;
|
||||
|
||||
/**
|
||||
* 静态资源带版本号(mtime + size)。
|
||||
* 不带版本号的话,文件换了浏览器还在用旧缓存 —— 详见踩坑记录。
|
||||
*/
|
||||
function fb_asset(string $rel): string
|
||||
{
|
||||
$p = FB_ROOT . $rel;
|
||||
if (!is_file($p)) {
|
||||
return $rel;
|
||||
}
|
||||
return $rel . '?v=' . substr(md5(filemtime($p) . filesize($p)), 0, 8);
|
||||
if (!Database::hasTables()) {
|
||||
header('Location: install.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$checks = [];
|
||||
$r = isset($_GET['r']) ? trim((string)$_GET['r'], '/') : 'home';
|
||||
$r = $r === '' ? 'home' : $r;
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
|
||||
// 1. PHP 版本:以 PHP 7.4 为底线(线上同时存在 7.4 与 8.1 两套)
|
||||
$checks['PHP 版本'] = [
|
||||
'ok' => PHP_VERSION_ID >= 70400,
|
||||
'val' => PHP_VERSION . '(需 7.4 以上)',
|
||||
];
|
||||
|
||||
// 2. 必需扩展
|
||||
$needExt = ['pdo_mysql' => '数据库', 'mbstring' => '中文处理', 'openssl' => '敏感字段加密', 'json' => '配置与接口', 'gd' => '图片裁剪'];
|
||||
foreach ($needExt as $ext => $why) {
|
||||
$ok = extension_loaded($ext);
|
||||
$checks['扩展 ' . $ext] = ['ok' => $ok, 'val' => $ok ? '已加载 · ' . $why : '缺失 · ' . $why];
|
||||
$publicRoutes = ['auth/login'];
|
||||
if (!Auth::check() && !in_array($r, $publicRoutes, true)) {
|
||||
redirect('auth/login');
|
||||
}
|
||||
|
||||
// 3. 配置文件
|
||||
$checks['配置文件 config.php'] = [
|
||||
'ok' => $hasConfig,
|
||||
'val' => $hasConfig ? '已就位' : '未找到(请复制 config.example.php 后填写)',
|
||||
];
|
||||
switch ($r) {
|
||||
|
||||
// 4. 数据库连接(只有配置齐全才去连)
|
||||
if (is_array($config) && !empty($config['db'])) {
|
||||
$d = $config['db'];
|
||||
try {
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$d['host'], $d['port'], $d['name'], $d['charset']
|
||||
);
|
||||
$pdo = new PDO($dsn, $d['user'], $d['pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
// ==================== 登录 / 退出 ====================
|
||||
case 'auth/login':
|
||||
if (Auth::check()) {
|
||||
redirect('home');
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
csrf_check();
|
||||
$phone = trim((string)($_POST['phone'] ?? ''));
|
||||
$pass = (string)($_POST['password'] ?? '');
|
||||
if (Auth::login($phone, $pass)) {
|
||||
redirect('home');
|
||||
}
|
||||
View::renderBare('login', [
|
||||
'title' => '登录',
|
||||
'familyName' => fb_config('site_name', '家书'),
|
||||
'error' => '手机号或密码不对,或者账号还没开通/已被停用。',
|
||||
'phone' => $phone,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
View::renderBare('login', [
|
||||
'title' => '登录',
|
||||
'familyName' => fb_config('site_name', '家书'),
|
||||
]);
|
||||
$ver = $pdo->query('SELECT VERSION()')->fetchColumn();
|
||||
$checks['数据库连接'] = ['ok' => true, 'val' => 'MySQL ' . $ver . ' · 库 ' . $d['name']];
|
||||
} catch (Throwable $e) {
|
||||
$checks['数据库连接'] = [
|
||||
'ok' => false,
|
||||
'val' => $debug ? $e->getMessage() : '连接失败(把 config.php 的 debug 设为 true 可看详情)',
|
||||
break;
|
||||
|
||||
case 'auth/logout':
|
||||
if (isset($_GET['_csrf'], $_SESSION['_csrf'])
|
||||
&& hash_equals($_SESSION['_csrf'], (string)$_GET['_csrf'])) {
|
||||
Auth::logout();
|
||||
}
|
||||
redirect('auth/login');
|
||||
break;
|
||||
|
||||
case 'auth/password':
|
||||
Auth::requireLogin();
|
||||
if ($method === 'POST') {
|
||||
csrf_check();
|
||||
$old = (string)($_POST['old_password'] ?? '');
|
||||
$new = (string)($_POST['password'] ?? '');
|
||||
$new2 = (string)($_POST['password2'] ?? '');
|
||||
|
||||
$st = Database::get()->prepare('SELECT password_hash FROM accounts WHERE id = ?');
|
||||
$st->execute([Auth::id()]);
|
||||
$hash = (string)$st->fetchColumn();
|
||||
|
||||
$err = null;
|
||||
if (!password_verify($old, $hash)) {
|
||||
$err = '当前密码不对';
|
||||
} elseif ($new !== $new2) {
|
||||
$err = '两次输入的新密码不一致';
|
||||
} elseif (Account::validatePassword($new)) {
|
||||
$err = implode(';', Account::validatePassword($new));
|
||||
}
|
||||
if ($err) {
|
||||
View::render('auth/password', ['title' => '修改密码', 'error' => $err], 'home');
|
||||
exit;
|
||||
}
|
||||
Account::setPassword((int)Auth::id(), $new);
|
||||
flash('ok', '密码已更新,下次登录用新密码。');
|
||||
redirect('home');
|
||||
}
|
||||
View::render('auth/password', ['title' => '修改密码'], 'home');
|
||||
break;
|
||||
|
||||
// ==================== 首页 ====================
|
||||
case 'home':
|
||||
$familyId = (int)Auth::familyId();
|
||||
View::render('home', [
|
||||
'title' => '首页',
|
||||
'family' => Family::find($familyId),
|
||||
'people' => Person::all($familyId),
|
||||
'birthdays' => Person::upcomingBirthdays($familyId),
|
||||
], 'home');
|
||||
break;
|
||||
|
||||
// ==================== 名册 ====================
|
||||
case 'people':
|
||||
$q = trim((string)($_GET['q'] ?? ''));
|
||||
View::render('people/index', [
|
||||
'title' => '家庭名册',
|
||||
'people' => Person::all((int)Auth::familyId(), $q),
|
||||
'q' => $q,
|
||||
], 'people');
|
||||
break;
|
||||
|
||||
case 'people/new':
|
||||
Auth::requireAdmin();
|
||||
clear_old();
|
||||
View::render('people/form', [
|
||||
'title' => '添加成员',
|
||||
'person' => null,
|
||||
'errors' => [],
|
||||
], 'people');
|
||||
break;
|
||||
|
||||
case 'people/create':
|
||||
Auth::requireAdmin();
|
||||
csrf_check();
|
||||
$d = [
|
||||
'name' => trim((string)($_POST['name'] ?? '')),
|
||||
'nickname' => trim((string)($_POST['nickname'] ?? '')),
|
||||
'gender' => (string)($_POST['gender'] ?? ''),
|
||||
'birthday' => trim((string)($_POST['birthday'] ?? '')),
|
||||
'phone' => trim((string)($_POST['phone'] ?? '')),
|
||||
'id_card' => trim((string)($_POST['id_card'] ?? '')),
|
||||
'email' => trim((string)($_POST['email'] ?? '')),
|
||||
'address' => trim((string)($_POST['address'] ?? '')),
|
||||
'note' => trim((string)($_POST['note'] ?? '')),
|
||||
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
|
||||
'avatar' => '',
|
||||
'kind' => 'member',
|
||||
];
|
||||
}
|
||||
$errors = Person::validate($d);
|
||||
|
||||
$up = Upload::avatar($_FILES['avatar'] ?? []);
|
||||
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
||||
$errors['avatar'] = $up['error'];
|
||||
}
|
||||
if ($errors) {
|
||||
set_old($_POST);
|
||||
View::render('people/form', [
|
||||
'title' => '添加成员',
|
||||
'person' => null,
|
||||
'errors' => $errors,
|
||||
'phoneValue' => $d['phone'],
|
||||
'idCardValue'=> $d['id_card'],
|
||||
], 'people');
|
||||
exit;
|
||||
}
|
||||
if ($up['ok']) {
|
||||
$d['avatar'] = $up['file'];
|
||||
}
|
||||
try {
|
||||
$id = Person::create((int)Auth::familyId(), $d);
|
||||
} catch (\PDOException $ex) {
|
||||
set_old($_POST);
|
||||
View::render('people/form', [
|
||||
'title' => '添加成员',
|
||||
'person' => null,
|
||||
'errors' => ['phone' => '这个手机号已经在名册里了'],
|
||||
'phoneValue' => $d['phone'],
|
||||
'idCardValue'=> $d['id_card'],
|
||||
], 'people');
|
||||
exit;
|
||||
}
|
||||
clear_old();
|
||||
flash('ok', '已把「' . $d['name'] . '」加进名册。');
|
||||
redirect('people/show', ['id' => $id]);
|
||||
break;
|
||||
|
||||
case 'people/edit':
|
||||
Auth::requireAdmin();
|
||||
$p = Person::find((int)($_GET['id'] ?? 0));
|
||||
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
||||
flash('err', '找不到这个人。');
|
||||
redirect('people');
|
||||
}
|
||||
clear_old();
|
||||
View::render('people/form', [
|
||||
'title' => '编辑成员',
|
||||
'person' => $p,
|
||||
'errors' => [],
|
||||
'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']),
|
||||
'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']),
|
||||
], 'people');
|
||||
break;
|
||||
|
||||
case 'people/update':
|
||||
Auth::requireAdmin();
|
||||
csrf_check();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$p = Person::find($id);
|
||||
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
||||
flash('err', '找不到这个人。');
|
||||
redirect('people');
|
||||
}
|
||||
$d = [
|
||||
'name' => trim((string)($_POST['name'] ?? '')),
|
||||
'nickname' => trim((string)($_POST['nickname'] ?? '')),
|
||||
'gender' => (string)($_POST['gender'] ?? ''),
|
||||
'birthday' => trim((string)($_POST['birthday'] ?? '')),
|
||||
'phone' => trim((string)($_POST['phone'] ?? '')),
|
||||
'id_card' => trim((string)($_POST['id_card'] ?? '')),
|
||||
'email' => trim((string)($_POST['email'] ?? '')),
|
||||
'address' => trim((string)($_POST['address'] ?? '')),
|
||||
'note' => trim((string)($_POST['note'] ?? '')),
|
||||
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
|
||||
'avatar' => (string)$p['avatar'],
|
||||
];
|
||||
$errors = Person::validate($d);
|
||||
$up = Upload::avatar($_FILES['avatar'] ?? []);
|
||||
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
||||
$errors['avatar'] = $up['error'];
|
||||
}
|
||||
if ($errors) {
|
||||
set_old($_POST);
|
||||
View::render('people/form', [
|
||||
'title' => '编辑成员',
|
||||
'person' => $p,
|
||||
'errors' => $errors,
|
||||
'phoneValue' => $d['phone'],
|
||||
'idCardValue' => $d['id_card'],
|
||||
], 'people');
|
||||
exit;
|
||||
}
|
||||
if ($up['ok']) {
|
||||
Upload::remove((string)$p['avatar']);
|
||||
$d['avatar'] = $up['file'];
|
||||
}
|
||||
try {
|
||||
Person::update($id, $d);
|
||||
} catch (\PDOException $ex) {
|
||||
set_old($_POST);
|
||||
View::render('people/form', [
|
||||
'title' => '编辑成员',
|
||||
'person' => $p,
|
||||
'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'],
|
||||
'phoneValue' => $d['phone'],
|
||||
'idCardValue' => $d['id_card'],
|
||||
], 'people');
|
||||
exit;
|
||||
}
|
||||
if ((int)$p['id'] === (int)Auth::personId()) {
|
||||
Auth::refreshAvatar((string)$d['avatar']);
|
||||
}
|
||||
clear_old();
|
||||
flash('ok', '已保存。');
|
||||
redirect('people/show', ['id' => $id]);
|
||||
break;
|
||||
|
||||
case 'people/show':
|
||||
$p = Person::findWithAccount((int)($_GET['id'] ?? 0));
|
||||
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
||||
flash('err', '找不到这个人。');
|
||||
redirect('people');
|
||||
}
|
||||
View::render('people/show', [
|
||||
'title' => $p['name'],
|
||||
'person' => $p,
|
||||
], 'people');
|
||||
break;
|
||||
|
||||
case 'people/destroy':
|
||||
Auth::requireAdmin();
|
||||
csrf_check();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$p = Person::find($id);
|
||||
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) {
|
||||
if ((int)$p['id'] === (int)Auth::personId()) {
|
||||
flash('err', '不能把自己从名册里移除。');
|
||||
redirect('people/show', ['id' => $id]);
|
||||
}
|
||||
// 不让最后一个管理员消失:系统里没人能管理家庭就锁死了
|
||||
$acc = Account::findByPerson($id);
|
||||
if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
||||
flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。');
|
||||
redirect('people/show', ['id' => $id]);
|
||||
}
|
||||
Upload::remove((string)$p['avatar']);
|
||||
Person::delete($id);
|
||||
flash('ok', '已从名册移除「' . $p['name'] . '」。');
|
||||
}
|
||||
redirect('people');
|
||||
break;
|
||||
|
||||
// ==================== 账号 ====================
|
||||
case 'accounts/create':
|
||||
Auth::requireAdmin();
|
||||
csrf_check();
|
||||
$personId = (int)($_POST['person_id'] ?? 0);
|
||||
$p = Person::find($personId);
|
||||
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
||||
flash('err', '找不到这个人。');
|
||||
redirect('people');
|
||||
}
|
||||
if (Account::findByPerson($personId)) {
|
||||
flash('err', '这个人的账号已经开通过了。');
|
||||
redirect('people/show', ['id' => $personId]);
|
||||
}
|
||||
if (Crypto::decrypt((string)$p['phone_encrypted']) === '') {
|
||||
flash('err', '要先填手机号,才能开通登录。');
|
||||
redirect('people/show', ['id' => $personId]);
|
||||
}
|
||||
$pass = (string)($_POST['password'] ?? '');
|
||||
$errs = Account::validatePassword($pass);
|
||||
if ($errs) {
|
||||
flash('err', implode(';', $errs));
|
||||
redirect('people/show', ['id' => $personId]);
|
||||
}
|
||||
Account::create($personId, $pass, ($_POST['role'] ?? '') === 'admin' ? 'admin' : 'member');
|
||||
flash('ok', '账号已开通,用手机号就能登录。');
|
||||
redirect('people/show', ['id' => $personId]);
|
||||
break;
|
||||
|
||||
case 'accounts/role':
|
||||
Auth::requireAdmin();
|
||||
csrf_check();
|
||||
$personId = (int)($_POST['person_id'] ?? 0);
|
||||
$role = ($_POST['role'] ?? '') === 'admin' ? 'admin' : 'member';
|
||||
$p = Person::find($personId);
|
||||
$acc = Account::findByPerson($personId);
|
||||
if ($p && $acc) {
|
||||
if ($role === 'member' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
||||
flash('err', '至少要留一位管理员。');
|
||||
redirect('people/show', ['id' => $personId]);
|
||||
}
|
||||
Account::setRole((int)$acc['id'], $role);
|
||||
flash('ok', '权限已更新。');
|
||||
}
|
||||
redirect('people/show', ['id' => $personId]);
|
||||
break;
|
||||
|
||||
case 'accounts/status':
|
||||
Auth::requireAdmin();
|
||||
csrf_check();
|
||||
$personId = (int)($_POST['person_id'] ?? 0);
|
||||
$status = ($_POST['status'] ?? '') === 'active' ? 'active' : 'disabled';
|
||||
$acc = Account::findByPerson($personId);
|
||||
if ($acc) {
|
||||
if ((int)$acc['person_id'] === (int)Auth::personId()) {
|
||||
flash('err', '不能停用自己。');
|
||||
redirect('people/show', ['id' => $personId]);
|
||||
}
|
||||
if ($status === 'disabled' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
||||
flash('err', '至少要留一位管理员。');
|
||||
redirect('people/show', ['id' => $personId]);
|
||||
}
|
||||
Account::setStatus((int)$acc['id'], $status);
|
||||
flash('ok', $status === 'active' ? '账号已启用。' : '账号已停用。');
|
||||
}
|
||||
redirect('people/show', ['id' => $personId]);
|
||||
break;
|
||||
|
||||
// ==================== 设置 ====================
|
||||
case 'settings':
|
||||
Auth::requireAdmin();
|
||||
$dbVersion = '未知';
|
||||
try {
|
||||
$dbVersion = Database::get()->query('SELECT VERSION()')->fetchColumn();
|
||||
} catch (\Throwable $ex) {
|
||||
// 拿不到版本不影响页面
|
||||
}
|
||||
$rawKey = (string)fb_config('app_key', '');
|
||||
View::render('settings', [
|
||||
'title' => '设置',
|
||||
'family' => Family::find((int)Auth::familyId()),
|
||||
'dbVersion' => $dbVersion,
|
||||
'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0,
|
||||
], 'settings');
|
||||
break;
|
||||
|
||||
case 'settings/update':
|
||||
Auth::requireAdmin();
|
||||
csrf_check();
|
||||
Family::update(
|
||||
(int)Auth::familyId(),
|
||||
trim((string)($_POST['name'] ?? '家书')),
|
||||
trim((string)($_POST['motto'] ?? ''))
|
||||
);
|
||||
flash('ok', '已保存。');
|
||||
redirect('settings');
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
View::render('error', [
|
||||
'title' => '页面不存在',
|
||||
'message' => '找不到这一页。',
|
||||
], 'home');
|
||||
break;
|
||||
}
|
||||
|
||||
$allOk = true;
|
||||
foreach ($checks as $c) {
|
||||
$allOk = $allOk && $c['ok'];
|
||||
}
|
||||
$siteName = is_array($config) && !empty($config['site_name']) ? $config['site_name'] : '家书';
|
||||
?><!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') ?> · 环境自检</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<style>
|
||||
:root { --paper:#F2E9D8; --cover:#A84A28; --cover2:#C96F45; --ink:#6E3820; --white:#FDFBF5; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; padding:40px 20px; background:var(--paper); color:var(--ink);
|
||||
font:15px/1.7 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif; }
|
||||
.wrap { max-width:560px; margin:0 auto; }
|
||||
.head { display:flex; align-items:center; gap:14px; margin-bottom:6px; }
|
||||
.head img { width:64px; height:64px; border-radius:14px; }
|
||||
h1 { font-size:22px; margin:0; font-weight:600; }
|
||||
.sub { color:#8a6a52; font-size:13px; margin:0 0 24px 78px; }
|
||||
ul { list-style:none; margin:0; padding:0; background:var(--white);
|
||||
border-radius:12px; overflow:hidden; }
|
||||
li { display:flex; align-items:baseline; gap:10px; padding:12px 16px;
|
||||
border-bottom:1px solid rgba(110,56,32,.08); }
|
||||
li:last-child { border-bottom:0; }
|
||||
.dot { width:9px; height:9px; border-radius:50%; flex:0 0 auto; margin-top:8px; }
|
||||
.ok { background:#6f9e5c; } .bad { background:#c0533f; }
|
||||
.k { font-weight:600; flex:0 0 auto; }
|
||||
.v { color:#7a5a42; font-size:13px; word-break:break-all; }
|
||||
.banner { padding:10px 16px; border-radius:10px; margin-bottom:18px; font-size:14px; }
|
||||
.pass { background:#e6f0dc; color:#3d6b1f; }
|
||||
.fail { background:#f8ddda; color:#a1341f; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="head">
|
||||
<img src="<?= htmlspecialchars(fb_asset('/assets/icons/icon-192.png'), ENT_QUOTES, 'UTF-8') ?>" alt="">
|
||||
<div>
|
||||
<h1><?= htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') ?> · 环境自检</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p class="sub">FamilyBook —— 一本用岁月写成的家谱</p>
|
||||
|
||||
<div class="banner <?= $allOk ? 'pass' : 'fail' ?>">
|
||||
<?= $allOk ? '全部通过,可以开始盖功能了。' : '还有未通过项,处理后请刷新这一页。' ?>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
<?php foreach ($checks as $name => $c): ?>
|
||||
<li>
|
||||
<span class="dot <?= $c['ok'] ? 'ok' : 'bad' ?>"></span>
|
||||
<span class="k"><?= htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?></span>
|
||||
<span class="v"><?= htmlspecialchars((string)$c['val'], ENT_QUOTES, 'UTF-8') ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user