diff --git a/app/.htaccess b/app/.htaccess
new file mode 100644
index 0000000..f26b2a7
--- /dev/null
+++ b/app/.htaccess
@@ -0,0 +1,9 @@
+# app/ 里的文件只会被 PHP include,绝不允许被 Web 直接请求。
+# 一旦能被直接请求,缺上下文的执行会 Fatal,并把服务器绝对路径打到页面上(信息泄露)。
+
+ Require all denied
+
+
+ Order allow,deny
+ Deny from all
+
diff --git a/app/Account.php b/app/Account.php
new file mode 100644
index 0000000..d988c27
--- /dev/null
+++ b/app/Account.php
@@ -0,0 +1,70 @@
+prepare('SELECT * FROM accounts WHERE person_id = ?');
+ $st->execute([$personId]);
+ $row = $st->fetch();
+ return $row ?: null;
+ }
+
+ public static function create(int $personId, string $password, string $role = 'member'): int
+ {
+ $st = Database::get()->prepare(
+ 'INSERT INTO accounts (person_id, password_hash, role) VALUES (?, ?, ?)'
+ );
+ $st->execute([$personId, password_hash($password, PASSWORD_DEFAULT), $role]);
+ return (int)Database::get()->lastInsertId();
+ }
+
+ public static function setPassword(int $accountId, string $password): void
+ {
+ Database::get()->prepare('UPDATE accounts SET password_hash = ? WHERE id = ?')
+ ->execute([password_hash($password, PASSWORD_DEFAULT), $accountId]);
+ }
+
+ public static function setRole(int $accountId, string $role): void
+ {
+ Database::get()->prepare('UPDATE accounts SET role = ? WHERE id = ?')
+ ->execute([$role, $accountId]);
+ }
+
+ public static function setStatus(int $accountId, string $status): void
+ {
+ Database::get()->prepare('UPDATE accounts SET status = ? WHERE id = ?')
+ ->execute([$status, $accountId]);
+ }
+
+ /** 管理员不能被停用/降权 —— 否则全家人都会被锁在系统外面 */
+ public static function adminCount(): int
+ {
+ $st = Database::get()->prepare(
+ "SELECT COUNT(*) FROM accounts a
+ JOIN persons p ON p.id = a.person_id
+ WHERE a.role = 'admin' AND a.status = 'active'"
+ );
+ $st->execute();
+ return (int)$st->fetchColumn();
+ }
+
+ public static function validatePassword(string $password): array
+ {
+ $err = [];
+ if (strlen($password) < 8) {
+ $err[] = '密码至少 8 位';
+ }
+ if (!preg_match('/[0-9]/', $password) || !preg_match('/[A-Za-z]/', $password)) {
+ $err[] = '密码要同时包含字母和数字';
+ }
+ return $err;
+ }
+}
diff --git a/app/Auth.php b/app/Auth.php
new file mode 100644
index 0000000..4124b6e
--- /dev/null
+++ b/app/Auth.php
@@ -0,0 +1,130 @@
+ '没有权限',
+ 'message' => '这一页只有家庭管理员可以操作。',
+ ], 'home');
+ exit;
+ }
+ }
+
+ /**
+ * 手机号 + 密码登录。
+ * 返回 true 表示成功并已写入会话。
+ */
+ public static function login(string $phone, string $password): bool
+ {
+ // 简单节流:5 次失败后 60 秒内不再尝试。
+ // 防爆破的关键不在判定多严,而在于「尝试次数有成本」。
+ $fail = $_SESSION['login_fail'] ?? ['n' => 0, 't' => 0];
+ if ($fail['n'] >= 5 && (time() - $fail['t']) < 60) {
+ usleep(500000);
+ return false;
+ }
+
+ $hash = Crypto::hash($phone);
+ $stmt = Database::get()->prepare(
+ 'SELECT a.id AS account_id, a.password_hash, a.role, a.status,
+ p.id AS person_id, p.family_id, p.name, p.avatar
+ FROM accounts a
+ JOIN persons p ON p.id = a.person_id
+ WHERE p.phone_hash = ? LIMIT 1'
+ );
+ $stmt->execute([$hash]);
+ $row = $stmt->fetch();
+
+ if (!$row || $row['status'] !== 'active'
+ || !password_verify($password, (string)$row['password_hash'])) {
+ $_SESSION['login_fail'] = ['n' => $fail['n'] + 1, 't' => time()];
+ usleep(300000);
+ return false;
+ }
+
+ unset($_SESSION['login_fail']);
+ session_regenerate_id(true);
+ $_SESSION['account_id'] = (int)$row['account_id'];
+ $_SESSION['person_id'] = (int)$row['person_id'];
+ $_SESSION['family_id'] = (int)$row['family_id'];
+ $_SESSION['name'] = $row['name'];
+ $_SESSION['role'] = $row['role'];
+ $_SESSION['avatar'] = $row['avatar'];
+
+ Database::get()->prepare('UPDATE accounts SET last_login_at = NOW() WHERE id = ?')
+ ->execute([$row['account_id']]);
+
+ return true;
+ }
+
+ public static function logout(): void
+ {
+ $_SESSION = [];
+ if (ini_get('session.use_cookies')) {
+ $p = session_get_cookie_params();
+ setcookie(session_name(), '', time() - 42000,
+ $p['path'], $p['domain'], $p['secure'], $p['httponly']);
+ }
+ session_destroy();
+ }
+
+ /** 头像改动后刷新会话里的缓存 */
+ public static function refreshAvatar(string $avatar): void
+ {
+ $_SESSION['avatar'] = $avatar;
+ }
+}
diff --git a/app/Crypto.php b/app/Crypto.php
new file mode 100644
index 0000000..277dd73
--- /dev/null
+++ b/app/Crypto.php
@@ -0,0 +1,100 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ ]);
+ return self::$pdo;
+ }
+
+ /** 表是否存在(安装判定用,不依赖异常) */
+ public static function hasTables(): bool
+ {
+ try {
+ $st = self::get()->prepare(
+ 'SELECT COUNT(*) FROM information_schema.tables
+ WHERE table_schema = DATABASE() AND table_name IN
+ (\'families\',\'persons\',\'accounts\',\'settings\')'
+ );
+ $st->execute();
+ return (int)$st->fetchColumn() === 4;
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+}
diff --git a/app/Family.php b/app/Family.php
new file mode 100644
index 0000000..ef26e60
--- /dev/null
+++ b/app/Family.php
@@ -0,0 +1,41 @@
+prepare('SELECT * FROM families WHERE id = ?');
+ $st->execute([$id]);
+ $row = $st->fetch();
+ return $row ?: null;
+ }
+
+ public static function create(string $name, string $motto = ''): int
+ {
+ $st = Database::get()->prepare('INSERT INTO families (name, motto) VALUES (?, ?)');
+ $st->execute([$name, $motto !== '' ? $motto : null]);
+ return (int)Database::get()->lastInsertId();
+ }
+
+ public static function update(int $id, string $name, string $motto = ''): void
+ {
+ $st = Database::get()->prepare('UPDATE families SET name = ?, motto = ? WHERE id = ?');
+ $st->execute([$name, $motto !== '' ? $motto : null, $id]);
+ }
+
+ public static function countMembers(int $familyId): int
+ {
+ $st = Database::get()->prepare(
+ "SELECT COUNT(*) FROM persons WHERE family_id = ? AND kind = 'member'"
+ );
+ $st->execute([$familyId]);
+ return (int)$st->fetchColumn();
+ }
+}
diff --git a/app/Person.php b/app/Person.php
new file mode 100644
index 0000000..d369ed6
--- /dev/null
+++ b/app/Person.php
@@ -0,0 +1,189 @@
+prepare($sql);
+ $st->execute($args);
+ return $st->fetchAll();
+ }
+
+ public static function find(int $id): ?array
+ {
+ $st = Database::get()->prepare('SELECT * FROM persons WHERE id = ?');
+ $st->execute([$id]);
+ $row = $st->fetch();
+ return $row ?: null;
+ }
+
+ /** 带账号信息的详情(详情页用) */
+ public static function findWithAccount(int $id): ?array
+ {
+ $st = Database::get()->prepare(
+ 'SELECT p.*, a.id AS account_id, a.role AS account_role,
+ a.status AS account_status, a.last_login_at
+ FROM persons p LEFT JOIN accounts a ON a.person_id = p.id
+ WHERE p.id = ?'
+ );
+ $st->execute([$id]);
+ $row = $st->fetch();
+ return $row ?: null;
+ }
+
+ public static function findByPhoneHash(string $hash): ?array
+ {
+ $st = Database::get()->prepare('SELECT * FROM persons WHERE phone_hash = ? LIMIT 1');
+ $st->execute([$hash]);
+ $row = $st->fetch();
+ return $row ?: null;
+ }
+
+ /**
+ * 新增。手机号是敏感字段,进来就加密 + 哈希。
+ * @return int 新 id
+ */
+ public static function create(int $familyId, array $d): int
+ {
+ $phone = $d['phone'] ?? '';
+ $sql = 'INSERT INTO persons
+ (family_id, kind, name, nickname, gender, birthday, avatar,
+ phone_hash, phone_encrypted, id_card_encrypted, email, address, note, sort_order)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
+ $st = Database::get()->prepare($sql);
+ $st->execute([
+ $familyId,
+ $d['kind'] ?? 'member',
+ $d['name'],
+ $d['nickname'] !== '' ? $d['nickname'] : null,
+ $d['gender'] !== '' ? $d['gender'] : null,
+ $d['birthday'] !== '' ? $d['birthday'] : null,
+ $d['avatar'] !== '' ? $d['avatar'] : null,
+ $phone !== '' ? Crypto::hash($phone) : null,
+ $phone !== '' ? Crypto::encrypt($phone) : null,
+ ($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
+ $d['email'] !== '' ? $d['email'] : null,
+ $d['address'] !== '' ? $d['address'] : null,
+ $d['note'] !== '' ? $d['note'] : null,
+ (int)($d['sort_order'] ?? 1000),
+ ]);
+ return (int)Database::get()->lastInsertId();
+ }
+
+ public static function update(int $id, array $d): void
+ {
+ $phone = $d['phone'] ?? '';
+ $sql = 'UPDATE persons SET
+ name = ?, nickname = ?, gender = ?, birthday = ?, avatar = ?,
+ phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?,
+ email = ?, address = ?, note = ?, sort_order = ?
+ WHERE id = ?';
+ Database::get()->prepare($sql)->execute([
+ $d['name'],
+ $d['nickname'] !== '' ? $d['nickname'] : null,
+ $d['gender'] !== '' ? $d['gender'] : null,
+ $d['birthday'] !== '' ? $d['birthday'] : null,
+ $d['avatar'] !== '' ? $d['avatar'] : null,
+ $phone !== '' ? Crypto::hash($phone) : null,
+ $phone !== '' ? Crypto::encrypt($phone) : null,
+ ($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
+ $d['email'] !== '' ? $d['email'] : null,
+ $d['address'] !== '' ? $d['address'] : null,
+ $d['note'] !== '' ? $d['note'] : null,
+ (int)($d['sort_order'] ?? 1000),
+ $id,
+ ]);
+ }
+
+ /** 只改头像(上传后单独调用,避免整条记录被表单覆盖) */
+ public static function setAvatar(int $id, ?string $avatar): void
+ {
+ Database::get()->prepare('UPDATE persons SET avatar = ? WHERE id = ?')
+ ->execute([$avatar, $id]);
+ }
+
+ public static function delete(int $id): void
+ {
+ Database::get()->prepare('DELETE FROM persons WHERE id = ?')->execute([$id]);
+ }
+
+ /**
+ * 最近要过生日的人(首页用)。
+ * 直接算「今天是今年的第几天」与生日的差值,跨年自动处理。
+ */
+ public static function upcomingBirthdays(int $familyId, int $withinDays = 60): array
+ {
+ $st = Database::get()->prepare(
+ "SELECT id, name, avatar, birthday FROM persons
+ WHERE family_id = ? AND kind = 'member' AND birthday IS NOT NULL
+ ORDER BY DAYOFYEAR(birthday)"
+ );
+ $st->execute([$familyId]);
+ $rows = $st->fetchAll();
+
+ $out = [];
+ foreach ($rows as $r) {
+ $days = days_until_birthday($r['birthday']);
+ if ($days !== null && $days <= $withinDays) {
+ $r['days_left'] = $days;
+ $out[] = $r;
+ }
+ }
+ usort($out, function ($a, $b) {
+ return $a['days_left'] <=> $b['days_left'];
+ });
+ return $out;
+ }
+
+ /** 校验表单,返回错误数组(空数组表示通过) */
+ public static function validate(array $d): array
+ {
+ $err = [];
+ if (trim((string)($d['name'] ?? '')) === '') {
+ $err['name'] = '姓名不能为空';
+ }
+ $phone = trim((string)($d['phone'] ?? ''));
+ if ($phone !== '' && !preg_match('/^\+?[0-9\-]{6,20}$/', $phone)) {
+ $err['phone'] = '手机号格式看起来不对';
+ }
+ $email = trim((string)($d['email'] ?? ''));
+ if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ $err['email'] = '邮箱格式看起来不对';
+ }
+ $b = trim((string)($d['birthday'] ?? ''));
+ if ($b !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $b)) {
+ $err['birthday'] = '生日请按 年-月-日 填写';
+ }
+ // 身份证号只做很松的校验:家庭场景里要不要存它,本来就值得三思
+ $idc = trim((string)($d['id_card'] ?? ''));
+ if ($idc !== '' && !preg_match('/^[0-9Xx]{15,18}$/', $idc)) {
+ $err['id_card'] = '身份证号应为 15 或 18 位';
+ }
+ return $err;
+ }
+}
diff --git a/app/Schema.php b/app/Schema.php
new file mode 100644
index 0000000..463cec4
--- /dev/null
+++ b/app/Schema.php
@@ -0,0 +1,81 @@
+prepare('SELECT v FROM settings WHERE family_id = ? AND k = ?');
+ $st->execute([$familyId, $key]);
+ $v = $st->fetchColumn();
+ return $v === false ? $default : $v;
+ }
+
+ public static function set(int $familyId, string $key, string $value): void
+ {
+ $st = Database::get()->prepare(
+ 'INSERT INTO settings (family_id, k, v) VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE v = VALUES(v)'
+ );
+ $st->execute([$familyId, $key, $value]);
+ }
+}
diff --git a/app/Upload.php b/app/Upload.php
new file mode 100644
index 0000000..42c0191
--- /dev/null
+++ b/app/Upload.php
@@ -0,0 +1,105 @@
+ false, 'error' => '上传参数不对'];
+ }
+ switch ($file['error']) {
+ case UPLOAD_ERR_OK:
+ break;
+ case UPLOAD_ERR_NO_FILE:
+ return ['ok' => false, 'error' => ''];
+ case UPLOAD_ERR_INI_SIZE:
+ case UPLOAD_ERR_FORM_SIZE:
+ return ['ok' => false, 'error' => '图片太大了(上限 5MB)'];
+ default:
+ return ['ok' => false, 'error' => '上传失败(错误码 ' . (int)$file['error'] . ')'];
+ }
+
+ if ($file['size'] > self::MAX_BYTES) {
+ return ['ok' => false, 'error' => '图片太大了(上限 5MB)'];
+ }
+
+ $finfo = new \finfo(FILEINFO_MIME_TYPE);
+ $mime = $finfo->file($file['tmp_name']);
+ $allow = ['image/jpeg' => 'imagecreatefromjpeg', 'image/png' => 'imagecreatefrompng',
+ 'image/gif' => 'imagecreatefromgif', 'image/webp' => 'imagecreatefromwebp'];
+ if (!isset($allow[$mime])) {
+ return ['ok' => false, 'error' => '只支持 JPG / PNG / GIF / WebP 图片'];
+ }
+
+ $src = $allow[$mime]($file['tmp_name']);
+ if (!$src) {
+ return ['ok' => false, 'error' => '图片读不出来'];
+ }
+
+ $w = imagesx($src);
+ $h = imagesy($src);
+ // 居中裁成正方形
+ $side = min($w, $h);
+ $sx = (int)(($w - $side) / 2);
+ $sy = (int)(($h - $side) / 2);
+
+ $canvas = imagecreatetruecolor(self::SIZE, self::SIZE);
+ // PNG/GIF 的透明区转成纸白,不然头像会是黑底
+ $bg = imagecolorallocate($canvas, 253, 251, 245);
+ imagefilledrectangle($canvas, 0, 0, self::SIZE, self::SIZE, $bg);
+ imagecopyresampled($canvas, $src, 0, 0, $sx, $sy, self::SIZE, self::SIZE, $side, $side);
+
+ $dir = FB_UPLOADS . '/avatars';
+ if (!is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+ $name = bin2hex(random_bytes(12)) . '.jpg';
+ if (!imagejpeg($canvas, $dir . '/' . $name, 88)) {
+ imagedestroy($canvas);
+ imagedestroy($src);
+ return ['ok' => false, 'error' => '保存失败'];
+ }
+ imagedestroy($canvas);
+ imagedestroy($src);
+
+ return ['ok' => true, 'file' => 'avatars/' . $name];
+ }
+
+ /** 头像访问地址(无头像时回退到内置剪影,保证永远不出现破图) */
+ public static function avatarUrl(?string $rel): string
+ {
+ if ($rel && is_file(FB_UPLOADS . '/' . $rel)) {
+ $p = FB_UPLOADS . '/' . $rel;
+ // 注意:filemtime() 返回 int,本文件开了 strict_types,直接喂给 md5() 会 TypeError
+ return 'uploads/' . $rel . '?v=' . substr(md5(filemtime($p) . filesize($p)), 0, 8);
+ }
+ return asset('/assets/avatar-default.svg');
+ }
+
+ public static function remove(?string $rel): void
+ {
+ if (!$rel) {
+ return;
+ }
+ $p = FB_UPLOADS . '/' . $rel;
+ if (is_file($p)) {
+ @unlink($p);
+ }
+ }
+}
diff --git a/app/View.php b/app/View.php
new file mode 100644
index 0000000..4922bba
--- /dev/null
+++ b/app/View.php
@@ -0,0 +1,42 @@
+
家书 · 未配置'
+ . ''
+ . '
还差一步:没有找到 config.php
'
+ . '
'
+ . '请复制同目录下的 config.example.php 为 config.php,'
+ . '填好数据库连接与 app_key 后再访问。
'
+ . '
';
+ exit;
+}
+
+$GLOBALS['config'] = require $configFile;
+
+// ---------------- 错误报告(线上不显示错误,避免泄露路径与库信息)----------------
+$debug = !empty($GLOBALS['config']['debug']);
+error_reporting(E_ALL);
+ini_set('display_errors', $debug ? '1' : '0');
+ini_set('log_errors', '1');
+
+// ---------------- 时区:日期是家书的核心数据,一开始就必须明确 ----------------
+date_default_timezone_set($GLOBALS['config']['timezone'] ?? 'Asia/Shanghai');
+
+// ---------------- 会话 ----------------
+$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
+ || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
+
+session_name('fbsid');
+session_set_cookie_params([
+ 'lifetime' => 0,
+ 'path' => '/',
+ 'secure' => $isHttps,
+ 'httponly' => true,
+ 'samesite' => 'Lax',
+]);
+if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
+}
+
+require FB_APP . '/helpers.php';
diff --git a/app/helpers.php b/app/helpers.php
new file mode 100644
index 0000000..8ca04dd
--- /dev/null
+++ b/app/helpers.php
@@ -0,0 +1,139 @@
+ $route], $query);
+ return 'index.php?' . http_build_query($q);
+}
+
+function redirect(string $route = 'home', array $query = []): void
+{
+ header('Location: ' . url($route, $query));
+ exit;
+}
+
+function is_post(): bool
+{
+ return ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
+}
+
+function fb_config(string $key = null, $default = null)
+{
+ $c = $GLOBALS['config'];
+ if ($key === null) {
+ return $c;
+ }
+ return array_key_exists($key, $c) ? $c[$key] : $default;
+}
+
+// ---------------- CSRF ----------------
+function csrf_token(): string
+{
+ if (empty($_SESSION['_csrf'])) {
+ $_SESSION['_csrf'] = bin2hex(random_bytes(16));
+ }
+ return $_SESSION['_csrf'];
+}
+
+function csrf_field(): string
+{
+ return '';
+}
+
+/**
+ * 校验 CSRF。失败直接结束请求 —— 不做这种假返回:
+ * 「返回 false 让调用层自己处理」的写法,迟早有人忘了判断。
+ */
+function csrf_check(): void
+{
+ $ok = isset($_POST['_csrf'], $_SESSION['_csrf'])
+ && hash_equals($_SESSION['_csrf'], (string)$_POST['_csrf']);
+ if (!$ok) {
+ http_response_code(419);
+ echo '页面已过期,请刷新后重试(CSRF 校验失败)';
+ exit;
+ }
+}
+
+// ---------------- 一次性提示 ----------------
+function flash(string $type, string $message): void
+{
+ $_SESSION['_flash'][] = ['type' => $type, 'message' => $message];
+}
+
+function take_flash(): array
+{
+ $f = $_SESSION['_flash'] ?? [];
+ unset($_SESSION['_flash']);
+ return $f;
+}
+
+/** 表单回填(校验失败时保留用户输入) */
+function old(string $key, $default = '')
+{
+ return $_SESSION['_old'][$key] ?? $default;
+}
+
+function set_old(array $data): void
+{
+ $_SESSION['_old'] = $data;
+}
+
+function clear_old(): void
+{
+ unset($_SESSION['_old']);
+}
+
+/** 生日相关:返回年龄与距下次生日天数 */
+function age_from(string $birthday): ?int
+{
+ if ($birthday === '' || $birthday === null) {
+ return null;
+ }
+ try {
+ $b = new DateTimeImmutable($birthday);
+ } catch (Exception $ex) {
+ return null;
+ }
+ return (int)$b->diff(new DateTimeImmutable('today'))->y;
+}
+
+function days_until_birthday(string $birthday): ?int
+{
+ if (!$birthday) {
+ return null;
+ }
+ try {
+ $b = new DateTimeImmutable($birthday);
+ $now = new DateTimeImmutable('today');
+ $next = new DateTimeImmutable($now->format('Y') . '-' . $b->format('m-d'));
+ if ($next < $now) {
+ $next = $next->modify('+1 year');
+ }
+ return (int)$now->diff($next)->days;
+ } catch (Exception $ex) {
+ return null;
+ }
+}
diff --git a/assets/avatar-default.svg b/assets/avatar-default.svg
new file mode 100644
index 0000000..a656d87
--- /dev/null
+++ b/assets/avatar-default.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/css/app.css b/assets/css/app.css
new file mode 100644
index 0000000..a4b8c02
--- /dev/null
+++ b/assets/css/app.css
@@ -0,0 +1,269 @@
+/* ============================================================
+ 家书 FamilyBook · 界面样式
+ 配色取自图标:宣纸米 / 砖红封面 / 深褐书脊 / 纸白
+ 原则:扁平、留白充足、字重克制(正文 400,标题 500-600)
+ ============================================================ */
+
+:root {
+ --paper: #F7F3EA;
+ --surface: #FFFFFF;
+ --ink: #2C231D;
+ --muted: #7E6B5A;
+ --faint: #A8988A;
+ --brand: #A84A28;
+ --brand-2: #C96F45;
+ --brand-ink:#6E3820;
+ --green: #6F9E5C;
+ --red: #C0533F;
+ --line: rgba(44, 35, 29, .10);
+ --line-2: rgba(44, 35, 29, .06);
+ --radius: 16px;
+ --radius-s: 10px;
+ --shadow: 0 1px 2px rgba(44, 35, 29, .05);
+ --shadow-l: 0 4px 16px rgba(44, 35, 29, .08);
+}
+
+* { box-sizing: border-box; }
+
+html, body { height: 100%; }
+
+body {
+ margin: 0;
+ background: var(--paper);
+ color: var(--ink);
+ font: 15px/1.7 -apple-system, BlinkMacSystemFont, "PingFang SC",
+ "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
+ -webkit-font-smoothing: antialiased;
+}
+
+a { color: var(--brand); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* ---------------- 顶栏 ---------------- */
+.topbar {
+ position: sticky; top: 0; z-index: 20;
+ background: var(--surface);
+ border-bottom: 1px solid var(--line);
+}
+.topbar-inner {
+ max-width: 1080px; margin: 0 auto; padding: 0 24px;
+ height: 60px; display: flex; align-items: center; gap: 24px;
+}
+.brand { display: flex; align-items: center; gap: 10px; color: var(--ink); }
+.brand:hover { text-decoration: none; }
+.brand img { width: 30px; height: 30px; border-radius: 8px; display: block; }
+.brand span { font-size: 16px; font-weight: 600; letter-spacing: .5px; }
+
+.nav { display: flex; gap: 4px; margin-left: 8px; }
+.nav a {
+ padding: 6px 12px; border-radius: 999px; color: var(--muted);
+ font-size: 14px;
+}
+.nav a:hover { background: var(--line-2); text-decoration: none; }
+.nav a.on { background: var(--brand); color: #fff; }
+
+.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
+.who { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 14px; }
+.who img { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; }
+.link-quiet { color: var(--muted); font-size: 14px; }
+.link-quiet:hover { color: var(--brand); }
+
+/* 顶栏里的文字一律不许换行 —— 一换行就是「首/页」「名/册」竖排,很难看 */
+.nav a, .topbar-right a, .who-name { white-space: nowrap; }
+
+/* ---------------- 布局 ---------------- */
+.container { max-width: 1080px; margin: 0 auto; padding: 28px 24px 64px; }
+.container-narrow { max-width: 560px; margin: 0 auto; padding: 28px 24px 64px; }
+
+.page-head { display: flex; align-items: flex-end; gap: 16px; margin-bottom: 20px; }
+.page-head h1 { font-size: 22px; font-weight: 600; margin: 0; }
+.page-head .sub { color: var(--muted); font-size: 14px; }
+.page-head .actions { margin-left: auto; display: flex; gap: 8px; }
+
+.section { margin-top: 28px; }
+.section-title {
+ display: flex; align-items: center; gap: 10px;
+ font-size: 15px; font-weight: 600; margin: 0 0 12px;
+}
+.section-title .hint { font-weight: 400; color: var(--muted); font-size: 13px; }
+
+/* ---------------- 卡片 ---------------- */
+.card {
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+}
+.card-pad { padding: 20px; }
+
+/* 首页头图 */
+.hero {
+ background: var(--surface); border: 1px solid var(--line);
+ border-radius: var(--radius); box-shadow: var(--shadow);
+ padding: 26px 28px;
+ display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
+}
+.hero-logo { width: 64px; height: 64px; border-radius: 16px; flex: 0 0 auto; }
+.hero h1 { font-size: 24px; font-weight: 600; margin: 0 0 4px; }
+.hero .motto { color: var(--muted); margin: 0; font-size: 14px; }
+.hero-stats { margin-left: auto; display: flex; gap: 28px; }
+.stat { text-align: center; }
+.stat b { display: block; font-size: 24px; font-weight: 600; line-height: 1.2; }
+.stat span { font-size: 12px; color: var(--muted); }
+
+/* ---------------- 成员卡 ---------------- */
+.people-grid {
+ display: grid; gap: 16px; margin-top: 4px;
+ grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
+}
+.person {
+ display: block; color: var(--ink);
+ background: var(--surface); border: 1px solid var(--line);
+ border-radius: var(--radius); overflow: hidden;
+ box-shadow: var(--shadow);
+ transition: transform .15s ease, box-shadow .15s ease;
+}
+.person:hover { transform: translateY(-2px); box-shadow: var(--shadow-l); text-decoration: none; }
+.person .ph {
+ background: #F1E7D6; aspect-ratio: 1 / 1; overflow: hidden;
+}
+.person .ph img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.person .pb { padding: 12px 14px 14px; }
+.person .pname { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 6px; }
+.person .pmeta { color: var(--muted); font-size: 13px; margin-top: 2px; }
+
+.badge {
+ display: inline-block; padding: 1px 8px; border-radius: 999px;
+ font-size: 11px; font-weight: 500; line-height: 1.7;
+ background: var(--line-2); color: var(--muted);
+}
+.badge-admin { background: #F6E3D7; color: var(--brand-ink); }
+.badge-open { background: #E6F0DC; color: #4A7226; }
+
+/* 生日条 */
+.bdays { display: flex; gap: 12px; flex-wrap: wrap; }
+.bday {
+ display: flex; align-items: center; gap: 10px;
+ background: var(--surface); border: 1px solid var(--line);
+ border-radius: 999px; padding: 6px 16px 6px 6px; box-shadow: var(--shadow);
+}
+.bday img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; }
+.bday b { font-weight: 500; }
+.bday .d { color: var(--brand); font-size: 13px; }
+
+/* ---------------- 表单 ---------------- */
+.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
+.field { display: flex; flex-direction: column; gap: 6px; }
+.field.full { grid-column: 1 / -1; }
+.field label { font-size: 13px; color: var(--muted); font-weight: 500; }
+.field .tip { font-size: 12px; color: var(--muted); }
+.input, .select, textarea.input {
+ width: 100%; padding: 10px 12px; font: inherit; font-size: 14px;
+ color: var(--ink); background: #fff;
+ border: 1px solid var(--line); border-radius: var(--radius-s);
+ transition: border-color .15s ease, box-shadow .15s ease;
+}
+.input:focus, .select:focus, textarea.input:focus {
+ outline: none; border-color: var(--brand);
+ box-shadow: 0 0 0 3px rgba(168, 74, 40, .12);
+}
+textarea.input { min-height: 76px; resize: vertical; }
+.err { color: var(--red); font-size: 12px; }
+
+.btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
+ padding: 9px 16px; font: inherit; font-size: 14px; font-weight: 500;
+ border-radius: var(--radius-s); border: 1px solid transparent; cursor: pointer;
+ background: var(--brand); color: #fff; transition: background .15s ease;
+}
+.btn:hover { background: #944022; text-decoration: none; }
+.btn-ghost { background: #fff; color: var(--ink); border-color: var(--line); }
+.btn-ghost:hover { background: #FAF7F1; }
+.btn-danger { background: #fff; color: var(--red); border-color: rgba(192,83,63,.35); }
+.btn-danger:hover { background: #FDF1EE; }
+.btn-sm { padding: 5px 12px; font-size: 13px; }
+.btn-row { display: flex; gap: 10px; margin-top: 20px; }
+
+/* ---------------- 详情 ---------------- */
+.detail { display: grid; grid-template-columns: 260px 1fr; gap: 24px; }
+.detail-photo {
+ background: var(--surface); border: 1px solid var(--line);
+ border-radius: var(--radius); padding: 16px; text-align: center;
+ box-shadow: var(--shadow);
+}
+.detail-photo img {
+ width: 100%; aspect-ratio: 1/1; object-fit: cover;
+ border-radius: 12px; display: block;
+}
+.detail-photo .name { font-size: 18px; font-weight: 600; margin: 14px 0 4px; }
+.detail-photo .sub { color: var(--muted); font-size: 13px; }
+
+.kv { display: flex; flex-direction: column; }
+.kv-row {
+ display: grid; grid-template-columns: 96px 1fr; gap: 12px;
+ padding: 12px 0; border-bottom: 1px solid var(--line-2);
+}
+.kv-row:last-child { border-bottom: 0; }
+.kv-row dt { color: var(--muted); font-size: 13px; }
+.kv-row dd { margin: 0; font-size: 14px; word-break: break-all; }
+.mono { font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; letter-spacing: .5px; }
+
+/* ---------------- 提示 ---------------- */
+.flash { display: flex; flex-direction: column; gap: 8px; margin-bottom: 18px; }
+.flash-item {
+ padding: 10px 14px; border-radius: var(--radius-s); font-size: 14px;
+ background: #E6F0DC; color: #3D6B1F;
+}
+.flash-item.err { background: #F8DDDA; color: #A1341F; }
+
+.empty {
+ background: var(--surface); border: 1px dashed var(--line);
+ border-radius: var(--radius); padding: 40px 20px; text-align: center;
+ color: var(--muted);
+}
+.empty b { display: block; color: var(--ink); font-weight: 500; margin-bottom: 4px; }
+
+/* ---------------- 登录 / 安装 ---------------- */
+.auth-wrap {
+ min-height: 100vh; display: flex; align-items: center; justify-content: center;
+ padding: 40px 20px;
+}
+.auth-card {
+ width: 100%; max-width: 400px; background: var(--surface);
+ border: 1px solid var(--line); border-radius: 20px; box-shadow: var(--shadow-l);
+ padding: 32px;
+}
+.auth-card .logo { width: 56px; height: 56px; border-radius: 14px; margin-bottom: 14px; }
+.auth-card h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
+.auth-card .lead { color: var(--muted); font-size: 14px; margin: 0 0 24px; }
+.auth-card .btn { width: 100%; margin-top: 20px; }
+.auth-foot { margin-top: 18px; font-size: 13px; color: var(--muted); text-align: center; }
+
+/* ---------------- 其他 ---------------- */
+.muted { color: var(--muted); }
+.small { font-size: 13px; }
+.nowrap { white-space: nowrap; }
+.search { display: flex; gap: 8px; margin-bottom: 16px; }
+.search .input { max-width: 260px; }
+
+@media (max-width: 720px) {
+ /* 390px 宽度放不下整套顶栏:只留 图标 + 导航 + 头像 + 退出 */
+ .topbar-inner { gap: 8px; padding: 0 12px; height: 54px; }
+ .brand img { width: 26px; height: 26px; }
+ .nav { gap: 0; }
+ .nav a { padding: 6px 9px; font-size: 14px; }
+ .topbar-right { gap: 8px; }
+ .who-name, .who-pwd { display: none; } /* 名字与「改密码」收起,设置页里都有 */
+ .who img { width: 26px; height: 26px; }
+ .container, .container-narrow { padding: 20px 16px 48px; }
+ .form-grid { grid-template-columns: 1fr; }
+ .detail { grid-template-columns: 1fr; }
+ .hero { padding: 20px; gap: 14px; }
+ .hero h1 { font-size: 20px; }
+ .hero-stats { margin-left: 0; gap: 20px; }
+ .page-head { flex-wrap: wrap; align-items: center; }
+ .page-head .actions { margin-left: 0; width: 100%; }
+ .kv-row { grid-template-columns: 84px 1fr; }
+ .search { flex-wrap: wrap; }
+ .search .input { max-width: none; flex: 1 1 auto; }
+}
diff --git a/assets/js/app.js b/assets/js/app.js
new file mode 100644
index 0000000..8fbf267
--- /dev/null
+++ b/assets/js/app.js
@@ -0,0 +1,27 @@
+/* 家书 · 前端小助手(无框架,无构建步骤) */
+(function () {
+ 'use strict';
+
+ // 头像选好后即时预览,别等上传完才知道选错了
+ var fileInput = document.getElementById('avatar');
+ if (fileInput && window.FileReader) {
+ fileInput.addEventListener('change', function () {
+ var f = this.files && this.files[0];
+ if (!f) { return; }
+ var img = document.getElementById('avatar-preview');
+ if (!img) { return; }
+ var reader = new FileReader();
+ reader.onload = function (ev) { img.src = ev.target.result; };
+ reader.readAsDataURL(f);
+ });
+ }
+
+ // 头像加载失败时换回内置剪影 —— 保证任何时候都不会出现破图
+ Array.prototype.forEach.call(document.querySelectorAll('img'), function (img) {
+ img.addEventListener('error', function () {
+ if (img.dataset.fallbackDone) { return; }
+ img.dataset.fallbackDone = '1';
+ img.src = '/assets/avatar-default.svg';
+ });
+ });
+})();
diff --git a/index.php b/index.php
index 3297935..28c542e 100644
--- a/index.php
+++ b/index.php
@@ -1,141 +1,410 @@
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'] : '家书';
-?>
-
-
-
-
-= htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') ?> · 环境自检
-
-
-
-
-
-
-
-
-
, ENT_QUOTES, 'UTF-8') ?>)
-
-
= htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') ?> · 环境自检
-
-
-
FamilyBook —— 一本用岁月写成的家谱
-
-
- = $allOk ? '全部通过,可以开始盖功能了。' : '还有未通过项,处理后请刷新这一页。' ?>
-
-
-
- $c): ?>
- -
-
- = htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?>
- = htmlspecialchars((string)$c['val'], ENT_QUOTES, 'UTF-8') ?>
-
-
-
-
-
-
diff --git a/install.php b/install.php
new file mode 100644
index 0000000..9d93f68
--- /dev/null
+++ b/install.php
@@ -0,0 +1,97 @@
+ '已经装好了',
+ 'message' => '「家书」已经安装过。如果要重新安装,请先清空数据库里的 families / persons / accounts / settings 四张表。',
+ ]);
+ exit;
+}
+
+$dbName = $GLOBALS['config']['db']['name'];
+
+if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
+ csrf_check();
+
+ $familyName = trim((string)($_POST['family_name'] ?? ''));
+ $motto = trim((string)($_POST['motto'] ?? ''));
+ $name = trim((string)($_POST['name'] ?? ''));
+ $phone = trim((string)($_POST['phone'] ?? ''));
+ $password = (string)($_POST['password'] ?? '');
+
+ $errors = [];
+ if ($familyName === '') { $errors['_'] = '家庭名称不能为空'; }
+ if ($name === '') { $errors['name'] = '请填写你的姓名'; }
+ if (!preg_match('/^\+?[0-9\-]{6,20}$/', $phone)) {
+ $errors['phone'] = '手机号格式看起来不对';
+ }
+ $pwErr = Account::validatePassword($password);
+ if ($pwErr) { $errors['password'] = implode(';', $pwErr); }
+
+ if (!$errors) {
+ try {
+ foreach (Schema::statements() as $sql) {
+ Database::get()->exec($sql);
+ }
+ $familyId = Family::create($familyName, $motto);
+ $personId = Person::create($familyId, [
+ 'name' => $name,
+ 'nickname' => '',
+ 'gender' => '',
+ 'birthday' => '',
+ 'avatar' => '',
+ 'phone' => $phone,
+ 'id_card' => '',
+ 'email' => '',
+ 'address' => '',
+ 'note' => '',
+ 'sort_order'=> 1,
+ 'kind' => 'member',
+ ]);
+ Account::create($personId, $password, 'admin');
+
+ // 装完就删掉自己 —— 避免把安装入口长期暴露在公网
+ @unlink(__FILE__);
+
+ header('Location: index.php?r=auth/login');
+ exit;
+ } catch (\Throwable $ex) {
+ $errors['_'] = '安装失败:' . $ex->getMessage();
+ }
+ }
+
+ set_old($_POST);
+ View::renderBare('install', [
+ 'title' => '安装家书',
+ 'dbName' => $dbName,
+ 'errors' => $errors,
+ ]);
+ exit;
+}
+
+clear_old();
+View::renderBare('install', [
+ 'title' => '安装家书',
+ 'dbName' => $dbName,
+ 'errors' => [],
+]);
diff --git a/templates/.htaccess b/templates/.htaccess
new file mode 100644
index 0000000..b2ce905
--- /dev/null
+++ b/templates/.htaccess
@@ -0,0 +1,9 @@
+# templates/ 里的文件只会被 PHP include,绝不允许被 Web 直接请求。
+# 直接请求 layout.php / show.php 会因为缺少变量而 Fatal,并把服务器绝对路径暴露出去。
+
+ Require all denied
+
+
+ Order allow,deny
+ Deny from all
+
diff --git a/templates/auth/password.php b/templates/auth/password.php
new file mode 100644
index 0000000..09a4a7b
--- /dev/null
+++ b/templates/auth/password.php
@@ -0,0 +1,27 @@
+
+
修改密码
+ = e(\App\Auth::name()) ?>
+
+
+
diff --git a/templates/error.php b/templates/error.php
new file mode 100644
index 0000000..8ffde05
--- /dev/null
+++ b/templates/error.php
@@ -0,0 +1,5 @@
+
+
= e($title ?? '没有权限') ?>
+
= e($message ?? '你不能访问这一页。') ?>
+
返回首页
+
diff --git a/templates/home.php b/templates/home.php
new file mode 100644
index 0000000..f16282b
--- /dev/null
+++ b/templates/home.php
@@ -0,0 +1,84 @@
+
+
+
+
+
+
= e($family['name']) ?>
+
= e($family['motto'] ?: '一本用岁月写成的家谱') ?>
+
+
+
= (int)$memberCount ?>位家庭成员
+
= (int)$openCount ?>已开通登录
+
= count($birthdays) ?>近期生日
+
+
+
+
+
+ 接下来的日子 两个月内
+
+
+
+
) ?>)
+
+
= e($b['name']) ?>
+
+
+ 就是今天,生日快乐
+
+ 还有 = (int)$b['days_left'] ?> 天过生日
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 名册还是空的
+ 先把家里人加进来,之后他们就能出现在每一条记录里。
+
+
+
+
+
diff --git a/templates/install.php b/templates/install.php
new file mode 100644
index 0000000..048f9c0
--- /dev/null
+++ b/templates/install.php
@@ -0,0 +1,49 @@
+
+
+

+
安装「家书」
+
先建好你的家庭空间,再给家里人开通知。填完这一步就能用了。
+
+
+
+
+
+
+
+
diff --git a/templates/layout.php b/templates/layout.php
new file mode 100644
index 0000000..e61f5a6
--- /dev/null
+++ b/templates/layout.php
@@ -0,0 +1,63 @@
+ ['首页', 'home'],
+ 'people' => ['名册', 'people'],
+];
+if (\App\Auth::isAdmin()) {
+ $navItems['settings'] = ['设置', 'settings'];
+}
+$me = \App\Auth::check();
+?>
+
+
+
+
+
+= e($pageTitle) ?> · 家书
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= e($f['message']) ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/login.php b/templates/login.php
new file mode 100644
index 0000000..3d098fc
--- /dev/null
+++ b/templates/login.php
@@ -0,0 +1,29 @@
+
+
+

+
= e($familyName) ?>
+
用手机号登录你的家书。
+
+
+
+
+
+
+
+
+
diff --git a/templates/people/form.php b/templates/people/form.php
new file mode 100644
index 0000000..cdcd85d
--- /dev/null
+++ b/templates/people/form.php
@@ -0,0 +1,113 @@
+
+
+
+
= $isEdit ? '编辑成员' : '添加成员' ?>
+ = $isEdit ? e($person['name']) : '填姓名就行,其他都可以以后再补' ?>
+
+
+
diff --git a/templates/people/index.php b/templates/people/index.php
new file mode 100644
index 0000000..ae3197e
--- /dev/null
+++ b/templates/people/index.php
@@ -0,0 +1,46 @@
+
+
家庭名册
+
共 = count($people) ?> 人
+
+
+
+
+
+
+
+ = $q !== '' ? '没有匹配的人' : '名册还是空的' ?>
+ = $q !== '' ? '换个词试试。' : '把家里人加进来,之后观影、旅行的同行人就可以直接从名册里选。' ?>
+
+
+
+
diff --git a/templates/people/show.php b/templates/people/show.php
new file mode 100644
index 0000000..a53064a
--- /dev/null
+++ b/templates/people/show.php
@@ -0,0 +1,147 @@
+
+
+
+
= e($person['name']) ?>
+
+ 小名 = e($person['nickname']) ?>
+
+
+
+
编辑
+
+
+
+
+
+
+
+
+
+
+
+
+
- 姓名
- = e($person['name']) ?>
+
+
+ - 小名
- = e($person['nickname']) ?>
+
+
+
- 性别
+ - = $person['gender'] === 'm' ? '男' : ($person['gender'] === 'f' ? '女' : '未填写') ?>
+
+
+
- 生日
+ -
+
+ = e($person['birthday']) ?>
+ · = (int)$age ?> 岁
+
+ · 今天生日
+ · 还有 = (int)$d ?> 天
+
+ 未填写
+
+
+
+
+
- 手机号
+ - = e(\App\Crypto::maskPhone($person['phone_encrypted'] ?? '')) ?>
+
+
+
- 身份证号
+ - = e(\App\Crypto::maskIdCard($person['id_card_encrypted'] ?? '')) ?>
+
+
+ - 邮箱
- = e($person['email']) ?>
+
+
+ - 住址
- = e($person['address']) ?>
+
+
+ - 备注
- = nl2br(e($person['note'])) ?>
+
+
+
- 最近登录
+ - = !empty($person['last_login_at']) ? e($person['last_login_at']) : '从未登录' ?>
+
+
+
+
+
+
+
登录账号
+
+
+
+ 开通后,= e($person['name']) ?> 就能用手机号和密码登录家书了。
+
+
+
+
+ 账号已开通,登录名是 = e($person['name']) ?> 的手机号。
+
+
+
+
+
+
+ 这是你自己 —— 要改自己的权限,请让另一位管理员来操作。
+
+
+
+
+
+
+
diff --git a/templates/settings.php b/templates/settings.php
new file mode 100644
index 0000000..36b09b6
--- /dev/null
+++ b/templates/settings.php
@@ -0,0 +1,30 @@
+
+
+
+
运行环境 排错时看这一块
+
+ - PHP
- = e(PHP_VERSION) ?>
+ - 数据库
- = e($dbVersion) ?>
+ - 加密密钥
- = $keyOk ? '已配置' : '未配置' ?>
+
+
+ 手机号与身份证号都是加密后入库的。这个密钥一旦换掉,已有数据就再也解不开 ——
+ 请把它和数据库分开、各备份一份。
+
+
diff --git a/uploads/.htaccess b/uploads/.htaccess
new file mode 100644
index 0000000..fbb2bd3
--- /dev/null
+++ b/uploads/.htaccess
@@ -0,0 +1,14 @@
+# 上传目录里禁止执行任何脚本 —— 没有这条,上传的 .php 就能被直接访问执行。
+# 同时兼容 Apache 2.4(Require)与 2.2(Order)。
+
+
+ Require all denied
+
+
+ Order allow,deny
+ Deny from all
+
+
+
+# 关掉目录列表
+Options -Indexes