v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:
- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
(实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
金额一律以「分」存,不用浮点。
验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
This commit is contained in:
+227
@@ -119,6 +119,19 @@ CREATE TABLE IF NOT EXISTS `accounts` (
|
||||
UNIQUE KEY `uk_account_person` (`person_id`),
|
||||
CONSTRAINT `fk_accounts_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录账号'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `caldav_credentials` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`person_id` INT UNSIGNED NOT NULL,
|
||||
`token_hash` CHAR(64) NOT NULL COMMENT 'CalDAV 专用口令的 sha256;口令本身不落库',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`last_used_at` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_cd_person` (`person_id`),
|
||||
CONSTRAINT `fk_cd_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='CalDAV 专用口令(与登录口令分开:客户端会把它长期存着,不能是登录口令)'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `relations` (
|
||||
@@ -759,6 +772,179 @@ SQL,
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 日程按 CalDAV / iCalendar 对齐(版本 13)----
|
||||
// 用户 2026-10-02 第 5、6 条:起止时刻**精确到秒**,且「日期和时间在一个控件里一起选」。
|
||||
// 原来是 starts_at DATE + start_time TIME 两个列,天然就是"分开的"两件事 ——
|
||||
// 连"跨天又跨时刻"这种最基本的日程都表达不了。现在并成一个 DATETIME。
|
||||
// ⚠️ 先 MODIFY 再回填:MODIFY 之后老数据的 DATE 自动变成 'YYYY-MM-DD 00:00:00',
|
||||
// 紧接着把 start_time 并进去,老日程的时刻才不会一夜之间全变成 00:00。
|
||||
$db->exec("ALTER TABLE `calendar_events` MODIFY COLUMN `starts_at`
|
||||
DATETIME NOT NULL COMMENT '开始时刻(精确到秒;全天日程的时间部分为 00:00:00)'");
|
||||
$db->exec("ALTER TABLE `calendar_events` MODIFY COLUMN `ends_at`
|
||||
DATETIME DEFAULT NULL COMMENT '结束时刻(精确到秒);NULL=与开始同一时刻'");
|
||||
self::tryStep('老日程的时刻并进 starts_at/ends_at', static function () use ($db) {
|
||||
$db->exec("UPDATE `calendar_events`
|
||||
SET `starts_at` = CONCAT(DATE(`starts_at`), ' ', `start_time`)
|
||||
WHERE `start_time` IS NOT NULL AND `all_day` = 0");
|
||||
$db->exec("UPDATE `calendar_events`
|
||||
SET `ends_at` = CONCAT(DATE(`ends_at`), ' ', `end_time`)
|
||||
WHERE `ends_at` IS NOT NULL AND `end_time` IS NOT NULL AND `all_day` = 0");
|
||||
});
|
||||
// 其余字段按 RFC 5545(iCalendar)/ RFC 4791(CalDAV)的 VEVENT 属性定:
|
||||
// UID/DTSTAMP/SEQUENCE 是 CalDAV 同步三件套(没有它们客户端每次都当新日程);
|
||||
// LOCATION / STATUS / TRANSP / RRULE 是用户第 6 条要的地点与忙闲;
|
||||
// reminder_minutes 对应 VALARM 的 TRIGGER(提前多久提醒)。
|
||||
self::addColumn($db, 'calendar_events', 'uid',
|
||||
"ADD COLUMN `uid` VARCHAR(255) DEFAULT NULL COMMENT 'iCalendar UID(RFC 5545):一条日程全球唯一的身份证,CalDAV 与 ics 导入导出全靠它判重' AFTER `calendar_id`");
|
||||
self::addColumn($db, 'calendar_events', 'location',
|
||||
"ADD COLUMN `location` VARCHAR(255) DEFAULT NULL COMMENT '地点(RFC 5545 LOCATION)' AFTER `title`");
|
||||
self::addColumn($db, 'calendar_events', 'reminder_minutes',
|
||||
"ADD COLUMN `reminder_minutes` INT DEFAULT NULL COMMENT '提前多少分钟提醒(对应 VALARM 的 TRIGGER);NULL=不提醒' AFTER `all_day`");
|
||||
self::addColumn($db, 'calendar_events', 'status',
|
||||
"ADD COLUMN `status` ENUM('confirmed','tentative','cancelled') NOT NULL DEFAULT 'confirmed' COMMENT 'RFC 5545 STATUS:确定 / 待定 / 取消' AFTER `reminder_minutes`");
|
||||
self::addColumn($db, 'calendar_events', 'transp',
|
||||
"ADD COLUMN `transp` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'RFC 5545 TRANSP:0=忙碌(OPAQUE) / 1=空闲(TRANSPARENT)' AFTER `status`");
|
||||
self::addColumn($db, 'calendar_events', 'rrule',
|
||||
"ADD COLUMN `rrule` VARCHAR(255) DEFAULT NULL COMMENT 'RFC 5545 RRULE 重复规则原文(ics 导入导出原样带过,不做解释)' AFTER `transp`");
|
||||
self::addColumn($db, 'calendar_events', 'sequence',
|
||||
"ADD COLUMN `sequence` INT NOT NULL DEFAULT 0 COMMENT 'RFC 5545 SEQUENCE:改一次 +1,CalDAV 客户端据此判断要不要拉新版' AFTER `rrule`");
|
||||
self::addColumn($db, 'calendar_events', 'created_at',
|
||||
"ADD COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'RFC 5545 CREATED' AFTER `created_by`");
|
||||
self::addColumn($db, 'calendar_events', 'updated_at',
|
||||
"ADD COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'RFC 5545 LAST-MODIFIED' AFTER `created_at`");
|
||||
// 老日程补 UID(CalDAV 那边没有 UID 就等于"每次都是新日程",客户端会无限重复)
|
||||
self::tryStep('给老日程补 UID / SEQUENCE', static function () use ($db) {
|
||||
self::backfillCalendarUid($db);
|
||||
});
|
||||
// 每人的 CalDAV 口令(第 7 条:把我的日历本通过 CalDAV 分享出去)。
|
||||
// 存 user_settings,不新建表 —— 与 nav_modules / nav_known 同一套做法。
|
||||
|
||||
// ---- 资源的创建者默认共享(版本 13)----
|
||||
// 历史数据里「我建的、挂在别人名下的资源」没有共享行 → 我在自己的列表里看不到它。
|
||||
// 新建时已经会自动补(Resource::create),这里把老的一次补齐(幂等:只补没有的)。
|
||||
self::tryStep('回填资源创建者的共享权限', static function () use ($db) {
|
||||
self::backfillResourceCreatorShare($db);
|
||||
});
|
||||
|
||||
// ---- 任务:分类 + 自定义看板(版本 13,用户第 9、10 条)----
|
||||
// 看板(board)与状态(status)是两件事:
|
||||
// status 是这条任务**做到哪一步**(todo / doing / done);
|
||||
// board 是它**属于哪一块**(家务 / 装修 / 旅行准备 …)。
|
||||
// 之前只有 status,于是"想分开看几件事"只能靠给标题加前缀,越用越乱。
|
||||
$db->exec(
|
||||
'CREATE TABLE IF NOT EXISTS `task_boards` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`family_id` INT UNSIGNED NOT NULL,
|
||||
`name` VARCHAR(80) NOT NULL,
|
||||
`position` INT NOT NULL DEFAULT 100,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tb_family` (`family_id`, `position`),
|
||||
CONSTRAINT `fk_tb_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
||||
);
|
||||
$db->exec(
|
||||
'CREATE TABLE IF NOT EXISTS `task_categories` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`family_id` INT UNSIGNED NOT NULL,
|
||||
`name` VARCHAR(80) NOT NULL,
|
||||
`position` INT NOT NULL DEFAULT 100,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tc_family` (`family_id`, `position`),
|
||||
CONSTRAINT `fk_tc_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
||||
);
|
||||
self::addColumn($db, 'tasks', 'board_id',
|
||||
"ADD COLUMN `board_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个看板(用户自建;NULL=默认看板)' AFTER `family_id`");
|
||||
self::addColumn($db, 'tasks', 'category_id',
|
||||
"ADD COLUMN `category_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个分类(用户自建;NULL=未分类)' AFTER `board_id`");
|
||||
// 每个家庭至少有一个看板,否则"看板"这个概念在界面上就是空的
|
||||
self::tryStep('给每个家庭补一个默认看板', static function () use ($db) {
|
||||
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
||||
\App\Task::ensureDefaultBoard((int)$fam['id']);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 纪念日(版本 13,用户第 13 条)----
|
||||
// ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的
|
||||
// (见 Anniversary::birthdays),改了生日纪念日就跟着变。
|
||||
// 这里只存"用户自己加的"纪念日(结婚纪念日、忌日、相识纪念日…)。
|
||||
// month/day 按**该重复方式的月日**存:repeat_type=lunar 时是农历的月日,
|
||||
// 不是公历换算结果(换算只对某一年有效,存了明年就错)。
|
||||
// ⚠️ 用 heredoc(<<<'SQL')而不是手写单引号字符串:这一段里有 ENUM 的
|
||||
// 引号、中文 COMMENT、反引号列名,逐处 `\'` 转义极容易漏一个引号 ——
|
||||
// 实测就漏过(PHP 报 "unexpected identifier VARCHAR",看着像 SQL 语法错,
|
||||
// 其实全是 PHP 字符串没闭合)。heredoc 里什么都不用转义。
|
||||
$db->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `anniversaries` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`family_id` INT UNSIGNED NOT NULL,
|
||||
`title` VARCHAR(120) NOT NULL,
|
||||
`repeat_type` ENUM('solar','lunar','once') NOT NULL DEFAULT 'solar'
|
||||
COMMENT '重复方式:solar=每年阳历同月同日 / lunar=每年农历同月同日 / once=只此一次',
|
||||
`month` TINYINT UNSIGNED NOT NULL,
|
||||
`day` TINYINT UNSIGNED NOT NULL,
|
||||
`is_leap` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '农历:该月是否为闰月',
|
||||
`kind` VARCHAR(40) DEFAULT NULL COMMENT '分类:生日 / 结婚纪念日 / 忌日 …',
|
||||
`note` TEXT DEFAULT NULL,
|
||||
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '与谁相关(可空)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_ann_family` (`family_id`, `month`, `day`),
|
||||
CONSTRAINT `fk_ann_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_ann_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='纪念日:用户自建(生日不在此表,由 persons 实时派生)'
|
||||
SQL);
|
||||
|
||||
// ---- 财务 / 账本(版本 13,用户第 15 条)----
|
||||
// 两层:账本(可共享给人的一本总账)+ 账目(存款/基金/保险/房子/车子…)。
|
||||
// ⚠️ 金额一律存**分**(BIGINT),不存浮点:钱是最不能容忍
|
||||
// "0.1+0.2=0.30000000000000004" 的地方(见 Ledger::toCents 的注释)。
|
||||
$db->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `ledgers` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`family_id` INT UNSIGNED NOT NULL,
|
||||
`owner_id` INT UNSIGNED DEFAULT NULL COMMENT '创建者',
|
||||
`name` VARCHAR(60) NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_led_family` (`family_id`),
|
||||
CONSTRAINT `fk_led_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_led_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账本:一本家庭总账,可共享给人'
|
||||
SQL);
|
||||
$db->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `ledger_entries` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`ledger_id` INT UNSIGNED NOT NULL,
|
||||
`kind` VARCHAR(20) NOT NULL DEFAULT 'other' COMMENT '存款/基金/保险/房子/车子/贷款…',
|
||||
`title` VARCHAR(120) NOT NULL,
|
||||
`amount_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '金额(**分**):正数记绝对值,是资产还是负债由 kind 决定',
|
||||
`note` TEXT DEFAULT NULL,
|
||||
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '挂在谁名下(可空)',
|
||||
`created_by` INT UNSIGNED DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_le_ledger` (`ledger_id`),
|
||||
CONSTRAINT `fk_le_ledger` FOREIGN KEY (`ledger_id`) REFERENCES `ledgers` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_le_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账目:一笔资产或负债'
|
||||
SQL);
|
||||
$db->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `ledger_shares` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`ledger_id` INT UNSIGNED NOT NULL,
|
||||
`person_id` INT UNSIGNED NOT NULL,
|
||||
`perm` ENUM('read','write') NOT NULL DEFAULT 'read',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_led_person` (`ledger_id`, `person_id`),
|
||||
CONSTRAINT `fk_ls_ledger` FOREIGN KEY (`ledger_id`) REFERENCES `ledgers` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_ls_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账本共享'
|
||||
SQL);
|
||||
|
||||
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
|
||||
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
|
||||
if (self::$stepErrors) {
|
||||
@@ -771,6 +957,47 @@ SQL,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v24:给老日程补 UID(RFC 5545 要求 VEVENT 必须有 UID)。
|
||||
*
|
||||
* 格式沿用「域名倒写 + 随机」的通行写法:`<random>-<id>@familybook`,
|
||||
* 保证在本系统内唯一即可(跨系统的唯一性靠随机段)。幂等:只补 uid IS NULL 的。
|
||||
*/
|
||||
private static function backfillCalendarUid(\PDO $db): void
|
||||
{
|
||||
$rows = $db->query('SELECT id FROM calendar_events WHERE uid IS NULL OR uid = \'\'')->fetchAll();
|
||||
if (!$rows) {
|
||||
return;
|
||||
}
|
||||
$st = $db->prepare('UPDATE calendar_events SET uid = ? WHERE id = ?');
|
||||
foreach ($rows as $r) {
|
||||
$st->execute([bin2hex(random_bytes(8)) . '-' . (int)$r['id'] . '@familybook', (int)$r['id']]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v24(用户 2026-10-02 第 17 条):给「我建的、挂在别人名下的资源」补一条共享。
|
||||
*
|
||||
* 只补 `created_by` 有值且 ≠ owner 的、**共享表里还没有这一对的**。
|
||||
* 幂等(NOT EXISTS 判过才插),重复跑不插重。
|
||||
*/
|
||||
private static function backfillResourceCreatorShare(\PDO $db): void
|
||||
{
|
||||
if (!self::hasColumn($db, 'resources', 'created_by')) {
|
||||
return;
|
||||
}
|
||||
$db->exec(
|
||||
"INSERT INTO resource_shares (resource_id, person_id, perm)
|
||||
SELECT r.id, r.created_by, 'write'
|
||||
FROM resources r
|
||||
WHERE r.created_by IS NOT NULL
|
||||
AND r.created_by <> r.owner_id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM resource_shares s
|
||||
WHERE s.resource_id = r.id AND s.person_id = r.created_by)"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ `birthday_lunar = ''` 这种写法**只在本机的 MariaDB 上能跑**:
|
||||
* MySQL 严格模式(线上是 8.0 + STRICT_TRANS_TABLES)会把右边的 `''`
|
||||
|
||||
Reference in New Issue
Block a user