Files
laoyang e0562b8627 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 无源码泄漏、错误日志无新增。
2026-10-03 15:01:38 +08:00

269 lines
11 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 资源(v12,用户 2026-10-01 第 8 条):挂在**某个人名下**的附属信息。
*
* 形态:标题 + 一段文字 + 多个附件(证件扫描件、一寸照片、合同…)。
* 访问:名下的人自己当然能看能改;另外可以**共享给多人**,各设只读 / 读写。
*
* 两条关键设计:
* ① **共享表里不含 owner**(`resource_shares` 只记"额外被共享的人")——
* 不然同一个人在两张表里各有一条,权限要取并集,改一处忘一处就会
* "本人反而看不了自己的东西"。
* ② **默认折叠**:一屏列出几十条正文会很长,列表只露标题行,点开才看正文与附件。
* 折叠用原生 <details>,没有 JS 也能开合(app.js 不依赖它)。
*
* ⚠️ 与「名册」的分工:名册记录的是**这个人是谁**(生日、电话、关系);
* 资源记录的是**这个人名下有什么东西**(证书、账号、照片)。不要混。
*/
declare(strict_types=1);
namespace App;
final class Resource
{
/** 资源的宿主类型(附件表用) */
public const ATTACH = 'resource';
/**
* 列表页默认最多显示几条(用户 2026-10-03 第 10 条:「只显示最新新增或者修改过的 10 个」)。
* 超过就只露出这一截,下面给「显示全部 / 搜索」的出口。
*/
public const LIST_LIMIT = 10;
/**
* 我能看到的资源 = 我名下的 + 别人共享给我的。
*
* 返回的行里带 `perm`:'owner' / 'write' / 'read'(模板据此决定能不能改)。
*
* @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 = '',
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"
ELSE COALESCE((SELECT s.perm FROM resource_shares s
WHERE s.resource_id = r.id AND s.person_id = ?), "read")
END AS perm
FROM resources r
JOIN persons p ON p.id = r.owner_id
LEFT JOIN nicknames n ON n.target_id = r.owner_id AND n.viewer_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, $personId, $personId, $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);
}
// ⚠️ 排序字段必须走白名单:直接拼用户传的串就是注入。
$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
{
$st = Database::get()->prepare(
'SELECT r.*, p.name AS owner_name, p.avatar, p.avatar_color,
n.nickname AS owner_nick,
CASE WHEN r.owner_id = ? THEN "owner"
ELSE COALESCE((SELECT s.perm FROM resource_shares s
WHERE s.resource_id = r.id AND s.person_id = ?), "read")
END AS perm
FROM resources r
JOIN persons p ON p.id = r.owner_id
LEFT JOIN nicknames n ON n.target_id = r.owner_id AND n.viewer_id = ?
WHERE r.id = ? AND r.family_id = ?'
);
$st->execute([$personId, $personId, $personId, $id, $familyId]);
$r = $st->fetch();
if (!$r) {
return null;
}
// ⚠️ 权限在**这一层**判死:调用方拿到记录就一定是能看的。
// 曾经想让模板自己判,结果是每条列表都要重复一遍 CASE WHEN。
$mine = (int)$r['owner_id'] === $personId
|| self::isShared((int)$r['id'], $personId);
if (!$mine) {
return null;
}
return $r;
}
public static function canWrite(array $r, int $personId): bool
{
if ((int)($r['owner_id'] ?? 0) === $personId) {
return true;
}
return ($r['perm'] ?? '') === 'write';
}
public static function isShared(int $resourceId, int $personId): bool
{
$st = Database::get()->prepare(
'SELECT 1 FROM resource_shares WHERE resource_id = ? AND person_id = ?'
);
$st->execute([$resourceId, $personId]);
return $st->fetchColumn() !== false;
}
/** @return int 新资源 id */
public static function create(int $familyId, int $ownerId, ?int $createdBy, array $d): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO resources (family_id, owner_id, title, content, created_by) VALUES (?,?,?,?,?)'
)->execute([
$familyId, $ownerId,
self::title($d['title'] ?? ''),
self::textOrNull($d['content'] ?? ''),
$createdBy ?: null,
]);
$id = (int)$db->lastInsertId();
// v24(用户 2026-10-02 第 17 条):**我建的、挂在别人名下的资源,我自己也要看得见**。
// 以前只有 owner 能看(共享表里不含 owner),于是"我帮孩子录了一张证件"之后,
// 这条资源从我的列表里**消失了** —— 我刚建的东西自己却找不到,看着像数据丢了。
// 现在建的同时给自己补一条共享(可读写):既能看能改,也不会在"共享给谁"
// 的列表里冒出 owner 自己(owner 那栏由 perm='owner' 单独表示)。
if ($createdBy > 0 && $createdBy !== $ownerId) {
self::setShare($id, $ownerId, $createdBy, 'write');
}
return $id;
}
public static function update(int $familyId, int $id, array $d): void
{
Database::get()->prepare(
'UPDATE resources SET title = ?, content = ? WHERE id = ? AND family_id = ?'
)->execute([
self::title($d['title'] ?? ''),
self::textOrNull($d['content'] ?? ''),
$id, $familyId,
]);
}
public static function destroy(int $familyId, int $id): void
{
// 附件表没有外键(target_type 表达不了),删宿主时自己清存储对象
\App\Attachment::removeAll(self::ATTACH, $id);
Database::get()->prepare('DELETE FROM resources WHERE id = ? AND family_id = ?')
->execute([$id, $familyId]);
}
// ---------------- 共享 ----------------
public static function shares(int $resourceId): array
{
$st = Database::get()->prepare(
'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color
FROM resource_shares s JOIN persons p ON p.id = s.person_id
WHERE s.resource_id = ? ORDER BY p.id'
);
$st->execute([$resourceId]);
return $st->fetchAll();
}
/**
* 设共享。
*
* ⚠️ owner 自己**不进**共享表 —— 他本来就有全部权限,
* 记进去只是多一条要维护的行(还会让"共享给谁"的列表里冒出自己)。
*/
public static function setShare(int $resourceId, int $ownerId, int $personId, string $perm): void
{
if ($personId <= 0 || $personId === $ownerId) {
self::dropShare($resourceId, $personId);
return;
}
Database::get()->prepare(
'INSERT INTO resource_shares (resource_id, person_id, perm) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
)->execute([$resourceId, $personId, $perm === 'write' ? 'write' : 'read']);
}
public static function dropShare(int $resourceId, int $personId): void
{
Database::get()->prepare('DELETE FROM resource_shares WHERE resource_id = ? AND person_id = ?')
->execute([$resourceId, $personId]);
}
public static function permWord(string $perm): string
{
return $perm === 'owner' ? '本人' : ($perm === 'write' ? '可读写' : '只读');
}
// ---------------- 小工具 ----------------
private static function title(string $v): string
{
return mb_substr(trim($v), 0, 120, 'UTF-8');
}
private static function textOrNull(string $v): ?string
{
$v = trim($v);
return $v !== '' ? $v : null;
}
}