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:
2026-10-03 08:18:05 +08:00
parent 72dc6c0006
commit 8f80f3e835
47 changed files with 5363 additions and 578 deletions
+32
View File
@@ -0,0 +1,32 @@
# 家书 FamilyBook · Apache 重写规则
#
# ⚠️ 这个文件**不随包上线**(familybook_push.sh 的打包白名单里没有它)。
# 原因:线上站点根那份 .htaccess 是**宝塔面板生成的**(PHP 版本段、防盗链、
# 缓存段、伪静态…),整份覆盖会把面板的东西清空,而且面板下次重写还会改回来,
# 变成"时好时坏"。
# /dav/ 的重写规则由 `familybook_deploy.sh` 第 5c 段**追加**到线上那份里
# (带 `# >>> FamilyBook CalDAV` 标记,幂等)。
#
# 那这份文件是干什么用的?
# · 新建站点(没有宝塔那层)时,可直接把它作为站点根的 .htaccess;
# · 它是那段规则的**唯一书面来源**,push 脚本会断言部署脚本里的规则串
# 与它完全一致(防两处漂移)。
#
# 只做一件事:把 /dav/ 开头的 URL 交给 dav.php。
# 其余一律不动 —— index.php 那套 ?r=xxx 路由不依赖重写,
# 少写规则就少一处"上线才发现 404"的地方。
<IfModule mod_rewrite.c>
RewriteEngine On
# CalDAV:/dav/、/dav/3.ics/、/dav/3.ics/12.ics 全部交给 dav.php
RewriteRule ^dav(/.*)?$ dav.php [L,QSA]
</IfModule>
# 备份/临时文件永远不给出去(这条线上很要紧:*.php.bak 曾经可被直接下载)
<FilesMatch "\.(bak|old|orig|save|swp|swo|tmp)$">
Require all denied
</FilesMatch>
<FilesMatch "^\.|~$">
Require all denied
</FilesMatch>
+275
View File
@@ -0,0 +1,275 @@
<?php
/**
* 纪念日(v24 用户第 13 条)
*
* 用户原话:「增加纪念日模块,可以根据阴历和阳历来设置纪念日,在纪念日模块中,
* 可以默认看到已经保存的名册中的人的各种生日,生日本身就是纪念日的一种。
* 所有的纪念日,都显示在日历的日程中,作为一个全天日程出现。
* 纪念日,需要按照阴历和阳历进行重复。」
*
* 三条设计决定:
*
* ① **生日不落这张表**,而是像观影/旅程那样**实时派生**。
* 理由:生日已经存在 persons(birthday / birthday_lunar),再复制一份到
* anniversaries 就会出现"改了生日,纪念日那边还是旧的"。派生出来,
* 改生日当天纪念日跟着变 —— 与"日历里观影/旅程是实时派生"同一套口径。
*
* ② 用户自建的纪念日才落库(anniversaries 表)。`repeat_type` 决定怎么"年年重现":
* solar = 每年公历的同月同日;lunar = 每年农历的同月同日(所以库里存的是
* **农历的月/日**,不是公历换算结果 —— 换算结果只对某一年有效)。
*
* ③ 进日历、而且是**全天**日程(用户明确要求)。所以渲染时 all_day=1、
* 时间部分留空。
*/
declare(strict_types=1);
namespace App;
final class Anniversary
{
/** 重复方式 */
public const REPEAT = [
'solar' => '每年(阳历)',
'lunar' => '每年(农历)',
'once' => '只此一次',
];
/** 庆典类纪念日大致的分类,给个默认值方便挑选(用户可自由填) */
public const PRESET_KINDS = ['生日', '结婚纪念日', '相识纪念日', '忌日', '节日', '其它'];
public const ATTACH = 'anniversary';
// ---------------- ① 用户自建的纪念日 ----------------
public static function list(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT * FROM anniversaries WHERE family_id = ? ORDER BY month, day, id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
public static function find(int $familyId, int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM anniversaries WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$r = $st->fetch();
return $r ?: null;
}
/**
* 新建 / 修改一条纪念日。
*
* @param array $d title / repeat_type / month / day / kind / note / person_id / leap
* @return int 记录 id
*/
public static function save(int $familyId, array $d, int $id = 0): int
{
$db = Database::get();
$title = trim((string)($d['title'] ?? ''));
if ($title === '') {
$title = '纪念日';
}
$repeat = (string)($d['repeat_type'] ?? 'solar');
if (!isset(self::REPEAT[$repeat])) {
$repeat = 'solar';
}
$month = max(1, min(12, (int)($d['month'] ?? 1)));
$day = max(1, min(31, (int)($d['day'] ?? 1)));
$kind = mb_substr(trim((string)($d['kind'] ?? '')), 0, 40, 'UTF-8');
$note = trim((string)($d['note'] ?? ''));
$pid = (int)($d['person_id'] ?? 0) ?: null;
$leap = !empty($d['leap']) ? 1 : 0;
if ($id > 0) {
$db->prepare(
'UPDATE anniversaries SET title=?, repeat_type=?, month=?, day=?, is_leap=?,
kind=?, note=?, person_id=? WHERE id=? AND family_id=?'
)->execute([$title, $repeat, $month, $day, $leap, $kind !== '' ? $kind : null,
$note !== '' ? $note : null, $pid, $id, $familyId]);
return $id;
}
$db->prepare(
'INSERT INTO anniversaries (family_id, title, repeat_type, month, day, is_leap,
kind, note, person_id)
VALUES (?,?,?,?,?,?,?,?,?)'
)->execute([$familyId, $title, $repeat, $month, $day, $leap,
$kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid]);
return (int)$db->lastInsertId();
}
public static function remove(int $familyId, int $id): void
{
Attachment::removeAll(self::ATTACH, $id);
Database::get()->prepare('DELETE FROM anniversaries WHERE id = ? AND family_id = ?')
->execute([$id, $familyId]);
}
// ---------------- ② 派生:名册里每个人的生日 ----------------
/**
* 把名册里所有人的生日,当成"纪念日"派生出来。
*
* 用户原话:「生日本身就是纪念日的一种」——所以这里不去 persons 里写任何东西,
* 只读出生日、包成与自建纪念日**同一个形状**的行,上层(列表页 / 日历)一视同仁。
*
* @return array<int,array> 每项含 title/kind/month/day/repeat_type/person_id/source/…
*/
public static function birthdays(int $familyId, int $viewerId): array
{
$people = Person::map($familyId);
$out = [];
foreach ($people as $pid => $p) {
$pid = (int)$pid;
// 阳历生日(列名叫 birthday,读取一律走 Birthday::solarOf)
$solar = Birthday::dateOnly(Birthday::solarOf($p));
if ($solar !== '') {
$out[] = [
'id' => 0,
'title' => (string)($p['name'] ?? '') . ' 的生日',
'kind' => '生日',
'repeat_type' => 'solar',
'month' => (int)substr($solar, 5, 2),
'day' => (int)substr($solar, 8, 2),
'is_leap' => 0,
'person_id' => $pid,
'person_name' => (string)($p['name'] ?? ''),
'source' => 'birthday_solar',
'born' => $solar,
];
}
// 农历生日(**日期部分是农历**,所以月/日是农历的月日)
$lunar = Birthday::dateOnly((string)($p['birthday_lunar'] ?? ''));
if ($lunar !== '') {
$out[] = [
'id' => 0,
'title' => (string)($p['name'] ?? '') . ' 的农历生日',
'kind' => '生日',
'repeat_type' => 'lunar',
'month' => (int)substr($lunar, 5, 2),
'day' => (int)substr($lunar, 8, 2),
'is_leap' => (int)($p['birthday_lunar_leap'] ?? 0),
'person_id' => $pid,
'person_name' => (string)($p['name'] ?? ''),
'source' => 'birthday_lunar',
'born' => $lunar,
];
}
}
unset($viewerId);
return $out;
}
/** 自建纪念日 + 派生生日,合成一份"全部纪念日" */
public static function all(int $familyId, int $viewerId): array
{
$out = self::birthdays($familyId, $viewerId);
foreach (self::list($familyId) as $r) {
$r['source'] = 'custom';
$r['person_name'] = '';
$out[] = $r;
}
return $out;
}
// ---------------- ③ 这一年会落在哪几天(给日历用) ----------------
/**
* 某一年里,这些纪念日各落在哪个公历日期。
*
* ⚠️ 农历那一路必须**逐年换算**:农历新年在公历 1~2 月,
* 所以同一条"农历冬月初一"在不同年份对应的公历日期完全不同,
* 不存在"存一个公历日期年年用"的可能。
*
* @return array<int,array{date:string,title:string,days:int,kind:string,person_id:int,source:string,age:?int}>
*/
public static function occurrences(int $familyId, int $viewerId, int $year): array
{
$out = [];
foreach (self::all($familyId, $viewerId) as $a) {
$repeat = (string)($a['repeat_type'] ?? 'solar');
$m = (int)($a['month'] ?? 0);
$d = (int)($a['day'] ?? 0);
if ($m < 1 || $m > 12 || $d < 1 || $d > 31) {
continue;
}
$date = null;
if ($repeat === 'lunar') {
$leap = !empty($a['is_leap']) && Lunar::leapMonth($year) === $m;
$sol = Lunar::lunarToSolar($year, $m, $d, $leap);
if ($sol === null) {
// 这一年这个月没有这一天(如腊月三十遇上 29 天的腊月)→ 退到当月最后一天
$max = $leap ? Lunar::leapDays($year) : Lunar::monthDays($year, $m);
$sol = Lunar::lunarToSolar($year, $m, max(1, min($d, $max)), $leap);
}
if ($sol === null) {
continue;
}
$date = sprintf('%04d-%02d-%02d', $sol['year'], $sol['month'], $sol['day']);
} else {
// 公历(含"只此一次"):2/29 落在平年时退到 2/28
$dd = $d;
if (!checkdate($m, $dd, $year)) {
$dd = min($dd, (int)date('t', mktime(0, 0, 0, $m, 1, $year)));
}
$date = sprintf('%04d-%02d-%02d', $year, $m, $dd);
}
$age = null;
if (($a['source'] ?? '') !== 'custom' && !empty($a['born'])) {
$age = Birthday::ageOn((string)$a['born'], $date);
}
$out[] = [
'date' => $date,
'title' => (string)($a['title'] ?? ''),
'kind' => (string)($a['kind'] ?? ''),
'person_id' => (int)($a['person_id'] ?? 0),
'source' => (string)($a['source'] ?? 'custom'),
'note' => (string)($a['note'] ?? ''),
'id' => (int)($a['id'] ?? 0),
'age' => $age,
'repeat' => $repeat,
];
}
return $out;
}
/**
* 一段时间内的纪念日(日历画格子用)。
* @return array<int,array> 按日期升序
*/
public static function between(int $familyId, int $viewerId, string $from, string $to): array
{
$y1 = (int)substr($from, 0, 4);
$y2 = (int)substr($to, 0, 4);
$out = [];
for ($y = $y1; $y <= $y2; $y++) {
foreach (self::occurrences($familyId, $viewerId, $y) as $o) {
if ($o['date'] >= $from && $o['date'] <= $to) {
$out[] = $o;
}
}
}
usort($out, static fn($a, $b) => strcmp($a['date'], $b['date']));
return $out;
}
/** 接下来 n 天内的纪念日(列表页头部"临近"用) */
public static function upcoming(int $familyId, int $viewerId, int $withinDays = 60): array
{
$today = date('Y-m-d');
$to = date('Y-m-d', strtotime('+' . max(1, $withinDays) . ' days'));
$rows = self::between($familyId, $viewerId, $today, $to);
foreach ($rows as &$r) {
$r['days'] = (int)((strtotime($r['date']) - strtotime($today)) / 86400);
}
unset($r);
return $rows;
}
public static function repeatWord(string $k): string
{
return self::REPEAT[$k] ?? $k;
}
}
+88 -3
View File
@@ -128,13 +128,98 @@ final class Attachment
} }
} }
/** 能在浏览器里直接预览吗(图片 / PDF) */ /**
public static function previewable(array $a): bool * 这个附件是「哪一类」—— 决定页面上画缩略图还是画图标、点开是预览还是下载。
*
* 返回:image / video / pdf / doc / xls / ppt / text / file
*
* ⚠️ 判据是**库里的 mime**(上传时由服务端嗅探内容得来),不看文件名后缀:
* 改个后缀骗不过它,而"文档其实是张图"这种也认得出来。
*/
public static function kind(array $a): string
{ {
if ((int)($a['is_image'] ?? 0) === 1) { if ((int)($a['is_image'] ?? 0) === 1) {
return 'image';
}
$mime = strtolower((string)($a['mime'] ?? ''));
$name = strtolower((string)($a['name'] ?? ''));
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($mime === '' && $ext !== '') {
$mime = self::EXT_MIME[$ext] ?? '';
}
if (strpos($mime, 'video/') === 0) {
return 'video';
}
if (strpos($mime, 'audio/') === 0) {
return 'video'; // 音频用播放器放,与视频同一条路
}
if (strpos($mime, 'pdf') !== false || $ext === 'pdf') {
return 'pdf';
}
if (strpos($mime, 'text/') === 0 || in_array($ext, ['txt', 'md', 'csv', 'log', 'json'], true)) {
return 'text';
}
// Office 的 MIME 一长串且各家写法不一(含 -msword / officedocument / opendocument),
// 扩展名反而更稳,所以这里两条路都认。
if (self::inAny($mime . ' ' . $ext, ['word', 'doc', 'rtf', 'odt', 'wps'])) {
return 'doc';
}
if (self::inAny($mime . ' ' . $ext, ['excel', 'sheet', 'xls', 'xlsx', 'csv', 'ods', 'et'])) {
return 'xls';
}
if (self::inAny($mime . ' ' . $ext, ['powerpoint', 'presentation', 'ppt', 'pps', 'odp', 'dps'])) {
return 'ppt';
}
return 'file';
}
/**
* 能在**本页**直接预览吗。
*
* 图片与视频不用说;PDF 浏览器自带阅读器,也能直接看。
* Word / Excel 之类浏览器渲染不了 —— 点了就下载(不装"点了没反应"的预览)。
*/
public static function previewable(array $a): bool
{
return in_array(self::kind($a), ['image', 'video', 'pdf'], true);
}
/** 这一类在人话里叫什么(图标上的角标、预览标题都用它) */
public static function kindWord(string $kind): string
{
return self::KIND_WORDS[$kind] ?? '文件';
}
private const KIND_WORDS = [
'image' => '图片', 'video' => '视频', 'pdf' => 'PDF',
'doc' => 'Word', 'xls' => 'Excel', 'ppt' => 'PPT',
'text' => '文本', 'file' => '文件',
];
/** 扩展名兜底用的 MIME 表(库里 mime 为空时) */
private const EXT_MIME = [
'pdf' => 'application/pdf', 'txt' => 'text/plain', 'md' => 'text/markdown',
'csv' => 'text/csv', 'json' => 'application/json',
'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'webm' => 'video/webm',
'mov' => 'video/quicktime', 'avi' => 'video/x-msvideo', 'mkv' => 'video/x-matroska',
'mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'wav' => 'audio/wav',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt' => 'application/vnd.ms-powerpoint',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'zip' => 'application/zip', 'rar' => 'application/x-rar-compressed',
];
private static function inAny(string $hay, array $needles): bool
{
foreach ($needles as $n) {
if (strpos($hay, $n) !== false) {
return true; return true;
} }
return strpos((string)($a['mime'] ?? ''), 'pdf') !== false; }
return false;
} }
/** 人话的大小 */ /** 人话的大小 */
+103
View File
@@ -38,6 +38,109 @@ final class Auth
return $_SESSION['name'] ?? ''; return $_SESSION['name'] ?? '';
} }
// ---------------- CalDAV 专用口令(v24 第 7 条后半) ----------------
/**
* 这个人有没有配过 CalDAV 口令。
*
* ⚠️ 为什么不用登录口令:CalDAV 客户端(iOS 日历 / DAVx⁵ / Thunderbird)
* 会把这串凭据**长期存在设备上**并反复自动发送。让它是登录口令,
* 等于把主密码复制到每一台设备、且改密码就要每台重配。
* 所以另发一个只用于日历订阅的口令,随时可换、与登录互不影响。
*/
public static function hasCalDav(int $personId): bool
{
$st = Database::get()->prepare('SELECT COUNT(*) FROM caldav_credentials WHERE person_id = ?');
$st->execute([$personId]);
return (int)$st->fetchColumn() > 0;
}
/**
* 生成(或重置)CalDAV 口令,**返回明文**(只在这一刻能看到,库里只有哈希)。
*/
public static function caldavIssue(int $personId): string
{
// 去掉容易读错的字符(0/O、1/l/I),因为用户要手打进手机
$alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
$plain = '';
for ($i = 0; $i < 24; $i++) {
$plain .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
Database::get()->prepare(
'INSERT INTO caldav_credentials (person_id, token_hash) VALUES (?,?)
ON DUPLICATE KEY UPDATE token_hash = VALUES(token_hash), created_at = NOW()'
)->execute([$personId, hash('sha256', $plain)]);
return $plain;
}
public static function caldavRevoke(int $personId): void
{
Database::get()->prepare('DELETE FROM caldav_credentials WHERE person_id = ?')
->execute([$personId]);
}
/**
* 用 Basic 凭据换一个「登录者」。成功返回 [personId, familyId, name],失败 null。
*
* 用户名可以是手机号,也可以是姓名 —— 手机上打姓名比打 11 位数字省事得多。
* 两者都走同一套节流(失败计数保存在会话里,与登录共用)。
*/
public static function caldavAuth(?string $user, ?string $pass): ?array
{
$user = trim((string)$user);
$pass = (string)$pass;
if ($user === '' || $pass === '') {
return null;
}
$fail = $_SESSION['cd_fail'] ?? ['n' => 0, 't' => 0];
if ($fail['n'] >= 8 && (time() - $fail['t']) < 60) {
usleep(500000);
return null;
}
$db = Database::get();
// 先按手机号(哈希)找,再按姓名找 —— 都限定 status=active
$row = null;
$st = $db->prepare(
'SELECT a.id AS account_id, p.id AS person_id, p.family_id, p.name, cd.token_hash
FROM accounts a
JOIN persons p ON p.id = a.person_id
LEFT JOIN caldav_credentials cd ON cd.person_id = p.id
WHERE a.status = "active" AND p.phone_hash = ? LIMIT 1'
);
$st->execute([Crypto::hash($user)]);
$row = $st->fetch();
if (!$row) {
$st = $db->prepare(
'SELECT a.id AS account_id, p.id AS person_id, p.family_id, p.name, cd.token_hash
FROM accounts a
JOIN persons p ON p.id = a.person_id
LEFT JOIN caldav_credentials cd ON cd.person_id = p.id
WHERE a.status = "active" AND p.name = ?
ORDER BY p.id LIMIT 1'
);
$st->execute([$user]);
$row = $st->fetch();
}
$ok = $row && !empty($row['token_hash'])
&& hash_equals((string)$row['token_hash'], hash('sha256', $pass));
if (!$ok) {
$_SESSION['cd_fail'] = ['n' => $fail['n'] + 1, 't' => time()];
return null;
}
unset($_SESSION['cd_fail']);
$db->prepare('UPDATE caldav_credentials SET last_used_at = NOW() WHERE person_id = ?')
->execute([(int)$row['person_id']]);
return [
'person_id' => (int)$row['person_id'],
'family_id' => (int)$row['family_id'],
'name' => (string)$row['name'],
];
}
public static function isAdmin(): bool public static function isAdmin(): bool
{ {
return ($_SESSION['role'] ?? '') === 'admin'; return ($_SESSION['role'] ?? '') === 'admin';
+20 -11
View File
@@ -1,8 +1,12 @@
<?php <?php
/** /**
* 三个生日:身份证生日 / 真实阳历生日 / 农历生日。 * 生日这一块:证件上的那天(只读、不显示)/ 真实阳历生日 / 农历生日。
* *
* 为什么要有三个(而不是把生日当一个字段): * ⚠️ v24(用户 2026-10-02 第 16 条):**身份证生日默认不显示**。
* 它照常存库(户籍事实,永不变),但不出现在任何页面与任何提醒里,
* 对外只保留"阳历生日"与"农历生日"两种真实生日。
*
* 为什么要有这些(而不是把生日当一个字段):
* · 身份证上的那个是**户籍事实**,和证件完全一致,任何人都不该在系统里改它 —— * · 身份证上的那个是**户籍事实**,和证件完全一致,任何人都不该在系统里改它 ——
* 它和真实生日**经常不一样**(当年上户口随手写的、只报农历的、报错的)。 * 它和真实生日**经常不一样**(当年上户口随手写的、只报农历的、报错的)。
* · 真实阳历生日是"实际过的那天",是给本人过的。 * · 真实阳历生日是"实际过的那天",是给本人过的。
@@ -29,9 +33,15 @@ namespace App;
final class Birthday final class Birthday
{ {
/** 三种生日的键 => 人话名字 */ /**
* 会**往外显示**的生日种类 => 人话名字。
*
* ⚠️ v24(用户 2026-10-02 第 16 条):这里原本有三档,含身份证生日。
* 现在只剩阳历与农历两种 —— 身份证生日仍然照常存库
* (`birthday_id_card`,只读、永不变),但**任何地方都不再显示它**:
* 它常常和真实生日不是同一天,两个日期并列摆着反而让人分不清"到底哪天过生日"。
*/
public const KINDS = [ public const KINDS = [
'id' => '身份证生日',
'solar' => '阳历生日', 'solar' => '阳历生日',
'lunar' => '农历生日', 'lunar' => '农历生日',
]; ];
@@ -259,10 +269,8 @@ final class Birthday
$lunarBd = self::dateOnly($person['birthday_lunar'] ?? ''); $lunarBd = self::dateOnly($person['birthday_lunar'] ?? '');
$lunarLeap = !empty($person['birthday_lunar_leap']); $lunarLeap = !empty($person['birthday_lunar_leap']);
if ($idBd !== '') { // v24 第 16 条:身份证生日不再参与"接下来要过哪个生日"(理由见 describe())。
$d = self::nextSolarDate($idBd, $today); // 它仍然会照常存库,只是不再产出提醒与日历条目。
$buckets[$d]['kinds'][] = 'id';
}
if ($solarBd !== '') { if ($solarBd !== '') {
$d = self::nextSolarDate($solarBd, $today); $d = self::nextSolarDate($solarBd, $today);
$buckets[$d]['kinds'][] = 'solar'; $buckets[$d]['kinds'][] = 'solar';
@@ -339,9 +347,10 @@ final class Birthday
$solarBd = self::dateOnly($solarRaw); $solarBd = self::dateOnly($solarRaw);
$lunarBd = self::dateOnly($person['birthday_lunar'] ?? ''); $lunarBd = self::dateOnly($person['birthday_lunar'] ?? '');
if ($idBd !== '') { // ⚠️ v24(用户 2026-10-02 第 16 条):**身份证生日默认不显示**。
$rows[] = ['kind' => 'id', 'text' => $idBd, 'note' => '由身份证推导,不可修改', 'date' => $idBd]; // 它只是"证件上的那一天",常常和真实生日不是同一天,两个日期并列摆在详情页
} // 反而让人分不清"到底哪天过生日"。现在只显示阳历与农历两种真实生日。
// 身份证生日仍然照常在库里(birthday_id_card 只读、永不变),只是不往外显示。
if ($solarBd !== '') { if ($solarBd !== '') {
$rows[] = ['kind' => 'solar', 'text' => self::pretty($solarRaw), 'note' => '', 'date' => $solarBd]; $rows[] = ['kind' => 'solar', 'text' => self::pretty($solarRaw), 'note' => '', 'date' => $solarBd];
} }
+142
View File
@@ -0,0 +1,142 @@
<?php
/**
* CalDAV 服务端(v24 用户第 7 条后半)
*
* 用户原话:「再增加caldav功能,就是可以把属于我的日历本,通过caldav协议分享给别人。」
*
* 实现的是 RFC 4791 的一个**只读子集** —— 够 iOS / 安卓 / Thunderbird / DAVx⁵
* 把家书的日历本订阅进来;本阶段不做 PUT/DELETE(在手机上改日程)。
* 少做这一半的代价是"要处理冲突与 ETag 回写",而收益(随时改)并不急。
*
* 客户端会依次发这些请求(这是**必须**按顺序实现的最小集):
*
* OPTIONS /dav/ → 声明支持 DAV: 1, calendar-access
* PROPFIND /dav/ (Depth: 0) → 当前用户可见的日历本清单
* PROPFIND /dav/<book>.ics/ (0) → 某一本的属性(displayname / color / 组件集)
* PROPFIND /dav/<book>.ics/ (1) → 这本里所有日程的资源清单(getetag)
* REPORT /dav/<book>.ics/ → calendar-query / calendar-multiget,取正文
* GET /dav/<book>.ics/ → 整本导出成一个 ics(很多客户端走这条)
*
* 鉴权用 **HTTP Basic**(DAVx⁵ / iOS 都原生支持),口令不是登录口令:
* 见 Auth::caldavToken() —— 登录口令绝不该在客户端里被反复存着。
*
* ⚠️ 只读:所有写方法(PUT/DELETE/PROPPATCH)一律 403。
* 与其"半支持"(写成功但客户端状态与服务端不一致),不如明确拒绝。
*/
declare(strict_types=1);
namespace App;
final class CalDav
{
/** 资源前缀(URL 里的路径) */
public const ROOT = '/dav/';
/**
* 当前用户可见的日历本。与「日程」模块里看到的是同一批
* (我建的 + 共享给我的),口径必须一致,否则手机上少一本、网页上多一本。
*/
public static function books(int $familyId, int $personId): array
{
return Calendar::mine($familyId, $personId);
}
/**
* 日历本 → URL 段。用 id 而不是名字:名字里有中文/空格/斜杠时,
* 客户端与服务端对"怎么编码"的理解常常不一致(实测 DAVx⁵ 会二次编码)。
*/
public static function hrefOf(array $cal): string
{
return self::ROOT . (int)$cal['id'] . '.ics/';
}
public static function bookHref(int $calId): string
{
return self::ROOT . $calId . '.ics/';
}
/** 从 URL 段(如 /dav/3.ics/)解出日历本 id 与"是不是要单个日程" */
public static function parsePath(string $path): array
{
$p = (string)parse_url($path, PHP_URL_PATH);
if (strpos($p, self::ROOT) !== 0) {
return ['book' => 0, 'event' => 0];
}
$rest = substr($p, strlen(self::ROOT));
$rest = trim($rest, '/');
if ($rest === '') {
return ['book' => 0, 'event' => 0];
}
// 形如 3.ics 或 3.ics/<uid>.ics
$parts = explode('/', $rest);
$bookSeg = $parts[0];
$book = (int)preg_replace('/\.ics$/', '', $bookSeg);
$event = 0;
if (isset($parts[1]) && $parts[1] !== '') {
// 单条日程:<uid>.ics —— uid 里可能含点,所以只去掉结尾的 .ics
$evSeg = preg_replace('/\.ics$/', '', $parts[1]);
$event = (int)$evSeg; // 我们用的是 calendar_events.id
}
return ['book' => $book, 'event' => $event];
}
// ---------------- 应答 ----------------
public static function xmlHeader(): void
{
header('Content-Type: application/xml; charset=utf-8');
}
/** 多状态应答(207)—— PROPFIND / REPORT 都用它 */
public static function multis(int $code = 207): void
{
http_response_code($code);
self::xmlHeader();
}
/** 把一个 xml 文本片段做最省事的转义(我们只回显自己库里的字符串) */
public static function x(string $s): string
{
return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
/**
* 某个日历本里"能列出来"的日程。
*
* ⚠️ 与网页日历同源:导出/订阅的内容必须与页面上看到的一致。
* 派生本(观影 / 旅程)走 Calendar::exportRows,自定义本走 calendar_events。
*/
public static function rows(array $cal, int $familyId, int $me): array
{
return Calendar::exportRows($cal, $familyId, $me);
}
/** 单条日程(按 calendar_events.id)—— 只允许自定义本,派生本没有"单条资源" */
public static function oneEvent(int $calId, int $eventId, array $familyIdAndMe): ?array
{
[$familyId, $me] = $familyIdAndMe;
$st = Database::get()->prepare(
'SELECT e.* FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
WHERE e.id = ? AND e.calendar_id = ? AND c.family_id = ?'
);
$st->execute([$eventId, $calId, $familyId]);
$r = $st->fetch();
if (!$r) {
return null;
}
// 共享只读的人也要能取到(能看见这本就能看见里面的条目)
$cal = Calendar::mineOne($familyId, $calId, $me);
if (!$cal) {
return null;
}
return $r;
}
/** 一整本导出成 ics 文本(GET 与 REPORT 都用它) */
public static function ics(array $cal, int $familyId, int $me): string
{
return Ics::export(self::rows($cal, $familyId, $me), (string)$cal['name']);
}
}
+290 -30
View File
@@ -19,6 +19,9 @@ namespace App;
final class Calendar final class Calendar
{ {
/** 日程附件的宿主类型(统一附件表用;v24 第 6 条:日程也能挂附件) */
public const ATTACH = 'calendar_event';
/** 系统预置的两本默认日历(kind 与名字都写死,用户的自定义本一律 kind='custom') */ /** 系统预置的两本默认日历(kind 与名字都写死,用户的自定义本一律 kind='custom') */
public const DEFAULT_KINDS = [ public const DEFAULT_KINDS = [
'movie' => ['观影', '#7A4E8C'], 'movie' => ['观影', '#7A4E8C'],
@@ -32,9 +35,30 @@ final class Calendar
* 太浅看不清字,太艳会盖过整个月视图。 * 太浅看不清字,太艳会盖过整个月视图。
*/ */
public const PRESET_CAL_COLORS = [ public const PRESET_CAL_COLORS = [
'#4A7C82', '#7A4E8C', '#3F7D5A', '#B0763A', '#5566A8', '#A63030', '#A65C30', '#A68930', '#97A630',
'#A8574F', '#8A6B4B', '#5C8A48', '#9A5C7A', '#3D6B8E', '#6BA630', '#3FA630', '#30A64E', '#30A67A',
'#8C7A3F', '#6B5B95', '#30A6A6', '#307AA6', '#304EA6', '#3F30A6',
'#6B30A6', '#9730A6', '#A63089', '#A6305C',
];
/**
* 提醒档位:提前多久(分钟)—— 对应 RFC 5545 的 VALARM / TRIGGER。
*
* ⚠️ 顺序就是用户 2026-10-02 第 6 条原话的顺序,别按数值大小重排:
* 下拉里"5 分钟、10 分钟、15 分钟…1 天"读起来是递进的,
* 按数值排也一样,但一改动顺序用户就会以为自己选错了。
*/
public const REMINDERS = [
5 => '提前 5 分钟',
10 => '提前 10 分钟',
15 => '提前 15 分钟',
30 => '提前 30 分钟',
60 => '提前 1 小时',
120 => '提前 2 小时',
240 => '提前 4 小时',
480 => '提前 8 小时',
720 => '提前 12 小时',
1440 => '提前 1 天',
]; ];
/** 新建一本 */ /** 新建一本 */
@@ -120,6 +144,23 @@ final class Calendar
return ($cal['perm'] ?? '') === 'write'; return ($cal['perm'] ?? '') === 'write';
} }
/**
* 按 id 取一本「我能看到的」日历(带 perm / shared)。
*
* ⚠️ 判权限时**必须**走这里而不是 Calendar::find():find() 返回的是 calendars
* 表原行,里面没有 perm —— 于是「共享给我的、可读写的本子」一律被判成不能写,
* 被共享的人建了日程也改不了(蹲在 calendar/event 页上看只读提示)。
*/
public static function mineOne(int $familyId, int $personId, int $calendarId): ?array
{
foreach (self::mine($familyId, $personId) as $c) {
if ((int)$c['id'] === $calendarId) {
return $c;
}
}
return null;
}
/** 给某人补建两本默认日历(幂等) */ /** 给某人补建两本默认日历(幂等) */
public static function seedDefaults(int $familyId, int $personId): void public static function seedDefaults(int $familyId, int $personId): void
{ {
@@ -176,39 +217,63 @@ final class Calendar
// ---------------- 日程 ---------------- // ---------------- 日程 ----------------
/**
* 新建一条日程。
*
* ⚠️ v24(第 5、6 条):起止是**完整的 DATETIME(精确到秒)**,不再分成
* "日期一列 + 时刻一列" —— 分开存就表达不了"今天 22:30 到明天 01:15",
* 而且用户填的时候也得分两处。全天日程仍然写 DATETIME,只是时间部分是 00:00:00。
* ⚠️ uid 一定要在**插入时**生成:CalDAV 客户端靠 UID 认日程,
* 没有 UID 的日程每同步一次就被当成新日程再建一条(无限重复)。
*/
public static function addEvent(int $calendarId, array $d): int public static function addEvent(int $calendarId, array $d): int
{ {
$db = Database::get(); $db = Database::get();
$db->prepare( $db->prepare(
'INSERT INTO calendar_events 'INSERT INTO calendar_events
(calendar_id, title, starts_at, ends_at, start_time, end_time, all_day, note, created_by) (calendar_id, uid, title, location, starts_at, ends_at, all_day,
VALUES (?,?,?,?,?,?,?,?,?)' reminder_minutes, status, transp, rrule, note, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([ )->execute([
$calendarId, $calendarId,
self::newUid(),
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'), mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
self::textOrNull((string)($d['location'] ?? ''), 255),
$d['starts_at'], $d['starts_at'],
$d['ends_at'] ?: null, $d['ends_at'] ?: null,
$d['start_time'] ?: null,
$d['end_time'] ?: null,
!empty($d['all_day']) ? 1 : 0, !empty($d['all_day']) ? 1 : 0,
self::reminderMinutes($d['reminder_minutes'] ?? null),
self::statusOf($d['status'] ?? null),
!empty($d['transp']) ? 1 : 0,
self::textOrNull((string)($d['rrule'] ?? ''), 255),
self::textOrNull($d['note'] ?? ''), self::textOrNull($d['note'] ?? ''),
$d['created_by'] ?: null, $d['created_by'] ?: null,
]); ]);
return (int)$db->lastInsertId(); return (int)$db->lastInsertId();
} }
/**
* 改一条日程。
*
* ⚠️ SEQUENCE 每次 +1(RFC 5545):CalDAV 客户端拿它判断"服务端这版比我新吗"。
* 不加的话,改完日程客户端可能还抱着旧的不放。
*/
public static function updateEvent(int $id, array $d): void public static function updateEvent(int $id, array $d): void
{ {
Database::get()->prepare( Database::get()->prepare(
'UPDATE calendar_events SET title=?, starts_at=?, ends_at=?, start_time=?, end_time=?, 'UPDATE calendar_events SET title=?, location=?, starts_at=?, ends_at=?, all_day=?,
all_day=?, note=? WHERE id=?' reminder_minutes=?, status=?, transp=?, rrule=?, note=?, sequence = sequence + 1
WHERE id=?'
)->execute([ )->execute([
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'), mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
self::textOrNull((string)($d['location'] ?? ''), 255),
$d['starts_at'], $d['starts_at'],
$d['ends_at'] ?: null, $d['ends_at'] ?: null,
$d['start_time'] ?: null,
$d['end_time'] ?: null,
!empty($d['all_day']) ? 1 : 0, !empty($d['all_day']) ? 1 : 0,
self::reminderMinutes($d['reminder_minutes'] ?? null),
self::statusOf($d['status'] ?? null),
!empty($d['transp']) ? 1 : 0,
self::textOrNull((string)($d['rrule'] ?? ''), 255),
self::textOrNull($d['note'] ?? ''), self::textOrNull($d['note'] ?? ''),
$id, $id,
]); ]);
@@ -216,9 +281,165 @@ final class Calendar
public static function deleteEvent(int $id): void public static function deleteEvent(int $id): void
{ {
Attachment::removeAll(self::ATTACH, $id);
Database::get()->prepare('DELETE FROM calendar_events WHERE id = ?')->execute([$id]); Database::get()->prepare('DELETE FROM calendar_events WHERE id = ?')->execute([$id]);
} }
/** 一条日程(含所属本子;用来判权限) */
public static function event(int $id): ?array
{
$st = Database::get()->prepare(
'SELECT e.*, c.owner_id, c.name AS cal_name, c.color AS cal_color, c.kind AS cal_kind
FROM calendar_events e JOIN calendars c ON c.id = e.calendar_id
WHERE e.id = ?'
);
$st->execute([$id]);
$r = $st->fetch();
return $r ?: null; // 「能不能写」另走 canWriteEvent()(要按人查共享表,塞不进这一行)
}
/**
* 这条日程我能写吗(按它所在本子的归属与共享权限判)。
*
* ⚠️ 单独开一个方法而不是让调用方自己拼数组给 canWrite():
* canWrite() 吃的是「日历本」那一行(要带 perm),拿日程行直接喂进去
* 会因为没有 perm 而恒判成不能写。
*/
public static function canWriteEvent(array $event, int $personId): bool
{
if ((int)($event['owner_id'] ?? 0) === $personId) {
return true;
}
$st = Database::get()->prepare(
'SELECT perm FROM calendar_shares WHERE calendar_id = ? AND person_id = ?'
);
$st->execute([(int)$event['calendar_id'], $personId]);
return (string)$st->fetchColumn() === 'write';
}
/** 一条日程的 UID(CalDAV 那边按 UID 取) */
public static function newUid(): string
{
return bin2hex(random_bytes(12)) . '@familybook';
}
/**
* 导出 ics 用的行集(用户 2026-10-02 第 7 条)。
*
* 三本日历的内容来源不一样,但**导出时都要有东西**:
* · custom → calendar_events 里真存着的自定义日程;
* · movie → events(kind=movie) 派生;
* · trip → trip_nodes 派生(与日历视图同一口径:出节点,不出整趟旅程)。
* 统一成 calendar_events 那一套字段名(starts_at/ends_at/title/...),
* Ics 那边就不必分辨来源是哪种。
*
* @return array<int,array>
*/
public static function exportRows(array $cal, int $familyId, int $me): array
{
$kind = (string)$cal['kind'];
if ($kind === 'movie' || $kind === 'trip') {
// 派生本:先按一个足够宽的范围取日历视图,再把每一条映射成可导出的行
$from = '1970-01-01';
$to = '2100-01-01';
$out = [];
$seen = [];
foreach (self::month($familyId, [$cal], $me, $from, $to) as $e) {
$key = $e['kind'] . '-' . $e['ref_id'] . '-' . $e['date'];
if (isset($seen[$key])) {
continue; // 跨天日程会逐天出一条,导出时一条就够
}
$seen[$key] = true;
$day = (string)$e['date'];
$out[] = [
'uid' => $e['kind'] . '-' . (int)$e['ref_id'] . '@familybook',
'title' => (string)$e['title'],
'starts_at' => $day . ' ' . ($e['time'] !== '' ? $e['time'] . ':00' : '00:00:00'),
'ends_at' => null,
'all_day' => empty($e['time']) ? 1 : 0,
'location' => null,
'note' => null,
'status' => 'confirmed',
'transp' => 0,
'sequence' => 0,
'created_at' => null,
'updated_at' => null,
'reminder_minutes' => null,
'rrule' => null,
];
}
return $out;
}
$st = Database::get()->prepare(
'SELECT * FROM calendar_events WHERE calendar_id = ? ORDER BY starts_at'
);
$st->execute([(int)$cal['id']]);
return $st->fetchAll();
}
/**
* 导入一条 ics 里的 VEVENT。
*
* ⚠️ 判重用 UID + 日历本:同一本日历里 UID 已存在就**更新**而不是再插一条 ——
* 否则同一个 .ics 导入两次,日历里就是两份一模一样的日程(用户最常见的操作
* "导一次没看清,再导一次"会立刻翻车)。
*
* @return string 'added' | 'updated'
*/
public static function importEvent(int $calendarId, array $v, int $createdBy): string
{
$db = Database::get();
$uid = (string)($v['uid'] ?? '');
if ($uid !== '') {
$st = $db->prepare('SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ?');
$st->execute([$calendarId, $uid]);
$old = (int)$st->fetchColumn();
if ($old > 0) {
self::updateEvent($old, $v);
return 'updated';
}
} else {
$uid = self::newUid();
}
$db->prepare(
'INSERT INTO calendar_events
(calendar_id, uid, title, location, starts_at, ends_at, all_day,
reminder_minutes, status, transp, rrule, note, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([
$calendarId,
$uid,
mb_substr(trim((string)($v['title'] ?? '')), 0, 200, 'UTF-8'),
self::textOrNull((string)($v['location'] ?? ''), 255),
$v['starts_at'],
$v['ends_at'] ?: null,
!empty($v['all_day']) ? 1 : 0,
self::reminderMinutes($v['reminder_minutes'] ?? null),
self::statusOf($v['status'] ?? null),
0,
self::textOrNull((string)($v['rrule'] ?? ''), 255),
self::textOrNull((string)($v['note'] ?? '')),
$createdBy ?: null,
]);
return 'added';
}
/** 提醒分钟数:只认预设档位与 0/NULL,乱传的落 NULL(不提醒) */
private static function reminderMinutes($v): ?int
{
if ($v === null || $v === '' || $v === false) {
return null;
}
$n = (int)$v;
return isset(self::REMINDERS[$n]) ? $n : null;
}
private static function statusOf($v): string
{
$v = (string)$v;
return in_array($v, ['confirmed', 'tentative', 'cancelled'], true) ? $v : 'confirmed';
}
/** /**
* 某个月(或某段日期)里**全部**日程: * 某个月(或某段日期)里**全部**日程:
* 自定义日程(calendar_events)+ 观影(events kind=movie)+ 旅行(events kind=trip) * 自定义日程(calendar_events)+ 观影(events kind=movie)+ 旅行(events kind=trip)
@@ -249,8 +470,9 @@ final class Calendar
$in = implode(',', array_fill(0, count($ids), '?')); $in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare( $st = Database::get()->prepare(
"SELECT * FROM calendar_events "SELECT * FROM calendar_events
WHERE calendar_id IN ($in) AND starts_at <= ? AND COALESCE(ends_at, starts_at) >= ? WHERE calendar_id IN ($in)
ORDER BY starts_at, start_time" AND DATE(starts_at) <= ? AND DATE(COALESCE(ends_at, starts_at)) >= ?
ORDER BY starts_at"
); );
$st->execute(array_merge($ids, [$to, $from])); $st->execute(array_merge($ids, [$to, $from]));
$calById = []; $calById = [];
@@ -259,21 +481,26 @@ final class Calendar
} }
foreach ($st->fetchAll() as $e) { foreach ($st->fetchAll() as $e) {
$c = $calById[(int)$e['calendar_id']] ?? null; $c = $calById[(int)$e['calendar_id']] ?? null;
$end = $e['ends_at'] ?: $e['starts_at']; $sd = substr((string)$e['starts_at'], 0, 10);
$ed = substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10);
// 跨天日程:在范围内**每天**都出一条(群晖日历就是这么画的) // 跨天日程:在范围内**每天**都出一条(群晖日历就是这么画的)
foreach (self::eachDay((string)$e['starts_at'], (string)$end, $from, $to) as $day) { $first = true;
foreach (self::eachDay($sd, $ed, $from, $to) as $day) {
$tm = substr((string)$e['starts_at'], 11, 5);
$out[] = [ $out[] = [
'cal' => (int)$e['calendar_id'], 'cal' => (int)$e['calendar_id'],
'color' => (string)($c['color'] ?? '#4A7C82'), 'color' => (string)($c['color'] ?? '#4A7C82'),
'calname' => (string)($c['name'] ?? ''), 'calname' => (string)($c['name'] ?? ''),
'title' => (string)$e['title'], 'title' => (string)$e['title'],
'date' => $day, 'date' => $day,
'time' => empty($e['all_day']) ? substr((string)$e['start_time'], 0, 5) : '', // 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
'time' => ($first && empty($e['all_day'])) ? $tm : '',
'all_day' => !empty($e['all_day']), 'all_day' => !empty($e['all_day']),
'kind' => 'custom', 'kind' => 'custom',
'ref_id' => (int)$e['id'], 'ref_id' => (int)$e['id'],
'href' => url('calendar/event', ['id' => (int)$e['id']]), 'href' => url('calendar/event', ['id' => (int)$e['id']]),
]; ];
$first = false;
} }
} }
} }
@@ -306,32 +533,46 @@ final class Calendar
} }
} }
// ---- 旅行(events kind=trip)---- // ---- 旅程(trip_nodes,不是 events)----
// ⚠️ v24(用户 2026-10-02 第 2 条):日历里出的是**行程节点**,不是「这趟旅程」本身。
// 原来把整趟旅程摊成一条跨天全天日程,于是日历上一整周都被"云南之行"占满,
// 真正要看的"几点出发 / 几点到景点"全都看不见 —— 旅程是个容器,节点才是日程。
// 现在按 trip_nodes.starts_at 出条目,跨天的节点同样逐天铺开。
if (isset($byKind['trip'])) { if (isset($byKind['trip'])) {
$c = $byKind['trip'][0]; $c = $byKind['trip'][0];
$st = Database::get()->prepare( $st = Database::get()->prepare(
"SELECT id, title, starts_at, ends_at FROM events "SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at,
WHERE family_id = ? AND kind = 'trip' AND starts_at IS NOT NULL n.node_type, n.status AS node_status,
AND DATE(starts_at) <= ? AND COALESCE(DATE(ends_at), DATE(starts_at)) >= ? e.id AS event_id, e.title AS trip_title
ORDER BY starts_at" FROM trip_nodes n
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
WHERE n.family_id = ? AND n.starts_at IS NOT NULL
AND n.status <> 'cancelled'
AND DATE(n.starts_at) <= ? AND COALESCE(DATE(n.ends_at), DATE(n.starts_at)) >= ?
ORDER BY n.starts_at"
); );
$st->execute([$familyId, $to, $from]); $st->execute([$familyId, $to, $from]);
foreach ($st->fetchAll() as $e) { foreach ($st->fetchAll() as $n) {
foreach (self::eachDay(substr((string)$e['starts_at'], 0, 10), $start = substr((string)$n['starts_at'], 0, 10);
substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10), $end = substr((string)($n['ends_at'] ?: $n['starts_at']), 0, 10);
$from, $to) as $day) { // 节点跨天时只在**第一天**显示时间(后面的天是"还在进行",不是"又开始了")
$tm = substr((string)$n['starts_at'], 11, 5);
$first = true;
foreach (self::eachDay($start, $end, $from, $to) as $day) {
$out[] = [ $out[] = [
'cal' => (int)$c['id'], 'cal' => (int)$c['id'],
'color' => (string)$c['color'], 'color' => (string)$c['color'],
'calname' => (string)$c['name'], 'calname' => (string)$c['name'],
'title' => '🧳 ' . (string)$e['title'], 'title' => Trip::typeIcon((string)$n['node_type']) . ' ' . (string)$n['node_title'],
'date' => $day, 'date' => $day,
'time' => '', 'time' => $first ? ($tm === '00:00' ? '' : $tm) : '',
'all_day' => true, 'all_day' => $first && $tm === '00:00',
'kind' => 'trip', 'kind' => 'trip',
'ref_id' => (int)$e['id'], 'ref_id' => (int)$n['node_id'],
'href' => url('trips/show', ['id' => (int)$e['id']]), 'href' => url('trips/show', ['id' => (int)$n['event_id']])
. '#node-' . (int)$n['node_id'],
]; ];
$first = false;
} }
} }
} }
@@ -364,6 +605,25 @@ final class Calendar
]; ];
} }
// ---- 纪念日(v24 第 13 条)----
// 用户明确要求:纪念日一律作为**全天日程**出现在日历里。
// 数据是**实时派生**的(生日读 persons、农历逐年换算),所以这里不查表、直接算。
// ⚠️ 农历那一路必须按"这一年"换算 —— 同一条农历生日在不同年份落在不同公历日期。
foreach (Anniversary::between($familyId, $personId, $from, $to) as $a) {
$out[] = [
'cal' => 0,
'color' => '#B4633C',
'calname' => '纪念日',
'title' => '🎂 ' . (string)$a['title'],
'date' => (string)$a['date'],
'time' => '',
'all_day' => true, // 全天(用户点名要求)
'kind' => 'anniv',
'ref_id' => (int)($a['id'] ?? 0),
'href' => $a['id'] > 0 ? url('days/edit', ['id' => (int)$a['id']]) : url('days'),
];
}
usort($out, static function ($a, $b) { usort($out, static function ($a, $b) {
return strcmp($a['date'], $b['date']) return strcmp($a['date'], $b['date'])
?: strcmp((string)$a['time'], (string)$b['time']) ?: strcmp((string)$a['time'], (string)$b['time'])
+402
View File
@@ -0,0 +1,402 @@
<?php
/**
* iCalendar(RFC 5545)的读与写 —— 日程的 ics 导入 / 导出(用户 2026-10-02 第 7 条)。
*
* 为什么自己写而不引一个库:
* 这个应用是「零依赖」的(连 Composer 都没有),为两个函数拖进一个依赖不划算;
* 而且我们只需要 VEVENT 的一个常用子集,自己写反而能把"哪些字段往返不丢"说清楚。
*
* 三条必须遵守的 RFC 规则(踩过就懂):
* ① **行要折**:一行超过 75 个**字节**(不是字符!中文一个字 3 字节)就必须折,
* 折行用 CRLF + 一个空格开头。不折的话 iOS 日历会整条拒收。
* ② **文本要转义**:\ ; , 和换行各有转义写法,不转义会把一行正文切成好几条属性。
* ③ **全天日程的 DTSTART 是 DATE 值**(20261015),不是 DATETIME ——
* 写成 DATETIME 的话,客户端会当成"这一天的 00:00",看着一样但跨时区会错。
*
* 时区:系统里存的一律是**本地时间**(不带时区),导出也不带 Z、不写 VTIMEZONE。
* 家庭应用里"几点开会"就是墙上那个钟,硬套 UTC 反而会整体偏移。
*/
declare(strict_types=1);
namespace App;
final class Ics
{
/** 导出的产品标识(RFC 5545 PRODID,客户端会拿它判断"是谁生成的") */
private const PRODID = '-//FamilyBook//FamilyBook Calendar//CN';
/**
* 把一个日历本导出成 .ics 文本。
*
* @param array $events calendar_events 的行
* @param string $calName 日历本名字(X-WR-CALNAME,客户端用它给订阅起标题)
*/
public static function export(array $events, string $calName = '日程'): string
{
$out = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:' . self::PRODID,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:' . self::escapeText($calName),
];
foreach ($events as $e) {
$out = array_merge($out, self::vevent($e));
}
$out[] = 'END:VCALENDAR';
// 折行 + 统一 CRLF(RFC 5545 要求行尾是 CRLF,虽然多数客户端也收 LF)
$lines = [];
foreach ($out as $line) {
foreach (self::fold($line) as $l) {
$lines[] = $l;
}
}
return implode("\r\n", $lines) . "\r\n";
}
/** 一条日程 → VEVENT 的若干行 */
private static function vevent(array $e): array
{
$allDay = !empty($e['all_day']);
$starts = (string)($e['starts_at'] ?? '');
$ends = (string)($e['ends_at'] ?? '');
$uid = (string)($e['uid'] ?? '');
if ($uid === '') {
$uid = Calendar::newUid();
}
$stamp = self::stampOf($e['updated_at'] ?? $e['created_at'] ?? null);
$out = ['BEGIN:VEVENT', 'UID:' . $uid, 'DTSTAMP:' . $stamp];
if ($allDay) {
// 全天:VALUE=DATE,且 DTEND 是**第二天**(RFC:DTEND 是排他边界)
$out[] = 'DTSTART;VALUE=DATE:' . self::dateValue($starts);
$d = self::dateValue($ends !== '' ? $ends : $starts);
$out[] = 'DTEND;VALUE=DATE:' . ($ends !== '' ? self::nextDay($d) : self::nextDay($d));
} else {
$out[] = 'DTSTART:' . self::dateTimeValue($starts);
// 没有结束时刻就按开始时刻算(RFC 允许只给 DTSTART + DURATION,
// 但很多客户端对 DURATION 支持得更差,给一个等于开始的 DTEND 最稳)
$out[] = 'DTEND:' . self::dateTimeValue($ends !== '' ? $ends : $starts);
}
$out[] = 'SUMMARY:' . self::escapeText((string)($e['title'] ?? ''));
if (($e['location'] ?? '') !== '') {
$out[] = 'LOCATION:' . self::escapeText((string)$e['location']);
}
if (($e['note'] ?? '') !== '') {
$out[] = 'DESCRIPTION:' . self::escapeText((string)$e['note']);
}
if (($e['rrule'] ?? '') !== '') {
$out[] = (string)$e['rrule']; // 原样带过(导入时存的就是原文)
}
$out[] = 'STATUS:' . strtoupper((string)($e['status'] ?: 'confirmed'));
$out[] = 'TRANSP:' . (empty($e['transp']) ? 'OPAQUE' : 'TRANSPARENT');
$out[] = 'SEQUENCE:' . (int)($e['sequence'] ?? 0);
if (!empty($e['created_at'])) {
$out[] = 'CREATED:' . self::stampOf($e['created_at']);
}
if (!empty($e['updated_at'])) {
$out[] = 'LAST-MODIFIED:' . self::stampOf($e['updated_at']);
}
// 提醒 = VALARM(TRIGGER 用**负**持续时间表示"提前多久")
if (($e['reminder_minutes'] ?? null) !== null && (int)$e['reminder_minutes'] > 0) {
$out = array_merge($out, [
'BEGIN:VALARM',
'ACTION:DISPLAY',
'DESCRIPTION:' . self::escapeText((string)($e['title'] ?? '提醒')),
'TRIGGER:-' . self::duration((int)$e['reminder_minutes']),
'END:VALARM',
]);
}
$out[] = 'END:VEVENT';
return $out;
}
/**
* 解析一个 .ics 文本,返回里面的 VEVENT。
*
* @return array<int, array{uid:string,title:string,starts_at:string,ends_at:string,
* all_day:bool,location:?string,note:?string,
* reminder_minutes:?int,status:string,rrule:?string}>
*/
public static function parse(string $text): array
{
$text = str_replace(["\r\n", "\r"], "\n", $text);
// ① 反折行:以空格或制表符开头的行是上一行的续行
$text = preg_replace("/\n[ \t]/", '', $text) ?? $text;
$lines = explode("\n", $text);
$out = [];
$cur = null;
$inAlarm = false;
foreach ($lines as $raw) {
$line = trim($raw);
if ($line === '') {
continue;
}
if ($line === 'BEGIN:VEVENT') {
$cur = [];
$inAlarm = false;
continue;
}
if ($line === 'END:VEVENT') {
if ($cur !== null) {
$out[] = self::normalizeVevent($cur);
}
$cur = null;
$inAlarm = false;
continue;
}
if ($cur === null) {
continue; // VCALENDAR / VTIMEZONE 里的东西一律跳过
}
if ($line === 'BEGIN:VALARM') { $inAlarm = true; continue; }
if ($line === 'END:VALARM') { $inAlarm = false; continue; }
[$name, $params, $value] = self::splitProp($line);
if ($name === '') {
continue;
}
// VALARM 里只认 TRIGGER(提前多久);DESCRIPTION 那些是给闹钟弹窗用的,
// 混进来会覆盖日程本身的备注(实测:iOS 导出的 VALARM 里也带 DESCRIPTION)。
if ($inAlarm) {
if ($name === 'TRIGGER') {
$cur['_trigger'] = $value;
}
continue;
}
// 同名属性出现多次时保留第一个(RFC 里多数属性都是单值的)
if (!array_key_exists($name, $cur)) {
$cur[$name] = $value;
}
if (!array_key_exists($name . '_PARAMS', $cur)) {
$cur[$name . '_PARAMS'] = $params;
}
}
return $out;
}
/** 把解析出来的属性归一成我们要落库的形状 */
private static function normalizeVevent(array $p): array
{
$startRaw = (string)($p['DTSTART'] ?? '');
$endRaw = (string)($p['DTEND'] ?? '');
$params = (string)($p['DTSTART_PARAMS'] ?? '');
// 全天:VALUE=DATE,或者值本身就是 8 位的 YYYYMMDD
$allDay = stripos($params, 'VALUE=DATE') !== false
|| preg_match('/^\d{8}$/', $startRaw) === 1;
$starts = $allDay ? self::fromDateValue($startRaw) : self::fromDateTimeValue($startRaw);
$ends = $endRaw !== ''
? ($allDay ? self::fromDateValue($endRaw) : self::fromDateTimeValue($endRaw))
: null;
// 全天日程的 DTEND 是**排他**的第二天,减回一天才对得上"到哪天为止"
if ($allDay && $ends !== null && $starts !== null) {
$d = strtotime($ends . ' -1 day');
$ends = $d ? date('Y-m-d', $d) . ' 00:00:00' : null;
}
return [
'uid' => (string)($p['UID'] ?? ''),
'title' => self::unescapeText((string)($p['SUMMARY'] ?? '')),
'starts_at' => $starts,
'ends_at' => $ends,
'all_day' => $allDay,
'location' => self::nullOr(self::unescapeText((string)($p['LOCATION'] ?? ''))),
'note' => self::nullOr(self::unescapeText((string)($p['DESCRIPTION'] ?? ''))),
'rrule' => self::nullOr((string)($p['RRULE'] ?? '')),
'status' => strtolower((string)($p['STATUS'] ?? '')) ?: 'confirmed',
'reminder_minutes' => self::triggerMinutes((string)($p['_trigger'] ?? '')),
];
}
/** TRIGGER:-PT15M / TRIGGER:-P1D / TRIGGER;VALUE=DATE-TIME:... → 分钟数 */
private static function triggerMinutes(string $v): ?int
{
$v = trim($v);
if ($v === '') {
return null;
}
// 绝大多数客户端导出的是"提前多久"的持续时间(负号开头)
if (preg_match('/^-?P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/', $v, $m)) {
$mins = (int)($m[1] ?? 0) * 1440;
$mins += (int)($m[2] ?? 0) * 60;
$mins += (int)($m[3] ?? 0);
$mins += (int)($m[4] ?? 0) > 0 ? 1 : 0; // 有秒就进位到 1 分钟,别归零
return $mins > 0 ? $mins : null;
}
return null;
}
/** 分钟 → RFC 5545 的持续时间,如 90 → PT1H30M */
private static function duration(int $mins): string
{
$d = intdiv($mins, 1440);
$h = intdiv($mins % 1440, 60);
$m = $mins % 60;
$s = 'P';
if ($d > 0) {
$s .= $d . 'D';
}
if ($h > 0 || $m > 0) {
$s .= 'T';
if ($h > 0) {
$s .= $h . 'H';
}
if ($m > 0) {
$s .= $m . 'M';
}
}
return $s === 'P' ? 'PT0S' : $s;
}
/** '2026-10-16 14:30:00' → '20261016T143000' */
private static function dateTimeValue(string $dt): string
{
$t = strtotime($dt);
if ($t === false) {
return '19700101T000000';
}
return date('Ymd\THis', $t);
}
/** '2026-10-16 14:30:00' → '20261016' */
private static function dateValue(string $dt): string
{
$t = strtotime($dt);
if ($t === false) {
return '19700101';
}
return date('Ymd', $t);
}
/** '20261016T143000' / '2026-10-16 14:30:00' → '2026-10-16 14:30:00' */
private static function fromDateTimeValue(string $v): ?string
{
$v = trim($v);
if (preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/', $v, $m)) {
return sprintf('%s-%s-%s %s:%s:%s', $m[1], $m[2], $m[3], $m[4], $m[5], $m[6]);
}
$t = strtotime($v);
return $t === false ? null : date('Y-m-d H:i:s', $t);
}
/** '20261016' → '2026-10-16 00:00:00' */
private static function fromDateValue(string $v): ?string
{
if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', trim($v), $m)) {
return sprintf('%s-%s-%s 00:00:00', $m[1], $m[2], $m[3]);
}
$t = strtotime($v);
return $t === false ? null : date('Y-m-d 00:00:00', $t);
}
/** '20261016' → '20261017' */
private static function nextDay(string $ymd): string
{
$t = strtotime(substr($ymd, 0, 4) . '-' . substr($ymd, 4, 2) . '-' . substr($ymd, 6, 2) . ' +1 day');
return $t === false ? $ymd : date('Ymd', $t);
}
/** 属性行 → [名, 参数串, 值](只在**第一个**未加引号的冒号处切) */
private static function splitProp(string $line): array
{
$pos = strpos($line, ':');
if ($pos === false) {
return ['', '', ''];
}
$left = substr($line, 0, $pos);
$value = substr($line, $pos + 1);
if (strpos($left, ';') === false) {
return [strtoupper($left), '', $value];
}
[$name, $params] = explode(';', $left, 2);
return [strtoupper($name), $params, $value];
}
/** DTSTAMP / CREATED 一律写成 UTC(RFC 要求带 Z) */
private static function stampOf($dt): string
{
$t = strtotime((string)$dt);
if ($t === false) {
$t = time();
}
return gmdate('Ymd\THis\Z', $t);
}
/** 文本转义(RFC 5545 3.3.11) */
private static function escapeText(string $v): string
{
$v = str_replace('\\', '\\\\', $v);
$v = str_replace(';', '\;', $v);
$v = str_replace(',', '\,', $v);
$v = str_replace(["\r\n", "\r", "\n"], '\n', $v);
return $v;
}
private static function unescapeText(string $v): string
{
$v = str_replace('\n', "\n", $v);
$v = str_replace('\N', "\n", $v);
$v = str_replace('\,', ',', $v);
$v = str_replace('\;', ';', $v);
return str_replace('\\\\', '\\', $v);
}
private static function nullOr(string $v): ?string
{
$v = trim($v);
return $v === '' ? null : $v;
}
/**
* 折行:一行最多 75 **字节**,且不能在多字节字符中间切断。
*
* ⚠️ 按字节而不是按字符数:中文一个字 3 字节,按字符数切会切出半个 UTF-8 字,
* 客户端解出来就是乱码。续行以**一个空格**开头。
*/
private static function fold(string $line): array
{
if (strlen($line) <= 75) {
return [$line];
}
$out = [];
$cur = '';
$len = 0;
$limit = 75;
$bytes = strlen($line);
$i = 0;
while ($i < $bytes) {
// 一次取一个完整 UTF-8 字符
$ch = $line[$i];
$n = 1;
$o = ord($ch);
if ($o >= 0xF0) { $n = 4; }
elseif ($o >= 0xE0) { $n = 3; }
elseif ($o >= 0xC0) { $n = 2; }
$tok = substr($line, $i, $n);
$i += $n;
// 续行要以空格开头,所以后续每行实际只能放 74 字节
$room = $out === [] ? $limit : $limit - 1;
if ($len + $n > $room) {
$out[] = $cur;
$cur = '';
$len = 0;
}
$cur .= $tok;
$len += $n;
}
if ($cur !== '') {
$out[] = $cur;
}
// 续行补前导空格
$res = [];
foreach ($out as $k => $v) {
$res[] = $k === 0 ? $v : ' ' . $v;
}
return $res;
}
}
+150 -1
View File
@@ -246,6 +246,10 @@ final class Kinship
return ['gen' => [], 'units' => [], 'unit_of' => [], 'me' => $me]; return ['gen' => [], 'units' => [], 'unit_of' => [], 'me' => $me];
} }
// ⚠️ $up / $down 是**代数标号的上下限**(clamp),不是遍历深度 ——
// 走是走遍整个连通图的。所以同一个人可能通过两条路算出不同代数,
// 下面取「离我最近」的那一档。
// ---- ① 代数:以「我」为 0 代,反复贯穿直到不再有人可加 ---- // ---- ① 代数:以「我」为 0 代,反复贯穿直到不再有人可加 ----
$gen = [$me => 0]; $gen = [$me => 0];
$seen = [$me => true]; $seen = [$me => true];
@@ -312,6 +316,145 @@ final class Kinship
return ['gen' => $gen, 'units' => $units, 'unit_of' => $unitOf, 'me' => $me]; return ['gen' => $gen, 'units' => $units, 'unit_of' => $unitOf, 'me' => $me];
} }
/**
* v24 用户第 12 条:谁的姻亲「没画在家谱上」。
*
* 家谱画的是「同一位祖先的血脉后代 + 各人配偶」,所以**配偶那一侧的
* 父母/兄弟姐妹**天然不在图内(洪博为的父母不会出现在以杨家为根的树上)。
* 用户要的是:这些人的卡片上给一个入口,点一下就以 TA 为中心重画一张,
* 把 TA 那侧的姻亲露出来,再给个「返回」回到原图。
*
* 判据:**这个人有父母,但父母不在"库实际画出来的那批人"里** →
* TA 是有独立亲缘家族的人,点一下就以 TA 为中心重画。
*
* ⚠️ 这里的关键是"实际画出来的" ≠ "我们遍历到的":
* familyMap 为了算称呼会走遍**整个连通图**(连配偶的父母都拉进来),
* 但 family-chart 只画「根的祖先链 + 根的后代 + 根的兄弟姐妹 + 这些人的配偶」——
* **配偶的父母不在画布上**。拿遍历集合当判据,就会得出"岳父母已经在家谱里了"
* 这种与眼睛看到的相反的结论(实测踩到)。
*
* @param array<int,true> $onTree 遍历到的人(用于确定根与限制范围)
* @return array<int, array{name:string, count:int}> 人 id => 提示文案
*/
public static function branchOuts(array $g, array $people, array $onTree): array
{
// 本图的根 = 辈分最高的那个人(与 kin/tree 的取法一致)
$rootId = 0;
foreach (array_keys($onTree) as $id) {
$id = (int)$id;
if (!isset($people[$id])) {
continue;
}
$hasParentOnTree = false;
foreach (self::parentIds($g, $id) as $p) {
if (isset($onTree[(int)$p])) {
$hasParentOnTree = true;
break;
}
}
if (!$hasParentOnTree) {
$rootId = $id;
break;
}
}
if ($rootId === 0) {
return [];
}
$drawn = self::drawnOf($g, $rootId, $onTree);
$out = [];
foreach (array_keys($drawn) as $id) {
$id = (int)$id;
if ($id === $rootId || !isset($people[$id])) {
continue;
}
$parents = self::parentIds($g, $id);
if (!$parents) {
continue; // 不知道父母的(根、待确认的占位人)没什么可展开
}
$outside = 0;
foreach ($parents as $p) {
if (!isset($drawn[(int)$p])) {
$outside++;
}
}
if ($outside === 0) {
continue; // 父母都在画布上,这一侧已经露出来了
}
$out[$id] = [
'name' => (string)($people[$id]['name'] ?? ''),
'count' => $outside,
];
}
return $out;
}
/**
* family-chart 实际会画出来的人(近似复刻它的选择规则)。
*
* 规则(按库的行为):
* ① 根的**祖先链**(一直往上);
* ② 根的**后代**(一直往下 —— 库在未设上限时不截断);
* ③ 根的**兄弟姐妹**(与根共享父母的人);
* ④ 以上所有人的**配偶**(夫妻并排是库的基本单位)。
*
* ⚠️ **配偶的父母不在其中** —— 这正是"姻亲看不到"的根源,也是本函数存在的理由。
*
* @param array<int,true> $inScope 只在遍历到的人里挑(脏数据不越界)
* @return array<int,true>
*/
private static function drawnOf(array $g, int $rootId, array $inScope): array
{
$drawn = [];
$mark = static function (int $id) use (&$drawn, $inScope): bool {
if (!isset($inScope[$id]) || isset($drawn[$id])) {
return false;
}
$drawn[$id] = true;
return true;
};
$mark($rootId);
// ① 祖先链
$cur = $rootId;
for ($i = 0; $i < 12; $i++) {
$next = 0;
foreach (self::parentIds($g, $cur) as $p) {
if (isset($inScope[(int)$p])) { $next = (int)$p; break; }
}
if ($next === 0) { break; }
$mark($next);
$cur = $next;
}
// ③ 根的兄弟姐妹(共享父母)
foreach (self::parentIds($g, $rootId) as $p) {
foreach (self::childIds($g, (int)$p) as $sib) {
$mark((int)$sib);
}
}
// ② 后代:从根出发逐层往下;④ 每加进一个人,配偶跟着进来
$queue = array_keys($drawn);
while ($queue) {
$id = (int)array_shift($queue);
foreach (self::spouseIds($g, $id) as $s) {
if ($mark((int)$s)) { $queue[] = (int)$s; }
}
foreach (self::childIds($g, $id) as $c) {
if ($mark((int)$c)) { $queue[] = (int)$c; }
}
}
// 收尾再来一轮配偶(新进来的孩子要带上他们的配偶)
foreach (array_keys($drawn) as $id) {
foreach (self::spouseIds($g, (int)$id) as $s) {
$mark((int)$s);
}
}
return $drawn;
}
/** /**
* v15:把「孩子」按**父母所在的户**归堆。 * v15:把「孩子」按**父母所在的户**归堆。
* *
@@ -566,7 +709,13 @@ final class Kinship
return $isKnown ? ($isMale ? '表兄弟' : '表姐妹') : '表亲'; return $isKnown ? ($isMale ? '表兄弟' : '表姐妹') : '表亲';
} }
return '亲属'; // ⚠️ v24(用户 2026-10-02 第 1 条):这里原来兜底返回「亲属」。
// 后果是名册里**每个没有血缘关系的人**(同事、朋友、老师…)都被贴上「亲属」——
// 人家明明只是个联系人,叫"亲属"是错的,而且人一多满屏都是同一个词,
// 看不出任何信息。「亲属」这个词本身也没有定义:关系推导不出来就是没关系,
// 硬套一个称呼反而是编造。
// 现在改为**返回空串**:没关系就不显示标签。调用方一律用 empty() 判断。
return '';
} }
/** 配偶的称呼:按「我的性别」定,因为中文里丈夫/妻子是从自己视角说的 */ /** 配偶的称呼:按「我的性别」定,因为中文里丈夫/妻子是从自己视角说的 */
+314
View File
@@ -0,0 +1,314 @@
<?php
/**
* 财务 / 账本(v24 用户第 15 条)
*
* 用户原话:「增加财务模块,用户可以添加自己的财务状况,例如存款、基金、保险、房子、车子等,
* 输入金额,最后给计算出所有的资金总数。这个模块,要先创建一个家庭,或者说,
* 创建一个账本,这个账本,可以共享给某个人,共享后,这个人就可以往这个账本加入账目,
* 这样,双方都可以看到所有的账目,最后计算出总数,这就是一个家庭的总账目。」
*
* 所以模型是两层:
* **账本(ledger)** —— 一个家庭的总账;可以共享给某个人(可读 / 可写)。
* **账目(ledger_entries)** —— 一笔资产/负债:存款、基金、保险、房子、车子…
*
* 三个设计决定:
* ① 金额一律用**分**存(BIGINT),不用浮点。
* 钱是最不能容忍 0.1+0.2=0.30000000000000004 的地方。
* 对外一律走 amountYuan() 转成元来显示,四舍五入到分。
* ② 分清「资产 / 负债」:房子车子是资产,房贷车贷是负债。
* 只把资产加总会把"欠着银行两百万"这件事藏起来。净资产 = 资产 − 负债。
* ③ 账本为空时**自动建一个默认账本**并把它共享给建号的人,
* 否则新用户进财务页一片空白、还不知道要先"创建家庭"。
*/
declare(strict_types=1);
namespace App;
final class Ledger
{
/** 账目分类:key => [名字, 是资产还是负债, 图标] */
public const KINDS = [
'deposit' => ['存款', 'asset', '🏦'],
'fund' => ['基金', 'asset', '📈'],
'stock' => ['股票', 'asset', '📊'],
'insurance'=> ['保险', 'asset', '🛡'],
'house' => ['房子', 'asset', '🏠'],
'car' => ['车子', 'asset', '🚗'],
'cash' => ['现金', 'asset', '💵'],
'loan' => ['贷款', 'debt', '📉'],
'credit' => ['信用卡', 'debt', '💳'],
'other' => ['其它', 'asset', '📦'],
];
public const PERM = ['read' => '只读', 'write' => '可写'];
/**
* 我能看到的账本 = 我建的 + 共享给我的。
* 全都带上 my_perm(我在这本账上能做什么),上层不必再查一次。
*/
public static function mine(int $familyId, int $personId): array
{
$st = Database::get()->prepare(
'SELECT l.*, s.perm AS share_perm,
(SELECT COUNT(*) FROM ledger_entries e WHERE e.ledger_id = l.id) AS entry_count
FROM ledgers l
LEFT JOIN ledger_shares s ON s.ledger_id = l.id AND s.person_id = ?
WHERE l.family_id = ? AND (l.owner_id = ? OR s.person_id IS NOT NULL)
ORDER BY l.id'
);
$st->execute([$personId, $familyId, $personId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$r['my_perm'] = (int)$r['owner_id'] === $personId
? 'write'
: (string)($r['share_perm'] ?? 'read');
$out[] = $r;
}
return $out;
}
public static function find(int $familyId, int $id, int $personId): ?array
{
$st = Database::get()->prepare(
'SELECT l.*, s.perm AS share_perm FROM ledgers l
LEFT JOIN ledger_shares s ON s.ledger_id = l.id AND s.person_id = ?
WHERE l.id = ? AND l.family_id = ? AND (l.owner_id = ? OR s.person_id IS NOT NULL)'
);
$st->execute([$personId, $id, $familyId, $personId]);
$r = $st->fetch();
if (!$r) {
return null;
}
$r['my_perm'] = (int)$r['owner_id'] === $personId ? 'write' : (string)($r['share_perm'] ?? 'read');
return $r;
}
public static function canWrite(array $ledger): bool
{
return ($ledger['my_perm'] ?? '') === 'write';
}
/** 账本里的全部账目 */
public static function entries(int $ledgerId): array
{
$st = Database::get()->prepare(
'SELECT e.*, p.name AS person_name, p.avatar, p.avatar_color,
n.nickname AS person_nick
FROM ledger_entries e
LEFT JOIN persons p ON p.id = e.person_id
LEFT JOIN nicknames n ON n.target_id = e.person_id AND n.viewer_id = ?
WHERE e.ledger_id = ? ORDER BY e.id'
);
$st->execute([0, $ledgerId]);
return $st->fetchAll();
}
/**
* 这本账的汇总。
* @return array{asset:int,debt:int,net:int,by_kind:array<string,int>,count:int}
*/
public static function summary(array $entries): array
{
$asset = 0;
$debt = 0;
$byKind = [];
foreach ($entries as $e) {
$amt = (int)($e['amount_cents'] ?? 0);
$kind = (string)($e['kind'] ?? 'other');
$isDebt = (self::KINDS[$kind][1] ?? 'asset') === 'debt';
if ($isDebt) {
$debt += $amt;
} else {
$asset += $amt;
}
$byKind[$kind] = ($byKind[$kind] ?? 0) + $amt;
}
return [
'asset' => $asset,
'debt' => $debt,
'net' => $asset - $debt,
'by_kind' => $byKind,
'count' => count($entries),
];
}
// ---------------- 写 ----------------
/** @return int 新账本 id */
public static function create(int $familyId, int $ownerId, string $name): int
{
$db = Database::get();
$name = trim($name);
if ($name === '') {
$name = '家庭账本';
}
$db->prepare('INSERT INTO ledgers (family_id, owner_id, name) VALUES (?,?,?)')
->execute([$familyId, $ownerId ?: null, mb_substr($name, 0, 60, 'UTF-8')]);
return (int)$db->lastInsertId();
}
/**
* 这个家庭至少有**一本我能看到的**账本;没有就建一本并给我共享。
* @return int 账本 id
*/
public static function ensureDefault(int $familyId, int $personId): int
{
$mine = self::mine($familyId, $personId);
if ($mine) {
return (int)$mine[0]['id'];
}
$id = self::create($familyId, $personId, '家庭账本');
return $id; // 建的人就是 owner,天然能写
}
public static function rename(int $familyId, int $id, string $name): void
{
Database::get()->prepare('UPDATE ledgers SET name = ? WHERE id = ? AND family_id = ?')
->execute([mb_substr(trim($name), 0, 60, 'UTF-8'), $id, $familyId]);
}
public static function remove(int $familyId, int $id): void
{
// 账目与共享行靠外键 ON DELETE CASCADE 一起走
Database::get()->prepare('DELETE FROM ledgers WHERE id = ? AND family_id = ?')
->execute([$id, $familyId]);
}
/** 共享给某人(重复共享 = 改权限) */
public static function setShare(int $ledgerId, int $personId, string $perm = 'read'): void
{
$perm = isset(self::PERM[$perm]) ? $perm : 'read';
Database::get()->prepare(
'INSERT INTO ledger_shares (ledger_id, person_id, perm) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
)->execute([$ledgerId, $personId, $perm]);
}
public static function unshare(int $ledgerId, int $personId): void
{
Database::get()->prepare('DELETE FROM ledger_shares WHERE ledger_id = ? AND person_id = ?')
->execute([$ledgerId, $personId]);
}
public static function shares(int $ledgerId): array
{
$st = Database::get()->prepare(
'SELECT s.*, p.name, p.avatar, p.avatar_color
FROM ledger_shares s JOIN persons p ON p.id = s.person_id
WHERE s.ledger_id = ? ORDER BY s.id'
);
$st->execute([$ledgerId]);
return $st->fetchAll();
}
/** @return int 新增账目 id */
public static function addEntry(int $ledgerId, array $d, int $byId = 0): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO ledger_entries (ledger_id, kind, title, amount_cents, note, person_id, created_by)
VALUES (?,?,?,?,?,?,?)'
)->execute([
$ledgerId,
self::kind($d['kind'] ?? ''),
mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'),
self::toCents($d['amount'] ?? ''),
trim((string)($d['note'] ?? '')) ?: null,
(int)($d['person_id'] ?? 0) ?: null,
$byId ?: null,
]);
return (int)$db->lastInsertId();
}
public static function updateEntry(int $ledgerId, int $id, array $d): void
{
Database::get()->prepare(
'UPDATE ledger_entries SET kind=?, title=?, amount_cents=?, note=?, person_id=?
WHERE id=? AND ledger_id=?'
)->execute([
self::kind($d['kind'] ?? ''),
mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'),
self::toCents($d['amount'] ?? ''),
trim((string)($d['note'] ?? '')) ?: null,
(int)($d['person_id'] ?? 0) ?: null,
$id, $ledgerId,
]);
}
public static function removeEntry(int $ledgerId, int $id): void
{
Database::get()->prepare('DELETE FROM ledger_entries WHERE id = ? AND ledger_id = ?')
->execute([$id, $ledgerId]);
}
// ---------------- 钱 ----------------
/**
* 「元」→「分」。
*
* ⚠️ 不能用 (int)($yuan * 100):浮点乘法会掉一分(19.99 * 100 = 1998.9999…)。
* 一律走字符串:拆出整数部与小数部,各自转整数再拼。
*/
public static function toCents($yuan): int
{
$s = trim((string)$yuan);
$s = str_replace([',', ',', ' '], '', $s);
if ($s === '') {
return 0;
}
$neg = false;
if ($s[0] === '-') {
$neg = true;
$s = substr($s, 1);
}
if (!preg_match('/^(\d*)(?:\.(\d*))?$/', $s, $m)) {
return 0;
}
$intPart = $m[1] !== '' ? $m[1] : '0';
$dec = $m[2] ?? '';
$dec = str_pad(substr($dec, 0, 2), 2, '0'); // 超过两位小数直接截断(不做四舍五入到厘)
$cents = (int)$intPart * 100 + (int)$dec;
return $neg ? -$cents : $cents;
}
/** 「分」→ 显示用的「元」字符串(总是两位小数) */
public static function amountYuan($cents): string
{
$c = (int)$cents;
$neg = $c < 0;
$c = abs($c);
$s = sprintf('%d.%02d', intdiv($c, 100), $c % 100);
return ($neg ? '-' : '') . $s;
}
/** 带千分位、带 ¥ 的显示(列表/汇总用) */
public static function money($cents): string
{
$c = (int)$cents;
$neg = $c < 0;
$c = abs($c);
$s = number_format(intdiv($c, 100)) . '.' . sprintf('%02d', $c % 100);
return ($neg ? '-' : '') . '¥' . $s;
}
public static function kind(string $k): string
{
return isset(self::KINDS[$k]) ? $k : 'other';
}
public static function kindWord(string $k): string
{
return self::KINDS[$k][0] ?? '其它';
}
public static function kindIcon(string $k): string
{
return self::KINDS[$k][2] ?? '📦';
}
public static function isDebt(string $k): bool
{
return (self::KINDS[$k][1] ?? 'asset') === 'debt';
}
}
+28 -2
View File
@@ -41,10 +41,23 @@ final class Module
'calendar' => ['日程', '#B0763A', 'calendar'], 'calendar' => ['日程', '#B0763A', 'calendar'],
'resources' => ['资源', '#5566A8', 'resources'], 'resources' => ['资源', '#5566A8', 'resources'],
'tasks' => ['任务', '#A8574F', 'tasks'], 'tasks' => ['任务', '#A8574F', 'tasks'],
// v24 第 13 条:纪念日(含名册里每个人的生日)
'days' => ['纪念日', '#B4633C', 'days'],
// v24 第 15 条:财务(账本 + 账目 + 共享)
'ledger' => ['财务', '#5E7A3C', 'ledger'],
]; ];
/** 默认出现在导航条上的(没自定义过就用它)。首页永远在,且不能被拖到最后面也不影响。 */ /**
public const DEFAULT_NAV = ['home', 'kin', 'people', 'movies', 'trips', 'calendar', 'resources', 'tasks']; * 默认出现在导航条上的(没自定义过就用它)。首页永远在,且不能被拖到最后面也不影响。
*
* ⚠️ v24(用户 2026-10-02 第 14 条):**家谱默认不上导航条** ——
* "毕竟,不需要每天都查看家谱"。家谱模块本身还在(个人设置里能勾回来),
* 只是不再占顶栏的位置。
*/
public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources', 'tasks', 'days', 'ledger'];
/** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */
private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24';
/** /**
* 主题色的预设色板(个人设置里点一下就换)。 * 主题色的预设色板(个人设置里点一下就换)。
@@ -106,6 +119,19 @@ final class Module
$saved[] = $k; $saved[] = $k;
} }
} }
// v24 一次性迁移:老用户存过配置、里面还留着家谱的,帮他撤下来。
// ⚠️ 只做一次(记 NAV_KIN_OFF_FLAG)—— 之后他自己在设置里勾回来就该尊重他,
// 否则每次请求都删一遍 = "家谱开关"永远失效。
if ($personId > 0
&& (string)UserSetting::get($personId, self::NAV_KIN_OFF_FLAG, '') === '') {
UserSetting::set($personId, self::NAV_KIN_OFF_FLAG, '1');
if (in_array('kin', $saved, true)) {
$saved = array_values(array_filter($saved, static fn($k) => $k !== 'kin'));
UserSetting::set($personId, 'nav_modules',
json_encode($saved, JSON_UNESCAPED_UNICODE));
}
}
// ⚠️ 「新装的模块要自动出现」**不能**用"不在 saved 里就补"来实现 —— // ⚠️ 「新装的模块要自动出现」**不能**用"不在 saved 里就补"来实现 ——
// 那样用户**主动关掉**的模块下一次请求就会被补回来, // 那样用户**主动关掉**的模块下一次请求就会被补回来,
// 「可设置哪些模块出现在导航条」直接失效(实测:关掉观影,刷新它又回来了)。 // 「可设置哪些模块出现在导航条」直接失效(实测:关掉观影,刷新它又回来了)。
+11 -1
View File
@@ -123,7 +123,17 @@ final class Resource
self::textOrNull($d['content'] ?? ''), self::textOrNull($d['content'] ?? ''),
$createdBy ?: null, $createdBy ?: null,
]); ]);
return (int)$db->lastInsertId(); $id = (int)$db->lastInsertId();
// v24(用户 2026-10-02 第 17 条):**我建的、挂在别人名下的资源,我自己也要看得见**。
// 以前只有 owner 能看(共享表里不含 owner),于是"我帮孩子录了一张证件"之后,
// 这条资源从我的列表里**消失了** —— 我刚建的东西自己却找不到,看着像数据丢了。
// 现在建的同时给自己补一条共享(可读写):既能看能改,也不会在"共享给谁"
// 的列表里冒出 owner 自己(owner 那栏由 perm='owner' 单独表示)。
if ($createdBy > 0 && $createdBy !== $ownerId) {
self::setShare($id, $ownerId, $createdBy, 'write');
}
return $id;
} }
public static function update(int $familyId, int $id, array $d): void public static function update(int $familyId, int $id, array $d): void
+227
View File
@@ -119,6 +119,19 @@ CREATE TABLE IF NOT EXISTS `accounts` (
UNIQUE KEY `uk_account_person` (`person_id`), UNIQUE KEY `uk_account_person` (`person_id`),
CONSTRAINT `fk_accounts_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE CONSTRAINT `fk_accounts_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录账号' ) 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,
<<<'SQL' <<<'SQL'
CREATE TABLE IF NOT EXISTS `relations` ( 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) { 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 上能跑**: * ⚠️ `birthday_lunar = ''` 这种写法**只在本机的 MariaDB 上能跑**:
* MySQL 严格模式(线上是 8.0 + STRICT_TRANS_TABLES)会把右边的 `''` * MySQL 严格模式(线上是 8.0 + STRICT_TRANS_TABLES)会把右边的 `''`
+222 -9
View File
@@ -35,6 +35,202 @@ final class Task
/** 附件的宿主类型 */ /** 附件的宿主类型 */
public const ATTACH = 'task'; public const ATTACH = 'task';
// ---------------- 看板与分类(v24 用户第 9、10 条)----------------
/**
* 这个家庭的看板列表。
*
* ⚠️ 「看板」与「状态」是两件事:状态是这条任务做到哪一步,看板是它属于哪一块
* (家务 / 装修 / 旅行准备)。之前只有状态,想分开看只能给标题加前缀。
*/
public static function boards(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT b.*, (SELECT COUNT(*) FROM tasks t WHERE t.board_id = b.id AND t.parent_id IS NULL
AND t.status <> "done") AS open_count
FROM task_boards b WHERE b.family_id = ? ORDER BY b.position, b.id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
public static function categories(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT c.*, (SELECT COUNT(*) FROM tasks t WHERE t.category_id = c.id) AS used_count
FROM task_categories c WHERE c.family_id = ? ORDER BY c.position, c.id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
public static function boardName(int $familyId, ?int $boardId): string
{
if (!$boardId) {
return '默认看板';
}
$st = Database::get()->prepare('SELECT name FROM task_boards WHERE id = ? AND family_id = ?');
$st->execute([$boardId, $familyId]);
$v = $st->fetchColumn();
return $v === false ? '' : (string)$v;
}
/**
* 这个家庭至少有一个看板(升级时给老家庭补,新家庭在建号时补)。
* @return int 默认看板的 id
*/
public static function ensureDefaultBoard(int $familyId): int
{
$db = Database::get();
$st = $db->prepare('SELECT id FROM task_boards WHERE family_id = ? ORDER BY position, id LIMIT 1');
$st->execute([$familyId]);
$id = (int)$st->fetchColumn();
if ($id > 0) {
return $id;
}
$db->prepare('INSERT INTO task_boards (family_id, name, position) VALUES (?,?,?)')
->execute([$familyId, '日常', 100]);
return (int)$db->lastInsertId();
}
/** @return int 新建看板的 id */
public static function createBoard(int $familyId, string $name): int
{
$db = Database::get();
$db->prepare('INSERT INTO task_boards (family_id, name, position) VALUES (?,?,?)')
->execute([$familyId, self::shortName($name), self::nextBoardPosition($familyId)]);
return (int)$db->lastInsertId();
}
public static function renameBoard(int $familyId, int $id, string $name): void
{
Database::get()->prepare('UPDATE task_boards SET name = ? WHERE id = ? AND family_id = ?')
->execute([self::shortName($name), $id, $familyId]);
}
/**
* 删看板:里头的任务**不删**,挪到默认看板上(删看板 ≠ 删任务,这个代价太突然)。
*/
public static function deleteBoard(int $familyId, int $id): void
{
$db = Database::get();
$st = $db->prepare('SELECT COUNT(*) FROM task_boards WHERE family_id = ?');
$st->execute([$familyId]);
if ((int)$st->fetchColumn() <= 1) {
return; // 最后一个看板不许删:删了界面上就没处放任务
}
$def = self::ensureDefaultBoard($familyId);
$db->prepare('UPDATE tasks SET board_id = ? WHERE board_id = ? AND family_id = ?')
->execute([$def === $id ? null : $def, $id, $familyId]);
$db->prepare('DELETE FROM task_boards WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
}
/** @return int 新建分类的 id */
public static function createCategory(int $familyId, string $name): int
{
$db = Database::get();
$db->prepare('INSERT INTO task_categories (family_id, name, position) VALUES (?,?,?)')
->execute([$familyId, self::shortName($name), self::nextCategoryPosition($familyId)]);
return (int)$db->lastInsertId();
}
public static function renameCategory(int $familyId, int $id, string $name): void
{
Database::get()->prepare('UPDATE task_categories SET name = ? WHERE id = ? AND family_id = ?')
->execute([self::shortName($name), $id, $familyId]);
}
/** 删分类:用过的任务变成「未分类」,任务本身不动 */
public static function deleteCategory(int $familyId, int $id): void
{
$db = Database::get();
$db->prepare('UPDATE tasks SET category_id = NULL WHERE category_id = ? AND family_id = ?')
->execute([$id, $familyId]);
$db->prepare('DELETE FROM task_categories WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
}
/**
* 已完成的任务(v24 第 10 条:从看板上消失,另外一处按分类查看/搜索)。
*
* @param int $categoryId 0=全部分类
* @param string $keyword 标题里含这个串(空=不限)
*/
public static function doneList(int $familyId, int $personId, int $categoryId = 0, string $keyword = ''): array
{
$kw = trim($keyword);
$sql = 'SELECT t.*, c.name AS category_name,
a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick
FROM tasks t
LEFT JOIN task_categories c ON c.id = t.category_id
LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
WHERE t.family_id = ? AND t.status = "done" AND t.parent_id IS NULL
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)';
$args = [$personId, $personId, $familyId, $personId, $personId];
if ($categoryId > 0) {
$sql .= ' AND t.category_id = ?';
$args[] = $categoryId;
}
if ($kw !== '') {
$sql .= ' AND t.title LIKE ?';
$args[] = '%' . $kw . '%';
}
$sql .= ' ORDER BY t.done_at DESC, t.id DESC';
$st = Database::get()->prepare($sql);
$st->execute($args);
return $st->fetchAll();
}
/**
* 看板 id 归一化:0 / 不存在的 id → 默认看板,并且**必须属于这个家庭**
* (不校验的话,改一下表单就能把任务挂到别人家的看板上)。
* @return int|null NULL 表示"用默认看板"落在库里也存 NULL,读的时候 COALESCE 回来
*/
private static function boardIdOf(int $familyId, int $id): ?int
{
if ($id <= 0) {
return self::ensureDefaultBoard($familyId);
}
$st = Database::get()->prepare('SELECT id FROM task_boards WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$v = $st->fetchColumn();
return $v === false ? null : (int)$v;
}
/** 分类 id 归一化:0 = 未分类(NULL);不属于这个家庭的一律不认 */
private static function categoryIdOf(int $familyId, int $id): ?int
{
if ($id <= 0) {
return null;
}
$st = Database::get()->prepare('SELECT id FROM task_categories WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$v = $st->fetchColumn();
return $v === false ? null : (int)$v;
}
private static function shortName(string $n): string
{
$n = trim(preg_replace('/\s+/u', ' ', $n ?? ''));
return mb_substr($n, 0, 40, 'UTF-8');
}
private static function nextBoardPosition(int $familyId): int
{
$st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM task_boards WHERE family_id = ?');
$st->execute([$familyId]);
return (int)$st->fetchColumn();
}
private static function nextCategoryPosition(int $familyId): int
{
$st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM task_categories WHERE family_id = ?');
$st->execute([$familyId]);
return (int)$st->fetchColumn();
}
// ---------------- 读 ---------------- // ---------------- 读 ----------------
/** /**
@@ -43,24 +239,33 @@ final class Task
* 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。 * 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。
* ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。 * ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
*/ */
public static function board(int $familyId, int $personId): array public static function board(int $familyId, int $personId, int $boardId = 0): array
{ {
// ⚠️ v24 第 10 条:**已完成不出现在看板里**(用户原话「直接消失」)。
// 所以这里根本不查 status='done';它们改由 doneList() 单独呈现。
// 看板上仍然只有 todo / doing 两列 —— 第三列没了,才是"已完成消失"。
// 老任务的 board_id 是 NULL → 一律算在**默认看板**上(COALESCE 到默认看板 id)
$defId = self::ensureDefaultBoard($familyId);
$boardId = $boardId > 0 ? $boardId : $defId;
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color, 'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick, na.nickname AS assignee_nick,
(SELECT COUNT(*) FROM tasks c WHERE c.parent_id = t.id) AS child_total, c.name AS category_name,
(SELECT COUNT(*) FROM tasks c WHERE c.parent_id = t.id AND c.status = "done") AS child_done, (SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id) AS child_total,
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id AND k.status = "done") AS child_done,
(SELECT COUNT(*) FROM task_comments m WHERE m.task_id = t.id) AS comment_count, (SELECT COUNT(*) FROM task_comments m WHERE m.task_id = t.id) AS comment_count,
(SELECT COUNT(*) FROM attachments f WHERE f.target_type = "task" AND f.target_id = t.id) AS attach_count (SELECT COUNT(*) FROM attachments f WHERE f.target_type = "task" AND f.target_id = t.id) AS attach_count
FROM tasks t FROM tasks t
LEFT JOIN persons a ON a.id = t.assignee_id LEFT JOIN persons a ON a.id = t.assignee_id
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ? LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ? LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
WHERE t.family_id = ? AND t.parent_id IS NULL LEFT JOIN task_categories c ON c.id = t.category_id
WHERE t.family_id = ? AND t.parent_id IS NULL AND t.status <> "done"
AND COALESCE(t.board_id, ?) = ?
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL) AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
ORDER BY t.position, t.id' ORDER BY t.position, t.id'
); );
$st->execute([$personId, $personId, $familyId, $personId, $personId]); $st->execute([$personId, $personId, $familyId, $defId, $boardId, $personId, $personId]);
$rows = $st->fetchAll(); $rows = $st->fetchAll();
// 一次把全部子任务取出来挂上去(N+1 会让看板在几十条时明显变慢) // 一次把全部子任务取出来挂上去(N+1 会让看板在几十条时明显变慢)
@@ -70,6 +275,8 @@ final class Task
} }
unset($r); unset($r);
// ⚠️ 只有 todo / doing 两列:done 已经从看板上撤走了(v24 第 10 条)。
// 保留 'done' 这个键(恒空)只为兼容个别直接按 key 取的老调用。
$out = ['todo' => [], 'doing' => [], 'done' => []]; $out = ['todo' => [], 'doing' => [], 'done' => []];
foreach ($rows as $r) { foreach ($rows as $r) {
$k = (string)$r['status']; $k = (string)$r['status'];
@@ -216,11 +423,13 @@ final class Task
{ {
$db = Database::get(); $db = Database::get();
$db->prepare( $db->prepare(
'INSERT INTO tasks (family_id, parent_id, title, note, status, due_at, position, 'INSERT INTO tasks (family_id, board_id, category_id, parent_id, title, note, status, due_at,
creator_id, assignee_id, repeat_rule) position, creator_id, assignee_id, repeat_rule)
VALUES (?,?,?,?,?,?,?,?,?,?)' VALUES (?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([ )->execute([
$familyId, $familyId,
self::boardIdOf($familyId, (int)($d['board_id'] ?? 0)),
self::categoryIdOf($familyId, (int)($d['category_id'] ?? 0)),
self::parentId($familyId, (int)($d['parent_id'] ?? 0)), self::parentId($familyId, (int)($d['parent_id'] ?? 0)),
self::title($d['title'] ?? ''), self::title($d['title'] ?? ''),
self::textOrNull($d['note'] ?? ''), self::textOrNull($d['note'] ?? ''),
@@ -247,7 +456,7 @@ final class Task
$status = self::status($d['status'] ?? ''); $status = self::status($d['status'] ?? '');
$db->prepare( $db->prepare(
'UPDATE tasks SET title=?, note=?, status=?, due_at=?, assignee_id=?, repeat_rule=?, 'UPDATE tasks SET title=?, note=?, status=?, due_at=?, assignee_id=?, repeat_rule=?,
parent_id=? WHERE id=? AND family_id=?' parent_id=?, board_id=?, category_id=? WHERE id=? AND family_id=?'
)->execute([ )->execute([
self::title($d['title'] ?? ''), self::title($d['title'] ?? ''),
self::textOrNull($d['note'] ?? ''), self::textOrNull($d['note'] ?? ''),
@@ -256,6 +465,10 @@ final class Task
(int)($d['assignee_id'] ?? 0) ?: null, (int)($d['assignee_id'] ?? 0) ?: null,
self::repeat($d['repeat_rule'] ?? ''), self::repeat($d['repeat_rule'] ?? ''),
$pid, $pid,
array_key_exists('board_id', $d) ? self::boardIdOf($familyId, (int)$d['board_id']) : $old['board_id'],
array_key_exists('category_id', $d)
? self::categoryIdOf($familyId, (int)$d['category_id'])
: $old['category_id'],
$id, $familyId, $id, $familyId,
]); ]);
if ($status === 'done' && (string)$old['status'] !== 'done') { if ($status === 'done' && (string)$old['status'] !== 'done') {
+311 -30
View File
@@ -521,11 +521,28 @@ textarea.input { min-height: 76px; resize: vertical; }
.kin-card:hover { .kin-card:hover {
box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none; box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none;
} }
/* ⚠️ v24 第 12 条:头像是**方形**、不裁切。
卡片高度从 120 加到 136,把这块从 70 放大到 74×74 的正方形;
object-fit 用 contain(不是 cover)—— cover 会把非正方形的头像切掉边,
用户要的正是"所有人的头像都是方形的,别截断"。 */
.kin-card {
width: 148px; height: 138px;
}
.kin-card .kin-ph { .kin-card .kin-ph {
width: 100%; height: 70px; border-radius: 7px; overflow: hidden; width: 74px; height: 74px; border-radius: 6px; overflow: hidden;
background: #F1E7D6; flex: 0 0 auto; background: #F1E7D6; flex: 0 0 auto;
} }
.kin-card .kin-ph img { width: 100%; height: 100%; object-fit: cover; display: block; } .kin-card .kin-ph img {
width: 100%; height: 100%; object-fit: contain; display: block;
}
/* 「⇄ 姻亲」:卡片右下角的小按钮(这一侧的人没画在本图上时才有) */
.kin-card .kin-branch {
position: absolute; right: 3px; bottom: 3px;
font-size: 10px; line-height: 1; padding: 3px 5px; border-radius: 999px;
background: var(--brand); color: #fff; cursor: pointer;
box-shadow: 0 1px 3px rgba(43, 38, 32, .25);
}
.kin-card .kin-branch:hover { background: var(--brand-ink); }
.kin-card .kin-nm { .kin-card .kin-nm {
font-size: 12.5px; font-weight: 600; line-height: 1.25; max-width: 100%; font-size: 12.5px; font-weight: 600; line-height: 1.25; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
@@ -1148,6 +1165,13 @@ textarea.input { min-height: 76px; resize: vertical; }
.lb-btn:hover { background: rgba(255, 255, 255, .26); } .lb-btn:hover { background: rgba(255, 255, 255, .26); }
.lb-btn[hidden] { display: none; } .lb-btn[hidden] { display: none; }
.lb-close { top: 14px; right: 16px; width: 40px; height: 40px; border-radius: 50%; font-size: 18px; } .lb-close { top: 14px; right: 16px; width: 40px; height: 40px; border-radius: 50%; font-size: 18px; }
/* 下载按钮在关闭按钮左边,同高、同一套视觉(v24 第 3 条:预览层右上角要有下载) */
.lb-dl {
top: 14px; right: 66px; height: 40px; padding: 0 16px; border-radius: 20px;
display: inline-flex; align-items: center; font-size: 13px; font-weight: 600;
background: #fff; color: #1a1a1a; text-decoration: none;
}
.lb-dl:hover { background: #eee; }
.lb-nav { top: 50%; transform: translateY(-50%); width: 48px; height: 48px; border-radius: 50%; font-size: 28px; padding-bottom: 4px; } .lb-nav { top: 50%; transform: translateY(-50%); width: 48px; height: 48px; border-radius: 50%; font-size: 28px; padding-bottom: 4px; }
.lb-prev { left: 14px; } .lb-prev { left: 14px; }
.lb-next { right: 14px; } .lb-next { right: 14px; }
@@ -1205,54 +1229,217 @@ textarea.input { min-height: 76px; resize: vertical; }
.stars-disp { display: inline-flex; gap: 1px; font-size: 17px; line-height: 1; vertical-align: -2px; } .stars-disp { display: inline-flex; gap: 1px; font-size: 17px; line-height: 1; vertical-align: -2px; }
.stars-disp .d-on { color: #f5b301; } .stars-disp .d-on { color: #f5b301; }
.stars-disp .d-off { color: #d3d7de; } .stars-disp .d-off { color: #d3d7de; }
/* ---- 同行观影人选择(v10):chips + 弹窗 + 拼音首字母联想(v11 起带头像) ---- */ /* ---- 财务 / 账本(v24 第 15 条) ---- */
.att-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } /* 金额列右对齐 + 等宽数字(表格里数字不齐会很难扫) */
.att-chips:empty { display: none; } .table .num, .table th.num { text-align: right; font-variant-numeric: tabular-nums; }
.att-chip { .table tfoot th { border-top: 2px solid var(--line); border-bottom: 0; color: var(--ink); }
.led-sum-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; }
.led-total { display: flex; flex-wrap: wrap; gap: 18px; margin: 12px 0 4px; }
.led-total-item { display: flex; flex-direction: column; gap: 2px; }
.led-total-lb { font-size: 12px; color: var(--muted); }
.led-total-num {
font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums;
color: var(--ink);
}
.led-total-item.led-net .led-total-num { color: var(--brand); }
/* 数字颜色:**收入/资产用红、负债用绿**(与 A 股涨红跌绿同一套约定,不要反) */
.led-asset { color: #B0342C; }
.led-debt { color: #1F7A4D; }
.led-kinds { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.led-kind-chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 9px; border-radius: 999px; font-size: 12px;
background: var(--line-2); color: var(--ink); white-space: nowrap;
}
.led-kind-chip b { font-variant-numeric: tabular-nums; }
.led-kind-chip.is-debt { background: #E7F2EC; }
.led-shares { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; }
.led-share-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px;
}
.led-ops { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; }
.led-ops .inline-form { flex-wrap: wrap; align-items: center; }
.led-ops .input { min-width: 140px; }
.led-edit summary { cursor: pointer; }
.led-edit .node-form { min-width: min(520px, 86vw); }
/* ---- 纪念日(v24 第 13 条) ---- */
.ann-up { list-style: none; margin: 0; padding: 0; }
.ann-up-item {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px;
padding: 8px 2px; border-bottom: 1px solid var(--line-2); font-size: 14px;
}
.ann-up-item:last-child { border-bottom: 0; }
.ann-days {
flex: 0 0 auto; min-width: 62px; font-weight: 600; color: var(--brand);
font-variant-numeric: tabular-nums;
}
.ann-date { flex: 0 0 auto; color: var(--muted); font-variant-numeric: tabular-nums; }
.ann-title { flex: 1 1 auto; color: var(--ink); min-width: 0; }
.chk-inline { display: flex; align-items: center; gap: 6px; font-size: 14px; }
/* ---- 任务设置(v24 第 9、10 条):看板/分类的增删改列表 ---- */
.kb-boards { margin-bottom: 12px; }
.set-list { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 0; }
/* 一行 = 一个「改名」表单 + 一个「删除」表单,靠 flex 排成一条 */
.set-row { flex: 1 1 auto; display: flex; align-items: center; gap: 8px; margin: 0; }
.set-row .input { flex: 1 1 auto; min-width: 0; }
.set-tail { flex: 0 0 auto; }
/* 「已完成」归档列表 */
.done-list { list-style: none; margin: 0; padding: 0; }
.done-item {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
padding: 9px 2px; border-bottom: 1px solid var(--line-2); font-size: 14px;
}
.done-item:last-child { border-bottom: 0; }
.done-title { color: var(--ink); text-decoration: none; }
.done-title:hover { color: var(--brand); text-decoration: underline; }
/* ---- 附件缩略图墙 + 全局预览(v24 第 3 条) ----
规则:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;
可预览的点开在本页这一层里看,不可预览的点了直接下载。 */
.att-wall {
display: flex; flex-wrap: wrap; gap: 12px; margin: 8px 0 2px;
}
.att-cell { width: 132px; }
/* ⚠️ 缩略图与文档图标**必须是同一个盒子**:用户要的是"图标跟预览图一样大小",
所以尺寸只在这里写一次,图标靠 flex 撑满,不另给一套尺寸。 */
.att-thumb {
display: flex; align-items: center; justify-content: center;
width: 132px; height: 96px; border-radius: 8px; overflow: hidden;
border: 1px solid var(--line); background: var(--line-2); position: relative;
}
.att-thumb:hover { border-color: var(--brand); }
.att-thumb img, .att-thumb video {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.att-thumb video { object-fit: cover; pointer-events: none; }
.att-play {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 22px; text-shadow: 0 2px 8px rgba(0, 0, 0, .6); pointer-events: none;
}
.att-doc {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 4px; width: 100%; height: 100%;
font-size: 12px; font-weight: 700; letter-spacing: .5px; color: #fff;
}
.att-doc::before {
content: ""; width: 34px; height: 34px; border-radius: 6px; background: rgba(255, 255, 255, .22);
}
.att-doc-pdf { background: #B0342C; }
.att-doc-doc { background: #2C5AA0; }
.att-doc-xls { background: #1F7A4D; }
.att-doc-ppt { background: #B4622A; }
.att-doc-text { background: #5F6672; }
.att-doc-file { background: #5F6672; }
.att-meta {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 6px;
margin-top: 4px; font-size: 12px; color: var(--muted);
}
.att-fname {
flex: 1 1 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--ink);
}
.att-fsize { flex: 0 0 auto; }
.att-meta form { display: inline; margin: 0; }
/* ---- 全局预览层 ---- */
.pv {
position: fixed; inset: 0; z-index: 320; display: flex; flex-direction: column;
background: rgba(0, 0, 0, .9); overscroll-behavior: contain;
}
/* ⚠️ .pv 自己声明了 display:flex,必须补这条,否则 preview.js 里的
box.hidden = true 藏不住它(与 .pp-item 同一个坑)。 */
.pv[hidden] { display: none; }
.pv-head {
flex: 0 0 auto; display: flex; align-items: center; gap: 10px;
padding: 12px 16px; color: #fff;
}
.pv-name {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 14px;
}
.pv-meta { flex: 0 0 auto; font-size: 12px; opacity: .7; }
/* 下载按钮恒在右上角(用户第 3 条点名要求) */
.pv-dl {
margin-left: auto; flex: 0 0 auto; padding: 6px 14px; border-radius: 8px;
background: #fff; color: #1a1a1a; font-size: 13px; font-weight: 600; text-decoration: none;
}
.pv-dl:hover { background: #eee; }
.pv-close {
flex: 0 0 auto; border: 0; background: rgba(255, 255, 255, .16); color: #fff;
width: 34px; height: 34px; border-radius: 50%; font-size: 16px; cursor: pointer;
}
.pv-close:hover { background: rgba(255, 255, 255, .28); }
.pv-stage {
flex: 1 1 auto; min-height: 0; display: flex; align-items: center; justify-content: center;
padding: 0 16px 16px;
}
.pv-img {
max-width: 92vw; max-height: 100%; width: auto; height: auto;
object-fit: contain; border-radius: 8px;
}
.pv-video { max-width: 92vw; max-height: 100%; width: auto; height: auto; background: #000; }
.pv-pdf {
width: 92vw; height: 100%; border: 0; border-radius: 8px; background: #fff;
}
@media (max-width: 560px) {
.att-cell, .att-thumb { width: 108px; }
.att-thumb { height: 80px; }
}
/* ---- 全局人员选择器(v24 第 11 条):chips + 弹窗 + 拼音首字母联想 ----
⚠️ .att-* 是这套组件早年在观影页的版本,已由 .pp-* 取代;
保留只为历史样式,新页面一律用 _person_picker.php。 */
.pp-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.pp-chip {
display: inline-flex; align-items: center; gap: 6px; padding: 3px 6px 3px 4px; display: inline-flex; align-items: center; gap: 6px; padding: 3px 6px 3px 4px;
border: 1px solid var(--line); border-radius: 999px; background: var(--surface); border: 1px solid var(--line); border-radius: 999px; background: var(--surface);
font-size: 13px; font-size: 13px;
} }
.att-chip img { .pp-chip img {
width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
background: #F1E7D6; background: #F1E7D6;
} }
.att-chip .att-x { .pp-chip .pp-x {
border: 0; background: none; color: var(--muted); font-size: 15px; border: 0; background: none; color: var(--muted); font-size: 15px;
line-height: 1; padding: 0 2px; cursor: pointer; line-height: 1; padding: 0 2px; cursor: pointer;
} }
.att-chip .att-x:hover { color: #c0392b; } .pp-chip .pp-x:hover { color: #c0392b; }
.att-modal { .pp-chip-empty { color: var(--muted); border-style: dashed; padding: 3px 10px; }
.pp-modal {
position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; position: fixed; inset: 0; z-index: 60; display: flex; align-items: center;
justify-content: center; background: rgba(0, 0, 0, .35); padding: 16px; justify-content: center; background: rgba(0, 0, 0, .35); padding: 16px;
} }
.att-modal[hidden] { display: none; } .pp-modal[hidden] { display: none; }
.att-modal-box { .pp-modal-box {
background: var(--surface); border-radius: 12px; box-shadow: 0 12px 40px rgba(0, 0, 0, .2); background: var(--surface); border-radius: 12px; box-shadow: 0 12px 40px rgba(0, 0, 0, .2);
width: min(420px, 100%); max-height: 70vh; display: flex; flex-direction: column; width: min(420px, 100%); max-height: 70vh; display: flex; flex-direction: column;
padding: 14px 16px; box-sizing: border-box; padding: 14px 16px; box-sizing: border-box;
} }
.att-modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } .pp-modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.att-close { .pp-close {
border: 0; background: none; font-size: 20px; line-height: 1; border: 0; background: none; font-size: 20px; line-height: 1;
color: var(--muted); cursor: pointer; padding: 2px 6px; color: var(--muted); cursor: pointer; padding: 2px 6px;
} }
.att-modal-box .input { margin-bottom: 8px; } .pp-modal-box .input { margin-bottom: 8px; }
.att-list { overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; } .pp-list { overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; }
.att-item { .pp-item {
display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer;
border-bottom: 1px solid var(--line-2); font-size: 14px; border-bottom: 1px solid var(--line-2); font-size: 14px;
} }
.att-item:last-child { border-bottom: 0; } /* ⚠️ 这条不能省:.pp-item 自己写了 display:flex,作者样式压过浏览器
.att-item:hover { background: var(--line-2); } [hidden] { display:none },缺了它「过滤」就成了摆设 —— 名字和拼音都搜不动。
.att-item input { accent-color: var(--brand); flex: 0 0 auto; } 弹窗 .pp-modal 同理(上面已有),凡是**自己声明了 display** 的元素要藏都得补一条。 */
.att-item .att-av { .pp-item[hidden] { display: none; }
.pp-item:last-child { border-bottom: 0; }
.pp-item:hover { background: var(--line-2); }
.pp-item input { accent-color: var(--brand); flex: 0 0 auto; }
.pp-item .pp-av {
width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
background: #F1E7D6; background: #F1E7D6;
} }
.att-item .att-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pp-item .pp-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.att-item .att-initials { margin-left: auto; color: var(--muted); font-size: 12px; } .pp-item .pp-initials { margin-left: auto; color: var(--muted); font-size: 12px; }
.att-empty { padding: 10px 12px; color: var(--muted); font-size: 13px; } .pp-item-none .pp-name { color: var(--muted); }
.pp-empty { padding: 10px 12px; color: var(--muted); font-size: 13px; }
.pp-empty[hidden] { display: none; }
/* ---- 豆瓣自动补充(v10):片名行 + 下拉候选 + 海报预览 ---- */ /* ---- 豆瓣自动补充(v10):片名行 + 下拉候选 + 海报预览 ---- */
.db-title-row { display: flex; gap: 8px; align-items: center; } .db-title-row { display: flex; gap: 8px; align-items: center; }
.db-title-row .input { flex: 1 1 auto; } .db-title-row .input { flex: 1 1 auto; }
@@ -1764,20 +1951,114 @@ textarea.input { min-height: 76px; resize: vertical; }
⚠️ 断点必须是 **720px**(站点既有的"窄屏留白 16px"断点),不能用 860: ⚠️ 断点必须是 **720px**(站点既有的"窄屏留白 16px"断点),不能用 860:
746px 宽时若只改顶栏留白,就会出现「顶栏 x=16、正文 x=24」错位 8px 746px 宽时若只改顶栏留白,就会出现「顶栏 x=16、正文 x=24」错位 8px
(几何检查会直接报出来)。窄屏规则只在站点真正切到小留白的那一档生效。 */ (几何检查会直接报出来)。窄屏规则只在站点真正切到小留白的那一档生效。 */
/* ==================== 月 / 周 / 天 三选一的分段控件 ==================== */
/* 三个按钮连成一条(相邻边各留一半边框,拼起来才是一整条外框),
当前那个用模块主题色填底 —— 一眼看出自己在哪种看法上。 */
.seg { display: inline-flex; }
.seg-btn {
padding: 6px 14px; font-size: 13px; line-height: 1;
color: var(--muted); background: var(--surface);
border: 1px solid var(--line); text-decoration: none;
}
.seg-btn + .seg-btn { border-left: 0; }
.seg-btn:first-child { border-radius: var(--radius-s) 0 0 var(--radius-s); }
.seg-btn:last-child { border-radius: 0 var(--radius-s) var(--radius-s) 0; }
.seg-btn:hover { background: var(--surface-2, #FDFBF7); text-decoration: none; }
.seg-btn.is-on {
background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600;
}
/* ==================== 周 / 天:按小时铺的时间网格 ==================== */
/* 结构:左边一条"小时刻度"沟槽(tg-gutter),右边 N 个日期列(tg-col)。
列高固定 = 24 小时 × 每小时 44px,日程块按 top/height 百分比**绝对定位**,
所以"09:15 的 20 分钟"就是一小条,不会跟 9 点整的会挤成一样高。 */
.cal-tg-card { padding: 0; overflow: auto; }
.tg-head { display: flex; position: sticky; top: 0; z-index: 3;
background: var(--surface); border-bottom: 1px solid var(--line); }
.tg-gutter {
flex: 0 0 56px; width: 56px; border-right: 1px solid var(--line);
display: flex; flex-direction: column; align-items: flex-end;
}
.tg-dayname {
flex: 1 1 0; min-width: 0; padding: 8px 6px; text-align: center;
border-left: 1px solid var(--line-2);
}
.tg-dayname:first-of-type { border-left: 0; }
.tg-dayname.is-today { background: #FDF6F1; }
.tg-wd { display: block; font-size: 11px; color: var(--faint); }
.tg-dd { display: block; font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
.tg-dayname.is-today .tg-dd { color: var(--brand); }
.tg-allday { display: flex; border-bottom: 1px solid var(--line); background: #FDFBF7; }
.tg-allday .tg-gutter { padding: 6px 6px; }
.tg-tag { font-size: 11px; color: var(--faint); }
.tg-adcell {
flex: 1 1 0; min-width: 0; padding: 4px 4px 6px; border-left: 1px solid var(--line-2);
display: flex; flex-direction: column; gap: 3px;
}
.tg-adcell:first-of-type { border-left: 0; }
.tg-body { display: flex; position: relative; }
.tg-hours { padding: 0; }
.tg-hr {
height: 44px; padding-right: 6px; padding-top: 2px;
font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums;
border-top: 1px solid var(--line-2); box-sizing: border-box;
}
.tg-hr:first-child { border-top: 0; }
.tg-col {
flex: 1 1 0; min-width: 0; position: relative;
height: 1056px; /* 24 × 44px,与 .tg-hr 的高度必须一致 */
border-left: 1px solid var(--line-2); box-sizing: border-box;
}
.tg-col:first-of-type { border-left: 0; }
.tg-col.is-today { background: #FDF6F1; }
.tg-line { display: block; height: 44px; border-top: 1px solid var(--line-2); box-sizing: border-box; }
.tg-line:first-child { border-top: 0; }
.tg-block {
position: absolute; left: 2px; right: 2px;
display: block; overflow: hidden; padding: 2px 5px;
border-radius: 4px; color: #fff; font-size: 11px; line-height: 1.35;
text-decoration: none; box-sizing: border-box;
border: 1px solid rgba(255,255,255,.35);
}
.tg-block:hover { text-decoration: none; filter: brightness(1.1); z-index: 2; }
.tg-block b { font-weight: 600; margin-right: 3px; }
/* 天视图只有一列,把列拉宽好读;周视图 7 列时列宽由 flex 平分 */
@media (max-width: 720px) {
/* 窄屏留不住 56px 的刻度沟槽 + 7 列,改成整体可横向滚动(.cal-tg-card 已 overflow:auto),
给每列一个最小宽度,滚动着看而不是把字挤成一团。 */
.tg-col, .tg-dayname, .tg-adcell { min-width: 92px; }
.cal-tg-card { -webkit-overflow-scrolling: touch; }
}
/* ⚠️ v24:导航从 7 个模块加到 9 个(纪念日 / 财务)之后,**746px 就放不下了**,
而"放不下"的表现不是换行、是顶栏被撑宽 20.5px:
`.nav` 内部产生横向滚动条(哪怕已用 scrollbar-width:none 隐藏),
`.topbar-inner` 的可用宽度就比 `.container` 少一档,
几何检查直接报「顶栏右 742.5 / 正文右 722.0」。
所以**不能**只在 720px 断点里给 .nav 加快收缩 ——
那样 721~1080 这一段区间没人管,正是出问题的地方。
正确做法:把「导航可收缩 + 横向滚动」提到**无媒体查询的基态**,
只在真的窄到需要缩留白时才改 padding(下面 720px 那段)。 */
.nav {
/* flex:1 1 auto + min-width:0 才真的会"被压缩";只写 min-width 时它仍是 auto 宽度,
内容一多就把顶栏撑出去(这就是 746px 那 20.5px 的来源)。 */
flex: 1 1 auto; min-width: 0;
overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none;
}
.nav::-webkit-scrollbar { display: none; }
.nav a { flex: 0 0 auto; }
@media (max-width: 720px) { @media (max-width: 720px) {
.topbar-inner { gap: 12px; padding: 0 16px; } .topbar-inner { gap: 12px; padding: 0 16px; }
/* min-width:0 是关键:flex 子项默认 min-width:auto,会拒绝被压缩, /* min-width:0 是关键:flex 子项默认 min-width:auto,会拒绝被压缩,
于是内部再 overflow-x 也撑不出滚动条,而是把父级一起顶宽。 */ 于是内部再 overflow-x 也撑不出滚动条,而是把父级一起顶宽。 */
.nav { .nav {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0;
margin-left: 0; margin-left: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* Firefox */
} }
.nav::-webkit-scrollbar { display: none; } /* Chrome/Safari:藏掉滚动条,仍可滑 */ .nav a { padding: 6px 10px; }
.nav a { flex: 0 0 auto; padding: 6px 10px; }
.topbar-right { margin-left: 0; gap: 6px; } .topbar-right { margin-left: 0; gap: 6px; }
/* 窄屏把「家书」两个字收起来,只留图标 —— 名字占的宽度正好够多放两个模块 */ /* 窄屏把「家书」两个字收起来,只留图标 —— 名字占的宽度正好够多放两个模块 */
.brand span { display: none; } .brand span { display: none; }
+189
View File
@@ -0,0 +1,189 @@
/**
* 全局人员选择器(v24 用户第 11 条)
*
* 一个页面里可能有多个 .pp(观影同行人 / 旅程同行人 / 资源归属 / 共享给谁 …),
* 所以这里按容器逐个初始化,状态全挂在容器自己身上,互不影响。
*
* 三件事:
* ① 汉字 → 拼音首字母(Intl 拼音排序 + 23 个锚字二分,零依赖)
* ② 搜索:名字 / 显示名 / 首字母 三者任一命中就留下
* ③ chips 与弹窗里的勾选状态双向同步
*/
(function () {
'use strict';
/* ------------------------------------------------------------------
* 汉字 → 拼音首字母
*
* 做法:a..z 各挑一个「锚字」(没有 i / u / v 开头的常用字,所以是 23 个),
* 用拼音 collation 二分定位某个汉字落在哪一段 —— 落在「垃」之后、「妈」之前
* 就是 l。整套零依赖,不需要塞一张几万字的拼音表进来。
*
* ⚠️ 浏览器不支持拼音排序时返回 null:此时只按名字搜,不硬出错误结果。
* ---------------------------------------------------------------- */
var initialsOf = (function () {
var anchors = '阿八擦搭娥发嘎哈击喀垃妈拿哦啪期然撒塌挖昔压匝';
var letters = 'abcdefghjklmnopqrstwxyz';
var coll;
try { coll = new Intl.Collator('zh-Hans-CN-u-co-pinyin'); } catch (e) { return null; }
// 排序没真正生效时 compare 会退化成按码点/笔画,这里直接认不出来 → 放弃拼音
if (!coll || coll.compare('阿', '匝') >= 0) { return null; }
return function (s) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
if (/[a-zA-Z]/.test(ch)) { out += ch.toUpperCase(); continue; }
if (ch.charCodeAt(0) < 0x2E80) { continue; } // 非 CJK:不产字母
var lo = 0, hi = anchors.length - 1, best = -1;
while (lo <= hi) {
var mid = (lo + hi) >> 1;
if (coll.compare(ch, anchors.charAt(mid)) >= 0) { best = mid; lo = mid + 1; }
else { hi = mid - 1; }
}
if (best >= 0) { out += letters.charAt(best); }
}
return out;
};
})();
/**
* q 是否是 s 的**子序列**。
* 为什么不用 indexOf:「杨永全」的首字母是 yyq,用户很自然地会打 yq ——
* 连续子串匹配不到,子序列能。选人这种小列表宁可放宽一点,也别让人以为坏了。
*/
function isSubSeq(q, s) {
if (!q) { return true; }
var i = 0;
for (var j = 0; j < s.length && i < q.length; j++) {
if (s.charAt(j) === q.charAt(i)) { i++; }
}
return i === q.length;
}
function init(root) {
var id = root.getAttribute('data-pp') || '';
var isMulti = root.getAttribute('data-pp-mode') !== 'single';
var modal = root.querySelector('.pp-modal');
var openBtn = root.querySelector('.pp-open');
var closeBtn = root.querySelector('.pp-close');
var search = root.querySelector('.pp-search');
var list = root.querySelector('.pp-list');
var chips = root.querySelector('.pp-chips');
var emptyTip = root.querySelector('.pp-empty');
if (!modal || !list || !chips) { return; }
// 预计算每个人的首字母串,挂在 data-initials 上(列表再长也只是算一次)
Array.prototype.forEach.call(list.querySelectorAll('.pp-item'), function (el) {
var s = (el.getAttribute('data-name') || '') + (el.getAttribute('data-shown') || '');
var ini = initialsOf ? initialsOf(s) : '';
el.setAttribute('data-initials', ini.toLowerCase());
var badge = el.querySelector('.pp-initials');
if (badge && ini) { badge.textContent = ini; badge.hidden = false; }
});
function syncChips() {
chips.innerHTML = '';
var any = false;
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input:checked'), function (box) {
if (box.value === '0') { return; } // 「不选」不进 chips
var item = box.closest('.pp-item');
if (!item) { return; }
any = true;
// ⚠️ 必须点名取 .pp-name:.pp-item 里第一个 span 是隐藏的首字母占位,
// 早年靠「取第一个 span 当名字」碰巧成立,加了首字母徽标后 chip 就没名字了。
var nameEl = item.querySelector('.pp-name');
var av = item.querySelector('.pp-av');
var chip = document.createElement('span');
chip.className = 'pp-chip';
chip.setAttribute('data-id', box.value);
if (av) {
var im = document.createElement('img');
im.src = av.getAttribute('src'); // 服务端已 e() 过,这里原样搬
im.alt = '';
chip.appendChild(im);
}
var nm = document.createElement('span');
nm.className = 'pp-name';
nm.textContent = nameEl ? nameEl.textContent : box.value;
chip.appendChild(nm);
if (isMulti) {
var x = document.createElement('button');
x.type = 'button'; x.className = 'pp-x'; x.textContent = '×';
x.title = '移除'; x.setAttribute('data-id', box.value);
chip.appendChild(x);
}
chips.appendChild(chip);
});
// ⚠️ 多选且一个都没选时**不要**塞占位 chip:.pp-chip 自己写了 display:inline-flex,
// 再给它 hidden 也藏不掉(作者样式压过 UA 的 [hidden]),会留下一个空胶囊。
if (!any && !isMulti) {
var ph = document.createElement('span');
ph.className = 'pp-chip pp-chip-empty';
ph.textContent = '未选择';
chips.appendChild(ph);
}
}
function applyFilter() {
var q = (search.value || '').trim().toLowerCase();
var shown = 0;
Array.prototype.forEach.call(list.querySelectorAll('.pp-item'), function (el) {
var hit = !q ||
(el.getAttribute('data-name') || '').toLowerCase().indexOf(q) >= 0 ||
(el.getAttribute('data-shown') || '').toLowerCase().indexOf(q) >= 0 ||
isSubSeq(q, el.getAttribute('data-initials') || '');
el.hidden = !hit;
if (hit) { shown++; }
});
if (emptyTip) { emptyTip.hidden = shown > 0; }
}
function openModal() {
modal.hidden = false;
search.value = '';
applyFilter();
syncChips();
search.focus();
}
function closeModal() { modal.hidden = true; }
if (openBtn) { openBtn.addEventListener('click', openModal); }
if (closeBtn) { closeBtn.addEventListener('click', closeModal); }
modal.addEventListener('click', function (e) { if (e.target === modal) { closeModal(); } });
search.addEventListener('input', applyFilter);
chips.addEventListener('click', function (e) {
var x = e.target.closest ? e.target.closest('.pp-x') : null;
if (!x) { return; }
var gid = x.getAttribute('data-id');
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input'), function (box) {
if (box.value === gid) { box.checked = false; }
});
syncChips();
});
/* 多选:勾一个 = 出 chip + 关弹窗(再添加就再点一次「+ 添加人」);
单选:点一个 = 换掉当前这个人 + 关弹窗 */
list.addEventListener('change', function (e) {
if (!e.target || e.target.tagName !== 'INPUT') { return; }
syncChips();
if (e.target.checked) { closeModal(); }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !modal.hidden) { closeModal(); }
});
syncChips();
}
function boot() {
Array.prototype.forEach.call(document.querySelectorAll('.pp'), init);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();
+101
View File
@@ -0,0 +1,101 @@
/**
* 全局附件预览(v24 用户第 3 条)
*
* 点缩略图 → **在本页**弹一层把内容放出来,不再新开标签页。
* · 图片 → <img>
* · 视频 → <video controls autoplay>(media.php 支持 Range,进度条能拖)
* · PDF → <iframe>(用浏览器自带的阅读器)
* · 其它(Word / Excel / …)→ 根本不进这里:那段链接指向的是下载地址,点了就下。
*
* 右上角恒有一个「下载」按钮(用户原话:所有的预览页面,右上角都有一个下载按钮)。
*
* 触发方式是数据属性,不用给每个地方写一遍初始化:
* <a href="…" data-prev="image|video|pdf" data-src="…" data-dl="…" data-name="…">
*/
(function () {
'use strict';
var box = document.getElementById('pv');
if (!box) { return; }
var stage = box.querySelector('.pv-stage');
var titleEl = box.querySelector('.pv-name');
var metaEl = box.querySelector('.pv-meta');
var dlEl = box.querySelector('.pv-dl');
var closeEl = box.querySelector('.pv-close');
var opener = null;
function clear() {
// ⚠️ 换内容要**整个重建**节点,不能只改 src:
// <video> 改 src 不会自动卸载上一支(声音会叠着放),<iframe> 同理。
stage.innerHTML = '';
}
function open(trigger) {
var kind = trigger.getAttribute('data-prev') || 'image';
var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || '';
var dl = trigger.getAttribute('data-dl') || src;
if (!src) { return; }
opener = trigger;
clear();
var el;
if (kind === 'video') {
el = document.createElement('video');
el.className = 'pv-video';
el.src = src;
el.controls = true;
el.autoplay = true;
el.playsInline = true;
} else if (kind === 'pdf') {
el = document.createElement('iframe');
el.className = 'pv-pdf';
el.src = src;
// PDF 是"另一份文档",别让它拿到本页的 window
el.setAttribute('sandbox', 'allow-same-origin allow-scripts');
} else {
el = document.createElement('img');
el.className = 'pv-img';
el.src = src;
el.alt = '';
}
stage.appendChild(el);
titleEl.textContent = trigger.getAttribute('data-name') || '';
var sz = trigger.getAttribute('data-size') || '';
metaEl.textContent = sz;
dlEl.setAttribute('href', dl);
// 下载要带上真实文件名:download 属性 + 服务端 Content-Disposition 双保险
var nm = trigger.getAttribute('data-name') || '';
if (nm) { dlEl.setAttribute('download', nm); } else { dlEl.removeAttribute('download'); }
box.hidden = false;
box.setAttribute('aria-hidden', 'false');
closeEl.focus();
}
function close() {
box.hidden = true;
box.setAttribute('aria-hidden', 'true');
clear(); // 停掉视频 / 卸掉 PDF
if (opener && opener.focus) { opener.focus(); }
opener = null;
}
document.addEventListener('click', function (e) {
var t = e.target && e.target.closest ? e.target.closest('[data-prev]') : null;
if (t) {
// ⚠️ 只拦"可预览"的:Word/Excel 那类链接没有 data-prev,
// 浏览器照常走 href(就是下载地址),不会被这里挡住。
e.preventDefault();
open(t);
return;
}
// 点遮罩(stage 本身或弹层空白处)关闭;点内容不关
if (!box.hidden && (e.target === box || e.target === stage)) { close(); }
});
closeEl.addEventListener('click', close);
document.addEventListener('keydown', function (e) {
if (!box.hidden && e.key === 'Escape') { close(); }
});
})();
+260
View File
@@ -0,0 +1,260 @@
<?php
/**
* 家书 FamilyBook · CalDAV 入口(v24 用户第 7 条后半)
*
* 「把我的日历本,通过 CalDAV 协议分享给别人」—— 别人在他们的
* iOS 日历 / 安卓 DAVx⁵ / Thunderbird / 群晖日历里填上这个地址就能订阅到。
*
* 地址:https://<站点>/dav/
* 账号:手机号或姓名(同登录)
* 口令:**CalDAV 专用口令**(在「日程 → 日历本」页生成,不是登录口令)
*
* ⚠️ 为什么单独一个文件而不是塞进 index.php:
* CalDAV 用的是 HTTP 方法名(PROPFIND / REPORT / OPTIONS / PUT),
* index.php 那套 `?r=xxx` 路由是 GET/POST 世界里的东西,
* 混在一起会让两边都变难懂。这里只做"按方法分派",业务在 App\CalDav 里。
*
* 本阶段是**只读**实现(RFC 4791 的子集):写方法一律 403。
* 与其"半支持"(客户端以为写成功了、服务端却没落库/没合冲突),不如明确拒绝。
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Auth;
use App\CalDav;
use App\Calendar;
/** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */
function dav_fail(int $code, string $msg): void
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
echo $msg;
exit;
}
// ---- 1. 鉴权:HTTP Basic(DAVx⁵ / iOS 都原生支持)----
$user = $_SERVER['PHP_AUTH_USER'] ?? null;
$pass = $_SERVER['PHP_AUTH_PW'] ?? null;
// 有些 SAPI 不填 PHP_AUTH_USER,得自己从 Authorization 头里拆
if ($user === null && !empty($_SERVER['HTTP_AUTHORIZATION'])) {
if (preg_match('/^Basic\s+(.+)$/i', (string)$_SERVER['HTTP_AUTHORIZATION'], $m)) {
$decoded = base64_decode($m[1], true);
if ($decoded !== false && strpos($decoded, ':') !== false) {
[$user, $pass] = explode(':', $decoded, 2);
}
}
}
$who = Auth::caldavAuth($user, $pass);
if (!$who) {
header('WWW-Authenticate: Basic realm="FamilyBook CalDAV"');
dav_fail(401, '需要 CalDAV 口令(在「日程 → 日历本」页生成,不是登录密码)。');
}
$familyId = (int)$who['family_id'];
$me = (int)$who['person_id'];
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/dav/'), PHP_URL_PATH);
$hit = CalDav::parsePath($path);
$bookId = (int)$hit['book'];
$evId = (int)$hit['event'];
// ---- 2. 只读:写方法直接拒(明确拒比"写一半"好)----
if (in_array($method, ['PUT', 'DELETE', 'PROPPATCH', 'MKCALENDAR', 'POST', 'PATCH'], true)) {
dav_fail(403, '家书的 CalDAV 目前是只读订阅:请在本站里修改日程。');
}
// ---- 3. OPTIONS:客户端第一句就问"你支持什么" ----
if ($method === 'OPTIONS') {
http_response_code(200);
header('DAV: 1, 2, calendar-access');
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
header('MS-Author-Via: DAV');
exit;
}
// ---- 4. 找不到那本 → 404(不要回 200 空列表:客户端会以为"这本是空的")----
$cal = null;
if ($bookId > 0) {
$cal = Calendar::mineOne($familyId, $me, $bookId);
if (!$cal) {
dav_fail(404, '没有这本日历。');
}
}
/** 日历本的显示名:带上是"谁的本"(共享来的本子同名时能分清) */
$dispName = static function (array $c) use ($me): string {
$nm = (string)$c['name'];
if ((int)($c['owner_id'] ?? 0) !== $me && !empty($c['owner_name'])) {
$nm .= '(' . (string)$c['owner_name'] . ')';
}
return $nm;
};
// ---- 5. PROPFIND:清单 / 属性 / 资源列表 ----
if ($method === 'PROPFIND') {
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
if ($bookId === 0) {
// 根:列出这个人可见的全部日历本
$parts = [];
foreach (CalDav::books($familyId, $me) as $c) {
$href = CalDav::hrefOf($c);
$parts[] = '<d:response><d:href>' . CalDav::x($href) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($dispName($c)) . '</d:displayname>'
. '<c:calendar-description>' . CalDav::x('家书 · ' . (string)$c['name']) . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x((string)$c['color']) . '</x:calendar-color>'
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
. '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>'
. '</d:prop></d:propstat></d:response>';
}
CalDav::multis();
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
. implode('', $parts) . '</d:multistatus>';
exit;
}
if ($depth === '1') {
// 某一本里的资源清单:客户端拿这个 ETag 判断"要不要重新拉"
$rows = CalDav::rows($cal, $familyId, $me);
$parts = [];
foreach ($rows as $r) {
$rid = (int)($r['id'] ?? 0);
$uid = (string)($r['uid'] ?? '');
// 派生本(观影 / 旅程)没有 calendar_events.id,用 uid 段做资源名
$seg = $rid > 0 ? (string)$rid : preg_replace('/[^A-Za-z0-9._-]/', '_', $uid);
if ($seg === '') {
continue;
}
$etag = '"' . md5($uid . '|' . (string)($r['starts_at'] ?? '') . '|' . (string)($r['updated_at'] ?? '')) . '"';
$parts[] = '<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CalDav::x($etag) . '</d:getetag>'
. '<d:resourcetype/>'
. '<d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>'
. '</d:prop></d:propstat></d:response>';
}
CalDav::multis();
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
. implode('', $parts) . '</d:multistatus>';
exit;
}
// Depth 0:这一天历本自身的属性
CalDav::multis();
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
. '<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId)) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($dispName($cal)) . '</d:displayname>'
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
. '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>'
. '</d:prop></d:propstat></d:response></d:multistatus>';
exit;
}
// ---- 6. GET / HEAD:整本导出成 ics(不少客户端就走这一条)----
if ($method === 'GET' || $method === 'HEAD') {
if (!$cal) {
dav_fail(404, '没有这本日历。');
}
$ics = CalDav::ics($cal, $familyId, $me);
$etag = '"' . md5($ics) . '"';
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
http_response_code(304);
header('ETag: ' . $etag);
exit;
}
http_response_code(200);
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . $etag);
header('Cache-Control: private, max-age=0, must-revalidate');
$fn = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)$cal['name']);
header('Content-Disposition: inline; filename="' . $fn . '.ics"');
if ($method === 'GET') {
echo $ics;
}
exit;
}
// ---- 7. REPORT:calendar-query(按时间范围) / calendar-multiget(按 href 取)----
if ($method === 'REPORT') {
if (!$cal) {
dav_fail(404, '没有这本日历。');
}
$bodyRaw = (string)file_get_contents('php://input');
// calendar-multiget:客户端点明要哪几条(我们按整本回,客户端自己挑)
$wantsMulti = stripos($bodyRaw, 'calendar-multiget') !== false;
// calendar-query 的时间范围(有就按范围裁;没有就整本)
$from = '';
$to = '';
if (preg_match('#<c:time-range[^>]*start="([^"]+)"#i', $bodyRaw, $m)) {
$from = substr($m[1], 0, 8); // 20260101T000000Z → 20260101
$from = substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2);
}
if (preg_match('#<c:time-range[^>]*end="([^"]+)"#i', $bodyRaw, $m)) {
$to = substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2);
}
// 单条资源请求(/dav/3.ics/12.ics)
if ($evId > 0) {
$one = CalDav::oneEvent($bookId, $evId, [$familyId, $me]);
if (!$one) {
dav_fail(404, '没有这条日程。');
}
$evIcs = \App\Ics::export([$one], (string)$cal['name']);
CalDav::multis();
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
. '<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId) . $evId . '.ics') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CalDav::x('"' . md5($evIcs) . '"') . '</d:getetag>'
. '<c:calendar-data>' . CalDav::x($evIcs) . '</c:calendar-data>'
. '</d:prop></d:propstat></d:response></d:multistatus>';
exit;
}
$rows = CalDav::rows($cal, $familyId, $me);
$parts = [];
foreach ($rows as $r) {
$day = substr((string)($r['starts_at'] ?? ''), 0, 10);
if ($from !== '' && $day !== '' && $day < $from) {
continue;
}
if ($to !== '' && $day !== '' && $day > $to) {
continue;
}
$rid = (int)($r['id'] ?? 0);
$uid = (string)($r['uid'] ?? '');
$seg = $rid > 0 ? (string)$rid : preg_replace('/[^A-Za-z0-9._-]/', '_', $uid);
if ($seg === '') {
continue;
}
$evIcs = \App\Ics::export([$r], (string)$cal['name']);
$parts[] = '<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CalDav::x('"' . md5($evIcs) . '"') . '</d:getetag>'
. '<c:calendar-data>' . CalDav::x($evIcs) . '</c:calendar-data>'
. '</d:prop></d:propstat></d:response>';
// calendar-multiget 时客户端自己按 href 挑;我们整本给,列表不长(家庭场景)
unset($wantsMulti);
}
CalDav::multis();
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
. implode('', $parts) . '</d:multistatus>';
exit;
}
// ---- 8. 其它方法一律 405(并告知支持哪些)----
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
dav_fail(405, '不支持这个请求方法。');
+567 -61
View File
@@ -19,7 +19,10 @@ use App\Crypto;
use App\Database; use App\Database;
use App\Douban; use App\Douban;
use App\Family; use App\Family;
use App\Anniversary;
use App\Ics;
use App\Kinship; use App\Kinship;
use App\Ledger;
use App\Movie; use App\Movie;
use App\MovieReview; use App\MovieReview;
use App\Module; use App\Module;
@@ -293,6 +296,81 @@ function build_month_grid(string $ym, array $events): array
return $weeks; return $weeks;
} }
/**
* 周视图 / 天视图的日期列(用户 2026-10-02 第 4 条)。
*
* 周视图固定 7 天;天视图就 1 天 —— 两者共用同一个"按小时铺的时间网格",
* 只是列数不同,所以放在一个函数里。
*
* @return array<int, array{date:string,w:int,day:int,is_today:bool}>
*/
function day_columns(string $anchor, int $span): array
{
$ts = strtotime($anchor);
if ($ts === false) {
$ts = time();
}
$w = (int)date('w', $ts); // 0=周日 … 6=周六
$mon = strtotime('-' . (($w + 6) % 7) . ' days', $ts); // 周一开头
$out = [];
for ($i = 0; $i < max(1, $span); $i++) {
$d = date('Y-m-d', strtotime('+' . $i . ' days', $mon));
$out[] = [
'date' => $d,
'w' => (int)date('w', strtotime($d)),
'day' => (int)date('j', strtotime($d)),
'is_today' => $d === date('Y-m-d'),
];
}
return $out;
}
/**
* 把日程摆进「小时 × 日期列」的时间网格(周视图 / 天视图共用)。
*
* 与月视图的不同:这里**按时段定位**(top/height 用百分比),
* 所以"09:15 的 20 分钟会"在格子里就是一小条,而不是跟"9 点的会"挤成一样高。
*
* ⚠️ 全天日程不进时间格 —— 它们没有时段,硬塞进 0 点会变成一条压在最上面的怪东西。
* 单独挂在顶上的一行(跟群晖日历一样)。
*
* @return array{all_day:array,timed:array} all_day 按列分组;timed 按列分组,含 top/height
*/
function build_time_grid(array $events, array $cols): array
{
$dayIndex = [];
foreach ($cols as $i => $c) {
$dayIndex[(string)$c['date']] = $i;
}
$allDay = array_fill(0, count($cols), []);
$timed = array_fill(0, count($cols), []);
foreach ($events as $e) {
$ci = $dayIndex[(string)$e['date']] ?? null;
if ($ci === null) {
continue;
}
if (!empty($e['all_day'])) {
$allDay[$ci][] = $e;
continue;
}
$t = (string)($e['time'] ?? '');
// 有具体时刻才进时间格;没有时刻又不是全天的(如任务全天),按全天处理
if ($t === '' || !preg_match('/^(\d{2}):(\d{2})/', $t, $m)) {
$allDay[$ci][] = $e;
continue;
}
$startMin = ((int)$m[1]) * 60 + (int)$m[2];
// 没有结束时刻的按 30 分钟画一条最短的可见块(不然高度是 0,等于看不见)
$endMin = $startMin + 30;
$timed[$ci][] = $e + [
'top' => round($startMin / 1440 * 100, 4),
'height' => round(max(30, $endMin - $startMin) / 1440 * 100, 4),
];
}
return ['all_day' => $allDay, 'timed' => $timed];
}
/** /**
* 读任务的「截止日期(+ 可选时刻)」。 * 读任务的「截止日期(+ 可选时刻)」。
* *
@@ -301,6 +379,13 @@ function build_month_grid(string $ym, array $events): array
*/ */
function read_due_input(): ?string function read_due_input(): ?string
{ {
// v24 第 5 条:截止时刻与全局其它地方一样走 `_datetime.php`(日期 + 时/分/秒一起选,
// 精确到秒)。下面那段 due_date / due_time 只是老表单的兜底,别删 ——
// 旧的链接、书签、脚本还在按老名字提交,删了就静默变成"没有截止日期"。
$full = read_dt_input('due_at');
if ($full !== null) {
return $full;
}
$d = trim((string)($_POST['due_date'] ?? '')); $d = trim((string)($_POST['due_date'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
return null; return null;
@@ -312,10 +397,13 @@ function read_due_input(): ?string
/** /**
* 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。 * 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。
* *
* ⚠️ 同一天太窄时会一条都没有(尤其刚用不久),所以**退回同月**: * ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**,
* 先给"就是这天",不够再补"这个月"的往年记录。标题由调用方按实际命中改写。 * 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**:
* "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事,
* 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。
* 现在只认同一天;没命中就明说"往年的这一天还没有记录"。
* *
* @return array{rows:array,exact:bool} exact=true 表示命中的真是"同一天" * @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容)
*/ */
function home_memories(int $familyId, int $limit = 6): array function home_memories(int $familyId, int $limit = 6): array
{ {
@@ -326,24 +414,17 @@ function home_memories(int $familyId, int $limit = 6): array
FROM events e FROM events e
WHERE e.family_id = ? AND e.kind IN ('movie','trip') AND e.starts_at IS NOT NULL WHERE e.family_id = ? AND e.kind IN ('movie','trip') AND e.starts_at IS NOT NULL
AND YEAR(e.starts_at) < ? AND YEAR(e.starts_at) < ?
AND DATE_FORMAT(e.starts_at, '%s') = ? AND DATE_FORMAT(e.starts_at, '%m-%d') = ?
ORDER BY e.starts_at DESC LIMIT %d"; ORDER BY e.starts_at DESC LIMIT " . (int)$limit;
$st = Database::get()->prepare(sprintf($sql, '%m-%d', $limit)); $st = Database::get()->prepare($sql);
$st->execute([$familyId, $year, substr($today, 5, 5)]); $st->execute([$familyId, $year, substr($today, 5, 5)]);
$rows = $st->fetchAll(); $rows = $st->fetchAll();
$exact = true;
if (!$rows) {
$st = Database::get()->prepare(sprintf($sql, '%m', $limit));
$st->execute([$familyId, $year, substr($today, 5, 2)]);
$rows = $st->fetchAll();
$exact = false;
}
foreach ($rows as &$r) { foreach ($rows as &$r) {
$r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4); $r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4);
$r['href'] = url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]); $r['href'] = url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]);
} }
unset($r); unset($r);
return ['rows' => $rows, 'exact' => $exact]; return ['rows' => $rows, 'exact' => true];
} }
/** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */ /** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */
@@ -502,9 +583,16 @@ switch ($r) {
// ⚠️ v15:家谱模板不再吃 Kinship::tree() 的产物(那套 units/generations 是给 // ⚠️ v15:家谱模板不再吃 Kinship::tree() 的产物(那套 units/generations 是给
// 旧画法用的),改由模板里调 Kinship::familyMap() 现算。这里就不再白算一遍。 // 旧画法用的),改由模板里调 Kinship::familyMap() 现算。这里就不再白算一遍。
// v24 第 12 条:可以按某个人为中心重画(姻亲不在主图里时点卡片上的入口进来)。
// ⚠️ 「看的是谁的家谱」与「我登录着」是两件事:$meId 决定称呼视角,$focusId 决定画谁的树。
$focusId = (int)($_GET['focus'] ?? 0);
if ($focusId <= 0 || !isset($people[$focusId])) {
$focusId = $me;
}
View::render('kin/tree', [ View::render('kin/tree', [
'title' => '我的家谱', 'title' => $focusId === $me ? '我的家谱' : ((string)($people[$focusId]['name'] ?? '') . ' 的家谱'),
'meId' => $me, 'meId' => $me,
'focusId' => $focusId,
'people' => $people, 'people' => $people,
'graph' => $graph, 'graph' => $graph,
], 'kin'); ], 'kin');
@@ -2415,25 +2503,245 @@ switch ($r) {
// 自定义日程落在 calendar_events;**观影与旅行两本是派生的**(从 events 实时汇总, // 自定义日程落在 calendar_events;**观影与旅行两本是派生的**(从 events 实时汇总,
// 不复制),给这两本"加日程"= 真的建一条观影 / 旅程记录。 // 不复制),给这两本"加日程"= 真的建一条观影 / 旅程记录。
/**
* 财务(v24 第 15 条):账本 + 账目 + 共享。
*
* 用户原话要的是「一个可以共享的家庭总账」:先有账本,账本共享给某人,
* 双方都能往里记账、都能看到全部账目,最后算出总数。
* ⚠️ 金额一律以**分**存取(见 Ledger::toCents 的注释),显示时再转元。
*/
case 'ledger':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$books = Ledger::mine($familyId, $me);
// 一本都没有 → 自动建一本(不然新用户进来一片空白,也不知道要先"创建家庭")
if (!$books) {
Ledger::ensureDefault($familyId, $me);
$books = Ledger::mine($familyId, $me);
}
$lid = (int)($_GET['book'] ?? 0);
$cur = null;
foreach ($books as $b) {
if ($lid > 0 && (int)$b['id'] === $lid) {
$cur = $b;
break;
}
}
if (!$cur) {
$cur = $books[0];
}
$entries = Ledger::entries((int)$cur['id']);
View::render('ledger/index', [
'title' => '财务',
'books' => $books,
'cur' => $cur,
'entries' => $entries,
'summary' => Ledger::summary($entries),
'shares' => Ledger::shares((int)$cur['id']),
'people' => Person::roster($familyId, $me, 'member', ''),
], 'ledger');
break;
case 'ledger/book_add':
case 'ledger/book_rename':
case 'ledger/book_del':
case 'ledger/entry_add':
case 'ledger/entry_update':
case 'ledger/entry_del':
case 'ledger/share':
case 'ledger/unshare':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$lid = (int)($_POST['ledger_id'] ?? 0);
$act = (string)($_GET['r'] ?? '');
$book = $lid > 0 ? Ledger::find($familyId, $lid, $me) : null;
if ($act === 'ledger/book_add') {
$id = Ledger::create($familyId, $me, (string)($_POST['name'] ?? ''));
flash('ok', '账本已创建。');
redirect('ledger', ['book' => $id]);
}
if (!$book) {
flash('err', '找不到这本账。');
redirect('ledger');
}
if (!Ledger::canWrite($book)) {
flash('err', '这本账你只有只读权限。');
redirect('ledger', ['book' => $lid]);
}
if ($act === 'ledger/book_rename') {
Ledger::rename($familyId, $lid, (string)($_POST['name'] ?? ''));
flash('ok', '账本已改名。');
} elseif ($act === 'ledger/book_del') {
Ledger::remove($familyId, $lid);
flash('ok', '账本已删除。');
redirect('ledger');
} elseif ($act === 'ledger/entry_add') {
Ledger::addEntry($lid, [
'kind' => (string)($_POST['kind'] ?? 'other'),
'title' => (string)($_POST['title'] ?? ''),
'amount' => (string)($_POST['amount'] ?? ''),
'note' => (string)($_POST['note'] ?? ''),
'person_id' => (int)($_POST['person_id'] ?? 0),
], $me);
flash('ok', '账目已添加。');
} elseif ($act === 'ledger/entry_update') {
Ledger::updateEntry($lid, (int)($_POST['id'] ?? 0), [
'kind' => (string)($_POST['kind'] ?? 'other'),
'title' => (string)($_POST['title'] ?? ''),
'amount' => (string)($_POST['amount'] ?? ''),
'note' => (string)($_POST['note'] ?? ''),
'person_id' => (int)($_POST['person_id'] ?? 0),
]);
flash('ok', '账目已保存。');
} elseif ($act === 'ledger/entry_del') {
Ledger::removeEntry($lid, (int)($_POST['id'] ?? 0));
flash('ok', '账目已删除。');
} elseif ($act === 'ledger/share') {
$pid = (int)($_POST['person_id'] ?? 0);
if ($pid > 0 && isset(Person::map($familyId)[$pid])) {
Ledger::setShare($lid, $pid, (string)($_POST['perm'] ?? 'read'));
flash('ok', '已共享。');
} else {
flash('err', '请选择要共享给的人。');
}
} else {
Ledger::unshare($lid, (int)($_POST['person_id'] ?? 0));
flash('ok', '已取消共享。');
}
redirect('ledger', ['book' => $lid]);
break;
/**
* 纪念日(v24 第 13 条):阴历/阳历都行;名册里每个人的生日默认可见
* (生日本身就是纪念日的一种,且**实时派生**、不复制一份)。
* 所有纪念日都会作为**全天日程**出现在日历里(见 Calendar::month 那段)。
*/
case 'days':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
View::render('days/index', [
'title' => '纪念日',
'rows' => Anniversary::all($familyId, $me),
'upcoming' => Anniversary::upcoming($familyId, $me, 60),
'people' => Person::roster($familyId, $me, 'member', ''),
], 'days');
break;
case 'days/new':
case 'days/edit':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_GET['id'] ?? 0);
$editing = $id > 0 ? Anniversary::find($familyId, $id) : null;
if ($id > 0 && !$editing) {
flash('err', '找不到这条纪念日。');
redirect('days');
}
View::render('days/form', [
'title' => $id > 0 ? '编辑纪念日' : '新建纪念日',
'editing' => $editing,
'people' => Person::roster($familyId, $me, 'member', ''),
], 'days');
break;
case 'days/save':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
Anniversary::save($familyId, [
'title' => (string)($_POST['title'] ?? ''),
'repeat_type' => (string)($_POST['repeat_type'] ?? 'solar'),
'month' => (int)($_POST['month'] ?? 1),
'day' => (int)($_POST['day'] ?? 1),
'is_leap' => !empty($_POST['is_leap']),
'kind' => (string)($_POST['kind'] ?? ''),
'note' => (string)($_POST['note'] ?? ''),
'person_id' => (int)($_POST['person_id'] ?? 0),
], $id);
flash('ok', $id > 0 ? '纪念日已保存。' : '纪念日已添加。');
redirect('days');
break;
case 'days/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
Anniversary::remove($familyId, (int)($_POST['id'] ?? 0));
flash('ok', '纪念日已删除。');
redirect('days');
break;
case 'calendar': case 'calendar':
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
$me = (int)Auth::personId(); $me = (int)Auth::personId();
$ym = trim((string)($_GET['ym'] ?? '')); // v24 第 4 条:月 / 周 / 天三种看法。view 只认这三个,认不出就回落月视图。
if (!preg_match('/^\d{4}-\d{2}$/', $ym)) { $view = (string)($_GET['view'] ?? 'month');
$ym = date('Y-m'); if (!in_array($view, ['month', 'week', 'day'], true)) {
$view = 'month';
} }
$anchor = trim((string)($_GET['d'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $anchor)) {
$anchor = date('Y-m-d');
}
$ym = substr($anchor, 0, 7);
$cals = Calendar::mine($familyId, $me); $cals = Calendar::mine($familyId, $me);
if ($view === 'month') {
$gym = trim((string)($_GET['ym'] ?? ''));
if (preg_match('/^\d{4}-\d{2}$/', $gym)) {
$ym = $gym;
}
[$from, $to] = month_grid_range($ym); [$from, $to] = month_grid_range($ym);
$events = Calendar::month($familyId, $cals, $me, $from, $to); $events = Calendar::month($familyId, $cals, $me, $from, $to);
$grid = build_month_grid($ym, $events);
} else {
// 周视图 7 列、天视图 1 列,共用同一个时间网格
$cols = day_columns($anchor, $view === 'week' ? 7 : 1);
$from = $cols[0]['date'];
$to = $cols[count($cols) - 1]['date'];
$events = Calendar::month($familyId, $cals, $me, $from, $to);
$grid = build_time_grid($events, $cols);
}
View::render('calendar/index', [ View::render('calendar/index', [
'title' => '日程', 'title' => '日程',
'ym' => $ym, 'ym' => $ym,
'weeks' => build_month_grid($ym, $events), 'view' => $view,
'anchor' => $anchor,
'cols' => $view === 'month' ? [] : ($cols ?? []),
'weeks' => $view === 'month' ? $grid : [],
'tgrid' => $view === 'month' ? null : $grid,
'cals' => $cals, 'cals' => $cals,
'people' => Person::roster($familyId, $me, 'member', ''), 'people' => Person::roster($familyId, $me, 'member', ''),
], 'calendar'); ], 'calendar');
break; break;
/**
* CalDAV 专用口令(v24 第 7 条后半):生成 / 重置 / 撤销。
*
* ⚠️ 明文口令只在这**一次**跳转里带回页面显示(存 $_SESSION 一次性取用),
* 库里只有 sha256。不这么做的话,要么明文落库(一旦泄露全暴露),
* 要么用户永远看不到自己刚设的花口令。
*/
case 'calendar/caldav_token':
case 'calendar/caldav_revoke':
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
if ($r === 'calendar/caldav_token') {
$_SESSION['caldav_plain'] = Auth::caldavIssue($me);
flash('ok', 'CalDAV 口令已生成(旧口令立即失效,请在客户端里更新)。');
} else {
Auth::caldavRevoke($me);
flash('ok', 'CalDAV 口令已撤销,所有已订阅的客户端会失去访问。');
}
redirect('calendar/calendars');
break;
case 'calendar/calendars': case 'calendar/calendars':
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
$me = (int)Auth::personId(); $me = (int)Auth::personId();
@@ -2451,6 +2759,10 @@ switch ($r) {
foreach ($mine as $c) { foreach ($mine as $c) {
$shares[(int)$c['id']] = Calendar::shares((int)$c['id']); $shares[(int)$c['id']] = Calendar::shares((int)$c['id']);
} }
// v24 第 7 条后半:CalDAV 专用口令的状态(明文只在刚生成那一次给用户看)
$cdHas = Auth::hasCalDav($me);
$cdPlain = (string)($_SESSION['caldav_plain'] ?? '');
unset($_SESSION['caldav_plain']);
$editing = null; $editing = null;
$editId = (int)($_GET['edit'] ?? 0); $editId = (int)($_GET['edit'] ?? 0);
if ($editId > 0) { if ($editId > 0) {
@@ -2467,6 +2779,8 @@ switch ($r) {
'shares' => $shares, 'shares' => $shares,
'editing' => $editing, 'editing' => $editing,
'people' => Person::roster($familyId, $me, 'member', ''), 'people' => Person::roster($familyId, $me, 'member', ''),
'cdHas' => $cdHas,
'cdPlain' => $cdPlain,
], 'calendar'); ], 'calendar');
break; break;
@@ -2553,43 +2867,39 @@ switch ($r) {
$ym = trim((string)($_POST['ym'] ?? '')); $ym = trim((string)($_POST['ym'] ?? ''));
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m'); $ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
$calId = (int)($_POST['calendar_id'] ?? 0); $calId = (int)($_POST['calendar_id'] ?? 0);
$cal = null; // ⚠️ 走 mineOne 而不是 Calendar::find():要带 perm,共享本的可读写才判得对
foreach (Calendar::mine($familyId, $me) as $c) { $cal = Calendar::mineOne($familyId, $me, $calId);
if ((int)$c['id'] === $calId) {
$cal = $c;
}
}
if (!$cal || !Calendar::canWrite($cal, $me)) { if (!$cal || !Calendar::canWrite($cal, $me)) {
flash('err', '这本日历你没有写权限。'); flash('err', '这本日历你没有写权限。');
redirect('calendar', ['ym' => $ym]); redirect('calendar', ['ym' => $ym]);
} }
$date = trim((string)($_POST['date'] ?? '')); // v24 第 5、6 条:起止各是一个「日期+时分秒」的完整时刻(_datetime.php 控件),
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { // 不再拆成 date / end_date / start_time / end_time 四格。
flash('err', '日期不对。'); $startsAt = read_dt_input('starts_at');
if ($startsAt === null) {
flash('err', '开始时间不对。');
redirect('calendar', ['ym' => $ym]); redirect('calendar', ['ym' => $ym]);
} }
$endDate = trim((string)($_POST['end_date'] ?? '')); $endsAt = read_dt_input('ends_at');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
$endDate = null;
}
$title = trim((string)($_POST['title'] ?? '')); $title = trim((string)($_POST['title'] ?? ''));
if ($title === '') { if ($title === '') {
flash('err', '日程得有个标题。'); flash('err', '日程得有个标题。');
redirect('calendar', ['ym' => $ym]); redirect('calendar', ['ym' => $ym]);
} }
$allDay = !empty($_POST['all_day']); $allDay = !empty($_POST['all_day']);
$st = $allDay ? '' : (string)($_POST['start_time'] ?? ''); if ($allDay) {
$et = $allDay ? '' : (string)($_POST['end_time'] ?? ''); // 全天日程:时刻部分一律归零(跨天的全天日程截止时刻含到当天末尾)
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
}
$note = trim((string)($_POST['note'] ?? '')); $note = trim((string)($_POST['note'] ?? ''));
// ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录, // ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录,
// 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。 // 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。
$starts = $date . ' ' . ($st !== '' && preg_match('/^\d{2}:\d{2}$/', $st) ? $st . ':00' : '00:00:00');
$ends = ($endDate ?: $date) . ' ' . ($et !== '' && preg_match('/^\d{2}:\d{2}$/', $et) ? $et . ':00' : '00:00:00');
$kind = (string)$cal['kind']; $kind = (string)$cal['kind'];
if ($kind === 'movie') { if ($kind === 'movie') {
$newId = Movie::create($familyId, $me, [ $newId = Movie::create($familyId, $me, [
'title' => $title, 'starts_at' => $starts, 'ends_at' => $ends, 'note' => $note, 'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
'attendees' => [$me], 'attendees' => [$me],
]); ]);
flash('ok', '已加进观影,日历里也能看到了。'); flash('ok', '已加进观影,日历里也能看到了。');
@@ -2597,24 +2907,30 @@ switch ($r) {
} }
if ($kind === 'trip') { if ($kind === 'trip') {
$newId = Trip::create($familyId, $me, [ $newId = Trip::create($familyId, $me, [
'title' => $title, 'starts_at' => $starts, 'ends_at' => $ends, 'note' => $note, 'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
'attendees' => [$me], 'attendees' => [$me],
]); ]);
flash('ok', '已加进旅程,日历里也能看到了。'); flash('ok', '已加进旅程,日历里也能看到了。');
redirect('trips/show', ['id' => $newId]); redirect('trips/show', ['id' => $newId]);
} }
Calendar::addEvent((int)$cal['id'], [ $evId = Calendar::addEvent((int)$cal['id'], [
'title' => $title, 'title' => $title,
'starts_at' => $date, 'location' => trim((string)($_POST['location'] ?? '')),
'ends_at' => $endDate, 'starts_at' => $startsAt,
'start_time' => $st !== '' ? $st . ':00' : null, 'ends_at' => $endsAt,
'end_time' => $et !== '' ? $et . ':00' : null,
'all_day' => $allDay, 'all_day' => $allDay,
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
'status' => (string)($_POST['status'] ?? 'confirmed'),
'transp' => !empty($_POST['transp']),
'note' => $note, 'note' => $note,
'created_by' => $me, 'created_by' => $me,
]); ]);
$warns = attach_save_files($familyId, Calendar::ATTACH, $evId, 'attach', $me);
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}
flash('ok', '日程已加上。'); flash('ok', '日程已加上。');
redirect('calendar', ['ym' => substr($date, 0, 7)]); redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
break; break;
case 'calendar/event': case 'calendar/event':
@@ -2632,8 +2948,7 @@ switch ($r) {
flash('err', '找不到这条日程。'); flash('err', '找不到这条日程。');
redirect('calendar'); redirect('calendar');
} }
$cal = Calendar::find((int)$ev['calendar_id']) ?: []; $ev['_can_write'] = Calendar::canWriteEvent($ev, $me);
$ev['_can_write'] = Calendar::canWrite($cal, $me);
View::render('calendar/event', [ View::render('calendar/event', [
'title' => (string)$ev['title'], 'title' => (string)$ev['title'],
'ev' => $ev, 'ev' => $ev,
@@ -2660,31 +2975,40 @@ switch ($r) {
flash('err', '找不到这条日程。'); flash('err', '找不到这条日程。');
redirect('calendar', ['ym' => $ym]); redirect('calendar', ['ym' => $ym]);
} }
$cal = Calendar::find((int)$ev['calendar_id']) ?: []; $cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
if (!Calendar::canWrite($cal, $me)) { if (!Calendar::canWrite($cal, $me)) {
flash('err', '这本日历你没有写权限。'); flash('err', '这本日历你没有写权限。');
redirect('calendar', ['ym' => $ym]); redirect('calendar', ['ym' => $ym]);
} }
$date = trim((string)($_POST['date'] ?? '')); // v24 第 5、6 条:起止各是一个完整时刻(精确到秒),见上面 event_add 的说明。
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { $startsAt = read_dt_input('starts_at');
flash('err', '日期不对。'); if ($startsAt === null) {
flash('err', '开始时间不对。');
redirect('calendar', ['ym' => $ym]); redirect('calendar', ['ym' => $ym]);
} }
$endsAt = read_dt_input('ends_at');
$allDay = !empty($_POST['all_day']); $allDay = !empty($_POST['all_day']);
$endDate = trim((string)($_POST['end_date'] ?? '')); if ($allDay) {
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
}
Calendar::updateEvent($id, [ Calendar::updateEvent($id, [
'title' => trim((string)($_POST['title'] ?? '')), 'title' => trim((string)($_POST['title'] ?? '')),
'starts_at' => $date, 'location' => trim((string)($_POST['location'] ?? '')),
'ends_at' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) ? $endDate : null, 'starts_at' => $startsAt,
'start_time' => !$allDay && preg_match('/^\d{2}:\d{2}$/', (string)($_POST['start_time'] ?? '')) 'ends_at' => $endsAt,
? (string)$_POST['start_time'] . ':00' : null,
'end_time' => !$allDay && preg_match('/^\d{2}:\d{2}$/', (string)($_POST['end_time'] ?? ''))
? (string)$_POST['end_time'] . ':00' : null,
'all_day' => $allDay, 'all_day' => $allDay,
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
'status' => (string)($_POST['status'] ?? 'confirmed'),
'transp' => !empty($_POST['transp']),
'note' => trim((string)($_POST['note'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')),
]); ]);
$warns = attach_save_files($familyId, Calendar::ATTACH, $id, 'attach', $me);
if ($warns) {
flash('err', '有附件没有保存:' . implode(';', $warns));
}
flash('ok', '日程已保存。'); flash('ok', '日程已保存。');
redirect('calendar', ['ym' => substr($date, 0, 7)]); redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
break; break;
case 'calendar/event_del': case 'calendar/event_del':
@@ -2703,7 +3027,7 @@ switch ($r) {
$st->execute([$id, $familyId]); $st->execute([$id, $familyId]);
$ev = $st->fetch(); $ev = $st->fetch();
if ($ev) { if ($ev) {
$cal = Calendar::find((int)$ev['calendar_id']) ?: []; $cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
if (Calendar::canWrite($cal, $me)) { if (Calendar::canWrite($cal, $me)) {
Calendar::deleteEvent($id); Calendar::deleteEvent($id);
flash('ok', '日程已删除。'); flash('ok', '日程已删除。');
@@ -2714,6 +3038,98 @@ switch ($r) {
redirect('calendar', ['ym' => $ym]); redirect('calendar', ['ym' => $ym]);
break; break;
// ==================== ics 导出 / 导入(v24 第 7 条)====================
case 'calendar/export':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$calId = (int)($_GET['id'] ?? 0);
$cal = Calendar::mineOne($familyId, $me, $calId);
if (!$cal) {
flash('err', '找不到这本日历。');
redirect('calendar');
}
$rows = Calendar::exportRows($cal, $familyId, $me);
$ics = Ics::export($rows, (string)$cal['name']);
// ⚠️ 文件名要用**本子名 + 日期**,别用中文之外的怪字符:
// Content-Disposition 的非 ASCII 文件名各家浏览器编码方式不一样,
// 最稳的做法是给一个 ASCII 兜底名(filename=)再给一个 UTF-8 的(filename*=)。
$ascii = 'calendar-' . (int)$cal['id'] . '-' . date('Ymd') . '.ics';
$utf8 = rawurlencode((string)$cal['name'] . '-' . date('Y-m-d') . '.ics');
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $ascii . '"; filename*=UTF-8\'\'' . $utf8);
header('Content-Length: ' . strlen($ics));
echo $ics;
exit;
case 'calendar/import':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$calId = (int)($_POST['calendar_id'] ?? 0);
$cal = Calendar::mineOne($familyId, $me, $calId);
if (!$cal || !Calendar::canWrite($cal, $me)) {
flash('err', '这本日历你没有写权限。');
redirect('calendar/calendars');
}
// ⚠️ 只能导进**自定义本**:观影 / 旅行两本是派生的(内容是 events / trip_nodes),
// 往里塞 calendar_events 会出现"日历里有、观影列表里没有"的孤儿。
if ((string)$cal['kind'] !== 'custom') {
flash('err', '只能往自定义日历本里导入。观影、旅行两本是从记录里自动汇总的。');
redirect('calendar/calendars');
}
if (empty($_FILES['ics']) || (int)($_FILES['ics']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
flash('err', '没收到 .ics 文件。');
redirect('calendar/calendars');
}
$text = (string)file_get_contents((string)$_FILES['ics']['tmp_name']);
$list = Ics::parse($text);
if (!$list) {
flash('err', '这个文件里没有找到任何日程(VEVENT)。');
redirect('calendar/calendars');
}
$added = 0;
$updated = 0;
foreach ($list as $v) {
if (($v['starts_at'] ?? null) === null) {
continue; // 没有 DTSTART 的 VEVENT 是坏数据,跳过而不是写成 1970 年
}
if (trim((string)$v['title']) === '') {
$v['title'] = '(无标题日程)';
}
if (Calendar::importEvent($calId, $v, $me) === 'added') {
$added++;
} else {
$updated++;
}
}
flash('ok', '导入完成:新增 ' . $added . ' 条,更新 ' . $updated . ' 条。');
redirect('calendar/calendars');
break;
case 'calendar/attach_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$aid = (int)($_POST['attach_id'] ?? 0);
$ev = Calendar::event($id);
if ($ev && Calendar::canWriteEvent($ev, $me)) {
// ⚠️ 附件 id 与宿主 id 一起判:只按 attach_id 删,就能用别的日程的
// 附件 id 把人家文件删掉(宿主对不上就静默放行是很典型的越权)。
$st = Database::get()->prepare(
'SELECT id FROM attachments WHERE id = ? AND target_type = ? AND target_id = ?'
);
$st->execute([$aid, Calendar::ATTACH, $id]);
if ($st->fetchColumn() !== false) {
Attachment::remove($aid);
flash('ok', '附件已删除。');
}
}
redirect('calendar/event', ['id' => $id]);
break;
// ==================== 资源(v12) ==================== // ==================== 资源(v12) ====================
// 挂在**某个人名下**的东西(证件 / 照片 / 账号),可共享给别人(只读或读写)。 // 挂在**某个人名下**的东西(证件 / 照片 / 账号),可共享给别人(只读或读写)。
@@ -2887,15 +3303,99 @@ switch ($r) {
case 'tasks': case 'tasks':
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
$me = (int)Auth::personId(); $me = (int)Auth::personId();
$board = Task::board($familyId, $me); // v24 第 10 条:看板由用户自己建(家务 / 装修 / …),顶上有个切换条
$boards = Task::boards($familyId);
$bid = (int)($_GET['board'] ?? 0);
$board = Task::board($familyId, $me, $bid);
View::render('tasks/index', [ View::render('tasks/index', [
'title' => '任务', 'title' => '任务',
'board' => $board, 'board' => $board,
'total' => count($board['todo']) + count($board['doing']) + count($board['done']), 'boards' => $boards,
'curBoard' => $bid > 0 ? $bid : Task::ensureDefaultBoard($familyId),
'categories' => Task::categories($familyId),
'total' => count($board['todo']) + count($board['doing']),
'people' => Person::roster($familyId, $me, 'member', ''), 'people' => Person::roster($familyId, $me, 'member', ''),
], 'tasks'); ], 'tasks');
break; break;
/**
* 任务设置(v24 第 9、10 条):分类 / 看板 / 已完成的归档。
* 入口在「新建任务」旁边那个 ☰ 下拉里,与观影的 ☰ 是同一套做法。
*/
case 'tasks/settings':
$familyId = (int)Auth::familyId();
View::render('tasks/settings', [
'title' => '任务设置',
'boards' => Task::boards($familyId),
'categories' => Task::categories($familyId),
], 'tasks');
break;
/** 已完成的任务(按分类查看 / 按关键词搜) */
case 'tasks/done':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$cid = (int)($_GET['cat'] ?? 0);
$kw = trim((string)($_GET['kw'] ?? ''));
View::render('tasks/done', [
'title' => '已完成',
'rows' => Task::doneList($familyId, $me, $cid, $kw),
'categories' => Task::categories($familyId),
'cat' => $cid,
'kw' => $kw,
], 'tasks');
break;
case 'tasks/board_add':
case 'tasks/board_update':
case 'tasks/board_del':
case 'tasks/cat_add':
case 'tasks/cat_update':
case 'tasks/cat_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$nm = trim((string)($_POST['name'] ?? ''));
$act = (string)($_GET['r'] ?? '');
if ($act === 'tasks/board_add') {
if ($nm === '') {
flash('err', '看板名不能为空。');
} else {
Task::createBoard($familyId, $nm);
flash('ok', '看板已添加。');
}
} elseif ($act === 'tasks/board_update') {
if ($nm === '') {
flash('err', '看板名不能为空。');
} else {
Task::renameBoard($familyId, $id, $nm);
flash('ok', '看板已改名。');
}
} elseif ($act === 'tasks/board_del') {
Task::deleteBoard($familyId, $id);
flash('ok', '看板已删除,里头的任务挪到了默认看板。');
} elseif ($act === 'tasks/cat_add') {
if ($nm === '') {
flash('err', '分类名不能为空。');
} else {
Task::createCategory($familyId, $nm);
flash('ok', '分类已添加。');
}
} elseif ($act === 'tasks/cat_update') {
if ($nm === '') {
flash('err', '分类名不能为空。');
} else {
Task::renameCategory($familyId, $id, $nm);
flash('ok', '分类已改名。');
}
} else {
Task::deleteCategory($familyId, $id);
flash('ok', '分类已删除,用它标过的任务变成「未分类」。');
}
redirect('tasks/settings');
break;
case 'tasks/show': case 'tasks/show':
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
$me = (int)Auth::personId(); $me = (int)Auth::personId();
@@ -2918,6 +3418,9 @@ switch ($r) {
't' => $t, 't' => $t,
'people' => Person::roster($familyId, $me, 'member', ''), 'people' => Person::roster($familyId, $me, 'member', ''),
'parents' => $parents, 'parents' => $parents,
// v24 第 9、10 条:详情页的编辑表单里也能改分类与看板
'boards' => Task::boards($familyId),
'categories' => Task::categories($familyId),
], 'tasks'); ], 'tasks');
break; break;
@@ -2946,6 +3449,9 @@ switch ($r) {
'repeat_rule' => trim((string)($_POST['repeat_rule'] ?? '')), 'repeat_rule' => trim((string)($_POST['repeat_rule'] ?? '')),
'parent_id' => (int)($_POST['parent_id'] ?? 0), 'parent_id' => (int)($_POST['parent_id'] ?? 0),
'assignee_id' => (int)($_POST['assignee_id'] ?? 0), 'assignee_id' => (int)($_POST['assignee_id'] ?? 0),
// v24 第 9、10 条:分类与看板。Task 侧会校验归属(不认别的家庭的 id)。
'category_id' => (int)($_POST['category_id'] ?? 0),
'board_id' => (int)($_POST['board_id'] ?? 0),
'status' => $isEdit ? (string)$old['status'] : 'todo', 'status' => $isEdit ? (string)$old['status'] : 'todo',
]; ];
if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) { if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) {
+31
View File
@@ -0,0 +1,31 @@
<?php
/**
* 前端控制器(路由表)—— 只给 **PHP 内置服务器** 用(php -S ... router.php)。
*
* 为什么要它:
* · 线上是 Apache,`/dav/3.ics/` 这种没有对应文件的 URL 靠 .htaccess 重写到 dav.php;
* · 本地 `php -S` **不读 .htaccess**,所以同一个地址会被当成"文件不存在"直接 404,
* 于是「CalDAV 在本地测不了、只能上线验证」——这正是最容易上线才发现问题的写法。
* 这个文件把那几条重写规则用 PHP 重述一遍,本地与线上一模一样的路径行为。
*
* ⚠️ 里面**不写任何业务**:只决定"交给哪个脚本",所有业务仍在 index.php / dav.php / media.php。
* 线上部署不该带它(Apache 走 .htaccess),但带着也无害 —— 它不会被执行。
*/
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
// CalDAV:/dav/ 及其子路径(/dav/、/dav/3.ics/、/dav/3.ics/12.ics)
if (preg_match('#^/dav(/.*)?$#', $path)) {
require __DIR__ . '/dav.php';
return true;
}
// 真实存在的文件(静态资源、media.php 等)→ 交给内置服务器自己处理
$file = __DIR__ . $path;
if ($path !== '/' && is_file($file)) {
return false;
}
// 其余交给主路由
require __DIR__ . '/index.php';
return true;
+92
View File
@@ -0,0 +1,92 @@
<?php
/**
* 附件缩略图墙(v24 用户第 3 条)
*
* 一处渲染、全站通用:旅程节点 / 资源 / 任务 / 日程的附件都长一样。
*
* 规则(用户原话):
* · 图片、视频 → **缩略图**,点开在**本页**预览(不再新开标签页)
* · PDF → 浏览器里直接预览(自带阅读器)
* · Word / Excel / PPT / 其它 → 一个**和缩略图一样大**的图标,点了直接下载
* (浏览器渲染不了的东西,绝不装一个"点了没反应"的预览)
* · 预览框**右上角**恒有一个下载按钮
*
* 用法(先准备变量再 require):
* $attRows 附件行(Attachment::list 的结果)
* $attCanEdit 是否显示删除按钮
* $attDelUrl 删除动作的路由名(如 'resources/attach_del')
* $attHostId 删除时要回传的宿主 id(表单里的隐藏字段用 $attHostKey 指定键名)
* $attDelField 删除表单里「附件 id」的字段名(各路由不一致:有的叫 id、有的叫 attach_id)
* $attExtra 额外隐藏字段 [键 => 值](如日程要连带传宿主 id 才能判权限)
*
* ⚠️ 无 JS 也能用:可预览的是 <a href=预览地址>,不可预览的是 <a href=下载地址>,
* 脚本只是把"可预览"的那部分拦下来改成弹层。
*/
$attCanEdit = !empty($attCanEdit);
$attRows = $attRows ?? [];
$attDelField = $attDelField ?? 'id';
$attExtra = $attExtra ?? [];
$attDelUrl = $attDelUrl ?? '';
?>
<?php if ($attRows): ?>
<div class="att-wall">
<?php foreach ($attRows as $a): ?>
<?php
$kind = \App\Attachment::kind($a);
$canPrev = \App\Attachment::previewable($a);
$view = media_url((string)$a['key']);
$dl = media_url((string)$a['key'], ['dl' => 1]);
$nm = (string)($a['name'] ?? '');
$sz = \App\Attachment::sizeWord($a['size'] ?? 0);
?>
<div class="att-cell">
<?php if ($canPrev): ?>
<a class="att-thumb att-kind-<?= e($kind) ?>"
href="<?= e($view) ?>"
data-prev="<?= e($kind === 'pdf' ? 'pdf' : ($kind === 'video' ? 'video' : 'image')) ?>"
data-src="<?= e($view) ?>"
data-dl="<?= e($dl) ?>"
data-name="<?= e($nm !== '' ? $nm : \App\Attachment::kindWord($kind)) ?>"
data-size="<?= e($sz) ?>"
title="<?= e($nm) ?>">
<?php if ($kind === 'image'): ?>
<img src="<?= e($view) ?>" alt="<?= e($nm) ?>" loading="lazy">
<?php elseif ($kind === 'video'): ?>
<?php /* #t=0.1 让浏览器把第 0.1 秒那一帧当封面显示出来 ——
没有它视频位就是一块黑板,看不出这是段什么视频。 */ ?>
<video src="<?= e($view) ?>#t=0.1" preload="metadata" muted playsinline></video>
<span class="att-play" aria-hidden="true">▶</span>
<?php else: ?>
<span class="att-doc att-doc-pdf">PDF</span>
<?php endif; ?>
</a>
<?php else: ?>
<a class="att-thumb att-kind-<?= e($kind) ?>" href="<?= e($dl) ?>"
title="<?= e($nm) ?>(点击下载)">
<span class="att-doc att-doc-<?= e($kind) ?>"><?= e(\App\Attachment::kindWord($kind)) ?></span>
</a>
<?php endif; ?>
<div class="att-meta">
<span class="att-fname" title="<?= e($nm) ?>"><?= e($nm !== '' ? $nm : \App\Attachment::kindWord($kind)) ?></span>
<span class="att-fsize"><?= e($sz) ?></span>
<a class="link-quiet att-dl" href="<?= e($dl) ?>">下载</a>
<?php if ($attCanEdit && !empty($attDelUrl)): ?>
<form method="post" action="<?= e(url($attDelUrl)) ?>" class="att-del">
<?= csrf_field() ?>
<input type="hidden" name="<?= e($attDelField) ?>" value="<?= (int)$a['id'] ?>">
<?php foreach ($attExtra as $ek => $ev): ?>
<input type="hidden" name="<?= e((string)$ek) ?>" value="<?= e((string)$ev) ?>">
<?php endforeach; ?>
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这个附件?')">删除</button>
</form>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php /* ⚠️ 循环里用到的临时变量一律用完即清:宿主模板里很可能也有 $a / $kind /
$view / $dl / $nm / $sz(各模块页面都爱用这种短名),留着就是静默覆盖。 */ ?>
<?php unset($a, $kind, $canPrev, $view, $dl, $nm, $sz); ?>
</div>
<?php endif; ?>
+8 -2
View File
@@ -24,7 +24,13 @@ $dtRequired = (bool)($dtRequired ?? false);
$dtId = (string)($dtId ?? ''); $dtId = (string)($dtId ?? '');
$dtSeconds = (bool)($dtSeconds ?? true); $dtSeconds = (bool)($dtSeconds ?? true);
$dtTs = (!empty($dtValue) && ($t = strtotime(trim((string)$dtValue)))) ? $t : false; // ⚠️⚠️ 临时变量**一律带 dt 前缀**,一个都不能漏:这里原来写成
// `$t = strtotime(...)`,把宿主模板里表示「这条任务 / 这条记录」的 `$t`
// 覆盖成了 int —— 于是 include 之后凡是读 `$t['id']`、`$t['status']` 的地方
// 全部拿到 null,子任务列表空、评论不渲染、@高亮消失,而且**不报任何错**
// (PHP 8 对 "Trying to access array offset on value of type int" 只 Warning)。
// tasks/show.php 上就是这么被 e2e 抓到的(2026-10-02)。
$dtTs = (!empty($dtValue) && ($dtTsTmp = strtotime(trim((string)$dtValue)))) ? $dtTsTmp : false;
$dtDate = $dtTs ? date('Y-m-d', $dtTs) : ''; $dtDate = $dtTs ? date('Y-m-d', $dtTs) : '';
$dtHour = $dtTs ? (int)date('G', $dtTs) : 0; // G = 0..23,天然 24 小时制 $dtHour = $dtTs ? (int)date('G', $dtTs) : 0; // G = 0..23,天然 24 小时制
$dtMin = $dtTs ? (int)date('i', $dtTs) : 0; $dtMin = $dtTs ? (int)date('i', $dtTs) : 0;
@@ -62,6 +68,6 @@ $dtSec = $dtTs ? (int)date('s', $dtTs) : 0;
</div> </div>
<?php /* 用完即清,避免残留变量影响下一次 include */ unset( <?php /* 用完即清,避免残留变量影响下一次 include */ unset(
$dtName, $dtValue, $dtRequired, $dtId, $dtSeconds, $dtName, $dtValue, $dtRequired, $dtId, $dtSeconds,
$dtTs, $dtDate, $dtHour, $dtMin, $dtSec, $dtTs, $dtTsTmp, $dtDate, $dtHour, $dtMin, $dtSec,
$dtH, $dtM, $dtS, $hv, $mv, $sv $dtH, $dtM, $dtS, $hv, $mv, $sv
); ?> ); ?>
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* 全局人员选择器(v24 用户第 11 条)
*
* 「所有要查找人的地方」统一走这一个片段:chips + 弹窗 + 拼音首字母联想。
* 之前只有观影表单有这套(.att-*),旅程是平铺的 checkbox 网格、共享/归属是
* <select> 下拉 —— 名册一多就变成"在一长串里瞪眼找",而且三种地方三种手感。
*
* 用法(在模板里先准备好变量再 include):
* $ppName 表单字段名。多选传 'attendees[]';单选传 'owner_id' 等
* $ppPeople 候选的人(Person::roster() 的行,至少要有 id / name)
* $ppSelected 已选的 id 数组(单选传 0 或 1 个)
* $ppMode 'multi'(默认)| 'single'
* $ppOpenBtn 打开按钮文案(默认 multi=「+ 添加人」/ single=「选择」)
* $ppTitle 弹窗标题(默认「选择人」)
* $ppAllowEmpty 单选模式下是否给一个「不选」选项(筛选表单用;值为 0)
* $ppEmptyText 单选且未选时的占位(默认「未选择」)
* $ppTip 可选,渲染在按钮下面的一句话
*
* ⚠️ 无 JS 时也能用:选项就是弹窗列表里的原生 checkbox / radio,
* noscript 样式把弹窗摊平成一个普通列表,提交语义与有 JS 时完全一致。
*
* ⚠️ 同一页出现多个选择器时 $ppId 必须不同 —— 片段自己按出现顺序编号,
* 调用方不用管(但同一页重复 include 同一份变量也不会撞)。
*/
$ppMode = $ppMode ?? 'multi';
$ppPeople = $ppPeople ?? [];
$ppSelected = array_map('intval', (array)($ppSelected ?? []));
$ppOpenBtn = $ppOpenBtn ?? ($ppMode === 'single' ? '选择' : '+ 添加人');
$ppTitle = $ppTitle ?? '选择';
$ppEmptyText = $ppEmptyText ?? '未选择';
$ppAllowEmpty = !empty($ppAllowEmpty);
// 同一页多个实例要各有一套 id,否则 document.getElementById 全抓到第一个
static $ppSeq = 0;
$ppSeq++;
$ppId = 'pp' . $ppSeq;
$ppIsMulti = $ppMode === 'single' ? false : true;
$ppInputType = $ppIsMulti ? 'checkbox' : 'radio';
// ⚠️ 「已选」要按**非零**判断:筛选表单常用 0 表示「全部」,[0] 是个非空数组,
// 直接 !$ppSelected 会误判成"选了人",于是「不选」那一项反而不勾。
$ppHasSel = count(array_filter($ppSelected)) > 0;
?>
<div class="pp" id="<?= e($ppId) ?>" data-pp="<?= e($ppId) ?>" data-pp-mode="<?= e($ppIsMulti ? 'multi' : 'single') ?>">
<div class="pp-chips" id="<?= e($ppId) ?>-chips">
<?php /* ⚠️ 循环变量一律用 $ppRow,绝不叫 $p:宿主模板里 $p 常常是别的东西
(高级搜索页的 $p 是"查询条件数组"),被这个片段一覆盖,
页面后半段的 $p['xxx'] 就全成了 Warning。_datetime.php 踩过同款。 */ ?>
<?php foreach ($ppPeople as $ppRow): ?>
<?php if (in_array((int)$ppRow['id'], $ppSelected, true)): ?>
<span class="pp-chip" data-id="<?= (int)$ppRow['id'] ?>"><img src="<?= e(avatar_url($ppRow['avatar'] ?? null)) ?>" alt=""><span class="pp-name"><?= e(person_name($ppRow)) ?></span><?php if ($ppIsMulti): ?><button type="button" class="pp-x" data-id="<?= (int)$ppRow['id'] ?>" title="移除">×</button><?php endif; ?></span>
<?php endif; ?>
<?php endforeach; ?>
<?php if (!$ppIsMulti && !$ppHasSel): ?>
<span class="pp-chip pp-chip-empty"><?= e($ppEmptyText) ?></span>
<?php endif; ?>
</div>
<button type="button" class="btn btn-ghost btn-sm pp-open" id="<?= e($ppId) ?>-open"><?= e($ppOpenBtn) ?></button>
<?php if (!empty($ppTip)): ?><span class="tip"><?= e($ppTip) ?></span><?php endif; ?>
<div class="pp-modal" id="<?= e($ppId) ?>-modal" hidden>
<div class="pp-modal-box">
<div class="pp-modal-head">
<b><?= e($ppTitle) ?></b>
<button type="button" class="pp-close" id="<?= e($ppId) ?>-close" title="关闭">×</button>
</div>
<input class="input pp-search" id="<?= e($ppId) ?>-search" type="text"
placeholder="输入名字或拼音首字母,如 yyq 找「杨永全」" autocomplete="off">
<div class="pp-list" id="<?= e($ppId) ?>-list">
<?php if (!$ppIsMulti && $ppAllowEmpty): ?>
<label class="pp-item pp-item-none" data-name="不选" data-shown="不选">
<input type="radio" name="<?= e($ppName) ?>" value="0" <?= $ppHasSel ? '' : 'checked' ?>>
<span class="pp-name">不选</span>
</label>
<?php endif; ?>
<?php foreach ($ppPeople as $ppRow): ?>
<label class="pp-item"
data-name="<?= e((string)$ppRow['name']) ?>"
data-shown="<?= e(person_name($ppRow)) ?>">
<input type="<?= e($ppInputType) ?>" name="<?= e($ppName) ?>" value="<?= (int)$ppRow['id'] ?>"
<?= in_array((int)$ppRow['id'], $ppSelected, true) ? 'checked' : '' ?>>
<img class="pp-av" src="<?= e(avatar_url($ppRow['avatar'] ?? null)) ?>" alt="" loading="lazy">
<span class="pp-name"><?= e(person_name($ppRow)) ?></span>
<span class="pp-initials" hidden></span>
</label>
<?php endforeach; ?>
<?php unset($ppRow); ?>
<div class="pp-empty" hidden>没有匹配的人。</div>
</div>
</div>
</div>
<noscript><style>.pp-open { display: none; } .pp-modal { position: static; display: block; background: none; padding: 0; } .pp-modal-box { box-shadow: none; border: 1px solid var(--line); max-height: none; }</style></noscript>
</div>
+120
View File
@@ -0,0 +1,120 @@
<?php
/**
* 日程表单的字段块(新建与编辑共用一份 —— 复制一遍迟早改漏一处)。
*
* 字段按 **RFC 5545(iCalendar)/ RFC 4791(CalDAV)** 的 VEVENT 属性定:
* 标题 = SUMMARY
* 地点 = LOCATION
* 开始/结束 = DTSTART / DTEND(DATETIME,精确到秒)
* 全天 —— VEVENT 没有"全天"这个属性,约定俗成是把 DTSTART 写成 DATE 值;
* 我们统一存 DATETIME,全天时时间部分为 00:00:00,导出 ics 时再落成 DATE 值。
* 提醒 = VALARM 的 TRIGGER(提前多久);不提醒 = 不写 VALARM
* 状态 = STATUS(CONFIRMED / TENTATIVE / CANCELLED)
* 忙闲 = TRANSP(OPAQUE=忙 / TRANSPARENT=空闲)
* 备注 = DESCRIPTION
* 附件 = ATTACH
*
* 用法(include 前设好):
* $evFields 正在编辑的日程(新建时传空数组)
* $evAllDay 全天的默认值(新建默认**不勾**,用户 2026-10-02 第 6 条)
*/
$evF = is_array($evFields ?? null) ? $evFields : [];
$evAllDayDefault = (bool)($evAllDay ?? false);
$evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evAllDayDefault;
?>
<div class="field full">
<label for="ev-title">标题 *</label>
<input class="input" id="ev-title" name="title" required
value="<?= e((string)($evF['title'] ?? '')) ?>" placeholder="如:家长会">
</div>
<div class="field">
<label for="ev-location">地点</label>
<input class="input" id="ev-location" name="location"
value="<?= e((string)($evF['location'] ?? '')) ?>" placeholder="如:学校三楼会议室">
</div>
<div class="field">
<label>提醒</label>
<?php /* 不提醒 = NULL(导出 ics 时不写 VALARM);档位就是用户第 6 条列的那些。 */ ?>
<select class="select" name="reminder_minutes" aria-label="提前多久提醒">
<?php $cur = $evF['reminder_minutes'] ?? null; ?>
<option value=""<?= $cur === null || $cur === '' ? ' selected' : '' ?>>不提醒</option>
<?php foreach (\App\Calendar::REMINDERS as $mins => $word): ?>
<option value="<?= (int)$mins ?>"<?= (string)$cur === (string)$mins ? ' selected' : '' ?>>
<?= e($word) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php /* v24 第 5、6 条:起止各一个「日期 + 时分秒」合在一起的控件(全局统一),
不再拆成"日期一格 + 时刻一格"。两个控件长得一样,扫一眼就知道哪个是开始。 */ ?>
<div class="field ev-dt" data-dt>
<label>开始 *</label>
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true;
require __DIR__ . '/../_datetime.php'; ?>
</div>
<div class="field ev-dt" data-dt>
<label>结束</label>
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
require __DIR__ . '/../_datetime.php'; ?>
</div>
<div class="field">
<label>
<input type="checkbox" id="ev-allday" name="all_day" value="1"
<?= $evIsAllDay ? 'checked' : '' ?>>
全天
</label>
<span class="field-hint">勾上就不管具体几点,只占这一天</span>
</div>
<div class="field">
<label for="ev-status">状态</label>
<select class="select" id="ev-status" name="status">
<?php $st = (string)($evF['status'] ?? 'confirmed'); ?>
<option value="confirmed"<?= $st === 'confirmed' ? ' selected' : '' ?>>确定</option>
<option value="tentative"<?= $st === 'tentative' ? ' selected' : '' ?>>待定</option>
<option value="cancelled"<?= $st === 'cancelled' ? ' selected' : '' ?>>取消</option>
</select>
</div>
<div class="field">
<label for="ev-transp">这段时间算</label>
<select class="select" id="ev-transp" name="transp">
<?php $tp = (int)($evF['transp'] ?? 0); ?>
<option value="0"<?= $tp === 0 ? ' selected' : '' ?>>忙碌</option>
<option value="1"<?= $tp === 1 ? ' selected' : '' ?>>空闲(别人还能约)</option>
</select>
</div>
<div class="field full">
<label for="ev-note">备注</label>
<textarea class="input" id="ev-note" name="note" rows="3"
placeholder="要带什么、跟谁一起…"><?= e((string)($evF['note'] ?? '')) ?></textarea>
</div>
<div class="field full">
<label for="ev-attach">附件(可多选)</label>
<input class="input" type="file" id="ev-attach" name="attach[]" multiple>
</div>
<script>
(function () {
/* 「全天」勾上就把两个时刻控件的**时间部分**收起来 —— 日期还得留着。
只藏 .dt-time(时/分/秒那一段),.dt-date 是日期,藏了就没法选哪天了。 */
var box = document.getElementById('ev-allday');
if (!box) return;
function sync() {
Array.prototype.forEach.call(document.querySelectorAll('[data-dt] .dt-time'), function (el) {
el.hidden = box.checked;
});
}
box.addEventListener('change', sync);
sync();
})();
</script>
<?php unset($evF, $evAllDayDefault, $evIsAllDay, $evFields, $evAllDay); ?>
+97 -6
View File
@@ -102,12 +102,17 @@ $canWrite = \App\Auth::canWrite();
<form class="inline-form" method="post" action="<?= e(url('calendar/cal_share')) ?>"> <form class="inline-form" method="post" action="<?= e(url('calendar/cal_share')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<select class="select" name="person_id"> <?php
<?php foreach ($people as $p): ?> $ppName = 'person_id';
<?php if ((int)$p['id'] === (int)\App\Auth::personId()) { continue; } ?> $ppPeople = array_values(array_filter($people,
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option> fn($p) => (int)$p['id'] !== (int)\App\Auth::personId()));
<?php endforeach; ?> $ppSelected = [];
</select> $ppMode = 'single';
$ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<select class="select" name="perm"> <select class="select" name="perm">
<option value="read">只读</option> <option value="read">只读</option>
<option value="write">可写</option> <option value="write">可写</option>
@@ -129,6 +134,9 @@ $canWrite = \App\Auth::canWrite();
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<td> <td>
<div class="actions"> <div class="actions">
<?php /* v24 第 7 条:每本都能导出 .ics(预置本也能 —— 导的是派生出来的内容)。 */ ?>
<a class="btn btn-ghost btn-sm"
href="<?= e(url('calendar/export', ['id' => (int)$c['id']])) ?>">导出 ics</a>
<?php if (($c['kind'] ?? '') === 'custom'): ?> <?php if (($c['kind'] ?? '') === 'custom'): ?>
<a class="btn btn-ghost btn-sm" <a class="btn btn-ghost btn-sm"
href="<?= e(url('calendar/calendars', ['edit' => (int)$c['id']])) ?>">编辑</a> href="<?= e(url('calendar/calendars', ['edit' => (int)$c['id']])) ?>">编辑</a>
@@ -152,6 +160,89 @@ $canWrite = \App\Auth::canWrite();
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if ($canWrite): ?>
<?php /* v24 第 7 条:把一个 .ics 文件里的日程整批导进某一本。
只能导进自定义本 —— 观影/旅行两本是派生的,塞进去就成了孤儿日程
(日历里有、观影列表里没有)。 */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
导入 ics
<span class="hint">把别的日历导出的 .ics 文件整批装进来</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
同一个文件导两次也<b>不会重复</b>:每条日程有个全球唯一的 UID,
已经导过的会被更新,而不是再插一条。
</p>
<form method="post" action="<?= e(url('calendar/import')) ?>" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="form-grid">
<div class="field">
<label for="im-cal">导进哪一本 *</label>
<select class="select" id="im-cal" name="calendar_id">
<?php foreach ($cals as $c): ?>
<?php if (($c['kind'] ?? '') !== 'custom') { continue; } ?>
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="im-file">.ics 文件 *</label>
<input class="input" type="file" id="im-file" name="ics" accept=".ics,text/calendar" required>
</div>
</div>
<div class="btn-row">
<button class="btn btn-sm" type="submit">导入</button>
</div>
</form>
</div>
<?php endif; ?>
<?php /* v24 第 7 条后半:CalDAV —— 把我的日历本订阅到手机/别的日历程序里 */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
CalDAV 订阅
<span class="hint">在手机日历 / DAVx⁵ / Thunderbird 里订阅你的日历本</span>
</h2>
<p class="small muted" style="margin:0 0 12px">
填下面这个地址 + 你的<b>姓名或手机号</b> + <b>CalDAV 口令</b>,就能把「我的日历本」
同步到别的日历程序里。这是<b>只读</b>订阅的 —— 在手机上改日程请回到本站来改。
<br>口令与登录密码是<b>分开的</b>:订阅凭据会被客户端长期存着,所以另发一个,
随时可换、不影响登录。
</p>
<div class="field">
<label>服务器地址</label>
<input class="input mono" readonly
value="<?= e(rtrim((string)fb_config('base_url', ''), '/') . '/dav/') ?>">
<span class="tip">在日历客户端里填这一条即可,会列出你所有的日历本。</span>
</div>
<?php if (!empty($cdPlain)): ?>
<p class="tip" style="background:#FFF6E5;border-color:#E8C98A;padding:8px 12px;border-radius:8px">
<b>这是你的新口令,只显示这一次,请立刻复制走:</b>
<br><code class="mono" style="font-size:15px"><?= e($cdPlain) ?></code>
</p>
<?php endif; ?>
<?php if ($canWrite): ?>
<div class="btn-row">
<form method="post" action="<?= e(url('calendar/caldav_token')) ?>" class="res-inline">
<?= csrf_field() ?>
<button class="btn btn-sm" type="submit">
<?= !empty($cdHas) ? '重置 CalDAV 口令' : '生成 CalDAV 口令' ?>
</button>
</form>
<?php if (!empty($cdHas)): ?>
<form method="post" action="<?= e(url('calendar/caldav_revoke')) ?>" class="res-inline">
<?= csrf_field() ?>
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('撤销后所有已订阅的客户端都会失去访问。')">撤销口令</button>
</form>
<span class="node-badge">口令已配置</span>
<?php else: ?>
<span class="node-badge">还没配置口令</span>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php if ($shared): ?> <?php if ($shared): ?>
<div class="card card-pad"> <div class="card card-pad">
<h2 class="cinema-h2">别人共享给我的</h2> <h2 class="cinema-h2">别人共享给我的</h2>
+50 -36
View File
@@ -16,61 +16,75 @@ $canWrite = \App\Auth::canWrite() && !empty($ev['_can_write']);
</div> </div>
<div class="card card-pad"> <div class="card card-pad">
<?php $ym = substr((string)$ev['starts_at'], 0, 7); ?>
<?php if (!$canWrite): ?> <?php if (!$canWrite): ?>
<dl class="kv"> <dl class="kv">
<div class="kv-row"><dt>日期</dt><dd><?= e((string)$ev['starts_at']) ?></dd></div> <div class="kv-row"><dt>开始</dt>
<?php if (!empty($ev['ends_at'])): ?> <dd><?= e(empty($ev['all_day']) ? (string)$ev['starts_at'] : substr((string)$ev['starts_at'], 0, 10)) ?></dd>
<div class="kv-row"><dt>到</dt><dd><?= e((string)$ev['ends_at']) ?></dd></div>
<?php endif; ?>
<div class="kv-row"><dt>时间</dt>
<dd><?= empty($ev['all_day']) ? e(substr((string)$ev['start_time'], 0, 5) . ' – ' . substr((string)$ev['end_time'], 0, 5)) : '全天' ?></dd>
</div> </div>
<?php if (!empty($ev['ends_at'])): ?>
<div class="kv-row"><dt>结束</dt>
<dd><?= e(empty($ev['all_day']) ? (string)$ev['ends_at'] : substr((string)$ev['ends_at'], 0, 10)) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($ev['all_day'])): ?>
<div class="kv-row"><dt></dt><dd>全天</dd></div>
<?php endif; ?>
<?php if (!empty($ev['location'])): ?>
<div class="kv-row"><dt>地点</dt><dd><?= e((string)$ev['location']) ?></dd></div>
<?php endif; ?>
<?php if (($ev['reminder_minutes'] ?? null) !== null): ?>
<div class="kv-row"><dt>提醒</dt>
<dd><?= e(\App\Calendar::REMINDERS[(int)$ev['reminder_minutes']] ?? '') ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($ev['note'])): ?> <?php if (!empty($ev['note'])): ?>
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$ev['note'])) ?></dd></div> <div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$ev['note'])) ?></dd></div>
<?php endif; ?> <?php endif; ?>
<?php $roAs = \App\Attachment::list(\App\Calendar::ATTACH, (int)$ev['id']); ?>
<?php if ($roAs): ?>
<div class="kv-row"><dt>附件</dt>
<dd class="attach-row">
<?php foreach ($roAs as $a): ?>
<span class="attach-item">
<a href="<?= e(media_url((string)$a['key'])) ?>" target="_blank" rel="noopener">
<?= (int)$a['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($a['name'] ?: '附件')) ?>
</a>
<a class="link-quiet" href="<?= e(media_url((string)$a['key'], ['dl' => 1])) ?>">下载</a>
</span>
<?php endforeach; ?>
</dd>
</div>
<?php endif; ?>
</dl> </dl>
<p class="empty-hint">这本日历你只有只读权限,看得到但不能改。</p> <p class="empty-hint">这本日历你只有只读权限,看得到但不能改。</p>
<?php else: ?> <?php else: ?>
<form method="post" action="<?= e(url('calendar/event_update')) ?>"> <form method="post" action="<?= e(url('calendar/event_update')) ?>" enctype="multipart/form-data">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<?php $ym = substr((string)$ev['starts_at'], 0, 7); ?>
<input type="hidden" name="ym" value="<?= e($ym) ?>"> <input type="hidden" name="ym" value="<?= e($ym) ?>">
<div class="form-grid"> <div class="form-grid">
<div class="field full"> <?php $evFields = $ev; $evAllDay = false; require __DIR__ . '/_event_fields.php'; ?>
<label for="ev-title">标题 *</label>
<input class="input" id="ev-title" name="title" required value="<?= e((string)$ev['title']) ?>">
</div>
<div class="field">
<label for="ev-date">开始日期 *</label>
<input class="input" type="date" id="ev-date" name="date" required
value="<?= e((string)$ev['starts_at']) ?>">
</div>
<div class="field">
<label for="ev-end">结束日期(跨天时填)</label>
<input class="input" type="date" id="ev-end" name="end_date" value="<?= e((string)($ev['ends_at'] ?? '')) ?>">
</div>
<div class="field full">
<label><input type="checkbox" name="all_day" value="1" <?= empty($ev['all_day']) ? '' : 'checked' ?>> 全天(不填具体时分)</label>
</div>
<div class="field">
<label for="ev-st">开始时刻</label>
<input class="input" type="time" id="ev-st" name="start_time" value="<?= e(substr((string)($ev['start_time'] ?? ''), 0, 5)) ?>">
</div>
<div class="field">
<label for="ev-et">结束时刻</label>
<input class="input" type="time" id="ev-et" name="end_time" value="<?= e(substr((string)($ev['end_time'] ?? ''), 0, 5)) ?>">
</div>
<div class="field full">
<label for="ev-note">备注</label>
<textarea class="input" id="ev-note" name="note" rows="3"><?= e((string)($ev['note'] ?? '')) ?></textarea>
</div>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button class="btn" type="submit">保存</button> <button class="btn" type="submit">保存</button>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $ym])) ?>">取消</a> <a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $ym])) ?>">取消</a>
</div> </div>
</form> </form>
<?php $as = \App\Attachment::list(\App\Calendar::ATTACH, (int)$ev['id']); ?>
<?php if ($as): ?>
<div class="ev-attach-list">
<span class="res-label">已有附件</span>
<?php
$attRows = $as;
$attCanEdit = true;
$attDelUrl = 'calendar/attach_del';
$attDelField = 'attach_id';
$attExtra = ['id' => (int)$ev['id']];
require __DIR__ . '/../_attachments.php';
?>
</div>
<?php endif; ?>
<?php /* ⚠️ 删除表单必须放在上面那个表单**外面**:<form> 套 <form> 是非法 HTML, <?php /* ⚠️ 删除表单必须放在上面那个表单**外面**:<form> 套 <form> 是非法 HTML,
浏览器会把外层表单在嵌套处切开,"保存"按钮就失效了。 */ ?> 浏览器会把外层表单在嵌套处切开,"保存"按钮就失效了。 */ ?>
<form method="post" action="<?= e(url('calendar/event_del')) ?>" class="ev-del" <form method="post" action="<?= e(url('calendar/event_del')) ?>" class="ev-del"
+105 -46
View File
@@ -17,19 +17,55 @@
* @param array $people 名册(共享给别人时用) * @param array $people 名册(共享给别人时用)
*/ */
$canWrite = \App\Auth::canWrite(); $canWrite = \App\Auth::canWrite();
$view = (string)($view ?? 'month');
$anchor = (string)($anchor ?? date('Y-m-d'));
$ts = strtotime($ym . '-01') ?: time(); $ts = strtotime($ym . '-01') ?: time();
$prevYm = date('Y-m', strtotime('-1 month', $ts)); $prevYm = date('Y-m', strtotime('-1 month', $ts));
$nextYm = date('Y-m', strtotime('+1 month', $ts)); $nextYm = date('Y-m', strtotime('+1 month', $ts));
$thisYm = date('Y-m'); $thisYm = date('Y-m');
$writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::canWrite($c, (int)\App\Auth::personId()))); $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::canWrite($c, (int)\App\Auth::personId())));
// 三种看法各自的「上一段 / 下一段」:月按整月走,周按 7 天走,天按 1 天走
$step = $view === 'week' ? 7 : ($view === 'day' ? 1 : 0);
$prevD = date('Y-m-d', strtotime('-' . $step . ' days', strtotime($anchor)));
$nextD = date('Y-m-d', strtotime('+' . $step . ' days', strtotime($anchor)));
$todayD = date('Y-m-d');
$headText = $view === 'month' ? date('Y 年 n 月', $ts)
: ($view === 'week' ? date('Y 年 n 月 j 日', strtotime($anchor)) . ' 起这一周'
: date('Y 年 n 月 j 日', strtotime($anchor)));
$mkUrl = static function (string $v) use ($ym, $prevD, $nextD, $todayD, $prevYm, $nextYm, $thisYm) {
if ($v === 'month') {
return url('calendar', ['view' => 'month', 'ym' => $ym]);
}
return null; // 周/天要把具体哪天传进去,由调用方拼
};
?> ?>
<div class="page-head"> <div class="page-head">
<h1>日程</h1> <h1>日程</h1>
<span class="sub"><?= e(date('Y 年 n 月', $ts)) ?></span> <span class="sub"><?= e($headText) ?></span>
<div class="actions"> <div class="actions">
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $prevYm])) ?>">上一月</a> <?php /* v24 第 4 条:月 / 周 / 天三选一 */ ?>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $thisYm])) ?>">今天</a> <span class="seg" role="group" aria-label="看法">
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $nextYm])) ?>">下一月</a> <a class="seg-btn<?= $view === 'month' ? ' is-on' : '' ?>"
href="<?= e(url('calendar', ['view' => 'month', 'ym' => $ym])) ?>">月</a>
<a class="seg-btn<?= $view === 'week' ? ' is-on' : '' ?>"
href="<?= e(url('calendar', ['view' => 'week', 'd' => $anchor])) ?>">周</a>
<a class="seg-btn<?= $view === 'day' ? ' is-on' : '' ?>"
href="<?= e(url('calendar', ['view' => 'day', 'd' => $anchor])) ?>">天</a>
</span>
<?php if ($view === 'month'): ?>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $prevYm])) ?>">上一月</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $thisYm])) ?>">今天</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $nextYm])) ?>">下一月</a>
<?php else: ?>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $prevD])) ?>">
<?= $view === 'week' ? '上一周' : '前一天' ?>
</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $todayD])) ?>">今天</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $nextD])) ?>">
<?= $view === 'week' ? '下一周' : '后一天' ?>
</a>
<?php endif; ?>
<a class="btn btn-ghost" href="<?= e(url('calendar/calendars')) ?>">日历本</a> <a class="btn btn-ghost" href="<?= e(url('calendar/calendars')) ?>">日历本</a>
<?php if ($canWrite && $writable): ?> <?php if ($canWrite && $writable): ?>
<button class="btn" type="button" id="calAddBtn">新建日程</button> <button class="btn" type="button" id="calAddBtn">新建日程</button>
@@ -68,7 +104,7 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can
<?php if ($canWrite && $writable): ?> <?php if ($canWrite && $writable): ?>
<div class="card card-pad cal-add" id="calAddCard" hidden> <div class="card card-pad cal-add" id="calAddCard" hidden>
<h2 class="cinema-h2">新建日程</h2> <h2 class="cinema-h2">新建日程</h2>
<form method="post" action="<?= e(url('calendar/event_add')) ?>"> <form method="post" action="<?= e(url('calendar/event_add')) ?>" enctype="multipart/form-data">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="ym" value="<?= e($ym) ?>"> <input type="hidden" name="ym" value="<?= e($ym) ?>">
<div class="form-grid"> <div class="form-grid">
@@ -80,34 +116,14 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div class="field"> <?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
<label for="ce-title">标题 *</label> 新建时「全天」默认**不勾**(用户原话),开始时间默认今天 09:00。 */ ?>
<input class="input" id="ce-title" name="title" required placeholder="如:家长会"> <?php
</div> $evFields = ['starts_at' => ($ym === $thisYm ? date('Y-m-d') : $ym . '-01') . ' 09:00:00',
<div class="field"> 'ends_at' => ($ym === $thisYm ? date('Y-m-d') : $ym . '-01') . ' 10:00:00'];
<label for="ce-date">日期 *</label> $evAllDay = false;
<input class="input" type="date" id="ce-date" name="date" required require __DIR__ . '/_event_fields.php';
value="<?= e($ym === $thisYm ? date('Y-m-d') : $ym . '-01') ?>"> ?>
</div>
<div class="field">
<label for="ce-end">结束日期(跨天时填)</label>
<input class="input" type="date" id="ce-end" name="end_date">
</div>
<div class="field">
<label><input type="checkbox" id="ce-allday" name="all_day" value="1" checked> 全天(不填具体时分)</label>
</div>
<div class="field">
<label for="ce-note">备注</label>
<input class="input" id="ce-note" name="note">
</div>
<div class="field" id="ce-time-s">
<label for="ce-st">开始时刻</label>
<input class="input" type="time" id="ce-st" name="start_time">
</div>
<div class="field" id="ce-time-e">
<label for="ce-et">结束时刻</label>
<input class="input" type="time" id="ce-et" name="end_time">
</div>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button class="btn btn-sm" type="submit">加上</button> <button class="btn btn-sm" type="submit">加上</button>
@@ -117,6 +133,60 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($view === 'week' || $view === 'day'): ?>
<?php /* ===== 周 / 天:按小时铺的时间网格(全天日程单独挂顶上) ===== */ ?>
<div class="card cal-tg-card">
<div class="tg-head">
<div class="tg-gutter"></div>
<?php foreach (($cols ?? []) as $c): ?>
<div class="tg-dayname<?= $c['is_today'] ? ' is-today' : '' ?>">
<span class="tg-wd"><?= e(['日','一','二','三','四','五','六'][$c['w']]) ?></span>
<span class="tg-dd"><?= (int)$c['day'] ?></span>
</div>
<?php endforeach; ?>
</div>
<?php $tgAll = $tgrid['all_day'] ?? []; ?>
<?php if (array_filter($tgAll)): ?>
<div class="tg-allday">
<div class="tg-gutter"><span class="tg-tag">全天</span></div>
<?php foreach (($cols ?? []) as $i => $c): ?>
<div class="tg-adcell">
<?php foreach (($tgAll[$i] ?? []) as $ev): ?>
<a class="cal-ev is-allday" data-cal="<?= (int)$ev['cal'] ?>"
style="background: <?= e((string)$ev['color']) ?>;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>"><?= e((string)$ev['title']) ?></a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="tg-body">
<div class="tg-gutter tg-hours">
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-hr"><?= sprintf('%02d', $h) ?></span>
<?php endfor; ?>
</div>
<?php foreach (($cols ?? []) as $i => $c): ?>
<div class="tg-col<?= $c['is_today'] ? ' is-today' : '' ?>">
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-line"></span>
<?php endfor; ?>
<?php foreach (($tgrid['timed'][$i] ?? []) as $ev): ?>
<a class="tg-block" data-cal="<?= (int)$ev['cal'] ?>"
style="background: <?= e((string)$ev['color']) ?>; top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>">
<b><?= e((string)$ev['time']) ?></b> <?= e((string)$ev['title']) ?>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<div class="card cal-grid-card"> <div class="card cal-grid-card">
<div class="cal-head"> <div class="cal-head">
<?php foreach (['日', '一', '二', '三', '四', '五', '六'] as $wd): ?> <?php foreach (['日', '一', '二', '三', '四', '五', '六'] as $wd): ?>
@@ -150,6 +220,7 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
<?php endif; ?>
</div> </div>
</div> </div>
@@ -180,18 +251,6 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can
}); });
} }
if (cancel && card) { cancel.addEventListener('click', function () { card.hidden = true; }); } if (cancel && card) { cancel.addEventListener('click', function () { card.hidden = true; }); }
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
/* 「全天」勾上就把两个时刻输入框藏掉 —— 没有具体时分就是全天(用户第 7 条) */
var allday = document.getElementById('ce-allday');
var ts = document.getElementById('ce-time-s');
var te = document.getElementById('ce-time-e');
if (allday && ts && te) {
function syncTime() {
var hide = allday.checked;
ts.hidden = hide; te.hidden = hide;
}
allday.addEventListener('change', syncTime);
syncTime();
}
})(); })();
</script> </script>
+96
View File
@@ -0,0 +1,96 @@
<?php
/**
* 新建 / 编辑纪念日(v24 用户第 13 条)
*
* 用户原话:「可以根据阴历和阳历来设置纪念日 …… 纪念日,需要按照阴历和阳历进行重复。」
* 所以重复方式这一栏是核心:阳历 / 农历 / 只此一次。
* 选农历时,下面的月/日填的是**农历**的月日(不是什么公历日期换算)。
*
* @param array|null $editing 编辑时的既有记录
* @param array $people 名册
*/
$e0 = $editing ?: [];
$v = static fn(string $k, $def = '') => $e0[$k] ?? $def;
$repeat = (string)($e0['repeat_type'] ?? 'solar');
?>
<div class="page-head">
<h1><?= $editing ? '编辑纪念日' : '新建纪念日' ?></h1>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('days')) ?>">返回</a>
</div>
</div>
<form class="card card-pad" method="post" action="<?= e(url('days/save')) ?>">
<?= csrf_field() ?>
<?php if ($editing): ?><input type="hidden" name="id" value="<?= (int)$e0['id'] ?>"><?php endif; ?>
<div class="form-grid">
<div class="field full">
<label for="an-title">叫什么 *</label>
<input class="input" id="an-title" name="title" required
value="<?= e((string)$v('title')) ?>" placeholder="如:结婚纪念日">
</div>
<div class="field">
<label for="an-repeat">按什么重复 *</label>
<select class="select" id="an-repeat" name="repeat_type">
<?php foreach (\App\Anniversary::REPEAT as $rk => $rv): ?>
<option value="<?= e($rk) ?>" <?= $repeat === $rk ? 'selected' : '' ?>><?= e($rv) ?></option>
<?php endforeach; ?>
</select>
<span class="tip">选「农历」时,下面填的是**农历**的月日(不是公历)。</span>
</div>
<div class="field">
<label for="an-kind">分类</label>
<input class="input" id="an-kind" name="kind" list="anKinds"
value="<?= e((string)$v('kind')) ?>" placeholder="如:结婚纪念日">
<datalist id="anKinds">
<?php foreach (\App\Anniversary::PRESET_KINDS as $k): ?>
<option value="<?= e($k) ?>"></option>
<?php endforeach; ?>
</datalist>
</div>
<div class="field">
<label for="an-month">月 *</label>
<input class="input" id="an-month" name="month" type="number" min="1" max="12" required
value="<?= (int)$v('month', 1) ?>">
</div>
<div class="field">
<label for="an-day">日 *</label>
<input class="input" id="an-day" name="day" type="number" min="1" max="31" required
value="<?= (int)$v('day', 1) ?>">
</div>
<div class="field">
<label for="an-person">与谁相关</label>
<select class="select" id="an-person" name="person_id">
<option value="0">不指定</option>
<?php foreach ($people as $p): ?>
<option value="<?= (int)$p['id'] ?>" <?= (int)$v('person_id', 0) === (int)$p['id'] ? 'selected' : '' ?>>
<?= e(person_name($p)) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="an-leap">农历闰月</label>
<label class="chk-inline">
<input type="checkbox" id="an-leap" name="is_leap" value="1" <?= !empty($e0['is_leap']) ? 'checked' : '' ?>>
<span>这个农历月是闰月</span>
</label>
</div>
<div class="field full">
<label for="an-note">备注</label>
<textarea class="input" id="an-note" name="note" rows="2"><?= e((string)$v('note')) ?></textarea>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $editing ? '保存' : '添加' ?></button>
<a class="btn btn-ghost" href="<?= e(url('days')) ?>">取消</a>
</div>
</form>
+105
View File
@@ -0,0 +1,105 @@
<?php
/**
* 纪念日(v24 用户第 13 条)
*
* 用户原话:「增加纪念日模块,可以根据阴历和阳历来设置纪念日,在纪念日模块中,
* 可以默认看到已经保存的名册中的人的各种生日,生日本身就是纪念日的一种。」
*
* 所以这一页两块:
* ① 临近的(今天起 60 天内)—— 一眼看到"该过什么日子了";
* ② 全部纪念日 —— 名册里每个人的生日(实时派生)+ 你自己加的,混在一起按日期排。
*
* @param array $rows 全部纪念日(含派生生日:source='birthday_solar'/'birthday_lunar')
* @param array $upcoming 接下来 60 天的
* @param array $people 名册(新建表单里选"与谁相关")
*/
$canWrite = \App\Auth::canWrite();
$kindBadge = static function (array $r): string {
$k = (string)($r['kind'] ?? '');
return $k === '' ? '' : '<span class="node-badge">' . e($k) . '</span>';
};
$srcWord = static function (array $r): string {
$src = (string)($r['source'] ?? 'custom');
if ($src === 'birthday_solar') {
return '名册生日(阳历)';
}
if ($src === 'birthday_lunar') {
return '名册生日(农历)';
}
return \App\Anniversary::repeatWord((string)($r['repeat_type'] ?? 'solar'));
};
?>
<div class="page-head">
<h1>纪念日</h1>
<span class="sub">生日本身就是一种纪念日 · 阳历与农历都能记</span>
<div class="actions">
<?php if ($canWrite): ?>
<a class="btn" href="<?= e(url('days/new')) ?>">新建纪念日</a>
<?php endif; ?>
<a class="btn btn-ghost" href="<?= e(url('calendar')) ?>">看日历</a>
</div>
</div>
<div class="card card-pad">
<h2 class="cinema-h2">接下来的日子</h2>
<?php if (!$upcoming): ?>
<p class="empty-hint">未来 60 天里没有纪念日。</p>
<?php else: ?>
<ul class="ann-up">
<?php foreach ($upcoming as $u): ?>
<li class="ann-up-item">
<span class="ann-days"><?= (int)$u['days'] === 0 ? '今天' : ((int)$u['days'] . ' 天后') ?></span>
<span class="ann-date"><?= e(substr((string)$u['date'], 5)) ?></span>
<span class="ann-title"><?= e((string)$u['title']) ?></span>
<?php if ($u['age'] !== null): ?><span class="small muted"><?= (int)$u['age'] ?> 岁</span><?php endif; ?>
<?= $kindBadge($u) ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<div class="card card-pad">
<h2 class="cinema-h2">全部纪念日(<?= count($rows) ?>)</h2>
<?php if (!$rows): ?>
<p class="empty-hint">还没有纪念日。先到名册里把家人的生日填上,这里就会自动出现。</p>
<?php else: ?>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>日期</th><th>纪念日</th><th>分类</th><th>重复</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td class="nowrap"><?= (int)$r['month'] ?> 月 <?= (int)$r['day'] ?> 日</td>
<td>
<?= e((string)$r['title']) ?>
<?php if (!empty($r['is_leap'])): ?><span class="small muted">(闰月)</span><?php endif; ?>
</td>
<td><?= $kindBadge($r) ?></td>
<td class="small muted"><?= e($srcWord($r)) ?></td>
<td class="nowrap">
<?php if (($r['source'] ?? 'custom') === 'custom' && $canWrite): ?>
<a class="link-quiet" href="<?= e(url('days/edit', ['id' => (int)$r['id']])) ?>">改</a>
<form method="post" action="<?= e(url('days/destroy')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这条纪念日?')">删</button>
</form>
<?php else: ?>
<span class="small muted">来自名册</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<p class="tip" style="margin-top:10px">
名册里那部分(生日)是**实时读出来的** —— 改生日,这里跟着变,不用重复维护。
所有纪念日都会作为**全天日程**出现在日历里。
</p>
</div>
+4 -3
View File
@@ -15,7 +15,7 @@
*/ */
$kinCount = count($kin); $kinCount = count($kin);
// 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出 // 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出
$shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '农历生日']; $shortKind = \App\Birthday::KINDS;
$today = date('Y-m-d'); $today = date('Y-m-d');
?> ?>
<section class="hero"> <section class="hero">
@@ -159,14 +159,15 @@ $today = date('Y-m-d');
<?php /* ==================== 往年今日 ==================== */ ?> <?php /* ==================== 往年今日 ==================== */ ?>
<section class="section"> <section class="section">
<div class="page-head" style="margin-bottom:12px"> <div class="page-head" style="margin-bottom:12px">
<?php /* v24 第 19 条:只认「往年的今天」,不再退回同月 —— 见 home_memories() 的说明。 */ ?>
<h1 style="font-size:15px"> <h1 style="font-size:15px">
<?= !empty($memories['exact']) ? '往年今日' : '往年这个月' ?> 往年的今天
<span class="sub"><?= e(date('n 月 j 日')) ?></span> <span class="sub"><?= e(date('n 月 j 日')) ?></span>
</h1> </h1>
</div> </div>
<?php if (empty($memories['rows'])): ?> <?php if (empty($memories['rows'])): ?>
<div class="card card-pad empty"> <div class="card card-pad empty">
<b><?= !empty($memories['exact']) ? '往年的这一天还没有记录' : '往年的这个月还没有记录' ?></b> <b>往年的这一天还没有记录</b>
日子还长,等有了第一次,明年这时候就会自己冒出来。 日子还长,等有了第一次,明年这时候就会自己冒出来。
</div> </div>
<?php else: ?> <?php else: ?>
+79 -14
View File
@@ -20,12 +20,18 @@ $people = $people ?? [];
// familyMap 的 $up/$down 只影响**代数标号**(超出就压到边界那一档), // familyMap 的 $up/$down 只影响**代数标号**(超出就压到边界那一档),
// 遍历本身是走遍整个连通图的 —— 所以这两个值不放宽的话, // 遍历本身是走遍整个连通图的 —— 所以这两个值不放宽的话,
// 曾祖辈与祖辈会被压成同一代,排序就乱了。给足余量。 // 曾祖辈与祖辈会被压成同一代,排序就乱了。给足余量。
$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), (int)$meId, $people, 9, 9); // ⚠️ v24 第 12 条:中心是 $focusId(默认=我)。点姻亲入口进来时,中心换成那个人,
// 这样 TA 那一侧的姻亲(岳父母等)才画得出来。
$focusId = (int)($focusId ?? $meId);
if (!isset($people[$focusId]) || !is_array($people[$focusId])) {
$focusId = (int)$meId;
}
$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), $focusId, $people, 9, 9);
$gen = $map['gen']; $gen = $map['gen'];
$units = $map['units']; $units = $map['units'];
$unitOf = $map['unit_of']; $unitOf = $map['unit_of'];
if (!isset($people[$meId]) || !is_array($people[$meId])) { if (!isset($people[$focusId]) || !is_array($people[$focusId])) {
$canChart = false; $canChart = false;
} else { } else {
$onTree = array_fill_keys(array_map('intval', array_keys($gen)), true); $onTree = array_fill_keys(array_map('intval', array_keys($gen)), true);
@@ -37,7 +43,7 @@ if (!isset($people[$meId]) || !is_array($people[$meId])) {
// 把树根换成我的最高祖先,整棵树就变成「这位祖先的全部后代」, // 把树根换成我的最高祖先,整棵树就变成「这位祖先的全部后代」,
// 上一代、这一代、下一代的血亲与旁支才能全部落进来。 // 上一代、这一代、下一代的血亲与旁支才能全部落进来。
// 代价是「我」不再是几何中心 —— 用「回到我」按钮 + 加载后自动居中补回来。 // 代价是「我」不再是几何中心 —— 用「回到我」按钮 + 加载后自动居中补回来。
$rootId = (int)$meId; $rootId = (int)$focusId;
for ($guard = 0; $guard < 8; $guard++) { for ($guard = 0; $guard < 8; $guard++) {
$up = \App\Kinship::parentIds($graph, $rootId); $up = \App\Kinship::parentIds($graph, $rootId);
$next = 0; $next = 0;
@@ -108,7 +114,9 @@ if (!isset($people[$meId]) || !is_array($people[$meId])) {
'name' => \App\Nickname::display((int)$meId, $p) ?: (string)($p['name'] ?? ''), 'name' => \App\Nickname::display((int)$meId, $p) ?: (string)($p['name'] ?? ''),
'label' => \App\Kinship::label($graph, (int)$meId, $pid, $people), 'label' => \App\Kinship::label($graph, (int)$meId, $pid, $people),
'avatar' => avatar_url($p['avatar'] ?? null), 'avatar' => avatar_url($p['avatar'] ?? null),
'me' => $pid === (int)$meId, // 「我」在图上高亮;中心那个人另有标记($focusId)
'me' => $pid === (int)$focusId,
'is_me_viewer' => $pid === (int)$meId,
'ph' => (int)($p['is_placeholder'] ?? 0) === 1, 'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
], ],
'rels' => [ 'rels' => [
@@ -119,6 +127,16 @@ if (!isset($people[$meId]) || !is_array($people[$meId])) {
]; ];
} }
// v24 第 12 条:本图上「有姻亲但没画出来」的人 —— 卡片上给个入口
$branchOuts = \App\Kinship::branchOuts($graph, $people, $onTree);
foreach ($datum as &$dn) {
$did = (int)$dn['id'];
if (isset($branchOuts[$did])) {
$dn['data']['branch'] = $branchOuts[$did]['name'];
}
}
unset($dn);
$canChart = count($datum) > 1; $canChart = count($datum) > 1;
} }
@@ -130,8 +148,18 @@ $datumJson = json_encode(
?> ?>
<div class="page-head"> <div class="page-head">
<h1>我的家谱</h1> <h1><?= $focusId === (int)$meId ? '我的家谱' : e((string)($people[$focusId]['name'] ?? '') . ' 的家谱') ?></h1>
<span class="sub">以我为中心 · 上下一共五代 · 夫妻并排,子女从两人之间的连线连出</span> <span class="sub">
<?= $focusId === (int)$meId
? '以我为中心 · 夫妻并排,子女从两人之间的连线连出'
: '以 TA 为中心(这样 TA 那一侧的姻亲才露得出来)' ?>
</span>
<div class="actions">
<?php if ($focusId !== (int)$meId): ?>
<?php /* v24 第 12 条:从姻亲入口进来的,给一个返回,回到「我的家谱」 */ ?>
<a class="btn btn-ghost" href="<?= e(url('kin')) ?>">← 返回我的家谱</a>
<?php endif; ?>
</div>
</div> </div>
<?php if (!$canChart): ?> <?php if (!$canChart): ?>
@@ -144,7 +172,8 @@ $datumJson = json_encode(
<div class="kin-tools"> <div class="kin-tools">
<button type="button" class="btn btn-ghost btn-sm" id="kinFit">看全部</button> <button type="button" class="btn btn-ghost btn-sm" id="kinFit">看全部</button>
<button type="button" class="btn btn-ghost btn-sm" id="kinMe">回到我</button> <?php /* 中心是别人时这颗按钮是"跳回我的家谱"(不是在本图里找我的坐标) */ ?>
<button type="button" class="btn btn-ghost btn-sm" id="kinMe"><?= $focusId === (int)$meId ? '回到我' : '回到我的家谱' ?></button>
</div> </div>
<!-- ⚠️ `f3` 这个类名是**库的 CSS 作用域**,不是我们起的名字。 <!-- ⚠️ `f3` 这个类名是**库的 CSS 作用域**,不是我们起的名字。
@@ -152,7 +181,8 @@ $datumJson = json_encode(
少了它,库里 `.f3 svg.main_svg{width:100%;height:100%}` 这条撑满规则不生效, 少了它,库里 `.f3 svg.main_svg{width:100%;height:100%}` 这条撑满规则不生效,
SVG 会退化成浏览器默认的 300×150(连线全被裁掉,而 HTML 卡片层不受影响 → SVG 会退化成浏览器默认的 300×150(连线全被裁掉,而 HTML 卡片层不受影响 →
表现为「卡在、线没了」)。 --> 表现为「卡在、线没了」)。 -->
<div class="kin-chart f3" id="kinChart" data-datum="<?= e($datumJson) ?>" data-me="<?= e((string)$meId) ?>"></div> <div class="kin-chart f3" id="kinChart" data-datum="<?= e($datumJson) ?>"
data-focus="<?= e((string)$focusId) ?>" data-me-viewer="<?= e((string)$meId) ?>"></div>
<p class="tree-legend"> <p class="tree-legend">
每代一行;<b>一对夫妻之间有一条短横线</b>, 每代一行;<b>一对夫妻之间有一条短横线</b>,
@@ -197,22 +227,29 @@ $datumJson = json_encode(
// 会跳过深度截断,等于**不限代数**,有多少画多少。 // 会跳过深度截断,等于**不限代数**,有多少画多少。
// 之前写死了具体代数,真实家庭一超过就被砍掉。 // 之前写死了具体代数,真实家庭一超过就被砍掉。
// 用户要求「全部取出来」,所以这里也不设。 // 用户要求「全部取出来」,所以这里也不设。
.setCardXSpacing(180) .setCardXSpacing(190)
.setCardYSpacing(180); .setCardYSpacing(180);
chart.setCardHtml() chart.setCardHtml()
.setStyle('rect') .setStyle('rect')
.setCardDim({ w: 140, h: 120, mini_tree: false }) .setCardDim({ w: 148, h: 138, mini_tree: false })
.setCardInnerHtmlCreator(function (d) { .setCardInnerHtmlCreator(function (d) {
var p = (d.data && d.data.data) || {}; var p = (d.data && d.data.data) || {};
var id = (d.data && d.data.id) || ''; var id = (d.data && d.data.id) || '';
var cls = 'kin-card' + (p.me ? ' is-me' : '') + (p.ph ? ' is-ph' : ''); var cls = 'kin-card' + (p.me ? ' is-me' : '') + (p.ph ? ' is-ph' : '');
// v24 第 12 条:头像改**方形**(原来是圆角矩形 + cover,把脸裁掉了)。
// 卡片本身是 <a> 进成员页;有姻亲没画出来的,再叠一个独立的小按钮
// (用 <span role=button> 而不是 <a>:<a> 不能套 <a>)。
return '<a class="' + cls + '" data-kin-id="' + esc(id) + '" href="/index.php?r=people/show&id=' return '<a class="' + cls + '" data-kin-id="' + esc(id) + '" href="/index.php?r=people/show&id='
+ encodeURIComponent(id) + '">' + encodeURIComponent(id) + '">'
+ '<span class="kin-ph"><img src="' + esc(p.avatar) + '" alt=""></span>' + '<span class="kin-ph"><img src="' + esc(p.avatar) + '" alt=""></span>'
+ '<span class="kin-nm">' + esc(p.name) + '</span>' + '<span class="kin-nm">' + esc(p.name) + '</span>'
+ '<span class="kin-lb">' + esc(p.label) + '</span>' + '<span class="kin-lb">' + esc(p.label) + '</span>'
+ (p.ph ? '<span class="kin-mark">待确认</span>' : '') + (p.ph ? '<span class="kin-mark">待确认</span>' : '')
+ (p.branch
? '<span class="kin-branch" role="button" tabindex="0" title="看看 '
+ esc(p.branch) + ' 那一侧的家谱" data-branch-id="' + esc(id) + '">⇄ 姻亲</span>'
: '')
+ '</a>'; + '</a>';
}); });
@@ -256,12 +293,15 @@ $datumJson = json_encode(
} }
window.kinCenterOn = centerOn; window.kinCenterOn = centerOn;
var meId = root.getAttribute('data-me') || ''; var focusId = root.getAttribute('data-focus') || '';
var meId = root.getAttribute('data-me-viewer') || focusId;
// ⚠️ 中心是别人时,「回到我」要跳到 focus=我 的图,而不是在图里找我的坐标
var centerTarget = root.getAttribute('data-me-viewer') || focusId;
// fit 动画完成后把镜头挪到「我」(树根是最高祖先,我不再是几何中心) // fit 动画完成后把镜头挪到「我」(树根是最高祖先,我不再是几何中心)
if (meId) { if (meId) {
setTimeout(function () { centerOn(meId); }, 900); setTimeout(function () { centerOn(focusId); }, 900);
// 万一 zoom 对象还没初始化好,再补一次 // 万一 zoom 对象还没初始化好,再补一次
setTimeout(function () { centerOn(meId); }, 1400); setTimeout(function () { centerOn(focusId); }, 1400);
} }
var btnFit = document.getElementById('kinFit'); var btnFit = document.getElementById('kinFit');
@@ -271,8 +311,33 @@ $datumJson = json_encode(
chart.updateTree({ tree_position: 'fit' }); chart.updateTree({ tree_position: 'fit' });
}); });
} }
/* v24 第 12 条:卡片右下角的「⇄ 姻亲」按钮 —— 以那个人为中心重画一张家谱。
⚠️ 这个 span 在卡片的 <a> 里,必须 preventDefault + stopPropagation,
否则会先触发外面那个链接跳去成员页。 */
Array.prototype.forEach.call(root.querySelectorAll('.kin-branch'), function (btn) {
function go(e) {
e.preventDefault();
e.stopPropagation();
var bid = btn.getAttribute('data-branch-id') || '';
if (bid) { window.location.href = '/index.php?r=kin&focus=' + encodeURIComponent(bid); }
}
btn.addEventListener('click', go);
btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { go(e); }
});
// 键盘/触摸都别让它冒泡到外层链接
btn.addEventListener('mousedown', function (e) { e.stopPropagation(); });
});
if (btnMe && meId) { if (btnMe && meId) {
btnMe.addEventListener('click', function () { centerOn(meId); }); btnMe.addEventListener('click', function () {
// 图是别人的家谱时,先跳回「我的家谱」再居中
if (focusId !== meId) {
window.location.href = '/index.php?r=kin';
return;
}
centerOn(centerTarget);
});
} }
})(); })();
</script> </script>
+14
View File
@@ -99,6 +99,20 @@ $isLoggedIn = \App\Auth::check();
</main> </main>
<?php /* 全局附件预览层(v24 第 3 条):图片 / 视频 / PDF 都在本页这一层里看,
右上角恒有下载按钮。内容由 assets/js/preview.js 按 data-prev 现填。 */ ?>
<div class="pv" id="pv" role="dialog" aria-modal="true" aria-hidden="true" aria-label="附件预览" hidden>
<div class="pv-head">
<span class="pv-name"></span>
<span class="pv-meta"></span>
<a class="pv-dl" href="#">下载</a>
<button type="button" class="pv-close" aria-label="关闭">✕</button>
</div>
<div class="pv-stage"></div>
</div>
<script src="<?= e(asset('/assets/js/app.js')) ?>"></script> <script src="<?= e(asset('/assets/js/app.js')) ?>"></script>
<script src="<?= e(asset('/assets/js/person_picker.js')) ?>"></script>
<script src="<?= e(asset('/assets/js/preview.js')) ?>"></script>
</body> </body>
</html> </html>
+288
View File
@@ -0,0 +1,288 @@
<?php
/**
* 财务 / 账本(v24 用户第 15 条)
*
* 用户原话:「先创建一个家庭,或者说,创建一个账本,这个账本,可以共享给某个人,
* 共享后,这个人就可以往这个账本加入账目,这样,双方都可以看到所有的账目,
* 最后计算出总数,这就是一个家庭的总账目。」
*
* 页面结构就是照着这句话来的:
* 顶上一排账本(切换 / 新建)→ 汇总卡(资产 / 负债 / 净资产)→ 账目表 → 共享设置。
*
* ⚠️ 金额全程以**分**参与计算(Ledger 里做的),这里只负责显示成元。
* 任何地方都不要拿元去加总。
*
* @param array $books 我能看到的账本(带 my_perm / entry_count)
* @param array $cur 当前账本
* @param array $entries 当前账本的账目
* @param array $summary ['asset','debt','net','by_kind','count'](单位:分)
* @param array $shares 当前账本的共享列表
* @param array $people 名册
*/
$canWrite = \App\Auth::canWrite() && \App\Ledger::canWrite($cur);
$curId = (int)$cur['id'];
$money = static fn($c): string => \App\Ledger::money($c);
?>
<div class="page-head">
<h1>财务</h1>
<span class="sub">家庭的账 · 共享后双方都能记、都能看</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('ledger', ['book' => $curId])) ?>">刷新</a>
</div>
</div>
<?php if (count($books) > 1): ?>
<div class="seg">
<?php foreach ($books as $b): ?>
<a class="seg-btn<?= (int)$b['id'] === $curId ? ' is-on' : '' ?>"
href="<?= e(url('ledger', ['book' => (int)$b['id']])) ?>">
<?= e((string)$b['name']) ?><?= ($b['my_perm'] ?? '') === 'read' ? '(只读)' : '' ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="card card-pad led-sum">
<div class="led-sum-head">
<h2 class="cinema-h2" style="margin:0"><?= e((string)$cur['name']) ?></h2>
<span class="small muted">
<?= (int)($cur['entry_count'] ?? count($entries)) ?> 笔账
<?php if (($cur['my_perm'] ?? '') === 'read'): ?>
· <b>只读</b>(不能记账)
<?php endif; ?>
</span>
</div>
<div class="led-total">
<div class="led-total-item">
<span class="led-total-lb">总资产</span>
<span class="led-total-num led-asset"><?= e($money($summary['asset'])) ?></span>
</div>
<div class="led-total-item">
<span class="led-total-lb">总负债</span>
<span class="led-total-num led-debt"><?= e($money($summary['debt'])) ?></span>
</div>
<div class="led-total-item led-net">
<span class="led-total-lb">净资产</span>
<span class="led-total-num"><?= e($money($summary['net'])) ?></span>
</div>
</div>
<?php if ($summary['by_kind']): ?>
<div class="led-kinds">
<?php foreach ($summary['by_kind'] as $k => $c): ?>
<span class="led-kind-chip<?= \App\Ledger::isDebt((string)$k) ? ' is-debt' : '' ?>">
<?= \App\Ledger::kindIcon((string)$k) ?> <?= e(\App\Ledger::kindWord((string)$k)) ?>
<b><?= e($money($c)) ?></b>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<div class="card card-pad">
<h2 class="cinema-h2">记一笔</h2>
<form method="post" action="<?= e(url('ledger/entry_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<div class="form-grid">
<div class="field">
<label for="le-kind">类别 *</label>
<select class="select" id="le-kind" name="kind">
<?php foreach (\App\Ledger::KINDS as $k => $v): ?>
<option value="<?= e($k) ?>"><?= e($v[2] . ' ' . $v[0]) ?><?= $v[1] === 'debt' ? '(负债)' : '' ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="le-title">名称 *</label>
<input class="input" id="le-title" name="title" required placeholder="如:工商银行定期">
</div>
<div class="field">
<label for="le-amount">金额(元)*</label>
<input class="input" id="le-amount" name="amount" required inputmode="decimal"
placeholder="如:120000.00">
<span class="tip">负债(贷款/信用卡)也填正数,系统按类别算成负债。</span>
</div>
<div class="field">
<label for="le-person">挂在谁名下</label>
<select class="select" id="le-person" name="person_id">
<option value="0">不指定</option>
<?php foreach ($people as $p): ?>
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field full">
<label for="le-note">备注</label>
<input class="input" id="le-note" name="note" placeholder="可选">
</div>
</div>
<div class="btn-row"><button class="btn" type="submit">添加账目</button></div>
</form>
</div>
<?php endif; ?>
<div class="card card-pad">
<h2 class="cinema-h2">账目(<?= count($entries) ?>)</h2>
<?php if (!$entries): ?>
<p class="empty-hint">这本账还没有账目。上面「记一笔」开始记吧。</p>
<?php else: ?>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>类别</th><th>名称</th><th class="num">金额</th><th>挂在谁名下</th><th>备注</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($entries as $en): ?>
<?php $ek = (string)$en['kind']; $debt = \App\Ledger::isDebt($ek); ?>
<tr>
<td class="nowrap">
<span class="led-kind-chip<?= $debt ? ' is-debt' : '' ?>">
<?= \App\Ledger::kindIcon($ek) ?> <?= e(\App\Ledger::kindWord($ek)) ?>
</span>
</td>
<td><?= e((string)$en['title']) ?></td>
<td class="num<?= $debt ? ' led-debt' : ' led-asset' ?>">
<?= $debt ? '-' : '' ?><?= e($money((int)$en['amount_cents'])) ?>
</td>
<td class="nowrap">
<?php if (!empty($en['person_name'])): ?>
<?= person_chip([
'id' => (int)$en['person_id'], 'name' => (string)$en['person_name'],
'my_nickname' => (string)($en['person_nick'] ?? ''),
'avatar' => $en['avatar'] ?? null, 'avatar_color' => $en['avatar_color'] ?? null,
]) ?>
<?php else: ?><span class="small muted">—</span><?php endif; ?>
</td>
<td class="small muted"><?= e((string)($en['note'] ?? '')) ?></td>
<td class="nowrap">
<?php if ($canWrite): ?>
<details class="led-edit">
<summary class="link-quiet">改</summary>
<form method="post" action="<?= e(url('ledger/entry_update')) ?>" class="node-form">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<input type="hidden" name="id" value="<?= (int)$en['id'] ?>">
<div class="nf-grid">
<div class="field">
<label>类别</label>
<select class="select" name="kind">
<?php foreach (\App\Ledger::KINDS as $k => $v): ?>
<option value="<?= e($k) ?>" <?= $ek === $k ? 'selected' : '' ?>>
<?= e($v[0]) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>名称</label>
<input class="input" name="title" value="<?= e((string)$en['title']) ?>" required>
</div>
<div class="field">
<label>金额(元)</label>
<input class="input" name="amount"
value="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>" required>
</div>
<div class="field">
<label>备注</label>
<input class="input" name="note" value="<?= e((string)($en['note'] ?? '')) ?>">
</div>
</div>
<div class="btn-row"><button class="btn btn-sm" type="submit">保存</button></div>
</form>
<form method="post" action="<?= e(url('ledger/entry_del')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<input type="hidden" name="id" value="<?= (int)$en['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这笔账目?')">删除</button>
</form>
</details>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<th colspan="2">合计(资产 − 负债)</th>
<th class="num led-net"><?= e($money($summary['net'])) ?></th>
<th colspan="3"></th>
</tr>
</tfoot>
</table>
</div>
<?php endif; ?>
</div>
<div class="card card-pad">
<h2 class="cinema-h2">共享与账本</h2>
<div class="led-shares">
<span class="res-label">已共享给</span>
<?php if (!$shares): ?>
<span class="small muted">还没有共享给任何人。</span>
<?php endif; ?>
<?php foreach ($shares as $s): ?>
<span class="led-share-chip">
<?= person_chip([
'id' => (int)$s['person_id'], 'name' => (string)$s['name'],
'avatar' => $s['avatar'] ?? null, 'avatar_color' => $s['avatar_color'] ?? null,
]) ?>
<span class="node-badge"><?= ($s['perm'] ?? 'read') === 'write' ? '可写' : '只读' ?></span>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('ledger/unshare')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<input type="hidden" name="person_id" value="<?= (int)$s['person_id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">取消</button>
</form>
<?php endif; ?>
</span>
<?php endforeach; ?>
</div>
<?php if ($canWrite): ?>
<div class="led-ops">
<?php
$ppName = 'person_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择共享给谁';
$ppTitle = '账本共享给谁';
$ppEmptyText = '未选择';
?>
<form class="inline-form" method="post" action="<?= e(url('ledger/share')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<?php require __DIR__ . '/../_person_picker.php'; ?>
<select class="select" name="perm">
<?php foreach (\App\Ledger::PERM as $pk => $pv): ?>
<option value="<?= e($pk) ?>"><?= e($pv) ?></option>
<?php endforeach; ?>
</select>
<button class="btn btn-sm" type="submit">共享</button>
</form>
<form class="inline-form" method="post" action="<?= e(url('ledger/book_rename')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<input class="input" name="name" value="<?= e((string)$cur['name']) ?>" required>
<button class="btn btn-ghost btn-sm" type="submit">账本改名</button>
</form>
<form class="inline-form" method="post" action="<?= e(url('ledger/book_add')) ?>">
<?= csrf_field() ?>
<input class="input" name="name" placeholder="新账本名,如:孩子的教育金" required>
<button class="btn btn-ghost btn-sm" type="submit">新建账本</button>
</form>
<form class="inline-form" method="post" action="<?= e(url('ledger/book_del')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这本账?里面的账目会一起删掉。')">删除这本账</button>
</form>
</div>
<?php endif; ?>
</div>
+14 -163
View File
@@ -105,44 +105,21 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
<div class="field full"> <div class="field full">
<label>同行的人</label> <label>同行的人</label>
<?php /* v10:名册人多时 checkbox 网格会拉得很长 —— 改为「chips + 弹窗选择」。 <?php /* v24 第 11 条:换成全局人员选择器(templates/_person_picker.php)。
attendees[] 的 checkbox 仍保留(在弹窗列表里,选中即提交),无 JS 时 原来这套是观影页私有的 .att-*,它的「过滤」其实一直是坏的 ——
借 noscript 样式把弹窗平铺成普通勾选列表,提交语义完全不变。 .att-item 自己写了 display:flex,压过了浏览器的 [hidden]{display:none},
v11:chips 与弹窗列表都带上头像($p 来自 Person::roster,含 p.avatar)。 */ ?> JS 里 el.hidden = true 根本没生效,搜什么都还是一整列。 */ ?>
<?php if ($people): ?> <?php if ($people): ?>
<div class="att-chips" id="attChips"> <?php
<?php foreach ($people as $p): ?> $ppName = 'attendees[]';
<?php if (in_array((int)$p['id'], $selAttendees, true)): ?> $ppPeople = $people;
<span class="att-chip" data-id="<?= (int)$p['id'] ?>"><img src="<?= e(avatar_url($p['avatar'] ?? null)) ?>" alt=""><span class="att-name"><?= e($p['shown'] ?? $p['name']) ?></span><button type="button" class="att-x" data-id="<?= (int)$p['id'] ?>" title="移除">×</button></span> $ppSelected = $selAttendees;
<?php endif; ?> $ppMode = 'multi';
<?php endforeach; ?> $ppOpenBtn = '+ 添加观影人';
</div> $ppTitle = '选择观影人';
<button type="button" class="btn btn-ghost btn-sm att-open-btn" id="attOpen">+ 添加观影人</button> $ppTip = '';
<div class="att-modal" id="attModal" hidden> require __DIR__ . '/../_person_picker.php';
<div class="att-modal-box"> ?>
<div class="att-modal-head">
<b>选择观影人</b>
<button type="button" class="att-close" id="attClose" title="关闭">×</button>
</div>
<input class="input" id="attSearch" type="text"
placeholder="输入名字或拼音首字母,如 zy 找「张三」" autocomplete="off">
<div class="att-list" id="attList">
<?php foreach ($people as $p): ?>
<label class="att-item"
data-name="<?= e((string)$p['name']) ?>"
data-shown="<?= e((string)($p['shown'] ?? '')) ?>">
<input type="checkbox" name="attendees[]" value="<?= (int)$p['id'] ?>"
<?= in_array((int)$p['id'], $selAttendees, true) ? 'checked' : '' ?>>
<img class="att-av" src="<?= e(avatar_url($p['avatar'] ?? null)) ?>" alt="" loading="lazy">
<span class="att-name"><?= e($p['shown'] ?? $p['name']) ?></span>
<span class="att-initials" hidden></span>
</label>
<?php endforeach; ?>
<div class="att-empty" hidden>没有匹配的人。</div>
</div>
</div>
</div>
<noscript><style>.att-open-btn { display: none; } .att-modal { position: static; display: block; background: none; padding: 0; } .att-modal-box { box-shadow: none; border: 1px solid var(--line); max-height: none; }</style></noscript>
<?php else: ?> <?php else: ?>
<span class="tip">名册里还没有人,先把家人加进名册再来记。</span> <span class="tip">名册里还没有人,先把家人加进名册再来记。</span>
<?php endif; ?> <?php endif; ?>
@@ -192,131 +169,6 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
</div> </div>
</form> </form>
<?php if ($people): ?>
<!-- 观影人选择弹窗:chips 同步 + 拼音首字母联想(Intl pinyin 排序 + 23 锚字二分,零依赖) -->
<script>
(function () {
var modal = document.getElementById('attModal');
if (!modal) return;
var openBtn = document.getElementById('attOpen');
var closeBtn = document.getElementById('attClose');
var search = document.getElementById('attSearch');
var list = document.getElementById('attList');
var chips = document.getElementById('attChips');
var emptyTip = list.querySelector('.att-empty');
/* ---- 汉字 → 拼音首字母:锚字表 a..z(23 个,无 i/u/v 开头字),
用拼音 collation 做二分定位;不支持的浏览器返回 null(只按名字搜) ---- */
var initialsOf = (function () {
var anchors = '阿八擦搭娥发嘎哈击喀垃妈拿哦啪期然撒塌挖昔压匝';
var letters = 'abcdefghjklmnopqrstwxyz';
var coll;
try { coll = new Intl.Collator('zh-Hans-CN-u-co-pinyin'); } catch (e) { return null; }
if (!coll || coll.compare('阿', '匝') >= 0) return null; // collation 没生效
return function (s) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s[i];
if (/[a-zA-Z]/.test(ch)) { out += ch.toUpperCase(); continue; }
if (ch.charCodeAt(0) < 0x2E80) continue; // 非 CJK:不产字母
var lo = 0, hi = anchors.length - 1, best = -1;
while (lo <= hi) {
var mid = (lo + hi) >> 1;
if (coll.compare(ch, anchors[mid]) >= 0) { best = mid; lo = mid + 1; }
else { hi = mid - 1; }
}
if (best >= 0) out += letters[best];
}
return out;
};
})();
/* 预计算每个人的首字母串,挂在 data-initials 上 */
Array.prototype.forEach.call(list.querySelectorAll('.att-item'), function (el) {
var s = (el.getAttribute('data-name') || '') + (el.getAttribute('data-shown') || '');
var ini = initialsOf ? initialsOf(s) : '';
el.setAttribute('data-initials', ini.toLowerCase());
var badge = el.querySelector('.att-initials');
if (badge && ini) { badge.textContent = ini; badge.hidden = false; }
});
function syncChips() {
if (!chips) return;
chips.innerHTML = '';
Array.prototype.forEach.call(list.querySelectorAll('.att-item input:checked'), function (box) {
var item = box.closest('.att-item');
// ⚠️ 必须点名取 .att-name:v11 起 .att-item 里第一个元素是头像 <img>,
// 早先靠「取第一个 span 当名字」碰巧成立,现在第一个 span 是隐藏的
// 首字母占位,选完人 chip 上就没名字了。
var nameEl = item.querySelector('.att-name');
var label = nameEl ? nameEl.textContent : box.value;
var av = item.querySelector('.att-av');
var id = box.value;
var chip = document.createElement('span');
chip.className = 'att-chip';
chip.setAttribute('data-id', id);
if (av) {
var im = document.createElement('img');
im.src = av.getAttribute('src'); // 服务端已 e() 过,这里原样搬
im.alt = '';
chip.appendChild(im);
}
var nm = document.createElement('span');
nm.className = 'att-name';
nm.textContent = label;
chip.appendChild(nm);
var x = document.createElement('button');
x.type = 'button'; x.className = 'att-x'; x.textContent = '×';
x.title = '移除'; x.setAttribute('data-id', id);
chip.appendChild(x);
chips.appendChild(chip);
});
}
chips.addEventListener('click', function (e) {
var x = e.target.closest ? e.target.closest('.att-x') : null;
if (!x) return;
var id = x.getAttribute('data-id');
Array.prototype.forEach.call(list.querySelectorAll('.att-item input'), function (box) {
if (box.value === id) box.checked = false;
});
syncChips();
});
function applyFilter() {
var q = (search.value || '').trim().toLowerCase();
var shown = 0;
Array.prototype.forEach.call(list.querySelectorAll('.att-item'), function (el) {
var hit = !q ||
(el.getAttribute('data-name') || '').toLowerCase().indexOf(q) >= 0 ||
(el.getAttribute('data-shown') || '').toLowerCase().indexOf(q) >= 0 ||
(el.getAttribute('data-initials') || '').indexOf(q) >= 0;
el.hidden = !hit;
if (hit) shown++;
});
emptyTip.hidden = shown > 0;
}
function openModal() { modal.hidden = false; search.value = ''; applyFilter(); syncChips(); search.focus(); }
function closeModal() { modal.hidden = true; }
openBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', function (e) { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !modal.hidden) closeModal();
});
search.addEventListener('input', applyFilter);
/* 选一个 = 勾上 + 出 chips + 关弹窗(再添加就再点一次「+ 添加观影人」);
在弹窗里取消勾选只同步 chips,不关弹窗 */
list.addEventListener('change', function (e) {
if (!e.target || e.target.tagName !== 'INPUT') return;
syncChips();
if (e.target.checked) closeModal();
});
})();
</script>
<!-- 豆瓣自动补充:下拉联想(320ms 防抖 + 中文输入法组字期不请求)+ 一键抓取 --> <!-- 豆瓣自动补充:下拉联想(320ms 防抖 + 中文输入法组字期不请求)+ 一键抓取 -->
<script> <script>
(function () { (function () {
@@ -454,4 +306,3 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
} }
})(); })();
</script> </script>
<?php endif; ?>
+13 -9
View File
@@ -35,15 +35,19 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label for="person_id">观影人</label> <label>观影人</label>
<select class="select" id="person_id" name="person_id"> <?php /* 筛选用的单选:要留一个「不选」= 全部,所以开 $ppAllowEmpty */ ?>
<option value="0">全部观影人</option> <?php
<?php foreach ($persons as $pp): ?> $ppName = 'person_id';
<option value="<?= (int)$pp['id'] ?>" <?= (int)$p['person_id'] === (int)$pp['id'] ? 'selected' : '' ?>> $ppPeople = $persons;
<?= e((string)$pp['name']) ?> $ppSelected = [(int)$p['person_id']];
</option> $ppMode = 'single';
<?php endforeach; ?> $ppAllowEmpty = true;
</select> $ppOpenBtn = '选择观影人';
$ppTitle = '按观影人筛选';
$ppEmptyText = '全部观影人';
require __DIR__ . '/../_person_picker.php';
?>
</div> </div>
<div class="field"> <div class="field">
<label>日期范围</label> <label>日期范围</label>
+7
View File
@@ -422,6 +422,8 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
<?php if ($hasPoster || $hasTicket || $m['photos']): ?> <?php if ($hasPoster || $hasTicket || $m['photos']): ?>
<!-- 图片灯箱:点任意一张图在当前页弹出,可左右翻看 / Esc 关闭(MovieLog 同款) --> <!-- 图片灯箱:点任意一张图在当前页弹出,可左右翻看 / Esc 关闭(MovieLog 同款) -->
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-hidden="true" aria-label="图片查看器"> <div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-hidden="true" aria-label="图片查看器">
<?php /* v24 第 3 条:预览层右上角恒有下载按钮(与全局附件预览一个规矩) */ ?>
<a class="lb-btn lb-dl" id="lbDl" href="#" download>下载</a>
<button type="button" class="lb-btn lb-close" id="lbClose" aria-label="关闭">✕</button> <button type="button" class="lb-btn lb-close" id="lbClose" aria-label="关闭">✕</button>
<button type="button" class="lb-btn lb-nav lb-prev" id="lbPrev" aria-label="上一张">‹</button> <button type="button" class="lb-btn lb-nav lb-prev" id="lbPrev" aria-label="上一张">‹</button>
<button type="button" class="lb-btn lb-nav lb-next" id="lbNext" aria-label="下一张">›</button> <button type="button" class="lb-btn lb-nav lb-next" id="lbNext" aria-label="下一张">›</button>
@@ -449,12 +451,17 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
var capEl = document.getElementById('lbCaption'); var capEl = document.getElementById('lbCaption');
var cntEl = document.getElementById('lbCounter'); var cntEl = document.getElementById('lbCounter');
var closeEl = document.getElementById('lbClose'); var closeEl = document.getElementById('lbClose');
var dlEl = document.getElementById('lbDl');
var prevEl = document.getElementById('lbPrev'); var prevEl = document.getElementById('lbPrev');
var nextEl = document.getElementById('lbNext'); var nextEl = document.getElementById('lbNext');
if (!box || !items.length) return; if (!box || !items.length) return;
var idx = 0, opener = null; var idx = 0, opener = null;
function render() { function render() {
imgEl.src = items[idx].url; imgEl.src = items[idx].url;
// 下载地址:与预览同一个键,只是带上 dl=1(服务端会改 Content-Disposition)
if (dlEl) {
dlEl.setAttribute('href', items[idx].url + (items[idx].url.indexOf('?') < 0 ? '?' : '&') + 'dl=1');
}
capEl.textContent = items[idx].cap; capEl.textContent = items[idx].cap;
cntEl.textContent = (idx + 1) + ' / ' + items.length; cntEl.textContent = (idx + 1) + ' / ' + items.length;
var multi = items.length > 1; var multi = items.length > 1;
+4 -12
View File
@@ -5,7 +5,7 @@
* 三处与早期版本不同的地方: * 三处与早期版本不同的地方:
* · 「小名」没了 —— 称呼是**视角属性**(妈妈叫"明明"、姐姐叫"小弟"), * · 「小名」没了 —— 称呼是**视角属性**(妈妈叫"明明"、姐姐叫"小弟"),
* 在「成员详情」页里各自设置自己怎么叫,不写在这个人的档案里。 * 在「成员详情」页里各自设置自己怎么叫,不写在这个人的档案里。
* · 生日变成**三个**:身份证生日(由证件推导、只读)、阳历生日、农历生日。 * · 生日有两种记法:阳历生日与农历生日(证件上那天照常存库,但按用户要求不再显示)。
* 阳历与农历是同一个人的同一天,所以用「以哪个为准」明确这次填的是哪一个, * 阳历与农历是同一个人的同一天,所以用「以哪个为准」明确这次填的是哪一个,
* 服务端据此反推另一个 —— 不靠 JS,避免"JS 没跑到就静默存错"。 * 服务端据此反推另一个 —— 不靠 JS,避免"JS 没跑到就静默存错"。
* · 生日精确到**时分秒**:原生 datetime-local 只有时分、且中文环境会显示成 * · 生日精确到**时分秒**:原生 datetime-local 只有时分、且中文环境会显示成
@@ -28,7 +28,6 @@ if ($lunVal === '') {
$lv = (string)($person['birthday_lunar'] ?? ''); $lv = (string)($person['birthday_lunar'] ?? '');
$lunVal = $lv; $lunVal = $lv;
} }
$idBd = trim((string)($person['birthday_id_card'] ?? ''));
// 补充联系方式(v6 起):新增时是空数组;编辑时是库里解密后的行; // 补充联系方式(v6 起):新增时是空数组;编辑时是库里解密后的行;
// 校验失败重画时是用户刚提交的那批(连同填错的也要原样带回)。 // 校验失败重画时是用户刚提交的那批(连同填错的也要原样带回)。
$contacts = $contacts ?? []; $contacts = $contacts ?? [];
@@ -165,8 +164,7 @@ $ctHints = [
value="<?= e(old('id_card', $idCardValue ?? '')) ?>" placeholder="可留空"> value="<?= e(old('id_card', $idCardValue ?? '')) ?>" placeholder="可留空">
<span class="tip"> <span class="tip">
加密存储,页面上一律只显示前 6 后 4 位。 加密存储,页面上一律只显示前 6 后 4 位。
<b>填了它,最下面的「身份证生日」会自动读出来</b>(当前:<span id="idCardBirthday"><?= $idBd !== '' ? e($idBd) : '' ?></span>), 家庭场景里真正需要它的场合很少 —— 能不填就别填。
这个日期系统永远不会改动。家庭场景里真正需要它的场合很少 —— 能不填就别填。
</span> </span>
<?php if (!empty($errors['id_card'])): ?><span class="err"><?= e($errors['id_card']) ?></span><?php endif; ?> <?php if (!empty($errors['id_card'])): ?><span class="err"><?= e($errors['id_card']) ?></span><?php endif; ?>
</div> </div>
@@ -230,16 +228,10 @@ $ctHints = [
<div class="field full"> <div class="field full">
<label>生日</label> <label>生日</label>
<p class="small muted" style="margin:0 0 12px"> <p class="small muted" style="margin:0 0 12px">
一个人有三个生日:<b>身份证上的</b>(由证件推导,系统永远不会改它)、 一个人的生日分两种记法:<b>真实阳历生日</b>与<b>农历生日</b>。
<b>真实阳历生日</b>、<b>农历生日</b>。填了身份证号会自动读出第一个; 阳历与农历只要填一个,另一个自动算出来。两个不在同一天时,首页会分别提醒。
阳历与农历只要填一个,另一个自动算出来。三个不在同一天时,首页会分别提醒。
</p> </p>
<div class="field" style="margin-bottom:12px">
<label for="birthday_id_card_display">身份证生日(只读)</label>
<span class="bd-readonly" id="birthday_id_card_display"><?= $idBd !== '' ? e($idBd) : '填了身份证号后自动读出' ?></span>
</div>
<div class="bd-source"> <div class="bd-source">
<label for="bdSource">我这次填的是</label> <label for="bdSource">我这次填的是</label>
<select class="select" id="bdSource" name="birthday_source" style="width:auto"> <select class="select" id="bdSource" name="birthday_source" style="width:auto">
+3 -2
View File
@@ -34,13 +34,14 @@ $theirParents = \App\Kinship::parents($graph, $pid);
$theirSpouses = \App\Kinship::spouseIds($graph, $pid); $theirSpouses = \App\Kinship::spouseIds($graph, $pid);
$theirKids = \App\Kinship::childIds($graph, $pid); $theirKids = \App\Kinship::childIds($graph, $pid);
$theirSibs = \App\Kinship::siblingIds($graph, $pid); $theirSibs = \App\Kinship::siblingIds($graph, $pid);
$shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '农历生日']; $shortKind = \App\Birthday::KINDS;
?> ?>
<div class="page-head"> <div class="page-head">
<h1><?= e($pShown) ?></h1> <h1><?= e($pShown) ?></h1>
<span class="sub"> <span class="sub">
<?php if (!$isSelf): ?> <?php if (!$isSelf && (string)$labelToMe !== ''): ?>
<?php /* v24:没关系就不显示标签(Kinship::label() 推不出关系时返回空串) */ ?>
<span class="rel-label"><?= e($labelToMe) ?></span> <span class="rel-label"><?= e($labelToMe) ?></span>
<?php if ($isMyNick): ?> <?php if ($isMyNick): ?>
· 本名 <?= e((string)$p['name']) ?> · 本名 <?= e((string)$p['name']) ?>
+35 -58
View File
@@ -28,22 +28,9 @@ $canWrite = \App\Auth::canWrite();
</div> </div>
</div> </div>
<form class="search" method="get" action="index.php"> <?php /* v24(用户 2026-10-02 第 18 条):新建资源页去掉「搜索资源」。
<input type="hidden" name="r" value="resources"> 资源本来就不多,列表默认折叠、一眼能扫完;搜索框反而把「新建」按钮挤下去了。
<input class="input" type="text" name="keyword" value="<?= e($keyword) ?>" placeholder="搜标题或正文"> 真的找不着再靠浏览器的 Ctrl+F,不为一个短列表专门养一套搜索。 */ ?>
<select class="select" name="owner">
<option value="0">所有人</option>
<?php foreach ($people as $p): ?>
<option value="<?= (int)$p['id'] ?>"<?= (int)$ownerId === (int)$p['id'] ? ' selected' : '' ?>>
<?= e(person_name($p)) ?>
</option>
<?php endforeach; ?>
</select>
<button class="btn" type="submit">搜索</button>
<?php if ($keyword !== '' || $ownerId > 0): ?>
<a class="search-clear" href="<?= e(url('resources')) ?>">清除</a>
<?php endif; ?>
</form>
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<div class="card card-pad" id="resAddCard" <?= $isEditing ? '' : 'hidden' ?>> <div class="card card-pad" id="resAddCard" <?= $isEditing ? '' : 'hidden' ?>>
@@ -56,15 +43,18 @@ $canWrite = \App\Auth::canWrite();
<?php endif; ?> <?php endif; ?>
<div class="form-grid"> <div class="form-grid">
<div class="field"> <div class="field">
<label for="rs-owner">归属于谁 *</label> <label>归属于谁 *</label>
<select class="select" id="rs-owner" name="owner_id" required> <?php /* v24 第 11 条:与全局选人组件统一(原来是下拉,名册一长就难找) */ ?>
<?php foreach ($people as $p): ?> <?php
<option value="<?= (int)$p['id'] ?>" $ppName = 'owner_id';
<?= (int)($editing['owner_id'] ?? (int)\App\Auth::personId()) === (int)$p['id'] ? ' selected' : '' ?>> $ppPeople = $people;
<?= e(person_name($p)) ?> $ppSelected = [(int)($editing['owner_id'] ?? (int)\App\Auth::personId())];
</option> $ppMode = 'single';
<?php endforeach; ?> $ppOpenBtn = '选择归属人';
</select> $ppTitle = '资源归属于谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
</div> </div>
<div class="field"> <div class="field">
<label for="rs-title">标题 *</label> <label for="rs-title">标题 *</label>
@@ -95,8 +85,7 @@ $canWrite = \App\Auth::canWrite();
<h2 class="cinema-h2">资源列表</h2> <h2 class="cinema-h2">资源列表</h2>
<?php if (!$rows): ?> <?php if (!$rows): ?>
<p class="empty-hint"> <p class="empty-hint">
<?= ($keyword !== '' || $ownerId > 0) ? '没有匹配的资源,换个条件试试。' 还没有资源。证件、照片、账号这些"挂在某人名下的东西"都可以记在这里。
: '还没有资源。证件、照片、账号这些"挂在某人名下的东西"都可以记在这里。' ?>
</p> </p>
<?php else: ?> <?php else: ?>
<div class="res-list"> <div class="res-list">
@@ -122,31 +111,14 @@ $canWrite = \App\Auth::canWrite();
<p class="empty-hint">(没有正文)</p> <p class="empty-hint">(没有正文)</p>
<?php endif; ?> <?php endif; ?>
<?php if ($as): ?> <?php
<div class="attach-row"> $attRows = $as;
<?php foreach ($as as $a): ?> $attCanEdit = $canWrite && \App\Resource::canWrite($r, (int)\App\Auth::personId());
<span class="attach-item"> $attDelUrl = 'resources/attach_del';
<?php if (\App\Attachment::previewable($a)): ?> $attDelField = 'id';
<a href="<?= e(media_url((string)$a['key'])) ?>" target="_blank" rel="noopener"> $attExtra = [];
<?= (int)$a['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($a['name'] ?: '附件')) ?> require __DIR__ . '/../_attachments.php';
</a> ?>
<?php else: ?>
<?= (int)$a['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($a['name'] ?: '附件')) ?>
<?php endif; ?>
<span class="res-size"><?= e(\App\Attachment::sizeWord($a['size'] ?? 0)) ?></span>
<a class="link-quiet" href="<?= e(media_url((string)$a['key'], ['dl' => 1])) ?>">下载</a>
<?php if ($canWrite && \App\Resource::canWrite($r, (int)\App\Auth::personId())): ?>
<form method="post" action="<?= e(url('resources/attach_del')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这个附件?')">删除</button>
</form>
<?php endif; ?>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($sh): ?> <?php if ($sh): ?>
<div class="res-shares"> <div class="res-shares">
@@ -175,12 +147,17 @@ $canWrite = \App\Auth::canWrite();
<form class="inline-form" method="post" action="<?= e(url('resources/share')) ?>"> <form class="inline-form" method="post" action="<?= e(url('resources/share')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $rid ?>"> <input type="hidden" name="id" value="<?= $rid ?>">
<select class="select" name="person_id"> <?php
<?php foreach ($people as $p): ?> $ppName = 'person_id';
<?php if ((int)$p['id'] === (int)$r['owner_id']) { continue; } ?> $ppPeople = array_values(array_filter($people,
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option> fn($p) => (int)$p['id'] !== (int)$r['owner_id']));
<?php endforeach; ?> $ppSelected = [];
</select> $ppMode = 'single';
$ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<select class="select" name="perm"> <select class="select" name="perm">
<option value="read">只读</option> <option value="read">只读</option>
<option value="write">可读写</option> <option value="write">可读写</option>
+40
View File
@@ -0,0 +1,40 @@
<?php
/**
* 任务模块的「设置」下拉(v24 用户第 10 条)
*
* 用户原话:「和观影一样,新建任务旁边,也是设置,对任务的设置。」
* 所以这里与观影的 _settings_menu.php 用**同一套外观**:只留图标(三条横线)、
* 排在最右侧(靠 CSS 的 margin-left:auto)、条目文字前都有图标。
*
* 用法(require 前 set 好):
* $setCurrent 当前页 key('board'|'settings'|'done'),用来高亮
*/
$setCurrent = (string)($setCurrent ?? '');
$setOn = static function (string $key) use ($setCurrent): string {
return $setCurrent === $key ? ' class="on"' : '';
};
$setIcon = static function (string $name): string {
$paths = [
// 看板设置:分栏
'board' => '<rect x="2" y="3" width="4" height="10" rx="1"/><rect x="7.5" y="3" width="4" height="6" rx="1"/>',
// 已完成:对勾
'done' => '<path d="M3 8.5l3 3 7-7"/>',
];
$d = $paths[$name] ?? '';
if ($d === '') {
return '';
}
return '<svg class="mi" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"'
. ' fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"'
. ' stroke-linejoin="round">' . $d . '</svg>';
};
?>
<details class="movie-set menu">
<summary class="btn btn-icon" title="任务设置" aria-label="任务设置"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg></summary>
<div class="menu-list">
<a href="<?= e(url('tasks/settings')) ?>"<?= $setOn('settings') ?>><?= $setIcon('board') ?><span>分类与看板</span></a>
<a href="<?= e(url('tasks/done')) ?>"<?= $setOn('done') ?>><?= $setIcon('done') ?><span>已完成</span></a>
</div>
</details>
<?php
unset($setCurrent, $setOn, $setIcon);
+79
View File
@@ -0,0 +1,79 @@
<?php
/**
* 已完成的任务(v24 用户第 10 条)
*
* 用户原话:「还可以在这里查看已完成的任务,已完成的任务,
* 按照分类来进行展示和搜索就可以了。」
*
* 所以这一页是**只看已完成**的归档:按分类筛 + 按标题搜,不再分列、不拖拽。
*
* @param array $rows 已完成的任务
* @param array $categories 分类清单(做筛选条)
* @param int $cat 当前选中的分类(0=全部)
* @param string $kw 搜索词
*/
?>
<div class="page-head">
<h1>已完成</h1>
<span class="sub">共 <?= count($rows) ?> 项</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('tasks')) ?>">返回看板</a>
<a class="btn btn-ghost" href="<?= e(url('tasks/settings')) ?>">任务设置</a>
</div>
</div>
<form class="card card-pad" method="get" action="index.php">
<input type="hidden" name="r" value="tasks/done">
<div class="form-grid">
<div class="field">
<label for="td-cat">分类</label>
<select class="select" id="td-cat" name="cat">
<option value="0">全部分类</option>
<?php foreach ($categories as $c): ?>
<option value="<?= (int)$c['id'] ?>" <?= $cat === (int)$c['id'] ? 'selected' : '' ?>>
<?= e((string)$c['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="td-kw">搜索标题</label>
<input class="input" id="td-kw" name="kw" value="<?= e($kw) ?>" placeholder="输入关键字">
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit">筛选</button>
<a class="btn btn-ghost" href="<?= e(url('tasks/done')) ?>">清空</a>
</div>
</form>
<div class="card card-pad">
<?php if (!$rows): ?>
<p class="empty-hint">
<?= ($cat > 0 || $kw !== '') ? '没有符合条件的已完成任务。' : '还没有已完成的任务。' ?>
</p>
<?php else: ?>
<ul class="done-list">
<?php foreach ($rows as $t): ?>
<li class="done-item">
<a class="done-title" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>">
<?= e((string)$t['title']) ?>
</a>
<?php if (($t['category_name'] ?? '') !== ''): ?>
<span class="node-badge"><?= e((string)$t['category_name']) ?></span>
<?php endif; ?>
<?php if (($t['done_at'] ?? '') !== ''): ?>
<span class="small muted">完成于 <?= e(substr((string)$t['done_at'], 0, 10)) ?></span>
<?php endif; ?>
<?php if (!empty($t['assignee_name'])): ?>
<?= person_chip([
'id' => (int)$t['assignee_id'], 'name' => (string)$t['assignee_name'],
'my_nickname' => (string)($t['assignee_nick'] ?? ''),
'avatar' => $t['avatar'] ?? null, 'avatar_color' => $t['avatar_color'] ?? null,
]) ?>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
+60 -3
View File
@@ -12,20 +12,35 @@
* @param array $people 名册(选负责人 / 共享给别人) * @param array $people 名册(选负责人 / 共享给别人)
*/ */
$canWrite = \App\Auth::canWrite(); $canWrite = \App\Auth::canWrite();
$cols = [['todo', '待办'], ['doing', '进行中'], ['done', '已完成']]; // ⚠️ v24 第 10 条:看板上只有「待办 / 进行中」两列 —— 已完成的任务从看板消失,
// 要看去「任务设置 → 已完成」按分类看/搜。留着第三列就不是"直接消失"了。
$cols = [['todo', '待办'], ['doing', '进行中']];
$boards = $boards ?? [];
$categories = $categories ?? [];
$curBoard = (int)($curBoard ?? 0);
?> ?>
<div class="page-head"> <div class="page-head">
<h1>任务</h1> <h1>任务</h1>
<span class="sub">共 <?= (int)$total ?> 项</span> <span class="sub">共 <?= (int)$total ?> 项未完成</span>
<div class="actions"> <div class="actions">
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<button class="btn" type="button" id="taskAddBtn">新建任务</button> <button class="btn" type="button" id="taskAddBtn">新建任务</button>
<?php else: ?> <?php else: ?>
<span class="link-quiet">只读账号</span> <span class="link-quiet">只读账号</span>
<?php endif; ?> <?php endif; ?>
<?php $setCurrent = 'board'; require __DIR__ . '/_settings_menu.php'; ?>
</div> </div>
</div> </div>
<?php if (count($boards) > 1): ?>
<div class="seg kb-boards">
<?php foreach ($boards as $b): ?>
<a class="seg-btn<?= (int)$b['id'] === $curBoard ? ' is-on' : '' ?>"
href="<?= e(url('tasks', ['board' => (int)$b['id']])) ?>"><?= e((string)$b['name']) ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<div class="card card-pad" id="taskAddCard" hidden> <div class="card card-pad" id="taskAddCard" hidden>
<h2 class="cinema-h2">新建任务</h2> <h2 class="cinema-h2">新建任务</h2>
@@ -36,9 +51,14 @@ $cols = [['todo', '待办'], ['doing', '进行中'], ['done', '已完成']];
<label for="tk-title">要做什么 *</label> <label for="tk-title">要做什么 *</label>
<input class="input" id="tk-title" name="title" required placeholder="如:周末大扫除"> <input class="input" id="tk-title" name="title" required placeholder="如:周末大扫除">
</div> </div>
<?php /* v24 第 5 条:截止时刻也走全局统一的「日期 + 时分秒」控件。
秒这一档保留(与其它模块同一口径),默认 23:59:59 —— "这天之前做完"
比"这天 00:00 之前"更符合直觉。 */ ?>
<div class="field"> <div class="field">
<label for="tk-due">截止时间(有才会出现在日历里)</label> <label for="tk-due">截止时间(有才会出现在日历里)</label>
<input class="input" type="date" id="tk-due" name="due_date"> <?php $dtName = 'due_at'; $dtId = 'tk-due'; $dtValue = null;
$dtRequired = false; $dtSeconds = true;
require __DIR__ . '/../_datetime.php'; ?>
</div> </div>
<div class="field"> <div class="field">
<label for="tk-assign">负责人</label> <label for="tk-assign">负责人</label>
@@ -57,6 +77,25 @@ $cols = [['todo', '待办'], ['doing', '进行中'], ['done', '已完成']];
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div class="field">
<label for="tk-cat">分类</label>
<select class="select" id="tk-cat" name="category_id">
<option value="0">未分类</option>
<?php foreach ($categories as $c): ?>
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="tk-board">看板</label>
<select class="select" id="tk-board" name="board_id">
<?php foreach ($boards as $b): ?>
<option value="<?= (int)$b['id'] ?>" <?= (int)$b['id'] === $curBoard ? 'selected' : '' ?>>
<?= e((string)$b['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field"> <div class="field">
<label for="tk-share">共享给(可多选,任何人完成都算完成)</label> <label for="tk-share">共享给(可多选,任何人完成都算完成)</label>
<select class="select" id="tk-share" name="shares[]" multiple size="3"> <select class="select" id="tk-share" name="shares[]" multiple size="3">
@@ -123,6 +162,9 @@ $cols = [['todo', '待办'], ['doing', '进行中'], ['done', '已完成']];
<?php if ((int)($t['attach_count'] ?? 0) > 0): ?> <?php if ((int)($t['attach_count'] ?? 0) > 0): ?>
<span class="kb-ico" title="附件">📎 <?= (int)$t['attach_count'] ?></span> <span class="kb-ico" title="附件">📎 <?= (int)$t['attach_count'] ?></span>
<?php endif; ?> <?php endif; ?>
<?php if (($t['category_name'] ?? '') !== ''): ?>
<span class="node-badge" title="分类"><?= e((string)$t['category_name']) ?></span>
<?php endif; ?>
</div> </div>
<?php if ($total > 0): ?> <?php if ($total > 0): ?>
<div class="kb-sub"> <div class="kb-sub">
@@ -194,21 +236,36 @@ $cols = [['todo', '待办'], ['doing', '进行中'], ['done', '已完成']];
}); });
/* ---- 拖动(渐进增强:拖不动还有上面的左右按钮)---- */ /* ---- 拖动(渐进增强:拖不动还有上面的左右按钮)---- */
/* ⚠️ v24(用户 2026-10-02 第 8 条):拖完一张卡,松手那一下浏览器还会
补一个 click 给卡片里的标题链接 —— 于是"刚拖到位就立刻跳进详情页",
看板直接没法用了。
修法:拖结束就立个牌子 dragEnded,标题链接的 click 看到牌子就吞掉。
牌子在**下一次 mousedown** 时摘掉(真想点进去总得再按一次鼠标),
所以不会误伤正常点击 —— 比用 setTimeout 猜时间准得多。 */
var dragging = null; var dragging = null;
var dragEnded = false;
document.addEventListener('mousedown', function () { dragEnded = false; }, true);
Array.prototype.forEach.call(document.querySelectorAll('.kb-card[draggable="true"]'), function (card) { Array.prototype.forEach.call(document.querySelectorAll('.kb-card[draggable="true"]'), function (card) {
card.addEventListener('dragstart', function (ev) { card.addEventListener('dragstart', function (ev) {
dragging = card; dragging = card;
dragEnded = false;
card.classList.add('is-dragging'); card.classList.add('is-dragging');
if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; } if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; }
}); });
card.addEventListener('dragend', function () { card.addEventListener('dragend', function () {
card.classList.remove('is-dragging'); card.classList.remove('is-dragging');
dragging = null; dragging = null;
dragEnded = true;
Array.prototype.forEach.call(document.querySelectorAll('.kb-body.is-over'), function (el) { Array.prototype.forEach.call(document.querySelectorAll('.kb-body.is-over'), function (el) {
el.classList.remove('is-over'); el.classList.remove('is-over');
}); });
}); });
}); });
Array.prototype.forEach.call(document.querySelectorAll('.kb-card .kb-title'), function (a) {
a.addEventListener('click', function (ev) {
if (dragEnded) { ev.preventDefault(); ev.stopPropagation(); }
});
});
Array.prototype.forEach.call(document.querySelectorAll('.kb-body'), function (body) { Array.prototype.forEach.call(document.querySelectorAll('.kb-body'), function (body) {
body.addEventListener('dragover', function (ev) { body.addEventListener('dragover', function (ev) {
if (!dragging) return; if (!dragging) return;
+95
View File
@@ -0,0 +1,95 @@
<?php
/**
* 任务设置(v24 用户第 9、10 条)
*
* 用户原话:「和观影一样,新建任务旁边,也是设置,对任务的设置。
* 用户可以设置任务分类、看板。还可以在这里查看已完成的任务,
* 已完成的任务,按照分类来进行展示和搜索就可以了。」
*
* 所以这一页两块:看板 / 分类,各有增删改;旁边给一个进「已完成」的入口。
*
* @param array $boards 看板(带 open_count:里面还有几件没做完)
* @param array $categories 分类(带 used_count:被几条任务用过)
*/
$canWrite = \App\Auth::canWrite();
?>
<div class="page-head">
<h1>任务设置</h1>
<span class="sub">看板与分类都由你自己定</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('tasks')) ?>">返回任务</a>
<a class="btn" href="<?= e(url('tasks/done')) ?>">已完成的任务</a>
</div>
</div>
<?php if (!$canWrite): ?>
<div class="card card-pad"><p class="empty-hint">只读账号不能改设置。</p></div>
<?php else: ?>
<div class="card card-pad">
<h2 class="cinema-h2">看板</h2>
<p class="small muted" style="margin-top:0">
看板是「分开看几摊事」用的(家务 / 装修 / 旅行准备 …)。任务看板顶上可以切换。
删掉一个看板时,里面的任务不会被删,会挪到默认看板。
</p>
<div class="set-list">
<?php foreach ($boards as $b): ?>
<form class="inline-form set-row" method="post" action="<?= e(url('tasks/board_update')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
<input class="input" name="name" value="<?= e((string)$b['name']) ?>" required>
<span class="small muted"><?= (int)($b['open_count'] ?? 0) ?> 件未完成</span>
<button class="btn btn-sm" type="submit">改名</button>
</form>
<?php if (count($boards) > 1): ?>
<form class="inline-form" method="post" action="<?= e(url('tasks/board_del')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这个看板?里面的任务会挪到默认看板,不会丢。')">删除</button>
</form>
<?php else: ?>
<span class="small muted set-tail">最后一个看板不能删</span>
<?php endif; ?>
<?php endforeach; ?>
</div>
<form class="inline-form" method="post" action="<?= e(url('tasks/board_add')) ?>" style="margin-top:10px">
<?= csrf_field() ?>
<input class="input" name="name" placeholder="新看板名,如:装修" required>
<button class="btn btn-sm" type="submit">添加看板</button>
</form>
</div>
<div class="card card-pad">
<h2 class="cinema-h2">分类</h2>
<p class="small muted" style="margin-top:0">
分类是给任务贴的标签(证件 / 报销 / 维修 …),用来在「已完成」里归类查看与搜索。
</p>
<div class="set-list">
<?php if (!$categories): ?>
<p class="empty-hint">还没有分类。</p>
<?php endif; ?>
<?php foreach ($categories as $c): ?>
<form class="inline-form set-row" method="post" action="<?= e(url('tasks/cat_update')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<input class="input" name="name" value="<?= e((string)$c['name']) ?>" required>
<span class="small muted"><?= (int)($c['used_count'] ?? 0) ?> 条用过</span>
<button class="btn btn-sm" type="submit">改名</button>
</form>
<form class="inline-form" method="post" action="<?= e(url('tasks/cat_del')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这个分类?用它标过的任务会变成「未分类」。')">删除</button>
</form>
<?php endforeach; ?>
</div>
<form class="inline-form" method="post" action="<?= e(url('tasks/cat_add')) ?>" style="margin-top:10px">
<?= csrf_field() ?>
<input class="input" name="name" placeholder="新分类名,如:证件" required>
<button class="btn btn-sm" type="submit">添加分类</button>
</form>
</div>
<?php endif; ?>
+46 -27
View File
@@ -88,8 +88,10 @@ $isChild = !empty($t['parent_id']);
<input class="input" name="title" required value="<?= e((string)$t['title']) ?>"> <input class="input" name="title" required value="<?= e((string)$t['title']) ?>">
</div> </div>
<div class="field"> <div class="field">
<label>截止日期(留空=不上日历)</label> <label>截止时间(留空=不上日历)</label>
<input class="input" type="date" name="due_date" value="<?= e(substr((string)($t['due_at'] ?? ''), 0, 10)) ?>"> <?php $dtName = 'due_at'; $dtValue = $t['due_at'] ?? null;
$dtRequired = false; $dtSeconds = true;
require __DIR__ . '/../_datetime.php'; ?>
</div> </div>
<div class="field"> <div class="field">
<label>负责人</label> <label>负责人</label>
@@ -123,6 +125,28 @@ $isChild = !empty($t['parent_id']);
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<?php /* v24 第 9、10 条:分类与看板(看板把任务放到哪一块,分类是标签) */ ?>
<div class="field">
<label>分类</label>
<select class="select" name="category_id">
<option value="0">未分类</option>
<?php foreach (($categories ?? []) as $c): ?>
<option value="<?= (int)$c['id'] ?>"<?= (int)($t['category_id'] ?? 0) === (int)$c['id'] ? ' selected' : '' ?>>
<?= e((string)$c['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>看板</label>
<select class="select" name="board_id">
<?php foreach (($boards ?? []) as $b): ?>
<option value="<?= (int)$b['id'] ?>"<?= (int)($t['board_id'] ?? 0) === (int)$b['id'] ? ' selected' : '' ?>>
<?= e((string)$b['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field full"> <div class="field full">
<label>备注</label> <label>备注</label>
<textarea class="input" name="note" rows="2"><?= e((string)($t['note'] ?? '')) ?></textarea> <textarea class="input" name="note" rows="2"><?= e((string)($t['note'] ?? '')) ?></textarea>
@@ -175,7 +199,8 @@ $isChild = !empty($t['parent_id']);
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="parent_id" value="<?= (int)$t['id'] ?>"> <input type="hidden" name="parent_id" value="<?= (int)$t['id'] ?>">
<input class="input" name="title" required placeholder="如:擦玻璃"> <input class="input" name="title" required placeholder="如:擦玻璃">
<input class="input" type="date" name="due_date"> <?php $dtName = 'due_at'; $dtValue = null; $dtRequired = false; $dtSeconds = true;
require __DIR__ . '/../_datetime.php'; ?>
<button class="btn btn-sm" type="submit">加上</button> <button class="btn btn-sm" type="submit">加上</button>
</form> </form>
</details> </details>
@@ -189,24 +214,14 @@ $isChild = !empty($t['parent_id']);
<?php if (!$t['attachs']): ?> <?php if (!$t['attachs']): ?>
<p class="empty-hint">还没有附件。</p> <p class="empty-hint">还没有附件。</p>
<?php else: ?> <?php else: ?>
<div class="attach-row"> <?php
<?php foreach ($t['attachs'] as $a): ?> $attRows = $t['attachs'];
<span class="attach-item"> $attCanEdit = $canWrite;
<a href="<?= e(media_url((string)$a['key'])) ?>" target="_blank" rel="noopener"> $attDelUrl = 'tasks/attach_del';
<?= (int)$a['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($a['name'] ?: '附件')) ?> $attDelField = 'id';
</a> $attExtra = [];
<span class="res-size"><?= e(\App\Attachment::sizeWord($a['size'] ?? 0)) ?></span> require __DIR__ . '/../_attachments.php';
<a class="link-quiet" href="<?= e(media_url((string)$a['key'], ['dl' => 1])) ?>">下载</a> ?>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('tasks/attach_del')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit" onclick="return confirm('删掉这个附件?')">删除</button>
</form>
<?php endif; ?>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?> <?php endif; ?>
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<form class="inline-form" method="post" enctype="multipart/form-data" action="<?= e(url('tasks/attach_add')) ?>"> <form class="inline-form" method="post" enctype="multipart/form-data" action="<?= e(url('tasks/attach_add')) ?>">
@@ -243,12 +258,16 @@ $isChild = !empty($t['parent_id']);
<form class="inline-form" method="post" action="<?= e(url('tasks/share')) ?>"> <form class="inline-form" method="post" action="<?= e(url('tasks/share')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<select class="select" name="person_id"> <?php
<?php foreach ($people as $p): ?> $ppName = 'person_id';
<?php if ((int)$p['id'] === $meId) { continue; } ?> $ppPeople = array_values(array_filter($people, fn($p) => (int)$p['id'] !== $meId));
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option> $ppSelected = [];
<?php endforeach; ?> $ppMode = 'single';
</select> $ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<select class="select" name="perm"> <select class="select" name="perm">
<option value="write">可写</option> <option value="write">可写</option>
<option value="read">只读</option> <option value="read">只读</option>
+2 -2
View File
@@ -57,12 +57,12 @@ $nfPrefix = $nfIsEdit ? 'ne' . (int)$nfNode['id'] : ('n' . ($nfCategory === 'rea
<div class="field"> <div class="field">
<label>开始</label> <label>开始</label>
<?php $dtName = 'starts_at'; $dtValue = $nfNode['starts_at'] ?? ''; <?php $dtName = 'starts_at'; $dtValue = $nfNode['starts_at'] ?? '';
$dtId = $nfPrefix . '-s'; $dtSeconds = false; require __DIR__ . '/../_datetime.php'; ?> $dtId = $nfPrefix . '-s'; $dtSeconds = true; require __DIR__ . '/../_datetime.php'; ?>
</div> </div>
<div class="field"> <div class="field">
<label>结束</label> <label>结束</label>
<?php $dtName = 'ends_at'; $dtValue = $nfNode['ends_at'] ?? ''; <?php $dtName = 'ends_at'; $dtValue = $nfNode['ends_at'] ?? '';
$dtId = $nfPrefix . '-e'; $dtSeconds = false; require __DIR__ . '/../_datetime.php'; ?> $dtId = $nfPrefix . '-e'; $dtSeconds = true; require __DIR__ . '/../_datetime.php'; ?>
</div> </div>
<div class="field full"> <div class="field full">
<label for="<?= e($nfPrefix) ?>-detail">记录</label> <label for="<?= e($nfPrefix) ?>-detail">记录</label>
+14 -16
View File
@@ -47,13 +47,13 @@ if ($isEdit) {
<div class="field"> <div class="field">
<label for="t-start">出发</label> <label for="t-start">出发</label>
<?php $dtName = 'starts_at'; $dtValue = $t['starts_at'] ?? ''; <?php $dtName = 'starts_at'; $dtValue = $t['starts_at'] ?? '';
$dtId = 't-start'; $dtSeconds = false; require __DIR__ . '/../_datetime.php'; ?> $dtId = 't-start'; $dtSeconds = true; require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?> <?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
</div> </div>
<div class="field"> <div class="field">
<label for="t-end">回来</label> <label for="t-end">回来</label>
<?php $dtName = 'ends_at'; $dtValue = $t['ends_at'] ?? ''; $dtId = 't-end'; $dtSeconds = false; <?php $dtName = 'ends_at'; $dtValue = $t['ends_at'] ?? ''; $dtId = 't-end'; $dtSeconds = true;
require __DIR__ . '/../_datetime.php'; ?> require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?> <?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
</div> </div>
@@ -77,23 +77,21 @@ if ($isEdit) {
<div class="field full"> <div class="field full">
<label>同行的人</label> <label>同行的人</label>
<?php /* v24 第 11 条:与全局选人组件统一(原来是平铺的 checkbox 网格,
名册一多就变成"在一长串里瞪眼找",也搜不了拼音) */ ?>
<?php if (!$people): ?> <?php if (!$people): ?>
<p class="empty-hint">名册里还没有人。</p> <p class="empty-hint">名册里还没有人。</p>
<?php else: ?> <?php else: ?>
<div class="pick-list"> <?php
<?php foreach ($people as $p): ?> $ppName = 'attendees[]';
<label class="pick-item"> $ppPeople = $people;
<input type="checkbox" name="attendees[]" value="<?= (int)$p['id'] ?>" $ppSelected = $selIds;
<?= in_array((int)$p['id'], $selIds, true) ? 'checked' : '' ?>> $ppMode = 'multi';
<?php if (!empty($p['avatar'])): ?> $ppOpenBtn = '+ 添加同行人';
<img class="pick-ph" src="<?= e(media_url((string)$p['avatar'])) ?>" alt=""> $ppTitle = '选择同行的人';
<?php else: ?> $ppTip = '';
<span class="pick-ph"></span> require __DIR__ . '/../_person_picker.php';
<?php endif; ?> ?>
<span class="pick-name"><?= e(person_name($p)) ?></span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
+15 -9
View File
@@ -71,7 +71,13 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
<div class="photo-grid"> <div class="photo-grid">
<?php foreach ($photos as $ph): ?> <?php foreach ($photos as $ph): ?>
<figure class="photo-cell"> <figure class="photo-cell">
<a href="<?= e(media_url((string)$ph['key'])) ?>" target="_blank" rel="noopener"> <?php /* v24 第 3 条:照片也在**本页**预览(原来 target=_blank 是另开一个标签页
只显示一张图,既没有下载按钮也不好回来看) */ ?>
<a href="<?= e(media_url((string)$ph['key'])) ?>"
data-prev="image"
data-src="<?= e(media_url((string)$ph['key'])) ?>"
data-dl="<?= e(media_url((string)$ph['key'], ['dl' => 1])) ?>"
data-name="<?= e((string)($ph['name'] ?? '照片')) ?>">
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="<?= e((string)($ph['name'] ?? '')) ?>"> <img src="<?= e(media_url((string)$ph['key'])) ?>" alt="<?= e((string)($ph['name'] ?? '')) ?>">
</a> </a>
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
@@ -107,7 +113,8 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
<?php else: ?> <?php else: ?>
<ol class="node-list"> <ol class="node-list">
<?php foreach ($list as $n): ?> <?php foreach ($list as $n): ?>
<li class="node-row status-<?= e((string)$n['status']) ?>"> <?php /* v24:日历里点「行程节点」是跳 #node-<id> 过来的,这里给个锚点好定位。 */ ?>
<li class="node-row status-<?= e((string)$n['status']) ?>" id="node-<?= (int)$n['id'] ?>">
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'])) ?>"> <span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'])) ?>">
<?= e(\App\Trip::typeIcon((string)$n['node_type'])) ?> <?= e(\App\Trip::typeIcon((string)$n['node_type'])) ?>
</span> </span>
@@ -132,13 +139,12 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($n['attachs'])): ?> <?php if (!empty($n['attachs'])): ?>
<div class="attach-row"> <?php
<?php foreach ($n['attachs'] as $at): ?> $attRows = $n['attachs'];
<a class="attach-item" href="<?= e(media_url((string)$at['key'], ['dl' => 1])) ?>"> $attCanEdit = false; // 节点附件不单独给删除入口(防越权:得先判整趟旅程)
<?= (int)$at['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($at['name'] ?: '附件')) ?> $attDelUrl = '';
</a> require __DIR__ . '/../_attachments.php';
<?php endforeach; ?> ?>
</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if ($canWrite): ?> <?php if ($canWrite): ?>