From e0562b86271f86f7fc06e7409539ba501932eee2 Mon Sep 17 00:00:00 2001 From: laoyang Date: Sat, 3 Oct 2026 15:01:38 +0800 Subject: [PATCH] =?UTF-8?q?v24=20=E7=AC=AC=205=20=E8=BD=AE=EF=BC=9A?= =?UTF-8?q?=E5=8D=81=E6=9D=A1=E6=B8=85=E5=8D=95=EF=BC=88=E5=A4=A9=E6=B0=94?= =?UTF-8?q?=E5=A1=AB=E5=9F=8E=E5=B8=82=20/=20=E8=BE=93=E5=85=A5=E5=8D=B3?= =?UTF-8?q?=E7=AD=9B=E9=80=89=20/=20=E7=9C=8B=E6=9D=BF=E5=88=97=EF=BC=9D?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=20/=20=E8=B4=A6=E6=9C=AC=E7=9B=98=E7=82=B9?= =?UTF-8?q?=E6=8A=98=E7=BA=BF=E5=9B=BE=20/=20=E7=BA=AA=E5=BF=B5=E6=97=A5?= =?UTF-8?q?=E5=88=86=E7=B1=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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 开头的 ` [...], '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; + } + // ---------------- ③ 这一年会落在哪几天(给日历用) ---------------- /** diff --git a/app/Calendar.php b/app/Calendar.php index 4b86f05..a24bb49 100644 --- a/app/Calendar.php +++ b/app/Calendar.php @@ -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 本子 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), diff --git a/app/Geo.php b/app/Geo.php new file mode 100644 index 0000000..82eb9a7 --- /dev/null +++ b/app/Geo.php @@ -0,0 +1,334 @@ + + */ + 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 = [ + '新疆维吾尔自治区', '广西壮族自治区', '宁夏回族自治区', '内蒙古自治区', + '西藏自治区', '香港特别行政区', '澳门特别行政区', + '黑龙江省', '内蒙古', '新疆', '广西', '宁夏', '西藏', + '北京市', '天津市', '上海市', '重庆市', + '河北省', '山西省', '辽宁省', '吉林省', '江苏省', '浙江省', '安徽省', + '福建省', '江西省', '山东省', '河南省', '湖北省', '湖南省', '广东省', + '海南省', '四川省', '贵州省', '云南省', '陕西省', '甘肃省', '青海省', + '台湾省', + ]; +} diff --git a/app/Home.php b/app/Home.php index 09a3a00..9805a09 100644 --- a/app/Home.php +++ b/app/Home.php @@ -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 !== '') { diff --git a/app/Ledger.php b/app/Ledger.php index 335b218..f06b414 100644 --- a/app/Ledger.php +++ b/app/Ledger.php @@ -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 至少一条(今天);空账本返回 [] + */ + 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 + */ + 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 + */ + 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; + } } \ No newline at end of file diff --git a/app/Person.php b/app/Person.php index 86b47c0..7c16df9 100644 --- a/app/Person.php +++ b/app/Person.php @@ -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; } diff --git a/app/PersonQuery.php b/app/PersonQuery.php new file mode 100644 index 0000000..177cc4c --- /dev/null +++ b/app/PersonQuery.php @@ -0,0 +1,143 @@ +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 { diff --git a/app/Schema.php b/app/Schema.php index 4f7f960..402c5dd 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -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) { diff --git a/app/Task.php b/app/Task.php index b3c4cfa..a30a758 100644 --- a/app/Task.php +++ b/app/Task.php @@ -233,11 +233,72 @@ final class Task // ---------------- 读 ---------------- + /** + * 我能看到的任务 = 我建的 + 分给我的 + 共享给我的。 + * + * ⚠️ v24(用户 2026-10-03 第 7 条):**看板的列 = 用户自己建的看板**, + * 不再是 todo / doing 两列。原话:「任务模块跟我想的不同,每个人自定义的看板, + * 不是显示在现在的看板上面,而是**直接代替现在的待办和进行中**,是看板, + * 而不是看板的分类。」 + * 所以 `status` 仍然存着 todo/doing(拖动换栏 = 改 status), + * 但**界面上的列**是 task_boards 里的那些行 —— 一列一个看板。 + * 这一层只负责"把任务按看板分堆",列名/顺序由 boards() 给。 + * + * 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。 + * + * @return 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 已保证), diff --git a/app/bootstrap.php b/app/bootstrap.php index f1d1c5c..fc2785e 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -109,3 +109,7 @@ if (session_status() !== PHP_SESSION_ACTIVE) { } require FB_APP . '/helpers.php'; +// 全局函数库(不是类,不吃自动加载):折线图等纯渲染工具。 +// ⚠️ 与 helpers.php 一样在**每个请求**都加载,所以里面只能放函数定义, +// 不要放查询/副作用(那会让每个请求都多跑一次)。 +require FB_APP . '/chart.php'; diff --git a/app/chart.php b/app/chart.php new file mode 100644 index 0000000..5f7f619 --- /dev/null +++ b/app/chart.php @@ -0,0 +1,134 @@ +'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 = ''; + // 基线(0 或最低值那一条)—— 让图有个水平参照 + $out .= ''; + if ($areaStr !== '') { + $out .= ''; + } + if ($n === 1) { + $out .= ''; + } else { + $out .= ''; + // 每个盘点点一个小圆点:有几处转折要看得出来 + foreach ($xy as $p) { + $out .= ''; + } + } + 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 .= '' + . e(\App\Ledger::money($first['v'])) . ''; + if ($n > 1) { + $out .= '' . e(\App\Ledger::money($last['v'])) . ''; + } + } + // 日期:首尾各一个(中间的点太密,标了反而糊) + $out .= '' + . e((string)$pts[0]['d']) . ''; + if ($n > 1) { + $out .= '' . e((string)$pts[$n - 1]['d']) . ''; + } + $out .= ''; + return $out; +} \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index e06be6d..87681d4 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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; } +/* 盘点面板:与「更正」并列在一个
里,中间给一条分隔线才分得清两件事 */ +.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; } diff --git a/assets/js/filter.js b/assets/js/filter.js new file mode 100644 index 0000000..5ee67a4 --- /dev/null +++ b/assets/js/filter.js @@ -0,0 +1,151 @@ +/** + * 输入即筛选(v24 用户 2026-10-03 第 2 条) + * + * 用户原话:「所有人员检索的地方,输入汉字或者首字母,自动筛选,而不需要按回车。」 + * + * 这个模块只做**前端显隐**:数据已经全在页面上了,输入就立刻筛,不发请求。 + * 为什么不做成"输入 → AJAX 重查": + * · 名册 / 挑人 / 选人弹窗都是几十条的小列表,整份渲染出来的成本可以忽略; + * · 一发请求就要处理"响应回来时输入框已经变了""光标跳到末尾"这些竞态, + * 而这个功能的价值恰恰是"打字的时候列表在动" —— 有一帧延迟就不对了; + * · 没 JS 时页面上的「搜索」按钮/form 提交照旧能用(渐进增强,不是二选一)。 + * + * 三种用法(都是声明式,靠 data-* 挂上去,不用改 JS): + * + * ① 列表行: … 行上带 data-search="姓名 称呼 邮箱 首字母" + * 行被筛掉 = hidden 属性(⚠️ CSS 里必须给 [hidden] 写 display:none, + * 作者样式里的 display:flex 会盖过浏览器默认的 [hidden] —— 项目里踩过两次) + * + * ② 下拉选项: → 把不匹配的
+ + count($rows)): ?> +
+ + 显示全部 条 + + 只按「」取了前 条 —— 找不到就直接搜。 +
+ diff --git a/templates/settings.php b/templates/settings.php index a8f75a0..680f690 100644 --- a/templates/settings.php +++ b/templates/settings.php @@ -25,29 +25,10 @@ 没写的人才用这里的这句。所以这是一句"兜底",不是全家的统一口号。 - -
- - - 只用于显示,随便写。 -
-
- - -
-
- - - - 三个都留空就不显示天气(首页不会因此出错 —— 不猜城市,猜错比不显示更糟)。 - 坐标可以在地图网站上右键查。 - -
+
diff --git a/templates/settings/me.php b/templates/settings/me.php index 7827dbe..cd920c3 100644 --- a/templates/settings/me.php +++ b/templates/settings/me.php @@ -46,6 +46,71 @@ $rosterCount = $rosterCount ?? 0;
+ + +
+

+ 天气 · 我所在的城市 + 只影响你自己首页上的那一格 +

+

+ 填个城市名就行(如「青岛」)。首页那句问候旁边会显示这儿的天气 —— + 每人一份,孩子在外地读书,他填他的、你看你的。 +
留空就是不显示天气(不影响别的东西)。 +

+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + +

+ 现在按 + ( , )取天气。 + + 不是这里?换个写法试试,比如加上省份:「山东青岛」。 + +

+ +

+ 这个城市名没认出坐标(首页就看不到天气了)。 + 换个写法试试(如「山东青岛」),或者直接填经纬度。 +

+ +
+ + +

+ 内置了 个城市(全国地级市 + 直辖市),一般常用的都在里面。 +

+

diff --git a/templates/tasks/index.php b/templates/tasks/index.php index 747a895..0f73eba 100644 --- a/templates/tasks/index.php +++ b/templates/tasks/index.php @@ -1,27 +1,52 @@ [], '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 ?? []; +$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; ?>

任务

- 共 项未完成 + + 项在做 1 ? ',' . count($boards) . ' 个看板' : '' ?> +
@@ -32,15 +57,6 @@ $curBoard = (int)($curBoard ?? 0);
- 1): ?> -
- - - -
- - +
@@ -118,29 +133,29 @@ $curBoard = (int)($curBoard ?? 0);

-
- +
-
- -
+
+ $col): $cid = (int)$col['id']; ?> +
- - + +
-
- +
+
@@ -166,10 +181,10 @@ $curBoard = (int)($curBoard ?? 0);
- 0): ?> + 0): ?>
- - / + + /
@@ -182,11 +197,11 @@ $curBoard = (int)($curBoard ?? 0); - + 0): ?> - + @@ -195,7 +210,7 @@ $curBoard = (int)($curBoard ?? 0);
- +

这一列是空的

@@ -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,9 +306,9 @@ $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') : ''); }); }); })(); - + \ No newline at end of file