Files
FamilyBook/templates/settings/me.php
T
laoyang 075ac6bbeb v44 第 44 批:界面 5 条 + 移动端 API v1 全量
界面(用户 2026-10-12 第 1~5 条)
1. 新建日程「日历本」并入 .cs 自绘下拉,颜色画进候选项,三字与下拉同行。
2. 新建日程「开始/结束」改「日期范围:」,删旧日期控件;
   重复相关三行仅选重复时出现;删「(含第一次)」与「这段时间算忙碌」。
3. 条目浮层加「地点」、去掉「状态」行;首页红线按分钟定位(原只取整小时);
   去掉左侧红点只留红线。
4. 任务页删掉状态说明提示(STATUS_HINT 三值清空)。
5. 新建任务:标题在前树框在后;截止时间改最新日历控件;负责人默认自己;
   删冗余提示;「上面要做什么」→「任务名称」;清理括号说明。

移动端 API(用户第 6 条:导航模块除外,其余全量)
- 新增 Bearer 令牌鉴权(api_tokens 表,Schema v32),与登录口令 / DAV 口令三套分立。
- Api.php 内核(鉴权/取参/信封/分页/分发 + _method 覆写)+
  ApiHandler.php 17 条资源 + ApiToken.php + api.php 入口。
- 路由:router.php / .htaccess / index.php 三处 /api/ 兜底(部署脚本自动追加线上规则)。
- 个人设置新增「App 接口令牌」卡;docs/API.md 接口文档;
  docs/API-QUESTION-LIST.md 自主决策与待确认缺口清单。

验证:本地 e2e 1510/0、v43 真机 33/0、v43 反向 9/0、
v44 真机 41/0、v44 API 63/0、v44 反向 19/0;全流水线 PUSH_RC=0。
2026-10-12 15:31:03 +08:00

462 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 个人设置 —— 「我自己的」设置。
*
* 与「系统设置」(?r=settings,仅管理员)的分工:
* · 系统设置改一次**全家都变**:slogan 兜底、附件存储、运行环境、**公共分组**;
* · 个人设置只影响**我自己看到的样子**:我自己的分组、天气地点、首页与导航的模块。
*
* ⚠️ 「我的资料」表单**不在这页**(用户明确要求合并):
* 「编辑成员」和「改自己的资料」本来就是一件事,所以只剩
* `templates/people/form.php` 那一张表单,两个入口都指过去。
*
* ────────────────────────────────────────────────────────────────
* v24 第 20 批 第 2 条(用户明确"不要了"的三处,都已删除):
* ① 页头那句副标题「只影响你自己看到的样子」—— **删掉**(用户点名);
* ② 「我怎么称呼家人」整张卡片(templates/settings/_nicknames.php)
* —— **不再加载**。⚠️ 「我叫他什么」这个能力本身还在(名册/详情页里照常出现、
* `person_name()` 照常按昵称显示);只是不再在这页单独占一张卡。
* 所以是"撤掉入口"而不是"删功能",数据一个字没动。
* ③ 「我的资料」卡片(姓名/头像/生日那句 + 两个按钮)—— **删掉**。
* ⚠️ 改资料的入口在别处都还有:右上角头像下拉 →「个人设置」进来后有页头那个
* 「编辑我的资料」按钮,名册与详情页也各有「编辑资料」;所以不会"进不去"。
* 页头那个按钮**保留**(它就是"我的资料"现在唯一的入口,删了就真没地方点了)。
*/
$groupCounts = $groupCounts ?? [];
$groupTotal = count($groups);
$rosterCount = $rosterCount ?? 0;
?>
<div class="page-head">
<h1>个人设置</h1>
<?php /* v24 第 20 批 第 2 条:副标题「只影响你自己看到的样子」按用户要求删除 */ ?>
<div class="actions">
<a class="btn btn-sm" href="<?= e(url('people/edit', ['id' => (int)$person['id']])) ?>">编辑我的资料</a>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => (int)$person['id']])) ?>">看我的详情页</a>
<?php if (\App\Auth::isAdmin()): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('settings')) ?>">系统设置</a>
<?php endif; ?>
</div>
</div>
<?php /* v24 第 20 批 第 2 条:原来这里有两块,用户要求都删掉 ——
① 「我的资料」卡片(姓名 + slogan + 「编辑我的资料 / 改登录密码」两个按钮)
② 「我怎么称呼家人」卡片(require _nicknames.php)
下面直接接天气地点。改登录密码在右上角头像下拉里也有入口。 */ ?>
<!-- ==================== DAV 同步(v24 第 22 批)==================== -->
<?php /* 用户原话:「caldav和cardav,都增加一个创建口令的功能,这样,就使用用户名和口令
来同步,避免在第三方客户端进行同步时自己的密码泄露。」
为什么这张卡在**个人设置**而不是「日程 → 日历本」:
它管的是"我的凭据",同时服务**日历(CalDAV)与通讯录(CardDAV)**两件事 ——
放在日历页会让只想同步通讯录的人在日程里找半天。
⚠️ 明文口令**只显示一次**(走一次性会话槽位,见 render_me_settings 末尾的 unset):
设备上要填的是这串,页面上能反复看到等于把它变成"又一个可被偷看的密码"。
⚠️ 口令与登录密码**必须**分开:第三方客户端会把这串长期存在设备上并反复自动发送,
用登录密码就等于把主密码复制到每一台设备(这正是用户要避开的事)。 */ ?>
<div class="card card-pad" id="dav" style="margin-top:20px">
<h2 class="section-title">
DAV 同步
<span class="hint">日历本 + 通讯录,一个口令在手机 / 电脑上同步</span>
</h2>
<p class="small muted" style="margin:0 0 14px">
在 iOS「日历/通讯录」、安卓 <b>DAVx⁵</b>、Thunderbird、群晖日历里,
填下面这两个地址 + <b>你的姓名或手机号</b> + <b>DAV 口令</b>,就能把
<b>我的日历本</b>和<b>我的名册</b>同步过去。
<br><b>口令和登录密码是分开的</b> —— 第三方客户端会把凭据长期存在设备上并反复自动发送,
用登录密码等于把主密码复制到每一台设备;所以这里单独发一个,随时可换、不影响登录。
<br>目前是<b>只读</b>同步:在手机上改日程 / 加联系人<b>不会</b>写回家书,
要改请回到本站来改。
</p>
<div class="form-grid">
<div class="field full">
<label>日历(CalDAV)地址</label>
<input class="input mono" readonly
value="<?= e((string)$davBase . '/dav/') ?>">
</div>
<div class="field full">
<label>通讯录(CardDAV)地址</label>
<input class="input mono" readonly
value="<?= e((string)$davBase . '/carddav/') ?>">
<span class="tip">
同步的是「我的名册」里那 <b><?= (int)$davRosterN ?></b> 个人 —— 和你名册页上看到的一致。
</span>
</div>
<?php /* 🔴 v31:客户端(DAVx⁵ / iOS)添加账号时有两条路:
① 只填**服务器地址**(不带 /dav/、/carddav/)让它自己发现 —— 走
`/.well-known/caldav`;② 直接填完整路径(上面两个框)。
以前只支持②,而很多客户端的引导界面**只让填域名**,
于是"填完点保存就失败",用户看到的就是「同步不出任何数据」。
现在两条路都通了(见 dav.php / carddav.php 的发现链实现),
这里把①也写出来,免得用户还得自己猜。 */ ?>
<div class="field full">
<label>或者只填服务器地址(让客户端自动发现)</label>
<input class="input mono" readonly value="<?= e((string)$davBase) ?>">
<span class="tip">
填这个也行 —— 客户端会自动向
<span class="mono"><?= e((string)$davBase . '/.well-known/caldav') ?></span>
问在哪里(RFC 6764)。iOS 的「日历账户 → 添加 CalDAV 账户」就推荐填这一种。
</span>
</div>
<div class="field">
<label>用户名</label>
<input class="input mono" readonly value="<?= e((string)$person['name']) ?>">
<span class="tip">也可以填手机号,两个都认。</span>
</div>
<div class="field">
<label>口令</label>
<?php /* ⚠️ 一行只能有一个 value:库里有的是 sha256,**取不回明文**,
所以已经配过就显示一排圆点(表示"有,但不给你看"),
没配过就写一句说明。真正的明文只在刚生成那一次显示(上面那块黄条)。 */ ?>
<input class="input mono" readonly
value="<?= !empty($davHas) ? '••••••••••••••••••••••••' : '还没生成,点下面的按钮' ?>">
<span class="tip">生成后<b>只显示一次</b>,请先复制到设备里再关页面。</span>
</div>
</div>
<?php if (!empty($davPlain)): ?>
<p class="tip" style="background:#FFF6E5;border-color:#E8C98A;padding:10px 12px;border-radius:8px">
<b>这是你的新口令,只显示这一次,请立刻复制到设备的「密码」栏里:</b>
<br><code class="mono" style="font-size:15px"><?= e((string)$davPlain) ?></code>
</p>
<?php endif; ?>
<?php if (\App\Auth::canWrite()): ?>
<div class="btn-row">
<form method="post" action="<?= e(url('settings/dav_token')) ?>" class="res-inline">
<?= csrf_field() ?>
<button class="btn btn-sm" type="submit"><?= !empty($davHas) ? '重置 DAV 口令' : '生成 DAV 口令' ?></button>
</form>
<?php if (!empty($davHas)): ?>
<form method="post" action="<?= e(url('settings/dav_revoke')) ?>" class="res-inline">
<?= csrf_field() ?>
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('撤销后,所有已同步的客户端(日历与通讯录)都会失去访问。确定吗?')">撤销口令</button>
</form>
<span class="node-badge">口令已配置<?= !empty($davLastUsed) ? ' · 最近使用 ' . e((string)$davLastUsed) : ' · 还没被用过' ?></span>
<?php else: ?>
<span class="node-badge">还没配置口令</span>
<?php endif; ?>
</div>
<?php if (!empty($davHas)): ?>
<p class="small muted" style="margin:10px 0 0">
「重置」会立刻换一串新口令:<b>已经配好的设备会连接失败</b>,
需要把新口令重新填进去。怀疑口令外泄时就用它。
</p>
<?php endif; ?>
<?php endif; ?>
</div>
<!-- ==================== 移动端 API 令牌(v44 用户第 6 条)==================== -->
<?php /* 用户原话:「我们现在马上要开始移动终端的开发,所以现在要开发所有接口。」
这张卡是**发令牌**的地方 —— 没有它,App 连不上(接口做完了也没入口)。
为什么与 DAV 口令**分开**:
· DAV 那把是给第三方同步客户端(DAVx⁵ / iOS 日历)的,走 Basic 鉴权、
明文要手打进手机、一把管日历+通讯录;
· API 令牌是给**自家 App** 的,走 `Authorization: Bearer`、可多枚
(一台设备一枚)、可逐枚吊销、可设有效期、带设备名。
两件事的生命周期完全不同(吊销 DAV 口令会连带断掉日历同步;
吊销一枚 App 令牌不该影响别人的手机)。 */ ?>
<div class="card card-pad" id="api" style="margin-top:20px">
<h2 class="section-title">
App 接口令牌
<span class="hint">移动端 / 第三方程序调用用</span>
</h2>
<p class="small muted" style="margin:0 0 14px">
接口地址前缀 <span class="mono"><b><?= e((string)$apiBase) ?>/api/v1/</b></span>,
请求头带 <span class="mono">Authorization: Bearer &lt;令牌&gt;</span>。
先调 <span class="mono">GET <?= e((string)$apiBase) ?>/api/v1/ping</span> 探连通(免令牌)。
<br>完整接口清单见仓库里的 <span class="mono">docs/API.md</span>。
</p>
<div class="form-grid">
<div class="field full">
<label>接口根地址</label>
<input class="input mono" readonly value="<?= e((string)$apiBase . '/api/v1') ?>">
</div>
<div class="field">
<label>用户名 / 手机号</label>
<input class="input mono" readonly value="<?= e((string)$person['name']) ?>">
<span class="tip">令牌本身就是凭据,这一行只是给你对账用。</span>
</div>
</div>
<?php if (!empty($apiPlain)): ?>
<p class="tip" style="background:#FFF6E5;border-color:#E8C98A;padding:10px 12px;border-radius:8px">
<b>这是你的新令牌,只显示这一次,请立刻复制到 App 里:</b>
<?php if ((string)$apiLabel !== ''): ?>
(设备名:<?= e((string)$apiLabel) ?>)
<?php endif; ?>
<br><code class="mono" style="font-size:14px;word-break:break-all"><?= e((string)$apiPlain) ?></code>
</p>
<?php endif; ?>
<?php if (!empty($apiTokens)): ?>
<table class="res-table" style="width:100%;margin:6px 0 10px">
<thead>
<tr><th>设备名</th><th>签发时间</th><th>最近使用</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($apiTokens as $tk): ?>
<tr>
<td><?= e((string)($tk['label'] ?: '(未命名)')) ?></td>
<td class="small muted"><?= e((string)($tk['created_at'] ?? '')) ?></td>
<td class="small muted"><?= e((string)($tk['last_used_at'] ?? '还没用过')) ?></td>
<td>
<form method="post" action="<?= e(url('settings/api_revoke')) ?>" class="res-inline"
onsubmit="return confirm('吊销这枚令牌?这台设备会立刻掉线。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$tk['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">吊销</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p class="small muted">还没有令牌。给每台设备发一枚吧。</p>
<?php endif; ?>
<?php if (\App\Auth::canWrite()): ?>
<div class="btn-row">
<form method="post" action="<?= e(url('settings/api_token')) ?>" class="res-inline">
<?= csrf_field() ?>
<input class="input" name="label" placeholder="设备名(如:我的手机)" style="max-width:200px">
<button class="btn btn-sm" type="submit">生成新令牌</button>
</form>
<?php if (!empty($apiTokens)): ?>
<form method="post" action="<?= e(url('settings/api_revoke')) ?>" class="res-inline"
onsubmit="return confirm('吊销全部令牌?所有 App 都要重新登录。')">
<?= csrf_field() ?>
<button class="btn btn-ghost btn-sm" type="submit">吊销全部</button>
</form>
<?php endif; ?>
</div>
<p class="small muted" style="margin:10px 0 0">
令牌只在生成那一次显示明文(库里只存哈希)。手机丢了就吊销那一枚。
</p>
<?php endif; ?>
</div>
<!-- ==================== 天气地点(用户 2026-10-03 第 1 条)==================== -->
<?php /* 原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
所以地点从系统设置搬到了这里 —— 而且改成**城市名**,不是经纬度:
填坐标是我们要解决的实现细节(App\Geo 内置 197 个地级市离线查),
不该甩给用户。内置表里没有的(镇 / 村 / 海外)才需要填坐标。 */ ?>
<div class="card card-pad" id="weather" style="margin-top:20px">
<?php /* v24 第 21 批 第 3 条(用户原话):
「天气设置那里,把:只影响你自己首页上的那一格 / 填个城市名就行(如「青岛」)。
首页那句问候旁边会显示这儿的天气 —— 每人一份,孩子在外地读书,
他填他的、你看你的。/ 留空就是不显示天气(不影响别的东西)。 这些提示,都删除。
把城市单独放一行,精度维度放在一行。」
→ ① 标题旁那句 hint、标题下那整段说明,**全删**(三处提示);
② 版式改成两行:第一行「城市」独占一行(它是要填的那个,得宽敞),
第二行「纬度 / 经度」并排一行。
⚠️ 删的只是**说明文字**:城市/纬度/经度三个输入框、保存按钮、
以及下面那句"现在按 XX 取天气"的状态回显**都保留** ——
那些是"填了之后发生了什么的反馈",不是啰嗦的提示。 */ ?>
<h2 class="section-title">天气 · 我所在的城市</h2>
<form method="post" action="<?= e(url('settings/weather')) ?>">
<?= csrf_field() ?>
<?php /* 第一行:城市单独一行(占满宽度) */ ?>
<div class="field">
<label for="w-city">城市</label>
<input class="input" id="w-city" name="city" maxlength="30"
value="<?= e((string)($weatherCity ?? '')) ?>"
placeholder="如:青岛 / 山东青岛 / 内蒙古包头" autocomplete="off">
</div>
<?php /* 第二行:精度维度(纬度 / 经度)同一行 */ ?>
<div class="loc-row">
<div class="field">
<label for="w-lat">纬度(可选)</label>
<input class="input" id="w-lat" name="lat" value="<?= e((string)($weatherLat ?? '')) ?>"
placeholder="内置表里没有的地方才需要">
</div>
<div class="field">
<label for="w-lon">经度(可选)</label>
<input class="input" id="w-lon" name="lon" value="<?= e((string)($weatherLon ?? '')) ?>"
placeholder="如 120.38">
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit">保存天气地点</button>
</div>
</form>
<?php if (!empty($weatherAt)): ?>
<p class="small muted" style="margin:12px 0 0">
现在按 <b><?= e((string)$weatherAt['place']) ?></b>
(<?= e((string)$weatherAt['lat']) ?> , <?= e((string)$weatherAt['lon']) ?>)取天气。
<?php if (($weatherCity ?? '') !== ''): ?>
不是这里?换个写法试试,比如加上省份:「山东青岛」。
<?php endif; ?>
</p>
<?php elseif (($weatherCity ?? '') !== ''): ?>
<p class="small muted" style="margin:12px 0 0">
<b class="warn-label">这个城市名没认出坐标</b>(首页就看不到天气了)。
换个写法试试(如「山东青岛」),或者直接填经纬度。
</p>
<?php endif; ?>
</div>
<?php
// 内置表里有几个城市,直接说清楚 —— 用户不用猜"到底支持哪些"
$fbCityN = count(\App\Geo::CITIES);
?>
<p class="small muted" style="margin:10px 2px 0">
内置了 <b><?= (int)$fbCityN ?></b> 个城市(全国地级市 + 直辖市),一般常用的都在里面。
</p>
<!-- ==================== 我的分组 ==================== -->
<div class="card card-pad" style="margin-top:20px">
<h2 class="section-title">
我的分组
<span class="hint">公共的 + 我自己加的</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
名册是按「关系分组」分栏显示的。这里有两层:
<b>公共分组</b>由管理员在「系统设置」里维护(全家都能用);
你要是觉得不够,就在下面<b>加只给自己看的分组</b>,别人看不到、也不受影响。
<br>
列表里 <b>{{ 公共 }}</b> 标的是大家共用的那几个。
哪一组排前面也是你自己的习惯,用 ▲ ▼ 调 —— 别人看到的顺序不变。
<br>
你的名册现在有 <b><?= (int)$rosterCount ?></b> 人。
</p>
<?php if (!$groups): ?>
<p class="small muted" style="margin:0 0 14px">还没有分组。加一个吧。</p>
<?php else: ?>
<ol class="order-list">
<?php foreach ($groups as $i => $g): $gid = (int)$g['id']; $own = (int)$g['owner_id']; ?>
<li class="order-row">
<span class="order-no"><?= $i + 1 ?></span>
<span class="order-name">
<?= e((string)$g['name']) ?>
<?php if ($own === 0): ?><span class="badge">公共</span><?php else: ?><span class="badge badge-open">我的</span><?php endif; ?>
</span>
<span class="order-count"><?= (int)($groupCounts[$gid] ?? 0) ?> 人</span>
<form method="post" action="<?= e(url('settings/groups/move')) ?>" class="order-move">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $gid ?>">
<input type="hidden" name="scope" value="me">
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="up"
title="上移一组" <?= $i === 0 ? 'disabled' : '' ?>>▲</button>
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="down"
title="下移一组" <?= $i === $groupTotal - 1 ? 'disabled' : '' ?>>▼</button>
</form>
</li>
<?php endforeach; ?>
</ol>
<?php endif; ?>
<form method="post" action="<?= e(url('settings/groups/add')) ?>" class="inline-form" style="margin-top:16px">
<?= csrf_field() ?>
<input type="hidden" name="scope" value="me">
<label class="sr-only" for="newMyGroup">新分组名</label>
<input class="input" id="newMyGroup" name="name" maxlength="20" placeholder="例如:球友、老同学">
<button class="btn btn-sm" type="submit">加一个我自己的分组</button>
</form>
<?php
// 只有我自己建的分组才给改名 / 删除 —— 公共分组要在系统设置里改(那是管理员的事),
// 免得有人在自己设置里把"家人"改成别的、影响全家。
$mineGroups = array_values(array_filter($groups, fn($g) => (int)$g['owner_id'] > 0));
?>
<?php if ($mineGroups): ?>
<ul class="group-manage" style="margin-top:14px">
<?php foreach ($mineGroups as $g): $gid = (int)$g['id']; ?>
<li class="group-manage-row">
<form method="post" action="<?= e(url('settings/groups/rename')) ?>" class="group-rename">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $gid ?>">
<input type="hidden" name="scope" value="me">
<label class="sr-only" for="gname<?= $gid ?>">分组名</label>
<input class="input" id="gname<?= $gid ?>" name="name" maxlength="20"
value="<?= e((string)$g['name']) ?>">
<button class="btn btn-ghost btn-sm" type="submit">改名</button>
</form>
<span class="group-count muted small"><?= (int)($groupCounts[$gid] ?? 0) ?> 人</span>
<?php /* 删除做成两步(<details>),不用 JS 也能防手滑 —— 一点就没了太容易出事 */ ?>
<details class="del-wrap">
<summary class="btn btn-ghost btn-sm">删除</summary>
<form method="post" action="<?= e(url('settings/groups/delete')) ?>" class="del-confirm">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $gid ?>">
<input type="hidden" name="scope" value="me">
<span class="small muted">这组的人会变成「未分类」,确定?</span>
<button class="btn btn-ghost btn-sm danger" type="submit">确认删除</button>
</form>
</details>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if (\App\Auth::isAdmin()): ?>
<p class="small muted" style="margin:16px 0 0">
公共分组在「<a href="<?= e(url('settings')) ?>#public-groups">系统设置</a>」里维护。
</p>
<?php endif; ?>
</div>
<!-- ==================== 首页上的模块(用户 2026-10-03 第 3 条)==================== -->
<?php /* 与下面「导航条上的模块」是同一套交互(勾选 + 拖动/▲▼ 排序),
只是作用对象换成首页 dashboard 上那几张模块卡。
两块都要有:导航条管"能不能进",这里管"首页上先看到什么"。 */ ?>
<div class="card card-pad" id="home-mods" 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/home_save')) ?>" id="homeForm">
<?= csrf_field() ?>
<ol class="order-list mod-order" id="homeOrderList">
<?php
$hOn = array_map('strval', (array)($homeOn ?? []));
$hOrder = (array)($homeOrder ?? []);
?>
<?php foreach ($hOrder as $i => $k): ?>
<li class="order-row mod-row" draggable="true" data-key="<?= e($k) ?>">
<span class="drag-handle" title="拖我排序" aria-hidden="true">⋮⋮</span>
<input type="hidden" name="order[]" value="<?= e($k) ?>">
<label class="mod-pick">
<input type="checkbox" name="on[]" value="<?= e($k) ?>"
<?= in_array($k, $hOn, true) ? 'checked' : '' ?>>
<span class="color-preview"
style="background: <?= e(\App\Module::color($k, (int)\App\Auth::familyId())) ?>;"></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($hOrder) - 1 ? 'disabled' : '' ?>>▼</button>
</span>
</li>
<?php endforeach; ?>
</ol>
<div class="btn-row">
<button class="btn" type="submit" name="save" value="1">保存首页显示</button>
</div>
</form>
</div>
<!-- ==================== 模块:导航条上显示哪些、各自什么主题色 ==================== -->
<?php
$navKeys = \App\Module::navFor((int)\App\Auth::personId());
$colors = \App\Module::colors((int)\App\Auth::familyId());
require __DIR__ . '/_modules.php';
?>