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:
@@ -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; ?>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
<!-- 生日块已挪到表单最底部:大多数情况下生日由身份证号读出、三个都对,
|
||||
不需要动(用户原话:生日什么的应该放下面)。 -->
|
||||
|
||||
@@ -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'];
|
||||
?>
|
||||
|
||||
@@ -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; ?>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
?>
|
||||
|
||||
@@ -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>
|
||||
@@ -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; ?>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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; ?>
|
||||
@@ -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; ?>
|
||||
Reference in New Issue
Block a user