v24 第 5 轮:十条清单(天气填城市 / 输入即筛选 / 看板列=看板 / 账本盘点折线图 / 纪念日分类)

用户 2026-10-03 的十条,逐条落点:

1. 天气改为**个人设置里填城市**(每人一份):新增 app/Geo.php,内置 197 个
   地级市坐标(离线、逐条核对,生成脚本 _tooling/_fb_geo_gen.py)。填坐标仍
   作为内置表没有时的出口;老的家庭级坐标保留为兜底,升级当天天气不会凭空消失。
2. 所有"找个人"的地方**输入即筛选**:新增 assets/js/filter.js(纯前端显隐,
   不发请求、回车不刷新)+ app/PersonQuery.php 把服务端判据收敛成一份
   (名册 / 挑人 / 高级搜索共用,避免三处实现漂移)。
3. 名册列表:有昵称就不重复显示本名、行变矮、补齐手机/阴历阳历生日(带年份)/
   邮箱/工作单位;「清除」与「搜索」同款同尺寸。
4/5. 日程:撤掉左侧日历本栏改满宽,功能(显隐/颜色/导入/默认视图)全收进
   右侧 ☰;**色块高度改为按起止时长**(旧实现一律 30 分钟,三小时的会跟
   十五分钟的同高);点日历空白处即建日程,开始时间=点的那一格。
6/10. 资源:搜索提到最上;按"最近改动/最新新增"排序,默认只显示最新 10 条并
   给「显示全部」出口。
7. 任务:看板的**列=用户自定义的看板**(不再是写死的待办/进行中),
   拖动/左右键=换看板(改 board_id,不动 status)。
8. 财务:概览在上账目在下、记一笔改右侧按钮、账本设置(共享/改名/新建/删除)
   收进 ☰、默认账本名「xxx的账本」、"挂在谁名下"默认自己且用标准选人框;
   **新增盘点**(新增 ledger_entry_snapshots 只增不改的历史表,旧值不被清空)
   + 上面画金额变化折线图(app/chart.php 服务端画 SVG,零依赖)。
9. 纪念日:手动添加的与名册生日派生的**分两块**、手动的在上;右侧设置可增删
   分类(家庭级,删分类不强改已用的历史)。

本轮另外修掉/加固的四类问题(都由新增的静态或运行时守卫兜住):
- index.php 少写 2 个 use 导入(Pinyin / UserSetting)→ 名册与个人设置
  返回 200 但正文是 Fatal;顺带修了一处早已存在的 Attachment 未导入。
  → 新增 _fb_import_check.py(1f 段)。
- 一次不精确替换弄丢了 app/Calendar.php 开头的 `<?php`,整份源码被当 HTML
  吐给浏览器,而 `php -l` 报"无语法错误"(纯文本输出确实合法)。
  → 新增 _fb_php_open_check.py(1e 段)。
- 模板片段 unset 了宿主模板的变量(账本片段 unset($canWrite))→ 正文里一片
  Undefined variable 而 HTTP 仍 200。同 _datetime.php 早年 $t 的坑。
  → 新增 _fb_fragment_scope_check.py(1g 段)+ _fb_batch_e.py 的 26 页无告警巡检。
- 窄屏下页头动作区不换行 → 周视图横向溢出且按钮被挤成三种高度(用户提过三次
  "按钮要同高")→ .page-head .actions 加 flex-wrap。

验证战绩(全绿):
  e2e 773/0 · 第 5 轮专项 91/0 · 结构升级 126/0 · 几何 428/0 · 移动端 684/0 ·
  交互 97/0 · 家谱 26/0 · 按钮 60/0;三轮反向验证各 3/3 抓到,还原后复绿。
线上:schema v14、指纹一致(9df6c9fccc6796db)、snapshots 表已建、197 城、
  8 个关键文件 md5 与本地逐字节一致、app/ 403 无源码泄漏、错误日志无新增。
This commit is contained in:
2026-10-03 15:01:38 +08:00
parent dbcf0c1bcd
commit e0562b8627
31 changed files with 2891 additions and 505 deletions
+264 -64
View File
@@ -11,6 +11,7 @@ declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Account;
use App\Attachment;
use App\Auth;
use App\Birthday;
use App\Calendar;
@@ -19,6 +20,7 @@ use App\Crypto;
use App\Database;
use App\Douban;
use App\Family;
use App\Geo;
use App\Home;
use App\HomeMods;
use App\Anniversary;
@@ -32,7 +34,9 @@ use App\MovieStats;
use App\Nickname;
use App\Person;
use App\PersonGroup;
use App\PersonQuery;
use App\PersonRoster;
use App\Pinyin;
use App\Relations;
use App\Repeat;
use App\Resource;
@@ -41,6 +45,7 @@ use App\Setting;
use App\Task;
use App\Trip;
use App\Upload;
use App\UserSetting;
use App\View;
if (!Database::hasTables()) {
@@ -211,6 +216,12 @@ function render_me_settings(array $person, array $errors = []): void
// 用户 2026-10-03 第 3 条:首页上显示哪些模块、什么顺序(每人一份)
'homeOrder' => HomeMods::orderedForPicker($me),
'homeOn' => HomeMods::forPerson($me),
// 用户 2026-10-03 第 1 条:我所在的城市(决定首页那格天气)。
// 同时把"查到的是哪儿"带过去,好让用户当场确认没填错(而不是回首页才发现)。
'weatherCity' => (string)UserSetting::get($me, 'weather_city', ''),
'weatherLat' => (string)UserSetting::get($me, 'weather_lat', ''),
'weatherLon' => (string)UserSetting::get($me, 'weather_lon', ''),
'weatherAt' => Home::weatherLocation($me, $familyId),
], 'settings');
}
@@ -345,6 +356,13 @@ function day_columns(string $anchor, int $span): array
* ⚠️ 全天日程不进时间格 —— 它们没有时段,硬塞进 0 点会变成一条压在最上面的怪东西。
* 单独挂在顶上的一行(跟群晖日历一样)。
*
* 用户 2026-10-03 第 5 条:「日程色块的高度不对,应该是从开始时间到结束时间,
* 高度随着日程的时间而不同才对。」
* 旧实现把**所有**日程都写成「30 分钟高」(`$endMin = $startMin + 30`),
* 于是三小时的会议和十五分钟的站立会同高 —— 一眼看不出"哪段时间被占住了",
* 而"时间网格"的全部意义就在于把时长画出来。
* 现在:有 ends_at 就按真实时长;没填结束才算"最短可见块"(30 分钟)。
*
* @return array{all_day:array,timed:array} all_day 按列分组;timed 按列分组,含 top/height
*/
function build_time_grid(array $events, array $cols): array
@@ -372,11 +390,23 @@ function build_time_grid(array $events, array $cols): array
continue;
}
$startMin = ((int)$m[1]) * 60 + (int)$m[2];
// 没有结束时刻的按 30 分钟画一条最短的可见块(不然高度是 0,等于看不见)
$endMin = $startMin + 30;
// 结束时刻:有就按真实时长;没有就画一条 30 分钟的最短可见块
// (高度是 0 等于看不见,那比"画得不准"更糟)。
$et = (string)($e['end_time'] ?? '');
$endMin = null;
if ($et !== '' && preg_match('/^(\d{2}):(\d{2})/', $et, $m2)) {
$endMin = ((int)$m2[1]) * 60 + (int)$m2[2];
if ($endMin <= $startMin) {
// 结束时刻不在开始之后(跨天日程的第二天、或用户填反了)→ 当没有结束
$endMin = null;
}
}
$spanMin = $endMin === null ? 30 : max(15, $endMin - $startMin);
// ⚠️ 上限到 24:00:超出的部分已经在网格外面,硬画出去会把整列撑长
$spanMin = min($spanMin, 1440 - $startMin);
$timed[$ci][] = $e + [
'top' => round($startMin / 1440 * 100, 4),
'height' => round(max(30, $endMin - $startMin) / 1440 * 100, 4),
'height' => round($spanMin / 1440 * 100, 4),
];
}
return ['all_day' => $allDay, 'timed' => $timed];
@@ -596,13 +626,11 @@ switch ($r) {
$meName = Nickname::display($me, $people[$me] ?? null) ?: (string)($people[$me]['name'] ?? '');
$greeting = Home::greeting($meName, $today, count($soon), $lateN);
// 天气:经纬度在系统设置里配;没配就整块不显示(不猜城市)
$weather = Home::weather(
$familyId,
(string)Setting::get($familyId, 'weather_lat', ''),
(string)Setting::get($familyId, 'weather_lon', ''),
(string)Setting::get($familyId, 'weather_place', '')
);
// 天气:地点在**个人设置**里(v24 用户 2026-10-03 第 1 条),每人一份。
// 三级回落都在这一个调用里:自定义经纬度 → 内置城市表 → 不显示。
$weatherLoc = Home::weatherLocation($me, $familyId);
$weather = $weatherLoc === null ? null
: Home::weather($familyId, $weatherLoc['lat'], $weatherLoc['lon'], $weatherLoc['place']);
View::render('home', [
'title' => '首页',
@@ -667,27 +695,13 @@ switch ($r) {
foreach ($rows as &$row) {
$row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people);
$row['shown'] = Nickname::display($me, $row) ?: (string)$row['name'];
// 用户 2026-10-03 第 4 条:列表要显示手机号(密文解密)与生日。
// ⚠️ 解密只对**能看名册的人**做(这页本来就要登录 + 在名册里),
// 但页面上仍然只显示"前 3 后 4"以外的形态由模板决定。
$row['phone_plain'] = '';
if (!empty($row['phone_encrypted'])) {
try {
$row['phone_plain'] = Crypto::decrypt((string)$row['phone_encrypted']);
} catch (\Throwable $e) {
$row['phone_plain'] = '';
}
}
// 生日优先显示阳历(真实生日),没有就显示农历
$row['birthday_show'] = Birthday::pretty(Birthday::solarOf($row));
$row['birthday_kind'] = '阳历';
if ($row['birthday_show'] === '') {
$lun = Birthday::dateOnly((string)($row['birthday_lunar'] ?? ''));
if ($lun !== '') {
$row['birthday_show'] = $lun;
$row['birthday_kind'] = '农历';
}
}
// 用户 2026-10-03 第 3 条:列表要显示手机号(密文解密)与生日。
// 解密与生日格式化统一走 PersonQuery(见该类的类注释:
// 名册 / 挑人 / 高级搜索必须**同一套判据**,否则同一句搜索在不同页面结果不同)。
$row['phone_plain'] = PersonQuery::phonePlain($row);
[$row['birthday_show'], $row['birthday_kind']] = PersonQuery::birthdayOf($row);
// 首字母供前端"输入即筛选"用(filter.js 对 data-initials 做子序列匹配)
$row['initials'] = Pinyin::initials((string)$row['name'] . ' ' . (string)($row['my_nickname'] ?? ''));
}
unset($row);
@@ -1004,6 +1018,46 @@ switch ($r) {
redirect('settings/me', [], 'home-mods');
break;
/**
* 保存「我所在的城市」(v24 用户 2026-10-03 第 1 条)—— **每人一份**。
*
* 用户原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
*
* 三段式:
* ① 填了城市名 → 用内置表 app/Geo 查坐标(不联网,见 Geo 的类注释);
* 查不到就**明说**查不到,不猜(猜错城市比不显示天气更糟)。
* ② 内置表没有的地方 → 允许直接填经纬度(镇 / 村 / 海外)。
* ③ 都不填 → 首页不显示天气那一块(不是错误,只是没配)。
*
* ⚠️ 存的键挂在 **user_settings**(每人一份)而不是家庭 settings 表:
* 一家人可能分处两个城市(孩子在外地),天气是"我看到的那一份天空",
* 家庭级的设置一改全家都变,正好是这里要避免的。
*/
case 'settings/weather':
Auth::requireLogin();
csrf_check();
$me = (int)Auth::personId();
$city = trim((string)($_POST['city'] ?? ''));
$latIn = trim((string)($_POST['lat'] ?? ''));
$lonIn = trim((string)($_POST['lon'] ?? ''));
UserSetting::set($me, 'weather_city', mb_substr($city, 0, 30, 'UTF-8'));
// 坐标只收合法数字,非法值一律当"没填" ——
// 一个手滑的字母会让首页每次请求都白白打一次外部接口。
$okNum = static fn(string $v): string =>
($v !== '' && is_numeric($v) && (float)$v >= -180 && (float)$v <= 180) ? $v : '';
UserSetting::set($me, 'weather_lat', $okNum($latIn));
UserSetting::set($me, 'weather_lon', $okNum($lonIn));
if ($city !== '' && Geo::lookup($city) === null && ($latIn === '' || $lonIn === '')) {
flash('err', '没有「' . $city . '」这座城市的坐标。换个写法(如「山东青岛」),'
. '或者直接在下面填经纬度。');
} else {
flash('ok', '天气地点已保存。');
}
redirect('settings/me', [], 'weather');
break;
/**
* 保存模块主题色(用户第 11 条,**家庭一份**,所以只有管理员能改)。
*/
@@ -1687,10 +1741,6 @@ switch ($r) {
'title' => '设置',
'family' => Family::find((int)Auth::familyId()),
'dbVersion' => $dbVersion,
// 用户第 3 条:首页天气的坐标(三个都空 = 不显示天气)
'weatherPlace' => (string)Setting::get((int)Auth::familyId(), 'weather_place', ''),
'weatherLat' => (string)Setting::get((int)Auth::familyId(), 'weather_lat', ''),
'weatherLon' => (string)Setting::get((int)Auth::familyId(), 'weather_lon', ''),
'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0,
'roleCounts' => $acc,
'relCount' => Relations::count((int)Auth::familyId()),
@@ -1812,19 +1862,11 @@ switch ($r) {
(string)($cur['name'] ?? '家书'),
trim((string)($_POST['motto'] ?? ''))
);
// 用户第 3 条:天气坐标(家庭级)。只收合法数字,非法值一律当"没填",
// 否则一个手滑的字母会让首页天气接口每次都白请求一次。
foreach ([
'weather_place' => 'weather_place',
'weather_lat' => 'weather_lat',
'weather_lon' => 'weather_lon',
] as $formKey => $settingKey) {
$v = trim((string)($_POST[$formKey] ?? ''));
if ($settingKey !== 'weather_place') {
$v = ($v !== '' && is_numeric($v)) ? $v : '';
}
Setting::set($fid, $settingKey, mb_substr($v, 0, 60, 'UTF-8'));
}
// ⚠️ 天气坐标**不再从系统设置收**(用户 2026-10-03 第 1 条:改到个人设置填城市)。
// 这里也不再 Set 那三个键 —— 但**不删**库里已有的旧值:
// 万一有人还没去个人设置填城市,旧的家庭级坐标留着还能兜底(见下)。
// (Home::weatherLocation 只读 user_settings,旧的家庭值已经不参与;
// 留着只是为了"哪天想回滚"时有据可查,代价是三个字符串。)
flash('ok', '已保存。');
redirect('settings');
break;
@@ -2677,9 +2719,16 @@ switch ($r) {
'title' => '财务',
'books' => $books,
'cur' => $cur,
// ⚠️ `me` 必须传进去:模板里「挂在谁名下默认是自己」(用户第 8 条)
// 要用它当默认选中值。漏传的话默认值会静默变成"不选" ——
// 功能"看起来在",只是每次都要手动点一次选人框。
'me' => $me,
'entries' => $entries,
'summary' => Ledger::summary($entries),
'shares' => Ledger::shares((int)$cur['id']),
// 用户 2026-10-03 第 8 条:上面画"金额随时间怎么变",所以要历史。
'charts' => Ledger::charts((int)$cur['id']),
'timeline'=> Ledger::timeline((int)$cur['id']),
'people' => Person::roster($familyId, $me, 'member', ''),
], 'ledger');
break;
@@ -2690,6 +2739,7 @@ switch ($r) {
case 'ledger/entry_add':
case 'ledger/entry_update':
case 'ledger/entry_del':
case 'ledger/reconcile':
case 'ledger/share':
case 'ledger/unshare':
$familyId = (int)Auth::familyId();
@@ -2741,6 +2791,17 @@ switch ($r) {
} elseif ($act === 'ledger/entry_del') {
Ledger::removeEntry($lid, (int)($_POST['id'] ?? 0));
flash('ok', '账目已删除。');
} elseif ($act === 'ledger/reconcile') {
// 用户 2026-10-03 第 8 条:盘点 = 记一次新金额,**旧值不被清空**
// (落一行快照,见 Ledger::reconcile 的注释)。
$eid = (int)($_POST['id'] ?? 0);
$ok = Ledger::reconcile(
$lid, $eid,
(string)($_POST['amount'] ?? ''),
(string)($_POST['note'] ?? ''),
$me
);
flash($ok ? 'ok' : 'err', $ok ? '盘点已记下(历史保留)。' : '找不到这笔账目。');
} elseif ($act === 'ledger/share') {
$pid = (int)($_POST['person_id'] ?? 0);
if ($pid > 0 && isset(Person::map($familyId)[$pid])) {
@@ -2764,14 +2825,58 @@ switch ($r) {
case 'days':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// 用户 2026-10-03 第 9 条:手动添加的与模块派生的**分开**,手动的在上。
$sp = Anniversary::split($familyId, $me);
View::render('days/index', [
'title' => '纪念日',
'rows' => Anniversary::all($familyId, $me),
'custom' => $sp['custom'],
'derived' => $sp['derived'],
'upcoming' => Anniversary::upcoming($familyId, $me, 60),
'kinds' => Anniversary::kinds($familyId),
'kindCounts' => Anniversary::kindCounts($familyId),
'people' => Person::roster($familyId, $me, 'member', ''),
], 'days');
break;
/**
* 纪念日分类设置(v24 用户 2026-10-03 第 9 条)。
*
* 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」
*
* 分类清单是**家庭级**的(全家共用一套,不然「结婚纪念日」在两个人屏幕上
* 是两套叫法,筛选/看的时候对不上)。所以放在 settings 而不是 user_settings。
*/
case 'days/kinds':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
// 两个动作共用一个路由:加一个 / 删一个(表单里带 del 或 add)
$kinds = Anniversary::kinds($familyId);
$del = trim((string)($_POST['del'] ?? ''));
if ($del !== '') {
$kinds = array_values(array_filter($kinds, static fn($k) => (string)$k !== $del));
Anniversary::saveKinds($familyId, $kinds);
flash('ok', '分类已删除(已用它的纪念日不变,只是新建时不再出现在下拉里)。');
redirect('days', [], 'anniv-kinds');
}
$add = trim((string)($_POST['add'] ?? ''));
if ($add !== '') {
if (mb_strlen($add, 'UTF-8') > 20) {
flash('err', '分类名太长了(最多 20 个字)。');
} else {
$kinds[] = $add;
Anniversary::saveKinds($familyId, $kinds);
flash('ok', '分类已添加。');
}
redirect('days', [], 'anniv-kinds');
}
// 整份覆盖(设置面板里直接改一串)
$list = (array)($_POST['kinds'] ?? []);
Anniversary::saveKinds($familyId, $list);
flash('ok', '分类已保存。');
redirect('days', [], 'anniv-kinds');
break;
case 'days/new':
case 'days/edit':
$familyId = (int)Auth::familyId();
@@ -2785,6 +2890,7 @@ switch ($r) {
View::render('days/form', [
'title' => $id > 0 ? '编辑纪念日' : '新建纪念日',
'editing' => $editing,
'kinds' => Anniversary::kinds($familyId),
'people' => Person::roster($familyId, $me, 'member', ''),
], 'days');
break;
@@ -2820,10 +2926,15 @@ switch ($r) {
case 'calendar':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// v24 第 4 条:月 / 周 / 天三种看法。view 只认这三个,认不出就回落月视图。
$view = (string)($_GET['view'] ?? 'month');
if (!in_array($view, ['month', 'week', 'day'], true)) {
$view = 'month';
// v24 第 4 条:月 / 周 / 天三种看法。view 只认这三个,认不出就回落**默认视图**。
// ⚠️ 默认视图是**每人一份**的设置(用户第 5 条:设置里选月/周/天)。
// 但只在**没带 view 参数**时才生效 —— 手点了「周」按钮之后,
// 那个选择必须优先,否则每次翻页都被默认值拽回去。
$viewIn = trim((string)($_GET['view'] ?? ''));
if ($viewIn === '') {
$view = Calendar::defaultView($me);
} else {
$view = in_array($viewIn, ['month', 'week', 'day'], true) ? $viewIn : 'month';
}
$anchor = trim((string)($_GET['d'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $anchor)) {
@@ -2831,6 +2942,16 @@ switch ($r) {
}
$ym = substr($anchor, 0, 7);
$cals = Calendar::mine($familyId, $me);
// 用户第 4 条:哪些本子显示,改由设置里的复选框决定(以前是左侧那一栏)。
// 藏掉的本子**不参与取数**,而不是"取出来再 display:none" ——
// 前端藏的话,关掉一本之后月视图的"+N 条"仍然按它算,数字对不上。
$hiddenCals = Calendar::hiddenCalendars($me);
$visCals = [];
foreach ($cals as $c) {
if (!isset($hiddenCals[(int)$c['id']])) {
$visCals[] = $c;
}
}
if ($view === 'month') {
$gym = trim((string)($_GET['ym'] ?? ''));
@@ -2838,17 +2959,21 @@ switch ($r) {
$ym = $gym;
}
[$from, $to] = month_grid_range($ym);
$events = Calendar::month($familyId, $cals, $me, $from, $to);
$events = Calendar::month($familyId, $visCals, $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);
$events = Calendar::month($familyId, $visCals, $me, $from, $to);
$grid = build_time_grid($events, $cols);
}
// 用户第 5 条:在日历里点一下就建日程,**开始时间 = 点的那一格**。
// 点空白处只带日期/时刻过来,标题等还是空的(不能替用户瞎填)。
$newAt = trim((string)($_GET['new_at'] ?? ''));
View::render('calendar/index', [
'title' => '日程',
'ym' => $ym,
@@ -2858,10 +2983,57 @@ switch ($r) {
'weeks' => $view === 'month' ? $grid : [],
'tgrid' => $view === 'month' ? null : $grid,
'cals' => $cals,
'visCals'=> array_map('intval', array_column($visCals, 'id')),
'hiddenCals' => array_keys($hiddenCals),
'newAt' => $newAt,
'people' => Person::roster($familyId, $me, 'member', ''),
], 'calendar');
break;
/**
* 保存「日程显示设置」(v24 用户 2026-10-03 第 4、5 条)—— 每人一份。
*
* 用户原话:「在右侧,也加一个设置按钮,设置中,可以设置哪些日历本显示,
* 调整日历的颜色,导入日历等功能,都放在这个设置下。」
* 以及「在日程的设置中,可以设置默认显示的视图模式」。
*
* 这一条只存两件**个人偏好**:默认视图 + 关掉哪几本。
* 颜色/新建/共享/导入仍在各自的既有路由上(它们改的是本子本身或数据,不是偏好),
* 只是界面上统一收进了那个设置下拉里。
*/
case 'calendar/prefs':
Auth::requireLogin();
csrf_check();
$me = (int)Auth::personId();
Calendar::saveDefaultView($me, (string)($_POST['view'] ?? 'month'));
// ⚠️ 复选框的语义是"**显示**",但库里存的是"关掉的"(见 hiddenCalendars 的注释)。
// 所以这里要把「没勾的」收集起来存。
//
// ⚠️ 但**只有表单真的带了这部分字段时才动它**:
// HTML 里"一个都没勾"会让浏览器整个省掉 `show` 这个键 ——
// 与"这个请求压根没想改显隐"(比如将来某个页面只想改默认视图)
// 在服务端看起来**一模一样**。
// 靠 `show_present` 这个隐藏标记把两者分开:
// 没有它 = 不改显隐;有它 + 一个都没勾 = 用户确实想把本子全关掉。
// 不这么做的话,任何一次"只改默认视图"的提交都会静默把所有日历本藏掉。
if (!empty($_POST['show_present'])) {
$onIds = array_map('intval', (array)($_POST['show'] ?? []));
$allIds = [];
foreach (Calendar::mine((int)Auth::familyId(), $me) as $c) {
$allIds[] = (int)$c['id'];
}
$off = [];
foreach ($allIds as $cid) {
if (!in_array($cid, $onIds, true)) {
$off[] = $cid;
}
}
Calendar::saveHiddenCalendars($me, $off);
}
flash('ok', '日程显示设置已保存。');
redirect('calendar', ['view' => (string)($_POST['back_view'] ?? 'month')]);
break;
/**
* CalDAV 专用口令(v24 第 7 条后半):生成 / 重置 / 撤销。
*
@@ -3354,7 +3526,16 @@ switch ($r) {
$me = (int)Auth::personId();
$keyword = trim((string)($_GET['keyword'] ?? ''));
$ownerId = (int)($_GET['owner'] ?? 0);
$rows = Resource::all($familyId, $me, $ownerId, $keyword);
// 用户 2026-10-03 第 10 条:按"最新新增 / 最近修改"排序,默认只显示最新 10 条。
// 原话:「应该按照最新的或者近期修改使用,进行排序,比如,只显示最新新增或者
// 修改或者使用过的 10 个,要不然太多了放不下。上面反正还有搜索呢。」
$sort = ($_GET['sort'] ?? '') === 'new' ? 'new' : 'used';
$limit = \App\Resource::LIST_LIMIT;
// 搜索时不受"只显示 10 条"限制:用户是带着明确目标来找的,
// 这时候还只给 10 条会让他以为"没搜到"。看全部也用 all=1 放开。
$showAll = !empty($_GET['all']) || $keyword !== '';
$rows = Resource::all($familyId, $me, $ownerId, $keyword, $sort, $showAll ? 0 : $limit);
$totalAll = Resource::countVisible($familyId, $me, $ownerId, $keyword);
// 附件与共享一次取齐(列表里每条都要显示"几个附件""共享给谁")
$attachs = [];
$shares = [];
@@ -3378,6 +3559,10 @@ switch ($r) {
'editing' => $editing,
'keyword' => $keyword,
'ownerId' => $ownerId,
'sort' => $sort,
'limit' => $limit,
'totalAll'=> $totalAll,
'showAll' => $showAll,
'people' => Person::roster($familyId, $me, 'member', ''),
], 'resources');
break;
@@ -3519,17 +3704,23 @@ switch ($r) {
case 'tasks':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// v24 第 10 条:看板由用户自己建(家务 / 装修 / …),顶上有个切换条
$boards = Task::boards($familyId);
$bid = (int)($_GET['board'] ?? 0);
$board = Task::board($familyId, $me, $bid);
// v24(用户 2026-10-03 第 7 条):**列 = 用户自定义的看板本身**。
// 原话:「每个人自定义的看板,不是显示在现在的看板上面,而是直接代替现在的
// 待办和进行中,是看板,而不是看板的分类。」
// 所以不再有「待办 / 进行中」两列、也不再有顶上那个"切换看板"的横条 ——
// 一列就是一个看板,都在同一屏上,一眼看全。
$bc = Task::boardColumns($familyId, $me);
$cols = $bc['cols'];
$total = 0;
foreach ($cols as $list) {
$total += count($list);
}
View::render('tasks/index', [
'title' => '任务',
'board' => $board,
'boards' => $boards,
'curBoard' => $bid > 0 ? $bid : Task::ensureDefaultBoard($familyId),
'cols' => $cols,
'boards' => $bc['boards'],
'categories' => Task::categories($familyId),
'total' => count($board['todo']) + count($board['doing']),
'total' => $total,
'people' => Person::roster($familyId, $me, 'member', ''),
], 'tasks');
break;
@@ -3704,6 +3895,15 @@ switch ($r) {
redirect('tasks');
}
$before = (int)($_POST['before'] ?? 0);
// ⚠️ v24(用户 2026-10-03 第 7 条):看板的列现在是**用户自定义的看板**,
// 所以"拖到另一列" = **换看板**(改 board_id),不是改 status。
// 两种搬法用一个路由:带 board 的走看板,带 status 的走状态
// (状态那条保留给任务详情页的"标记完成"和子任务用)。
if (array_key_exists('board', $_POST)) {
Task::moveToBoard($familyId, $id, (int)$_POST['board'], $before, $me);
redirect('tasks');
break;
}
// 子任务只在 todo / done 之间切(看板里没有子任务的列,给它做定位没意义)
if (!empty($t['parent_id'])) {
$status = $status === 'done' ? 'done' : 'todo';