v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)

用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:

- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
     弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
     REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
     按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
     修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
     压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
     以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
     (实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
     金额一律以「分」存,不用浮点。

验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
This commit is contained in:
2026-10-03 08:18:05 +08:00
parent 72dc6c0006
commit 8f80f3e835
47 changed files with 5363 additions and 578 deletions
+288
View File
@@ -0,0 +1,288 @@
<?php
/**
* 财务 / 账本(v24 用户第 15 条)
*
* 用户原话:「先创建一个家庭,或者说,创建一个账本,这个账本,可以共享给某个人,
* 共享后,这个人就可以往这个账本加入账目,这样,双方都可以看到所有的账目,
* 最后计算出总数,这就是一个家庭的总账目。」
*
* 页面结构就是照着这句话来的:
* 顶上一排账本(切换 / 新建)→ 汇总卡(资产 / 负债 / 净资产)→ 账目表 → 共享设置。
*
* ⚠️ 金额全程以**分**参与计算(Ledger 里做的),这里只负责显示成元。
* 任何地方都不要拿元去加总。
*
* @param array $books 我能看到的账本(带 my_perm / entry_count)
* @param array $cur 当前账本
* @param array $entries 当前账本的账目
* @param array $summary ['asset','debt','net','by_kind','count'](单位:分)
* @param array $shares 当前账本的共享列表
* @param array $people 名册
*/
$canWrite = \App\Auth::canWrite() && \App\Ledger::canWrite($cur);
$curId = (int)$cur['id'];
$money = static fn($c): string => \App\Ledger::money($c);
?>
<div class="page-head">
<h1>财务</h1>
<span class="sub">家庭的账 · 共享后双方都能记、都能看</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('ledger', ['book' => $curId])) ?>">刷新</a>
</div>
</div>
<?php if (count($books) > 1): ?>
<div class="seg">
<?php foreach ($books as $b): ?>
<a class="seg-btn<?= (int)$b['id'] === $curId ? ' is-on' : '' ?>"
href="<?= e(url('ledger', ['book' => (int)$b['id']])) ?>">
<?= e((string)$b['name']) ?><?= ($b['my_perm'] ?? '') === 'read' ? '(只读)' : '' ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="card card-pad led-sum">
<div class="led-sum-head">
<h2 class="cinema-h2" style="margin:0"><?= e((string)$cur['name']) ?></h2>
<span class="small muted">
<?= (int)($cur['entry_count'] ?? count($entries)) ?> 笔账
<?php if (($cur['my_perm'] ?? '') === 'read'): ?>
· <b>只读</b>(不能记账)
<?php endif; ?>
</span>
</div>
<div class="led-total">
<div class="led-total-item">
<span class="led-total-lb">总资产</span>
<span class="led-total-num led-asset"><?= e($money($summary['asset'])) ?></span>
</div>
<div class="led-total-item">
<span class="led-total-lb">总负债</span>
<span class="led-total-num led-debt"><?= e($money($summary['debt'])) ?></span>
</div>
<div class="led-total-item led-net">
<span class="led-total-lb">净资产</span>
<span class="led-total-num"><?= e($money($summary['net'])) ?></span>
</div>
</div>
<?php if ($summary['by_kind']): ?>
<div class="led-kinds">
<?php foreach ($summary['by_kind'] as $k => $c): ?>
<span class="led-kind-chip<?= \App\Ledger::isDebt((string)$k) ? ' is-debt' : '' ?>">
<?= \App\Ledger::kindIcon((string)$k) ?> <?= e(\App\Ledger::kindWord((string)$k)) ?>
<b><?= e($money($c)) ?></b>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<div class="card card-pad">
<h2 class="cinema-h2">记一笔</h2>
<form method="post" action="<?= e(url('ledger/entry_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<div class="form-grid">
<div class="field">
<label for="le-kind">类别 *</label>
<select class="select" id="le-kind" name="kind">
<?php foreach (\App\Ledger::KINDS as $k => $v): ?>
<option value="<?= e($k) ?>"><?= e($v[2] . ' ' . $v[0]) ?><?= $v[1] === 'debt' ? '(负债)' : '' ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="le-title">名称 *</label>
<input class="input" id="le-title" name="title" required placeholder="如:工商银行定期">
</div>
<div class="field">
<label for="le-amount">金额(元)*</label>
<input class="input" id="le-amount" name="amount" required inputmode="decimal"
placeholder="如:120000.00">
<span class="tip">负债(贷款/信用卡)也填正数,系统按类别算成负债。</span>
</div>
<div class="field">
<label for="le-person">挂在谁名下</label>
<select class="select" id="le-person" name="person_id">
<option value="0">不指定</option>
<?php foreach ($people as $p): ?>
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field full">
<label for="le-note">备注</label>
<input class="input" id="le-note" name="note" placeholder="可选">
</div>
</div>
<div class="btn-row"><button class="btn" type="submit">添加账目</button></div>
</form>
</div>
<?php endif; ?>
<div class="card card-pad">
<h2 class="cinema-h2">账目(<?= count($entries) ?>)</h2>
<?php if (!$entries): ?>
<p class="empty-hint">这本账还没有账目。上面「记一笔」开始记吧。</p>
<?php else: ?>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>类别</th><th>名称</th><th class="num">金额</th><th>挂在谁名下</th><th>备注</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($entries as $en): ?>
<?php $ek = (string)$en['kind']; $debt = \App\Ledger::isDebt($ek); ?>
<tr>
<td class="nowrap">
<span class="led-kind-chip<?= $debt ? ' is-debt' : '' ?>">
<?= \App\Ledger::kindIcon($ek) ?> <?= e(\App\Ledger::kindWord($ek)) ?>
</span>
</td>
<td><?= e((string)$en['title']) ?></td>
<td class="num<?= $debt ? ' led-debt' : ' led-asset' ?>">
<?= $debt ? '-' : '' ?><?= e($money((int)$en['amount_cents'])) ?>
</td>
<td class="nowrap">
<?php if (!empty($en['person_name'])): ?>
<?= person_chip([
'id' => (int)$en['person_id'], 'name' => (string)$en['person_name'],
'my_nickname' => (string)($en['person_nick'] ?? ''),
'avatar' => $en['avatar'] ?? null, 'avatar_color' => $en['avatar_color'] ?? null,
]) ?>
<?php else: ?><span class="small muted">—</span><?php endif; ?>
</td>
<td class="small muted"><?= e((string)($en['note'] ?? '')) ?></td>
<td class="nowrap">
<?php if ($canWrite): ?>
<details class="led-edit">
<summary class="link-quiet">改</summary>
<form method="post" action="<?= e(url('ledger/entry_update')) ?>" class="node-form">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<input type="hidden" name="id" value="<?= (int)$en['id'] ?>">
<div class="nf-grid">
<div class="field">
<label>类别</label>
<select class="select" name="kind">
<?php foreach (\App\Ledger::KINDS as $k => $v): ?>
<option value="<?= e($k) ?>" <?= $ek === $k ? 'selected' : '' ?>>
<?= e($v[0]) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>名称</label>
<input class="input" name="title" value="<?= e((string)$en['title']) ?>" required>
</div>
<div class="field">
<label>金额(元)</label>
<input class="input" name="amount"
value="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>" required>
</div>
<div class="field">
<label>备注</label>
<input class="input" name="note" value="<?= e((string)($en['note'] ?? '')) ?>">
</div>
</div>
<div class="btn-row"><button class="btn btn-sm" type="submit">保存</button></div>
</form>
<form method="post" action="<?= e(url('ledger/entry_del')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<input type="hidden" name="id" value="<?= (int)$en['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这笔账目?')">删除</button>
</form>
</details>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<th colspan="2">合计(资产 − 负债)</th>
<th class="num led-net"><?= e($money($summary['net'])) ?></th>
<th colspan="3"></th>
</tr>
</tfoot>
</table>
</div>
<?php endif; ?>
</div>
<div class="card card-pad">
<h2 class="cinema-h2">共享与账本</h2>
<div class="led-shares">
<span class="res-label">已共享给</span>
<?php if (!$shares): ?>
<span class="small muted">还没有共享给任何人。</span>
<?php endif; ?>
<?php foreach ($shares as $s): ?>
<span class="led-share-chip">
<?= person_chip([
'id' => (int)$s['person_id'], 'name' => (string)$s['name'],
'avatar' => $s['avatar'] ?? null, 'avatar_color' => $s['avatar_color'] ?? null,
]) ?>
<span class="node-badge"><?= ($s['perm'] ?? 'read') === 'write' ? '可写' : '只读' ?></span>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('ledger/unshare')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<input type="hidden" name="person_id" value="<?= (int)$s['person_id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">取消</button>
</form>
<?php endif; ?>
</span>
<?php endforeach; ?>
</div>
<?php if ($canWrite): ?>
<div class="led-ops">
<?php
$ppName = 'person_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择共享给谁';
$ppTitle = '账本共享给谁';
$ppEmptyText = '未选择';
?>
<form class="inline-form" method="post" action="<?= e(url('ledger/share')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<?php require __DIR__ . '/../_person_picker.php'; ?>
<select class="select" name="perm">
<?php foreach (\App\Ledger::PERM as $pk => $pv): ?>
<option value="<?= e($pk) ?>"><?= e($pv) ?></option>
<?php endforeach; ?>
</select>
<button class="btn btn-sm" type="submit">共享</button>
</form>
<form class="inline-form" method="post" action="<?= e(url('ledger/book_rename')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<input class="input" name="name" value="<?= e((string)$cur['name']) ?>" required>
<button class="btn btn-ghost btn-sm" type="submit">账本改名</button>
</form>
<form class="inline-form" method="post" action="<?= e(url('ledger/book_add')) ?>">
<?= csrf_field() ?>
<input class="input" name="name" placeholder="新账本名,如:孩子的教育金" required>
<button class="btn btn-ghost btn-sm" type="submit">新建账本</button>
</form>
<form class="inline-form" method="post" action="<?= e(url('ledger/book_del')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这本账?里面的账目会一起删掉。')">删除这本账</button>
</form>
</div>
<?php endif; ?>
</div>