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') ?> · 环境自检 - - - - - - -
-
- -
-

· 环境自检

-
-
-

FamilyBook —— 一本用岁月写成的家谱

- - - - -
- - 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 @@ +
+

修改密码

+ +
+ +
+ +
+
+ + + +
+
+ + +
+
+ + +
+
+
+ + 取消 +
+
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 @@ +
+

+

+ 返回首页 +
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 @@ + + +
+ +
+

+

+
+
+
位家庭成员
+
已开通登录
+
近期生日
+
+
+ + +
+

接下来的日子 两个月内

+
+ +
+ +
+ +
+ + 就是今天,生日快乐 + + 还有 天过生日 + +
+
+
+ +
+
+ + +
+
+

家庭成员

+ +
+ + +
+ 名册还是空的 + 先把家里人加进来,之后他们就能出现在每一条记录里。 +
+ +
+ + +
+
+
+
+ + 管理员 + + 已开通 + + 未开通 + + +
+
+
+ +
+ +
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 @@ +
+
+ +

安装「家书」

+

先建好你的家庭空间,再给家里人开通知。填完这一步就能用了。

+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + + +
+
+ + + 家里每个人都可以有自己的手机号和密码。 + +
+
+ + + 至少 8 位,且同时含字母和数字。 + +
+
+ +

+ 数据库: · 手机号与身份证号都会加密后入库 +

+
+
+
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) ?> · 家书 + + + + + + + + + +
+
+ + + 家书 + + +
+ + + + + 改密码 + 退出 +
+
+
+ + +
+ + +
+
+
+ + + + +
+ + + + 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 @@ +
+
+ +

+

用手机号登录你的家书。

+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+

忘记密码?让家里有管理员权限的人帮你重置。

+
+
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 @@ + + +
+

+ +
+ +
+ + + + + +
+
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + + 填了之后,首页会提醒还有几天过生日。 + +
+ +
+ + + 开通登录后用它登录;加密存储,列表里只显示部分。 + +
+ +
+ + + +
+ +
+ + + + 加密存储,页面上一律只显示前 6 后 4 位。 + 家庭场景里真正需要它的场合很少 —— 能不填就别填。 + + +
+ +
+ + +
+ +
+ + +
+ +
+ + + 会自动裁成正方形,上限 5MB。 +
+
+ + +
+ +
+ + + 小的排前面。 +
+ +
+ +
+ + 取消 +
+
+
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 @@ +
+

家庭名册

+ 共 人 +
+ + 添加成员 + +
+
+ + + + +
+ + +
+ +
+ + +
+
+
+
+ + 管理员 + + 已开通 + + 未开通 + + · +
+
+
+ +
+ 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 @@ + + +
+

+ + 小名 + +
+ + 编辑 +
+ + + +
+ +
+
+ +
+ + +
+
+
+
+
姓名
+
+ +
小名
+ +
+
性别
+
+
+
+
生日
+
+ + + · 岁 + + · 今天生日 + · 还有 天 + + 未填写 + +
+
+
+
手机号
+
+
+
+
身份证号
+
+
+ +
邮箱
+ + +
住址
+ + +
备注
+ +
+
最近登录
+
+
+
+
+ + +
+

登录账号

+ + +

+ 开通后, 就能用手机号和密码登录家书了。 +

+
+ + +
+ + +
+
+ + +
+
+ +
+
+ +

+ 账号已开通,登录名是 的手机号。 +

+
+ +
+ + + + +
+
+ + + + +
+ + 这是你自己 —— 要改自己的权限,请让另一位管理员来操作。 + +
+ +
+ +
+
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
+
数据库
+
加密密钥
未配置' ?>
+
+

+ 手机号与身份证号都是加密后入库的。这个密钥一旦换掉,已有数据就再也解不开 —— + 请把它和数据库分开、各备份一份。 +

+
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