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:
@@ -19,7 +19,10 @@ use App\Crypto;
|
||||
use App\Database;
|
||||
use App\Douban;
|
||||
use App\Family;
|
||||
use App\Anniversary;
|
||||
use App\Ics;
|
||||
use App\Kinship;
|
||||
use App\Ledger;
|
||||
use App\Movie;
|
||||
use App\MovieReview;
|
||||
use App\Module;
|
||||
@@ -293,6 +296,81 @@ function build_month_grid(string $ym, array $events): array
|
||||
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
|
||||
{
|
||||
// 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'] ?? ''));
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
|
||||
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
|
||||
{
|
||||
@@ -326,24 +414,17 @@ function home_memories(int $familyId, int $limit = 6): array
|
||||
FROM events e
|
||||
WHERE e.family_id = ? AND e.kind IN ('movie','trip') AND e.starts_at IS NOT NULL
|
||||
AND YEAR(e.starts_at) < ?
|
||||
AND DATE_FORMAT(e.starts_at, '%s') = ?
|
||||
ORDER BY e.starts_at DESC LIMIT %d";
|
||||
$st = Database::get()->prepare(sprintf($sql, '%m-%d', $limit));
|
||||
AND DATE_FORMAT(e.starts_at, '%m-%d') = ?
|
||||
ORDER BY e.starts_at DESC LIMIT " . (int)$limit;
|
||||
$st = Database::get()->prepare($sql);
|
||||
$st->execute([$familyId, $year, substr($today, 5, 5)]);
|
||||
$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) {
|
||||
$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']]);
|
||||
}
|
||||
unset($r);
|
||||
return ['rows' => $rows, 'exact' => $exact];
|
||||
return ['rows' => $rows, 'exact' => true];
|
||||
}
|
||||
|
||||
/** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */
|
||||
@@ -502,11 +583,18 @@ switch ($r) {
|
||||
// ⚠️ v15:家谱模板不再吃 Kinship::tree() 的产物(那套 units/generations 是给
|
||||
// 旧画法用的),改由模板里调 Kinship::familyMap() 现算。这里就不再白算一遍。
|
||||
|
||||
// v24 第 12 条:可以按某个人为中心重画(姻亲不在主图里时点卡片上的入口进来)。
|
||||
// ⚠️ 「看的是谁的家谱」与「我登录着」是两件事:$meId 决定称呼视角,$focusId 决定画谁的树。
|
||||
$focusId = (int)($_GET['focus'] ?? 0);
|
||||
if ($focusId <= 0 || !isset($people[$focusId])) {
|
||||
$focusId = $me;
|
||||
}
|
||||
View::render('kin/tree', [
|
||||
'title' => '我的家谱',
|
||||
'meId' => $me,
|
||||
'people' => $people,
|
||||
'graph' => $graph,
|
||||
'title' => $focusId === $me ? '我的家谱' : ((string)($people[$focusId]['name'] ?? '') . ' 的家谱'),
|
||||
'meId' => $me,
|
||||
'focusId' => $focusId,
|
||||
'people' => $people,
|
||||
'graph' => $graph,
|
||||
], 'kin');
|
||||
break;
|
||||
|
||||
@@ -2415,25 +2503,245 @@ switch ($r) {
|
||||
// 自定义日程落在 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':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
$ym = trim((string)($_GET['ym'] ?? ''));
|
||||
if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
|
||||
$ym = date('Y-m');
|
||||
// v24 第 4 条:月 / 周 / 天三种看法。view 只认这三个,认不出就回落月视图。
|
||||
$view = (string)($_GET['view'] ?? 'month');
|
||||
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);
|
||||
[$from, $to] = month_grid_range($ym);
|
||||
$events = Calendar::month($familyId, $cals, $me, $from, $to);
|
||||
|
||||
if ($view === 'month') {
|
||||
$gym = trim((string)($_GET['ym'] ?? ''));
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $gym)) {
|
||||
$ym = $gym;
|
||||
}
|
||||
[$from, $to] = month_grid_range($ym);
|
||||
$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', [
|
||||
'title' => '日程',
|
||||
'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,
|
||||
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||
], 'calendar');
|
||||
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':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
@@ -2451,6 +2759,10 @@ switch ($r) {
|
||||
foreach ($mine as $c) {
|
||||
$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;
|
||||
$editId = (int)($_GET['edit'] ?? 0);
|
||||
if ($editId > 0) {
|
||||
@@ -2467,6 +2779,8 @@ switch ($r) {
|
||||
'shares' => $shares,
|
||||
'editing' => $editing,
|
||||
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||
'cdHas' => $cdHas,
|
||||
'cdPlain' => $cdPlain,
|
||||
], 'calendar');
|
||||
break;
|
||||
|
||||
@@ -2553,43 +2867,39 @@ switch ($r) {
|
||||
$ym = trim((string)($_POST['ym'] ?? ''));
|
||||
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
|
||||
$calId = (int)($_POST['calendar_id'] ?? 0);
|
||||
$cal = null;
|
||||
foreach (Calendar::mine($familyId, $me) as $c) {
|
||||
if ((int)$c['id'] === $calId) {
|
||||
$cal = $c;
|
||||
}
|
||||
}
|
||||
// ⚠️ 走 mineOne 而不是 Calendar::find():要带 perm,共享本的可读写才判得对
|
||||
$cal = Calendar::mineOne($familyId, $me, $calId);
|
||||
if (!$cal || !Calendar::canWrite($cal, $me)) {
|
||||
flash('err', '这本日历你没有写权限。');
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
}
|
||||
$date = trim((string)($_POST['date'] ?? ''));
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
flash('err', '日期不对。');
|
||||
// v24 第 5、6 条:起止各是一个「日期+时分秒」的完整时刻(_datetime.php 控件),
|
||||
// 不再拆成 date / end_date / start_time / end_time 四格。
|
||||
$startsAt = read_dt_input('starts_at');
|
||||
if ($startsAt === null) {
|
||||
flash('err', '开始时间不对。');
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
}
|
||||
$endDate = trim((string)($_POST['end_date'] ?? ''));
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
|
||||
$endDate = null;
|
||||
}
|
||||
$endsAt = read_dt_input('ends_at');
|
||||
$title = trim((string)($_POST['title'] ?? ''));
|
||||
if ($title === '') {
|
||||
flash('err', '日程得有个标题。');
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
}
|
||||
$allDay = !empty($_POST['all_day']);
|
||||
$st = $allDay ? '' : (string)($_POST['start_time'] ?? '');
|
||||
$et = $allDay ? '' : (string)($_POST['end_time'] ?? '');
|
||||
if ($allDay) {
|
||||
// 全天日程:时刻部分一律归零(跨天的全天日程截止时刻含到当天末尾)
|
||||
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
|
||||
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
|
||||
}
|
||||
$note = trim((string)($_POST['note'] ?? ''));
|
||||
|
||||
// ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录,
|
||||
// 而不是往 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') {
|
||||
$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],
|
||||
]);
|
||||
flash('ok', '已加进观影,日历里也能看到了。');
|
||||
@@ -2597,24 +2907,30 @@ switch ($r) {
|
||||
}
|
||||
if ($kind === 'trip') {
|
||||
$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],
|
||||
]);
|
||||
flash('ok', '已加进旅程,日历里也能看到了。');
|
||||
redirect('trips/show', ['id' => $newId]);
|
||||
}
|
||||
Calendar::addEvent((int)$cal['id'], [
|
||||
'title' => $title,
|
||||
'starts_at' => $date,
|
||||
'ends_at' => $endDate,
|
||||
'start_time' => $st !== '' ? $st . ':00' : null,
|
||||
'end_time' => $et !== '' ? $et . ':00' : null,
|
||||
'all_day' => $allDay,
|
||||
'note' => $note,
|
||||
'created_by' => $me,
|
||||
$evId = Calendar::addEvent((int)$cal['id'], [
|
||||
'title' => $title,
|
||||
'location' => trim((string)($_POST['location'] ?? '')),
|
||||
'starts_at' => $startsAt,
|
||||
'ends_at' => $endsAt,
|
||||
'all_day' => $allDay,
|
||||
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
|
||||
'status' => (string)($_POST['status'] ?? 'confirmed'),
|
||||
'transp' => !empty($_POST['transp']),
|
||||
'note' => $note,
|
||||
'created_by' => $me,
|
||||
]);
|
||||
$warns = attach_save_files($familyId, Calendar::ATTACH, $evId, 'attach', $me);
|
||||
if ($warns) {
|
||||
flash('err', '有附件没有保存:' . implode(';', $warns));
|
||||
}
|
||||
flash('ok', '日程已加上。');
|
||||
redirect('calendar', ['ym' => substr($date, 0, 7)]);
|
||||
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
|
||||
break;
|
||||
|
||||
case 'calendar/event':
|
||||
@@ -2632,8 +2948,7 @@ switch ($r) {
|
||||
flash('err', '找不到这条日程。');
|
||||
redirect('calendar');
|
||||
}
|
||||
$cal = Calendar::find((int)$ev['calendar_id']) ?: [];
|
||||
$ev['_can_write'] = Calendar::canWrite($cal, $me);
|
||||
$ev['_can_write'] = Calendar::canWriteEvent($ev, $me);
|
||||
View::render('calendar/event', [
|
||||
'title' => (string)$ev['title'],
|
||||
'ev' => $ev,
|
||||
@@ -2660,31 +2975,40 @@ switch ($r) {
|
||||
flash('err', '找不到这条日程。');
|
||||
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)) {
|
||||
flash('err', '这本日历你没有写权限。');
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
}
|
||||
$date = trim((string)($_POST['date'] ?? ''));
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
flash('err', '日期不对。');
|
||||
// v24 第 5、6 条:起止各是一个完整时刻(精确到秒),见上面 event_add 的说明。
|
||||
$startsAt = read_dt_input('starts_at');
|
||||
if ($startsAt === null) {
|
||||
flash('err', '开始时间不对。');
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
}
|
||||
$allDay = !empty($_POST['all_day']);
|
||||
$endDate = trim((string)($_POST['end_date'] ?? ''));
|
||||
$endsAt = read_dt_input('ends_at');
|
||||
$allDay = !empty($_POST['all_day']);
|
||||
if ($allDay) {
|
||||
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
|
||||
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
|
||||
}
|
||||
Calendar::updateEvent($id, [
|
||||
'title' => trim((string)($_POST['title'] ?? '')),
|
||||
'starts_at' => $date,
|
||||
'ends_at' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) ? $endDate : null,
|
||||
'start_time' => !$allDay && preg_match('/^\d{2}:\d{2}$/', (string)($_POST['start_time'] ?? ''))
|
||||
? (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,
|
||||
'note' => trim((string)($_POST['note'] ?? '')),
|
||||
'title' => trim((string)($_POST['title'] ?? '')),
|
||||
'location' => trim((string)($_POST['location'] ?? '')),
|
||||
'starts_at' => $startsAt,
|
||||
'ends_at' => $endsAt,
|
||||
'all_day' => $allDay,
|
||||
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
|
||||
'status' => (string)($_POST['status'] ?? 'confirmed'),
|
||||
'transp' => !empty($_POST['transp']),
|
||||
'note' => trim((string)($_POST['note'] ?? '')),
|
||||
]);
|
||||
$warns = attach_save_files($familyId, Calendar::ATTACH, $id, 'attach', $me);
|
||||
if ($warns) {
|
||||
flash('err', '有附件没有保存:' . implode(';', $warns));
|
||||
}
|
||||
flash('ok', '日程已保存。');
|
||||
redirect('calendar', ['ym' => substr($date, 0, 7)]);
|
||||
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
|
||||
break;
|
||||
|
||||
case 'calendar/event_del':
|
||||
@@ -2703,7 +3027,7 @@ switch ($r) {
|
||||
$st->execute([$id, $familyId]);
|
||||
$ev = $st->fetch();
|
||||
if ($ev) {
|
||||
$cal = Calendar::find((int)$ev['calendar_id']) ?: [];
|
||||
$cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
|
||||
if (Calendar::canWrite($cal, $me)) {
|
||||
Calendar::deleteEvent($id);
|
||||
flash('ok', '日程已删除。');
|
||||
@@ -2714,6 +3038,98 @@ switch ($r) {
|
||||
redirect('calendar', ['ym' => $ym]);
|
||||
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) ====================
|
||||
// 挂在**某个人名下**的东西(证件 / 照片 / 账号),可共享给别人(只读或读写)。
|
||||
|
||||
@@ -2887,15 +3303,99 @@ switch ($r) {
|
||||
case 'tasks':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$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', [
|
||||
'title' => '任务',
|
||||
'board' => $board,
|
||||
'total' => count($board['todo']) + count($board['doing']) + count($board['done']),
|
||||
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||
'title' => '任务',
|
||||
'board' => $board,
|
||||
'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', ''),
|
||||
], 'tasks');
|
||||
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':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
@@ -2914,10 +3414,13 @@ switch ($r) {
|
||||
}
|
||||
}
|
||||
View::render('tasks/show', [
|
||||
'title' => (string)$t['title'],
|
||||
't' => $t,
|
||||
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||
'parents' => $parents,
|
||||
'title' => (string)$t['title'],
|
||||
't' => $t,
|
||||
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||
'parents' => $parents,
|
||||
// v24 第 9、10 条:详情页的编辑表单里也能改分类与看板
|
||||
'boards' => Task::boards($familyId),
|
||||
'categories' => Task::categories($familyId),
|
||||
], 'tasks');
|
||||
break;
|
||||
|
||||
@@ -2946,6 +3449,9 @@ switch ($r) {
|
||||
'repeat_rule' => trim((string)($_POST['repeat_rule'] ?? '')),
|
||||
'parent_id' => (int)($_POST['parent_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',
|
||||
];
|
||||
if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) {
|
||||
|
||||
Reference in New Issue
Block a user