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
+100
View File
@@ -174,6 +174,106 @@ final class Anniversary
return $out;
}
/**
* 两块分开(v24 用户 2026-10-03 第 9 条)。
*
* 用户原话:「纪念日中,手动添加的纪念日和来自其他模块的,例如生日的纪念日,
* 要分开,手动纪念日在上面。」
*
* 所以返回 ['custom' => [...], 'derived' => [...]] 两块,各按日期排。
* ⚠️ 派生块用**这个人的出生年**来算"今年是几号"没法排序(农历要逐年换算),
* 这里统一按 month/day 排(也就是"一年里按日历顺序"),
* 与列表上显示的「X 月 X 日」完全一致 —— 不会出现"排出来的顺序和看到的不一样"。
*
* @return array{custom:array,derived:array}
*/
public static function split(int $familyId, int $viewerId): array
{
$custom = [];
foreach (self::list($familyId) as $r) {
$r['source'] = 'custom';
$r['person_name'] = '';
$custom[] = $r;
}
$derived = self::birthdays($familyId, $viewerId);
$byMD = static function (array $a, array $b): int {
return ((int)($a['month'] ?? 0) <=> (int)($b['month'] ?? 0))
?: ((int)($a['day'] ?? 0) <=> (int)($b['day'] ?? 0))
?: strcmp((string)($a['title'] ?? ''), (string)($b['title'] ?? ''));
};
usort($custom, $byMD);
usort($derived, $byMD);
return ['custom' => $custom, 'derived' => $derived];
}
// ---------------- 纪念日分类(v24 用户 2026-10-03 第 9 条)----------------
/**
* 用户自定义的分类清单(family 级,存 settings)。
*
* 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」
*
* ⚠️ 与分类**落到每条纪念日上的 kind 字段**是两件事:
* · 这里的清单 = 新建时下拉里给哪些选项(可增删);
* · kind = 这一条实际选了哪个。
* 删掉一个分类**不**会把已用的纪念日改掉(那些 kind 仍然是那个字),
* 只是新建时不再出现在下拉里 —— 强改历史数据比留着一个"没人认识的分类"更糟。
*
* @return string[] 有序、去重、非空
*/
public static function kinds(int $familyId): array
{
$raw = (string)Setting::get($familyId, 'anniv_kinds', '');
$dec = $raw !== '' ? json_decode($raw, true) : null;
if (!is_array($dec)) {
return self::PRESET_KINDS; // 没配过 → 用预置那套
}
$out = [];
foreach ($dec as $k) {
$k = trim((string)$k);
if ($k !== '' && mb_strlen($k, 'UTF-8') <= 20 && !in_array($k, $out, true)) {
$out[] = $k;
}
}
// ⚠️ 空数组是**合法**的(用户可能就是不想有分类),所以不在这里兜回预置;
// 只有"从来没配过"($raw === '')才给预置。
return $out;
}
public static function saveKinds(int $familyId, array $kinds): void
{
$out = [];
foreach ($kinds as $k) {
$k = trim((string)$k);
if ($k === '' || mb_strlen($k, 'UTF-8') > 20) {
continue;
}
if (!in_array($k, $out, true)) {
$out[] = $k;
}
if (count($out) >= 30) { // 上限:一个下拉放不下更多了
break;
}
}
Setting::set($familyId, 'anniv_kinds', json_encode($out, JSON_UNESCAPED_UNICODE));
}
/** 每个分类被用了几条(设置页要显示"这个分类有没有在用") */
public static function kindCounts(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT kind, COUNT(*) AS n FROM anniversaries
WHERE family_id = ? AND kind IS NOT NULL AND kind <> "" GROUP BY kind'
);
$st->execute([$familyId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(string)$r['kind']] = (int)$r['n'];
}
return $out;
}
// ---------------- ③ 这一年会落在哪几天(给日历用) ----------------
/**
+81
View File
@@ -144,6 +144,68 @@ final class Calendar
return ($cal['perm'] ?? '') === 'write';
}
// ---------------- 我的日程显示偏好(v24 用户 2026-10-03 第 4、5 条)----------------
/** 三种视图(用户第 4 条:设置里可选默认) */
public const VIEWS = ['month' => '月', 'week' => '周', 'day' => '天'];
/**
* 打开日程页默认看哪个视图(每人一份)。
*
* 用户原话:「在日程的设置中,可以设置默认显示的视图模式,月视图还是周视图还是日视图。」
*
* ⚠️ 只在用户**没显式指定 view** 时才用它 —— 手点了「周」「月」按钮之后,
* 那个选择必须优先,否则每次翻页都被默认值拽回去,等于按钮点不动。
*/
public static function defaultView(int $personId): string
{
$v = (string)UserSetting::get($personId, 'cal_view', '');
return isset(self::VIEWS[$v]) ? $v : 'month';
}
/** 保存默认视图(非法值当没设过,回落 month) */
public static function saveDefaultView(int $personId, string $v): void
{
UserSetting::set($personId, 'cal_view', isset(self::VIEWS[$v]) ? $v : 'month');
}
/**
* 我在日历页上**显式隐藏了**哪几本(每人一份,存本子 id 的 JSON 数组)。
*
* ⚠️ 存"关掉的"而不是"打开的":本子会不断新建(也可能别人共享给我),
* 存"打开的"会导致**新本子默认不显示** —— 用户会以为"共享了但还是看不到"。
* 存"关掉的",新本子天然是显示的,符合直觉。
*
* @return array<int,bool> 本子 id => true
*/
public static function hiddenCalendars(int $personId): array
{
$raw = (string)UserSetting::get($personId, 'cal_hidden', '');
$dec = $raw !== '' ? json_decode($raw, true) : null;
$out = [];
if (is_array($dec)) {
foreach ($dec as $id) {
$id = (int)$id;
if ($id > 0) {
$out[$id] = true;
}
}
}
return $out;
}
public static function saveHiddenCalendars(int $personId, array $ids): void
{
$out = [];
foreach ($ids as $id) {
$id = (int)$id;
if ($id > 0 && !in_array($id, $out, true)) {
$out[] = $id;
}
}
UserSetting::set($personId, 'cal_hidden', json_encode($out));
}
/**
* 按 id 取一本「我能看到的」日历(带 perm / shared)。
*
@@ -572,6 +634,9 @@ final class Calendar
$sd = substr((string)$e['starts_at'], 0, 10);
$ed = substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10);
$tm = substr((string)$e['starts_at'], 11, 5);
// 用户 2026-10-03 第 5 条:周/日视图里色块的**高度要按日程时长**画
// (从开始时间到结束时间),所以结束时刻必须带出去。
$etm = $e['ends_at'] ? substr((string)$e['ends_at'], 11, 5) : '';
$rr = (string)($e['rrule'] ?? '');
// 用户 2026-10-03 第 5/6 条:重复日程按规则展开成若干次发生;
// 不重复的走同一条路(occurrences 会返回它自己那一天)。
@@ -584,6 +649,7 @@ final class Calendar
$span = (int)floor((strtotime($ed) - strtotime($sd)) / 86400);
$first = true;
foreach (self::eachDay($day, date('Y-m-d', strtotime($day . ' +' . $span . ' day')), $from, $to) as $d2) {
$isLast = ($d2 === date('Y-m-d', strtotime($day . ' +' . $span . ' day')));
$out[] = [
'cal' => (int)$e['calendar_id'],
'color' => (string)($c['color'] ?? '#4A7C82'),
@@ -592,6 +658,8 @@ final class Calendar
'date' => $d2,
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
'time' => ($first && empty($e['all_day'])) ? $tm : '',
// 结束时刻同理:只落在**最后一天**上(那才是它真正结束的那天)
'end_time'=> ($isLast && $etm !== '' && empty($e['all_day'])) ? $etm : '',
'all_day' => !empty($e['all_day']),
'kind' => 'custom',
'ref_id' => (int)$e['id'],
@@ -619,6 +687,9 @@ final class Calendar
foreach ($st->fetchAll() as $e) {
$day = substr((string)$e['starts_at'], 0, 10);
$t = substr((string)$e['starts_at'], 11, 5);
// 用户 2026-10-03 第 5 条:周/日视图色块高度按真实时长画 ——
// 一部电影两小时,在时间网格里就该占两小时(ends_at 库里本来就有)。
$et = $e['ends_at'] ? substr((string)$e['ends_at'], 11, 5) : '';
$out[] = [
'cal' => (int)$c['id'],
'color' => (string)$c['color'],
@@ -626,6 +697,7 @@ final class Calendar
'title' => '🎬 ' . (string)$e['title'],
'date' => $day,
'time' => $t === '00:00' ? '' : $t,
'end_time'=> $t === '00:00' ? '' : ($et === '00:00' ? '' : $et),
'all_day' => $t === '00:00',
'kind' => 'movie',
'ref_id' => (int)$e['id'],
@@ -665,8 +737,11 @@ final class Calendar
$end = substr((string)($n['ends_at'] ?: $n['starts_at']), 0, 10);
// 节点跨天时只在**第一天**显示时间(后面的天是"还在进行",不是"又开始了")
$tm = substr((string)$n['starts_at'], 11, 5);
// 用户 2026-10-03 第 5 条:色块高度按时长画 —— 节点的结束时刻也要带出去
$etm = $n['ends_at'] ? substr((string)$n['ends_at'], 11, 5) : '';
$first = true;
foreach (self::eachDay($start, $end, $from, $to) as $day) {
$isLast = ($day === $end);
$out[] = [
'cal' => (int)$c['id'],
'color' => (string)$c['color'],
@@ -674,6 +749,8 @@ final class Calendar
'title' => Trip::typeIcon((string)$n['node_type']) . ' ' . (string)$n['node_title'],
'date' => $day,
'time' => $first ? ($tm === '00:00' ? '' : $tm) : '',
// 结束时刻只落在节点跨天的最后一天(与自定义日程同一口径)
'end_time'=> (!$first || $isLast) && $tm !== '00:00' && $etm !== '' ? $etm : '',
'all_day' => $first && $tm === '00:00',
'kind' => 'trip',
'ref_id' => (int)$n['node_id'],
@@ -706,6 +783,9 @@ final class Calendar
'title' => '☑ ' . (string)$t['title'],
'date' => $day,
'time' => $tm === '00:00' ? '' : $tm,
// 任务只有"截止时刻"一个点,没有时长 → 不给 end_time,
// 让时间网格按"最短可见块"画(见 build_time_grid)。
'end_time'=> '',
'all_day' => $tm === '00:00',
'kind' => 'task',
'ref_id' => (int)$t['id'],
@@ -725,6 +805,7 @@ final class Calendar
'title' => '🎂 ' . (string)$a['title'],
'date' => (string)$a['date'],
'time' => '',
'end_time'=> '',
'all_day' => true, // 全天(用户点名要求)
'kind' => 'anniv',
'ref_id' => (int)($a['id'] ?? 0),
+334
View File
@@ -0,0 +1,334 @@
<?php
/**
* 城市 → 经纬度(v24 用户 2026-10-03 第 1 条)。
*
* 用户原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
*
* 所以天气的地点从「管理员在系统设置里填经纬度」改成了「**每个人**在自己的
* 个人设置里填城市名」。于是就必须解决"城市名 → 坐标"这一步。
*
* 三级回落,**每一级都可用**:
* ① 内置表(下面 CITIES):全国地级市 + 直辖市 + 计划单列市,共 197 条。
* **不联网**、不受外部接口抖动影响 —— 绝大多数人到这里就结束了。
* ② 个人设置留了「自定义经纬度」两个框:内置表里没有的地方(镇、村、海外)
* 直接填经纬度即可,不用等我们加表。
* ③ 都没有 → 首页整块不显示天气。
*
* ⚠️ 为什么不直接让用户填经纬度就完了:普通人不知道自己在哪个坐标。
* 填城市名是"人话",坐标是我们要解决的实现细节,不该外泄成用户负担。
*
* ⚠️ 为什么不在请求时去调 Open-Meteo 的地理编码接口(那也能查中文地名):
* 实测它对中国**地级市**的命中率很差 —— 中文名「长沙」「汕头」「湛江」「荆门」
* 「泸州」全部查不到(返回的是同名村/镇,甚至第一版查「青岛」返回的
* 第一条在**辽宁**)。要靠它就得先猜拼音、再按省份过滤,一轮下来仍是不可靠的。
* 离线表是确定的:写死的东西不会哪天变成另一个省。
*
* ⚠️ 表里的坐标是**逐条核对过**的(生成脚本 _tooling/_fb_geo_gen.py:
* 用拼音查 Open-Meteo 并按省份过滤,输出每条的 admin1 供人工核对,
* 个别被同名小镇盖住的如「淮安」取公开的城区坐标)。
* 改表请重跑那个脚本,别手敲坐标。
*/
declare(strict_types=1);
namespace App;
final class Geo
{
/**
* 直辖市 / 地级市 → [纬度, 经度, 省份]
*
* @var array<string, array{0:float,1:float,2:string}>
*/
public const CITIES = [
'北京' => [39.9075, 116.3972, '北京市'],
'上海' => [31.2222, 121.4581, '上海市'],
'天津' => [39.1422, 117.1767, '天津市'],
'重庆' => [29.5603, 106.5577, '重庆市'],
'石家庄' => [38.0414, 114.4786, '河北省'],
'唐山' => [39.6438, 118.1832, '河北省'],
'保定' => [38.8729, 115.4625, '河北省'],
'廊坊' => [39.5208, 116.7147, '河北省'],
'秦皇岛' => [39.9410, 119.5894, '河北省'],
'沧州' => [38.3112, 116.8533, '河北省'],
'邯郸' => [36.6100, 114.4876, '河北省'],
'邢台' => [37.0622, 114.4927, '河北省'],
'张家口' => [40.7834, 114.8714, '河北省'],
'承德' => [40.9519, 117.9588, '河北省'],
'衡水' => [37.7391, 115.6835, '河北省'],
'太原' => [37.8694, 112.5603, '山西省'],
'大同' => [40.0936, 113.2914, '山西省'],
'临汾' => [36.0889, 111.5189, '山西省'],
'运城' => [35.0231, 110.9928, '山西省'],
'长治' => [34.8659, 110.3957, '山西省'],
'晋中' => [37.6840, 112.7547, '山西省'],
'呼和浩特' => [40.8106, 111.6522, '内蒙古自治区'],
'包头' => [40.6516, 109.8439, '内蒙古自治区'],
'鄂尔多斯' => [39.6086, 109.7816, '内蒙古自治区'],
'赤峰' => [42.2683, 118.9636, '内蒙古自治区'],
'沈阳' => [41.7922, 123.4328, '辽宁省'],
'大连' => [38.9122, 121.6022, '辽宁省'],
'鞍山' => [41.1236, 122.9900, '辽宁省'],
'抚顺' => [41.8867, 123.9436, '辽宁省'],
'本溪' => [41.2886, 123.7650, '辽宁省'],
'丹东' => [40.1292, 124.3947, '辽宁省'],
'锦州' => [41.1078, 121.1417, '辽宁省'],
'营口' => [40.6647, 122.2318, '辽宁省'],
'盘锦' => [41.1210, 122.0739, '辽宁省'],
'葫芦岛' => [40.7524, 120.8355, '辽宁省'],
'朝阳' => [40.4844, 123.5369, '辽宁省'],
'长春' => [43.8800, 125.3228, '吉林省'],
'吉林' => [43.8465, 126.5608, '吉林省'],
'四平' => [43.1614, 124.3778, '吉林省'],
'通化' => [41.7197, 125.9264, '吉林省'],
'延吉' => [42.8882, 129.5024, '吉林省'],
'哈尔滨' => [45.7500, 126.6500, '黑龙江省'],
'齐齐哈尔' => [47.3392, 123.9615, '黑龙江省'],
'大庆' => [46.5833, 125.0000, '黑龙江省'],
'牡丹江' => [44.5480, 129.6259, '黑龙江省'],
'佳木斯' => [46.7971, 130.3112, '黑龙江省'],
'南京' => [32.0617, 118.7778, '江苏省'],
'苏州' => [31.3041, 120.5954, '江苏省'],
'无锡' => [31.5689, 120.2886, '江苏省'],
'徐州' => [34.2044, 117.2839, '江苏省'],
'常州' => [31.7736, 119.9540, '江苏省'],
'南通' => [32.0303, 120.8747, '江苏省'],
'扬州' => [32.3972, 119.4358, '江苏省'],
'盐城' => [33.3575, 120.1573, '江苏省'],
'泰州' => [32.4907, 119.9081, '江苏省'],
'镇江' => [32.2109, 119.4551, '江苏省'],
'淮安' => [33.5975, 119.0211, '江苏省'],
'连云港' => [34.5984, 119.2156, '江苏省'],
'宿迁' => [33.9492, 118.2958, '江苏省'],
'杭州' => [30.2936, 120.1614, '浙江省'],
'宁波' => [29.8782, 121.5494, '浙江省'],
'温州' => [27.9994, 120.6668, '浙江省'],
'嘉兴' => [30.7522, 120.7500, '浙江省'],
'绍兴' => [30.0024, 120.5786, '浙江省'],
'台州' => [28.6627, 121.4331, '浙江省'],
'金华' => [29.1068, 119.6442, '浙江省'],
'湖州' => [30.8703, 120.0933, '浙江省'],
'衢州' => [28.9594, 118.8686, '浙江省'],
'丽水' => [28.4604, 119.9103, '浙江省'],
'舟山' => [29.9887, 122.2049, '浙江省'],
'合肥' => [31.8639, 117.2808, '安徽省'],
'芜湖' => [31.3526, 118.4295, '安徽省'],
'蚌埠' => [32.9408, 117.3608, '安徽省'],
'安庆' => [30.5136, 117.0472, '安徽省'],
'马鞍山' => [31.6858, 118.5101, '安徽省'],
'淮南' => [32.6264, 116.9969, '安徽省'],
'黄山' => [29.7114, 118.3125, '安徽省'],
'滁州' => [32.3219, 118.2978, '安徽省'],
'阜阳' => [32.9000, 115.8167, '安徽省'],
'宿州' => [33.6361, 116.9789, '安徽省'],
'六安' => [31.7356, 116.5169, '安徽省'],
'亳州' => [33.8772, 115.7703, '安徽省'],
'福州' => [26.0614, 119.3061, '福建省'],
'厦门' => [24.4798, 118.0819, '福建省'],
'泉州' => [24.9139, 118.5858, '福建省'],
'漳州' => [24.5133, 117.6556, '福建省'],
'莆田' => [25.4394, 119.0103, '福建省'],
'三明' => [26.2486, 117.6186, '福建省'],
'南平' => [26.6450, 118.1736, '福建省'],
'龙岩' => [25.0749, 117.0178, '福建省'],
'宁德' => [26.6617, 119.5228, '福建省'],
'南昌' => [28.6840, 115.8531, '江西省'],
'九江' => [29.7048, 116.0021, '江西省'],
'赣州' => [25.8466, 114.9326, '江西省'],
'景德镇' => [29.2947, 117.2079, '江西省'],
'上饶' => [28.4518, 117.9429, '江西省'],
'宜春' => [27.8333, 114.4000, '江西省'],
'吉安' => [27.1172, 114.9793, '江西省'],
'抚州' => [27.9600, 116.3333, '江西省'],
'济南' => [36.6683, 116.9972, '山东省'],
'青岛' => [36.0649, 120.3804, '山东省'],
'烟台' => [37.4765, 121.4408, '山东省'],
'威海' => [37.5091, 122.1136, '山东省'],
'潍坊' => [36.7100, 119.1019, '山东省'],
'淄博' => [36.7906, 118.0633, '山东省'],
'临沂' => [35.0631, 118.3428, '山东省'],
'济宁' => [35.4050, 116.5814, '山东省'],
'泰安' => [36.1853, 117.1200, '山东省'],
'日照' => [35.4141, 119.5291, '山东省'],
'东营' => [37.4627, 118.4917, '山东省'],
'滨州' => [37.3667, 118.0167, '山东省'],
'德州' => [37.4466, 116.3671, '山东省'],
'聊城' => [36.4506, 116.0025, '山东省'],
'菏泽' => [35.2393, 115.4736, '山东省'],
'枣庄' => [34.8647, 117.5542, '山东省'],
'郑州' => [34.7578, 113.6486, '河南省'],
'洛阳' => [34.6735, 112.4368, '河南省'],
'开封' => [34.7986, 114.3074, '河南省'],
'新乡' => [35.1903, 113.8015, '河南省'],
'安阳' => [36.0960, 114.3828, '河南省'],
'焦作' => [35.2392, 113.2391, '河南省'],
'平顶山' => [33.7309, 113.3155, '河南省'],
'许昌' => [34.0319, 113.8630, '河南省'],
'漯河' => [33.5639, 114.0427, '河南省'],
'三门峡' => [34.7808, 111.1929, '河南省'],
'南阳' => [33.0052, 112.5466, '河南省'],
'商丘' => [34.4143, 115.6561, '河南省'],
'信阳' => [32.1228, 114.0656, '河南省'],
'周口' => [33.6333, 114.6333, '河南省'],
'驻马店' => [32.9794, 114.0294, '河南省'],
'武汉' => [30.5833, 114.2667, '湖北省'],
'宜昌' => [30.7144, 111.2847, '湖北省'],
'襄阳' => [32.0422, 112.1448, '湖北省'],
'荆州' => [30.3503, 112.1903, '湖北省'],
'黄石' => [30.2471, 115.0481, '湖北省'],
'十堰' => [32.6475, 110.7781, '湖北省'],
'孝感' => [30.9269, 113.9222, '湖北省'],
'荆门' => [31.0336, 112.2047, '湖北省'],
'长沙' => [28.1987, 112.9709, '湖南省'],
'株洲' => [27.8333, 113.1500, '湖南省'],
'湘潭' => [27.8500, 112.9000, '湖南省'],
'衡阳' => [26.8895, 112.6189, '湖南省'],
'岳阳' => [29.3745, 113.0948, '湖南省'],
'常德' => [29.0321, 111.6984, '湖南省'],
'郴州' => [25.8000, 113.0333, '湖南省'],
'张家界' => [29.1294, 110.4783, '湖南省'],
'广州' => [23.1167, 113.2500, '广东省'],
'深圳' => [22.5455, 114.0683, '广东省'],
'珠海' => [22.2769, 113.5678, '广东省'],
'汕头' => [23.3549, 116.6788, '广东省'],
'佛山' => [23.0268, 113.1315, '广东省'],
'东莞' => [23.0180, 113.7487, '广东省'],
'中山' => [22.5231, 113.3791, '广东省'],
'惠州' => [23.1115, 114.4152, '广东省'],
'江门' => [22.5833, 113.0833, '广东省'],
'湛江' => [21.2339, 110.3875, '广东省'],
'肇庆' => [23.0489, 112.4609, '广东省'],
'茂名' => [21.6663, 110.9136, '广东省'],
'揭阳' => [23.5418, 116.3658, '广东省'],
'潮州' => [23.6540, 116.6226, '广东省'],
'梅州' => [24.2886, 116.1177, '广东省'],
'河源' => [23.7333, 114.6833, '广东省'],
'清远' => [23.7000, 113.0333, '广东省'],
'韶关' => [24.8000, 113.5833, '广东省'],
'阳江' => [21.8556, 111.9627, '广东省'],
'云浮' => [22.9279, 112.0381, '广东省'],
'南宁' => [22.8167, 108.3167, '广西壮族自治区'],
'桂林' => [25.2802, 110.2964, '广西壮族自治区'],
'柳州' => [24.3240, 109.4070, '广西壮族自治区'],
'北海' => [21.4835, 109.1155, '广西壮族自治区'],
'海口' => [20.0342, 110.3465, '海南省'],
'三亚' => [18.2543, 109.5095, '海南省'],
'成都' => [30.6667, 104.0667, '四川省'],
'绵阳' => [31.4678, 104.6817, '四川省'],
'德阳' => [31.1302, 104.3820, '四川省'],
'宜宾' => [28.7593, 104.6399, '四川省'],
'泸州' => [28.8903, 105.4257, '四川省'],
'南充' => [30.7951, 106.0847, '四川省'],
'乐山' => [29.5623, 103.7639, '四川省'],
'贵阳' => [26.5833, 106.7167, '贵州省'],
'遵义' => [27.6867, 106.9072, '贵州省'],
'昆明' => [25.0389, 102.7183, '云南省'],
'大理' => [25.5847, 100.2123, '云南省'],
'丽江' => [26.8688, 100.2207, '云南省'],
'曲靖' => [25.4833, 103.7833, '云南省'],
'拉萨' => [29.6500, 91.1000, '西藏自治区'],
'西安' => [34.3378, 108.7026, '陕西省'],
'宝鸡' => [34.3678, 107.2370, '陕西省'],
'咸阳' => [34.3378, 108.7026, '陕西省'],
'渭南' => [34.5035, 109.5089, '陕西省'],
'榆林' => [38.2918, 109.7375, '陕西省'],
'兰州' => [36.0570, 103.8399, '甘肃省'],
'天水' => [34.5795, 105.7424, '甘肃省'],
'西宁' => [36.6255, 101.7574, '青海省'],
'银川' => [38.4681, 106.2731, '宁夏回族自治区'],
'乌鲁木齐' => [43.8010, 87.6005, '新疆维吾尔自治区'],
'喀什' => [39.4672, 75.9868, '新疆维吾尔自治区'],
];
/**
* 找坐标。返回 ['lat' => '36.0649', 'lon' => '120.3804', 'place' => '山东省青岛'] 或 null。
*
* 接受:完整市名(青岛) / 带「市」的(青岛市) / 带省份前缀的(山东省青岛)。
* 不做模糊匹配 —— "猜错城市"比"没有城市"糟得多(会让人以为天气坏了)。
*/
public static function lookup(string $q): ?array
{
$name = self::normalize($q);
if ($name === '' || !isset(self::CITIES[$name])) {
return null;
}
[$lat, $lon, $prov] = self::CITIES[$name];
return [
'lat' => number_format($lat, 4, '.', ''),
'lon' => number_format($lon, 4, '.', ''),
'place' => self::label($prov, $name),
];
}
/**
* 归一化:去空白 → 去末尾「市」→ 去省份前缀。
*
* ⚠️ 顺序很重要:**先**去「市」再去省份前缀。
* 反过来的话「上海市」会被当成"省份=上海市 + 空市名",
* 前缀一剥就什么都不剩了 → 直辖市全都查不到(北京/上海/天津/重庆)。
*
* ⚠️ 去省份用"已知省名清单"前缀匹配,而不是"取最后两个字":
* 「内蒙古」是三个字、「新疆」是两个字,按长度切必然切错。
*/
public static function normalize(string $q): string
{
$q = trim(preg_replace('/\s+/u', '', $q));
if ($q === '') {
return '';
}
// ① 末尾的「市」去掉(表里存的是不带「市」的名字)
if (mb_substr($q, -1, 1, 'UTF-8') === '市' && mb_strlen($q, 'UTF-8') > 1) {
$q = (string)mb_substr($q, 0, mb_strlen($q, 'UTF-8') - 1, 'UTF-8');
}
// ② 「山东省青岛」/「河北石家庄」这类带省份的写法,去掉省份前缀
foreach (self::PROVINCE_WORDS as $pw) {
if ($pw !== '' && mb_strpos($q, $pw) === 0 && mb_strlen($q, 'UTF-8') > mb_strlen($pw, 'UTF-8')) {
$q = (string)mb_substr($q, mb_strlen($pw, 'UTF-8'), null, 'UTF-8');
break;
}
}
return $q;
}
/**
* 展示用的地名:省份简称 + 市名(如「山东·青岛」),直辖市只说市名。
*
* ⚠️ 判据不能写成「市名只有两个字就是直辖市」——「青岛」「大连」「厦门」
* 「深圳」都是两个字,那样会把它们漏成光秃秃的「青岛」,
* 而没人在乎「北京」是不是直辖市,大家在乎的是"我看到的天气是哪儿的"。
*/
private static function label(string $prov, string $city): string
{
$short = self::PROV_SHORT[$prov] ?? '';
if ($short === '') {
return $city; // 直辖市 / 认不出的省份:只说市名
}
return $short . '·' . $city;
}
/** 完整省份名 → 简称(直辖市映射到空串 = 不加前缀) */
private const PROV_SHORT = [
'北京市' => '', '天津市' => '', '上海市' => '', '重庆市' => '',
'河北省' => '河北', '山西省' => '山西', '辽宁省' => '辽宁', '吉林省' => '吉林',
'黑龙江省' => '黑龙江', '江苏省' => '江苏', '浙江省' => '浙江', '安徽省' => '安徽',
'福建省' => '福建', '江西省' => '江西', '山东省' => '山东', '河南省' => '河南',
'湖北省' => '湖北', '湖南省' => '湖南', '广东省' => '广东', '海南省' => '海南',
'四川省' => '四川', '贵州省' => '贵州', '云南省' => '云南', '陕西省' => '陕西',
'甘肃省' => '甘肃', '青海省' => '青海', '台湾省' => '台湾',
'内蒙古自治区' => '内蒙古', '广西壮族自治区' => '广西',
'西藏自治区' => '西藏', '宁夏回族自治区' => '宁夏',
'新疆维吾尔自治区' => '新疆',
];
/** 支持"省份前缀"写法的省名清单(长的排前面,避免「山西」被「山」抢先匹配) */
private const PROVINCE_WORDS = [
'新疆维吾尔自治区', '广西壮族自治区', '宁夏回族自治区', '内蒙古自治区',
'西藏自治区', '香港特别行政区', '澳门特别行政区',
'黑龙江省', '内蒙古', '新疆', '广西', '宁夏', '西藏',
'北京市', '天津市', '上海市', '重庆市',
'河北省', '山西省', '辽宁省', '吉林省', '江苏省', '浙江省', '安徽省',
'福建省', '江西省', '山东省', '河南省', '湖北省', '湖南省', '广东省',
'海南省', '四川省', '贵州省', '云南省', '陕西省', '甘肃省', '青海省',
'台湾省',
];
}
+60 -4
View File
@@ -77,14 +77,69 @@ final class Home
}
/**
* 今天的天气。没配 key / 请求失败 / 超时 → null(首页整块不显示)。
* 我在哪儿(v24 用户 2026-10-03 第 1 条)。
*
* 用户原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
* 所以地点从"管理员在系统设置里填经纬度"改成"**每个人**在个人设置里填城市"。
*
* 三级回落:
* ① 个人设置里直接填的经纬度(镇 / 村 / 海外,内置表没有的地方)
* ② 个人设置里填的城市名 → App\Geo 的**内置表**(197 个地级市,不联网)
* ③ 个人设置还没配 → 回落到**旧的家庭级坐标**(settings.weather_lat/lon)
* ④ 都没有 → null,首页那一块不显示
*
* ⚠️ 第 ③ 级是**迁移的善意**,不是冗余:天气原来配在系统设置里(管理员填一次),
* 改成个人设置之后,如果只认 user_settings,那么所有人升级完当天首页的
* 天气就凭空消失了 —— 用户会以为"改坏了"。留着这条回落,
* 他自己去个人设置填上城市的那一刻才真正接管(顺序上个人值优先)。
*
* ⚠️ 顺序是"个人经纬度优先于个人城市名":用户亲手填的坐标比城市名更精确,
* 也更明确地表了态 —— 既填了城市又填了坐标,当然是按坐标来。
*
* @param int $familyId 用来取旧的家庭级兜底坐标(0 = 不做兜底)
* @return array{lat:string,lon:string,place:string}|null
*/
public static function weatherLocation(int $personId, int $familyId = 0): ?array
{
$lat = trim((string)UserSetting::get($personId, 'weather_lat', ''));
$lon = trim((string)UserSetting::get($personId, 'weather_lon', ''));
$city = trim((string)UserSetting::get($personId, 'weather_city', ''));
if ($lat !== '' && $lon !== '' && is_numeric($lat) && is_numeric($lon)) {
// 手填坐标时地名就用他填的城市名(没填就只说"我所在的地方")
return ['lat' => $lat, 'lon' => $lon, 'place' => $city !== '' ? $city : '我所在的地方'];
}
if ($city !== '') {
$g = Geo::lookup($city);
if ($g !== null) {
return $g;
}
// 城市名认不出,但用户确实配了别的(见下)→ 继续往下回落,别直接返回 null
}
if ($familyId > 0) {
$flat = trim((string)Setting::get($familyId, 'weather_lat', ''));
$flon = trim((string)Setting::get($familyId, 'weather_lon', ''));
if ($flat !== '' && $flon !== '' && is_numeric($flat) && is_numeric($flon)) {
return [
'lat' => $flat,
'lon' => $flon,
'place' => trim((string)Setting::get($familyId, 'weather_place', '')),
];
}
}
return null;
}
/**
* 今天的天气。没配地点 / 请求失败 / 超时 → null(首页整块不显示)。
*
* 用 Open-Meteo:**不需要申请 key**、允许非商业使用、有中文描述。
* 之所以不选和风/心知:都要申请 key,而"配好 key 才能看见天气"这件事
* 会让用户以为功能没做(配置项藏在哪他未必找得到)。
*
* ⚠️ 只按经纬度请求,不传城市名 —— 经纬度由管理员在系统设置里填。
* 没填经纬度就当"没配天气",不猜(猜错城市比不显示更糟)。
* ⚠️ 只按经纬度请求,不传城市名 —— 城市名 → 坐标的事在 Geo 里离线做了
* (见 Geo 的类注释:在线地理编码对中国地级市实测命中率很差)。
* 这里拿到的 lat/lon 已经是确定的坐标,不再猜。
*
* @return array{temp:string,text:string,icon:string,place:string}|null
*/
@@ -98,7 +153,8 @@ final class Home
// ⚠️ 缓存存在 settings 表里,而那张表对 family_id 有**外键**:
// 传 0 会插入失败(外键不存在),所以必须带家庭 id。
// 同一个家庭共用一份天气缓存 —— 本来也就是同一个地方。
// ⚠️ 缓存键里带上坐标:v24 起地点是**每人一份**的(一家人可能分处两地),
// 只用家庭 id 做键会让两口子互相看到对方城市的天气。
$cacheKey = 'weather_' . md5($lat . ',' . $lon);
$cached = Setting::get($familyId, $cacheKey, '');
if ($cached !== '') {
+213 -2
View File
@@ -141,13 +141,37 @@ final class Ledger
$db = Database::get();
$name = trim($name);
if ($name === '') {
$name = '家庭账本';
$name = self::defaultName($ownerId);
}
$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();
}
/**
* 默认账本名:「xxx的账本」(v24 用户 2026-10-03 第 7 条)。
*
* 用户原话:「每个人的账本,应该默认的名字是 xxx 的账本,而不是家庭账本,
* 否则的话,如果我把账本共享给另外一个人,他将会看到好几个家庭账本,
* 这不好区分。」
*
* ⚠️ 用**本名**而不是「我」对他的称呼:账本名是给**所有能看到它的人**看的
* (共享出去之后别人也会看到),用我私下的称呼(如「老公」)会让别人莫名其妙。
* 取不到名字就退回「家庭账本」(别造出「的账本」这种残句)。
*/
public static function defaultName(int $ownerId): string
{
if ($ownerId > 0) {
$st = Database::get()->prepare('SELECT name FROM persons WHERE id = ?');
$st->execute([$ownerId]);
$n = trim((string)($st->fetchColumn() ?: ''));
if ($n !== '') {
return $n . '的账本';
}
}
return '家庭账本';
}
/**
* 这个家庭至少有**一本我能看到的**账本;没有就建一本并给我共享。
* @return int 账本 id
@@ -158,7 +182,9 @@ final class Ledger
if ($mine) {
return (int)$mine[0]['id'];
}
$id = self::create($familyId, $personId, '家庭账本');
// 建的时候就把名字定成「xxx的账本」(用户第 7 条),
// 而不是等下一次有人抱怨"怎么又叫家庭账本"。
$id = self::create($familyId, $personId, '');
return $id; // 建的人就是 owner,天然能写
}
@@ -311,4 +337,189 @@ final class Ledger
{
return (self::KINDS[$k][1] ?? 'asset') === 'debt';
}
// ---------------- 盘点:金额的历史(v24 用户 2026-10-03 第 8 条)----------------
/**
* 盘点一笔账目:把新金额记下来,**旧值不丢**。
*
* 用户原话:「账本要增加盘点功能,因为某一个账目可能会变,要记住每一次变化,
* 用盘点来完成。我点击盘点,让我对一个账目的金额进行重新输入,
* 但是盘点之前的账目数目不能清空,要记住,这样的话,每个账目的变化,
* 就知道了,比如我每年盘点一次,我招商银行每年的金额变化,就看到了。」
*
* 所以这是**两条记录**一起写:
* ① 账目本身的当前金额更新(列表上显示的还是最新值);
* ② 历史表落一行快照(用于画"这条账目随时间怎么变的")。
*
* ⚠️ 为什么历史不与"改账目"共用:普通的 `改` 是**修正录错了**(那笔账从来就不是
* 那个数),盘点才是"它真的变了"。两者的语义完全不同 ——
* 如果改账目也落历史,图上会出现一堆"记账手滑"造成的假波动,
* 真正的资产变化反而淹没在里面。
*
* @return bool 成功与否(账目不属于这本账、金额非法 → false)
*/
public static function reconcile(int $ledgerId, int $entryId, $amount, string $note = '', int $byId = 0): bool
{
$db = Database::get();
$st = $db->prepare('SELECT id FROM ledger_entries WHERE id = ? AND ledger_id = ?');
$st->execute([$entryId, $ledgerId]);
if ($st->fetchColumn() === false) {
return false;
}
$cents = self::toCents($amount);
$db->prepare('UPDATE ledger_entries SET amount_cents = ? WHERE id = ? AND ledger_id = ?')
->execute([$cents, $entryId, $ledgerId]);
$db->prepare(
'INSERT INTO ledger_entry_snapshots (ledger_id, entry_id, amount_cents, note, by_id)
VALUES (?,?,?,?,?)'
)->execute([
$ledgerId, $entryId, $cents,
trim($note) !== '' ? mb_substr(trim($note), 0, 120, 'UTF-8') : null,
$byId ?: null,
]);
return true;
}
/** 一条账目的盘点历史(按时间正序) */
public static function history(int $entryId): array
{
$st = Database::get()->prepare(
'SELECT s.*, p.name AS by_name
FROM ledger_entry_snapshots s
LEFT JOIN persons p ON p.id = s.by_id
WHERE s.entry_id = ? ORDER BY s.created_at, s.id'
);
$st->execute([$entryId]);
return $st->fetchAll();
}
/**
* 这本账的**汇总时间线**:每个盘点日,整本账的净资产是多少。
*
* 做法:把每个账目在每个时点上"当时的值"取出来再加总 ——
* · 某账目在 t 之前从未盘点过 → 它的值是**它现在的值**(因为一直没变过);
* · 盘点过 → 取 t 之前最后一次盘点的值。
* 这样"只盘过招行"也能画出一条合理的总资产曲线,
* 而不是"没盘过的账目在图上凭空消失"。
*
* @return array<int,array{date:string,net:int}> 至少一条(今天);空账本返回 []
*/
public static function timeline(int $ledgerId): array
{
$entries = self::entries($ledgerId);
if (!$entries) {
return [];
}
// 所有盘点过的日期(同一天多次盘点合成一个时点)
$st = Database::get()->prepare(
'SELECT DISTINCT DATE(created_at) AS d FROM ledger_entry_snapshots
WHERE ledger_id = ? ORDER BY d'
);
$st->execute([$ledgerId]);
$dates = [];
foreach ($st->fetchAll() as $r) {
$dates[] = (string)$r['d'];
}
$today = date('Y-m-d');
$dates[] = $today; // 永远带上"现在"
$dates = array_values(array_unique($dates));
sort($dates);
// 每条账目的快照:entry_id => [[date, cents], …]
$snaps = [];
foreach ($entries as $e) {
$snaps[(int)$e['id']] = [];
}
$st = Database::get()->prepare(
'SELECT entry_id, DATE(created_at) AS d, amount_cents
FROM ledger_entry_snapshots WHERE ledger_id = ?
ORDER BY created_at, id'
);
$st->execute([$ledgerId]);
foreach ($st->fetchAll() as $r) {
$eid = (int)$r['entry_id'];
if (isset($snaps[$eid])) {
$snaps[$eid][] = [(string)$r['d'], (int)$r['amount_cents']];
}
}
$out = [];
foreach ($dates as $d) {
$asset = 0;
$debt = 0;
foreach ($entries as $e) {
$eid = (int)$e['id'];
$val = (int)$e['amount_cents']; // 默认:一直没变过 = 还是现在这个值
foreach ($snaps[$eid] as [$sd, $sv]) {
if ($sd <= $d) {
$val = $sv; // 取该时点之前最后一次盘点的值
} else {
break;
}
}
if (self::isDebt((string)$e['kind'])) {
$debt += $val;
} else {
$asset += $val;
}
}
$out[] = ['date' => $d, 'net' => $asset - $debt,
'asset' => $asset, 'debt' => $debt];
}
return $out;
}
/**
* 一条账目自己的金额变化(画它那条线用)。
* 没有任何盘点记录 → 返回单点(就是它现在这个值),图上画一个点而不是空白。
*
* @return array<int,array{date:string,cents:int}>
*/
public static function entrySeries(int $entryId, int $currentCents): array
{
$rows = self::history($entryId);
$out = [];
foreach ($rows as $r) {
$out[] = ['date' => substr((string)$r['created_at'], 0, 10), 'cents' => (int)$r['amount_cents']];
}
if (!$out) {
$out[] = ['date' => date('Y-m-d'), 'cents' => $currentCents];
}
return $out;
}
/**
* 这本账里**有盘点历史**的账目(列表上方只给这几条画线)。
* 全画会变成一坨面条 —— 用户要的是"招商银行每年的金额变化"这种单条曲线。
*
* @return array<int,array{entry:array,series:array}>
*/
public static function charts(int $ledgerId): array
{
$st = Database::get()->prepare(
'SELECT entry_id, COUNT(*) AS n FROM ledger_entry_snapshots
WHERE ledger_id = ? GROUP BY entry_id HAVING n > 0 ORDER BY entry_id'
);
$st->execute([$ledgerId]);
$ids = array_map('intval', array_column($st->fetchAll(), 'entry_id'));
if (!$ids) {
return [];
}
$byId = [];
foreach (self::entries($ledgerId) as $e) {
$byId[(int)$e['id']] = $e;
}
$out = [];
foreach ($ids as $eid) {
if (!isset($byId[$eid])) {
continue;
}
$out[] = [
'entry' => $byId[$eid],
'series' => self::entrySeries($eid, (int)$byId[$eid]['amount_cents']),
];
}
return $out;
}
}
+36 -45
View File
@@ -35,29 +35,30 @@ final class Person
$args[] = $familyId;
$args[] = $kind;
if ($q !== '') {
// ⚠️ 纯字母/数字的查询词**不能**交给 SQL 的 LIKE 过滤:
// 它是"姓名首字母"(如 yyq),SQL 里根本没法把汉字转拼音,
// 一 LIKE 就把结果筛成空 —— 后面的 PHP 精筛也就没东西可筛了,
// 表现为"搜 yyq 一个人都没有"(功能像没做)。
// 所以这种查询词直接跳过 SQL 条件,全量取回来之后在 PHP 里筛
// (名册是个人小列表,几十条,全取完全没问题)。
//
// ⚠️ **也不能**用 SQL 过滤纯数字(手机号):手机号是**加密存的**,
// 库里只有密文,SQL 的 LIKE 永远匹配不到明文数字。
// 数字查询同样交给 PHP 精筛(那里会解密后再比)。
$needSql = (bool)preg_match('/[^A-Za-z0-9]/u', $q);
if ($needSql) {
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
$like = '%' . $q . '%';
$args[] = $like;
$args[] = $like;
$args[] = $like;
// ⚠️ 纯字母/数字的查询词**不能**交给 SQL 的 LIKE 过滤 —— 判据见 PersonQuery。
// 这里与 roster() 用的是**同一个** PersonQuery::needsSql /
// PersonQuery::refine,所以"找个人"在两处的结果一定一致。
if (PersonQuery::needsSql($q)) {
$sql .= PersonQuery::SQL_WHERE;
foreach (PersonQuery::likeArgs($q) as $a) {
$args[] = $a;
}
}
}
$sql .= ' ORDER BY p.id ASC';
$st = Database::get()->prepare($sql);
$st->execute($args);
return $st->fetchAll();
$rows = $st->fetchAll();
if ($q !== '') {
foreach ($rows as &$r) {
if (!array_key_exists('phone_plain', $r)) {
$r['phone_plain'] = PersonQuery::phonePlain($r);
}
}
unset($r);
$rows = PersonQuery::refine($rows, $q);
}
return $rows;
}
/**
@@ -88,20 +89,17 @@ final class Person
$args[] = $viewerId;
$args[] = $kind;
if ($q !== '') {
// ⚠️ SQL 只在**能帮忙**时才粗筛,两个反例:
// · 纯字母(yyq)—— 那是"姓名首字母",SQL 里没法把汉字转拼音,
// 一旦 LIKE 就把结果筛成空,后面的 PHP 精筛也就没东西可筛了;
// · 纯数字(手机号)—— 手机号是**加密存的**,库里只有密文,
// LIKE 永远匹配不到明文数字。
// ⚠️ SQL 只在**能帮忙**时才粗筛 —— 判据在 PersonQuery::needsSql(**唯一一处**):
// · 纯字母(yyq)是"姓名首字母",SQL 里没法把汉字转拼音,一 LIKE 就筛成空;
// · 纯数字(手机号)是**加密存的**,库里只有密文,LIKE 永远匹配不到明文。
// 这两类都交给下面的 PHP 精筛(它会解密、会算首字母)。
// 只含中文/邮箱这类"库里有明文可比"的查询词才加 SQL 条件。
$needSql = (bool)preg_match('/[^A-Za-z0-9]/u', $q);
if ($needSql) {
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
$like = '%' . $q . '%';
$args[] = $like;
$args[] = $like;
$args[] = $like;
// 这条判断以前在 roster() 和 all() 里各写了一遍,改的时候踩过"只改了一处",
// 所以抽成了 PersonQuery::needsSql。
if (PersonQuery::needsSql($q)) {
$sql .= PersonQuery::SQL_WHERE;
foreach (PersonQuery::likeArgs($q) as $a) {
$args[] = $a;
}
}
}
$sql .= ' ORDER BY r.sort_order ASC, p.id ASC';
@@ -110,23 +108,16 @@ final class Person
$rows = $st->fetchAll();
// 用户 2026-10-03 第 4 条:再加**姓名首字母**检索(y / yy / yyq 都命中「杨永全」)。
// ⚠️ 不在 SQL 里做:SQL 侧没法把汉字转拼音(线上也没有现成的函数),
// 硬塞进 SQL 只能靠一堆 LIKE OR,既慢又不可能穷尽。
// 名册是**一个人自己的**小列表(几十条),在 PHP 里过滤完全够快。
// 做法:SQL 只在能帮忙时粗筛(中文/邮箱这类),然后**一律**走这里精筛一次。
// 精筛覆盖:姓名 / 称呼 / 邮箱 / **解密后的手机号** / 姓名首字母。
// 精筛一律走 PersonQuery::refine —— 覆盖:姓名 / 称呼 / 邮箱 /
// **解密后的手机号** / 姓名首字母。见该类的类注释(判据必须与挑人页一致)。
if ($q !== '') {
$rows = array_values(array_filter($rows, static function (array $r) use ($q): bool {
// 手机号是加密的,Pinyin::matches 认 phone_plain;这里把它带上再判一次
if (!empty($r['phone_encrypted'])) {
try {
$r['phone_plain'] = Crypto::decrypt((string)$r['phone_encrypted']);
} catch (\Throwable $e) {
// 解不开就当没填(不影响其它字段的匹配)
foreach ($rows as &$r) {
if (!array_key_exists('phone_plain', $r)) {
$r['phone_plain'] = PersonQuery::phonePlain($r);
}
}
return Pinyin::matches($r, $q);
}));
unset($r);
$rows = PersonQuery::refine($rows, $q);
}
return $rows;
}
+143
View File
@@ -0,0 +1,143 @@
<?php
/**
* 「找个人」的统一查法(v24 用户 2026-10-03 第 2 条)。
*
* 用户原话:「所有人员检索的地方,输入汉字或者首字母,自动筛选,而不需要按回车。」
*
* 这里只负责**服务端那一半**:把一份人员行按查询词筛一遍。
* 前端「输入即筛选」那一半在 assets/js/filter.js(纯 DOM 显隐,不发请求)。
*
* 为什么要把这段抽出来(而不是各页各写一遍):
* · 名册(Person::roster)、家里已有的人(Person::all)、以及以后任何"找个人"的地方,
* 判据必须**完全一致** —— 否则同一句「yyq」在名册里能搜到、在挑人页搜不到,
* 用户只会觉得"搜索坏了",而不会想到是两处实现不一样。
* · 上半段那个「要不要给 SQL 加 LIKE」的判断很容易写错(本项目已经错过一次):
* 纯字母(yyq)与纯数字(手机号)**都不能**交给 SQL 的 LIKE ——
* 前者 SQL 里没法把汉字转拼音,后者手机号是密文存的,一 LIKE 结果就成空。
*
* 分工:
* · needsSql() / likeArgs() → SQL 侧的**粗筛**(只在能帮忙时才加条件)
* · refine() → PHP 侧的**精筛**(一律跑,结果才是最终答案)
* · decorate() → 把「解密后的手机号 / 生日 / 我怎么称呼他」补齐给列表用
*/
declare(strict_types=1);
namespace App;
final class PersonQuery
{
/**
* 这个查询词该不该让 SQL 帮忙粗筛?
*
* 只有**库里有明文可比**的查询词值得:中文姓名、邮箱里的字母+符号。
* ⚠️ 纯字母(yyq = 姓名首字母)与纯数字(手机号)一律交给 PHP 精筛:
* · SQL 里没有"汉字→拼音"的函数,对 yyq 做 LIKE 必然 0 行;
* · 手机号是 Crypto 加密后存的(phone_encrypted),明文数字 SQL 永远匹配不到。
* 这两类一旦加了 SQL 条件,精筛阶段就已经没有数据可筛了,
* 症状是"搜 yyq 一个人都没有" —— 看着像功能没做。
*/
public static function needsSql(string $q): bool
{
$q = trim($q);
if ($q === '') {
return false;
}
return (bool)preg_match('/[^A-Za-z0-9]/u', $q);
}
/** SQL 粗筛要绑的参数(name / email / nickname 三个 LIKE) */
public static function likeArgs(string $q): array
{
$like = '%' . trim($q) . '%';
return [$like, $like, $like];
}
/** SQL 粗筛的 WHERE 片段(只在 needsSql() 为真时调用) */
public const SQL_WHERE = ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
/**
* PHP 精筛:姓名 / 我怎么称呼他 / 库里的旧小名 / 邮箱 / 解密后的手机号 / 姓名首字母。
*
* ⚠️ 进来之前**必须**先给每行补上 phone_plain(见 phonePlain()),
* 否则手机号这一路永远匹配不上(Pinyin::matches 认的是 phone_plain)。
*/
public static function refine(array $rows, string $q): array
{
$q = trim($q);
if ($q === '') {
return $rows;
}
$out = [];
foreach ($rows as $row) {
if (Pinyin::matches($row, $q)) {
$out[] = $row;
}
}
return array_values($out);
}
/**
* 解密手机号(失败一律当"没填",绝不让一行坏数据把整页筛挂)。
* @return string 明文;没有/解不开返回 ''
*/
public static function phonePlain(array $row): string
{
$enc = (string)($row['phone_encrypted'] ?? '');
if ($enc === '') {
return '';
}
try {
return (string)Crypto::decrypt($enc);
} catch (\Throwable $ex) {
return '';
}
}
/**
* 给列表用的行补上派生字段。幂等(已有值就不覆盖)。
*
* @param int $viewerId 谁在看(决定"我怎么称呼他")
* @param int $meId 用来标"这是我"(0=不标)
*/
public static function decorate(array $rows, int $viewerId, int $meId = 0): array
{
$graph = null;
$people = null;
foreach ($rows as &$row) {
if (!array_key_exists('phone_plain', $row)) {
$row['phone_plain'] = self::phonePlain($row);
}
if (!isset($row['shown'])) {
$row['shown'] = Nickname::display($viewerId, $row) ?: (string)($row['name'] ?? '');
}
}
unset($row);
return $rows;
}
/**
* 生日展示串:优先阳历(真实生日),没有就农历。带上**年份**(用户明确要求)。
*
* @return array{0:string,1:string} [显示串, '阳历'|'农历'|'']
*/
public static function birthdayOf(array $row): array
{
$solar = (string)Birthday::solarOf($row);
$d = Birthday::dateOnly($solar);
if ($d !== '') {
return [self::ymd($d), '阳历'];
}
$lun = Birthday::dateOnly((string)($row['birthday_lunar'] ?? ''));
if ($lun !== '') {
return [self::ymd($lun), '农历'];
}
return ['', ''];
}
/** '1998-03-07' → '1998-03-07'(保持 YYYY-MM-DD,用户要求带年份) */
private static function ymd(string $d): string
{
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) ? $d : '';
}
}
+59 -3
View File
@@ -25,6 +25,12 @@ final class Resource
/** 资源的宿主类型(附件表用) */
public const ATTACH = 'resource';
/**
* 列表页默认最多显示几条(用户 2026-10-03 第 10 条:「只显示最新新增或者修改过的 10 个」)。
* 超过就只露出这一截,下面给「显示全部 / 搜索」的出口。
*/
public const LIST_LIMIT = 10;
/**
* 我能看到的资源 = 我名下的 + 别人共享给我的。
*
@@ -33,9 +39,25 @@ final class Resource
* @param int $personId 看的人(决定"我能看什么")
* @param int $ownerId 0=不限归属;非 0=只看这个人(且必须是我能看到的)
* @param string $keyword 标题 + 正文里搜
* @param string $sort 'used'(最近改动,默认)/ 'new'(最新新增)
* @param int $limit >0 时最多返回这么多条(0=不限)
*
* 用户 2026-10-03 第 10 条:「资源列表,可能会非常多,应该按照最新的或者近期修改使用,
* 进行排序,比如,只显示最新新增或者修改或者使用过的 10 个」。
*
* ⚠️ 只提供了「最近改动」(updated_at)与「最新新增」(created_at)两种排序,
* **没有**单独做一个"最近使用过" —— 系统里没有"某条资源被查看过"的记录,
* 要凭空造一个就得在每次打开列表/展开详情时写一次库:为了排个序而制造写放大,
* 代价比收益大。改成"最近改动"已经覆盖了绝大多数"想找最近动过的那条"的需求。
*/
public static function all(int $familyId, int $personId, int $ownerId = 0, string $keyword = ''): array
{
public static function all(
int $familyId,
int $personId,
int $ownerId = 0,
string $keyword = '',
string $sort = 'used',
int $limit = 0
): array {
$sql = 'SELECT r.*, p.name AS owner_name, p.avatar, p.avatar_color,
n.nickname AS owner_nick,
CASE WHEN r.owner_id = ? THEN "owner"
@@ -58,12 +80,46 @@ final class Resource
$like = '%' . $kw . '%';
array_push($bind, $like, $like);
}
$sql .= ' ORDER BY r.updated_at DESC, r.id DESC';
// ⚠️ 排序字段必须走白名单:直接拼用户传的串就是注入。
$order = $sort === 'new'
? 'r.created_at DESC, r.id DESC'
: 'r.updated_at DESC, r.id DESC';
$sql .= ' ORDER BY ' . $order;
// LIMIT 也走整数强转(PDO 绑 LIMIT 在部分驱动上会被当成字符串,反而报错)
if ($limit > 0) {
$sql .= ' LIMIT ' . (int)$limit;
}
$st = Database::get()->prepare($sql);
$st->execute($bind);
return $st->fetchAll();
}
/**
* 我能看到的资源**一共几条**(列表只显示前 N 条时要告诉用户"还有多少")。
* 与 all() 用**同一套可见性条件**,免得"显示了 10 条 / 说一共 8 条"这种自相矛盾。
*/
public static function countVisible(int $familyId, int $personId, int $ownerId = 0, string $keyword = ''): int
{
$sql = 'SELECT COUNT(*) FROM resources r
LEFT JOIN persons p ON p.id = r.owner_id
LEFT JOIN resource_shares sh ON sh.resource_id = r.id AND sh.person_id = ?
WHERE r.family_id = ? AND (r.owner_id = ? OR sh.person_id IS NOT NULL)';
$bind = [$personId, $familyId, $personId];
if ($ownerId > 0) {
$sql .= ' AND r.owner_id = ?';
$bind[] = $ownerId;
}
$kw = trim($keyword);
if ($kw !== '') {
$sql .= ' AND (r.title LIKE ? OR r.content LIKE ?)';
$like = '%' . $kw . '%';
array_push($bind, $like, $like);
}
$st = Database::get()->prepare($sql);
$st->execute($bind);
return (int)$st->fetchColumn();
}
/** 一条资源(带归属人与我对它的权限;看不到返回 null) */
public static function find(int $familyId, int $id, int $personId): ?array
{
+31 -1
View File
@@ -71,7 +71,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '13';
public const VERSION = '14';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -1035,6 +1035,36 @@ CREATE TABLE IF NOT EXISTS `ledger_shares` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账本共享'
SQL);
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
// ---- 账本盘点(版本 14,用户 2026-10-03 第 8 条)----
// 用户原话:「账本要增加盘点功能,因为某一个账目可能会变,要记住每一次变化,
// 用盘点来完成。我点击盘点,让我对一个账目的金额进行重新输入,
// 但是盘点之前的账目数目不能清空,要记住。」
//
// 所以需要一张**只增不改**的历史表:每次盘点落一行快照。
// ⚠️ 不复用 ledger_entries 的 amount_cents:账目表存的是"现在是多少",
// 这张表存的是"什么时候变成过多少"。一张表里塞两种语义,
// 迟早有人 UPDATE 了 entry 又奇怪"历史怎么也跟着变了"。
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `ledger_entry_snapshots` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ledger_id` INT UNSIGNED NOT NULL COMMENT '冗余存一份:画"整本账随时间的变化"时不用回表',
`entry_id` INT UNSIGNED NOT NULL,
`amount_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '这次盘点的金额(分)',
`note` VARCHAR(120) DEFAULT NULL COMMENT '盘点备注(可选)',
`by_id` INT UNSIGNED DEFAULT NULL COMMENT '谁盘的',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_snap_entry` (`entry_id`, `created_at`),
KEY `idx_snap_ledger` (`ledger_id`, `created_at`),
CONSTRAINT `fk_snap_ledger` FOREIGN KEY (`ledger_id`) REFERENCES `ledgers` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_snap_entry` FOREIGN KEY (`entry_id`) REFERENCES `ledger_entries` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_snap_person` FOREIGN KEY (`by_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='账目金额的历史快照(盘点记录,只增不改)'
SQL);
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
if (self::$stepErrors) {
+115
View File
@@ -233,11 +233,72 @@ final class Task
// ---------------- 读 ----------------
/**
* 我能看到的任务 = 我建的 + 分给我的 + 共享给我的。
*
* ⚠️ v24(用户 2026-10-03 第 7 条):**看板的列 = 用户自己建的看板**,
* 不再是 todo / doing 两列。原话:「任务模块跟我想的不同,每个人自定义的看板,
* 不是显示在现在的看板上面,而是**直接代替现在的待办和进行中**,是看板,
* 而不是看板的分类。」
* 所以 `status` 仍然存着 todo/doing(拖动换栏 = 改 status),
* 但**界面上的列**是 task_boards 里的那些行 —— 一列一个看板。
* 这一层只负责"把任务按看板分堆",列名/顺序由 boards() 给。
*
* 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
*
* @return array<int, array> 看板 id => 该列的任务(键 0 = 没有看板归属的兜底列)
*/
public static function boardColumns(int $familyId, int $personId): array
{
$boards = self::boards($familyId);
$defId = self::ensureDefaultBoard($familyId);
$cols = [];
foreach ($boards as $b) {
$cols[(int)$b['id']] = [];
}
// ⚠️ 兜底列(键 0):老任务的 board_id 是 NULL,或者看板被删过之后没搬到默认看板。
// 必须有个地方放它们,否则那些任务在看板上**直接消失**(比显示错更糟)。
$cols[0] = [];
$st = Database::get()->prepare(
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
na.nickname AS assignee_nick,
c.name AS category_name,
(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 attachments f WHERE f.target_type = "task" AND f.target_id = t.id) AS attach_count
FROM tasks t
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 = ?
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 (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
ORDER BY t.status, t.position, t.id'
);
$st->execute([$personId, $personId, $familyId, $personId, $personId]);
$rows = $st->fetchAll();
$children = self::childrenFor(array_map('intval', array_column($rows, 'id')), $personId);
foreach ($rows as $r) {
$r['children'] = $children[(int)$r['id']] ?? [];
$bid = (int)($r['board_id'] ?? 0);
if (!isset($cols[$bid])) {
$bid = 0; // 看板已被删 / 不认识 → 落进兜底列
}
$cols[$bid][] = $r;
}
return ['cols' => $cols, 'boards' => $boards, 'defId' => $defId];
}
/**
* 我能看到的任务 = 我建的 + 分给我的 + 共享给我的。
*
* 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。
* ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
*
* @param int $boardId 0 = 全部看板
*/
public static function board(int $familyId, int $personId, int $boardId = 0): array
{
@@ -548,6 +609,60 @@ final class Task
}
}
/**
* 看板里把一张卡搬到**另一个看板**(v24 用户 2026-10-03 第 7 条)。
*
* ⚠️ 与 moveTo() 的分工(别再混起来):
* · v24 第 7 条之前,界面上的两列是「待办 / 进行中」→ 搬卡 = 改 status(moveTo);
* · 现在界面上的一列一个**看板** → 搬卡 = 改 board_id(这个方法)。
* 两个字段都还在、都有用:board 是"属于哪摊事",status 是"做到哪一步"。
* 看板上只显示未完成的(status<>done),所以搬卡不动 status ——
* 顺手把 status 重置成 todo 反而会把用户"进行中"的标记抹掉。
*
* @param int $beforeId 放在这张卡**前面**(0 = 放到末尾)
* @param int $personId 谁在搬(用来只在他看得见的那些卡里排序)
*/
public static function moveToBoard(int $familyId, int $id, int $boardId, int $beforeId = 0, int $personId = 0): void
{
$old = self::row($familyId, $id);
if (!$old) {
return;
}
// 看板 id 归一化:0 或不认识的一律落到默认看板(不校验就等着"搬到别人家的看板")
$target = self::boardIdOf($familyId, $boardId);
$db = Database::get();
if ($target === null) {
$target = self::ensureDefaultBoard($familyId);
}
// 同列重排:把这一列的 id 按目标顺序重排成 10/20/30…
// (与 moveTo 同一套做法:中点法在两卡相邻时会挤不开,整列重排永远不会排错)
// ⚠️ 排序范围按"这个人能看到的卡"算 —— 否则把别人私有的任务也算进来,
// position 会被那些看不见的卡占掉(表现为"拖了跟没拖一样")。
$st = $db->prepare(
'SELECT t.id FROM tasks t
WHERE t.family_id = ? AND t.status <> "done" AND t.parent_id IS NULL AND t.id <> ?
AND COALESCE(t.board_id, 0) = ?
AND (t.creator_id = ? OR t.assignee_id = ? OR EXISTS (
SELECT 1 FROM task_shares sh WHERE sh.task_id = t.id AND sh.person_id = ?))
ORDER BY t.position, t.id'
);
$st->execute([$familyId, $id, $target, $personId, $personId, $personId]);
$ids = array_map('intval', array_column($st->fetchAll(), 'id'));
$idx = count($ids);
if ($beforeId > 0) {
$k = array_search($beforeId, $ids, true);
if ($k !== false) {
$idx = (int)$k;
}
}
array_splice($ids, $idx, 0, [$id]);
$up = $db->prepare('UPDATE tasks SET board_id = ?, position = ? WHERE id = ? AND family_id = ?');
foreach ($ids as $i => $tid) {
$up->execute([$target, ($i + 1) * 10, $tid, $familyId]);
}
}
public static function destroy(int $familyId, int $id): void
{
// 子任务随父任务级联(外键 ON DELETE CASCADE 已保证),
+4
View File
@@ -109,3 +109,7 @@ if (session_status() !== PHP_SESSION_ACTIVE) {
}
require FB_APP . '/helpers.php';
// 全局函数库(不是类,不吃自动加载):折线图等纯渲染工具。
// ⚠️ 与 helpers.php 一样在**每个请求**都加载,所以里面只能放函数定义,
// 不要放查询/副作用(那会让每个请求都多跑一次)。
require FB_APP . '/chart.php';
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* 折线图(v24 用户 2026-10-03 第 8 条)—— **服务端画 SVG**,零依赖。
*
* 用户原话:「把这种变化,每个账目的金额变化,还有总金额的变化,在账本列表上面,
* 画出一个图,折线图这样的,这样就能清晰的看到账户的金额变化。」
*
* 为什么不用 Chart.js / ECharts:
* · 这个项目是**零依赖**的(没有 Composer,也没有前端构建)——
* 为两张小图引一个几百 KB 的图表库、再配一个 CDN(线上未必连得上),
* 代价和风险都不成比例;
* · 折线图本来就是一个 polyline,SVG 手写十几行就够;
* · 服务端画 = 没有 JS 也能看见(打印、禁用 JS 的场景都在)。
*
* 用法:
* $ckSeries = [['date'=>'2025-01-01','cents'=>123456], …];
* $ckLabel = '招商银行';
* $ckKind = 'asset'; // 'asset' 红 / 'debt' 绿(**A 股口径:涨红跌绿**)
* $ckWidth = 260;
* echo chart_line($ckSeries, $ckLabel, $ckKind, $ckWidth);
*
* ⚠️ 颜色按**中式财务习惯**:金额变多 = 红,变少 = 绿(与 A 股一致)。
* 这是项目里已经定下的口径(见 Ledger 的 led-asset/led-debt 配色),
* 改成欧美口径(涨绿跌红)会让这个页面和账目表对不上。
*/
/**
* 把一串「日期 → 分值」画成一条折线。
*
* @param array $series ['date'=>'Y-m-d','cents'=>int] 列表,按时间正序;空数组返回 ''
* @param string $label 图例上的名字
* @param string $kind 'asset' | 'debt'(只影响配色)
* @param int $width 画布宽(高度按 3:1 推)
* @param bool $showVal 是否在两端标出金额
* @return string SVG(可直接 echo)
*/
function chart_line(array $series, string $label, string $kind = 'asset', int $width = 260, bool $showVal = true): string
{
$pts = [];
foreach ($series as $p) {
$pts[] = ['d' => (string)($p['date'] ?? ''), 'v' => (int)($p['cents'] ?? 0)];
}
if (!$pts) {
return '';
}
$w = max(120, $width);
$h = (int)round($w / 3.2);
// 内边距:左边留给"最高/最低"标签,下边留给日期,上边留给数值标
$padL = 6; $padR = 6; $padT = 14; $padB = 16;
$iw = $w - $padL - $padR;
$ih = $h - $padT - $padB;
$vals = array_column($pts, 'v');
$min = min($vals);
$max = max($vals);
// ⚠️ 只有一个点、或所有值相同 → max==min,除零。
// 此时把线画在**中间**(比贴底好:贴底看起来像"跌到 0 了")。
$span = $max - $min;
$flat = $span <= 0;
if ($flat) {
$span = 1;
}
$n = count($pts);
$xy = [];
foreach ($pts as $i => $p) {
$x = $n === 1 ? ($padL + $iw / 2) : ($padL + $iw * $i / ($n - 1));
$ratio = $flat ? 0.5 : (($p['v'] - $min) / $span);
$y = $padT + $ih * (1 - $ratio);
$xy[] = [round($x, 2), round($y, 2)];
}
$isDebt = $kind === 'debt';
// 中式财务口径:资产涨=红、负债涨=绿(负债变多是"好"的方向相反,同样用绿表示"更多")
$stroke = $isDebt ? '#1F7A4D' : '#B0342C';
$fill = $isDebt ? 'rgba(31,122,77,.10)' : 'rgba(176,52,44,.10)';
$poly = [];
foreach ($xy as $p) {
$poly[] = $p[0] . ',' . $p[1];
}
$polyStr = implode(' ', $poly);
// 面积:从线走一遍再回到基线 —— 让"变化"比一条细线更醒目
$areaStr = '';
if ($n > 1) {
$base = $padT + $ih;
$areaStr = $padL . ',' . $base . ' ' . $polyStr . ' '
. ($padL + $iw) . ',' . $base;
}
$out = '<svg class="ck-svg" viewBox="0 0 ' . $w . ' ' . $h . '" width="100%" height="' . $h . '"'
. ' role="img" aria-label="' . e($label) . ' 金额变化"'
. ' xmlns="http://www.w3.org/2000/svg">';
// 基线(0 或最低值那一条)—— 让图有个水平参照
$out .= '<line x1="' . $padL . '" y1="' . ($padT + $ih) . '" x2="' . ($padL + $iw) . '" y2="' . ($padT + $ih)
. '" stroke="currentColor" stroke-opacity=".18" stroke-width="1"/>';
if ($areaStr !== '') {
$out .= '<polygon points="' . $areaStr . '" fill="' . $fill . '" stroke="none"/>';
}
if ($n === 1) {
$out .= '<circle cx="' . $xy[0][0] . '" cy="' . $xy[0][1] . '" r="3" fill="' . $stroke . '"/>';
} else {
$out .= '<polyline points="' . $polyStr . '" fill="none" stroke="' . $stroke . '"'
. ' stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
// 每个盘点点一个小圆点:有几处转折要看得出来
foreach ($xy as $p) {
$out .= '<circle cx="' . $p[0] . '" cy="' . $p[1] . '" r="2.4" fill="#fff" stroke="' . $stroke . '" stroke-width="1.6"/>';
}
}
if ($showVal && $n > 0) {
// 首尾各标一个数值:光看形状不知道"多少"
$first = $pts[0];
$last = $pts[$n - 1];
$fy = max(9, $xy[0][1] - 6);
$ly = max(9, $xy[$n - 1][1] - 6);
$out .= '<text x="' . $padL . '" y="' . $fy . '" font-size="9" fill="currentColor" fill-opacity=".65">'
. e(\App\Ledger::money($first['v'])) . '</text>';
if ($n > 1) {
$out .= '<text x="' . ($padL + $iw) . '" y="' . $ly . '" font-size="9" text-anchor="end"'
. ' fill="' . $stroke . '">' . e(\App\Ledger::money($last['v'])) . '</text>';
}
}
// 日期:首尾各一个(中间的点太密,标了反而糊)
$out .= '<text x="' . $padL . '" y="' . ($h - 4) . '" font-size="9" fill="currentColor" fill-opacity=".55">'
. e((string)$pts[0]['d']) . '</text>';
if ($n > 1) {
$out .= '<text x="' . ($padL + $iw) . '" y="' . ($h - 4) . '" font-size="9" text-anchor="end"'
. ' fill="currentColor" fill-opacity=".55">' . e((string)$pts[$n - 1]['d']) . '</text>';
}
$out .= '</svg>';
return $out;
}
+133 -2
View File
@@ -149,6 +149,41 @@ a:hover { text-decoration: underline; }
.movie-set .menu-list a:hover .mi,
.movie-set .menu-list a.on .mi { color: currentColor; }
.movie-set .menu-list a.on { color: var(--brand); background: var(--line-2); }
/* ---- 日程页的设置下拉(v24 用户 2026-10-03 第 4、5 条)----
它比观影/任务那个 ☰ 内容多:里面有个**表单**(默认视图 + 显示哪些本子)。
本子清单会随共享/新建变长,所以给列表一个最大高度 + 滚动,
否则菜单会顶出屏幕、底下的「保存」按钮够不着。 */
.cal-set .menu-list { min-width: 260px; max-width: min(320px, 92vw); }
.cal-set-list { padding: 10px; gap: 0; }
.cal-set-form { display: block; }
.cal-set-block { padding: 6px 2px; }
.cal-set-block + .cal-set-block { border-top: 1px solid var(--line-2); margin-top: 4px; }
.cal-set-h { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.cal-set-views { display: flex; gap: 12px; flex-wrap: wrap; }
.cal-set-view { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; cursor: pointer; }
.cal-set-view input { accent-color: var(--brand); }
/* 本子清单:最多占 42vh,超了内部滚动 —— 菜单本身不滚,否则保存按钮会被推出可视区 */
.cal-set-books { max-height: 42vh; overflow-y: auto; }
.cal-set-book {
display: flex; align-items: center; gap: 7px; padding: 5px 4px;
font-size: 13px; cursor: pointer; border-radius: 6px; white-space: normal;
}
.cal-set-book:hover { background: var(--line-2); }
.cal-set-book input { accent-color: var(--brand); flex: 0 0 auto; }
.cal-set-book .color-preview { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
.cal-set-book .cal-name { overflow: hidden; text-overflow: ellipsis; }
.cal-set-book .node-badge { margin-left: auto; flex: 0 0 auto; }
.cal-set-foot { margin-top: 10px; }
.cal-set-foot .btn { width: 100%; justify-content: center; }
.cal-set-links { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line-2); }
.cal-set-links a { display: flex; align-items: center; gap: 10px; white-space: normal; }
.cal-set-links a .mi { flex: 0 0 16px; color: var(--muted); }
.cal-set-links a:hover .mi { color: currentColor; }
/* 点了日历空白处就建日程 —— 鼠标要能看出那里可点(用户第 5 条)。
⚠️ 只给「能写」的人光标:只读账号点上去弹不出表单,给个十字光标是骗人。 */
.tg-col.rt-writable, .cal-cell.rt-writable { cursor: crosshair; }
.link-quiet { color: var(--muted); font-size: 14px; }
.link-quiet:hover { color: var(--brand); }
@@ -308,6 +343,17 @@ a:hover { text-decoration: underline; }
background: var(--line-2);
}
/* ⚠️⚠️ 过滤用的行必须能真的藏起来。
`[hidden]` 的 display:none 是**浏览器默认样式**,优先级最低 —— 只要作者样式里
给同一个元素写了 display(.person 是 display:block、.pp-item 是 flex、
.att-item 是 inline-flex…),就会把它盖掉:元素"标注了 hidden 却照样显示",
于是「输入即筛选」看着完全没反应(列表一条都不少)。
这个坑本项目已经踩过两次(.att-item、.pp-item),所以统一写成一条通则:
凡是靠 [hidden] 隐藏的列表行,都必须在作者样式里显式再声明一次。 */
[data-search][hidden],
.pick-row[hidden],
.person[hidden] { display: none !important; }
.badge {
display: inline-block; padding: 1px 8px; border-radius: 999px;
font-size: 11px; font-weight: 500; line-height: 1.7;
@@ -734,6 +780,14 @@ textarea.input { min-height: 76px; resize: vertical; }
.hero-stats { margin-left: 0; gap: 20px; }
.page-head { flex-wrap: wrap; align-items: center; }
.page-head .actions { margin-left: 0; width: 100%; }
/* ⚠️ 窄屏下动作区**必须允许换行**:
一行里可能有 6 个按钮(月/周/天 + 上一周/今天/下一周 + 新建 + ☰),
360px 根本放不下。不换行的后果不只是横向溢出(整页被拖走),
还会把按钮**挤扁**成高低不一(实测 91/68/44 三种高度,同排按钮参差不齐)——
而"页头按钮同高"是用户提过三次的明确要求。
换行之后每个按钮保持自己的尺寸,高度自然一致。 */
.page-head .actions { flex-wrap: wrap; }
.page-head .actions > .movie-set { margin-left: 0; }
.kv-row { grid-template-columns: 84px 1fr; }
.search { flex-wrap: wrap; }
.search .input { max-width: none; flex: 1 1 auto; }
@@ -1349,6 +1403,47 @@ textarea.input { min-height: 76px; resize: vertical; }
.led-ops .input { min-width: 140px; }
.led-edit summary { cursor: pointer; }
.led-edit .node-form { min-width: min(520px, 86vw); }
/* ---- 财务:变化折线图 + 设置下拉(v24 用户 2026-10-03 第 7、8 条)---- */
.led-charts { margin-bottom: 0; }
.ck-grid {
display: grid; gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.ck-card {
border: 1px solid var(--line-2); border-radius: var(--radius-s);
background: #FDFBF7; padding: 10px 12px;
color: var(--ink); /* SVG 用 currentColor 画基线与日期,靠这里继承 */
}
.ck-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.ck-head b { font-size: 14px; }
.ck-svg { display: block; margin-top: 2px; }
/* 盘点面板:与「更正」并列在一个 <details> 里,中间给一条分隔线才分得清两件事 */
.led-recon { padding-bottom: 10px; border-bottom: 1px dashed var(--line-2); margin-bottom: 10px; }
.led-form-h { font-size: 12.5px; color: var(--muted); font-weight: 500; margin: 0 0 8px; }
.led-hist { margin-top: 6px; }
.led-hist summary { cursor: pointer; }
.led-hist-list { list-style: none; margin: 6px 0 0; padding: 0; font-size: 13px; }
.led-hist-list li {
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
padding: 5px 0; border-top: 1px solid var(--line-2);
}
.led-hist-list li b { font-variant-numeric: tabular-nums; }
/* 账本设置下拉:比观影那个内容多,给个上限 + 内部滚动 */
.led-set .menu-list { min-width: 280px; max-width: min(340px, 94vw); }
.led-set-list { padding: 10px; gap: 0; max-height: 70vh; overflow-y: auto; }
.led-set-block { padding: 8px 2px; }
.led-set-block + .led-set-block { border-top: 1px solid var(--line-2); margin-top: 4px; }
.led-set-shares { list-style: none; margin: 0 0 8px; padding: 0; }
.led-set-shares li {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 4px 0;
}
.led-set-shares li .node-badge { flex: 0 0 auto; }
.led-set-form-in { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.led-set-form-in .input { flex: 1 1 120px; min-width: 0; }
.led-set-form-in .pp { flex: 1 1 100%; }
.led-add { margin-bottom: 18px; }
/* ---- 纪念日(v24 第 13 条) ---- */
.ann-up { list-style: none; margin: 0; padding: 0; }
.ann-up-item {
@@ -1876,6 +1971,10 @@ textarea.input { min-height: 76px; resize: vertical; }
/* ---- 日程(月视图,群晖 Calendar 的外观) ---- */
.cal-wrap { display: grid; grid-template-columns: 230px 1fr; gap: 18px; align-items: start; }
/* 用户 2026-10-03 第 4 条:左侧「我的日历本」那一栏撤了,日历占满整宽。
⚠️ 用**单列**改写而不是删掉 .cal-wrap:`.cal-wrap` 还被别处(窄屏媒体查询)
引用着,删掉会连带把那条声明变空。单列 = 视觉上就是"充满整个宽度"。 */
.cal-wrap-full { grid-template-columns: 1fr; }
.cal-side { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; }
.cal-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.cal-item { display: flex; flex-direction: column; gap: 4px; }
@@ -1939,9 +2038,29 @@ textarea.input { min-height: 76px; resize: vertical; }
.res-label { font-size: 12px; color: var(--muted); }
.res-ops { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.res-share-add > summary { cursor: pointer; }
/* ---- 资源列表:搜索在上(用户 2026-10-03 第 6 条)、排序与截断提示(第 10 条)---- */
.res-search-card { display: flex; flex-direction: column; gap: 10px; }
.res-search { margin-bottom: 0; }
/* 搜索框给宽一点:资源标题长(「不动产证 / 学区房合同」),窄了看不全 */
.res-search .input { max-width: none; flex: 1 1 auto; min-width: 0; }
.res-sortbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.res-sortbar .seg { margin: 0; }
.res-showall { margin-left: auto; }
.res-more {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line-2);
}
/* ---- 任务看板 ---- */
.kanban { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; align-items: start; }
/* ---- 任务看板 ----
⚠️ 列数不再固定为 3:v24(用户 2026-10-03 第 7 条)起**一列 = 用户自己的一个看板**,
看板能加能删,写死 repeat(3, 1fr) 的话加第四个看板就直接掉到第二行去
(看起来像"加了个看板结果没显示")。
改成"横向自动排 + 可横向滚动":几列都放得下,列多了也不挤。 */
.kanban {
display: grid; grid-auto-flow: column;
grid-auto-columns: minmax(230px, 1fr);
gap: 14px; align-items: start; overflow-x: auto; padding-bottom: 2px;
}
.kb-col { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.kb-head {
display: flex; align-items: center; gap: 8px; padding: 12px 16px;
@@ -2012,6 +2131,18 @@ textarea.input { min-height: 76px; resize: vertical; }
.mod-preset { display: flex; flex-wrap: wrap; gap: 4px; }
.mod-preset .preset-color { cursor: pointer; }
/* ---- 纪念日设置下拉(v24 用户 2026-10-03 第 9 条)---- */
.ann-set .menu-list { min-width: 260px; max-width: min(320px, 92vw); }
.ann-set-list { padding: 10px; gap: 0; }
.ann-kind-list { list-style: none; margin: 0; padding: 0; max-height: 46vh; overflow-y: auto; }
.ann-kind-list li {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 5px 2px; border-bottom: 1px solid var(--line-2);
}
.ann-kind-list li:last-child { border-bottom: 0; }
.ann-kind-name { font-size: 13.5px; font-weight: 500; }
.ann-kind-list li .small { margin-left: auto; }
/* ---- 首页 dashboard ---- */
.hero-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.dash-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
+151
View File
@@ -0,0 +1,151 @@
/**
* 输入即筛选(v24 用户 2026-10-03 第 2 条)
*
* 用户原话:「所有人员检索的地方,输入汉字或者首字母,自动筛选,而不需要按回车。」
*
* 这个模块只做**前端显隐**:数据已经全在页面上了,输入就立刻筛,不发请求。
* 为什么不做成"输入 → AJAX 重查":
* · 名册 / 挑人 / 选人弹窗都是几十条的小列表,整份渲染出来的成本可以忽略;
* · 一发请求就要处理"响应回来时输入框已经变了""光标跳到末尾"这些竞态,
* 而这个功能的价值恰恰是"打字的时候列表在动" —— 有一帧延迟就不对了;
* · 没 JS 时页面上的「搜索」按钮/form 提交照旧能用(渐进增强,不是二选一)。
*
* 三种用法(都是声明式,靠 data-* 挂上去,不用改 JS):
*
* ① 列表行:<input data-filter="#roster"> … 行上带 data-search="姓名 称呼 邮箱 首字母"
* 行被筛掉 = hidden 属性(⚠️ CSS 里必须给 [hidden] 写 display:none,
* 作者样式里的 display:flex 会盖过浏览器默认的 [hidden] —— 项目里踩过两次)
*
* ② 下拉选项:<input data-filter-select="#foo"> → 把不匹配的 <option> 藏起来。
* 应用在"共享给谁 / 负责人 / 纪要人"这类原生下拉上(名册一长就难找)。
*
* ③ 计数与空态:容器上带 data-filter-count="#id"(显示"命中 N 条")、
* data-filter-empty="#id"(一条都没命中时露出来的提示)。
*
* 拼音首字母:服务端已经算好放进 data-initials(见 App\Pinyin::initials)。
* 这里对 data-search 做"包含",对 data-initials 做**子序列**(yyq / yy / y 都能中)。
*/
(function () {
'use strict';
/** q 是否是 s 的子序列(与 person_picker.js 同一判据) */
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;
}
/** 一个元素能不能被 q 命中 */
function hit(el, q) {
if (!q) { return true; }
var hay = (el.getAttribute('data-search') || '').toLowerCase();
if (hay.indexOf(q) >= 0) { return true; }
var ini = (el.getAttribute('data-initials') || '').toLowerCase();
return ini ? isSubSeq(q, ini) : false;
}
function bindList(input) {
var sel = input.getAttribute('data-filter');
var box = sel ? document.querySelector(sel) : null;
if (!box) { return; }
// 行本身可能不是"直接子元素",统一找带 data-search 的
var items = box.querySelectorAll('[data-search]');
var countEl = input.getAttribute('data-filter-count')
? document.querySelector(input.getAttribute('data-filter-count')) : null;
var emptyEl = input.getAttribute('data-filter-empty')
? document.querySelector(input.getAttribute('data-filter-empty')) : null;
function apply() {
var q = (input.value || '').trim().toLowerCase();
var shown = 0;
Array.prototype.forEach.call(items, function (el) {
var ok = hit(el, q);
el.hidden = !ok;
if (ok) { shown++; }
});
if (countEl) { countEl.textContent = q === '' ? '' : ('命中 ' + shown + ' 条'); }
if (emptyEl) { emptyEl.hidden = !(q !== '' && shown === 0); }
// 分组栏里一个人都不剩时,整栏也收起来(否则留一排空标题)
Array.prototype.forEach.call(box.querySelectorAll('[data-filter-group]'), function (g) {
var n = 0;
Array.prototype.forEach.call(g.querySelectorAll('[data-search]'), function (el) {
if (!el.hidden) { n++; }
});
g.hidden = n === 0;
});
}
input.addEventListener('input', apply);
// ⚠️ 回车**不要**提交表单:一提交页面就整页刷新、输入框失焦,
// 而列表已经实时筛过了 —— 用户按回车只会看到"闪一下、什么都没变"。
var form = input.form;
if (form) {
form.addEventListener('submit', function (e) {
// 没有 JS 时的兜底是 form 提交;有 JS 时拦下来,避免无意义的整页刷新。
if (input.getAttribute('data-filter-submit') !== '1') {
e.preventDefault();
apply();
}
});
}
apply();
// 清除按钮:与「搜索」同形同尺寸(用户第 3 条),点了 = 清空 + 立刻重筛
var clearSel = input.getAttribute('data-filter-clear');
if (clearSel) {
Array.prototype.forEach.call(document.querySelectorAll(clearSel), function (btn) {
btn.addEventListener('click', function () {
input.value = '';
apply();
input.focus();
});
});
}
return apply;
}
function bindSelect(input) {
var box = document.querySelector(input.getAttribute('data-filter-select'));
if (!box) { return; }
var opts = Array.prototype.slice.call(box.options);
// 每个 option 首次筛之前先备份(中文名 + 服务端给的 data-initials)
Array.prototype.forEach.call(opts, function (o) {
if (o.getAttribute('data-initials') === null) {
o.setAttribute('data-initials', (o.getAttribute('data-initials-raw') || '').toLowerCase());
}
if (o.getAttribute('data-search') === null) {
o.setAttribute('data-search', (o.textContent || '').trim().toLowerCase());
}
});
input.addEventListener('input', function () {
var q = (input.value || '').trim().toLowerCase();
Array.prototype.forEach.call(opts, function (o) {
if (o.value === '' || o.value === '0') { o.hidden = false; return; } // 留「全部/不指定」
var hay = o.getAttribute('data-search') || '';
var ini = o.getAttribute('data-initials') || '';
o.hidden = !(q === '' || hay.indexOf(q) >= 0 || (ini && isSubSeq(q, ini)));
});
// 当前选中的那项被筛掉了就把选择收回到「全部/不指定」,免得提交一个看不见的值
if (box.selectedOptions.length && box.selectedOptions[0].hidden) {
box.selectedIndex = 0;
}
});
}
function boot() {
// 给页面一个"我到了"的标记:有些页面(名册的「清除」按钮)要据此决定
// 是走"就地清空重筛"还是回落到跳转。没这个标记它们只能靠猜。
window.__fbFilterLoaded = true;
Array.prototype.forEach.call(document.querySelectorAll('[data-filter]'), bindList);
Array.prototype.forEach.call(document.querySelectorAll('[data-filter-select]'), bindSelect);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();
+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';
+29 -29
View File
@@ -31,50 +31,50 @@ $attDelUrl = $attDelUrl ?? '';
?>
<?php if ($attRows): ?>
<div class="att-wall">
<?php foreach ($attRows as $a): ?>
<?php foreach ($attRows as $attRow): ?>
<?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);
$attKind = \App\Attachment::kind($attRow);
$canPrev = \App\Attachment::previewable($attRow);
$attView = media_url((string)$attRow['key']);
$attDl = media_url((string)$attRow['key'], ['dl' => 1]);
$attNm = (string)($attRow['name'] ?? '');
$attSz = \App\Attachment::sizeWord($attRow['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'): ?>
<a class="att-thumb att-kind-<?= e($attKind) ?>"
href="<?= e($attView) ?>"
data-prev="<?= e($attKind === 'pdf' ? 'pdf' : ($attKind === 'video' ? 'video' : 'image')) ?>"
data-src="<?= e($attView) ?>"
data-dl="<?= e($attDl) ?>"
data-name="<?= e($attNm !== '' ? $attNm : \App\Attachment::kindWord($attKind)) ?>"
data-size="<?= e($attSz) ?>"
title="<?= e($attNm) ?>">
<?php if ($attKind === 'image'): ?>
<img src="<?= e($attView) ?>" alt="<?= e($attNm) ?>" loading="lazy">
<?php elseif ($attKind === 'video'): ?>
<?php /* #t=0.1 让浏览器把第 0.1 秒那一帧当封面显示出来 ——
没有它视频位就是一块黑板,看不出这是段什么视频。 */ ?>
<video src="<?= e($view) ?>#t=0.1" preload="metadata" muted playsinline></video>
<video src="<?= e($attView) ?>#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 class="att-thumb att-kind-<?= e($attKind) ?>" href="<?= e($attDl) ?>"
title="<?= e($attNm) ?>(点击下载)">
<span class="att-doc att-doc-<?= e($attKind) ?>"><?= e(\App\Attachment::kindWord($attKind)) ?></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>
<span class="att-fname" title="<?= e($attNm) ?>"><?= e($attNm !== '' ? $attNm : \App\Attachment::kindWord($attKind)) ?></span>
<span class="att-fsize"><?= e($attSz) ?></span>
<a class="link-quiet att-dl" href="<?= e($attDl) ?>">下载</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'] ?>">
<input type="hidden" name="<?= e($attDelField) ?>" value="<?= (int)$attRow['id'] ?>">
<?php foreach ($attExtra as $ek => $ev): ?>
<input type="hidden" name="<?= e((string)$ek) ?>" value="<?= e((string)$ev) ?>">
<?php endforeach; ?>
@@ -85,8 +85,8 @@ $attDelUrl = $attDelUrl ?? '';
</div>
</div>
<?php endforeach; ?>
<?php /* ⚠️ 循环里用到的临时变量一律用完即清:宿主模板里很可能也有 $a / $kind /
$view / $dl / $nm / $sz(各模块页面都爱用这种短名),留着就是静默覆盖。 */ ?>
<?php unset($a, $kind, $canPrev, $view, $dl, $nm, $sz); ?>
<?php /* ⚠️ 循环里用到的临时变量一律用完即清:宿主模板里很可能也有 $attRow / $attKind /
$attView / $attDl / $attNm / $attSz(各模块页面都爱用这种短名),留着就是静默覆盖。 */ ?>
<?php unset($attRow, $attKind, $canPrev, $attView, $attDl, $attNm, $attSz); ?>
</div>
<?php endif; ?>
+8
View File
@@ -64,8 +64,16 @@ $ppHasSel = count(array_filter($ppSelected)) > 0;
<b><?= e($ppTitle) ?></b>
<button type="button" class="pp-close" id="<?= e($ppId) ?>-close" title="关闭">×</button>
</div>
<?php /* ⚠️ 这个 div 原来是个 <form>,看着更"正规",实际有两个坑:
① 选人组件常被嵌在**别的表单里**(新建资源 / 记一笔 / 共享账本…),
HTML 不允许 form 嵌套 —— 浏览器会把外层表单切两半,
提交时丢字段("保存了但选的人没存上"就是这么来的);
② 在里面按回车会提交那个表单、整页刷新,而列表本来就实时筛过了。
改成 div:回车不再有任何副作用,筛选照样是即时的。 */ ?>
<div class="pp-searchline">
<input class="input pp-search" id="<?= e($ppId) ?>-search" type="text"
placeholder="输入名字或拼音首字母,如 yyq 找「杨永全」" autocomplete="off">
</div>
<div class="pp-list" id="<?= e($ppId) ?>-list">
<?php if (!$ppIsMulti && $ppAllowEmpty): ?>
<label class="pp-item pp-item-none" data-name="不选" data-shown="不选">
+114
View File
@@ -0,0 +1,114 @@
<?php
/**
* 日程模块的「设置」下拉(v24 用户 2026-10-03 第 4、5 条)
*
* 用户原话:「日程模块,左侧的日历本显示与否,去掉,让日历充满整个宽度。
* 在右侧,也加一个设置按钮,设置中,可以设置哪些日历本显示,
* 调整日历的颜色,导入日历等功能,都放在这个设置下。」
* 「在日程的设置中,可以设置默认显示的视图模式,月视图还是周视图还是日视图。
* 把日历本这个按钮的功能,收到设置中。」
*
* 所以这个 ☰ 是**原来左边那一栏 + 「日历本」按钮**的合并去处。
* 外观与观影 / 任务的设置下拉完全一致(只留图标、排最右、条目带小图标),
* 三处同一个手势,用户不用记第二套。
*
* 两件**个人偏好**(默认视图 / 显示哪些本子)就地用表单提交;
* 其余的(颜色、共享、导入、CalDAV)是**页面级**的操作,
* 跳到「日历本」管理页 —— 那里本来就有全套,没必要在这儿再写一遍。
*
* 用法(require 前 set 好):
* $setCurrent 当前页 key('view'),用来高亮
* $cals 我可见的日历本(带 perm / color);可能为空
* $view 当前视图(回跳时保持)
* $anchor 当前锚定日(周/日视图回跳时保持)
*/
$setCurrent = (string)($setCurrent ?? '');
$setOn = static function (string $key) use ($setCurrent): string {
return $setCurrent === $key ? ' class="on"' : '';
};
$setIcon = static function (string $name): string {
$paths = [
'view' => '<rect x="2" y="3.2" width="12" height="10.4" rx="1.6"/><path d="M2 6.4h12M5.4 1.6v3.2M10.6 1.6v3.2"/>',
'books' => '<path d="M2.6 3.2h4v9.6h-4zM9.4 3.2h4v9.6h-4z"/>',
'import' => '<path d="M8 2.6v7.2M5 6.8l3 3 3-3M3.2 13.4h9.6"/>',
'dav' => '<circle cx="8" cy="8" r="5.6"/><path d="M2.4 8h11.2M8 2.4c1.8 2 2.6 3.7 2.6 5.6S9.8 12 8 13.6C6.2 11.6 5.4 9.9 5.4 8S6.2 4.4 8 2.4z"/>',
];
$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.5" stroke-linecap="round"'
. ' stroke-linejoin="round">' . $d . '</svg>';
};
$myCals = (array)($cals ?? []);
$hiddenIds = array_map('intval', (array)($hiddenCals ?? []));
$curView = (string)($view ?? 'month');
$backView = $curView;
?>
<details class="movie-set menu cal-set">
<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 cal-set-list">
<?php /* ---- 默认视图 + 显示哪些本子(个人偏好,就地提交)---- */ ?>
<form method="post" action="<?= e(url('calendar/prefs')) ?>" class="cal-set-form">
<?= csrf_field() ?>
<input type="hidden" name="back_view" value="<?= e($backView) ?>">
<div class="cal-set-block">
<div class="cal-set-h">默认打开时看</div>
<div class="cal-set-views">
<?php foreach (\App\Calendar::VIEWS as $vk => $vw): ?>
<label class="cal-set-view">
<input type="radio" name="view" value="<?= e($vk) ?>"
<?= \App\Calendar::defaultView((int)\App\Auth::personId()) === $vk ? 'checked' : '' ?>>
<span><?= e($vw) ?>视图</span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="cal-set-block">
<div class="cal-set-h">显示哪些日历本</div>
<?php /* ⚠️ 这个隐藏标记是**必须**的:HTML 里"一个都没勾"会让浏览器
整个省掉 `show[]` 键,服务端就分不清「用户想全关掉」与
「这次请求没打算改显隐」—— 没有它的话,将来任何只改默认视图的
提交都会把所有日历本静默藏掉。 */ ?>
<input type="hidden" name="show_present" value="1">
<?php if (!$myCals): ?>
<p class="small muted" style="margin:0">还没有日历本。</p>
<?php else: ?>
<?php foreach ($myCals as $c): $cid = (int)$c['id']; ?>
<label class="cal-set-book">
<input type="checkbox" name="show[]" value="<?= $cid ?>"
<?= in_array($cid, $hiddenIds, true) ? '' : 'checked' ?>>
<span class="color-preview" style="background: <?= e((string)$c['color']) ?>;"></span>
<span class="cal-name"><?= e((string)$c['name']) ?></span>
<?php if ((string)$c['kind'] !== 'custom'): ?>
<span class="node-badge">系统</span>
<?php elseif ((int)$c['shared'] === 1): ?>
<span class="node-badge"><?= ($c['perm'] ?? '') === 'write' ? '共享·可写' : '共享·只读' ?></span>
<?php endif; ?>
</label>
<?php endforeach; ?>
<?php endif; ?>
<p class="small muted" style="margin:6px 0 0">
观影、旅行、有截止时间的任务会自动出现在这里,不用另建本子。
</p>
</div>
<div class="cal-set-foot">
<button class="btn btn-sm" type="submit">保存显示设置</button>
</div>
</form>
<?php /* ---- 页面级操作:颜色/新建/共享/导入/CalDAV 都在「日历本」管理页 ---- */ ?>
<div class="cal-set-links">
<a href="<?= e(url('calendar/calendars')) ?>"<?= $setOn('books') ?>><?= $setIcon('books') ?><span>日历本 · 颜色 / 共享 / 导入</span></a>
<a href="<?= e(url('calendar/calendars')) ?>#caldav"><?= $setIcon('dav') ?><span>CalDAV 订阅(在手机日历里看)</span></a>
</div>
</div>
</details>
<?php
unset($setCurrent, $setOn, $setIcon, $myCals, $hiddenIds, $curView, $backView);
+111 -64
View File
@@ -1,20 +1,23 @@
<?php
/**
* 日程 · 月视图(v12)。
* 日程 · 月 / 周 / 天 三视图(v12,v24 第 4、5 条重排)。
*
* 参照群晖 Calendar 的外观:左边一列"我的日历本"(可勾选显隐),右边 7×6 的月网格,
* 有日程的日子在格子里露出彩色小条,点小条跳到对应模块(观影/旅程/任务)。
* ⚠️ v24 用户 2026-10-03 第 4 条:**左侧那一栏「我的日历本」撤掉了**,日历占满整宽。
* 原话:「日程模块,左侧的日历本显示与否,去掉,让日历充满整个宽度。
* 在右侧,也加一个设置按钮,设置中,可以设置哪些日历本显示,
* 调整日历的颜色,导入日历等功能,都放在这个设置下。」
* 所以那些功能全搬进了页头最右边那个 ☰ 里(见 _settings_menu.php)。
* 显隐改由**服务端**处理(Calendar::hiddenCalendars):藏掉的本子不参与取数,
* "+N 条"之类按可见条目算 —— 前端 display:none 会让这些数字对不上。
*
* 两条设计:
* ① **日历本显隐是纯前端的**(勾选框 + 给日程条加 data-cal):
* 不做成"勾一次刷一次页面",否则翻月、勾选都在等服务端。没有 JS 时全部照常显示。
* ② **观影 / 旅行 / 任务不复制进日历**,是从各自模块实时汇总出来的(Calendar::month),
* 所以改了观影日期,日历里立刻跟着变。反过来在日历里给观影本加日程 = 真的建一条观影记录。
* ⚠️ 用户第 5 条:在日历里点一下就建日程(开始时间 = 点的那一格)。
* 月视图的格子按天、周/日视图按半小时——
* 后者用鼠标在列里的纵向位置反算时刻(见页面底部那两段 JS)。
*
* @param string $ym 'Y-m'
* @param array $weeks 月网格(build_month_grid 产出)
* @param array $cals 我可见的日历本(含 perm)
* @param array $people 名册(共享给别人时用)
* @param array $cals 我可见的日历本(含 perm);$visCals 是其中显示着的 id
* @param string $newAt 点进来时预填的开始时间('Y-m-d H:i' 或 'Y-m-d')
*/
$canWrite = \App\Auth::canWrite();
$view = (string)($view ?? 'month');
@@ -24,6 +27,11 @@ $prevYm = date('Y-m', strtotime('-1 month', $ts));
$nextYm = date('Y-m', strtotime('+1 month', $ts));
$thisYm = date('Y-m');
$writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::canWrite($c, (int)\App\Auth::personId())));
$visCals = array_map('intval', (array)($visCals ?? []));
$hiddenCals = array_map('intval', (array)($hiddenCals ?? []));
$newAt = (string)($newAt ?? '');
// 能不能"点一下建日程":只读账号点了没反应,所以连十字光标都不给(见 CSS 的 rt-writable)
$canNew = $canWrite && (bool)$writable;
// 三种看法各自的「上一段 / 下一段」:月按整月走,周按 7 天走,天按 1 天走
$step = $view === 'week' ? 7 : ($view === 'day' ? 1 : 0);
@@ -33,12 +41,6 @@ $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">
<h1>日程</h1>
@@ -66,41 +68,22 @@ $mkUrl = static function (string $v) use ($ym, $prevD, $nextD, $todayD, $prevYm,
<?= $view === 'week' ? '下一周' : '后一天' ?>
</a>
<?php endif; ?>
<a class="btn btn-ghost" href="<?= e(url('calendar/calendars')) ?>">日历本</a>
<?php if ($canWrite && $writable): ?>
<button class="btn" type="button" id="calAddBtn">新建日程</button>
<?php endif; ?>
<?php /* 用户第 4、5 条:日历本 / 颜色 / 导入 / 默认视图 全收进这个 ☰ ——
它顶替了原来左边那一栏,所以排在**最右**(靠 .page-head .actions 的顺序)。 */ ?>
<?php $setCurrent = 'view'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<div class="cal-wrap">
<?php /* ---- 左:日历本 ---- */ ?>
<aside class="cal-side">
<h2 class="cinema-h2">我的日历本</h2>
<?php if (!$cals): ?>
<p class="empty-hint">还没有日历本。</p>
<?php else: ?>
<ul class="cal-list">
<?php foreach ($cals as $c): ?>
<li class="cal-item">
<label class="cal-toggle">
<input type="checkbox" class="cal-cb" data-cal="<?= (int)$c['id'] ?>" checked>
<span class="color-preview" style="background: <?= e((string)$c['color']) ?>;"></span>
<span class="cal-name"><?= e((string)$c['name']) ?></span>
</label>
<?php if ((int)$c['shared'] === 1): ?>
<span class="node-badge"><?= ($c['perm'] ?? '') === 'write' ? '共享·可写' : '共享·只读' ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php /* 观影/旅行/任务三路是**自动汇总**的,没有本子开关,这里说明一下来源 */ ?>
<p class="cal-note">观影、旅行、有截止时间的任务会自动出现在这里,不需要另建本子。</p>
<?php endif; ?>
</aside>
<?php /* ---- 右:月网格 ---- */ ?>
<div class="cal-wrap cal-wrap-full">
<?php /* ---- 右:月网格 / 时间网格 ----
用户第 4 条:左边那一栏「我的日历本」已经撤掉(功能搬进右上角 ☰),
所以这里直接就是主区域,`.cal-wrap-full` 把网格调成"只有一列"。 */ ?>
<div class="cal-main">
<?php /* 用户第 5 条:这张表单既服务「新建日程」按钮,也服务「点日历空白处」——
后者由 JS 现填开始时间,所以表单本身不再写死 09:00。 */ ?>
<?php if ($canWrite && $writable): ?>
<div class="card card-pad cal-add" id="calAddCard" hidden>
<h2 class="cinema-h2">新建日程</h2>
@@ -117,10 +100,17 @@ $mkUrl = static function (string $v) use ($ym, $prevD, $nextD, $todayD, $prevYm,
</select>
</div>
<?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
新建时「全天」默认**不勾**(用户原话),开始时间默认今天 09:00。 */ ?>
新建时「全天」默认**不勾**(用户原话)。开始时间默认:
带了 new_at 就用它(点日历的那一格),否则今天 09:00。 */ ?>
<?php
$evFields = ['starts_at' => ($ym === $thisYm ? date('Y-m-d') : $ym . '-01') . ' 09:00:00',
'ends_at' => ($ym === $thisYm ? date('Y-m-d') : $ym . '-01') . ' 10:00:00'];
// 点的位置优先;没点就"月视图当月 → 该月 1 号 / 本月 → 今天"的 09:00
$defDay = $newAt !== '' ? substr($newAt, 0, 10)
: (($ym === $thisYm ? date('Y-m-d') : $ym . '-01'));
$defHM = $newAt !== '' && strlen($newAt) > 10
? substr($newAt, 11, 5) : '09:00';
$defEndH = sprintf('%02d', (min(23, (int)substr($defHM, 0, 2) + 1)));
$evFields = ['starts_at' => $defDay . ' ' . $defHM . ':00',
'ends_at' => $defDay . ' ' . $defEndH . ':' . substr($defHM, 3, 2) . ':00'];
$evAllDay = false;
require __DIR__ . '/_event_fields.php';
?>
@@ -170,7 +160,8 @@ $mkUrl = static function (string $v) use ($ym, $prevD, $nextD, $todayD, $prevYm,
<?php endfor; ?>
</div>
<?php foreach (($cols ?? []) as $i => $c): ?>
<div class="tg-col<?= $c['is_today'] ? ' is-today' : '' ?>">
<div class="tg-col<?= $c['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
data-new-at="<?= e((string)$c['date']) ?>">
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-line"></span>
<?php endfor; ?>
@@ -196,7 +187,11 @@ $mkUrl = static function (string $v) use ($ym, $prevD, $nextD, $todayD, $prevYm,
<div class="cal-grid">
<?php foreach ($weeks as $wk): ?>
<?php foreach ($wk as $cell): ?>
<div class="cal-cell<?= $cell['in_month'] ? '' : ' is-out' ?><?= $cell['is_today'] ? ' is-today' : '' ?>">
<?php /* 用户第 5 条:点格子里的空白 = 在这天建日程。
用 data-new-at 把日期带过去,由底部 JS 交给新建表单
(点了小条本身是"打开那条日程",由 <a> 自己处理,JS 会排除掉)。 */ ?>
<div class="cal-cell<?= $cell['in_month'] ? '' : ' is-out' ?><?= $cell['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
data-new-at="<?= e((string)$cell['date']) ?>">
<div class="cal-dnum"><?= (int)$cell['day'] ?></div>
<div class="cal-evs">
<?php $n = 0; foreach ($cell['events'] as $ev): $n++; ?>
@@ -226,21 +221,10 @@ $mkUrl = static function (string $v) use ($ym, $prevD, $nextD, $todayD, $prevYm,
<script>
(function () {
/* 日历本显隐:只切 display,不重新请求 —— 翻月/勾选都是立刻的。
没有 JS 时全部照常显示(勾选框失效而已),不会丢内容。 */
var boxes = document.querySelectorAll('.cal-cb');
function apply() {
var off = {};
Array.prototype.forEach.call(boxes, function (b) {
if (!b.checked) { off[b.getAttribute('data-cal')] = true; }
});
Array.prototype.forEach.call(document.querySelectorAll('.cal-ev'), function (el) {
el.style.display = off[el.getAttribute('data-cal')] ? 'none' : '';
});
}
Array.prototype.forEach.call(boxes, function (b) { b.addEventListener('change', apply); });
apply();
/* ---- 新建日程:按钮开合 + 「点日历空白处」预填开始时间 ----
⚠️ 上一版的「日历本显隐」JS 已经**整段删掉**:显隐改成服务端处理
(Calendar::hiddenCalendars,见路由里的说明),前端不再需要它。
留着会让人以为"改一下勾选就能切换",但那些勾选框已经不在这儿了。 */
var btn = document.getElementById('calAddBtn');
var card = document.getElementById('calAddCard');
var cancel = document.getElementById('calAddCancel');
@@ -251,6 +235,69 @@ $mkUrl = static function (string $v) use ($ym, $prevD, $nextD, $todayD, $prevYm,
});
}
if (cancel && card) { cancel.addEventListener('click', function () { card.hidden = true; }); }
/* ---- 用户第 5 条:在日历里点一下 → 弹新建,开始时间 = 点的位置 ----
月视图:一格 = 一天 → 开始时间用那天 09:00("当日默认"比 00:00 更像个日程)。
周/日视图:按鼠标在**那一列里的纵向位置**反算时刻,最接近的半小时 ——
点在"下午三点那一段"就该得到 15:00,这是"点哪儿建哪儿"的全部意义。 */
var startField = document.querySelector('.field.ev-dt[data-dt]');
var startDate = startField ? startField.querySelector('input.dt-date') : null;
var startHour = startField ? startField.querySelector('select.dt-hour') : null;
var startMin = startField ? startField.querySelector('select.dt-minute') : null;
var startSec = startField ? startField.querySelector('select.dt-second') : null;
function setStart(dateStr, hh, mm) {
if (!startDate) return;
startDate.value = dateStr;
if (hh !== null && startHour) { startHour.value = hh; }
if (mm !== null && startMin) { startMin.value = mm; }
if (startSec) { startSec.value = '00'; }
}
function openNew(dateStr, hh, mm) {
if (!card) return;
card.hidden = false;
setStart(dateStr, hh, mm);
card.scrollIntoView({ block: 'nearest' });
// 标题是第一个输入框,直接把光标放过去 —— 点完就能打字
var t = document.getElementById('ev-title');
if (t) { t.focus(); }
}
/* 月视图格子 */
Array.prototype.forEach.call(document.querySelectorAll('.cal-cell[data-new-at]'), function (cell) {
cell.addEventListener('click', function (ev) {
// 点在小条上 = 想打开那条日程(<a> 的默认行为),不要抢
if (ev.target.closest && ev.target.closest('.cal-ev')) { return; }
if (!<?= $canWrite && $writable ? 'true' : 'false' ?>) { return; }
openNew(cell.getAttribute('data-new-at'), '09', '00');
});
});
/* 周/日视图的列:纵向位置 → 时刻。
列高 1056px = 24 小时(见 .tg-col 的 CSS),所以 1 分钟 = 1056/1440 px。 */
Array.prototype.forEach.call(document.querySelectorAll('.tg-col[data-new-at]'), function (col) {
col.addEventListener('click', function (ev) {
if (ev.target.closest && ev.target.closest('.tg-block')) { return; }
if (!<?= $canWrite && $writable ? 'true' : 'false' ?>) { return; }
var box = col.getBoundingClientRect();
var ratio = (ev.clientY - box.top) / Math.max(1, box.height);
var mins = Math.max(0, Math.min(24 * 60 - 30, Math.round(ratio * 1440 / 30) * 30));
var hh = String(Math.floor(mins / 60)).padStart(2, '0');
var mm = String(mins % 60).padStart(2, '0');
openNew(col.getAttribute('data-new-at'), hh, mm);
});
});
/* 从 ?new_at= 进来(比如别处链过来)→ 直接开弹窗并预填 */
var preset = <?= json_encode($newAt, JSON_UNESCAPED_UNICODE) ?>;
if (preset) {
var parts = String(preset).split(/[ T]/);
if (parts[0]) {
var hm = (parts[1] || '').split(':');
openNew(parts[0], hm[0] || '09', hm[1] || '00');
}
}
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
})();
</script>
+63
View File
@@ -0,0 +1,63 @@
<?php
/**
* 纪念日模块的「设置」下拉(v24 用户 2026-10-03 第 9 条)
*
* 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」
*
* 分类清单是**家庭级**的(settings.anniv_kinds)—— 全家共用一套叫法,
* 不然「结婚纪念日」在两个人屏幕上是两套名字,看的时候对不上。
* 所以这里跟「模块主题色」一样:**能改的是所有人**,但改的是同一份。
*
* 用法(require 前 set 好):
* $kinds 当前分类清单
* $kindCounts 每个分类用了几条(显示"这个分类有没有在用")
*/
$daySetKinds = (array)($kinds ?? \App\Anniversary::PRESET_KINDS);
$daySetCounts = (array)($kindCounts ?? []);
$daySetCanWrite = \App\Auth::canWrite();
?>
<details class="movie-set menu ann-set">
<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 ann-set-list">
<div class="cal-set-h">纪念日分类(全家共用一套)</div>
<?php if (!$daySetKinds): ?>
<p class="small muted" style="margin:0 0 8px">一个分类都没有。下面加一个。</p>
<?php else: ?>
<ul class="ann-kind-list">
<?php foreach ($daySetKinds as $k): ?>
<li>
<span class="ann-kind-name"><?= e((string)$k) ?></span>
<span class="small muted"><?= (int)($daySetCounts[(string)$k] ?? 0) ?> 条在用</span>
<?php if ($daySetCanWrite): ?>
<form method="post" action="<?= e(url('days/kinds')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="del" value="<?= e((string)$k) ?>">
<button class="btn btn-ghost btn-sm" type="submit"
title="删掉这个分类(已用它的纪念日不变)">删</button>
</form>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($daySetCanWrite): ?>
<form method="post" action="<?= e(url('days/kinds')) ?>" class="led-set-form-in" style="margin-top:8px">
<?= csrf_field() ?>
<input class="input" name="add" maxlength="20" placeholder="新分类,如:相识纪念日">
<button class="btn btn-sm" type="submit">加分类</button>
</form>
<?php endif; ?>
<p class="small muted" style="margin:8px 0 0">
删掉一个分类**不会**改已用过它的纪念日 —— 只是新建时不再出现在下拉里。
</p>
</div>
</details>
<?php
// ⚠️ 只 unset **本片段自己定义**的那三个前缀变量。
// 绝不能再写 `unset($kinds, $kindCounts, $canWrite)` —— days/index.php 里
// 同样有 $canWrite(还有 $kinds),那样会把宿主模板的变量一起干掉,
// 后面每一处用到的地方都变成 Undefined variable(账本那个片段刚这么炸过一次)。
unset($daySetKinds, $daySetCounts, $daySetCanWrite);
+18 -6
View File
@@ -12,6 +12,15 @@
$e0 = $editing ?: [];
$v = static fn(string $k, $def = '') => $e0[$k] ?? $def;
$repeat = (string)($e0['repeat_type'] ?? 'solar');
// 用户 2026-10-03 第 9 条:分类下拉的选项来自**可配置**的分类清单
// (原来写死 PRESET_KINDS,用户要能自己增删 —— 见「纪念日设置」)。
$kinds = (array)($kinds ?? \App\Anniversary::PRESET_KINDS);
$curKind = (string)($e0['kind'] ?? '');
// 编辑一条用了**已被删掉的分类**的纪念日时,要把它自己也列进下拉,
// 否则保存一次就把它悄悄改成了第一个选项(用户没动它,值却变了)。
if ($curKind !== '' && !in_array($curKind, $kinds, true)) {
array_unshift($kinds, $curKind);
}
?>
<div class="page-head">
<h1><?= $editing ? '编辑纪念日' : '新建纪念日' ?></h1>
@@ -43,13 +52,16 @@ $repeat = (string)($e0['repeat_type'] ?? 'solar');
<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 /* 用 <select> 而不是 <input list=…>:分类现在是**用户自己维护的一套**,
下拉能保证选到的都是存在的分类;datalist 允许自由填,
填出来的新词不会进清单,下次别人也不知道有这个词。 */ ?>
<select class="select" id="an-kind" name="kind">
<option value="">不分类</option>
<?php foreach ($kinds as $k): ?>
<option value="<?= e((string)$k) ?>" <?= $curKind === (string)$k ? 'selected' : '' ?>><?= e((string)$k) ?></option>
<?php endforeach; ?>
</datalist>
</select>
<span class="tip">分类可以在「纪念日」页右侧的设置里增删。</span>
</div>
<div class="field">
+88 -48
View File
@@ -1,17 +1,21 @@
<?php
/**
* 纪念日(v24 用户第 13 条)
* 纪念日(v24 用户第 13 条;2026-10-03 第 9 条重排)
*
* 用户原话:「增加纪念日模块,可以根据阴历和阳历来设置纪念日,在纪念日模块中,
* 可以默认看到已经保存的名册中的人的各种生日,生日本身就是纪念日的一种。」
* 第 9 条原话:「纪念日中,手动添加的纪念日和来自其他模块的,例如生日的纪念日,
* 要分开,手动纪念日在上面。在新建纪念日和右侧,也增加设置按钮,
* 可以设置纪念日的分类。」
*
* 所以这一页两块:
* ① 临近的(今天起 60 天内)—— 一眼看到"该过什么日子了";
* ② 全部纪念日 —— 名册里每个人的生日(实时派生)+ 你自己加的,混在一起按日期排。
* 所以这一页三块,顺序按用户要求:
* ① 接下来的日子(未来 60 天,两类混在一起 —— 那块本来就是"该过什么日子了")
* ② **手动添加的**纪念日(在上)
* ③ 来自名册生日的(在下,实时派生、不能改删)
*
* @param array $rows 全部纪念日(含派生生日:source='birthday_solar'/'birthday_lunar')
* @param array $custom 用户自建的
* @param array $derived 从名册生日派生的
* @param array $upcoming 接下来 60 天的
* @param array $people 名册(新建表单里选"与谁相关")
* @param array $kinds 分类清单(设置里维护)
* @param array $kindCounts 每个分类用了几条
*/
$canWrite = \App\Auth::canWrite();
$kindBadge = static function (array $r): string {
@@ -28,6 +32,48 @@ $srcWord = static function (array $r): string {
}
return \App\Anniversary::repeatWord((string)($r['repeat_type'] ?? 'solar'));
};
/* 画一张纪念日表。两块(手动 / 派生)共用同一个渲染 —— 复制一遍迟早改漏一处。 */
$renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge, $srcWord): void {
if (!$rows) {
return;
}
?>
<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 ($editable && $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
};
?>
<div class="page-head">
<h1>纪念日</h1>
@@ -37,6 +83,8 @@ $srcWord = static function (array $r): string {
<a class="btn" href="<?= e(url('days/new')) ?>">新建纪念日</a>
<?php endif; ?>
<a class="btn btn-ghost" href="<?= e(url('calendar')) ?>">看日历</a>
<?php /* 用户第 9 条:设置按钮就在「新建纪念日」右边(☰,排最右) */ ?>
<?php $setCurrent = 'kinds'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
@@ -59,47 +107,39 @@ $srcWord = static function (array $r): string {
<?php endif; ?>
</div>
<?php /* ==================== ② 手动添加的(在上,用户第 9 条)==================== */ ?>
<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>
<h2 class="cinema-h2">
我自己添加的(<?= count($custom) ?>)
<span class="hint">只有这些能改能删</span>
</h2>
<?php if (!$custom): ?>
<p class="empty-hint">
还没有自己添加的纪念日。
<?php if ($canWrite): ?>
结婚纪念日、忌日、相识的日子…点右上角「新建纪念日」加一条。
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<p class="tip" style="margin-top:10px">
名册里那部分(生日)是**实时读出来的** —— 改生日,这里跟着变,不用重复维护。
所有纪念日都会作为**全天日程**出现在日历里。
</p>
<?php else: ?>
<?php $renderTable($custom, true); ?>
<?php endif; ?>
</div>
<?php /* ==================== ③ 来自名册的生日(在下)==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
来自名册的(<?= count($derived) ?>)
<span class="hint">实时读出来的,改生日这里跟着变</span>
</h2>
<?php if (!$derived): ?>
<p class="empty-hint">
名册里还没有人填过生日。到「名册 → 编辑成员」把生日填上,这里就会自动出现。
</p>
<?php else: ?>
<?php $renderTable($derived, false); ?>
<p class="tip" style="margin-top:10px">
这一块是**实时派生**的 —— 改生日,这里跟着变,不用重复维护。
不需要在这里新建,也没法单独删(删掉生日就等于删了它)。
</p>
<?php endif; ?>
</div>
+1
View File
@@ -113,6 +113,7 @@ $isLoggedIn = \App\Auth::check();
<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/filter.js')) ?>"></script>
<script src="<?= e(asset('/assets/js/preview.js')) ?>"></script>
</body>
</html>
+127
View File
@@ -0,0 +1,127 @@
<?php
/**
* 账本模块的「设置」下拉(v24 用户 2026-10-03 第 7 条)
*
* 用户原话:「共享账本,改名等内容,都放在账本的设置中,也是一样的,
* 在新建账目按钮旁边,加一个设置按钮,在里面对账本进行设置、共享等。」
*
* 所以原来那个「共享与账本」大卡片撤了,里面的东西全搬进这个 ☰:
* 共享给谁 / 取消共享 / 账本改名 / 新建账本 / 删除这本账。
* 外观与观影、任务的设置下拉一致(只留图标、排最右、条目带小图标)。
*
* ⚠️⚠️ 本片段是 `require` 进**另一个模板**里的,而 PHP 的 require 共享同一个作用域 ——
* 所以这里**绝不能**定义 / 覆盖 / unset 宿主模板用到的名字(`$canWrite` / `$curId`…)。
* 本条已实测踩到:片段结尾写了 `unset($canWrite, $curId)`,把宿主模板的
* `$canWrite` 一起干掉了 → 后面每一行 `<?php if ($canWrite): ?>` 都变成
* "Undefined variable"(页面在 debug 下会**直接把 Warning 打进正文**,
* 看着像排版坏了一小段,其实是变量被偷走)。
* 同理,_datetime.php 早年在任务页覆盖过 $t —— 同一个坑,别再踩第三次。
* 所以下面所有局部变量一律加 `$ledSet` 前缀,并且只 unset **自己定义**的那些。
*
* 用法(require 前 set 好):
* $cur 当前账本(含 name / my_perm)
* $shares 当前账本的共享列表
* $people 名册(共享给谁)
*/
$ledSetCanWrite = \App\Auth::canWrite() && \App\Ledger::canWrite($cur ?? []);
$ledSetCurId = (int)($cur['id'] ?? 0);
$ledSetIcon = static function (string $name): string {
$paths = [
'share' => '<circle cx="6" cy="6.2" r="2.4"/><circle cx="12.4" cy="9.6" r="2"/><path d="M2.2 13.4c0-1.9 1.7-3 3.8-3s3.8 1.1 3.8 3M9.6 13.4c0-1.5 1.3-2.4 2.8-2.4s2.8.9 2.8 2.4"/>',
'rename'=> '<path d="M2.6 13.4l.6-3 7.4-7.4 2.4 2.4-7.4 7.4zM9.6 4l2.4 2.4"/>',
'add' => '<path d="M8 3.2v9.6M3.2 8h9.6"/>',
'del' => '<path d="M3.4 4.6h9.2M6.6 4.6V3.2h2.8v1.4M4.8 4.6l.7 8.8h5l.7-8.8"/>',
];
$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.5" stroke-linecap="round"'
. ' stroke-linejoin="round">' . $d . '</svg>';
};
?>
<details class="movie-set menu led-set">
<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 led-set-list">
<?php if (!$ledSetCanWrite): ?>
<div class="led-set-block">
<p class="small muted" style="margin:0">这本账你只有只读权限,能看不能改。</p>
</div>
<?php else: ?>
<?php /* ---- 共享:给谁 + 权限 + 取消 ---- */ ?>
<div class="led-set-block">
<div class="cal-set-h">共享给谁(对方就能记能看)</div>
<?php if (!empty($shares)): ?>
<ul class="led-set-shares">
<?php foreach ($shares as $s): ?>
<li>
<?= 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>
<form method="post" action="<?= e(url('ledger/unshare')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $ledSetCurId ?>">
<input type="hidden" name="person_id" value="<?= (int)$s['person_id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">取消</button>
</form>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="small muted" style="margin:0 0 6px">还没有共享给任何人。</p>
<?php endif; ?>
<form method="post" action="<?= e(url('ledger/share')) ?>" class="led-set-form-in">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $ledSetCurId ?>">
<?php
$ppName = 'person_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择共享给谁';
$ppTitle = '账本共享给谁';
$ppEmptyText= '未选择';
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>
</div>
<?php /* ---- 改名 ---- */ ?>
<div class="led-set-block">
<div class="cal-set-h">账本名字</div>
<form method="post" action="<?= e(url('ledger/book_rename')) ?>" class="led-set-form-in">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $ledSetCurId ?>">
<input class="input" name="name" value="<?= e((string)($cur['name'] ?? '')) ?>" required>
<button class="btn btn-sm" type="submit">改名</button>
</form>
</div>
<?php /* ---- 新建 / 删除 ---- */ ?>
<div class="led-set-block">
<form method="post" action="<?= e(url('ledger/book_add')) ?>" class="led-set-form-in">
<?= csrf_field() ?>
<input class="input" name="name" placeholder="新账本名,如:孩子的教育金" required>
<button class="btn btn-sm" type="submit">新建账本</button>
</form>
<form method="post" action="<?= e(url('ledger/book_del')) ?>" class="led-set-form-in" style="margin-top:6px">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $ledSetCurId ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这本账?里面的账目会一起删掉。')">删除这本账</button>
</form>
</div>
<?php endif; ?>
</div>
</details>
<?php
unset($ledSetCanWrite, $ledSetCurId, $ledSetIcon);
+142 -92
View File
@@ -1,13 +1,19 @@
<?php
/**
* 财务 / 账本(v24 用户第 15 条)
* 财务 / 账本(v24 用户第 15 条;2026-10-03 第 7、8 条重排)。
*
* 用户原话:「先创建一个家庭,或者说,创建一个账本,这个账本,可以共享给某个人,
* 共享后,这个人就可以往这个账本加入账目,这样,双方都可以看到所有的账目,
* 最后计算出总数,这就是一个家庭的总账目。」
* 用户要求的页面结构(第 7 条原话):
* 「上面是概览,下面就是具体账目,添加账目的功能和新增日程之类的一样,
* 放在右侧的按钮。……共享账本,改名等内容,都放在账本的设置中,
* 也是一样的,在新建账目按钮旁边,加一个设置按钮,在里面对账本进行设置、共享等。
* 挂在谁名下,应该默认是自己,如果要更改,点一下,弹出选人框。」
*
* 页面结构就是照着这句话来的:
* 顶上一排账本(切换 / 新建)→ 汇总卡(资产 / 负债 / 净资产)→ 账目表 → 共享设置。
* 第 8 条(盘点):
* 「我点击盘点,让我对一个账目的金额进行重新输入,但是盘点之前的账目数目不能清空,
* 要记住,这样的话,每个账目的变化,就知道了……然后把这种变化,每个账目的
* 金额变化,还有总金额的变化,在账本列表上面,画出一个图,折线图这样的。」
* → 所以「账目」这块的最上面是**图**(整本账 + 每条有盘点记录的账目),
* 下面是账目表,每行带一个「盘点」入口。
*
* ⚠️ 金额全程以**分**参与计算(Ledger 里做的),这里只负责显示成元。
* 任何地方都不要拿元去加总。
@@ -17,17 +23,31 @@
* @param array $entries 当前账本的账目
* @param array $summary ['asset','debt','net','by_kind','count'](单位:分)
* @param array $shares 当前账本的共享列表
* @param array $charts 有盘点历史的账目 → ['entry'=>行, 'series'=>[…] ]
* @param array $timeline 整本账的净资产时间线
* @param array $people 名册
*/
$canWrite = \App\Auth::canWrite() && \App\Ledger::canWrite($cur);
$curId = (int)$cur['id'];
$money = static fn($c): string => \App\Ledger::money($c);
$charts = $charts ?? [];
$timeline = $timeline ?? [];
// 用户第 8 条:「挂在谁名下,应该默认是自己」。这里的 $me 由 index.php 传进来(当前登录者)。
// ⚠️ 别用 `$me ?? 0` 再判 0 —— 那样一旦没传进来就会静默变成"不选",看着像默认值丢了。
$ledMe = (int)($me ?? 0);
?>
<div class="page-head">
<h1>财务</h1>
<span class="sub">家庭的账 · 共享后双方都能记、都能看</span>
<span class="sub"><?= e((string)$cur['name']) ?> · 共享后双方都能记、都能看</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('ledger', ['book' => $curId])) ?>">刷新</a>
<?php /* 用户第 7 条:加账目 = 右侧按钮(与「新建日程」同一套) */ ?>
<?php if ($canWrite): ?>
<button class="btn" type="button" id="ledAddBtn">记一笔</button>
<?php else: ?>
<span class="link-quiet">只读</span>
<?php endif; ?>
<?php /* 用户第 7 条:共享 / 改名 / 新建 / 删除 全在账本设置里(☰) */ ?>
<?php $setCurrent = 'book'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
@@ -42,6 +62,7 @@ $money = static fn($c): string => \App\Ledger::money($c);
</div>
<?php endif; ?>
<?php /* ==================== ① 概览 ==================== */ ?>
<div class="card card-pad led-sum">
<div class="led-sum-head">
<h2 class="cinema-h2" style="margin:0"><?= e((string)$cur['name']) ?></h2>
@@ -79,7 +100,7 @@ $money = static fn($c): string => \App\Ledger::money($c);
</div>
<?php if ($canWrite): ?>
<div class="card card-pad">
<div class="card card-pad led-add" id="ledAddCard" hidden>
<h2 class="cinema-h2">记一笔</h2>
<form method="post" action="<?= e(url('ledger/entry_add')) ?>">
<?= csrf_field() ?>
@@ -104,13 +125,22 @@ $money = static fn($c): string => \App\Ledger::money($c);
<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>
<?php /* 用户第 7 条:「挂在谁名下,应该默认是自己,如果要更改,点一下,弹出选人框」
—— 用全局选人组件(与观影选人同一套,含拼音首字母联想)。 */ ?>
<label>挂在谁名下</label>
<?php
$ppName = 'person_id';
$ppPeople = $people;
// 默认就是**我自己**(用户第 8 条)。单选组件按 $ppSelected 勾选并渲染 chip,
// 传 [0] 的话会渲染成"未选择"——所以必须传真实 id。
$ppSelected = $ledMe > 0 ? [$ledMe] : [];
$ppMode = 'single';
$ppOpenBtn = '更换归属人';
$ppTitle = '这笔账挂在谁名下';
$ppEmptyText= '不指定';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field full">
<label for="le-note">备注</label>
@@ -122,6 +152,46 @@ $money = static fn($c): string => \App\Ledger::money($c);
</div>
<?php endif; ?>
<?php /* ==================== ② 变化图(用户第 8 条)==================== */ ?>
<?php if ($charts || count($timeline) > 1): ?>
<div class="card card-pad led-charts">
<h2 class="cinema-h2">
金额变化
<span class="hint">每次「盘点」记一个点,旧数字不会被清掉</span>
</h2>
<div class="ck-grid">
<?php if (count($timeline) > 1): ?>
<div class="ck-card">
<div class="ck-head">
<b>整本账 · 净资产</b>
<span class="small muted"><?= count($timeline) ?> 个时间点</span>
</div>
<?= chart_line($timeline, '净资产', 'asset', 320) ?>
</div>
<?php endif; ?>
<?php foreach ($charts as $ch): ?>
<?php $en = $ch['entry']; $ek = (string)$en['kind']; ?>
<div class="ck-card">
<div class="ck-head">
<b><?= e((string)$en['title']) ?></b>
<span class="small muted">
<?= \App\Ledger::kindIcon($ek) ?> <?= e(\App\Ledger::kindWord($ek)) ?>
· <?= count($ch['series']) ?> 个点
</span>
</div>
<?= chart_line($ch['series'], (string)$en['title'], \App\Ledger::isDebt($ek) ? 'debt' : 'asset', 320) ?>
</div>
<?php endforeach; ?>
</div>
<?php if (!$charts): ?>
<p class="small muted" style="margin:8px 2px 0">
还没有任何盘点记录 —— 下面账目表里每行都有「盘点」,盘过之后这里就会出现它自己的曲线。
</p>
<?php endif; ?>
</div>
<?php endif; ?>
<?php /* ==================== ③ 账目 ==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">账目(<?= count($entries) ?>)</h2>
<?php if (!$entries): ?>
@@ -134,7 +204,7 @@ $money = static fn($c): string => \App\Ledger::money($c);
</thead>
<tbody>
<?php foreach ($entries as $en): ?>
<?php $ek = (string)$en['kind']; $debt = \App\Ledger::isDebt($ek); ?>
<?php $ek = (string)$en['kind']; $debt = \App\Ledger::isDebt($ek); $eid = (int)$en['id']; ?>
<tr>
<td class="nowrap">
<span class="led-kind-chip<?= $debt ? ' is-debt' : '' ?>">
@@ -158,11 +228,50 @@ $money = static fn($c): string => \App\Ledger::money($c);
<td class="nowrap">
<?php if ($canWrite): ?>
<details class="led-edit">
<summary class="link-quiet">改</summary>
<summary class="link-quiet">操作</summary>
<?php /* ---- 盘点(用户第 8 条):重新输一个金额,旧值进历史 ---- */ ?>
<form method="post" action="<?= e(url('ledger/reconcile')) ?>" class="node-form led-recon">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<input type="hidden" name="id" value="<?= $eid ?>">
<h4 class="led-form-h">盘点这笔(旧金额会记进历史)</h4>
<div class="nf-grid">
<div class="field">
<label>盘点后的金额(元)</label>
<input class="input" name="amount" inputmode="decimal" required
value="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>">
</div>
<div class="field">
<label>盘点备注</label>
<input class="input" name="note" placeholder="如:2026 年初盘点">
</div>
</div>
<div class="btn-row"><button class="btn btn-sm" type="submit">盘点</button></div>
<?php if (\App\Ledger::history($eid)): ?>
<details class="led-hist">
<summary class="link-quiet">看历史(<?= count(\App\Ledger::history($eid)) ?> 次)</summary>
<ul class="led-hist-list">
<?php foreach (\App\Ledger::history($eid) as $hs): ?>
<li>
<span class="nowrap"><?= e(substr((string)$hs['created_at'], 0, 10)) ?></span>
<b><?= e($money((int)$hs['amount_cents'])) ?></b>
<?php if (($hs['note'] ?? '') !== ''): ?>
<span class="muted small"><?= e((string)$hs['note']) ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</details>
<?php endif; ?>
</form>
<?php /* ---- 改 / 删(改 = 修正录错了,与"盘点"是两件事,见 Ledger::reconcile)---- */ ?>
<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'] ?>">
<input type="hidden" name="id" value="<?= $eid ?>">
<h4 class="led-form-h">更正这笔(当作当初就录错了,不留历史)</h4>
<div class="nf-grid">
<div class="field">
<label>类别</label>
@@ -188,12 +297,12 @@ $money = static fn($c): string => \App\Ledger::money($c);
<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>
<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'] ?>">
<input type="hidden" name="id" value="<?= $eid ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这笔账目?')">删除</button>
</form>
@@ -215,74 +324,15 @@ $money = static fn($c): string => \App\Ledger::money($c);
<?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>
<script>
(function () {
var btn = document.getElementById('ledAddBtn');
var card = document.getElementById('ledAddCard');
if (!btn || !card) return;
btn.addEventListener('click', function () {
card.hidden = !card.hidden;
btn.textContent = card.hidden ? '记一笔' : '收起';
if (!card.hidden) { card.scrollIntoView({ block: 'nearest' }); }
});
})();
</script>
+95 -48
View File
@@ -42,10 +42,33 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
// ⚠️ 一律走 person_name():它按 shown → my_nickname → name 的顺序取,
// 这里自己拼一次迟早漏掉 my_nickname(v14 前就是漏的,于是设了称呼也不显示)。
$pshow = person_name($p);
// 用户 2026-10-03 第 3 条:「如果有昵称,就不要显示真实姓名了」。
// 所以这里只在**没有昵称/称呼**、显示名就是本名时,本名才等于 $pshow(不重复画)。
$pNick = (string)($p['my_nickname'] ?? '');
$pReal = (string)$p['name'];
$pid = (int)$p['id'];
// 用户第 3 条:行高要矮。姓名行右边的徽标只留最必要的几个,
// 「已开通 / 访客 / 家谱 / 待确认」这些收成小徽标(有就显示,没有就不占位)。
// ⚠️「家谱」那枚必须带 title 说明来历 —— 不然"我没加过他,他怎么在我名册里"
// 会让人以为数据坏了(v7 加它就是为了回答这个问题,别在改版时弄丢)。
$metaBits = [];
if ($pid === (int)$meId) { $metaBits[] = ['我', '']; }
$r = $p['account_role'] ?? '';
if ($r === 'admin' && ($p['account_status'] ?? '') === 'active') {
$metaBits[] = ['管理员', ''];
} elseif ($r === 'guest') {
$metaBits[] = ['访客', ''];
} elseif (!empty($p['account_id'])) {
$metaBits[] = ['已开通', ''];
}
if (($p['roster_source'] ?? '') === 'kin') {
$metaBits[] = ['家谱', '他在你的家谱树上(配偶/父母/子女/祖孙…),自动加入你的名册'];
}
if (!empty($p['is_placeholder'])) { $metaBits[] = ['待确认', '']; }
?>
<div class="person" data-person="<?= $pid ?>" <?= $canMove ? 'draggable="true"' : '' ?>>
<div class="person" data-person="<?= $pid ?>" <?= $canMove ? 'draggable="true"' : '' ?>
data-search="<?= e($pshow . ' ' . $pReal . ' ' . (string)($p['email'] ?? '') . ' ' . (string)($p['org'] ?? '') . ' ' . (string)($p['phone_plain'] ?? '')) ?>"
data-initials="<?= e(\App\Pinyin::initials($pReal . ' ' . $pNick)) ?>">
<?php if ($canMove): ?>
<span class="drag-handle" title="拖到我想要的位置 / 那一栏" aria-hidden="true">⠿</span>
<?php endif; ?>
@@ -55,49 +78,48 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
<div class="ph"><img src="<?= e(avatar_url($p['avatar'])) ?>" alt="" draggable="false"></div>
<div class="pb">
<div class="pname">
<?php /* ⚠️ 姓名必须是 .pname 里的**第一段裸文本**,不要包 <span>。
① 现有的 e2e 断言就是按这个形状读「这一行显示的是谁」
(person_names():只认 <div class="pname"> 内的第一段文本)——
包一层 span 会让它读到空串,6 条断言齐刷刷变红,
而页面看着完全正常(这种"改样式把测试打挂"最难查);
② 包起来也没有任何用处:.pname 本身已经带字重与字号,
下面的关系标签/徽标都在姓名**之后**,不需要靠 span 定位。 */ ?>
<?= e($pshow) ?>
<?php if ($pid === (int)$meId): ?><span class="badge badge-me">我</span><?php endif; ?>
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">待确认</span><?php endif; ?>
<?php /* 用户第 4 条:与我的关系放姓名右边(列表里一眼能看到) */ ?>
<?php if (!empty($p['label']) && $p['label'] !== '我'): ?>
<span class="rel-label"><?= e($p['label']) ?></span>
<?php endif; ?>
<?php foreach ($metaBits as [$b, $bTitle]): ?>
<span class="badge<?= $b === '我' ? ' badge-me' : ($b === '已开通' ? ' badge-open' : '') ?>"
<?= $bTitle !== '' ? 'title="' . e($bTitle) . '"' : '' ?>><?= e($b) ?></span>
<?php endforeach; ?>
</div>
<?php /* 用户第 4 条:中间显示几个重要信息 —— 手机号 / 生日。
关系标签已挪到姓名行,这里只放"事实性"的两项。 */ ?>
<div class="pfacts">
<?php if (($p['phone_plain'] ?? '') !== ''): ?>
<span class="pfact" title="手机号">
<span class="pfact-k">手机</span><?= e((string)$p['phone_plain']) ?>
</span>
<?php endif; ?>
<?php if (($p['birthday_show'] ?? '') !== ''): ?>
<span class="pfact" title="<?= e((string)$p['birthday_kind']) ?>生日">
<span class="pfact-k"><?= e((string)$p['birthday_kind']) ?></span><?= e((string)$p['birthday_show']) ?>
</span>
<?php endif; ?>
</div>
<div class="pmeta">
<?php
$r = $p['account_role'] ?? '';
if ($r === 'admin' && ($p['account_status'] ?? '') === 'active') {
echo '<span class="badge badge-admin">管理员</span>';
} elseif ($r === 'guest') {
echo '<span class="badge">访客</span>';
} elseif (!empty($p['account_id'])) {
echo '<span class="badge badge-open">已开通</span>';
// 用户第 3 条:一行放齐「手机号 / 阴历+阳历生日(带年份)/ 邮箱 / 工作单位」。
// 没填的项**整个 <span> 都不画** —— 留一个空标签只会让每行都长一截。
// 生日优先阳历、其次农历,用 [阳历]/[农历] 小字标明是哪一个。
$facts = [];
if (($p['phone_plain'] ?? '') !== '') {
$facts[] = ['手机', (string)$p['phone_plain']];
}
// 我叫他别的名字时,把本名再用小字附上,免得自己都认不出这人是谁
if ($pNick !== '' && $pNick !== (string)$p['name']) {
echo ' · <span class="muted">' . e((string)$p['name']) . '</span>';
if (($p['birthday_show'] ?? '') !== '') {
$facts[] = [(string)$p['birthday_kind'], (string)$p['birthday_show']];
}
// v7:因家谱关系自动进名册的人,标出来历 ——
// 不然"我没加过他,他怎么在我名册里"会让人怀疑数据坏了
if (($p['roster_source'] ?? '') === 'kin') {
echo ' <span class="badge" title="他在你的家谱树上(配偶/父母/子女/祖孙…),自动加入你的名册">家谱</span>';
if (trim((string)($p['email'] ?? '')) !== '') {
$facts[] = ['邮箱', (string)$p['email']];
}
if (trim((string)($p['org'] ?? '')) !== '') {
$facts[] = ['单位', (string)$p['org']];
}
?>
<?php if ($facts): ?>
<div class="pfacts">
<?php foreach ($facts as [$k, $v]): ?>
<span class="pfact"><span class="pfact-k"><?= e($k) ?></span><?= e($v) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</a>
<?php if ($canMove): ?>
@@ -138,7 +160,7 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
/** 一栏(含空栏)。空栏平时收起来,拖动时由 JS 加 is-dragging 露出来当落点。 */
$renderBlock = function (int $gid, string $name, array $list) use ($renderGrid): void {
?>
<section class="group-block<?= $list ? '' : ' is-empty' ?>" data-group="<?= $gid ?>">
<section class="group-block<?= $list ? '' : ' is-empty' ?>" data-group="<?= $gid ?>" data-filter-group>
<h2 class="group-head">
<span class="group-title"><?= e($name) ?></span>
<span class="group-n"><span class="n"><?= count($list) ?></span> 人</span>
@@ -170,18 +192,22 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
<?php /* 用户第 4 条:这句拖动提示**去掉**(用户直接点名要删)。
功能本身不变,只是不再占版面。 */ ?>
<form class="search" method="get" action="index.php">
<?php /* 用户 2026-10-03 第 2 条:**输入即筛选**,不需要按回车。
第 3 条:搜索后的「清除」按钮要与搜索保持一致(同款 .btn 尺寸、同一行)。
⚠️ 这里仍然是个 form(没有 JS 时按回车照样能搜)——有 JS 时 filter.js 会把
提交拦下来,改成即时筛,避免整页刷新把焦点弹走。 */ ?>
<form class="search" method="get" action="index.php" data-roster-search>
<input type="hidden" name="r" value="people">
<?php /* 用户第 4 条:除了姓名/称呼/邮箱的模糊搜索,还支持**姓名首字母**
(yyq / yy / y 都能搜到「杨永全」)。后端在 Person::roster 里按
"汉字转首字母后再比对"实现,所以这里只要说清楚用法。 */ ?>
<input class="input" name="q" value="<?= e($q) ?>"
<input class="input" name="q" id="rosterSearch" data-filter="#rosterBox"
data-filter-empty="#rosterEmpty" data-filter-clear=".js-roster-clear"
value="<?= e($q) ?>" autocomplete="off"
placeholder="姓名 / 称呼 / 邮箱 / 手机号,或姓名首字母(如 yyq)">
<button class="btn btn-ghost btn-sm" type="submit">搜索</button>
<?php if ($q !== ''): ?>
<a class="link-quiet" href="<?= e(url('people')) ?>">清除</a>
<?php endif; ?>
<button class="btn btn-ghost btn-sm js-roster-clear<?= $q === '' ? ' is-hidden' : '' ?>"
type="button" data-href="<?= e(url('people')) ?>">清除</button>
</form>
<p class="small muted search-count" id="rosterCount" style="margin:-8px 0 10px"></p>
<p class="small muted" id="rosterEmpty" hidden>没有匹配的人。</p>
<?php if (!$people): ?>
<div class="empty">
@@ -209,14 +235,12 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
<?php endif; ?>
<?php endif; ?>
</div>
<?php elseif ($q !== ''): ?>
<?php /* 搜索时按"命中的人"平铺 —— 分栏会把一次搜索拆成一堆只有一两个人的碎块,反而更难看 */ ?>
<p class="small muted" style="margin:0 0 12px">
找到 <?= count($people) ?> 个匹配「<?= e($q) ?>」的人(搜索时不分组)。
</p>
<?php $renderGrid($people); ?>
<?php else: ?>
<div class="roster<?= $canMove ? ' is-droppable' : '' ?>"
<?php /* ⚠️ 搜索与否**都走同一张列表**(v24 用户第 2 条之后)。
原来搜索时会把分组拆掉、平铺成一堆卡片,看着"搜的时候样式变了";
现在一律按分组呈现,filter.js 会把没命中的行藏起来,
整栏都不剩人时连栏一起收 —— 搜索结果与平时长得一样,只少了不匹配的。 */ ?>
<div class="roster<?= $canMove ? ' is-droppable' : '' ?>" id="rosterBox"
data-reorder="<?= e(url('people/reorder')) ?>"
data-csrf="<?= e(csrf_token()) ?>">
<?php foreach ($groups as $g): ?>
@@ -238,3 +262,26 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($q !== '' || $canMove): ?>
<script>
(function () {
/* 「清除」按钮:有 JS 时不动页面、只清空输入并立刻重筛
(data-href 是没有 JS 时的兜底:直接跳回不带 q 的名册)。 */
var btn = document.querySelector('.js-roster-clear');
var input = document.getElementById('rosterSearch');
if (!btn || !input) { return; }
function syncClear() {
btn.classList.toggle('is-hidden', (input.value || '').trim() === '');
}
input.addEventListener('input', syncClear);
btn.addEventListener('click', function () {
// filter.js 里已经挂了"点它就清空重筛",这里只负责同步按钮显隐;
// 没有 filter.js(极老浏览器)时退化成跳转。
if (!window.__fbFilterLoaded) { window.location.href = btn.getAttribute('data-href'); }
setTimeout(syncClear, 0);
});
syncClear();
})();
</script>
<?php endif; ?>
+16 -2
View File
@@ -44,7 +44,18 @@
</select>
</div>
<ul class="pick-list">
<?php /* 用户 2026-10-03 第 2 条:所有"找个人"的地方都要**输入即筛选**。
这一页也是找人的地方(家里已有的人可能有几十个),
所以同样挂上 filter.js —— 行上带 data-search / data-initials。 */ ?>
<div class="search pick-search">
<label class="sr-only" for="pickFilter">筛选</label>
<input class="input" id="pickFilter" type="text" autocomplete="off"
data-filter="#pickList" data-filter-empty="#pickEmpty"
placeholder="输入名字或拼音首字母筛选,如 yyq">
</div>
<p class="small muted" id="pickEmpty" hidden>没有匹配的人。</p>
<ul class="pick-list" id="pickList">
<?php foreach ($people as $p): $pid = (int)$p['id']; ?>
<?php
// 显示名优先用「我怎么称呼他」:别人录的名字未必是我对他的叫法
@@ -52,8 +63,11 @@
if ($shown === '') {
$shown = (string)$p['name'];
}
$pReal = (string)$p['name'];
?>
<li class="pick-row">
<li class="pick-row"
data-search="<?= e($shown . ' ' . $pReal . ' ' . (string)($p['email'] ?? '')) ?>"
data-initials="<?= e(\App\Pinyin::initials($pReal)) ?>">
<label class="pick-item">
<input type="checkbox" name="ids[]" value="<?= $pid ?>">
<img class="pick-ph" src="<?= e(avatar_url($p['avatar'])) ?>" alt="">
+64 -5
View File
@@ -28,9 +28,41 @@ $canWrite = \App\Auth::canWrite();
</div>
</div>
<?php /* v24(用户 2026-10-02 第 18 条):新建资源页去掉「搜索资源」。
资源本来就不多,列表默认折叠、一眼能扫完;搜索框反而把「新建」按钮挤下去了。
真的找不着再靠浏览器的 Ctrl+F,不为一个短列表专门养一套搜索。 */ ?>
<?php /* v24(用户 2026-10-02 第 18 条):**新建资源页**去掉「搜索资源」——
资源本来就不多,搜索框反而把「新建」按钮挤下去了。
⚠️ 但 2026-10-03 第 6 / 10 条把搜索**请回列表页**了,而且是"上来就是搜索":
资源会越攒越多(第 10 条原话:「可能会非常多」),没有搜索就只能滚动。
两件事不矛盾:新建那块仍然干净,搜索长在**列表**上。
区别在于现在是页面顶部的**第一块**,不是夹在新建表单里的一个输入框。 */ ?>
<div class="card card-pad res-search-card">
<form class="search res-search" method="get" action="index.php">
<input type="hidden" name="r" value="resources">
<input type="hidden" name="sort" value="<?= e($sort) ?>">
<label class="sr-only" for="resKw">搜索资源</label>
<input class="input" id="resKw" name="keyword" value="<?= e($keyword) ?>"
placeholder="搜标题 / 正文,如「身份证」" autocomplete="off">
<button class="btn btn-ghost btn-sm" type="submit">搜索</button>
<?php if ($keyword !== ''): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('resources')) ?>">清除</a>
<?php endif; ?>
</form>
<?php /* 排序:最新新增 / 最近改动。用链接而不是下拉,一眼看出当前按什么排的。 */ ?>
<div class="res-sortbar">
<span class="small muted">排序</span>
<span class="seg">
<a class="seg-btn<?= $sort === 'used' ? ' is-on' : '' ?>"
href="<?= e(url('resources', ['sort' => 'used', 'keyword' => $keyword])) ?>">最近改动</a>
<a class="seg-btn<?= $sort === 'new' ? ' is-on' : '' ?>"
href="<?= e(url('resources', ['sort' => 'new', 'keyword' => $keyword])) ?>">最新新增</a>
</span>
<?php if (!$showAll && $totalAll > count($rows)): ?>
<a class="link-quiet res-showall" href="<?= e(url('resources', ['sort' => $sort, 'keyword' => $keyword, 'all' => 1])) ?>">
显示全部 <?= (int)$totalAll ?> 条
</a>
<?php endif; ?>
</div>
</div>
<?php if ($canWrite): ?>
<div class="card card-pad" id="resAddCard" <?= $isEditing ? '' : 'hidden' ?>>
@@ -82,10 +114,27 @@ $canWrite = \App\Auth::canWrite();
<?php endif; ?>
<div class="card card-pad">
<h2 class="cinema-h2">资源列表</h2>
<h2 class="cinema-h2">
资源列表
<?php
// ⚠️ 提示语要说清"为什么只有这些":
// · 搜索时只有几条是**因为搜索**(不是被截断)→ 别在这儿提"只显示最新 10 条",
// 否则用户会以为"搜出来的结果又被砍了";
// · 没搜索又确实多于 10 条,才提示截断,并给出"显示全部"。
?>
<?php if ($keyword !== ''): ?>
<span class="hint">匹配「<?= e($keyword) ?>」的 <?= count($rows) ?> 条</span>
<?php elseif (!$showAll && $totalAll > count($rows)): ?>
<span class="hint">只显示最新的 <?= (int)$limit ?> 条(共 <?= (int)$totalAll ?> 条)</span>
<?php else: ?>
<span class="hint">共 <?= count($rows) ?> 条</span>
<?php endif; ?>
</h2>
<?php if (!$rows): ?>
<p class="empty-hint">
还没有资源。证件、照片、账号这些"挂在某人名下的东西"都可以记在这里。
<?= $keyword !== ''
? '没有匹配「' . e($keyword) . '」的资源,换个词试试。'
: '还没有资源。证件、照片、账号这些"挂在某人名下的东西"都可以记在这里。' ?>
</p>
<?php else: ?>
<div class="res-list">
@@ -188,6 +237,16 @@ $canWrite = \App\Auth::canWrite();
</details>
<?php endforeach; ?>
</div>
<?php /* 用户第 10 条:截断的地方要留一个明确的出口,别让人以为"就只有这 10 条"。 */ ?>
<?php if (!$showAll && $totalAll > count($rows)): ?>
<div class="res-more">
<a class="btn btn-ghost btn-sm"
href="<?= e(url('resources', ['sort' => $sort, 'keyword' => $keyword, 'all' => 1])) ?>">
显示全部 <?= (int)$totalAll ?> 条
</a>
<span class="small muted">只按「<?= $sort === 'new' ? '最新新增' : '最近改动' ?>」取了前 <?= (int)$limit ?> 条 —— 找不到就直接搜。</span>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
+4 -23
View File
@@ -25,29 +25,10 @@
<b>没写的人才用这里的这句</b>。所以这是一句"兜底",不是全家的统一口号。
</span>
</div>
<?php /* 用户 2026-10-03 第 3 条:首页要显示"今天的天气",所以得有地方填坐标。
用 Open-Meteo(不需要 key)。只填经纬度,不填城市名 ——
经纬度是"位置",城市名只是给人看的备注。 */ ?>
<div class="field">
<label for="weather_place">天气 · 地点名</label>
<input class="input" id="weather_place" name="weather_place"
value="<?= e((string)($weatherPlace ?? '')) ?>" placeholder="如:青岛">
<span class="tip">只用于显示,随便写。</span>
</div>
<div class="field">
<label for="weather_lat">天气 · 纬度</label>
<input class="input" id="weather_lat" name="weather_lat"
value="<?= e((string)($weatherLat ?? '')) ?>" placeholder="如:36.067">
</div>
<div class="field">
<label for="weather_lon">天气 · 经度</label>
<input class="input" id="weather_lon" name="weather_lon"
value="<?= e((string)($weatherLon ?? '')) ?>" placeholder="如:120.383">
<span class="tip">
<b>三个都留空就不显示天气</b>(首页不会因此出错 —— 不猜城市,猜错比不显示更糟)。
坐标可以在地图网站上右键查。
</span>
</div>
<?php /* ⚠️ 天气的坐标输入框**从这里撤了**(用户 2026-10-03 第 1 条)。
原话是「让用户在个人设置中填写自己的城市」——
地点是**每个人自己**的事(一家人可能分处两地),而且应该是"填城市名",
不该让用户去查经纬度。现在在「个人设置 → 天气 · 我所在的城市」里。 */ ?>
</div>
<div class="btn-row">
<button class="btn" type="submit">保存</button>
+65
View File
@@ -46,6 +46,71 @@ $rosterCount = $rosterCount ?? 0;
<div class="stack-gap"></div>
<?php require __DIR__ . '/_nicknames.php'; ?>
<!-- ==================== 天气地点(用户 2026-10-03 第 1 条)==================== -->
<?php /* 原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
所以地点从系统设置搬到了这里 —— 而且改成**城市名**,不是经纬度:
填坐标是我们要解决的实现细节(App\Geo 内置 197 个地级市离线查),
不该甩给用户。内置表里没有的(镇 / 村 / 海外)才需要填坐标。 */ ?>
<div class="card card-pad" id="weather" style="margin-top:20px">
<h2 class="section-title">
天气 · 我所在的城市
<span class="hint">只影响你自己首页上的那一格</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
填个城市名就行(如「青岛」)。首页那句问候旁边会显示这儿的天气 ——
<b>每人一份</b>,孩子在外地读书,他填他的、你看你的。
<br>留空就是不显示天气(不影响别的东西)。
</p>
<form method="post" action="<?= e(url('settings/weather')) ?>">
<?= csrf_field() ?>
<div class="form-grid">
<div class="field">
<label for="w-city">城市</label>
<input class="input" id="w-city" name="city" maxlength="30"
value="<?= e((string)($weatherCity ?? '')) ?>"
placeholder="如:青岛 / 山东青岛 / 内蒙古包头" autocomplete="off">
</div>
<div class="field">
<label for="w-lat">纬度(可选)</label>
<input class="input" id="w-lat" name="lat" value="<?= e((string)($weatherLat ?? '')) ?>"
placeholder="内置表里没有的地方才需要">
</div>
<div class="field">
<label for="w-lon">经度(可选)</label>
<input class="input" id="w-lon" name="lon" value="<?= e((string)($weatherLon ?? '')) ?>"
placeholder="如 120.38">
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit">保存天气地点</button>
</div>
</form>
<?php if (!empty($weatherAt)): ?>
<p class="small muted" style="margin:12px 0 0">
现在按 <b><?= e((string)$weatherAt['place']) ?></b>
(<?= e((string)$weatherAt['lat']) ?> , <?= e((string)$weatherAt['lon']) ?>)取天气。
<?php if (($weatherCity ?? '') !== ''): ?>
不是这里?换个写法试试,比如加上省份:「山东青岛」。
<?php endif; ?>
</p>
<?php elseif (($weatherCity ?? '') !== ''): ?>
<p class="small muted" style="margin:12px 0 0">
<b class="warn-label">这个城市名没认出坐标</b>(首页就看不到天气了)。
换个写法试试(如「山东青岛」),或者直接填经纬度。
</p>
<?php endif; ?>
</div>
<?php
// 内置表里有几个城市,直接说清楚 —— 用户不用猜"到底支持哪些"
$fbCityN = count(\App\Geo::CITIES);
?>
<p class="small muted" style="margin:10px 2px 0">
内置了 <b><?= (int)$fbCityN ?></b> 个城市(全国地级市 + 直辖市),一般常用的都在里面。
</p>
<!-- ==================== 我的分组 ==================== -->
<div class="card card-pad" style="margin-top:20px">
<h2 class="section-title">
+69 -48
View File
@@ -1,27 +1,52 @@
<?php
/**
* 任务 · 看板(v12):待办 / 进行中 / 已完成 三列。
* 任务 · 看板(v12;v24 用户 2026-10-03 第 7 条重做列的含义)。
*
* 拖动:卡片是 HTML5 draggable,松手时把「拖到哪一列、放在谁后面」写进一个隐藏表单再提交
* —— **没有 JS 时每列下方的 ← → 按钮同样能搬**(渐进增强:功能是有的,拖动只是更顺手)。
* ⚠️ 第 7 条原话:「任务模块跟我想的不同,每个人自定义的看板,不是显示在现在的看板上面,
* 而是**直接代替现在的待办和进行中**,是看板,而不是看板的分类。」
*
* ⚠️ 看板里只有父任务:子任务是挂在父任务下面的(点卡片进详情看/勾),
* 混进看板会变成一堆没有上下文的小条目,"还剩几件"反而看不清。
* 于是这一页的样子变了:
* · 以前:顶上一条「看板切换」,下面是固定两列「待办 / 进行中」;
* · 现在:**一列 = 一个看板**(你自己建的那些),全都在同一屏上,
* 拖动/左右箭头 = **把卡搬到另一个看板**。
*
* @param array $board ['todo'=>[], 'doing'=>[], 'done'=>[]]
* 三条保留的纪律:
* · **已完成的任务不在看板上**(用户第 10 条:直接消失)→ 去「任务设置 → 已完成」看;
* · 拖动是渐进增强:每张卡下面还有 ← → 按钮,没有 JS / 手机上照样能搬;
* · 看板里只有父任务:子任务挂在父任务下面(点卡片进详情看/勾)。
*
* @param array $cols 看板 id => 该列的任务(键 0 = 没有看板归属的兜底列)
* @param array $boards 看板清单(顺序即列顺序)
* @param array $people 名册(选负责人 / 共享给别人)
*/
$canWrite = \App\Auth::canWrite();
// ⚠️ v24 第 10 条:看板上只有「待办 / 进行中」两列 —— 已完成的任务从看板消失,
// 要看去「任务设置 → 已完成」按分类看/搜。留着第三列就不是"直接消失"了。
$cols = [['todo', '待办'], ['doing', '进行中']];
$boards = $boards ?? [];
$cols = $cols ?? [];
$categories = $categories ?? [];
$curBoard = (int)($curBoard ?? 0);
/* 列清单:先按 boards 的顺序,最后补上兜底列(键 0,且只在真有任务时才画)。
⚠️ 兜底列的标题不能叫「未分类」——「分类」在任务模块里是另一回事(task_categories),
叫「未分类」会让人以为是按分类分的列。这里它表示"还没归到任何看板",叫「其他」。 */
$colList = [];
foreach ($boards as $b) {
$bid = (int)$b['id'];
$colList[] = ['id' => $bid, 'name' => (string)$b['name'], 'rows' => $cols[$bid] ?? []];
}
if (!empty($cols[0])) {
$colList[] = ['id' => 0, 'name' => '其他', 'rows' => $cols[0]];
}
// 一个看板都没有(也不该发生:ensureDefaultBoard 保证至少一个)→ 给一句人话
if (!$colList) {
$colList[] = ['id' => 0, 'name' => '任务', 'rows' => $cols[0] ?? []];
}
$firstColId = (int)($colList[0]['id'] ?? 0);
$lastColIdx = count($colList) - 1;
?>
<div class="page-head">
<h1>任务</h1>
<span class="sub">共 <?= (int)$total ?> 项未完成</span>
<span class="sub">
<?= (int)$total ?> 项在做<?= count($boards) > 1 ? ',' . count($boards) . ' 个看板' : '' ?>
</span>
<div class="actions">
<?php if ($canWrite): ?>
<button class="btn" type="button" id="taskAddBtn">新建任务</button>
@@ -32,15 +57,6 @@ $curBoard = (int)($curBoard ?? 0);
</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): ?>
<div class="card card-pad" id="taskAddCard" hidden>
<h2 class="cinema-h2">新建任务</h2>
@@ -86,13 +102,12 @@ $curBoard = (int)($curBoard ?? 0);
<?php endforeach; ?>
</select>
</div>
<?php /* 用户第 7 条:看板 = 任务"归属哪摊事",新建时直接选(不再是顶上的切换条) */ ?>
<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>
<option value="<?= (int)$b['id'] ?>"><?= e((string)$b['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
@@ -118,29 +133,29 @@ $curBoard = (int)($curBoard ?? 0);
</div>
<?php endif; ?>
<?php /* 拖动的提交目标:JS 把 id / 目标列 / 参照顺序填进来再 submit。
<?php /* 拖动的提交目标:JS 把 id / 目标**看板** / 参照顺序填进来再 submit。
放在看板**外面**,避免被任何一张卡片的 form 套住。 */ ?>
<form method="post" action="<?= e(url('tasks/move')) ?>" id="taskMoveForm" class="hidden-form">
<?= csrf_field() ?>
<input type="hidden" name="id" id="mvId">
<input type="hidden" name="status" id="mvStatus">
<input type="hidden" name="board" id="mvBoard">
<input type="hidden" name="before" id="mvBefore">
</form>
<div class="kanban">
<?php foreach ($cols as [$key, $label]): ?>
<section class="kb-col" data-status="<?= e($key) ?>">
<div class="kanban" data-cols="<?= count($colList) ?>">
<?php foreach ($colList as $ci => $col): $cid = (int)$col['id']; ?>
<section class="kb-col" data-board="<?= $cid ?>">
<header class="kb-head">
<b><?= e($label) ?></b>
<span class="kb-n"><?= count($board[$key] ?? []) ?></span>
<b><?= e((string)$col['name']) ?></b>
<span class="kb-n"><?= count($col['rows']) ?></span>
</header>
<div class="kb-body" data-status="<?= e($key) ?>">
<?php foreach (($board[$key] ?? []) as $t): ?>
<div class="kb-body" data-board="<?= $cid ?>">
<?php foreach ($col['rows'] as $t): ?>
<?php
$tid = (int)$t['id'];
$total = (int)($t['child_total'] ?? 0);
$kids = (int)($t['child_total'] ?? 0);
$doneN = (int)($t['child_done'] ?? 0);
$overdue = \App\Task::isOverdue($t['due_at'] ?? null) && $key !== 'done';
$overdue = \App\Task::isOverdue($t['due_at'] ?? null);
?>
<div class="kb-card<?= $overdue ? ' is-overdue' : '' ?>" data-id="<?= $tid ?>"
draggable="<?= $canWrite ? 'true' : 'false' ?>">
@@ -166,10 +181,10 @@ $curBoard = (int)($curBoard ?? 0);
<span class="node-badge" title="分类"><?= e((string)$t['category_name']) ?></span>
<?php endif; ?>
</div>
<?php if ($total > 0): ?>
<?php if ($kids > 0): ?>
<div class="kb-sub">
<span class="kb-bar"><i style="width: <?= (int)round($doneN * 100 / max(1, $total)) ?>%"></i></span>
<span class="kb-sub-n"><?= $doneN ?>/<?= $total ?></span>
<span class="kb-bar"><i style="width: <?= (int)round($doneN * 100 / max(1, $kids)) ?>%"></i></span>
<span class="kb-sub-n"><?= $doneN ?>/<?= $kids ?></span>
</div>
<?php endif; ?>
<div class="kb-foot">
@@ -182,11 +197,11 @@ $curBoard = (int)($curBoard ?? 0);
<?php endif; ?>
<?php if ($canWrite): ?>
<span class="kb-move">
<?php if ($key !== 'todo'): ?>
<?php if ($ci > 0): ?>
<button class="btn btn-ghost btn-sm" type="button"
data-move="left" data-id="<?= $tid ?>">←</button>
<?php endif; ?>
<?php if ($key !== 'done'): ?>
<?php if ($ci < $lastColIdx): ?>
<button class="btn btn-ghost btn-sm" type="button"
data-move="right" data-id="<?= $tid ?>">→</button>
<?php endif; ?>
@@ -195,7 +210,7 @@ $curBoard = (int)($curBoard ?? 0);
</div>
</div>
<?php endforeach; ?>
<?php if (!($board[$key] ?? [])): ?>
<?php if (!$col['rows']): ?>
<p class="empty-hint kb-empty">这一列是空的</p>
<?php endif; ?>
</div>
@@ -216,22 +231,28 @@ $curBoard = (int)($curBoard ?? 0);
}
if (addCancel && addCard) { addCancel.addEventListener('click', function () { addCard.hidden = true; }); }
/* ---- 左右搬(不需要拖也能用)---- */
var order = ['todo', 'doing', 'done'];
/* ---- 左右搬(不需要拖也能用)----
⚠️ 列的集合**从 DOM 现取**,不要写死数组:看板是用户自己建的,
"待办/进行中/已完成"那套常量在这儿已经不成立了。 */
var form = document.getElementById('taskMoveForm');
if (!form) return;
function submitMove(id, status, before) {
var colIds = Array.prototype.map.call(
document.querySelectorAll('.kb-body[data-board]'),
function (b) { return b.getAttribute('data-board'); }
);
function submitMove(id, board, before) {
document.getElementById('mvId').value = id;
document.getElementById('mvStatus').value = status;
document.getElementById('mvBoard').value = board;
document.getElementById('mvBefore').value = before || '';
form.submit();
}
Array.prototype.forEach.call(document.querySelectorAll('[data-move]'), function (btn) {
btn.addEventListener('click', function () {
var card = btn.closest('.kb-card');
var from = card.closest('.kb-body').getAttribute('data-status');
var i = order.indexOf(from) + (btn.getAttribute('data-move') === 'right' ? 1 : -1);
submitMove(card.getAttribute('data-id'), order[i], '');
var from = card.closest('.kb-body').getAttribute('data-board');
var i = colIds.indexOf(from) + (btn.getAttribute('data-move') === 'right' ? 1 : -1);
if (i < 0 || i >= colIds.length) { return; }
submitMove(card.getAttribute('data-id'), colIds[i], '');
});
});
@@ -285,7 +306,7 @@ $curBoard = (int)($curBoard ?? 0);
var box = kids[i].getBoundingClientRect();
if (ev.clientY < box.top + box.height / 2) { after = kids[i]; break; }
}
submitMove(dragging.getAttribute('data-id'), body.getAttribute('data-status'),
submitMove(dragging.getAttribute('data-id'), body.getAttribute('data-board'),
after ? after.getAttribute('data-id') : '');
});
});