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:
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------- ③ 这一年会落在哪几天(给日历用) ----------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
+37
-46
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 已保证),
|
||||
|
||||
@@ -109,3 +109,7 @@ if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
}
|
||||
|
||||
require FB_APP . '/helpers.php';
|
||||
// 全局函数库(不是类,不吃自动加载):折线图等纯渲染工具。
|
||||
// ⚠️ 与 helpers.php 一样在**每个请求**都加载,所以里面只能放函数定义,
|
||||
// 不要放查询/副作用(那会让每个请求都多跑一次)。
|
||||
require FB_APP . '/chart.php';
|
||||
|
||||
+134
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user