Files
FamilyBook/templates/settings/me.php
T
laoyang ebaed21920 v57:令牌升级为「设备会话」(24h 滑动续期 / 每账号 10 台 / 网页端远程退出)
用户 2026-10-17 定的模型:
  · 一次登录 = 一台设备一条会话;同一台设备重登,旧令牌立即作废
  · 24 小时滑动续期(一直联网就不用每天输密码);离线超 24h 只锁界面、不删数据
  · 网页端「个人设置 → 设备管理」能看见都有谁在登录,并能把丢了的那台踢掉
  · 每账号最多 10 台,超了让用户先踢旧的

服务端配套:
  · api_tokens 加 device_id / device_name / platform / app_version / last_ip
    + idx_api_device;历史令牌回填 legacy-<id>(不回填 = 踢不掉的幽灵会话)
  · ApiToken:issueDevice / listDevices / revokeDevice / revokeOthers / revokeCurrent
    + 滑动续期(剩余不足 4h 才写库);verify 同时返回数字键与关联键(三处调用方在用)
  · 新增 /api/v1/logout、/api/v1/devices;ping 报 features.device_session
  · 登录不再读 days(0 = 永不过期会绕过 24h 这条线);超限回 409 且**不动任何一行**
  · 老客户端兜底固定槽位 auto-unknown(随机设备号会撞满 10 台把账号锁死)
  · Schema 34 → 35

顺带:首页近期任务/往年的今天(Home::memories)、名册派生字段口径统一。

验证:设备会话探针 56/0、设备管理渲染 22/0、登录全链路 30/0、反向验证 12/0、
      e2e 1519/0、静态断言本地空跑 357/0、线上 /api/v1/{ping,devices,logout} 复核通过。
2026-10-18 03:34:47 +08:00

574 lines
31 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 令牌不该影响别人的手机)。 */ ?>
<!-- ==================== 设备管理(v57,用户 2026-10-17)==================== -->
<?php /* 用户原话:「在我们服务器端的个人设置那里面,就有一个这个设备管理,
就是说我都有哪些设备已经在登录这个东西。」
「允许我们的用户把其中的某一个客户端远程给它退出,相当于它这个 Token 给它删掉。
这样的话,这个应用一旦打开,就发现这个 Token 已经失效了,返回登录页面。」
⚠️ 这里列的是**设备**(按 device_id 聚合),不是令牌行:
同一台手机重登过 5 次,这一页只该出现 1 行。
⚠️ 文案里必须说清"下次联网才生效" —— 服务端没有推送通道,
写"立刻失效"是骗人;而 App 端每次启动都会打 `/me`,所以体感上很快。 */ ?>
<div class="card card-pad" id="devices" style="margin-top:20px">
<h2 class="section-title">
设备管理
<span class="hint">哪些手机 / 平板正在登录这个账号</span>
</h2>
<p class="small muted" style="margin:0 0 14px">
在 App 里每登录一次,这里就多一台设备(最多 <?= (int)($deviceMax ?? 10) ?> 台)。
令牌有效期 <?= (int)($deviceTtl ?? 24) ?> 小时,
<b>只要设备在联网就会自动续期</b>,所以常用的手机不需要反复登录。
<br>手机丢了就在这里「移除」—— 那台设备下次联网时会立刻退回登录页,
本机已下载的数据将无法查看。
</p>
<?php $deviceRows = $devices ?? []; ?>
<?php if (!empty($deviceRows)): ?>
<table class="res-table" style="width:100%;margin:6px 0 10px">
<thead>
<tr><th>设备</th><th>系统 / 客户端</th><th>最近活跃</th><th>登录时间</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($deviceRows as $d): ?>
<?php
$dvName = (string)($d['device_name'] ?? '');
$dvPlat = trim((string)($d['platform'] ?? ''));
$dvVer = (string)($d['app_version'] ?? '');
if ($dvVer !== '') {
$dvPlat = ($dvPlat !== '' ? $dvPlat . ' · ' : '') . '客户端 ' . $dvVer;
}
$dvId = (string)($d['device_id'] ?? '');
?>
<tr>
<td>
<b><?= e($dvName !== '' ? $dvName : '未知设备') ?></b>
<?php if (strpos($dvId, 'legacy-') === 0): ?>
<span class="small muted">(升级前的旧会话)</span>
<?php elseif ($dvId === 'auto-unknown'): ?>
<span class="small muted">(旧版 App)</span>
<?php endif; ?>
</td>
<td class="small muted"><?= e($dvPlat !== '' ? $dvPlat : '—') ?></td>
<td class="small muted">
<?= e(((string)($d['last_seen_at'] ?? '')) !== '' ? (string)$d['last_seen_at'] : '还没用过') ?>
</td>
<td class="small muted"><?= e((string)($d['created_at'] ?? '')) ?></td>
<td>
<?php if (\App\Auth::canWrite()): ?>
<form method="post" action="<?= e(url('settings/device_remove')) ?>" class="res-inline"
onsubmit="return confirm('移除这台设备?它下次联网时会退回登录页,本机已下载的数据将无法查看。')">
<?= csrf_field() ?>
<input type="hidden" name="device_id" value="<?= e($dvId) ?>">
<button class="btn btn-ghost btn-sm" type="submit">移除</button>
</form>
<?php else: ?>
<span class="small muted">只读账号</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if (count($deviceRows) > 1 && \App\Auth::canWrite()): ?>
<div class="btn-row">
<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>
</div>
<?php endif; ?>
<?php else: ?>
<p class="small muted">还没有设备登录过。在 App 里用这个账号登录后,这里就会出现它。</p>
<?php endif; ?>
</div>
<div class="card card-pad" id="api" style="margin-top:20px">
<h2 class="section-title">
手动签发接口令牌
<span class="hint">第三方脚本 / 调试用;App 正常登录**不需要**这里</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">还没有手动签发的令牌。App 登录走上面的「设备管理」,不用在这里生成。</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>
<?php /* 🔴 v52(用户 2026-10-15 第 6 条):「原来个人设置那里的名册分组……
这样**个人设置中就不显示名册分组的内容**了。」
⇒ 整块卡片**搬到了**「名册 → 设置 → 名册标签」
(`templates/people/settings.php` + `templates/_person_groups.php`)。
⚠️ 是**真的搬走**,不是复制一份:两份一模一样的管理界面,
改一处忘一处是必然的。这里只留一句指路,免得老用户找不到。
🔴 v54(用户 2026-10-16 第 2 条):口径**从"分组"改成"标签"** ——
用户原话:「应该叫「名册标签」,是不是?」⇒ 这块指路文案也跟着改。 */ ?>
<div class="card card-pad" style="margin-top:20px">
<h2 class="section-title">名册标签</h2>
<p class="small muted" style="margin:0 0 12px">
标签改到**名册页**那边去了 —— 它本来就是"给名册里的人打什么标签"的事,与个人设置无关。
</p>
<div class="btn-row">
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/settings')) ?>">去名册设置里改标签</a>
</div>
</div>
<!-- ==================== 模块:导航 / 首页 / 颜色 / 顺序(v52 用户第 7 条)==================== -->
<?php /* 🔴🔴 v52(用户 2026-10-15 第 7 条):「个人设置里面,模块相关的设置太多了,应该**整合**,
先列出来所有模块,中间有一列,让他勾选是否在导航显示,再给他一列,让他选择是否在首页显示,
再给他一列,让他选择颜色,最后给他一列,让他**调整顺序**,这样就把几个设置,整合到一起了,
更加简洁。」
⇒ 原来是**三张卡、三个表单、三次保存**:
① 首页上的模块(勾选 + 排序)
② 导航条上的模块(勾选 + 排序)
③ 模块主题色(改色)
同一串模块名出现两遍、颜色又另起一张 —— 用户看得累、改起来要点三次保存。
⇒ 现在合成**一张表**:一行一个模块,四列 = 导航 / 首页 / 颜色 / 顺序。
提交走 `settings/modules_save`(它内部把三个 save 依次调一遍,见 index.php 的说明)。
⚠️ 颜色那一列的权限**没有变**:它仍是家庭一份、只有管理员能改;
非管理员那一列显示成只读色块(看得到、改不了)。
⚠️ 拖动排序仍搬整行(行自带 order[]),▲▼ 是没 JS 时的兜底 —— 两条路都在。 */
$plMAll = \App\Module::keys();
$plMNav = array_map('strval', (array)(\App\Module::navFor((int)\App\Auth::personId())));
$plMHome = array_map('strval', (array)(\App\HomeMods::forPerson((int)\App\Auth::personId())));
$plMColor = \App\Module::colors((int)\App\Auth::familyId());
$plMIsAdmin = \App\Auth::isAdmin();
// 行的顺序:导航里显示的按导航顺序在前(那是用户最在意的),其余按默认顺序在后
$plMOrder = [];
foreach ($plMNav as $k) { if (in_array($k, $plMAll, true)) { $plMOrder[] = $k; } }
foreach ($plMAll as $k) { if (!in_array($k, $plMOrder, true)) { $plMOrder[] = $k; } }
?>
<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>管"顶栏上有没有它",<b>首页</b>管"打开首页看不看得到",
<b>颜色</b>是进到那个模块后顶栏高亮的色(<b>全家共用一套</b>),
<b>顺序</b>用拖动手柄或 ▲▼ 调。
<br>关掉的模块<b>不会丢数据</b>,只是从顶栏 / 首页收起来,别处仍能进。
</p>
<form method="post" action="<?= e(url('settings/modules_save')) ?>" id="modulesForm">
<?= csrf_field() ?>
<div class="mod-table-wrap">
<table class="mod-table">
<thead>
<tr>
<th class="mod-th-order">顺序</th>
<th class="mod-th-name">模块</th>
<th class="mod-th-on">导航</th>
<th class="mod-th-on">首页</th>
<th class="mod-th-color">颜色</th>
</tr>
</thead>
<tbody id="modRows">
<?php foreach ($plMOrder as $i => $k): ?>
<tr class="mod-row" draggable="true" data-key="<?= e($k) ?>">
<td class="mod-td-order">
<span class="drag-handle" title="拖我排序" aria-hidden="true">⋮⋮</span>
<?php /* ⚠️ 顺序就藏在 DOM 里:每行自带 order[] 隐藏域,拖动搬整行 ⇒
提交上来的顺序天然就是屏幕上的顺序(与旧的两块同一手法)。 */ ?>
<input type="hidden" name="order[]" value="<?= e($k) ?>">
<span class="mod-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($plMOrder) - 1 ? 'disabled' : '' ?>>▼</button>
</span>
</td>
<td class="mod-td-name">
<span class="color-preview" style="background: <?= e((string)($plMColor[$k] ?? '#888')) ?>;"></span>
<?= e(\App\Module::name($k)) ?>
</td>
<td class="mod-td-on">
<label class="mod-chk" title="显示在顶部导航条">
<input type="checkbox" name="nav[]" value="<?= e($k) ?>"
<?= in_array($k, $plMNav, true) ? 'checked' : '' ?>>
</label>
</td>
<td class="mod-td-on">
<label class="mod-chk" title="显示在首页">
<input type="checkbox" name="home[]" value="<?= e($k) ?>"
<?= in_array($k, $plMHome, true) ? 'checked' : '' ?>>
</label>
</td>
<td class="mod-td-color">
<?php if ($plMIsAdmin): ?>
<input type="color" class="color-picker" name="c[<?= e($k) ?>]"
value="<?= e((string)($plMColor[$k] ?? '#888888')) ?>"
aria-label="<?= e(\App\Module::name($k)) ?>的颜色">
<?php else: ?>
<?php /* 非管理员:看得到颜色、改不了(家庭一份,见本页说明) */ ?>
<span class="color-preview mod-color-ro"
style="background: <?= e((string)($plMColor[$k] ?? '#888')) ?>;"
title="由管理员设置"></span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="btn-row" style="margin-top:14px">
<button class="btn" type="submit" name="save" value="1">保存模块设置</button>
<?php if ($plMIsAdmin): ?>
<span class="small muted">颜色是全家共用的一套,改了对所有人都生效。</span>
<?php else: ?>
<span class="small muted">颜色由管理员设置,你这里只读。</span>
<?php endif; ?>
</div>
</form>
</div>
<script>
(function () {
/* 拖动排序(▲▼ 是不用 JS 的兜底,两条路都能用)。
⚠️ 搬的是整行 <tr> —— 它自带的 order[] 隐藏域跟着走,
所以提交上来的顺序 = 屏幕上的顺序,不用再同步一遍。 */
var list = document.getElementById('modRows');
if (!list) { return; }
var dragEl = null;
Array.prototype.forEach.call(list.querySelectorAll('.mod-row'), function (tr) {
tr.addEventListener('dragstart', function (ev) {
dragEl = tr;
tr.classList.add('is-dragged');
if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; }
});
tr.addEventListener('dragend', function () {
tr.classList.remove('is-dragged');
dragEl = null;
Array.prototype.forEach.call(list.querySelectorAll('.mod-row'), function (x) {
x.classList.remove('is-drop-before');
});
});
tr.addEventListener('dragover', function (ev) {
if (!dragEl || dragEl === tr) { return; }
ev.preventDefault();
var box = tr.getBoundingClientRect();
tr.classList.toggle('is-drop-before', ev.clientY < box.top + box.height / 2);
});
tr.addEventListener('dragleave', function () { tr.classList.remove('is-drop-before'); });
tr.addEventListener('drop', function (ev) {
if (!dragEl || dragEl === tr) { return; }
ev.preventDefault();
var box = tr.getBoundingClientRect();
if (ev.clientY < box.top + box.height / 2) {
list.insertBefore(dragEl, tr);
} else {
list.insertBefore(dragEl, tr.nextSibling);
}
tr.classList.remove('is-drop-before');
});
});
})();
</script>