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:
+142
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user