阶段 0 起步:图标与环境自检脚手架

- assets/icons 全套图标:SVG 源文件 + maskable + 12 档 PNG
- favicon.ico / apple-touch-icon.png / site.webmanifest
- index.php 环境自检页(PHP 版本 / 必需扩展 / 配置 / 数据库连接),
  详情页默认不打印连接错误原文,避免泄露数据库账号名
- config.example.php:数据库与 app_key 的配置模板(config.php 不入库)
- README 补充项目定位与六阶段路线图;.gitignore 沿用既有约定
This commit is contained in:
2026-09-29 23:48:45 +08:00
parent d8914a6dfc
commit ecafa110f2
23 changed files with 350 additions and 1 deletions
+15
View File
@@ -0,0 +1,15 @@
# ── 本地配置(含数据库口令与加密密钥),不入库 ──────────────────────────────
# 部署时由服务器上的 config.php 提供;新环境请复制 config.example.php 再改。
config.php
# ── 用户上传内容(头像 / 照片 / 附件),不入库 ──────────────────────────────
# 只保留「禁止执行脚本」的保护规则,否则上传的 .php 能被直接访问。
uploads/*
!uploads/.htaccess
# ── 临时产物 ────────────────────────────────────────────────────────────────
*.bak
*.bak.*
*~
.DS_Store
Thumbs.db
+62 -1
View File
@@ -1,3 +1,64 @@
# FamilyBook
家书,记录家庭的全部生活,一本用岁月写成的家谱。
**家书,记录家庭的全部生活,一本用岁月写成的家谱。**
> 把一个家的「人」「日子」「去过的地方、看过的电影」收进同一本书里。
---
## 它是什么
一个自托管的家庭生活系统。它不是又一个待办清单,而是**这个家的长期档案** ——
十年之后还能翻出孩子第一次观影的票根、那年暑假走过的路线。
围绕三个圈展开:
- **人**:家庭成员、亲友通讯录,以及他们之间的共享关系
- **时间**:日历、日程、提醒,以及任务的期限
- **事件**:观影、旅行、其他活动 —— 一切都落在同一条时间轴上
## 它在做什么
| 阶段 | 主题 | 内容 |
| --- | --- | --- |
| 0 | 地基 | 名册、账号、登录、权限模型、安装向导 |
| 1 | 通信 | 通讯录、共享给某个成员、CardDAV 服务端 |
| 2 | 时间 | 日历本、重复日程、提醒、CalDAV 服务端 |
| 3 | 归一 | 观影与旅行迁入统一事件模型 |
| 4 | 任务 | 任务管理,期限自动映射成日历事件 |
| 5 | 回看 | 统计、年度家书、时间轴 |
当前进度:**阶段 0 起步**,仓库里只有图标与脚手架。
## 技术选型
- PHP 7.4+(无框架、无 Composer,上传到站点目录即可运行)
- MySQL 5.7+ / 8.0(`utf8mb4`)
- 前端为原生 HTML / CSS / JavaScript,无构建步骤
⚠️ 注意 CalDAV / CardDAV 是**双向协议**,和单纯的 `.ics` 文件导出不是一个量级的东西。
## 部署
1. 把代码放到站点根目录;
2. 复制 `config.example.php` 为 `config.php`,填写数据库与 `app_key`;
3. 访问站点根,按引导页确认 PHP 版本、扩展、配置、数据库连接四项都通过。
`config.php` 不入库(已在 `.gitignore` 中忽略),其中包含数据库口令与加密密钥。
⚠️ **`app_key` 一旦开始记录数据就不能再改**(换掉即解不开已有数据),
并且它必须单独备份 —— 密钥丢失等同于数据永久丢失。
## 隐私
身份证号、手机号属于敏感个人信息,一律加密存储。
(就家庭自用而言,身份证号其实没有多少必须存的场景,启用前请三思。)
## 图标
见 `assets/icons/`。概念是「**这本书就是家**」:屋顶即一本摊开的书,
屋顶下纸做的墙上,三行横线是这个家写下的日子。
## 开源协议
[Apache License 2.0](LICENSE)
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="家书 FamilyBook">
<!--
maskable 版本:用于 Android 自适应图标 / PWA。
背景铺满整个方形(不裁圆角),主体按 0.7 缩放居中。
⚠️ 0.7 是按主体包围盒算出来的:主体 55×48.2,缩放后角点到中心约 25.6,
正好落在直径 80% 的圆形安全区边界上,再放大会被裁掉屋檐。
⚠️ 绘制顺序与主图标一致:墙压住屋顶底边 1px,避免出现抗锯齿缝隙。
-->
<rect width="64" height="64" fill="#F2E9D8"/>
<g transform="translate(32 32) scale(0.7) translate(-32 -32)">
<path d="M32 7 L4.5 34 H32 Z" fill="#A84A28"/>
<path d="M32 7 L59.5 34 H32 Z" fill="#C96F45"/>
<path d="M28 34 L32 25 L36 34 Z" fill="#F2E9D8"/>
<path d="M32 7 V25" stroke="#6E3820" stroke-width="2.4" fill="none"/>
<rect x="13" y="33" width="38" height="19" rx="3" fill="#FDFBF5"/>
<g stroke="#BD5C39" stroke-width="2.4" stroke-linecap="round" fill="none">
<path d="M18 40 H46"/>
<path d="M18 44.5 H46"/>
<path d="M18 48.5 H37"/>
</g>
<rect x="10.5" y="52" width="43" height="3.2" rx="1.6" fill="#7A3F2A"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+45
View File
@@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="家书 FamilyBook">
<!--
家书 FamilyBook 图标
概念:这本书就是家。
· 屋顶 = 摊开的书:两片封面(左暗右亮),顶端的深色短脊线点明书脊,
底部中央一道 V 形沟口露出背景,是书页分开的地方
· 屋顶下是纸做的墙,墙上三行横线是这个家写下的日子
· 深褐地基收底
构图:主体横向占约 86%,四边留白收紧,小尺寸下更饱满。
⚠️ 绘制顺序不能改:墙在屋顶之后绘制,由墙压住屋顶底边 1px。
这样既不会出现抗锯齿缝隙,也不会让沟口在墙顶切出一道米色横缝。
配色:宣纸米 #F2E9D8 / 封面 #A84A28·#C96F45 / 书脊·地基 #6E3820·#7A3F2A / 纸白 #FDFBF5
-->
<defs>
<clipPath id="fb-clip">
<rect width="64" height="64" rx="14"/>
</clipPath>
</defs>
<g clip-path="url(#fb-clip)">
<!-- 宣纸底 -->
<rect width="64" height="64" fill="#F2E9D8"/>
<!-- 屋顶 = 摊开的书 -->
<path d="M32 7 L4.5 34 H32 Z" fill="#A84A28"/>
<path d="M32 7 L59.5 34 H32 Z" fill="#C96F45"/>
<path d="M28 34 L32 25 L36 34 Z" fill="#F2E9D8"/>
<!-- 书脊 -->
<path d="M32 7 V25" stroke="#6E3820" stroke-width="2.4" fill="none"/>
<!-- 纸做的墙:写下的岁月 -->
<rect x="13" y="33" width="38" height="19" rx="3" fill="#FDFBF5"/>
<g stroke="#BD5C39" stroke-width="2.4" stroke-linecap="round" fill="none">
<path d="M18 40 H46"/>
<path d="M18 44.5 H46"/>
<path d="M18 48.5 H37"/>
</g>
<!-- 地基 -->
<rect x="10.5" y="52" width="43" height="3.2" rx="1.6" fill="#7A3F2A"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+41
View File
@@ -0,0 +1,41 @@
<?php
/**
* FamilyBook · 家书 —— 本地配置模板
*
* 使用:复制为 config.php,按注释填写。
* config.php 已在 .gitignore 中忽略,不会进 Git —— 换机器、换库都靠它。
*
* ⚠️ 仓库是公开的:这里不许出现真实域名、真实口令、真实密钥。
*/
declare(strict_types=1);
return [
// ---------------- 站点 ----------------
'site_name' => '家书',
// 站点对外根地址,末尾不要带斜杠。示例值,请替换成你自己的
'base_url' => 'https://example.com',
// ---------------- 数据库(MySQL)----------------
'db' => [
'host' => '127.0.0.1',
'port' => 3306,
'name' => 'familybook',
'user' => 'familybook',
'pass' => '',
'charset' => 'utf8mb4',
],
// ---------------- 敏感字段加密密钥 ----------------
// 用于身份证号、手机号的对称加密(openssl AES-256-CBC)。
// ⚠️ 开始记录数据之后这个值就不能再改:换掉它,已有数据就解不开了。
// ⚠️ 请单独备份它。密钥丢失 = 数据永久丢失,比数据库丢了还严重。
// 生成方式:openssl rand -hex 16
'app_key' => '请填写32位随机十六进制串',
// ---------------- 调试 ----------------
// true 时引导页会打印数据库连接错误的详情。
// ⚠️ 线上务必保持 false —— 错误详情里可能带数据库账号名。
'debug' => false,
];
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+141
View File
@@ -0,0 +1,141 @@
<?php
/**
* FamilyBook · 家书 —— 阶段 0 引导页
*
* 这一页不是首页。它的职责是:部署之后先看这里,确认
* 「PHP 版本 / 扩展 / 配置文件 / 数据库连接」四件事都通了,再往上盖功能。
* 正式首页做好之后会替换掉它。
*
* ⚠️ 线上请把 config.php 里的 debug 设为 false:
* 连接报错时,详情里可能含数据库账号名,不该公开展示。
*/
declare(strict_types=1);
define('FB_ROOT', __DIR__);
$configFile = FB_ROOT . '/config.php';
$hasConfig = is_file($configFile);
$config = $hasConfig ? require $configFile : null;
$debug = is_array($config) && !empty($config['debug']);
/**
* 静态资源带版本号(mtime + size)。
* 不带版本号的话,文件换了浏览器还在用旧缓存 —— 详见踩坑记录。
*/
function fb_asset(string $rel): string
{
$p = FB_ROOT . $rel;
if (!is_file($p)) {
return $rel;
}
return $rel . '?v=' . substr(md5(filemtime($p) . filesize($p)), 0, 8);
}
$checks = [];
// 1. PHP 版本:以 PHP 7.4 为底线(线上同时存在 7.4 与 8.1 两套)
$checks['PHP 版本'] = [
'ok' => PHP_VERSION_ID >= 70400,
'val' => PHP_VERSION . '(需 7.4 以上)',
];
// 2. 必需扩展
$needExt = ['pdo_mysql' => '数据库', 'mbstring' => '中文处理', 'openssl' => '敏感字段加密', 'json' => '配置与接口', 'gd' => '图片裁剪'];
foreach ($needExt as $ext => $why) {
$ok = extension_loaded($ext);
$checks['扩展 ' . $ext] = ['ok' => $ok, 'val' => $ok ? '已加载 · ' . $why : '缺失 · ' . $why];
}
// 3. 配置文件
$checks['配置文件 config.php'] = [
'ok' => $hasConfig,
'val' => $hasConfig ? '已就位' : '未找到(请复制 config.example.php 后填写)',
];
// 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,
]);
$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 可看详情)',
];
}
}
$allOk = true;
foreach ($checks as $c) {
$allOk = $allOk && $c['ok'];
}
$siteName = is_array($config) && !empty($config['site_name']) ? $config['site_name'] : '家书';
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') ?> · 环境自检</title>
<link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<style>
:root { --paper:#F2E9D8; --cover:#A84A28; --cover2:#C96F45; --ink:#6E3820; --white:#FDFBF5; }
* { box-sizing:border-box; }
body { margin:0; padding:40px 20px; background:var(--paper); color:var(--ink);
font:15px/1.7 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif; }
.wrap { max-width:560px; margin:0 auto; }
.head { display:flex; align-items:center; gap:14px; margin-bottom:6px; }
.head img { width:64px; height:64px; border-radius:14px; }
h1 { font-size:22px; margin:0; font-weight:600; }
.sub { color:#8a6a52; font-size:13px; margin:0 0 24px 78px; }
ul { list-style:none; margin:0; padding:0; background:var(--white);
border-radius:12px; overflow:hidden; }
li { display:flex; align-items:baseline; gap:10px; padding:12px 16px;
border-bottom:1px solid rgba(110,56,32,.08); }
li:last-child { border-bottom:0; }
.dot { width:9px; height:9px; border-radius:50%; flex:0 0 auto; margin-top:8px; }
.ok { background:#6f9e5c; } .bad { background:#c0533f; }
.k { font-weight:600; flex:0 0 auto; }
.v { color:#7a5a42; font-size:13px; word-break:break-all; }
.banner { padding:10px 16px; border-radius:10px; margin-bottom:18px; font-size:14px; }
.pass { background:#e6f0dc; color:#3d6b1f; }
.fail { background:#f8ddda; color:#a1341f; }
</style>
</head>
<body>
<div class="wrap">
<div class="head">
<img src="<?= htmlspecialchars(fb_asset('/assets/icons/icon-192.png'), ENT_QUOTES, 'UTF-8') ?>" alt="">
<div>
<h1><?= htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') ?> · 环境自检</h1>
</div>
</div>
<p class="sub">FamilyBook —— 一本用岁月写成的家谱</p>
<div class="banner <?= $allOk ? 'pass' : 'fail' ?>">
<?= $allOk ? '全部通过,可以开始盖功能了。' : '还有未通过项,处理后请刷新这一页。' ?>
</div>
<ul>
<?php foreach ($checks as $name => $c): ?>
<li>
<span class="dot <?= $c['ok'] ? 'ok' : 'bad' ?>"></span>
<span class="k"><?= htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?></span>
<span class="v"><?= htmlspecialchars((string)$c['val'], ENT_QUOTES, 'UTF-8') ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
{
"name": "家书 FamilyBook",
"short_name": "家书",
"description": "记录家庭的全部生活,一本用岁月写成的家谱。",
"start_url": "/",
"scope": "/",
"display": "standalone",
"lang": "zh-CN",
"background_color": "#F2E9D8",
"theme_color": "#A84A28",
"icons": [
{ "src": "/assets/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/assets/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/assets/icons/icon-192-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/assets/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}