v14: 观影/家谱/日程/旅程/资源/任务六模块统一,首页改 dashboard

用户 14 条需求一次交付(1-13 功能 + 14 工作方式)。

观影(1-4)
- 返回文案全模块统一为「返回观影」(不再有「返回列表」「返回观影记录」三种叫法)
- 观影人一律用彩色人物标签 person_chip:底色由头像主色算出来(上传时存库),
  瀑布流卡片也按人显示;五处观影模板全部改用它
- 人员显示统一走 person_name()(shown → 我给他起的称呼 → 姓名)
- 影院管理页与年度报告页的上下两块用 .card + .card 统一给间距(原来紧贴成一整块)

家谱(5)
- 树根从「我」换成**辈分最高的祖先**:库画的是"树根的祖先链+后代+树根的兄弟姐妹",
  根=我 时伯父/堂兄/嫂子/侄子一个都不画(12 人夹具只画出 8 人)
- focusMap 按**代数**圈定三代(不再按步数),normalizeLinks 把子女对称挂到父母双方
  —— 修掉"母亲登录看不到孩子"
- 新增「看全部 / 回到我」两个按钮,放在画布外(画布整块被库接管了手势)

统一事件原语(6-9)
- 旅程:TripLog 全量迁入(3 趟旅程 / 21 段行程 / 72 张照片 / 5 个人)。
  复用 events(kind='trip') + trip_details + trip_nodes(计划/执行双轨保留)
- 日程:每人可建日历本(自定义色)+ 共享只读/可写;观影与旅行是每人默认两本,
  内容是**从各模块实时派生**的(不复制,所以改了观影日期日历立刻跟着变);
  没有具体时分 = 全天;月视图参照群晖 Calendar
- 资源:挂在某人名下的附属信息(标题+正文+多附件),可共享给多人并设只读/读写,
  默认折叠、可搜标题与正文
- 任务:看板三列 + 两级子任务 + 评论@人 + 共享 + 循环(完成时按原截止日推下一期)
- 统一附件表:四个模块的附件共用一张,下载地址不落库、一律由 media.php 现算

模块化(10-11)
- App\Module 是模块的唯一来源(加一行 = 导航条/主题色/个人设置三处自动出现)
- 个人设置里可勾选要显示哪些模块、拖动排序(关掉的模块**不会**被兜底补回来)
- 每个模块一套主题色(家庭级),只覆盖 --brand 三兄弟,不碰纸墨底色

其他(12-13)
- 新增成员表单可选所属分组(编辑表单没有:分组是"我和他的关系",只归我自己)
- 首页改 dashboard:接下来的日子 / 未来七天 / 手头在做 / 往年今日。
  家人卡片从首页撤走(看人去名册)

验证(全部通过)
- e2e 542 条(新增 4 模块端到端 60+ 条)
- 桌面几何 368 / 移动端几何 520 / 家谱 12 / 交互 97 / 老库升级 123
- SQL 列名静态校验 0 处可疑(对着真实表结构)
- 反向验证 18 条新注入全部抓住

顺带修掉的既有问题
- 老库升级回填 SQL 按实际存在的列动态拼(缺列不再 1054 炸断升级)
- 全新安装时 Schema::ensure 跑在"人还没建"之前 → 补建日历本三处齐备
- 窄屏导航横向溢出(模块从 4 个加到 8 个后顶栏撑破视口)
This commit is contained in:
2026-10-02 09:28:07 +08:00
parent 7f00d3e553
commit 71a7ec35a0
42 changed files with 6770 additions and 201 deletions
+175
View File
@@ -0,0 +1,175 @@
<?php
/**
* 日历本管理(v12):建本 / 改名 / 换色 / 删除 / 共享给别人(只读或可写)。
*
* ⚠️ 「观影」「旅行」两本是系统预置的,不能改名也不能删 ——
* 它们的日程是从观影模块与旅程模块实时汇总出来的,删掉就再也回不来了
* (Calendar::delete 里也只删 kind='custom',这里是同一道栅栏的界面侧)。
*
* @param array $cals 我自己的日历本(kind 任意)
* @param array $shared 别人共享给我的
* @param array $people 名册(共享下拉用)
*/
$isEditing = !empty($editing);
$canWrite = \App\Auth::canWrite();
?>
<div class="page-head">
<h1>日历本</h1>
<span class="sub">共 <?= count($cals) ?> 本<?= $shared ? ',别人共享给我 ' . count($shared) . ' 本' : '' ?></span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('calendar')) ?>">返回日程</a>
</div>
</div>
<?php if (!$canWrite): ?>
<div class="card card-pad empty"><b>只读账号</b>访客只能查看日历本,不能新建或修改。</div>
<?php else: ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= $isEditing ? '编辑日历本' : '新建日历本' ?></h2>
<form method="post" action="<?= e(url($isEditing ? 'calendar/cal_update' : 'calendar/cal_add')) ?>">
<?= csrf_field() ?>
<?php if ($isEditing): ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label for="cb-name">名字 *</label>
<input class="input" id="cb-name" name="name" required
value="<?= e(old('name', $editing['name'] ?? '')) ?>" placeholder="如:体检 / 值日">
</div>
<div class="field">
<label for="cb-color">颜色</label>
<div class="color-row">
<input type="color" id="cb-color" name="color" class="color-picker"
value="<?= e(old('color', $editing['color'] ?? '#4A7C82')) ?>">
<span class="color-hex"><?= e(old('color', $editing['color'] ?? '#4A7C82')) ?></span>
<span class="preset-palette">
<?php foreach (\App\Calendar::PRESET_CAL_COLORS as $pc): ?>
<span class="preset-color" data-color="<?= $pc ?>" style="background: <?= $pc ?>;" title="<?= $pc ?>"></span>
<?php endforeach; ?>
</span>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEditing ? '保存' : '新建' ?></button>
<?php if ($isEditing): ?>
<a class="btn btn-ghost" href="<?= e(url('calendar/calendars')) ?>">取消</a>
<?php endif; ?>
</div>
</form>
</div>
<?php endif; ?>
<div class="card card-pad">
<h2 class="cinema-h2">我自己的</h2>
<?php if (!$cals): ?>
<p class="empty-hint">还没有日历本。</p>
<?php else: ?>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>名字</th><th>颜色</th><th>共享给</th><?php if ($canWrite): ?><th>操作</th><?php endif; ?></tr>
</thead>
<tbody>
<?php foreach ($cals as $c): ?>
<tr>
<td>
<b><?= e((string)$c['name']) ?></b>
<?php if (($c['kind'] ?? '') !== 'custom'): ?>
<span class="node-badge">系统预置</span>
<?php endif; ?>
</td>
<td>
<span class="color-preview" style="background: <?= e((string)$c['color']) ?>;"></span>
<span class="mono"><?= e((string)$c['color']) ?></span>
</td>
<td>
<?php $sh = $shares[(int)$c['id']] ?? []; ?>
<?php if (!$sh): ?>
<span class="link-quiet">不共享</span>
<?php else: ?>
<div class="trip-people">
<?php foreach ($sh as $s): ?>
<?= person_chip($s) ?>
<span class="node-badge"><?= ($s['perm'] ?? '') === 'write' ? '可写' : '只读' ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canWrite && $people): ?>
<details class="node-add">
<summary class="link-quiet">共享设置</summary>
<form class="inline-form" method="post" action="<?= e(url('calendar/cal_share')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<select class="select" name="person_id">
<?php foreach ($people as $p): ?>
<?php if ((int)$p['id'] === (int)\App\Auth::personId()) { continue; } ?>
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
<?php endforeach; ?>
</select>
<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('calendar/cal_unshare')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<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>
<?php endif; ?>
</td>
<?php if ($canWrite): ?>
<td>
<div class="actions">
<?php if (($c['kind'] ?? '') === 'custom'): ?>
<a class="btn btn-ghost btn-sm"
href="<?= e(url('calendar/calendars', ['edit' => (int)$c['id']])) ?>">编辑</a>
<form method="post" action="<?= e(url('calendar/cal_del')) ?>"
onsubmit="return confirm('删除「<?= e((string)$c['name']) ?>」?本子里的日程会一起删掉。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<button class="btn btn-danger btn-sm" type="submit">删除</button>
</form>
<?php else: ?>
<span class="link-quiet">不可改</span>
<?php endif; ?>
</div>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php if ($shared): ?>
<div class="card card-pad">
<h2 class="cinema-h2">别人共享给我的</h2>
<div class="table-wrap">
<table class="table">
<thead><tr><th>名字</th><th>谁的</th><th>我的权限</th></tr></thead>
<tbody>
<?php foreach ($shared as $c): ?>
<tr>
<td><b><?= e((string)$c['name']) ?></b></td>
<?php /* 共享过来的行里"人"的字段带着前缀(owner_name / owner_nick),
不是 person_name() 直接认的 name / my_nickname —— 在这里适配一次。 */ ?>
<td><?= e(person_name(['name' => $c['owner_name'] ?? '', 'my_nickname' => $c['owner_nick'] ?? ''])) ?></td>
<td><span class="node-badge"><?= ($c['perm'] ?? '') === 'write' ? '可写' : '只读' ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
+84
View File
@@ -0,0 +1,84 @@
<?php
/**
* 一条自定义日程:查看 / 改 / 删。
*
* ⚠️ 从月视图点**观影 / 旅程 / 任务**那些条会直接跳到对应模块(它们的"编辑页"在那里),
* 这里只处理 calendar_events 里真正属于某本日历的自定义日程。
*/
$canWrite = \App\Auth::canWrite() && !empty($ev['_can_write']);
?>
<div class="page-head">
<h1><?= e((string)$ev['title']) ?></h1>
<span class="sub"><?= e((string)($ev['starts_at'] ?? '')) ?></span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => substr((string)$ev['starts_at'], 0, 7)])) ?>">返回日程</a>
</div>
</div>
<div class="card card-pad">
<?php if (!$canWrite): ?>
<dl class="kv">
<div class="kv-row"><dt>日期</dt><dd><?= e((string)$ev['starts_at']) ?></dd></div>
<?php if (!empty($ev['ends_at'])): ?>
<div class="kv-row"><dt>到</dt><dd><?= e((string)$ev['ends_at']) ?></dd></div>
<?php endif; ?>
<div class="kv-row"><dt>时间</dt>
<dd><?= empty($ev['all_day']) ? e(substr((string)$ev['start_time'], 0, 5) . ' – ' . substr((string)$ev['end_time'], 0, 5)) : '全天' ?></dd>
</div>
<?php if (!empty($ev['note'])): ?>
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$ev['note'])) ?></dd></div>
<?php endif; ?>
</dl>
<p class="empty-hint">这本日历你只有只读权限,看得到但不能改。</p>
<?php else: ?>
<form method="post" action="<?= e(url('calendar/event_update')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<?php $ym = substr((string)$ev['starts_at'], 0, 7); ?>
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<div class="form-grid">
<div class="field full">
<label for="ev-title">标题 *</label>
<input class="input" id="ev-title" name="title" required value="<?= e((string)$ev['title']) ?>">
</div>
<div class="field">
<label for="ev-date">开始日期 *</label>
<input class="input" type="date" id="ev-date" name="date" required
value="<?= e((string)$ev['starts_at']) ?>">
</div>
<div class="field">
<label for="ev-end">结束日期(跨天时填)</label>
<input class="input" type="date" id="ev-end" name="end_date" value="<?= e((string)($ev['ends_at'] ?? '')) ?>">
</div>
<div class="field full">
<label><input type="checkbox" name="all_day" value="1" <?= empty($ev['all_day']) ? '' : 'checked' ?>> 全天(不填具体时分)</label>
</div>
<div class="field">
<label for="ev-st">开始时刻</label>
<input class="input" type="time" id="ev-st" name="start_time" value="<?= e(substr((string)($ev['start_time'] ?? ''), 0, 5)) ?>">
</div>
<div class="field">
<label for="ev-et">结束时刻</label>
<input class="input" type="time" id="ev-et" name="end_time" value="<?= e(substr((string)($ev['end_time'] ?? ''), 0, 5)) ?>">
</div>
<div class="field full">
<label for="ev-note">备注</label>
<textarea class="input" id="ev-note" name="note" rows="3"><?= e((string)($ev['note'] ?? '')) ?></textarea>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit">保存</button>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $ym])) ?>">取消</a>
</div>
</form>
<?php /* ⚠️ 删除表单必须放在上面那个表单**外面**:<form> 套 <form> 是非法 HTML,
浏览器会把外层表单在嵌套处切开,"保存"按钮就失效了。 */ ?>
<form method="post" action="<?= e(url('calendar/event_del')) ?>" class="ev-del"
onsubmit="return confirm('删除这条日程?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<button class="btn btn-danger btn-sm" type="submit">删除这条日程</button>
</form>
<?php endif; ?>
</div>
+197
View File
@@ -0,0 +1,197 @@
<?php
/**
* 日程 · 月视图(v12)。
*
* 参照群晖 Calendar 的外观:左边一列"我的日历本"(可勾选显隐),右边 7×6 的月网格,
* 有日程的日子在格子里露出彩色小条,点小条跳到对应模块(观影/旅程/任务)。
*
* 两条设计:
* ① **日历本显隐是纯前端的**(勾选框 + 给日程条加 data-cal):
* 不做成"勾一次刷一次页面",否则翻月、勾选都在等服务端。没有 JS 时全部照常显示。
* ② **观影 / 旅行 / 任务不复制进日历**,是从各自模块实时汇总出来的(Calendar::month),
* 所以改了观影日期,日历里立刻跟着变。反过来在日历里给观影本加日程 = 真的建一条观影记录。
*
* @param string $ym 'Y-m'
* @param array $weeks 月网格(build_month_grid 产出)
* @param array $cals 我可见的日历本(含 perm)
* @param array $people 名册(共享给别人时用)
*/
$canWrite = \App\Auth::canWrite();
$ts = strtotime($ym . '-01') ?: time();
$prevYm = date('Y-m', strtotime('-1 month', $ts));
$nextYm = date('Y-m', strtotime('+1 month', $ts));
$thisYm = date('Y-m');
$writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::canWrite($c, (int)\App\Auth::personId())));
?>
<div class="page-head">
<h1>日程</h1>
<span class="sub"><?= e(date('Y 年 n 月', $ts)) ?></span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $prevYm])) ?>">上一月</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $thisYm])) ?>">今天</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $nextYm])) ?>">下一月</a>
<a class="btn btn-ghost" href="<?= e(url('calendar/calendars')) ?>">日历本</a>
<?php if ($canWrite && $writable): ?>
<button class="btn" type="button" id="calAddBtn">新建日程</button>
<?php endif; ?>
</div>
</div>
<div class="cal-wrap">
<?php /* ---- 左:日历本 ---- */ ?>
<aside class="cal-side">
<h2 class="cinema-h2">我的日历本</h2>
<?php if (!$cals): ?>
<p class="empty-hint">还没有日历本。</p>
<?php else: ?>
<ul class="cal-list">
<?php foreach ($cals as $c): ?>
<li class="cal-item">
<label class="cal-toggle">
<input type="checkbox" class="cal-cb" data-cal="<?= (int)$c['id'] ?>" checked>
<span class="color-preview" style="background: <?= e((string)$c['color']) ?>;"></span>
<span class="cal-name"><?= e((string)$c['name']) ?></span>
</label>
<?php if ((int)$c['shared'] === 1): ?>
<span class="node-badge"><?= ($c['perm'] ?? '') === 'write' ? '共享·可写' : '共享·只读' ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php /* 观影/旅行/任务三路是**自动汇总**的,没有本子开关,这里说明一下来源 */ ?>
<p class="cal-note">观影、旅行、有截止时间的任务会自动出现在这里,不需要另建本子。</p>
<?php endif; ?>
</aside>
<?php /* ---- 右:月网格 ---- */ ?>
<div class="cal-main">
<?php if ($canWrite && $writable): ?>
<div class="card card-pad cal-add" id="calAddCard" hidden>
<h2 class="cinema-h2">新建日程</h2>
<form method="post" action="<?= e(url('calendar/event_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<div class="form-grid">
<div class="field">
<label for="ce-cal">放在哪本</label>
<select class="select" id="ce-cal" name="calendar_id">
<?php foreach ($writable as $c): ?>
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="ce-title">标题 *</label>
<input class="input" id="ce-title" name="title" required placeholder="如:家长会">
</div>
<div class="field">
<label for="ce-date">日期 *</label>
<input class="input" type="date" id="ce-date" name="date" required
value="<?= e($ym === $thisYm ? date('Y-m-d') : $ym . '-01') ?>">
</div>
<div class="field">
<label for="ce-end">结束日期(跨天时填)</label>
<input class="input" type="date" id="ce-end" name="end_date">
</div>
<div class="field">
<label><input type="checkbox" id="ce-allday" name="all_day" value="1" checked> 全天(不填具体时分)</label>
</div>
<div class="field">
<label for="ce-note">备注</label>
<input class="input" id="ce-note" name="note">
</div>
<div class="field" id="ce-time-s">
<label for="ce-st">开始时刻</label>
<input class="input" type="time" id="ce-st" name="start_time">
</div>
<div class="field" id="ce-time-e">
<label for="ce-et">结束时刻</label>
<input class="input" type="time" id="ce-et" name="end_time">
</div>
</div>
<div class="btn-row">
<button class="btn btn-sm" type="submit">加上</button>
<button class="btn btn-ghost btn-sm" type="button" id="calAddCancel">取消</button>
</div>
</form>
</div>
<?php endif; ?>
<div class="card cal-grid-card">
<div class="cal-head">
<?php foreach (['日', '一', '二', '三', '四', '五', '六'] as $wd): ?>
<div class="cal-wd"><?= e($wd) ?></div>
<?php endforeach; ?>
</div>
<div class="cal-grid">
<?php foreach ($weeks as $wk): ?>
<?php foreach ($wk as $cell): ?>
<div class="cal-cell<?= $cell['in_month'] ? '' : ' is-out' ?><?= $cell['is_today'] ? ' is-today' : '' ?>">
<div class="cal-dnum"><?= (int)$cell['day'] ?></div>
<div class="cal-evs">
<?php $n = 0; foreach ($cell['events'] as $ev): $n++; ?>
<?php if ($n <= 3): ?>
<a class="cal-ev<?= empty($ev['all_day']) ? '' : ' is-allday' ?>"
data-cal="<?= (int)$ev['cal'] ?>"
style="background: <?= e((string)$ev['color']) ?>;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>">
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b> <?php endif; ?>
<?= e((string)$ev['title']) ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
<?php if (count($cell['events']) > 3): ?>
<span class="cal-more">+<?= count($cell['events']) - 3 ?> 条</span>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<script>
(function () {
/* 日历本显隐:只切 display,不重新请求 —— 翻月/勾选都是立刻的。
没有 JS 时全部照常显示(勾选框失效而已),不会丢内容。 */
var boxes = document.querySelectorAll('.cal-cb');
function apply() {
var off = {};
Array.prototype.forEach.call(boxes, function (b) {
if (!b.checked) { off[b.getAttribute('data-cal')] = true; }
});
Array.prototype.forEach.call(document.querySelectorAll('.cal-ev'), function (el) {
el.style.display = off[el.getAttribute('data-cal')] ? 'none' : '';
});
}
Array.prototype.forEach.call(boxes, function (b) { b.addEventListener('change', apply); });
apply();
var btn = document.getElementById('calAddBtn');
var card = document.getElementById('calAddCard');
var cancel = document.getElementById('calAddCancel');
if (btn && card) {
btn.addEventListener('click', function () {
card.hidden = !card.hidden;
if (!card.hidden) { card.scrollIntoView({ block: 'nearest' }); }
});
}
if (cancel && card) { cancel.addEventListener('click', function () { card.hidden = true; }); }
/* 「全天」勾上就把两个时刻输入框藏掉 —— 没有具体时分就是全天(用户第 7 条) */
var allday = document.getElementById('ce-allday');
var ts = document.getElementById('ce-time-s');
var te = document.getElementById('ce-time-e');
if (allday && ts && te) {
function syncTime() {
var hide = allday.checked;
ts.hidden = hide; te.hidden = hide;
}
allday.addEventListener('change', syncTime);
syncTime();
}
})();
</script>
+115 -90
View File
@@ -1,35 +1,22 @@
<?php
/**
* 首页 —— 以「我」为中心。
* 首页 —— 一块 dashboard(用户 2026-10-01 第 13 条)。
*
* 同一份数据,爸爸登进来看到的是「我的父亲/我的儿子」,
* 爱人登进来看到的是同一批人,称呼却变成「公公/婆婆」。
* 称呼全部由 Kinship 现算、由 Nickname 决定"我叫他什么",库里不存任何一次性称谓。
* 现在首页只回答三个问题:**最近有什么事**、**手头在做什么**、**往年这时候我们在干嘛**。
* · 接下来的日子(两个月内的生日)
* · 未来七天的日程(自定义 + 观影 + 旅行 + 有截止时间的任务,一次汇总)
* · 进行中的任务(含已过期的待办)
* · 往年今日(往年的这一天看过的电影 / 走过的旅程)
*
* ⚠️ 标题下的那句个人口号已按用户要求撤掉(v8):
* hero 区只留标题与三个统计,不再读 slogan / motto。
* ⚠️ **家人卡片已经不在首页了**(用户明确要求):看人去「名册」、看关系去「家谱」,
* 首页留给"今天该关心什么"。统计数字仍在,因为那是唯一的全局概览。
*
* ⚠️ 标题下的那句个人口号已按用户要求撤掉(v8):hero 区只留标题与三个统计。
*/
$kinCount = count($kin);
$hasRel = $relCount > 0;
// 显示名一律走「我怎么称呼他」:同一个孩子,爸爸看到"儿子"、妈妈看到"小宝"。
// ⚠️ 用**已经取好的那张表**(target_id => 我叫他什么)做本地查表,
// 不要再对每个人调一次 Nickname::display() —— 那是每人一条 SQL。
// 没有称呼的人回落到姓名,所以永远不会出现空白名字。
$shownOf = function (array $row) use ($myNicknames): string {
$nk = (string)($myNicknames[(int)($row['id'] ?? 0)] ?? '');
return $nk !== '' ? $nk : (string)($row['name'] ?? '');
};
// 有亲属关系 → 只显示家人;还没建 → 退回显示全名册,别让首页空着
$list = $hasRel ? $kin : [];
if (!$list) {
foreach (\App\Person::all((int)\App\Auth::familyId(), '', 'member', (int)$meId) as $p) {
$p['label'] = '';
$list[(int)$p['id']] = $p;
}
}
// 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出
$shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '农历生日'];
$today = date('Y-m-d');
?>
<section class="hero">
<img class="hero-logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
@@ -41,11 +28,26 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '
<div class="stat"><b><?= (int)$allCount ?></b><span>名册总数</span></div>
<div class="stat"><b><?= (int)$relCount ?></b><span>亲属连线</span></div>
</div>
<?php /* ⚠️ 这两个链接必须带上真实的 person id:曾经 layout.php 里写
`$me = Auth::check()` 把模板里的 $me 覆盖成布尔值,链接就变成 id=0
("补充亲属关系"点进去是空白页)。所以这里显式用 $meId。 */ ?>
<div class="hero-actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => (int)$meId])) ?>">我的资料</a>
<a class="btn btn-ghost btn-sm" href="<?= e(url('kin')) ?>">我的家谱</a>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people')) ?>">名册</a>
</div>
</section>
<?php if ($birthdays): ?>
<section class="section">
<?php /* ==================== 接下来的日子 ==================== */
/* ⚠️ 即使近两个月没人过生日,这一块也**照常渲染**(给空态),只是内容为空。
理由:dashboard 的四块是"每天要看的东西",某一块忽然整块消失会让人以为
功能坏了 / 数据丢了(旧版就是 `if ($birthdays)` 一省略了之)。
空态还能顺带告诉用户"系统在盯着生日,只是最近没有"。 */ ?>
<section class="section">
<h2 class="section-title">接下来的日子 <span class="hint">两个月内</span></h2>
<?php if (!$birthdays): ?>
<div class="card card-pad empty"><b>近两个月没有人过生日</b>等到了月份,这里会自动冒出来。</div>
<?php else: ?>
<div class="bdays">
<?php foreach ($birthdays as $b): ?>
<?php
@@ -85,79 +87,102 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<?php /* ==================== 日程(未来七天) ==================== */ ?>
<section class="section">
<div class="page-head" style="margin-bottom:12px">
<h1 style="font-size:15px"><?= $hasRel ? '我的家人' : '家庭成员' ?></h1>
<h1 style="font-size:15px">未来七天 <span class="sub">日程 · 观影 · 旅行 · 到期任务</span></h1>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('kin')) ?>">我的家谱</a>
<?php if ($me): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => (int)$me['id']])) ?>">补充亲属关系</a>
<?php endif; ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('calendar')) ?>">看整月</a>
</div>
</div>
<?php if (!$hasRel): ?>
<div class="empty">
<b>先把家人连起来</b>
目前还没有任何亲属关系,所以下面显示的是整个名册。<br>
到自己的资料页填上「谁是我的父母、配偶、子女」,之后这一页就只显示跟你有关的人,
每个人的称呼也会按你的视角来算。
<?php if ($me): ?>
<div class="btn-row" style="justify-content:center">
<a class="btn" href="<?= e(url('people/show', ['id' => (int)$me['id']])) ?>">开始连关系</a>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!$list): ?>
<div class="empty">
<b>名册还是空的</b>
先把家里人加进来,之后他们就能出现在每一条记录里。
<div class="btn-row" style="justify-content:center">
<a class="btn" href="<?= e(url('people/new')) ?>">添加成员</a>
</div>
</div>
<?php if (!$soon): ?>
<div class="card card-pad empty"><b>这七天还没有安排</b>有事先记一笔,日后再翻才有得看。</div>
<?php else: ?>
<div class="people-grid">
<?php foreach ($list as $p): ?>
<?php
// ⚠️ 这里必须用「我给他起的称呼」,不能直接用姓名:
// 同一个「杨父」,儿媳妇看到的是"公公大人"、儿子看到的是"爸"。
$pshow = $shownOf($p);
$pIsMe = (int)$p['id'] === (int)$meId;
?>
<a class="person" href="<?= e(url('people/show', ['id' => (int)$p['id']])) ?>">
<div class="ph"><img src="<?= e(avatar_url($p['avatar'] ?? '')) ?>" alt=""></div>
<div class="pb">
<div class="pname">
<?= e($pshow) ?>
<?php if ($pIsMe): ?><span class="badge badge-me">我</span><?php endif; ?>
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">待确认</span><?php endif; ?>
</div>
<div class="pmeta">
<?php if (!empty($p['label'])): ?>
<span class="rel-label"><?= e($p['label']) ?></span>
<div class="card card-pad">
<ul class="dash-list">
<?php foreach ($soon as $ev): ?>
<li class="dash-row">
<span class="dash-dot" style="background: <?= e((string)$ev['color']) ?>;"></span>
<span class="dash-when">
<?= e((string)$ev['date'] === $today ? '今天' : substr((string)$ev['date'], 5)) ?>
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b><?php endif; ?>
</span>
<a class="dash-what" href="<?= e((string)$ev['href']) ?>"><?= e((string)$ev['title']) ?></a>
<span class="dash-tag"><?= e((string)$ev['calname']) ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</section>
<?php /* ==================== 进行中的任务 ==================== */ ?>
<section class="section">
<div class="page-head" style="margin-bottom:12px">
<h1 style="font-size:15px">手头在做 <span class="sub">进行中 · 已逾期</span></h1>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('tasks')) ?>">任务看板</a>
</div>
</div>
<?php if (!$tasks): ?>
<div class="card card-pad empty"><b>没有进行中的任务</b>也没有逾期的事,挺好。</div>
<?php else: ?>
<div class="card card-pad">
<ul class="dash-list">
<?php foreach ($tasks as $t): ?>
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
<li class="dash-row">
<span class="dash-dot" style="background: <?= e($late ? 'var(--red)' : 'var(--brand)') ?>;"></span>
<span class="dash-when">
<?php if (($t['due_at'] ?? '') !== ''): ?>
<?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?>
<?php else: ?>
进行中
<?php endif; ?>
<?php
$role = $p['account_role'] ?? '';
if ($role === 'admin' && ($p['account_status'] ?? '') === 'active') {
echo '<span class="badge badge-admin">管理员</span>';
} elseif ($role === 'guest') {
echo '<span class="badge">访客</span>';
} elseif (!empty($p['account_id'])) {
echo '<span class="badge badge-open">已开通</span>';
}
$age = age_from(\App\Birthday::solarOf($p));
if ($age !== null) { echo ' · ' . (int)$age . ' 岁'; }
?>
</div>
</div>
</a>
<?php endforeach; ?>
</span>
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>">
<?= e((string)$t['title']) ?>
</a>
<?php if ((int)($t['child_total'] ?? 0) > 0): ?>
<span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</section>
<?php /* ==================== 往年今日 ==================== */ ?>
<section class="section">
<div class="page-head" style="margin-bottom:12px">
<h1 style="font-size:15px">
<?= !empty($memories['exact']) ? '往年今日' : '往年这个月' ?>
<span class="sub"><?= e(date('n 月 j 日')) ?></span>
</h1>
</div>
<?php if (empty($memories['rows'])): ?>
<div class="card card-pad empty">
<b><?= !empty($memories['exact']) ? '往年的这一天还没有记录' : '往年的这个月还没有记录' ?></b>
日子还长,等有了第一次,明年这时候就会自己冒出来。
</div>
<?php else: ?>
<div class="card card-pad">
<ul class="dash-list">
<?php foreach ($memories['rows'] as $mm): ?>
<li class="dash-row">
<span class="dash-dot" style="background: <?= $mm['kind'] === 'trip' ? '#3F7D5A' : '#7A4E8C' ?>;"></span>
<span class="dash-when"><?= (int)$mm['years_ago'] ?> 年前</span>
<a class="dash-what" href="<?= e((string)$mm['href']) ?>">
<?= $mm['kind'] === 'trip' ? '🧳 ' : '🎬 ' ?><?= e((string)$mm['title']) ?>
</a>
<span class="dash-tag"><?= e(substr((string)$mm['starts_at'], 0, 10)) ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</section>
+146 -31
View File
@@ -34,41 +34,102 @@
$nodes = $tree['nodes'];
$me = $nodes[$meId] ?? null;
// ---- 转成 family-chart 需要的 Datum 格式 ----
// ⚠️ 「我」排最前(见上面坑①)。其余按名册顺序跟上,库自己会按辈分重新摆放。
$order = [(int)$meId];
foreach (array_keys($people) as $pid) {
if ((int)$pid !== (int)$meId) {
$order[] = (int)$pid;
// ---- v14:先圈定「上 / 本 / 下三代里所有有关系的人」----
//
// ⚠️ 这是本轮重写的核心(用户第 5 条:「显示不齐全…以本人为中心去搜索,只要有关系的
// 都检索出来进行展示」)。旧写法把**全名册**塞给库,看着多,其实库只画
// 「从 main_id 出发的层次结构」,横向的堂表亲、配偶那边的旁支照样进不来,
// 而树上又挂了一堆根本没关系的人(看着像"家谱里混进了外人")。
// 现在先自己按**代数**筛选(focusMap),再交给库 —— 库拿到的人就是该出现的人。
$focusGen = \App\Kinship::focusMap($graph, (int)$meId, $people, 2, 2);
if (!isset($focusGen[(int)$meId])) {
$focusGen[(int)$meId] = 0;
}
$focusIds = array_map('intval', array_keys($focusGen));
// ---- 树根 = 「我」往上走到的最高那一代 ----
//
// ⚠️⚠️ 这是 v14 最关键的一处实测结论(_fb_kin_probe.py / probe3 跑出来的):
// family-chart 画的是「**树根**的祖先链 + 后代 + 树根的兄弟姐妹 + 各人的配偶」。
// 树根 = 我 时,**伯父 / 堂兄 / 嫂子 / 侄子 一个都不画**(实测 12 人只画出 8 人)——
// 因为堂兄是"我伯父的孩子",不在"我的后代"里,也不是"我的兄弟姐妹"。
// 把树根换成**我的最高祖先**,整棵树就变成"这位祖先的全部后代",
// 于是上一代(伯叔姑舅姨)、这一代(堂表亲)、下一代(侄甥)**全部**落进树里
// (实测 12 人全画出,且按辈分正确分层)。
// 这正是用户说的「显示不齐全…以本人为中心去搜索,只要有关系的都检索出来」。
//
// ⚠️ 代价是"我"不再是几何中心 —— 用下面的「定位到我」按钮 + 加载后自动居中补回来。
$rootId = (int)$meId;
for ($guard = 0; $guard < 8; $guard++) {
$up = \App\Kinship::parentIds($graph, $rootId);
$next = 0;
foreach ($up as $p) {
$p = (int)$p;
if (isset($focusGen[$p]) && (int)$focusGen[$p] < (int)$focusGen[$rootId]) {
$next = $p;
break;
}
}
if ($next === 0) {
break;
}
$rootId = $next;
}
$datum = [];
// ---- 连边:子女一律挂在「夫妻」上(normalizeLinks 的对称补齐)----
// 见 App\Kinship::normalizeLinks 的注释:孩子同时挂到父母双方、双方也都有这个孩子,
// 库才会把连线从**夫妻之间那条横线**的中点拉下来 —— 否则就是"从父亲一个人身上
// 垂下来一根线",母亲那条线永远连不到孩子(用户点名的重大 bug)。
$links = \App\Kinship::normalizeLinks($graph, $focusIds);
// 同一代里按长幼排(长子左、次子右);长幼键取不到时退到 id,保证顺序稳定
$rankKey = static function (int $id) use ($people): string {
return \App\Kinship::rankValueFor($people, $id);
};
// ---- 转成 family-chart 需要的 Datum 格式 ----
// ⚠️ **树根**排最前(见上面坑①:`main_id = data[0].id`)。其余按「代数 → 长幼」跟上。
$order = [$rootId];
$rest = [];
foreach ($focusIds as $pid) {
if ((int)$pid !== $rootId) {
$rest[] = (int)$pid;
}
}
usort($rest, static function (int $a, int $b) use ($focusGen, $rankKey): int {
return ((int)$focusGen[$a] <=> (int)$focusGen[$b])
?: strcmp($rankKey($a), $rankKey($b))
?: ($a <=> $b);
});
$order = array_merge($order, $rest);
$onTree = array_fill_keys($focusIds, true);
$datum = [];
foreach ($order as $pid) {
if (!isset($people[$pid]) || !is_array($people[$pid])) {
continue;
}
$p = $people[$pid];
// 只连「名册里真的有的人」——树上出现一个查不到的人会画出空白卡
$parents = [];
foreach (\App\Kinship::parents($graph, $pid) as $pp) {
if ($pp !== null && isset($people[$pp])) {
$parents[] = (string)$pp;
$mapIds = static function (array $ids) use ($onTree): array {
$out = [];
foreach ($ids as $x) {
$x = (int)$x;
if (isset($onTree[$x])) {
$out[] = (string)$x;
}
}
}
$spouses = [];
foreach (\App\Kinship::spouseIds($graph, $pid) as $sp) {
if (isset($people[$sp])) {
$spouses[] = (string)$sp;
}
}
$kids = [];
foreach (\App\Kinship::childIds($graph, $pid) as $kd) {
if (isset($people[$kd])) {
$kids[] = (string)$kd;
}
}
return array_values(array_unique($out));
};
$parents = $mapIds($links['parents'][$pid] ?? []);
$spouses = $mapIds($links['spouses'][$pid] ?? []);
$kids = $mapIds($links['children'][$pid] ?? []);
// 子女按长幼排:库按 rels.children 的**数组顺序**摆放(它不是自己排序的),
// 顺序错了就是"弟弟排在哥哥左边"。
$kidsInt = array_map('intval', $kids);
usort($kidsInt, static fn(int $a, int $b): int => strcmp($rankKey($a), $rankKey($b)) ?: ($a <=> $b));
$kids = array_map('strval', $kidsInt);
// 库认 M / F / U 三种(决定卡片底色)
$g = strtolower((string)($p['gender'] ?? ''));
@@ -78,7 +139,7 @@ foreach ($order as $pid) {
'id' => (string)$pid,
'data' => [
'gender' => $gender,
'name' => (string)($p['name'] ?? ''),
'name' => \App\Nickname::display((int)$meId, $p) ?: (string)($p['name'] ?? ''),
'label' => \App\Kinship::label($graph, (int)$meId, $pid, $people),
'avatar' => avatar_url($p['avatar'] ?? null),
'me' => $pid === (int)$meId,
@@ -106,7 +167,7 @@ $canChart = count($datum) > 1;
<div class="page-head">
<h1>我的家谱</h1>
<span class="sub">以我为中心 · 同代按长幼从左到右 · 夫妻并排 · 折线表示谁是谁的父母</span>
<span class="sub">以我为中心 · 上一代 / 这一代 / 下一代 · 夫妻并排,子女从两人之间的横线连出</span>
</div>
<?php if (!$canChart): ?>
@@ -123,12 +184,22 @@ $canChart = count($datum) > 1;
⚠️ 库初始化那一刻**容器必须已经有真实尺寸**:它按容器宽高算 fit 缩放,
还在 0×0 时初始化会一路缩到 0.4 倍,卡片小到看不清(实测踩过)。 */
?>
<div class="kin-chart" id="kinChart" data-datum="<?= e($datumJson) ?>"></div>
<div class="kin-tools">
<?php /* 树根是「最高祖先」之后,整棵树可能很大 —— 这两个按钮把「看全部」与
「回到我」分开,不然每次进来都要手动拖半天才能找到自己。 */ ?>
<button type="button" class="btn btn-ghost btn-sm" id="kinFit">看全部</button>
<button type="button" class="btn btn-ghost btn-sm" id="kinMe">回到我</button>
</div>
<div class="kin-chart" id="kinChart" data-datum="<?= e($datumJson) ?>"
data-me="<?= e((string)$meId) ?>"></div>
<p class="tree-legend">
画的是**以我的最高祖先为根、本家血脉的整棵后代树**:父母与伯叔姑舅姨(上一代)、
我与兄弟姐妹和堂表亲及各自的配偶(这一代)、他们的孩子(下一代)都在上面。
「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。
图里画的是**本家血脉 + 各自的配偶**;配偶的父母(如岳父母)属姻亲,不在这张图上。
可以直接**拖拽平移**、**滚轮缩放**;点一下卡片进对应成员页。
夫妻并排,**子女一律从两人之间的横线连出** —— 不管是谁登录,孩子都挂在这对夫妻上。
可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。
</p>
<script src="<?= e(asset('/assets/vendor/family-chart/d3.min.js')) ?>"></script>
@@ -173,7 +244,7 @@ $canChart = count($datum) > 1;
// (抛 TypeError),异常把后面的 updateTree() 一起带走了 →
// 整张家谱**一张卡都不画**(页面 200、容器有尺寸、看着"正常")。
// 用 <a> 件不依赖任何库 API,也不怕库版本变了改签名。
return '<a class="' + cls + '" href="/index.php?r=people/show&id='
return '<a class="' + cls + '" data-kin-id="' + esc(id) + '" href="/index.php?r=people/show&id='
+ encodeURIComponent(id) + '">'
+ '<span class="kin-ph"><img src="' + esc(p.avatar) + '" alt=""></span>'
+ '<span class="kin-nm">' + esc(p.name) + '</span>'
@@ -207,6 +278,50 @@ $canChart = count($datum) > 1;
// initial:true 才会画出第一版;fit 让整棵树自适应容器大小
chart.updateTree({ initial: true, tree_position: 'fit' });
window.kinChart = chart;
// ---- 平移 / 居中 ----
// 库自带拖拽与滚轮缩放,但**没有**"定位到某个人"的 API(method 清单里没有)。
// 好在缩放是标准 d3.zoom,它把对象挂在 svg 元素上(__zoomObj / __zoom),
// 直接拿它改 transform 就行 —— 不用碰库的内部状态。
// ⚠️ transform 的平移量是**屏幕像素**:想把某张卡挪到容器中心,
// 就是把它当前的屏幕中心与容器中心之差加到 t.x / t.y 上,k 不动。
var svg = root.querySelector('svg.main_svg');
function currentTransform() {
if (!svg) return null;
return svg.__zoom || null;
}
function applyTransform(t) {
if (!svg || !svg.__zoomObj || !window.d3) return;
window.d3.select(svg).call(svg.__zoomObj.transform, t);
}
function centerOn(id) {
var el = root.querySelector('.kin-card[data-kin-id="' + id + '"]');
if (!el || !window.d3) return;
var t = currentTransform();
if (!t) return;
var rr = root.getBoundingClientRect();
var er = el.getBoundingClientRect();
var dx = (rr.left + rr.width / 2) - (er.left + er.width / 2);
var dy = (rr.top + rr.height / 2) - (er.top + er.height / 2);
applyTransform(window.d3.zoomIdentity.translate(t.x + dx, t.y + dy).scale(t.k));
}
window.kinCenterOn = centerOn;
var meId = root.getAttribute('data-me') || '';
// 库是「过渡」出来的:卡片位置要等动画停了才准。等一小会儿再居中。
if (meId) { setTimeout(function () { centerOn(meId); }, 700); }
var btnFit = document.getElementById('kinFit');
var btnMe = document.getElementById('kinMe');
if (btnFit) {
btnFit.addEventListener('click', function () {
chart.updateTree({ tree_position: 'fit' });
});
}
if (btnMe && meId) {
btnMe.addEventListener('click', function () { centerOn(meId); });
}
})();
</script>
+25 -8
View File
@@ -4,12 +4,22 @@
// ⚠️ v11:「影院」也**不在**顶栏里了(用户明确要求)—— 影院是观影模块的一部分,
// 统一从观影列表页右侧的「设置」按钮进(那里还挂着高级搜索/统计/年度报告)。
// 别再往 $navItems 里加子功能入口,顶栏只放"模块"这一级。
$navItems = [
'home' => ['首页', 'home'],
'kin' => ['家谱', 'kin'],
'people' => ['名册', 'people'],
'movies' => ['观影', 'movies'],
];
// ⚠️ v14 起导航条**不再写死**:模块清单在 App\Module::LIST 里(加一个模块只改那一处),
// 显示哪些、什么顺序由**每个人自己**的 user_settings('nav_modules') 决定
// (用户第 10 条:允许设置哪些模块出现在导航条上,拖动排序)。
// App\Module::navFor() 内部做了两道兜底(剔除已不存在的模块、补上新装的模块),
// 这里直接拿结果用,不要再自己过滤一遍。
$navKeys = \App\Module::navFor((int)\App\Auth::personId());
$navItems = [];
foreach ($navKeys as $k) {
$navItems[$k] = [\App\Module::name($k), \App\Module::route($k)];
}
// 本页属于哪个模块 → 决定主题色(用户第 11 条:每个模块一个主题色)
$moduleKey = \App\Module::ofPage((string)$pageKey);
if ($moduleKey === '') {
$moduleKey = 'home';
}
$themeVars = \App\Module::cssVars($moduleKey, (int)\App\Auth::familyId());
// ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的,
// 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。
// home.php 用 $me 画「补充亲属关系」链接,被覆盖成 true 之后
@@ -29,7 +39,12 @@ $isLoggedIn = \App\Auth::check();
<meta name="theme-color" content="#A84A28">
<link rel="stylesheet" href="<?= e(asset('/assets/css/app.css')) ?>">
</head>
<body>
<?php /* 主题色:把 --brand / --brand-2 / --brand-ink 三个变量在 <body> 上换成本模块的色,
下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。
⚠️ 只覆盖这三个变量,**不碰** --paper / --ink 这些纸墨底色 ——
模块色是用来"分区"的,把整页背景也换了就不是家书了。 */ ?>
<body class="mod-<?= e($moduleKey) ?>"
style="--brand: <?= e($themeVars['brand']) ?>; --brand-2: <?= e($themeVars['brand2']) ?>; --brand-ink: <?= e($themeVars['ink']) ?>;">
<?php if ($isLoggedIn): ?>
<header class="topbar">
@@ -40,7 +55,9 @@ $isLoggedIn = \App\Auth::check();
</a>
<nav class="nav">
<?php foreach ($navItems as $key => list($label, $route)): ?>
<a class="<?= $pageKey === $key ? 'on' : '' ?>" href="<?= e(url($route)) ?>"><?= e($label) ?></a>
<?php /* 高亮按「本页属于哪个模块」判,而不是 pageKey 全等 ——
子页(观影里的影院管理/统计/年度报告)也要让「观影」亮着。 */ ?>
<a class="<?= $moduleKey === $key ? 'on' : '' ?>" href="<?= e(url($route)) ?>"><?= e($label) ?></a>
<?php endforeach; ?>
</nav>
<div class="topbar-right">
+1 -1
View File
@@ -18,7 +18,7 @@ $canWrite = \App\Auth::canWrite();
<h1>影院管理</h1>
<span class="sub">共 <?= count($cinemas) ?> 家影院</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回观影记录</a>
<a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回观影</a>
<?php $setCurrent = 'cinemas'; $setCanWrite = $canWrite; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
+4 -2
View File
@@ -32,10 +32,12 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
<h1><?= $isEdit ? '编辑观影记录' : '记一场电影' ?></h1>
<span class="sub"><?= $isEdit ? e($m['title']) : '片名必填,其他都可以以后再补' ?></span>
<div class="actions">
<?php /* 表单页没有返回按钮(提交/取消在表单底部),返回放前面、设置贴最右。 */ ?>
<?php /* 文案一律「返回观影」(用户 2026-10-01 要求:观影模块里不再出现
「返回观影记录」「返回列表」「返回详情」三种叫法)。
落地页仍按来路走:从详情页点「编辑」进来的,回去还是那条记录的详情页。 */ ?>
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('movies/show', ['id' => (int)$m['id']]) : url('movies')) ?>">
<?= $isEdit ? '返回详情' : '返回观影' ?>
返回观影
</a>
<?php $setCurrent = 'form'; $setCanWrite = \App\Auth::canWrite(); require __DIR__ . '/_settings_menu.php'; ?>
</div>
+6 -2
View File
@@ -72,8 +72,12 @@ $fmtNum = static function ($v): string {
<span class="meta-rating" title="共 <?= (int)$mv['review_count'] ?> 条评价">⭐ <?= e($fmtNum($mv['review_avg'])) ?><span class="meta-rating-max">/10</span></span>
<?php endif; ?>
</span>
<?php if (!empty($mv['attendee_names'])): ?>
<span class="card-viewers"><span class="viewer-tag">👥 <?= e($mv['attendee_names']) ?></span></span>
<?php if (!empty($mv['attendees'])): ?>
<?php /* 每人一个标签(头像 + 自己的底色 + 我对他的称呼)。
⚠️ 整张卡片已经是 <a>,这里只能出 <span> —— a 套 a 会把外层链接切开。 */ ?>
<span class="card-viewers">
<?php foreach ($mv['attendees'] as $a): ?><?= person_chip($a) ?><?php endforeach; ?>
</span>
<?php endif; ?>
</span>
</a>
+1 -3
View File
@@ -129,9 +129,7 @@ $fmtDate = static function (?string $d): string {
</span>
<?php if (!empty($mv['viewers'])): ?>
<span class="card-viewers">
<?php foreach ($mv['viewers'] as $vw): ?>
<span class="viewer-tag"><img src="<?= e(avatar_url($vw['avatar'] ?? null)) ?>" alt=""><?= e((string)$vw['name']) ?></span>
<?php endforeach; ?>
<?php foreach ($mv['viewers'] as $vw): ?><?= person_chip($vw) ?><?php endforeach; ?>
</span>
<?php endif; ?>
</span>
+4 -2
View File
@@ -97,8 +97,10 @@
<span style="color: <?= e((string)($mv['cinema_color'] ?: '#888888')) ?>">🏛️ <?= e($mv['cinema_name']) ?></span>
<?php endif; ?>
</span>
<?php if (!empty($mv['attendee_names'])): ?>
<span class="card-viewers"><span class="viewer-tag">👥 <?= e($mv['attendee_names']) ?></span></span>
<?php if (!empty($mv['attendees'])): ?>
<span class="card-viewers">
<?php foreach ($mv['attendees'] as $a): ?><?= person_chip($a) ?><?php endforeach; ?>
</span>
<?php endif; ?>
</span>
</a>
+2 -5
View File
@@ -152,7 +152,7 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
</form>
<?php endif; ?>
<?php /* 设置入口放在 actions 最后 → 永远在最右侧(用户 2026-10-01 要求) */ ?>
<a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回列表</a>
<a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回观影</a>
<?php $setCurrent = 'show'; $setCanWrite = $canWrite; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
@@ -218,10 +218,7 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
<?php if ($m['attendees']): ?>
<div class="viewers-list">
<?php foreach ($m['attendees'] as $a): ?>
<a class="viewer-tag" href="<?= e(url('people/show', ['id' => (int)$a['id']])) ?>">
<?= $avatarHtml($a['avatar'], (string)$a['name']) ?>
<?= e($a['name']) ?>
</a>
<?= person_chip($a, url('people/show', ['id' => (int)$a['id']])) ?>
<?php endforeach; ?>
</div>
<?php else: ?>
+1 -1
View File
@@ -73,7 +73,7 @@ $wd = static function (?string $d): string {
<div class="bar-item">
<span class="bar-label">
<img class="bar-avatar" src="<?= e(avatar_url($r['avatar'] ?? null)) ?>" alt="">
<?= e((string)$r['name']) ?>
<?= e(person_name($r)) ?>
</span>
<span class="bar-track"><span class="bar-fill" style="width: <?= e((string)$fmtPct((int)$r['cnt'], $maxPerson)) ?>%"></span></span>
<span class="bar-count"><?= (int)$r['cnt'] ?></span>
+23 -7
View File
@@ -93,13 +93,29 @@ $ctHints = [
</div>
<?php endif; ?>
<?php /* ⚠️ 这里**没有**「排序号」,也**没有**「关系分组」:
① 排序号已经彻底取消(用户原话:「排序号,不要让用户填写」),
名册里的先后靠**拖动卡片**调,存在我自己的 person_roster.sort_order 上;
② 分组是「我和他的关系」,写在我自己的名册条目上 ——
只能在名册里拖到那一栏,或在那张卡片的「调整分组」里改。
写进这张(全家人共用的)资料表单里是错的:我把他归成"同事"
不该把妈妈那边的归类也改掉。 */ ?>
<?php /* ⚠️ 这里**没有**「排序号」(已彻底取消,用户原话:「排序号,不要让用户填写」),
名册里的先后靠**拖动卡片**调。
⚠️「所属分组」**只在新增时出现**($isEdit 为假):
分组是"我和他的关系",写在我自己的名册条目上 ——
· 新增时这个人还不存在,别人名册里不可能有他,这里选了只影响我自己,
正是用户要的「增加名册时可选择所属分组」;
· 编辑时**不能**在这里给:我把他归成"同事"不该把妈妈那边的归类也改掉 ——
改分组走名册里拖动,或那张卡片的「调整分组」。 */ ?>
<?php if (!$isEdit): ?>
<div class="field">
<label for="group_id">所属分组</label>
<select class="select" id="group_id" name="group_id">
<option value="0">先不分类</option>
<?php foreach (\App\PersonGroup::orderFor((int)\App\Auth::familyId(), (int)\App\Auth::personId()) as $g): ?>
<option value="<?= (int)$g['id'] ?>"
<?= (int)old('group_id', 0) === (int)$g['id'] ? 'selected' : '' ?>>
<?= e((string)$g['name']) ?><?= (int)$g['owner_id'] === 0 ? '(公共)' : '' ?>
</option>
<?php endforeach; ?>
</select>
<span class="tip">加进来之后也能改:在名册里把他拖到那一栏就行。</span>
</div>
<?php endif; ?>
<!-- 生日块已挪到表单最底部:大多数情况下生日由身份证号读出、三个都对,
不需要动(用户原话:生日什么的应该放下面)。 -->
+3 -4
View File
@@ -39,10 +39,9 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
<?php
// 显示名走「我怎么称呼他」:同一个孩子,爸爸看到"儿子"、妈妈看到"小宝"。
// ⚠️ 不要再读 $p['nickname'] —— 那是已废弃的旧列(全家共用一个小名)。
$pshow = (string)($p['shown'] ?? '');
if ($pshow === '') {
$pshow = (string)$p['name'];
}
// ⚠️ 一律走 person_name():它按 shown → my_nickname → name 的顺序取,
// 这里自己拼一次迟早漏掉 my_nickname(v14 前就是漏的,于是设了称呼也不显示)。
$pshow = person_name($p);
$pNick = (string)($p['my_nickname'] ?? '');
$pid = (int)$p['id'];
?>
+230
View File
@@ -0,0 +1,230 @@
<?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>
<form class="search" method="get" action="index.php">
<input type="hidden" name="r" value="resources">
<input class="input" type="text" name="keyword" value="<?= e($keyword) ?>" placeholder="搜标题或正文">
<select class="select" name="owner">
<option value="0">所有人</option>
<?php foreach ($people as $p): ?>
<option value="<?= (int)$p['id'] ?>"<?= (int)$ownerId === (int)$p['id'] ? ' selected' : '' ?>>
<?= e(person_name($p)) ?>
</option>
<?php endforeach; ?>
</select>
<button class="btn" type="submit">搜索</button>
<?php if ($keyword !== '' || $ownerId > 0): ?>
<a class="search-clear" href="<?= e(url('resources')) ?>">清除</a>
<?php endif; ?>
</form>
<?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 for="rs-owner">归属于谁 *</label>
<select class="select" id="rs-owner" name="owner_id" required>
<?php foreach ($people as $p): ?>
<option value="<?= (int)$p['id'] ?>"
<?= (int)($editing['owner_id'] ?? (int)\App\Auth::personId()) === (int)$p['id'] ? ' selected' : '' ?>>
<?= e(person_name($p)) ?>
</option>
<?php endforeach; ?>
</select>
</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">资源列表</h2>
<?php if (!$rows): ?>
<p class="empty-hint">
<?= ($keyword !== '' || $ownerId > 0) ? '没有匹配的资源,换个条件试试。'
: '还没有资源。证件、照片、账号这些"挂在某人名下的东西"都可以记在这里。' ?>
</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 if ($as): ?>
<div class="attach-row">
<?php foreach ($as as $a): ?>
<span class="attach-item">
<?php if (\App\Attachment::previewable($a)): ?>
<a href="<?= e(media_url((string)$a['key'])) ?>" target="_blank" rel="noopener">
<?= (int)$a['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($a['name'] ?: '附件')) ?>
</a>
<?php else: ?>
<?= (int)$a['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($a['name'] ?: '附件')) ?>
<?php endif; ?>
<span class="res-size"><?= e(\App\Attachment::sizeWord($a['size'] ?? 0)) ?></span>
<a class="link-quiet" href="<?= e(media_url((string)$a['key'], ['dl' => 1])) ?>">下载</a>
<?php if ($canWrite && \App\Resource::canWrite($r, (int)\App\Auth::personId())): ?>
<form method="post" action="<?= e(url('resources/attach_del')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这个附件?')">删除</button>
</form>
<?php endif; ?>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?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 ?>">
<select class="select" name="person_id">
<?php foreach ($people as $p): ?>
<?php if ((int)$p['id'] === (int)$r['owner_id']) { continue; } ?>
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
<?php endforeach; ?>
</select>
<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 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; ?>
+170
View File
@@ -0,0 +1,170 @@
<?php
/**
* 个人设置里的两块「模块」设置(用户 2026-10-01 第 10、11 条):
*
* ① **导航条上显示哪些模块、什么顺序** —— 每人一份(App\Module::navFor 读的就是它)。
* 勾选 = 出现在导航条上;拖动 / ▲▼ = 排序。
* ② **每个模块的主题色** —— **家庭一份**(settings('module_colors')),
* 所以只有管理员能改:一个家认同一套配色,不然同一个"观影"在爸爸那屏是紫的、
* 妈妈那屏是绿的,反而记不住。非管理员在这里只看得到色板。
*
* 变量(require 前设好):
* $navKeys 我当前导航条上的顺序(只含显示的)
* $colors key => '#rrggbb'
*/
$allKeys = \App\Module::keys();
$onKeys = array_map('strval', (array)($navKeys ?? []));
// 显示顺序:已勾选的按现有顺序在前,未勾选的按 LIST 顺序在后(这样"关掉再打开"位置不跳)
$ordered = array_values(array_filter($allKeys, static fn($k) => in_array($k, $onKeys, true)));
foreach ($allKeys as $k) {
if (!in_array($k, $ordered, true)) {
$ordered[] = $k;
}
}
$isAdmin = \App\Auth::isAdmin();
?>
<!-- ==================== 导航条上的模块 ==================== -->
<div class="card card-pad" id="modules" style="margin-top:20px">
<h2 class="section-title">
导航条上的模块
<span class="hint">勾上才会出现在顶部,拖动可改顺序</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
模块越来越多,但每个人关心的不一样 —— 这里只挑你常用的几个上导航条,其余的仍然能
从别处进(比如观影里还能进影院管理)。关掉的模块<b>不会丢数据</b>,只是从顶栏收起来。
</p>
<form method="post" action="<?= e(url('settings/nav_save')) ?>" id="navForm">
<?= csrf_field() ?>
<ol class="order-list mod-order" id="navOrderList">
<?php foreach ($ordered as $i => $k): ?>
<li class="order-row mod-row" draggable="true" data-key="<?= e($k) ?>">
<span class="drag-handle" title="拖我排序" aria-hidden="true">⋮⋮</span>
<?php /* ⚠️ 顺序就藏在 DOM 里:每个 <li> 自带一个 order[] 隐藏域,
拖动搬的是整个 <li>,所以提交上来的顺序天然就是屏幕上的顺序。
没有 JS 时 ▲▼ 按钮走同一份表单(服务端按 move 参数交换后再存)。 */ ?>
<input type="hidden" name="order[]" value="<?= e($k) ?>">
<label class="mod-pick">
<input type="checkbox" name="on[]" value="<?= e($k) ?>"
<?= in_array($k, $onKeys, true) ? 'checked' : '' ?>>
<span class="color-preview" style="background: <?= e((string)($colors[$k] ?? '#888')) ?>;"></span>
<span class="order-name"><?= e(\App\Module::name($k)) ?></span>
</label>
<span class="order-move">
<button class="btn btn-ghost btn-sm" type="submit" name="move" value="up:<?= e($k) ?>"
title="上移" <?= $i === 0 ? 'disabled' : '' ?>>▲</button>
<button class="btn btn-ghost btn-sm" type="submit" name="move" value="down:<?= e($k) ?>"
title="下移" <?= $i === count($ordered) - 1 ? 'disabled' : '' ?>>▼</button>
</span>
</li>
<?php endforeach; ?>
</ol>
<div class="btn-row">
<button class="btn" type="submit" name="save" value="1">保存导航条</button>
<a class="btn btn-ghost" href="<?= e(url('settings/me')) ?>">重来</a>
</div>
</form>
</div>
<!-- ==================== 模块主题色 ==================== -->
<div class="card card-pad" style="margin-top:20px">
<h2 class="section-title">
模块主题色
<span class="hint"><?= $isAdmin ? '全家共用一套' : '由管理员设置' ?></span>
</h2>
<p class="small muted" style="margin:0 0 16px">
进到哪个模块,顶栏高亮与按钮就换成那个模块的颜色 —— 一眼就知道自己在哪儿。
<?php if (!$isAdmin): ?>
<br>这是<b>全家共用</b>的一套配色(不然同一个「观影」在两个人屏幕上两种颜色,反而记不住),
所以只有管理员能改。
<?php endif; ?>
</p>
<?php if (!$isAdmin): ?>
<div class="mod-swatches">
<?php foreach ($allKeys as $k): ?>
<span class="mod-swatch">
<span class="color-preview" style="background: <?= e((string)($colors[$k] ?? '#888')) ?>;"></span>
<?= e(\App\Module::name($k)) ?>
</span>
<?php endforeach; ?>
</div>
<?php else: ?>
<form method="post" action="<?= e(url('settings/module_colors')) ?>">
<?= csrf_field() ?>
<div class="mod-colors">
<?php foreach ($allKeys as $k): ?>
<?php $cid = 'mc-' . $k; ?>
<div class="mod-color-row">
<span class="mod-color-name"><?= e(\App\Module::name($k)) ?></span>
<input type="color" class="color-picker" id="<?= e($cid) ?>" name="c[<?= e($k) ?>]"
value="<?= e((string)($colors[$k] ?? '#888888')) ?>">
<span class="mod-preset">
<?php foreach (\App\Module::PRESET_COLORS as $pc): ?>
<span class="preset-color" data-color="<?= $pc ?>" data-for="<?= e($cid) ?>"
style="background: <?= $pc ?>;" title="<?= $pc ?>"></span>
<?php endforeach; ?>
</span>
</div>
<?php endforeach; ?>
</div>
<div class="btn-row">
<button class="btn" type="submit">保存主题色</button>
<button class="btn btn-ghost" type="submit" name="reset" value="1"
onclick="return confirm('恢复成默认配色?')">恢复默认</button>
</div>
</form>
<?php endif; ?>
</div>
<script>
(function () {
/* ---- 拖动排序(▲▼ 是不用 JS 的兜底,两条路都能用)---- */
var list = document.getElementById('navOrderList');
if (!list) return;
var dragEl = null;
Array.prototype.forEach.call(list.querySelectorAll('.mod-row'), function (li) {
li.addEventListener('dragstart', function (ev) {
dragEl = li;
li.classList.add('is-dragged');
if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; }
});
li.addEventListener('dragend', function () {
li.classList.remove('is-dragged');
dragEl = null;
Array.prototype.forEach.call(list.querySelectorAll('.mod-row'), function (x) {
x.classList.remove('is-drop-before');
});
});
li.addEventListener('dragover', function (ev) {
if (!dragEl || dragEl === li) return;
ev.preventDefault();
var box = li.getBoundingClientRect();
var before = ev.clientY < box.top + box.height / 2;
li.classList.toggle('is-drop-before', before);
li.classList.toggle('is-drop-after', !before);
});
li.addEventListener('dragleave', function () {
li.classList.remove('is-drop-before', 'is-drop-after');
});
li.addEventListener('drop', function (ev) {
if (!dragEl || dragEl === li) return;
ev.preventDefault();
var box = li.getBoundingClientRect();
var before = ev.clientY < box.top + box.height / 2;
/* 搬的是整个 <li> —— 它自带的 order[] 隐藏域跟着走,
所以提交上来的顺序 = 屏幕上的顺序,不用再同步一遍。 */
if (before) { list.insertBefore(dragEl, li); } else { list.insertBefore(dragEl, li.nextSibling); }
li.classList.remove('is-drop-before', 'is-drop-after');
});
});
/* ---- 色板点一下就换 ---- */
Array.prototype.forEach.call(document.querySelectorAll('.preset-color[data-for]'), function (el) {
el.addEventListener('click', function () {
var target = document.getElementById(el.getAttribute('data-for'));
if (target) { target.value = el.getAttribute('data-color'); }
});
});
})();
</script>
+7
View File
@@ -138,3 +138,10 @@ $rosterCount = $rosterCount ?? 0;
</p>
<?php endif; ?>
</div>
<!-- ==================== 模块:导航条上显示哪些、各自什么主题色 ==================== -->
<?php
$navKeys = \App\Module::navFor((int)\App\Auth::personId());
$colors = \App\Module::colors((int)\App\Auth::familyId());
require __DIR__ . '/_modules.php';
?>
+236
View File
@@ -0,0 +1,236 @@
<?php
/**
* 任务 · 看板(v12):待办 / 进行中 / 已完成 三列。
*
* 拖动:卡片是 HTML5 draggable,松手时把「拖到哪一列、放在谁后面」写进一个隐藏表单再提交
* —— **没有 JS 时每列下方的 ← → 按钮同样能搬**(渐进增强:功能是有的,拖动只是更顺手)。
*
* ⚠️ 看板里只有父任务:子任务是挂在父任务下面的(点卡片进详情看/勾),
* 混进看板会变成一堆没有上下文的小条目,"还剩几件"反而看不清。
*
* @param array $board ['todo'=>[], 'doing'=>[], 'done'=>[]]
* @param array $people 名册(选负责人 / 共享给别人)
*/
$canWrite = \App\Auth::canWrite();
$cols = [['todo', '待办'], ['doing', '进行中'], ['done', '已完成']];
?>
<div class="page-head">
<h1>任务</h1>
<span class="sub">共 <?= (int)$total ?> 项</span>
<div class="actions">
<?php if ($canWrite): ?>
<button class="btn" type="button" id="taskAddBtn">新建任务</button>
<?php else: ?>
<span class="link-quiet">只读账号</span>
<?php endif; ?>
</div>
</div>
<?php if ($canWrite): ?>
<div class="card card-pad" id="taskAddCard" hidden>
<h2 class="cinema-h2">新建任务</h2>
<form method="post" action="<?= e(url('tasks/create')) ?>">
<?= csrf_field() ?>
<div class="form-grid">
<div class="field full">
<label for="tk-title">要做什么 *</label>
<input class="input" id="tk-title" name="title" required placeholder="如:周末大扫除">
</div>
<div class="field">
<label for="tk-due">截止时间(有才会出现在日历里)</label>
<input class="input" type="date" id="tk-due" name="due_date">
</div>
<div class="field">
<label for="tk-assign">负责人</label>
<select class="select" id="tk-assign" name="assignee_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">
<label for="tk-repeat">循环</label>
<select class="select" id="tk-repeat" name="repeat_rule">
<?php foreach (\App\Task::REPEAT as $rk => $rv): ?>
<option value="<?= e($rk) ?>"><?= e($rv) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="tk-share">共享给(可多选,任何人完成都算完成)</label>
<select class="select" id="tk-share" name="shares[]" multiple size="3">
<?php foreach ($people as $p): ?>
<?php if ((int)$p['id'] === (int)\App\Auth::personId()) { continue; } ?>
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field full">
<label for="tk-note">备注</label>
<textarea class="input" id="tk-note" name="note" rows="2"></textarea>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit">新建</button>
<button class="btn btn-ghost" type="button" id="taskAddCancel">收起</button>
</div>
</form>
</div>
<?php endif; ?>
<?php /* 拖动的提交目标:JS 把 id / 目标列 / 参照顺序填进来再 submit。
放在看板**外面**,避免被任何一张卡片的 form 套住。 */ ?>
<form method="post" action="<?= e(url('tasks/move')) ?>" id="taskMoveForm" class="hidden-form">
<?= csrf_field() ?>
<input type="hidden" name="id" id="mvId">
<input type="hidden" name="status" id="mvStatus">
<input type="hidden" name="before" id="mvBefore">
</form>
<div class="kanban">
<?php foreach ($cols as [$key, $label]): ?>
<section class="kb-col" data-status="<?= e($key) ?>">
<header class="kb-head">
<b><?= e($label) ?></b>
<span class="kb-n"><?= count($board[$key] ?? []) ?></span>
</header>
<div class="kb-body" data-status="<?= e($key) ?>">
<?php foreach (($board[$key] ?? []) as $t): ?>
<?php
$tid = (int)$t['id'];
$total = (int)($t['child_total'] ?? 0);
$doneN = (int)($t['child_done'] ?? 0);
$overdue = \App\Task::isOverdue($t['due_at'] ?? null) && $key !== 'done';
?>
<div class="kb-card<?= $overdue ? ' is-overdue' : '' ?>" data-id="<?= $tid ?>"
draggable="<?= $canWrite ? 'true' : 'false' ?>">
<a class="kb-title" href="<?= e(url('tasks/show', ['id' => $tid])) ?>">
<?= e((string)$t['title']) ?>
</a>
<div class="kb-meta">
<?php if (($t['due_at'] ?? '') !== ''): ?>
<span class="kb-due<?= $overdue ? ' is-late' : '' ?>">
<?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?>
</span>
<?php endif; ?>
<?php if (!empty($t['repeat_rule'])): ?>
<span class="node-badge"><?= e(\App\Task::repeatWord($t['repeat_rule'])) ?></span>
<?php endif; ?>
<?php if ((int)($t['comment_count'] ?? 0) > 0): ?>
<span class="kb-ico" title="评论">💬 <?= (int)$t['comment_count'] ?></span>
<?php endif; ?>
<?php if ((int)($t['attach_count'] ?? 0) > 0): ?>
<span class="kb-ico" title="附件">📎 <?= (int)$t['attach_count'] ?></span>
<?php endif; ?>
</div>
<?php if ($total > 0): ?>
<div class="kb-sub">
<span class="kb-bar"><i style="width: <?= (int)round($doneN * 100 / max(1, $total)) ?>%"></i></span>
<span class="kb-sub-n"><?= $doneN ?>/<?= $total ?></span>
</div>
<?php endif; ?>
<div class="kb-foot">
<?php if (!empty($t['assignee_name'])): ?>
<?= person_chip([
'id' => (int)$t['assignee_id'], 'name' => (string)$t['assignee_name'],
'my_nickname' => (string)($t['assignee_nick'] ?? ''),
'avatar' => $t['avatar'] ?? null, 'avatar_color' => $t['avatar_color'] ?? null,
]) ?>
<?php endif; ?>
<?php if ($canWrite): ?>
<span class="kb-move">
<?php if ($key !== 'todo'): ?>
<button class="btn btn-ghost btn-sm" type="button"
data-move="left" data-id="<?= $tid ?>">←</button>
<?php endif; ?>
<?php if ($key !== 'done'): ?>
<button class="btn btn-ghost btn-sm" type="button"
data-move="right" data-id="<?= $tid ?>">→</button>
<?php endif; ?>
</span>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php if (!($board[$key] ?? [])): ?>
<p class="empty-hint kb-empty">这一列是空的</p>
<?php endif; ?>
</div>
</section>
<?php endforeach; ?>
</div>
<script>
(function () {
var addBtn = document.getElementById('taskAddBtn');
var addCard = document.getElementById('taskAddCard');
var addCancel = document.getElementById('taskAddCancel');
if (addBtn && addCard) {
addBtn.addEventListener('click', function () {
addCard.hidden = !addCard.hidden;
if (!addCard.hidden) { addCard.scrollIntoView({ block: 'nearest' }); }
});
}
if (addCancel && addCard) { addCancel.addEventListener('click', function () { addCard.hidden = true; }); }
/* ---- 左右搬(不需要拖也能用)---- */
var order = ['todo', 'doing', 'done'];
var form = document.getElementById('taskMoveForm');
if (!form) return;
function submitMove(id, status, before) {
document.getElementById('mvId').value = id;
document.getElementById('mvStatus').value = status;
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 dragging = null;
Array.prototype.forEach.call(document.querySelectorAll('.kb-card[draggable="true"]'), function (card) {
card.addEventListener('dragstart', function (ev) {
dragging = card;
card.classList.add('is-dragging');
if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; }
});
card.addEventListener('dragend', function () {
card.classList.remove('is-dragging');
dragging = null;
Array.prototype.forEach.call(document.querySelectorAll('.kb-body.is-over'), function (el) {
el.classList.remove('is-over');
});
});
});
Array.prototype.forEach.call(document.querySelectorAll('.kb-body'), function (body) {
body.addEventListener('dragover', function (ev) {
if (!dragging) return;
ev.preventDefault();
body.classList.add('is-over');
});
body.addEventListener('dragleave', function () { body.classList.remove('is-over'); });
body.addEventListener('drop', function (ev) {
if (!dragging) return;
ev.preventDefault();
body.classList.remove('is-over');
/* 放在谁前面:找松手位置下方最近的那张卡(排除自己),没有就排到末尾 */
var after = null;
var kids = Array.prototype.slice.call(body.querySelectorAll('.kb-card'));
for (var i = 0; i < kids.length; i++) {
if (kids[i] === dragging) continue;
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'),
after ? after.getAttribute('data-id') : '');
});
});
})();
</script>
+307
View File
@@ -0,0 +1,307 @@
<?php
/**
* 任务详情(v12):正文 + 子任务 + 评论(@人)+ 附件 + 共享。
*
* @人:评论里写「@老杨」会高亮(Task::commentHtml),**不会**真的发通知 ——
* 家庭场景下"点名"主要是让人一眼看到在说谁,做通知要先解决推送渠道,先不做。
*
* @param array $t 任务(带 children / comments / attachs / shares)
* @param array $people 名册(负责人 / 共享 / @人 提示)
* @param array $parents 可选的父任务候选(把当前任务挂到别的父任务下)
*/
$canWrite = \App\Auth::canWrite() && \App\Task::canWrite($t, (int)\App\Auth::personId());
$meId = (int)\App\Auth::personId();
$names = [];
foreach ($people as $p) {
$names[] = person_name($p);
$nm = trim((string)($p['name'] ?? ''));
if ($nm !== '') {
$names[] = $nm;
}
}
$names = array_values(array_unique(array_filter($names)));
$isChild = !empty($t['parent_id']);
?>
<div class="page-head">
<h1><?= e((string)$t['title']) ?></h1>
<span class="sub"><?= e(\App\Task::statusWord((string)$t['status'])) ?></span>
<div class="actions">
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('tasks/move')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<?php if ($t['status'] !== 'done'): ?>
<input type="hidden" name="status" value="done">
<button class="btn" type="submit">标记完成</button>
<?php else: ?>
<input type="hidden" name="status" value="doing">
<button class="btn btn-ghost" type="submit">重新打开</button>
<?php endif; ?>
</form>
<?php endif; ?>
<a class="btn btn-ghost" href="<?= e(url('tasks')) ?>">返回任务</a>
</div>
</div>
<div class="card card-pad">
<dl class="kv">
<div class="kv-row"><dt>状态</dt><dd><?= e(\App\Task::statusWord((string)$t['status'])) ?></dd></div>
<?php if (($t['due_at'] ?? '') !== ''): ?>
<div class="kv-row"><dt>截止</dt>
<dd class="<?= \App\Task::isOverdue($t['due_at'] ?? null) && $t['status'] !== 'done' ? 'kb-late' : '' ?>">
<?= e((string)$t['due_at']) ?><?= \App\Task::dueWord($t['due_at'] ?? null) !== '' ? '(' . e(\App\Task::dueWord($t['due_at'] ?? null)) . ')' : '' ?>
</dd>
</div>
<?php endif; ?>
<?php if (!empty($t['repeat_rule'])): ?>
<div class="kv-row"><dt>循环</dt><dd><?= e(\App\Task::repeatWord($t['repeat_rule'])) ?></dd></div>
<?php endif; ?>
<?php if (!empty($t['assignee_name'])): ?>
<div class="kv-row"><dt>负责人</dt>
<dd><?= e(person_name(['name' => $t['assignee_name'], 'my_nickname' => $t['assignee_nick'] ?? ''])) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($t['creator_name'])): ?>
<div class="kv-row"><dt>谁建的</dt>
<dd><?= e(person_name(['name' => $t['creator_name'], 'my_nickname' => $t['creator_nick'] ?? ''])) ?></dd>
</div>
<?php endif; ?>
<?php if ($isChild && !empty($t['parent_title'])): ?>
<div class="kv-row"><dt>属于</dt>
<dd><a href="<?= e(url('tasks/show', ['id' => (int)$t['parent_id']])) ?>"><?= e((string)$t['parent_title']) ?></a></dd>
</div>
<?php endif; ?>
<?php if (!empty($t['note'])): ?>
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$t['note'])) ?></dd></div>
<?php endif; ?>
</dl>
<?php if ($canWrite): ?>
<details class="node-add">
<summary class="link-quiet">编辑这项任务</summary>
<form method="post" action="<?= e(url('tasks/update')) ?>" class="node-form">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<div class="nf-grid">
<div class="field full">
<label>标题</label>
<input class="input" name="title" required value="<?= e((string)$t['title']) ?>">
</div>
<div class="field">
<label>截止日期(留空=不上日历)</label>
<input class="input" type="date" name="due_date" value="<?= e(substr((string)($t['due_at'] ?? ''), 0, 10)) ?>">
</div>
<div class="field">
<label>负责人</label>
<select class="select" name="assignee_id">
<option value="0">不指定</option>
<?php foreach ($people as $p): ?>
<option value="<?= (int)$p['id'] ?>"<?= (int)($t['assignee_id'] ?? 0) === (int)$p['id'] ? ' selected' : '' ?>>
<?= e(person_name($p)) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>循环</label>
<select class="select" name="repeat_rule">
<?php foreach (\App\Task::REPEAT as $rk => $rv): ?>
<option value="<?= e($rk) ?>"<?= (string)($t['repeat_rule'] ?? '') === (string)$rk ? ' selected' : '' ?>>
<?= e($rv) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>挂到哪个父任务下</label>
<select class="select" name="parent_id">
<option value="0">(这是一项独立的任务)</option>
<?php foreach ($parents as $p): ?>
<option value="<?= (int)$p['id'] ?>"<?= (int)($t['parent_id'] ?? 0) === (int)$p['id'] ? ' selected' : '' ?>>
<?= e((string)$p['title']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field full">
<label>备注</label>
<textarea class="input" name="note" rows="2"><?= e((string)($t['note'] ?? '')) ?></textarea>
</div>
</div>
<div class="btn-row"><button class="btn btn-sm" type="submit">保存</button></div>
</form>
</details>
<?php endif; ?>
</div>
<?php /* ---- 子任务 ---- */ ?>
<?php if (!$isChild): ?>
<div class="card card-pad">
<h2 class="cinema-h2">子任务 <span class="group-n"><?= count($t['children']) ?> 项</span></h2>
<?php if (!$t['children']): ?>
<p class="empty-hint">还没有子任务。把一件大事拆成几件小事,进度才看得见。</p>
<?php else: ?>
<ul class="sub-list">
<?php foreach ($t['children'] as $c): ?>
<li class="sub-row<?= $c['status'] === 'done' ? ' is-done' : '' ?>">
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('tasks/move')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<input type="hidden" name="status" value="<?= $c['status'] === 'done' ? 'todo' : 'done' ?>">
<button class="sub-check" type="submit" title="<?= $c['status'] === 'done' ? '取消完成' : '标记完成' ?>">
<?= $c['status'] === 'done' ? '☑' : '☐' ?>
</button>
</form>
<?php else: ?>
<span class="sub-check is-static"><?= $c['status'] === 'done' ? '☑' : '☐' ?></span>
<?php endif; ?>
<a class="sub-title" href="<?= e(url('tasks/show', ['id' => (int)$c['id']])) ?>">
<?= e((string)$c['title']) ?>
</a>
<?php if (($c['due_at'] ?? '') !== ''): ?>
<span class="node-time<?= \App\Task::isOverdue($c['due_at'] ?? null) && $c['status'] !== 'done' ? ' kb-late' : '' ?>">
<?= e(\App\Task::dueWord($c['due_at'] ?? null)) ?>
</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($canWrite): ?>
<details class="node-add">
<summary class="link-quiet">+ 加一项子任务</summary>
<form method="post" action="<?= e(url('tasks/create')) ?>" class="inline-form">
<?= csrf_field() ?>
<input type="hidden" name="parent_id" value="<?= (int)$t['id'] ?>">
<input class="input" name="title" required placeholder="如:擦玻璃">
<input class="input" type="date" name="due_date">
<button class="btn btn-sm" type="submit">加上</button>
</form>
</details>
<?php endif; ?>
</div>
<?php endif; ?>
<?php /* ---- 附件 ---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">附件</h2>
<?php if (!$t['attachs']): ?>
<p class="empty-hint">还没有附件。</p>
<?php else: ?>
<div class="attach-row">
<?php foreach ($t['attachs'] as $a): ?>
<span class="attach-item">
<a href="<?= e(media_url((string)$a['key'])) ?>" target="_blank" rel="noopener">
<?= (int)$a['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($a['name'] ?: '附件')) ?>
</a>
<span class="res-size"><?= e(\App\Attachment::sizeWord($a['size'] ?? 0)) ?></span>
<a class="link-quiet" href="<?= e(media_url((string)$a['key'], ['dl' => 1])) ?>">下载</a>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('tasks/attach_del')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit" onclick="return confirm('删掉这个附件?')">删除</button>
</form>
<?php endif; ?>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canWrite): ?>
<form class="inline-form" method="post" enctype="multipart/form-data" action="<?= e(url('tasks/attach_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input type="file" name="attach[]" multiple required>
<button class="btn btn-sm" type="submit">上传附件</button>
</form>
<?php endif; ?>
</div>
<?php /* ---- 共享 ---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">共享</h2>
<?php if (!$t['shares']): ?>
<p class="empty-hint">还没共享给别人。共享之后,任何一个人完成都算完成。</p>
<?php else: ?>
<div class="res-shares">
<?php foreach ($t['shares'] as $s): ?>
<?= person_chip($s) ?>
<span class="node-badge"><?= ($s['perm'] ?? '') === 'write' ? '可写' : '只读' ?></span>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('tasks/unshare')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input type="hidden" name="person_id" value="<?= (int)$s['person_id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">取消</button>
</form>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canWrite): ?>
<form class="inline-form" method="post" action="<?= e(url('tasks/share')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<select class="select" name="person_id">
<?php foreach ($people as $p): ?>
<?php if ((int)$p['id'] === $meId) { continue; } ?>
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
<?php endforeach; ?>
</select>
<select class="select" name="perm">
<option value="write">可写</option>
<option value="read">只读</option>
</select>
<button class="btn btn-sm" type="submit">共享</button>
</form>
<?php endif; ?>
</div>
<?php /* ---- 评论 ---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">讨论 <span class="group-n"><?= count($t['comments']) ?> 条</span></h2>
<?php if (!$t['comments']): ?>
<p class="empty-hint">还没有留言。写 @名字 可以点名。</p>
<?php else: ?>
<ul class="cmt-list">
<?php foreach ($t['comments'] as $c): ?>
<li class="cmt-row">
<div class="cmt-who">
<?= person_chip($c) ?>
<span class="cmt-time"><?= e((string)$c['created_at']) ?></span>
</div>
<div class="cmt-body"><?= \App\Task::commentHtml((string)$c['content'], $names) ?></div>
<?php if ((int)$c['person_id'] === $meId): ?>
<form method="post" action="<?= e(url('tasks/comment_del')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<input type="hidden" name="task_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit" onclick="return confirm('删掉这条留言?')">删除</button>
</form>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('tasks/comment_add')) ?>" class="cmt-form">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<textarea class="input" name="content" rows="2" required
placeholder="说点什么… 写 @名字 可以点名"><?= e(old('content', '')) ?></textarea>
<div class="btn-row"><button class="btn btn-sm" type="submit">发表</button></div>
</form>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<div class="task-danger">
<form method="post" action="<?= e(url('tasks/destroy')) ?>"
onsubmit="return confirm('删除「<?= e((string)$t['title']) ?>」?子任务、附件与留言会一起删掉。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-danger btn-sm" type="submit">删除这项任务</button>
</form>
</div>
<?php endif; ?>
+90
View File
@@ -0,0 +1,90 @@
<?php
/**
* 行程节点表单(新增 / 编辑共用;被 show.php 循环 require)。
*
* 计划与执行是**双轨**:同一段行程可以先在 plan 里排一遍,走完再在 reality 里记一遍。
* 所以表单里带一个隐藏的 category —— 加在哪一轨就进哪一轨。
*
* 变量(require 前设好):
* $nfNode 被编辑的节点数组,或 null(新增)
* $nfTripId 旅程 id(events.id)
* $nfCategory 'plan' | 'reality'
* $nfPeople 可选人名册(选参与人用),为空就不显示成员多选
* $nfMembers 已选成员 id 列表
*/
$nfIsEdit = !empty($nfNode);
$nfPrefix = $nfIsEdit ? 'ne' . (int)$nfNode['id'] : ('n' . ($nfCategory === 'reality' ? 'r' : 'p'));
?>
<form class="node-form" method="post" action="<?= e(url($nfIsEdit ? 'trips/node_update' : 'trips/node_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="trip_id" value="<?= (int)$nfTripId ?>">
<input type="hidden" name="category" value="<?= e($nfCategory) ?>">
<?php if ($nfIsEdit): ?>
<input type="hidden" name="id" value="<?= (int)$nfNode['id'] ?>">
<?php endif; ?>
<div class="nf-grid">
<div class="field">
<label for="<?= e($nfPrefix) ?>-title">这一段 *</label>
<input class="input" id="<?= e($nfPrefix) ?>-title" name="title" required
value="<?= e($nfNode['title'] ?? '') ?>" placeholder="如:栈桥看海">
</div>
<div class="field">
<label for="<?= e($nfPrefix) ?>-type">类型</label>
<select class="select" id="<?= e($nfPrefix) ?>-type" name="node_type">
<?php foreach (\App\Trip::NODE_TYPES as $tk => $tv): ?>
<option value="<?= e($tk) ?>"<?= ($nfNode['node_type'] ?? 'other') === $tk ? ' selected' : '' ?>>
<?= e($tv[1] . ' ' . $tv[0]) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="<?= e($nfPrefix) ?>-place">地点</label>
<input class="input" id="<?= e($nfPrefix) ?>-place" name="place"
value="<?= e($nfNode['place'] ?? '') ?>" placeholder="选填">
</div>
<div class="field">
<label for="<?= e($nfPrefix) ?>-status">状态</label>
<select class="select" id="<?= e($nfPrefix) ?>-status" name="status">
<?php foreach (\App\Trip::NODE_STATUS as $sk => $sv): ?>
<option value="<?= e($sk) ?>"<?= ($nfNode['status'] ?? 'planned') === $sk ? ' selected' : '' ?>>
<?= e($sv) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>开始</label>
<?php $dtName = 'starts_at'; $dtValue = $nfNode['starts_at'] ?? '';
$dtId = $nfPrefix . '-s'; $dtSeconds = false; require __DIR__ . '/../_datetime.php'; ?>
</div>
<div class="field">
<label>结束</label>
<?php $dtName = 'ends_at'; $dtValue = $nfNode['ends_at'] ?? '';
$dtId = $nfPrefix . '-e'; $dtSeconds = false; require __DIR__ . '/../_datetime.php'; ?>
</div>
<div class="field full">
<label for="<?= e($nfPrefix) ?>-detail">记录</label>
<textarea class="input" id="<?= e($nfPrefix) ?>-detail" name="detail" rows="2"
placeholder="这一段发生了什么"><?= e($nfNode['detail'] ?? '') ?></textarea>
</div>
<?php if (!empty($nfPeople)): ?>
<div class="field full">
<label>这一段里有谁(不勾 = 全趟同行的人)</label>
<div class="nf-members">
<?php foreach ($nfPeople as $p): ?>
<label class="check-item">
<input type="checkbox" name="members[]" value="<?= (int)$p['id'] ?>"
<?= in_array((int)$p['id'], $nfMembers, true) ? 'checked' : '' ?>>
<span><?= e(person_name($p)) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<div class="btn-row">
<button class="btn btn-sm" type="submit"><?= $nfIsEdit ? '保存' : '加一段' ?></button>
</div>
</form>
+118
View File
@@ -0,0 +1,118 @@
<?php
/**
* 记一趟旅程 / 编辑旅程(v12)。
*
* 字段分两组:
* · 骨架(落在 events 上):标题 / 出发 / 回来 / 目的地 / 备注 —— 与观影同一套原语;
* · 旅程特有(落在 trip_details 上):封面图 + 一段话小结。
*
* ⚠️ 同行的人用多选框(人不多,一次性列全比"搜索+联想"快);
* 显示名一律是「我叫他什么」(person_name),提交的是 person_id。
*/
$isEdit = !empty($t);
$canWrite = \App\Auth::canWrite();
$selIds = [];
if ($isEdit) {
foreach ((array)($t['attendees'] ?? []) as $a) {
$selIds[] = (int)$a['id'];
}
} else {
$selIds = array_map('intval', (array)($presetAttendees ?? []));
}
?>
<div class="page-head">
<h1><?= $isEdit ? '编辑旅程' : '记一趟旅程' ?></h1>
<div class="actions">
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">返回旅程</a>
</div>
</div>
<div class="card card-pad">
<form method="post" enctype="multipart/form-data"
action="<?= e(url($isEdit ? 'trips/update' : 'trips/create')) ?>">
<?= csrf_field() ?>
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field full">
<label for="t-title">这趟旅程叫什么 *</label>
<input class="input" id="t-title" name="title" required
value="<?= e(old('title', $t['title'] ?? '')) ?>" placeholder="如:青岛三日 · 暑假">
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="t-start">出发</label>
<?php $dtName = 'starts_at'; $dtValue = $t['starts_at'] ?? '';
$dtId = 't-start'; $dtSeconds = false; require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="t-end">回来</label>
<?php $dtName = 'ends_at'; $dtValue = $t['ends_at'] ?? ''; $dtId = 't-end'; $dtSeconds = false;
require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
</div>
<div class="field full">
<label for="t-loc">目的地</label>
<input class="input" id="t-loc" name="location"
value="<?= e(old('location', $t['location'] ?? '')) ?>" placeholder="如:青岛">
</div>
<div class="field full">
<label for="t-sum">一句话小结</label>
<textarea class="input" id="t-sum" name="summary" rows="3"
placeholder="回看时最能勾起记忆的那一句"><?= e(old('summary', $t['summary'] ?? '')) ?></textarea>
</div>
<div class="field full">
<label for="t-note">备注</label>
<textarea class="input" id="t-note" name="note" rows="3"><?= e(old('note', $t['note'] ?? '')) ?></textarea>
</div>
<div class="field full">
<label>同行的人</label>
<?php if (!$people): ?>
<p class="empty-hint">名册里还没有人。</p>
<?php else: ?>
<div class="pick-list">
<?php foreach ($people as $p): ?>
<label class="pick-item">
<input type="checkbox" name="attendees[]" value="<?= (int)$p['id'] ?>"
<?= in_array((int)$p['id'], $selIds, true) ? 'checked' : '' ?>>
<?php if (!empty($p['avatar'])): ?>
<img class="pick-ph" src="<?= e(media_url((string)$p['avatar'])) ?>" alt="">
<?php else: ?>
<span class="pick-ph"></span>
<?php endif; ?>
<span class="pick-name"><?= e(person_name($p)) ?></span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<div class="field full">
<label for="t-cover">封面图</label>
<?php if ($isEdit && !empty($t['cover_key'])): ?>
<div class="cover-now">
<img src="<?= e(media_url((string)$t['cover_key'])) ?>" alt="">
<span class="tip">不换图就不用管这里;选一张新图会替换它。</span>
</div>
<?php endif; ?>
<input class="input" type="file" id="t-cover" name="cover" accept="image/*">
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEdit ? '保存' : '记下来' ?></button>
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">取消</a>
</div>
</form>
</div>
+71
View File
@@ -0,0 +1,71 @@
<?php
/**
* 旅程列表(v12,TripLog activities/index 迁来,但按家书的统一事件原语重写)。
*
* 与观影列表同一个骨架:页头 + 搜索 + 卡片列表。
* ⚠️ 卡片上的人是「我叫他什么」(person_chip),不是姓名(用户第 3 条)。
*/
$canWrite = \App\Auth::canWrite();
?>
<div class="page-head">
<h1>旅程</h1>
<span class="sub">共 <?= (int)$total ?> 趟</span>
<div class="actions">
<?php if ($canWrite): ?>
<a class="btn" href="<?= e(url('trips/new')) ?>">记一趟旅程</a>
<?php else: ?>
<span class="link-quiet">只读账号</span>
<?php endif; ?>
</div>
</div>
<form class="search" method="get" action="index.php">
<input type="hidden" name="r" value="trips">
<input class="input" type="text" name="keyword" value="<?= e($keyword) ?>" placeholder="搜标题 / 目的地 / 备注">
<button class="btn" type="submit">搜索</button>
<?php if ($keyword !== ''): ?>
<a class="search-clear" href="<?= e(url('trips')) ?>">清除</a>
<?php endif; ?>
</form>
<?php if (!$rows): ?>
<div class="card card-pad empty">
<b><?= $keyword !== '' ? '没有匹配的旅程' : '还没有旅程记录' ?></b>
<?= $keyword !== '' ? '换个关键词试试,或清除搜索看全部。'
: ($canWrite ? '点右上角「记一趟旅程」,把一次出行记下来。' : '家人还没有记录旅程。') ?>
</div>
<?php else: ?>
<div class="trip-list">
<?php foreach ($rows as $t): ?>
<a class="trip-card" href="<?= e(url('trips/show', ['id' => (int)$t['id']])) ?>">
<div class="trip-cover">
<?php if (!empty($t['cover_key'])): ?>
<img src="<?= e(media_url((string)$t['cover_key'])) ?>" alt="">
<?php else: ?>
<span class="trip-cover-empty">🧳</span>
<?php endif; ?>
</div>
<div class="trip-main">
<h2 class="trip-title"><?= e((string)$t['title']) ?></h2>
<div class="trip-meta">
<span><?= e(\App\Trip::formatSpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span>
<?php if (!empty($t['location'])): ?>
<span>📍 <?= e((string)$t['location']) ?></span>
<?php endif; ?>
<span><?= (int)($t['node_count'] ?? 0) ?> 段行程</span>
</div>
<?php if (!empty($t['summary'])): ?>
<p class="trip-sum"><?= e(mb_substr((string)$t['summary'], 0, 80, 'UTF-8')) ?></p>
<?php endif; ?>
<?php if (!empty($t['attendees'])): ?>
<div class="trip-people">
<?php foreach ($t['attendees'] as $a): ?>
<?= person_chip($a) ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
+180
View File
@@ -0,0 +1,180 @@
<?php
/**
* 旅程详情(v12):封面 + 小结 + 同行的人 + 照片 + 行程节点(计划 / 执行双轨)。
*
* ⚠️ 双轨的意义:出发前排的是「打算怎么走」,走完记的是「实际怎么走的」。
* 两轨并存才能回看"本来想去哪、后来去了哪"—— 这是 TripLog 最有价值的一点,搬过来时保留了。
*/
$canWrite = \App\Auth::canWrite();
$plan = [];
$reality = [];
foreach ((array)($nodes ?? []) as $n) {
if (($n['category'] ?? 'plan') === 'reality') {
$reality[] = $n;
} else {
$plan[] = $n;
}
}
$memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'id'));
?>
<div class="page-head">
<h1><?= e((string)$t['title']) ?></h1>
<span class="sub"><?= e(\App\Trip::formatSpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span>
<div class="actions">
<?php if ($canWrite): ?>
<a class="btn btn-ghost" href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">编辑</a>
<form method="post" action="<?= e(url('trips/destroy')) ?>"
onsubmit="return confirm('确定删除「<?= e((string)$t['title']) ?>」吗?照片与行程节点会一起删掉,不可恢复。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-danger" type="submit">删除</button>
</form>
<?php endif; ?>
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回旅程</a>
</div>
</div>
<div class="card card-pad trip-hero">
<div class="trip-hero-cover">
<?php if (!empty($t['cover_key'])): ?>
<img src="<?= e(media_url((string)$t['cover_key'])) ?>" alt="">
<?php else: ?>
<span class="trip-cover-empty">🧳</span>
<?php endif; ?>
</div>
<div class="trip-hero-body">
<?php if (!empty($t['location'])): ?>
<p class="trip-loc">📍 <?= e((string)$t['location']) ?></p>
<?php endif; ?>
<?php if (!empty($t['summary'])): ?>
<p class="info-text"><?= nl2br(e((string)$t['summary'])) ?></p>
<?php endif; ?>
<?php if (!empty($t['attendees'])): ?>
<div class="viewers-list">
<?php foreach ($t['attendees'] as $a): ?>
<?= person_chip($a, url('people/show', ['id' => (int)$a['id']])) ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($t['note'])): ?>
<p class="remark-text"><?= nl2br(e((string)$t['note'])) ?></p>
<?php endif; ?>
</div>
</div>
<?php /* ---- 照片 ---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">照片</h2>
<?php if (!$photos): ?>
<p class="empty-hint">还没有照片。</p>
<?php else: ?>
<div class="photo-grid">
<?php foreach ($photos as $ph): ?>
<figure class="photo-cell">
<a href="<?= e(media_url((string)$ph['key'])) ?>" target="_blank" rel="noopener">
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="<?= e((string)($ph['name'] ?? '')) ?>">
</a>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('trips/photo_del')) ?>" class="photo-del">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$ph['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这张照片?')">删除</button>
</form>
<?php endif; ?>
</figure>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canWrite): ?>
<form class="inline-form" method="post" enctype="multipart/form-data" action="<?= e(url('trips/photo_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input type="file" name="photo" accept="image/*" multiple required>
<button class="btn btn-sm" type="submit">上传照片</button>
</form>
<?php endif; ?>
</div>
<?php /* ---- 行程节点(双轨)---- */ ?>
<?php foreach ([['plan', '计划行程', $plan], ['reality', '实际走过', $reality]] as [$cat, $catName, $list]): ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= e($catName) ?> <span class="group-n"><?= count($list) ?> 段</span></h2>
<?php if (!$list): ?>
<p class="empty-hint"><?= $cat === 'plan' ? '还没有排计划。' : '还没有记录实际行程。' ?></p>
<?php else: ?>
<ol class="node-list">
<?php foreach ($list as $n): ?>
<li class="node-row status-<?= e((string)$n['status']) ?>">
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'])) ?>">
<?= e(\App\Trip::typeIcon((string)$n['node_type'])) ?>
</span>
<div class="node-body">
<div class="node-head">
<b><?= e((string)$n['title']) ?></b>
<span class="node-badge"><?= e(\App\Trip::statusWord((string)$n['status'])) ?></span>
<?php if (!empty($n['place'])): ?>
<span class="node-place">📍 <?= e((string)$n['place']) ?></span>
<?php endif; ?>
</div>
<?php $span = \App\Trip::formatSpan($n['starts_at'] ?? null, $n['ends_at'] ?? null); ?>
<?php if ($span !== '日期待补'): ?>
<div class="node-time"><?= e($span) ?></div>
<?php endif; ?>
<?php if (!empty($n['detail'])): ?>
<p class="node-detail"><?= nl2br(e((string)$n['detail'])) ?></p>
<?php endif; ?>
<?php if (!empty($n['members'])): ?>
<div class="trip-people">
<?php foreach ($n['members'] as $mm): ?><?= person_chip($mm) ?><?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($n['attachs'])): ?>
<div class="attach-row">
<?php foreach ($n['attachs'] as $at): ?>
<a class="attach-item" href="<?= e(media_url((string)$at['key'], ['dl' => 1])) ?>">
<?= (int)$at['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($at['name'] ?: '附件')) ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<div class="node-ops">
<details class="node-edit">
<summary class="link-quiet">编辑</summary>
<?php
$nfNode = $n; $nfTripId = (int)$t['id']; $nfCategory = $cat;
$nfPeople = $people ?? [];
$nfMembers = array_map('intval', array_column((array)($n['members'] ?? []), 'id'));
require __DIR__ . '/_node_form.php';
?>
</details>
<form method="post" action="<?= e(url('trips/node_del')) ?>"
onsubmit="return confirm('删掉这一段?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">删除</button>
</form>
</div>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ol>
<?php endif; ?>
<?php if ($canWrite): ?>
<details class="node-add">
<summary class="link-quiet">+ 加一段<?= e($catName) ?></summary>
<?php
$nfNode = null; $nfTripId = (int)$t['id']; $nfCategory = $cat;
$nfPeople = $people ?? []; $nfMembers = $memberIds;
require __DIR__ . '/_node_form.php';
?>
</details>
<?php endif; ?>
</div>
<?php endforeach; ?>