Files
FamilyBook/templates/resources/index.php
T
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

267 lines
13 KiB
PHP
Raw 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):挂在某人名下的附属信息(证件、照片、账号…)。
*
* 两条交互上的讲究:
* ① **默认折叠**:一屏几十条正文会很长,列表只露标题行(原生 <details>,
* 没 JS 也能开合),点开才看正文与附件。
* ② **归属于谁**:每条都挂在一个具体的人名下(不是"家庭共享"),
* 所以列表上一定看得到"这是谁的",新建时必须选归属人。
*
* @param array $rows 我能看到的资源(带 perm / owner_nick)
* @param array $people 名册(新建时选归属人、共享给别人)
* @param array $attachs resource_id => 附件列表
* @param array $shares resource_id => 共享列表
*/
$isEditing = !empty($editing);
$canWrite = \App\Auth::canWrite();
?>
<div class="page-head">
<h1>资源</h1>
<span class="sub">共 <?= count($rows) ?> 条</span>
<div class="actions">
<?php if ($canWrite): ?>
<button class="btn" type="button" id="resAddBtn"><?= $isEditing ? '收起' : '新建资源' ?></button>
<?php else: ?>
<span class="link-quiet">只读账号</span>
<?php endif; ?>
</div>
</div>
<?php /* v24(用户 2026-10-02 第 18 条):**新建资源页**去掉「搜索资源」——
资源本来就不多,搜索框反而把「新建」按钮挤下去了。
⚠️ 但 2026-10-03 第 6 / 10 条把搜索**请回列表页**了,而且是"上来就是搜索":
资源会越攒越多(第 10 条原话:「可能会非常多」),没有搜索就只能滚动。
两件事不矛盾:新建那块仍然干净,搜索长在**列表**上。
区别在于现在是页面顶部的**第一块**,不是夹在新建表单里的一个输入框。 */ ?>
<div class="card card-pad res-search-card">
<form class="search res-search" method="get" action="index.php">
<input type="hidden" name="r" value="resources">
<input type="hidden" name="sort" value="<?= e($sort) ?>">
<label class="sr-only" for="resKw">搜索资源</label>
<input class="input" id="resKw" name="keyword" value="<?= e($keyword) ?>"
placeholder="搜标题 / 正文,如「身份证」" autocomplete="off">
<button class="btn btn-ghost btn-sm" type="submit">搜索</button>
<?php if ($keyword !== ''): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('resources')) ?>">清除</a>
<?php endif; ?>
</form>
<?php /* 排序:最新新增 / 最近改动。用链接而不是下拉,一眼看出当前按什么排的。 */ ?>
<div class="res-sortbar">
<span class="small muted">排序</span>
<span class="seg">
<a class="seg-btn<?= $sort === 'used' ? ' is-on' : '' ?>"
href="<?= e(url('resources', ['sort' => 'used', 'keyword' => $keyword])) ?>">最近改动</a>
<a class="seg-btn<?= $sort === 'new' ? ' is-on' : '' ?>"
href="<?= e(url('resources', ['sort' => 'new', 'keyword' => $keyword])) ?>">最新新增</a>
</span>
<?php if (!$showAll && $totalAll > count($rows)): ?>
<a class="link-quiet res-showall" href="<?= e(url('resources', ['sort' => $sort, 'keyword' => $keyword, 'all' => 1])) ?>">
显示全部 <?= (int)$totalAll ?> 条
</a>
<?php endif; ?>
</div>
</div>
<?php if ($canWrite): ?>
<div class="card card-pad" id="resAddCard" <?= $isEditing ? '' : 'hidden' ?>>
<h2 class="cinema-h2"><?= $isEditing ? '编辑资源' : '新建资源' ?></h2>
<form method="post" enctype="multipart/form-data"
action="<?= e(url($isEditing ? 'resources/update' : 'resources/create')) ?>">
<?= csrf_field() ?>
<?php if ($isEditing): ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label>归属于谁 *</label>
<?php /* v24 第 11 条:与全局选人组件统一(原来是下拉,名册一长就难找) */ ?>
<?php
$ppName = 'owner_id';
$ppPeople = $people;
$ppSelected = [(int)($editing['owner_id'] ?? (int)\App\Auth::personId())];
$ppMode = 'single';
$ppOpenBtn = '选择归属人';
$ppTitle = '资源归属于谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field">
<label for="rs-title">标题 *</label>
<input class="input" id="rs-title" name="title" required
value="<?= e($editing['title'] ?? '') ?>" placeholder="如:身份证 / 学位证">
</div>
<div class="field full">
<label for="rs-content">正文</label>
<textarea class="input" id="rs-content" name="content" rows="3"
placeholder="编号、有效期、放在哪里…"><?= e($editing['content'] ?? '') ?></textarea>
</div>
<div class="field full">
<label for="rs-file">附件(可多选)</label>
<input class="input" type="file" id="rs-file" name="attach[]" multiple>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEditing ? '保存' : '新建' ?></button>
<?php if ($isEditing): ?>
<a class="btn btn-ghost" href="<?= e(url('resources')) ?>">取消</a>
<?php endif; ?>
</div>
</form>
</div>
<?php endif; ?>
<div class="card card-pad">
<h2 class="cinema-h2">
资源列表
<?php
// ⚠️ 提示语要说清"为什么只有这些":
// · 搜索时只有几条是**因为搜索**(不是被截断)→ 别在这儿提"只显示最新 10 条",
// 否则用户会以为"搜出来的结果又被砍了";
// · 没搜索又确实多于 10 条,才提示截断,并给出"显示全部"。
?>
<?php if ($keyword !== ''): ?>
<span class="hint">匹配「<?= e($keyword) ?>」的 <?= count($rows) ?> 条</span>
<?php elseif (!$showAll && $totalAll > count($rows)): ?>
<span class="hint">只显示最新的 <?= (int)$limit ?> 条(共 <?= (int)$totalAll ?> 条)</span>
<?php else: ?>
<span class="hint">共 <?= count($rows) ?> 条</span>
<?php endif; ?>
</h2>
<?php if (!$rows): ?>
<p class="empty-hint">
<?= $keyword !== ''
? '没有匹配「' . e($keyword) . '」的资源,换个词试试。'
: '还没有资源。证件、照片、账号这些"挂在某人名下的东西"都可以记在这里。' ?>
</p>
<?php else: ?>
<div class="res-list">
<?php foreach ($rows as $r): ?>
<?php $rid = (int)$r['id']; $as = $attachs[$rid] ?? []; $sh = $shares[$rid] ?? []; ?>
<details class="res-item">
<summary class="res-head">
<span class="res-title"><?= e((string)$r['title']) ?></span>
<?php /* 归属人标签:查询里"人"的字段没加前缀(owner_name / avatar / avatar_color),
只有昵称带 owner_ 前缀 —— 在这里拼成 person_chip 认的形状。 */ ?>
<?= person_chip([
'id' => (int)$r['owner_id'], 'name' => (string)$r['owner_name'],
'my_nickname' => (string)($r['owner_nick'] ?? ''),
'avatar' => $r['avatar'] ?? null, 'avatar_color' => $r['avatar_color'] ?? null,
]) ?>
<span class="node-badge"><?= e(\App\Resource::permWord((string)$r['perm'])) ?></span>
<?php if ($as): ?><span class="res-n"><?= count($as) ?> 个附件</span><?php endif; ?>
</summary>
<div class="res-body">
<?php if (!empty($r['content'])): ?>
<p class="info-text"><?= nl2br(e((string)$r['content'])) ?></p>
<?php else: ?>
<p class="empty-hint">(没有正文)</p>
<?php endif; ?>
<?php
$attRows = $as;
$attCanEdit = $canWrite && \App\Resource::canWrite($r, (int)\App\Auth::personId());
$attDelUrl = 'resources/attach_del';
$attDelField = 'id';
$attExtra = [];
require __DIR__ . '/../_attachments.php';
?>
<?php if ($sh): ?>
<div class="res-shares">
<span class="res-label">共享给</span>
<?php foreach ($sh as $s): ?>
<?= person_chip($s) ?>
<span class="node-badge"><?= ($s['perm'] ?? '') === 'write' ? '可读写' : '只读' ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canWrite && \App\Resource::canWrite($r, (int)\App\Auth::personId())): ?>
<div class="res-ops">
<?php /* 补传附件 / 共享设置 / 编辑 / 删除 —— 每个都是独立的小表单,
不套在别的表单里(<form> 嵌套会让浏览器把外层切开)。 */ ?>
<form class="inline-form" method="post" enctype="multipart/form-data"
action="<?= e(url('resources/attach_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $rid ?>">
<input type="file" name="attach[]" multiple required>
<button class="btn btn-ghost btn-sm" type="submit">补传附件</button>
</form>
<details class="res-share-add">
<summary class="link-quiet">共享设置</summary>
<form class="inline-form" method="post" action="<?= e(url('resources/share')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $rid ?>">
<?php
$ppName = 'person_id';
$ppPeople = array_values(array_filter($people,
fn($p) => (int)$p['id'] !== (int)$r['owner_id']));
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<select class="select" name="perm">
<option value="read">只读</option>
<option value="write">可读写</option>
</select>
<button class="btn btn-sm" type="submit">共享</button>
</form>
<?php foreach ($sh as $s): ?>
<form class="inline-form" method="post" action="<?= e(url('resources/unshare')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $rid ?>">
<input type="hidden" name="person_id" value="<?= (int)$s['person_id'] ?>">
<span class="cal-share-name"><?= e(person_name($s)) ?></span>
<button class="btn btn-ghost btn-sm" type="submit">取消共享</button>
</form>
<?php endforeach; ?>
</details>
<a class="btn btn-ghost btn-sm" href="<?= e(url('resources', ['edit' => $rid])) ?>">编辑</a>
<form method="post" action="<?= e(url('resources/destroy')) ?>"
onsubmit="return confirm('删除「<?= e((string)$r['title']) ?>」?附件会一起删掉。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $rid ?>">
<button class="btn btn-danger btn-sm" type="submit">删除</button>
</form>
</div>
<?php endif; ?>
</div>
</details>
<?php endforeach; ?>
</div>
<?php /* 用户第 10 条:截断的地方要留一个明确的出口,别让人以为"就只有这 10 条"。 */ ?>
<?php if (!$showAll && $totalAll > count($rows)): ?>
<div class="res-more">
<a class="btn btn-ghost btn-sm"
href="<?= e(url('resources', ['sort' => $sort, 'keyword' => $keyword, 'all' => 1])) ?>">
显示全部 <?= (int)$totalAll ?> 条
</a>
<span class="small muted">只按「<?= $sort === 'new' ? '最新新增' : '最近改动' ?>」取了前 <?= (int)$limit ?> 条 —— 找不到就直接搜。</span>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<script>
(function () {
var btn = document.getElementById('resAddBtn');
var card = document.getElementById('resAddCard');
if (!btn || !card) return;
btn.addEventListener('click', function () {
card.hidden = !card.hidden;
btn.textContent = card.hidden ? '新建资源' : '收起';
if (!card.hidden) { card.scrollIntoView({ block: 'nearest' }); }
});
})();
</script>
<?php endif; ?>