v28 第 28 批(12 条):名册分组改标签 / 资源合并为两按钮 / 首页定位与主题色 / 笔记富文本双模式

其中 4 条是**上批遗漏的真 bug**(1/4/8/9),根因已逐条定位:

1. 首页日程没定位到红线 —— 真根因:刻度区间**不含当前时刻** → $showNow=false
   → 红线**整个不渲染**,而定位脚本用同一个越界比例算目标,被 clamp 到底部。
   修:把当前小时并进区间端点(现在必然落在区间内)。
2. 「看整月」→「更多日程」+ 日程主题色;3. 「手头在做」→「近期任务」、
   「任务看板」→「全部任务」+ 任务主题色(注入 --hd-c,不再写死白底)。
4. 任务复选框提示还在 —— 真根因:**JS 在点击后**给状态徽标挂 title
   (`badge.setAttribute('title',…)`),上批只去了静态渲染,静态断言永远抓不到。
5. 名册「分组」→「标签」(用户:一个人可能既是小学同学又是初中同学,没法分组):
   新增 person_tags(每人一份、一人多标签),名册改**平铺 + 顶部标签筛选**;
   🔴 迁移用**一次性哨兵**(第一版用 NOT EXISTS,实测被"用户清空标签"绕过 → 旧分组名被灌回)。
6. 名册卡片按钮加语义底色。
7. 资源四按钮 → **编辑 / 删除**两颗:三块内容进一个带页签的弹框(信息/附件/共享)。
8. 财务多选数字不变 —— 真根因:前端发 `kinds=a,b`(逗号串),服务端 `(array)` 收成
   单元素 → 求交为空 → **静默回落整本账**。修:两种形状都收。
9. 微信读书偶发很慢 —— 真根因:syncNow 扫 60 本 × 每本 1~3 次网关调用(单次超时 20s),
   全同步阻塞在列表页请求里。修:总时长预算 + 单次超时 8s + 自动/手动分批。
10. 新增全局可见的联想接口库(6 个实测可用来源)+ 三种新解析类型。
11. 导航右上角「设置」:默认进"添加网址 + 全部网址",四类设置收进 ?set=1 视图。
12. 笔记:段落/清单支持 Markdown(自研渲染器,**不允许原始 HTML**),
    实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
    列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
This commit is contained in:
2026-10-08 02:20:19 +08:00
parent 404b1afee0
commit 6852b4ee2c
19 changed files with 2273 additions and 587 deletions
+25 -4
View File
@@ -148,7 +148,7 @@ $todayMarks = $todayMarks ?? ['tags' => [], 'line' => ''];
首页要回答的是"我手头在做什么",所以主数字用「在做」的口径;
排队的那些(todo)另给一个小字,不必让人点进任务页才知道还有多少。 */ ?>
<a class="hbs" href="<?= e(url('tasks')) ?>"
title="手头在做(进行中 + 已逾期的待办)">
title="近期任务(进行中 + 已逾期的待办)">
<b><?= count($tasks ?? []) ?></b> 件在做
</a>
<?php if ((int)($stat7['todos'] ?? 0) > 0): ?>
@@ -208,6 +208,20 @@ if ($timed) {
// 前后各留一小时:紧了会让人以为"到点就结束了",松了又浪费高度
$startH = max(0, intdiv(min($mins), 60) - 1);
$endH = min(24, intdiv(max($mins), 60) + 2); // +2 = 跨过那一小时再收尾
/* 🔴 v28 用户第 1 条:「今天的日程,并没有定位到红线所在的位置」。
根因:**区间里没有"现在"**,于是红线不画、定位算出来是个越界的值。
(实测:现在 22:44、日程 09:00 与 14:00 → 区间掐成 08–17,
`$nowPos = (22-8)/9 = 1.56` → `$showNow=false` → `class="ag-now"` 整个不渲染;
而定位脚本用同一个越界的 RATIO 去乘网格高度,被 clamp 到最底 → 停在底部。)
⇒ 修法不是"没红线就别定位",而是**让区间永远包含当前时刻**:
用户要的正是"一眼看到现在处在哪儿",那就必须把"现在"纳入刻度。
下面的 `$nowH` 是当前小时(服务端时区,与页面口径一致),
把它并进区间端点 —— 于是红线必然落在区间内,定位也就有了正确的目标。
⚠️ 必须在**算 $nowH 之前**完成这一步(原代码把 $nowH 放在区间之后算,
这里把它提上来,避免"改了区间却没重新算位置"这种漏改)。 */
$nowHNow = (int)date('G');
if ($nowHNow < $startH) { $startH = max(0, $nowHNow - 1); }
if ($nowHNow >= $endH) { $endH = min(24, $nowHNow + 2); }
if ($endH <= $startH) { $endH = min(24, $startH + 2); }
}
$nowH = (int)date('G');
@@ -220,7 +234,11 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
<div class="card card-pad hd-pane" id="homeAgenda">
<div class="hd-pane-head">
<h2 class="cinema-h2">今天的日程</h2>
<a class="btn btn-ghost btn-sm" href="<?= e(url('calendar')) ?>">看整月</a>
<?php /* 🔴 v28 用户第 2 条:「看正月这个按钮,改成:更多日程,
背景颜色和日程的主题颜色保持一致。」
(「看正月」是用户对「看整月」的口误。) */ ?>
<a class="btn btn-sm hd-more" href="<?= e(url('calendar')) ?>"
style="--hd-c: <?= e(\App\Module::color('calendar', (int)\App\Auth::familyId())) ?>;">更多日程</a>
</div>
<?php /* 🔴 v24 第 26 批 用户第 2 条(前半):「今天的日程,除了全天日程**一直显示在上面**外,
其他日程随着滚动」。
@@ -286,8 +304,11 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
<?php /* ---- 右:进行中的任务(标题 + 状态 + 看板名)---- */ ?>
<div class="card card-pad hd-pane" id="homeTasks">
<div class="hd-pane-head">
<h2 class="cinema-h2">手头在做</h2>
<a class="btn btn-ghost btn-sm" href="<?= e(url('tasks')) ?>">任务看板</a>
<h2 class="cinema-h2">近期任务</h2>
<?php /* 🔴 v28 用户第 3 条:「『手头在做』改成『近期任务』,『任务看板』这个按钮
改成『全部任务』,按钮颜色和任务的主题颜色保持一致。」 */ ?>
<a class="btn btn-sm hd-more" href="<?= e(url('tasks')) ?>"
style="--hd-c: <?= e(\App\Module::color('tasks', (int)\App\Auth::familyId())) ?>;">全部任务</a>
</div>
<div class="hd-pane-body">
<?php /* 用户第 6 条:「所有的任务,前面都带一个复选框,在任何地方,点这个复选框,
+74 -1
View File
@@ -41,15 +41,40 @@ foreach ($engines as $e) {
if ((int)$e['id'] === $eEng) { $editEngine = $e; }
}
$sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
/* 🔴 v28 用户第 11 条:「导航模块,右上角增加设置按钮,和其他模块一样。
把导航展示地址,放进去,把导航设置,放进去,把板块设置,放进去,
把搜索引擎的设置,也放进去,你参考一下之前的导航系统 simplenav,
把之前的那些设置,都放在设置中,进来之后,默认就是添加网址,和所有网址的列表。」
⇒ 两种视图,用 `?set=1` 切(服务端渲染,无 JS 也能切;刷新/回退都不丢状态):
· 默认(`?set` 空):**只显示「新增站点」+「站点列表」** —— 日常真正要用的两件事;
· 设置(`?set=1`):展示地址 / 导航设置 / 板块 / 搜索引擎 四块。
⚠️ 为什么用查询参数而不是 JS 折叠:设置项里有表单(保存后会 302 回本页),
用 JS 折叠的话每次保存回来都会**自动摊开全部设置**,用户还要再收一次。
用参数就能让"保存后回到设置页"(下面的表单里保留了 `set=1` 的隐藏域)。
⚠️ 保留 `$editSite` 等编辑态:正在编辑某个站点/板块/引擎时,**强制显示设置视图**,
否则用户点"编辑板块"会看到一个没有板块表单的页面(看起来像功能坏了)。 */
$inSettings = !empty($_GET['set']) || $editGroup !== null || $editEngine !== null;
?>
<div class="page-head">
<h1>导航</h1>
<span class="sub">你个人用的网址导航 · 每人一份</span>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('nav')) ?>">刷新</a>
<?php /* v28 用户第 11 条:右上角「设置」按钮,与其他模块的手指位置一致。
⚠️ 它是个**链接**(服务端切换视图),不是 JS 开关 —— 理由见上面的注释。 */ ?>
<?php if ($inSettings): ?>
<a class="btn btn-sm" href="<?= e(url('nav')) ?>">完成</a>
<?php else: ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('nav', ['set' => 1])) ?>">设置</a>
<?php endif; ?>
</div>
</div>
<?php /* 设置视图里的四块(展示地址 / 导航设置 / 板块 / 搜索引擎)。
只在设置态渲染 —— 默认进来看到的是"添加网址 + 网址列表"(用户原话)。 */ ?>
<?php if ($inSettings): ?><?php /* ==== 设置视图开始 ==== */ ?>
<?php /* ==================== ① 展示页地址与短语(用户第 4 条的"特殊地址")====================
用户原话:「给他一个非常特殊的地址…地址后面可以跟着用户添加的一个短语,就跟类似
于一个密钥一样,他可以给自己起一个短语…这个页面是不需要登录的。」
@@ -171,6 +196,8 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
</form>
</div>
<?php endif; ?><?php /* ==== 设置视图(前半:展示地址 + 导航设置)结束 ==== */ ?>
<?php /* ==================== ③ 站点:新增/编辑 ==================== */ ?>
<?php if ($canWrite): ?>
<div class="card card-pad">
@@ -301,6 +328,7 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
<?php endif; ?>
</div>
<?php if ($inSettings): ?><?php /* ==== 设置视图(后半:板块 + 搜索引擎)重新开始 ==== */ ?>
<?php /* ==================== ⑤ 板块 ==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= $editGroup ? '编辑板块' : '新增板块' ?></h2>
@@ -406,6 +434,30 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
value="<?= e((string)($editEngine['suggest_type'] ?? '')) ?>"
placeholder="bing_osjson / baidu_sugrec / so360">
</div>
<?php /* 🔴 v28 用户第 10 条:「联想接口还是不能用啊,需要你补充几个进去,
让用户选择。搜索引擎列表,联想接口,是**对所有用户可见**的。」
⇒ 这里列出内置的联想接口库(App\Nav::SUGGEST_LIBRARY,全局共享一份),
**点一下就把地址与类型填进上面两个框** —— 用户不必去记
"sugrec 还是 osjson"这种东西,也不必手抄 URL(抄错一位就永远空下拉)。
⚠️ 这些只是"填入表单",仍要用户点保存才生效 ——
静默改数据会在用户没注意时改掉他自己的配置。 */ ?>
<div class="field">
<label>内置联想接口 <span class="hint">点一下即可填入上面两项</span></label>
<div class="sug-lib">
<?php foreach (\App\Nav::suggestLibrary() as $sl): ?>
<button type="button" class="sug-lib-item"
data-sug-url="<?= e((string)$sl['url']) ?>"
data-sug-type="<?= e((string)$sl['type']) ?>">
<b><?= e((string)$sl['name']) ?></b>
<span><?= e((string)$sl['note']) ?></span>
</button>
<?php endforeach; ?>
</div>
<span class="tip">
这些接口对**所有用户**都可见、可直接选用。国内网络下不可达的
(谷歌 / DuckDuckGo)没有列进来 —— 选了也只会永远是空下拉。
</span>
</div>
<div class="field">
<label>颜色</label>
<input class="input" type="color" name="color"
@@ -468,6 +520,7 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
</ul>
</div>
<?php endif; ?>
<?php endif; ?><?php /* ==== 设置视图(后半:板块 + 搜索引擎)结束 ==== */ ?>
<script>
/* 导航管理页的交互(v24 第 24 批)。
@@ -672,5 +725,25 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
lastLookup = '';
});
}
/* 🔴 v28 用户第 10 条:内置联想接口库 —— 点一下把地址+类型填进**同一个表单**里。
⚠️ 用 closest('form') 定位,而不是 document.querySelector:
页面上可能同时有"新增引擎"和"编辑引擎"两个表单(后者是 ?edit_engine=… 带出来的),
用全局查找会把内容填到**另一个**表单里 —— 用户点了没反应、或更糟:填错地方。 */
Array.prototype.forEach.call(document.querySelectorAll('.sug-lib-item'), function (b) {
b.addEventListener('click', function () {
var form = b.closest('form');
if (!form) { return; }
var u = form.querySelector('input[name="suggest_url"]');
var t = form.querySelector('input[name="suggest_type"]');
if (u) { u.value = b.getAttribute('data-sug-url') || ''; }
if (t) { t.value = b.getAttribute('data-sug-type') || ''; }
// 反馈:让用户看到"填进去了"(否则一片安静,会以为没点上)
b.classList.add('is-picked');
Array.prototype.forEach.call(b.parentNode.children, function (sib) {
if (sib !== b) { sib.classList.remove('is-picked'); }
});
});
});
})();
</script>
+85
View File
@@ -81,6 +81,31 @@ if (!$blocks) {
</div>
</div>
<?php /* 🔴 v28 用户第 12 条:「段落是需要支持富文本的,可以使用 markdown,
但是需要使用富文本的方式进行编辑,同时可以使用 markdown 源码观看编辑。
就是说,允许所见即所得编辑,也允许 markdown 编辑。」
⇒ 两件事:
① **每个段落块下方实时显示渲染结果**("所见即所得")——
写 Markdown 的同时就能看到它长什么样,不必切来切去;
② 一个**全局"源码 / 预览"开关**:想专心改 Markdown 源码时,
把实时预览关掉(长文档下预览会占很多地方,也会让人分心)。
⚠️ 为什么不做 contenteditable 那种"直接在渲染结果上编辑":
那要引入一套 HTML↔Markdown 的双向转换与清洗(正是"引大库"的那条路),
而且富文本内容再存回 Markdown 时几乎必然丢格式或引入噪音。
这里选择"**输入 Markdown + 实时预览**"—— 两个诉求都满足,
数据始终是干净的 Markdown 纯文本(可导出、可搜索、可版本对比)。 */ ?>
<div class="note-modebar">
<span class="small muted">段落支持 Markdown:<code>**粗体**</code>
<code>*斜体*</code> <code># 标题</code> <code>- 清单</code> <code>&gt; 引用</code>
<code>`代码`</code></span>
<label class="note-live">
<input type="checkbox" id="noteLivePreview" checked>
<span>实时预览</span>
</label>
</div>
<div id="noteBlockList" class="note-blocks"></div>
<?php /* 无 JS 兜底:脚本没跑时用户仍能写一条纯文字笔记 */ ?>
@@ -125,12 +150,72 @@ if (!$blocks) {
</label>
</div>
<?php /* 🔴 v28 用户第 12 条:「再加上附件上传功能,附件,都放在笔记最下面就行了。」
⇒ 附件区放在**整张表单的最下面**(标签与共享之后),与用户说的一致。
⚠️ 附件要挂在**已存在的笔记**上(attachments.target_id 要真实 id),
所以新建时这一区是**禁用并说明**的 —— 保存之后进入编辑态就能传了。
不采用"草稿附件 + 保存时认领"(图片块那套):附件不像图片那样内嵌在正文里,
草稿态文件在用户放弃保存后会变成谁看不见的垃圾;宁可让他保存完再传。 */ ?>
<?php if ($isEditing): ?>
<div class="card card-pad" id="noteFilesCard">
<h2 class="cinema-h2">附件 <span class="hint">放最后,与正文分开</span></h2>
<?php /* 已有附件:可下载、可删 */ ?>
<?php if (!empty($editing['files'])): ?>
<div class="note-files">
<?php foreach ((array)$editing['files'] as $f): ?>
<?php $fk = \App\Attachment::kind($f); ?>
<div class="note-file">
<span class="note-file-ico"><?= e(\App\Attachment::kindWord($fk)) ?></span>
<a class="note-file-nm" href="<?= e(media_url((string)$f['key'], ['dl' => 1])) ?>">
<?= e((string)($f['name'] ?? '附件')) ?>
</a>
<span class="small muted"><?= e(\App\Attachment::sizeWord($f['size'] ?? 0)) ?></span>
<button type="button" class="note-file-del" data-note-file-del="<?= (int)$f['id'] ?>"
title="删掉这个附件">✕</button>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="empty-hint" id="noteFilesEmpty">还没有附件。</p>
<?php endif; ?>
<form method="post" enctype="multipart/form-data"
action="<?= e(url('notes/attach_add')) ?>" id="noteAttForm">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<div class="note-att-drop" id="noteAttDrop">
<input type="file" name="file[]" id="noteAttInput" multiple
accept="image/*,.pdf,.txt,.md,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx">
<span class="note-att-tip">把文件拖到这里,或<label for="noteAttInput">点这里选文件</label></span>
</div>
<div class="btn-row">
<button class="btn btn-ghost btn-sm" type="submit">上传所选文件</button>
</div>
</form>
</div>
<?php else: ?>
<div class="card card-pad">
<h2 class="cinema-h2">附件</h2>
<p class="empty-hint">先「记下来」,保存之后回到这一页就能传附件了。</p>
</div>
<?php endif; ?>
<div class="note-actions">
<button class="btn" type="submit"><?= $isEditing ? '保存' : '记下来' ?></button>
<a class="btn btn-ghost" href="<?= e(url('notes')) ?>">取消</a>
</div>
</form>
<?php if ($isEditing): ?>
<?php /* 删附件:全页唯一的一份表单,脚本填 id 后提交(沿用项目既有做法,
避免"每行一份 form"那种页面膨胀)。 */ ?>
<form method="post" action="<?= e(url('notes/attach_del')) ?>" id="noteFileDelForm" hidden>
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
</form>
<?php endif; ?>
<?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* ——
属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?>
<script type="application/json" id="noteSeed"><?= json_encode(
+129 -134
View File
@@ -1,16 +1,23 @@
<?php
/**
* 个人笔记 · 列表(v27 用户 2026-10-07)。
* 个人笔记 · 列表(v27 建;v28 用户第 12 条重构)。
*
* 形态参考 **flomo**:一条一张卡片、按时间倒序的流;顶部一个搜索框;
* 侧边一列标签云(点一下只看这个标签)。
* 「图文混排」在**卡片里就看得见**(图以缩略图排在文字之下),
* 不必点进去 —— 笔记是"回看"的东西,回看时应该一眼看到内容。
* 🔴 v28 第 12 条原话:「笔记列表页面,要注意大小,要么按照列表展示,要么按照块展示,
* 一行几个块,但是块大小要确定,如果显示不下,就显示简要信息,图片缩放后,放在下面。
* 这样看起来更美观。标签这个侧边栏,我感觉可以不要。」
*
* 分块只在**编辑页**体现(卡内可加段/图/清单并上下移);列表页只做**只读渲染**,
* 这是刻意的取舍:列表页可编辑就得为每张卡片配一套 JS 状态,与"轻量级"冲突。
* ⇒ 两处改动:
* ① **定尺寸卡片网格**(一行多个)—— 卡片高度固定,装不下就显示摘要,
* 图片等比缩放在卡片下方。原来是一列长卡片(一条占整屏),列表页很长、很难扫。
* ② **去掉左侧标签栏** —— 标签筛选改成顶部一条细筛选条(占位小得多),
* 宽度全给卡片。"可以不要"是用户的判断,照做。
*
* @param array $rows 我能看到的笔记(带 blocks / tags / is_mine)
* ⚠️ 卡片高度**固定**(CSS 里给固定高度 + 内容裁切),这是用户明确要求
* "块大小要确定"。所以卡片里不能放"会撑高的东西":
* · 正文只放**摘要**(不渲染全部块);
* · 图片只放第一张缩略图(按卡片宽度等比缩放,不会把卡片顶高)。
*
* @param array $rows 我能看到的笔记(带 blocks / tags / excerpt / is_mine)
* @param array $tagCloud 标签 + 条数
* @param string $keyword 搜索词
* @param string $tag 当前筛的标签
@@ -19,7 +26,6 @@
* @param int $limit 列表默认条数
*/
$canWrite = \App\Auth::canWrite();
$myPerson = (int)\App\Auth::personId();
?>
<div class="page-head">
<h1>笔记</h1>
@@ -33,136 +39,125 @@ $myPerson = (int)\App\Auth::personId();
</div>
</div>
<div class="note-layout">
<?php /* ---------------- 左侧:标签云 ---------------- */ ?>
<aside class="note-side">
<div class="card card-pad">
<h2 class="cinema-h2">标签</h2>
<?php if (!$tagCloud): ?>
<p class="empty-hint">还没有标签。写笔记时加上 <code>#标签</code> 就会出现在这里。</p>
<?php /* ---------------- 顶部:搜索 + 标签筛选(v28 去掉了左侧标签栏)---------------- */ ?>
<div class="card card-pad note-bar">
<form class="search note-search" method="get" action="index.php">
<input type="hidden" name="r" value="notes">
<?php if ($tag !== ''): ?>
<input type="hidden" name="tag" value="<?= e($tag) ?>">
<?php endif; ?>
<label class="sr-only" for="noteKw">搜索笔记</label>
<input class="input" id="noteKw" name="keyword" value="<?= e($keyword) ?>"
placeholder="搜标题 / 正文 / 标签" autocomplete="off">
<button class="btn btn-ghost btn-sm" type="submit">搜索</button>
<?php if ($keyword !== '' || $tag !== ''): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('notes')) ?>">清除</a>
<?php endif; ?>
</form>
<?php if ($tagCloud): ?>
<div class="note-tags note-tagbar">
<a class="note-tag<?= $tag === '' ? ' is-on' : '' ?>" href="<?= e(url('notes')) ?>">
全部 <b><?= (int)$totalAll ?></b>
</a>
<?php foreach ($tagCloud as $t): ?>
<a class="note-tag<?= $tag === (string)$t['tag'] ? ' is-on' : '' ?>"
href="<?= e(url('notes', ['tag' => (string)$t['tag']])) ?>">
#<?= e((string)$t['tag']) ?> <b><?= (int)$t['n'] ?></b>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if (!$rows): ?>
<div class="card card-pad">
<p class="empty-hint">
<?php if ($keyword !== '' || $tag !== ''): ?>
没有匹配的笔记。
<?php elseif ($canWrite): ?>
还没有笔记。点右上角「写笔记」随手记一条吧。
<?php else: ?>
<div class="note-tags">
<a class="note-tag<?= $tag === '' ? ' is-on' : '' ?>" href="<?= e(url('notes')) ?>">
全部 <b><?= (int)$totalAll ?></b>
</a>
<?php foreach ($tagCloud as $t): ?>
<a class="note-tag<?= $tag === (string)$t['tag'] ? ' is-on' : '' ?>"
href="<?= e(url('notes', ['tag' => (string)$t['tag']])) ?>">
#<?= e((string)$t['tag']) ?> <b><?= (int)$t['n'] ?></b>
</a>
<?php endforeach; ?>
</div>
还没有笔记。
<?php endif; ?>
</div>
</aside>
<?php /* ---------------- 右侧:卡片流 ---------------- */ ?>
<div class="note-main">
<div class="card card-pad note-search-card">
<form class="search" method="get" action="index.php">
<input type="hidden" name="r" value="notes">
<?php if ($tag !== ''): ?>
<input type="hidden" name="tag" value="<?= e($tag) ?>">
<?php endif; ?>
<label class="sr-only" for="noteKw">搜索笔记</label>
<input class="input" id="noteKw" name="keyword" value="<?= e($keyword) ?>"
placeholder="搜标题 / 正文 / 标签,如「读书」" autocomplete="off">
<button class="btn btn-ghost btn-sm" type="submit">搜索</button>
<?php if ($keyword !== '' || $tag !== ''): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('notes')) ?>">清除</a>
<?php endif; ?>
</form>
</div>
<?php if (!$rows): ?>
<div class="card card-pad">
<p class="empty-hint">
<?php if ($keyword !== '' || $tag !== ''): ?>
没有匹配的笔记。
<?php elseif ($canWrite): ?>
还没有笔记。点右上角「写笔记」随手记一条吧。
<?php else: ?>
还没有笔记。
<?php endif; ?>
</p>
</div>
<?php else: ?>
<?php foreach ($rows as $n): ?>
<?php $mine = !empty($n['is_mine']); ?>
<article class="card card-pad note-card">
<header class="note-head">
<div class="note-meta">
<?php /* 头像 + 作者:共享笔记要一眼看出是谁写的 */ ?>
<img class="note-ava" src="<?= e(avatar_url($n['avatar'] ?? null)) ?>" alt="">
<span class="note-who"><?= e((string)$n['author_name']) ?></span>
<?php if (!empty($n['is_shared'])): ?>
<span class="note-share" title="全家可见">共享</span>
<?php else: ?>
<span class="note-private" title="只有自己可见">私有</span>
<?php endif; ?>
<?php /* 只显示到分钟、且把 T 换成空格后的形态(全站惯例:不引第三方时间库) */ ?>
<span class="note-time"><?= e(substr((string)$n['updated_at'], 0, 16)) ?></span>
</div>
<?php if ($mine && $canWrite): ?>
<div class="note-ops">
<a class="btn btn-ghost btn-sm" href="<?= e(url('notes/form', ['id' => (int)$n['id']])) ?>">编辑</a>
<form method="post" action="<?= e(url('notes/destroy')) ?>" class="inline-form"
onsubmit="return confirm('删掉这条笔记?');">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">删除</button>
</form>
</div>
</p>
</div>
<?php else: ?>
<?php /* 定尺寸网格:一行多个,卡片高度固定(用户第 12 条"块大小要确定") */ ?>
<div class="note-grid">
<?php foreach ($rows as $n): ?>
<?php
$mine = !empty($n['is_mine']);
$nid = (int)$n['id'];
$title = trim((string)$n['title']);
// 卡片上的第一张图(等比缩放在卡片下方)—— 只取一张,多了会把卡片顶高
$firstImg = '';
foreach ((array)$n['blocks'] as $b) {
if ((string)$b['kind'] === 'image' && (string)$b['content'] !== '') {
$firstImg = (string)$b['content'];
break;
}
}
?>
<article class="note-card" data-note="<?= $nid ?>">
<header class="note-head">
<span class="note-meta">
<?php if (!empty($n['is_shared'])): ?>
<span class="note-share" title="全家可见">共享</span>
<?php else: ?>
<span class="note-private" title="只有自己可见">私有</span>
<?php endif; ?>
</header>
<?php if (trim((string)$n['title']) !== ''): ?>
<h3 class="note-title"><?= e((string)$n['title']) ?></h3>
<span class="note-time"><?= e(substr((string)$n['updated_at'], 0, 16)) ?></span>
</span>
<?php if ($mine && $canWrite): ?>
<span class="note-ops">
<a class="note-op" href="<?= e(url('notes/form', ['id' => $nid])) ?>"
title="编辑这条笔记">改</a>
<form method="post" action="<?= e(url('notes/destroy')) ?>" class="inline-form"
onsubmit="return confirm('删掉这条笔记?');">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $nid ?>">
<button type="submit" class="note-op note-op-del" title="删除这条笔记">删</button>
</form>
</span>
<?php endif; ?>
</header>
<?php /* 块渲染:文字段 / 图片 / 清单 —— 「图文混排」就在这儿 */ ?>
<div class="note-body">
<?php foreach ((array)$n['blocks'] as $b): ?>
<?php $kind = (string)$b['kind']; $c = (string)$b['content']; ?>
<?php if ($kind === 'image'): ?>
<a class="note-img" href="<?= e(media_url($c)) ?>"
data-prev="image" data-prev-src="<?= e(media_url($c)) ?>">
<img src="<?= e(media_url($c)) ?>" alt="笔记图片" loading="lazy">
</a>
<?php elseif ($kind === 'list'): ?>
<ul class="note-list">
<?php foreach (preg_split('/\R/u', $c) ?: [] as $line): ?>
<?php $line = trim((string)$line); ?>
<?php if ($line !== ''): ?>
<li><?= e($line) ?></li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="note-text"><?= nl2br(e($c)) ?></p>
<?php endif; ?>
<a class="note-hit" href="<?= e(url('notes/form', ['id' => $nid])) ?>">
<?php if ($title !== ''): ?>
<h3 class="note-title"><?= e($title) ?></h3>
<?php endif; ?>
<?php if (trim((string)($n['excerpt'] ?? '')) !== ''): ?>
<p class="note-ex"><?= e((string)$n['excerpt']) ?></p>
<?php elseif ($title === ''): ?>
<p class="note-ex note-ex-empty">(这条笔记没有正文)</p>
<?php endif; ?>
</a>
<?php /* 图片:等比缩放到卡片宽度,放在摘要下方(用户第 12 条原话) */ ?>
<?php if ($firstImg !== ''): ?>
<div class="note-thumb">
<img src="<?= e(media_url($firstImg)) ?>" alt="" loading="lazy" decoding="async">
</div>
<?php endif; ?>
<?php if (!empty($n['tags'])): ?>
<div class="note-tags note-tags-inline">
<?php foreach ((array)$n['tags'] as $t): ?>
<a class="note-tag" href="<?= e(url('notes', ['tag' => (string)$t])) ?>">
#<?= e((string)$t) ?>
</a>
<?php endforeach; ?>
</div>
<?php if (!empty($n['tags'])): ?>
<div class="note-tags note-tags-inline">
<?php foreach ((array)$n['tags'] as $t): ?>
<a class="note-tag" href="<?= e(url('notes', ['tag' => (string)$t])) ?>">
#<?= e((string)$t) ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article>
<?php endforeach; ?>
<?php if (!$showAll && (int)$totalAll > count($rows)): ?>
<div class="note-more">
<a class="btn btn-ghost" href="<?= e(url('notes', ['all' => 1, 'keyword' => $keyword, 'tag' => $tag])) ?>">
显示全部 <?= (int)$totalAll ?> 条
</a>
</div>
<?php endif; ?>
<?php endif; ?>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
</div>
<?php if (!$showAll && (int)$totalAll > count($rows)): ?>
<div class="note-more">
<a class="btn btn-ghost" href="<?= e(url('notes', ['all' => 1, 'keyword' => $keyword, 'tag' => $tag])) ?>">
显示全部 <?= (int)$totalAll ?> 条
</a>
</div>
<?php endif; ?>
<?php endif; ?>
+266 -245
View File
@@ -1,27 +1,33 @@
<?php
/**
* 家庭名册 —— 按「关系分组」分栏显示,卡片可以拖动。
* 家庭名册 —— v28 起**平铺成一张列表**,顶部用「标签」筛选,卡片可拖动排序。
*
* 🔴 v28 用户第 5 条:「名册的分组,用下来不太好,我感觉应该改成打标签,
* 就是说,一个人,他可能并不是只在一个分组,例如,他既是我的小学同学,
* 又是我的初中同学,就没法分组,改成标签,似乎更合理。」
*
* ⇒ 从「一人一组的**分栏**」改成「**一人多标签** + 平铺 + 顶部标签筛选」。
* 为什么必须取消分栏:一个人挂 2 个标签时,分栏会让他在两栏里各出现一次,
* 看起来像"名册里有两个人";而且"未分类"那栏会越积越多(挂 2 个标签的人
* 反而更难归类)。平铺 + 筛选是唯一自洽的做法(手机通讯录也是这么做的)。
*
* 三处刻意的设计:
* ① **名册是每人一份的**(v5 起,见 App\PersonRoster):这里只显示"我加的人 +
* 别人分享给我的",归到哪一组、排第几也都只影响我自己。所以"改分组"不再是
* 改全家的信息,而是改我自己那份 —— 于是顺手就能做成拖动。
* ② **拖动改分组、拖动改顺序**(需求原话:「拖动一个人的卡片进行排序…通过拖动
* 改变分组」):拖动本身是 JS,但**每个分组栏也是空的时候照样存在**,
* 拖动时统一露出来当落点,否则"想拖到一个现在没人的分组里"就没地方放。
* ③ **没有 JS 也完全能用**:卡片上的 `<details>调整分组</details>` 还在,
* 而且手机上的 HTML5 拖放本来就不生效,那套表单就是窄屏的正路。
* ① **名册是每人一份的**(v5 起,见 App\PersonRoster):只显示"我加的人 +
* 别人分享给我的";标签也只影响我自己那份(我给他打"球友"不该让全家都变)。
* ② **拖动仍然只改顺序**(用户明确要求保留拖动排序):不再表示"改分组",
* 拖动只调整"我看到的先后"。标签在卡片上点一下就能改。
* ③ **没有 JS 也完全能用**:卡片上的「编辑」是普通链接;标签用 `?tag=` 链接筛选
* (服务端渲染,不依赖 JS)。
*
* ⚠️ 卡片结构是 div > a + details,**不能写成 a > form**:
* 表单套在链接里是非法 HTML,浏览器会把 DOM 拆开,表单里那个「移动」按钮
* 可能连提交机会都没有(或者把整张卡片点成跳转)。
* ⚠️ 卡片结构是 div > a + div,**不能写成 a > form**:
* 表单套在链接里是非法 HTML,浏览器会把 DOM 拆开(本仓踩过)。
*/
$canMove = \App\Auth::canWrite();
$pickable = $pickable ?? 0;
$total = 0;
foreach ($grouped as $list) {
$total += count($list);
}
$tagCloud = $tagCloud ?? [];
$tagFilter = $tagFilter ?? '';
// v28:平铺列表,人数直接数行数(不再按分组累加)
$total = count($people ?? []);
/**
* 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片,
@@ -53,7 +59,7 @@ foreach ($grouped as $list) {
* (拖动排序、即时筛选都不是无 JS 能用的),而且卡片上的「编辑」仍是普通链接,
* 全站核心功能不依赖它。页面正文明说这一点,不静默降级。
*/
$renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
$renderGrid = function (array $list) use ($meId, $canMove): void {
?>
<div class="people-grid">
<?php foreach ($list as $p): ?>
@@ -86,16 +92,12 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
$metaBits[] = ['家谱', '他在你的家谱树上(配偶/父母/子女/祖孙…),自动加入你的名册'];
}
if (!empty($p['is_placeholder'])) { $metaBits[] = ['待确认', '']; }
// 当前分组:行上那个「分组」按钮要**显示它**(用户 2026-10-04 第 1 条)。
// ⚠️ 只认「我能用的分组」里那几个 —— 挂在一个别人家的私自分组上时,
// 这里取不到名字,就按"未分类"显示(按钮照样能改成一个正常的分组)。
$curGid = (int)($p['group_id'] ?? 0);
$grpName = '';
foreach ($groups as $g) {
if ((int)$g['id'] === $curGid) { $grpName = (string)$g['name']; break; }
}
/* v28 用户第 5 条:原来这里算「当前分组名」给按钮显示。
改成标签后不再需要 —— 标签直接从 `$p['tags']` 取(见下面按钮那段),
而且**一个人可能有好几个标签**,没法再塞进一个"当前值"里。 */
?>
<div class="person" data-person="<?= $pid ?>"
data-tags="<?= e(implode(' ', (array)($p['tags'] ?? []))) ?>"
data-search="<?= e($pshow . ' ' . $pReal . ' ' . (string)($p['email'] ?? '') . ' ' . (string)($p['org'] ?? '') . ' ' . (string)($p['phone_plain'] ?? '')) ?>"
<?php /* ⚠️ 首字母**分字段**、用空格隔开(前端逐段独立判子序列)。
把"姓名+称呼"拼成一串会造出跨字段巧合:用户实测「输 yy 搜出岳父」
@@ -170,60 +172,54 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
就得继续猜偏移;而且「分组」按钮的宽度随分组名变化,写死必然重叠。
容器贴右、内部自然排开,.person-main 也就不用再靠 padding 让位。 */ ?>
<div class="person-actions">
<?php if ($canMove): ?>
<?php /* 用户 2026-10-04 第 1 条:名册要有地方改分组 → 「分组」按钮,
**直接显示当前分组**,点开就是分组列表,选中即改。
v24 第 10 批 #6:按钮本身不再内联 <details>+<form>+11 个 radio,
只带数据、交给全页共享的那一份浮层(理由见 $renderGrid 的注释)。 */ ?>
<button type="button" class="btn btn-ghost btn-sm person-group-btn<?= $grpName === '' ? ' is-none' : '' ?>"
data-pg-open="<?= $pid ?>"
data-pg-name="<?= e($pshow) ?>"
data-pg-cur="<?= $curGid ?>"
title="改 <?= e($pshow) ?> 的分组"><?= e($grpName !== '' ? $grpName : '分组') ?></button>
<?php endif; ?>
<a class="btn btn-ghost btn-sm person-edit" data-no-drag
href="<?= e(url('people/edit', ['id' => $pid])) ?>"
title="编辑 <?= e($pshow) ?> 的资料">编辑</a>
<?php /* 用户 2026-10-04 第 11 批 #2 原话:「在编辑后面,放一个按钮,删除,
用来删除一个人。」
⚠️ 它与「⋯→从我的名册移走」不是一件事,别混:
· 「从我的名册移走」= 只是不在**我的**名册里显示,人还在家里;
· 「删除」= 真的把这个人删掉(账号与亲属关系一起走,不可恢复)。
所以它必须是显式的红色按钮 + 二次确认,不能收进 ⋯ 里。
没有删除权限(非管理员)时不画,并给出原因提示。 */ ?>
<?php if (\App\Auth::isAdmin()): ?>
<button type="button" class="btn btn-danger btn-sm person-del"
data-pd-open="<?= $pid ?>" data-pd-name="<?= e($pshow) ?>"
title="删除 <?= e($pshow) ?>">删除</button>
<?php endif; ?>
<?php if ($canMove): ?>
<button type="button" class="btn btn-ghost btn-sm person-more-btn"
data-pm-open="<?= $pid ?>"
data-pm-hint="<?= ($p['roster_source'] ?? '') === 'kin' ? '1' : '' ?>"
aria-label="更多操作" title="更多操作">⋯</button>
<?php endif; ?>
</div>
<?php
/* 🔴 v28 用户第 5 条:分组 → 标签。
原按钮显示"当前分组"(一个人只有一个);现在显示**他身上的标签**
(可能好几个,也可能一个都没有)。点开就是标签编辑浮层:
可以勾已有的、也可以直接打新词 —— 这正是「一个人可以既是小学同学、
又是初中同学」在界面上的样子。
⚠️ 按钮文案:有标签就列出来,没有就显示「标签」,
与原来的「分组 / is-none」形态保持一致的心智。 */
$pTags = (array)($p['tags'] ?? []);
$tagLabel = $pTags ? implode(' ', $pTags) : '标签';
?>
<?php if ($canMove): ?>
<button type="button" class="btn btn-ghost btn-sm person-tag-btn<?= $pTags ? '' : ' is-none' ?>"
data-pt-open="<?= $pid ?>"
data-pt-name="<?= e($pshow) ?>"
data-pt-tags="<?= e(implode(' ', $pTags)) ?>"
title="改 <?= e($pshow) ?> 的标签"><?= e($tagLabel) ?></button>
<?php endif; ?>
<a class="btn btn-ghost btn-sm person-edit" data-no-drag
href="<?= e(url('people/edit', ['id' => $pid])) ?>"
title="编辑 <?= e($pshow) ?> 的资料">编辑</a>
<?php /* 用户 2026-10-04 第 11 批 #2 原话:「在编辑后面,放一个按钮,删除,
用来删除一个人。」
⚠️ 它与「⋯→从我的名册移走」不是一件事,别混:
· 「从我的名册移走」= 只是不在**我的**名册里显示,人还在家里;
· 「删除」= 真的把这个人删掉(账号与亲属关系一起走,不可恢复)。
所以它必须是显式的红色按钮 + 二次确认,不能收进 ⋯ 里。
没有删除权限(非管理员)时不画,并给出原因提示。 */ ?>
<?php if (\App\Auth::isAdmin()): ?>
<button type="button" class="btn btn-danger btn-sm person-del"
data-pd-open="<?= $pid ?>" data-pd-name="<?= e($pshow) ?>"
title="删除 <?= e($pshow) ?>">删除</button>
<?php endif; ?>
<?php if ($canMove): ?>
<button type="button" class="btn btn-ghost btn-sm person-more-btn"
data-pm-open="<?= $pid ?>"
data-pm-hint="<?= ($p['roster_source'] ?? '') === 'kin' ? '1' : '' ?>"
aria-label="更多操作" title="更多操作">⋯</button>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php
};
/** 一栏(含空栏)。空栏平时收起来,拖动时由 JS 加 is-dragging 露出来当落点。 */
$renderBlock = function (int $gid, string $name, array $list) use ($renderGrid): void {
?>
<section class="group-block<?= $list ? '' : ' is-empty' ?>" data-group="<?= $gid ?>" data-filter-group>
<h2 class="group-head">
<span class="group-title"><?= e($name) ?></span>
<?php /* data-group-n 上挂的是**数字节点**:无刷新删除后 JS 直接改它的文本,
不必重算/重绘整栏。 */ ?>
<span class="group-n"><span class="n" data-group-n="<?= $gid ?>"><?= count($list) ?></span> 人</span>
<span class="group-drop-hint">放到这里</span>
</h2>
<?php $renderGrid($list); ?>
</section>
<?php
};
/* v28 用户第 5 条:`$renderBlock`(画"一栏")**整个删掉** ——
名册不再分栏,改成一整张平铺列表(见下面 `$renderGrid($people)`)。
留着它只会让后人以为"还有个分栏路径"。 */
?>
<div class="page-head">
@@ -231,9 +227,9 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
<span class="sub">
<?php /* v24 第 21 批 第 1 条:无刷新删除后要就地更新这个数 ——
所以「共 N 人」那个 N 单独包一个 <b>(加 data-roster-total 供 JS 定位)。
⚠️ 不能把整段文字都塞进去:后面的「,分 6 个关系分组」是**另一件事**,
删一个人不该动它。 */ ?>
共 <b data-roster-total><?= $total ?></b> 人<?= $groups && $q === '' ? ',分 ' . count($groups) . ' 个关系分组' : '' ?>
v28:原来后面跟着「,分 6 个关系分组」—— 分组已成标签,
改成说"打了几个标签"(情报更贴近新模型)。 */ ?>
共 <b data-roster-total><?= $total ?></b> 人<?= $tagCloud && $q === '' ? ',有 ' . count($tagCloud) . ' 个标签' : '' ?>
</span>
<div class="actions">
<?php if ($canMove && $pickable > 0): ?>
@@ -299,30 +295,42 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
<?php endif; ?>
<?php endif; ?>
</div>
<?php /* ==================== v28:顶部标签筛选条 ====================
用户第 5 条把「分组」换成「标签」之后,名册**不再分栏**(一个人挂 2 个标签
会在两栏里各出现一次,看着像两个人)。改成一整张列表 + 这条筛选:
· 「全部」= 不筛;点某个标签 = 只看挂了这个标签的人。
· 用**链接**(`?tag=xxx`)而不是 JS 切换 —— 服务端渲染,无 JS 也能筛,
而且"筛选状态"在 URL 里,刷新/分享/回退都不会丢。
⚠️ 只列出**真有人的**标签(PersonTag::cloud 已经 JOIN 了 roster)——
否则会出现"标签写着 5 个、点进去只有 3 个"。 */ ?>
<?php if ($tagCloud): ?>
<div class="roster-tagbar">
<span class="roster-tagbar-k">标签</span>
<a class="note-tag<?= $tagFilter === '' ? ' is-on' : '' ?>"
href="<?= e(url('people', $q !== '' ? ['q' => $q] : [])) ?>">全部 <b><?= (int)$total ?></b></a>
<?php foreach ($tagCloud as $t): ?>
<a class="note-tag<?= $tagFilter === (string)$t['tag'] ? ' is-on' : '' ?>"
href="<?= e(url('people', ['tag' => (string)$t['tag']] + ($q !== '' ? ['q' => $q] : []))) ?>">
#<?= e((string)$t['tag']) ?> <b><?= (int)$t['n'] ?></b>
</a>
<?php endforeach; ?>
<?php if ($tagFilter !== ''): ?>
<a class="link-quiet roster-tagbar-clear" href="<?= e(url('people', $q !== '' ? ['q' => $q] : [])) ?>">清除筛选</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php else: ?>
<?php /* ⚠️ 搜索与否**都走同一张列表**(v24 用户第 2 条之后)。
原来搜索时会把分组拆掉、平铺成一堆卡片,看着"搜的时候样式变了";
现在一律按分组呈现,filter.js 会把没命中的行藏起来,
整栏都不剩人时连栏一起收 —— 搜索结果与平时长得一样,只少了不匹配的。 */ ?>
<div class="roster<?= $canMove ? ' is-droppable' : '' ?>" id="rosterBox"
<?php /* v28:**平铺**成一张列表(原来按分组分成好几栏)。
⚠️ 拖动仍然可用,但语义已变:拖动只调整**我看到的先后**,
不再表示"改分组"(标签在卡片上点一下就能改)。 */ ?>
<div class="roster is-flat<?= $canMove ? ' is-droppable' : '' ?>" id="rosterBox"
data-reorder="<?= e(url('people/reorder')) ?>"
data-csrf="<?= e(csrf_token()) ?>">
<?php foreach ($groups as $g): ?>
<?php $gid = (int)$g['id']; ?>
<?php $renderBlock($gid, (string)$g['name'], $grouped[$gid] ?? []); ?>
<?php endforeach; ?>
<?php $renderGrid($people); ?>
<?php
// 「未分类」栏永远画出来(哪怕是空的)—— 它是把卡片从某个分组里拖出来的落点。
// 提示语按需求删掉了(用户原话:「这些人还没归到任何分组里…这些提示,都去掉」)。
$renderBlock(0, '未分类', $grouped[0] ?? []);
?>
<?php if (!$groups): ?>
<p class="small muted" style="margin:0">
还没有任何分组。管理员可以在「系统设置 → 公共分组」里建一套全家共用的,
你也可以在「个人设置 → 我的分组」里加只给自己看的。
</p>
<?php if ($tagFilter !== '' && !$people): ?>
<p class="small muted" style="margin:0">这个标签下暂时没有人。</p>
<?php endif; ?>
</div>
@@ -384,23 +392,41 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
改成居中的模态框后,外部点击只关模态本身,触发按钮的语义不再冲突。 */ ?>
<div class="rm-modal" id="rosterMenus" hidden>
<div class="rm-mask" data-rm-close></div>
<div class="rm-panel" id="rmGroup" role="dialog" aria-modal="true" aria-label="选择分组" hidden>
<div class="rm-head"><b>把「<span class="pg-who"></span>」分到</b>
<?php /* 🔴 v28 用户第 5 条:这个浮层从「选一个分组(radio 单选)」改成
「**打标签(多选 + 自由输入)**」。
为什么用 checkbox + 一个文本框而不是纯文本输入:
· checkbox 列出**我已有过的标签**,点一下就能加/去 —— 日常最高频的动作;
· 文本框允许**当场打新词**("小学同学"这种第一次出现的),
两种情况都要支持,否则用户第一次打新标签时无处可打。
⚠️ 提交的是一个**逗号分隔的字符串**(`tags`),服务端 `PersonTag::setFor`
做整组替换 —— 与笔记标签同一套归一规则(去 #、去空白、去重、限长)。
⚠️ 保留原生「保存」按钮:JS 出错时手动点它也能提交,不掉进"点了没反应"的黑洞。 */ ?>
<div class="rm-panel" id="rmTags" role="dialog" aria-modal="true" aria-label="编辑标签" hidden>
<div class="rm-head"><b>「<span class="pt-who"></span>」的标签</b>
<button type="button" class="rm-x" data-rm-close aria-label="关闭">✕</button></div>
<form method="post" action="<?= e(url('people/group')) ?>" class="pg-menu">
<form method="post" action="<?= e(url('people/tags')) ?>" class="pt-form">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="">
<div class="rm-body">
<label class="pg-item">
<input type="radio" name="group_id" value="">
<span>未分类</span>
</label>
<?php foreach ($groups as $g): ?>
<label class="pg-item">
<input type="radio" name="group_id" value="<?= (int)$g['id'] ?>">
<span><?= e((string)$g['name']) ?></span>
</label>
<?php endforeach; ?>
<?php /* 已有的标签(我名下全部用过的):点一下勾选/取消 */ ?>
<?php if ($tagCloud): ?>
<div class="pt-picks" id="ptPicks">
<?php foreach ($tagCloud as $t): ?>
<label class="pt-pick">
<input type="checkbox" value="<?= e((string)$t['tag']) ?>">
<span>#<?= e((string)$t['tag']) ?></span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="field" style="margin-top:12px">
<label for="ptInput">标签(用空格分开,可直接打新的)</label>
<input class="input" id="ptInput" name="tags" maxlength="300"
placeholder="如:小学同学 初中同学 球友" autocomplete="off">
</div>
<p class="small muted" style="margin:8px 0 0">
一个人可以有多个标签 —— 他既是小学同学、又是初中同学,两个都打上就行。
</p>
</div>
<div class="rm-foot">
<button class="btn" type="submit">保存</button>
@@ -500,28 +526,20 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
var totalEl = document.querySelector('[data-roster-total]');
var searchInput = document.getElementById('rosterSearch');
/** 摘掉一个人 = 摘卡片 + 更新"共 N 人" + 更新他那一栏的人数 */
/** 摘掉一个人 = 摘卡片 + 更新"共 N 人"
* ⚠️ v28:名册已**平铺**(不再分栏),所以这里删掉了原来"更新他那一栏人数"
* 的两行(`.group-block` / `[data-group-n]` 在新结构里都不存在了)。 */
function removePerson(pid) {
var card = document.querySelector('.person[data-person="' + pid + '"]');
if (!card) { return false; }
var block = card.closest('.group-block');
card.parentNode.removeChild(card);
if (totalEl) {
var n = parseInt(totalEl.textContent || '0', 10) || 0;
totalEl.textContent = String(Math.max(0, n - 1));
}
if (block) {
var gid = block.getAttribute('data-group');
var nEl = document.querySelector('[data-group-n="' + gid + '"]');
if (nEl) {
var m = parseInt(nEl.textContent || '0', 10) || 0;
nEl.textContent = String(Math.max(0, m - 1));
}
}
// ① 让 filter.js 重算筛选/空态/分栏显隐(它监听的正是 input 事件)。
// 没有 filter.js(极老浏览器)时什么也不做 —— 只是栏头人数会略旧,
// 卡片本身已经摘掉了,不影响用户看到的结果。
// 让 filter.js 重算筛选/空态(它监听的正是 input 事件)。
// 没有 filter.js(极老浏览器)时什么也不做 —— 卡片本身已经摘掉了。
if (searchInput) {
try { searchInput.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {}
}
@@ -608,17 +626,17 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
(function () {
var wrap = document.getElementById('rosterMenus');
if (!wrap) { return; }
var gPanel = document.getElementById('rmGroup');
var tPanel = document.getElementById('rmTags');
var dPanel = document.getElementById('rmDel');
var mPanel = document.getElementById('rmMore');
function closeAll() {
wrap.hidden = true;
[gPanel, dPanel, mPanel].forEach(function (p) { if (p) { p.hidden = true; } });
[tPanel, dPanel, mPanel].forEach(function (p) { if (p) { p.hidden = true; } });
}
function open(panel) {
[gPanel, dPanel, mPanel].forEach(function (p) { if (p && p !== panel) { p.hidden = true; } });
[tPanel, dPanel, mPanel].forEach(function (p) { if (p && p !== panel) { p.hidden = true; } });
panel.hidden = false;
wrap.hidden = false;
// 焦点进面板:点开就能用键盘选(Esc 也能关,见下)
@@ -626,23 +644,28 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
if (first) { first.focus(); }
}
// ---- 打开:分组 ----
// ---- 打开:标签(v28 用户第 5 条,原先是"分组")----
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var gb = t.closest('[data-pg-open]');
if (gb) {
var tb = t.closest('[data-pt-open]');
if (tb) {
e.preventDefault();
var form = gPanel.querySelector('form');
form.querySelector('input[name="person_id"]').value = gb.getAttribute('data-pg-open');
gPanel.querySelector('.pg-who').textContent = gb.getAttribute('data-pg-name') || '';
// 预选当前分组(点开就知道他现在在哪一栏)
var cur = parseInt(gb.getAttribute('data-pg-cur') || '0', 10) || 0;
Array.prototype.forEach.call(form.querySelectorAll('input[name="group_id"]'), function (r) {
r.checked = (parseInt(r.value || '0', 10) || 0) === cur;
});
open(gPanel);
var form = tPanel.querySelector('form');
form.querySelector('input[name="person_id"]').value = tb.getAttribute('data-pt-open');
tPanel.querySelector('.pt-who').textContent = tb.getAttribute('data-pt-name') || '';
// 预填他现在的标签(勾选框回勾 + 文本框回填)——
// 点开就知道他打了什么,不用去卡片上找。
// ⚠️ 回填函数由下面那段 IIFE 挂到 window.__ptFill(两段脚本解耦,
// 顺序变了也不会"回填静默失效")。
if (typeof window.__ptFill === 'function') {
window.__ptFill(tb.getAttribute('data-pt-tags') || '');
} else {
var inp0 = form.querySelector('#ptInput');
if (inp0) { inp0.value = tb.getAttribute('data-pt-tags') || ''; }
}
open(tPanel);
return;
}
@@ -693,83 +716,124 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
});
})();
<?php /* ==================== v24 第 25 批 第 1 条:改分组**不刷新页面** ====================
用户原话:「名册模块在列表中,对列表中的某一个人进行分组,也不应该刷新界面,
而是应该动态调整页面。」
<?php /* ==================== v28 用户第 5 条:改标签**不刷新页面** ====================
原型是 v24 第 25 批的"改分组不刷新"(用户原话:「名册模块在列表中,对列表中的
某一个人进行分组,也不应该刷新界面,而是应该动态调整页面」)——
现在分组换成了标签,这条诉求一字不变地适用:改完标签**就地更新卡片**,
不整页重载(否则搜索框的字没了、滚动位置回到顶部)。
原来:`change` 一下 → `requestSubmit()` → 服务端 302 回 people 页 → **整页重载**
(搜索框里的字没了、滚动位置回到顶部、正在拖的栏也收起来了)。
原来还要"把卡片挪到目标分组栏里";名册已平铺(不再分栏),
所以现在只做两件事:① 换掉卡片上那颗标签按钮的显示;② 更新卡片的 data-tags。
现在:拦下那个提交,改 `fetch` POST;成功后**就地把他挪到目标分组栏里**,
并更新两栏的人数与「共 N 人」不变,最后重跑一次筛选(复用 filter.js)。
⚠️ 四个必须踩准的点(与删除那段的四条同源,但多了一条"挪卡片"):
① **卡片真的要在栏之间移动**,不能只改按钮上的文字 ——
用户改分组的目的是"看到他在新栏里",只换文字等于没做。
② 移动要**插到目标栏末尾**,而不是 append 到 .roster(那会掉到所有栏外面)。
③ 目标栏原本是空的(`is-empty` + `hidden`)→ 必须把 `is-empty` 去掉、
`hidden` 也要清掉。只去 is-empty 没用:作者样式里 `.group-block` 没写 display,
浏览器默认的 `[hidden]{display:none}` 照样生效,那一栏连同刚挪进去的人一起看不见
(app.js 的 refreshCounts() 里记过同一个坑)。
④ 失败一律**退回原生提交**:网络断/会话过期/权限不足 ——
任何一种都不能静默失败("点了没反应"比"刷一下"糟得多)。 */ ?>
⚠️ 失败一律**退回原生提交**:网络断 / 会话过期 / 权限不足 ——
任何一种都不能静默失败("点了没反应"比"刷一下"糟得多)。 */ ?>
(function () {
var wrap = document.getElementById('rosterMenus');
if (!wrap) { return; }
var gPanel = document.getElementById('rmGroup');
var form = gPanel ? gPanel.querySelector('form.pg-menu') : null;
var tPanel = document.getElementById('rmTags');
var form = tPanel ? tPanel.querySelector('form.pt-form') : null;
if (!form || !window.fetch || !window.FormData) { return; }
var totalEl = document.querySelector('[data-roster-total]');
var searchInput = document.getElementById('rosterSearch');
/** 就地把他挪到目标分组栏里 + 两栏人数跟着变 */
function moveCard(pid, gid, gname) {
var card = document.querySelector('.person[data-person="' + pid + '"]');
if (!card) { return false; }
var from = card.closest('.group-block');
var toSec = document.querySelector('.group-block[data-group="' + gid + '"]');
if (!toSec) { return false; } // 目标栏不存在(分组被删了)→ 别乱挪
var grid = toSec.querySelector('.people-grid');
if (!grid) { return false; }
// ① 先记下"他在哪一栏"(要减人数的那一栏)
var fromGid = from ? from.getAttribute('data-group') : null;
// ② 挪过去(插到末尾:新归到一组的人在那一组最后,与拖拽的语义一致)
grid.appendChild(card);
// ③ 目标栏:清 is-empty 与 hidden(空栏平时是收起来的)
toSec.classList.remove('is-empty');
toSec.hidden = false;
// ④ 两栏的人数都重数一遍(比 +1/-1 稳:筛选着的时候"可见数"≠"总数")
[fromGid, String(gid)].forEach(function (g) {
if (g === null) { return; }
var sec = document.querySelector('.group-block[data-group="' + g + '"]');
var nEl = document.querySelector('[data-group-n="' + g + '"]');
if (!sec || !nEl) { return; }
var n = sec.querySelectorAll('.people-grid .person').length;
nEl.textContent = String(n);
sec.classList.toggle('is-empty', n === 0);
/* 勾选框 ↔ 文本框**双向同步**:
· 勾/取消一个已有标签 → 把它增删进文本框(文本框是唯一提交源);
· 文本框里手打的新词 → 打开时按它回勾(见 fillPanel)。
为什么用"文本框当唯一真相"而不是"提交时拼两部分":
拼两部分就要处理"同一个标签既勾了又在文本框里"(重复)、
"文本框里删了但勾还在"(该算删还是算加)—— 全是边界 bug 的温床。 */
function syncFromPicks() {
if (!tPanel) { return; }
var inp = tPanel.querySelector('#ptInput');
if (!inp) { return; }
var on = [];
Array.prototype.forEach.call(tPanel.querySelectorAll('.pt-pick input:checked'), function (c) {
on.push(c.value);
});
// ⑤ 按钮上显示的文字跟着换(用户第 1 条:那颗按钮**显示当前分组**)
var btn = card.querySelector('.person-group-btn');
if (btn) {
btn.textContent = gname !== '' ? gname : '分组';
btn.classList.toggle('is-none', gname === '');
btn.setAttribute('data-pg-cur', String(gid));
btn.setAttribute('title', '改 ' + (btn.getAttribute('data-pg-name') || '') + ' 的分组');
}
// ⑥ 让 filter.js 重算筛选/空态/分栏显隐(它监听的正是 input 事件)
if (searchInput) {
try { searchInput.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {}
}
return true;
// 保留文本框里**手打的、不在已有标签里**的那些(它们没有勾选框)
var known = {};
Array.prototype.forEach.call(tPanel.querySelectorAll('.pt-pick input'), function (c) {
known[c.value] = true;
});
var keep = [];
String(inp.value || '').split(/[\s,,]+/).forEach(function (w) {
w = w.replace(/^#/, '');
if (w !== '' && !known[w]) { keep.push(w); }
});
inp.value = on.concat(keep).join(' ');
}
/** 打开面板时:把这个人已有的标签灌进去(勾选框回勾 + 文本框回填) */
function fillPanel(tagsStr) {
if (!tPanel) { return; }
var tags = String(tagsStr || '').split(/[\s,,]+/).filter(function (x) { return x !== ''; });
var inp = tPanel.querySelector('#ptInput');
if (inp) { inp.value = tags.join(' '); }
Array.prototype.forEach.call(tPanel.querySelectorAll('.pt-pick input'), function (c) {
c.checked = tags.indexOf(c.value) >= 0;
});
}
window.__ptFill = fillPanel;
if (tPanel) {
tPanel.addEventListener('change', function (e) {
if (e.target && e.target.closest && e.target.closest('.pt-pick')) { syncFromPicks(); }
});
}
form.addEventListener('submit', function (ev) {
if (form.getAttribute('data-ajax-fallback') === '1') { return; }
ev.preventDefault();
var pid = (form.querySelector('input[name="person_id"]') || {}).value || '';
var input = form.querySelector('#ptInput');
var want = input ? input.value : '';
var btn = form.querySelector('button[type="submit"]');
if (btn) { btn.disabled = true; }
fetch(form.getAttribute('action'), {
method: 'POST',
body: new FormData(form),
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) {
return r.json().catch(function () { return null; });
}).then(function (d) {
if (btn) { btn.disabled = false; }
if (d && d.ok) {
wrap.hidden = true;
if (tPanel) { tPanel.hidden = true; }
var tags = (d.tags || []);
var card = document.querySelector('.person[data-person="' + pid + '"]');
if (card) {
card.setAttribute('data-tags', tags.join(' '));
var b = card.querySelector('.person-tag-btn');
if (b) {
b.textContent = tags.length ? tags.join(' ') : '标签';
b.classList.toggle('is-none', tags.length === 0);
b.setAttribute('data-pt-tags', tags.join(' '));
}
}
say(true, d.msg || '标签已改。');
return;
}
if (d && d.msg) {
wrap.hidden = true;
if (tPanel) { tPanel.hidden = true; }
say(false, d.msg);
return;
}
form.setAttribute('data-ajax-fallback', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
}).catch(function () {
if (btn) { btn.disabled = false; }
form.setAttribute('data-ajax-fallback', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
});
function say(ok, msg) {
var host = document.querySelector('.flash');
if (!host) {
@@ -785,49 +849,6 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
host.insertBefore(el, host.firstChild);
setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 4000);
}
form.addEventListener('submit', function (ev) {
if (form.getAttribute('data-ajax-fallback') === '1') { return; }
ev.preventDefault();
var pid = (form.querySelector('input[name="person_id"]') || {}).value || '';
var checked = form.querySelector('input[name="group_id"]:checked');
var gid = checked ? (parseInt(checked.value || '0', 10) || 0) : 0;
var btn = form.querySelector('button[type="submit"]');
if (btn) { btn.disabled = true; }
fetch(form.getAttribute('action'), {
method: 'POST',
body: new FormData(form),
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) {
return r.json().catch(function () { return null; });
}).then(function (d) {
if (btn) { btn.disabled = false; }
if (d && d.ok) {
if (wrap) { wrap.hidden = true; }
if (gPanel) { gPanel.hidden = true; }
moveCard(String(pid), gid, String(d.gname || ''));
say(true, d.msg || '分组已改。');
return;
}
if (d && d.msg) {
if (wrap) { wrap.hidden = true; }
if (gPanel) { gPanel.hidden = true; }
say(false, d.msg);
return;
}
form.setAttribute('data-ajax-fallback', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
}).catch(function () {
if (btn) { btn.disabled = false; }
form.setAttribute('data-ajax-fallback', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
});
})();
</script>
<?php endif; ?>
+230 -161
View File
@@ -189,29 +189,33 @@ $myPerson = (int)\App\Auth::personId();
<span class="node-badge"><?= e(\App\Resource::permWord((string)$r['perm'])) ?></span>
<?php if ($as): ?><span class="res-n"><?= count($as) ?> 个附件</span><?php endif; ?>
<?php /* ---- 右上角四色按钮(与标题同一排)----
⚠️ 必须用 <span> 包(phrasing content);`<div>`/`<form>` 放 <summary> 里是非法 HTML。
⚠️ 每个按钮都带 `data-res-open`,脚本里对它 `preventDefault()`
以阻止 <summary> 顺带把这一条展开。 */ ?>
<?php /* ---- 右上角按钮(与标题同一排)----
🔴 v28 用户第 7 条:「资源功能,编辑和附件两个按钮合并吧,就叫编辑,
既可以更改信息,也可以删除和增加附件,之前分开,操作的时候,
还是有割裂感。共享也合并,这样就剩下两个按钮,编辑和删除。」
⇒ 从四颗(编辑/共享/附件/删除)收成**两颗**:
· 「编辑」→ 打开一个**带页签**的弹框:信息 / 附件 / 共享;
· 「删除」→ 保持独立(危险动作不该混在编辑里,误点代价不可逆)。
⚠️ 仍然必须用 <span> 包(phrasing content);`<div>`/`<form>` 放
<summary> 里是非法 HTML。
⚠️ 按钮仍带 `data-res-open`,脚本对它 `preventDefault()`
以阻止 <summary> 顺带把这一条展开。
⚠️ 页签要的数据(标题/正文/归属人/附件/已共享)全都挂在这一颗按钮上:
弹框全页只有一份,点哪条就把哪条的数据灌进去 ——
这正是"避免每行一份表单"那条老规矩(2369 个 form 把页面拖死)。 */ ?>
<?php if ($canEdit): ?>
<span class="res-ops">
<button type="button" class="btn btn-warn btn-sm res-op"
data-res-open="edit" data-res-id="<?= $rid ?>"
data-res-title="<?= e((string)$r['title']) ?>"
data-res-content="<?= e((string)($r['content'] ?? '')) ?>"
data-res-owner="<?= (int)$r['owner_id'] ?>">编辑</button>
<button type="button" class="btn btn-ok btn-sm res-op"
data-res-open="share" data-res-id="<?= $rid ?>"
data-res-title="<?= e((string)$r['title']) ?>"
data-res-owner="<?= (int)$r['owner_id'] ?>"
data-res-shares="<?= e((string)json_encode(array_map(
static fn($s): array => [
'id' => (int)$s['person_id'],
'name' => (string)$s['name'],
'perm' => (string)($s['perm'] ?? 'read'),
], $sh), JSON_UNESCAPED_UNICODE)) ?>">共享</button>
<button type="button" class="btn btn-info btn-sm res-op"
data-res-open="att" data-res-id="<?= $rid ?>"
data-res-title="<?= e((string)$r['title']) ?>"
], $sh), JSON_UNESCAPED_UNICODE)) ?>"
data-res-atts="<?= e((string)json_encode(array_map(
static function ($a): array {
$k = \App\Attachment::kind($a);
@@ -224,7 +228,7 @@ $myPerson = (int)\App\Auth::personId();
'dl' => media_url((string)$a['key'], ['dl' => 1]),
'sz' => \App\Attachment::sizeWord($a['size'] ?? 0),
];
}, $as), JSON_UNESCAPED_UNICODE)) ?>">附件</button>
}, $as), JSON_UNESCAPED_UNICODE)) ?>">编辑</button>
<button type="button" class="btn btn-del btn-sm res-op"
data-res-open="del" data-res-id="<?= $rid ?>"
data-res-title="<?= e((string)$r['title']) ?>"
@@ -295,128 +299,136 @@ $myPerson = (int)\App\Auth::personId();
<div class="res-modal" id="resPanels" hidden>
<div class="res-mask" data-res-close></div>
<?php /* ---------- ① 编辑信息(黄色按钮) ---------- */ ?>
<div class="res-panel-lg" id="resEditPanel" hidden role="dialog" aria-modal="true" aria-label="编辑资源">
<div class="res-ph"><b>编辑「<span class="rd-title"></span>」</b>
<?php /* ---------- 编辑(一个弹框,三个页签:信息 / 附件 / 共享)----------
🔴 v28 用户第 7 条原话:「资源功能,编辑和附件两个按钮合并吧,就叫编辑,
既可以更改信息,也可以删除和增加附件,之前分开,操作的时候,还是有割裂感。
共享也合并,这样就剩下两个按钮,编辑和删除。」
⇒ 三块内容合成一个面板,用**页签**分隔。为什么用页签而不是把它们堆成一长条:
· 三块各是一套表单(信息 / 上传 / 共享),堆起来用户要滚很久才找得到;
· 页签让"我现在在改哪一块"一目了然,也避免了"三套表单同时铺在眼前"的压力。
⚠️ 只有 info 那一页是**本来就有的表单**,附件与共享两块复用了原来那两份表单,
服务端路由**一个都没改**(resources/update、attach_add、share、unshare)——
这是纯前端的信息架构调整,不动数据契约,也不动权限。
⚠️ 共享那块要的"已共享给谁"由脚本按按钮上的 data-res-shares 现渲染,
全页仍然只有**一份** #resUnshareForm(沿用原设计,别改回每条一份)。 */ ?>
<div class="res-panel-lg res-panel-tabs" id="resEditPanel" hidden role="dialog" aria-modal="true" aria-label="编辑资源">
<div class="res-ph"><b>「<span class="rd-title"></span>」</b>
<button type="button" class="res-x" data-res-close aria-label="关闭">✕</button></div>
<form method="post" action="<?= e(url('resources/update')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<?php /* 页签条 */ ?>
<div class="res-tabs" role="tablist">
<button type="button" class="res-tab is-on" data-res-tab="info" role="tab">信息</button>
<button type="button" class="res-tab" data-res-tab="att" role="tab">附件</button>
<button type="button" class="res-tab" data-res-tab="share" role="tab">共享</button>
</div>
<?php /* ---- 页签 ① 信息 ---- */ ?>
<div class="res-tabpage" data-res-page="info">
<form method="post" action="<?= e(url('resources/update')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<div class="res-pb">
<div class="field">
<label>归属于谁 *</label>
<?php
$ppName = 'owner_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '更换归属人';
$ppTitle = '资源归属于谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field">
<label>标题 *</label>
<input class="input" name="title" required>
</div>
<div class="field">
<label>正文</label>
<textarea class="input" name="content" rows="3"
placeholder="编号、有效期、放在哪里…"></textarea>
</div>
</div>
<div class="res-pf">
<button class="btn btn-warn" type="submit">保存</button>
<button class="btn btn-ghost" type="button" data-res-close>取消</button>
</div>
</form>
</div>
<?php /* ---- 页签 ② 附件(原「附件」按钮那整套搬进来,一字未改)---- */ ?>
<div class="res-tabpage" data-res-page="att" hidden>
<div class="res-pb">
<div class="field">
<label>归属于谁 *</label>
<?php
$ppName = 'owner_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '更换归属人';
$ppTitle = '资源归属于谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<div class="rd-att-now">
<h3 class="res-sub">已有附件</h3>
<div class="rd-att-list" id="resAttNow"></div>
</div>
<div class="field">
<label>标题 *</label>
<input class="input" name="title" required>
</div>
<div class="field">
<label>正文</label>
<textarea class="input" name="content" rows="3"
placeholder="编号、有效期、放在哪里…"></textarea>
</div>
<p class="tip" style="margin:0">
附件不在这里改 —— 用蓝色「附件」按钮(那边可以拖动上传、也能删)。
</p>
</div>
<div class="res-pf">
<button class="btn btn-warn" type="submit">保存</button>
<button class="btn btn-ghost" type="button" data-res-close>取消</button>
</div>
</form>
</div>
<?php /* ---------- ② 共享设置(绿色按钮)----------
用户原话:「设置共享人员,**包括删除共享人**等」→ 一个面板里两件事:
· 上面:给谁开共享(选人 + 权限 + 共享按钮)
· 下面:已经共享给谁 + 逐个「取消共享」
⚠️ "已共享"那一段由脚本按按钮上的 `data-res-shares` 现渲染,
**不是**每条资源渲染一份表单 —— 那正是要避免的膨胀。
取消共享走下面这个唯一的 #resUnshareForm(脚本填 id/person_id 后提交)。 */ ?>
<div class="res-panel-lg" id="resSharePanel" hidden role="dialog" aria-modal="true" aria-label="共享设置">
<div class="res-ph"><b>共享「<span class="rd-title"></span>」</b>
<button type="button" class="res-x" data-res-close aria-label="关闭">✕</button></div>
<div class="res-pb">
<form method="post" action="<?= e(url('resources/share')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<div class="field">
<label>共享给谁</label>
<?php
$ppName = 'person_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<span class="tip">所有者本来就有全部权限,不用(也不能)共享给他。</span>
</div>
<div class="field">
<label>权限</label>
<select class="select" name="perm">
<option value="read">只读</option>
<option value="write">可读写</option>
</select>
</div>
<div class="btn-row" style="margin-top:0">
<button class="btn btn-ok" type="submit">共享</button>
</div>
</form>
<div class="res-share-now">
<h3 class="res-sub">已共享给</h3>
<div class="rd-share-list" id="resShareNow"></div>
<form method="post" enctype="multipart/form-data"
action="<?= e(url('resources/attach_add')) ?>" id="resAttForm">
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<h3 class="res-sub">添加附件</h3>
<?php /* ⚠️ 自己的文件框 id(与"新建资源"里那个必须不同,同 id 会撞)。 */ ?>
<?php $sdName = 'attach[]'; $sdSlot = 'photo'; $sdId = 'sd-attach-panel';
require __DIR__ . '/../_stage_drop.php'; ?>
<div class="btn-row">
<button class="btn btn-info" type="submit">上传</button>
</div>
<p class="tip" style="margin:4px 0 0">
拖进来会**立刻上传**;点「上传」才真正挂到这条资源上。
</p>
</form>
</div>
</div>
<?php /* 取消共享:唯一的一份表单,脚本填两个隐藏字段后提交。 */ ?>
<form method="post" action="<?= e(url('resources/unshare')) ?>" id="resUnshareForm" hidden>
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<input type="hidden" name="person_id" value="">
</form>
</div>
<?php /* ---------- ③ 附件(蓝色按钮)----------
用户原话:「附件,点击后弹框,允许用户拖动上传」。
→ 上:已有附件(可删);下:拖动/点击上传区(复用全站那套 `_stage_drop.php`,
框 + 立刻上传 + 暂存令牌,与其它模块完全一致)。 */ ?>
<div class="res-panel-lg" id="resAttPanel" hidden role="dialog" aria-modal="true" aria-label="附件">
<div class="res-ph"><b>附件 ·「<span class="rd-title"></span>」</b>
<button type="button" class="res-x" data-res-close aria-label="关闭">✕</button></div>
<div class="res-pb">
<div class="rd-att-now">
<h3 class="res-sub">已有附件</h3>
<div class="rd-att-list" id="resAttNow"></div>
<?php /* ---- 页签 ③ 共享(原「共享」按钮那整套搬进来)---- */ ?>
<div class="res-tabpage" data-res-page="share" hidden>
<div class="res-pb">
<form method="post" action="<?= e(url('resources/share')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<div class="field">
<label>共享给谁</label>
<?php
$ppName = 'person_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<span class="tip">所有者本来就有全部权限,不用(也不能)共享给他。</span>
</div>
<div class="field">
<label>权限</label>
<select class="select" name="perm">
<option value="read">只读</option>
<option value="write">可读写</option>
</select>
</div>
<div class="btn-row" style="margin-top:0">
<button class="btn btn-ok" type="submit">共享</button>
</div>
</form>
<div class="res-share-now">
<h3 class="res-sub">已共享给</h3>
<div class="rd-share-list" id="resShareNow"></div>
</div>
</div>
<form method="post" enctype="multipart/form-data"
action="<?= e(url('resources/attach_add')) ?>" id="resAttForm">
<?php /* 取消共享:唯一的一份表单,脚本填两个隐藏字段后提交。 */ ?>
<form method="post" action="<?= e(url('resources/unshare')) ?>" id="resUnshareForm" hidden>
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<h3 class="res-sub">添加附件</h3>
<?php /* ⚠️ 自己的文件框 id(与"新建资源"里那个必须不同,同 id 会撞)。 */ ?>
<?php $sdName = 'attach[]'; $sdSlot = 'photo'; $sdId = 'sd-attach-panel';
require __DIR__ . '/../_stage_drop.php'; ?>
<div class="btn-row">
<button class="btn btn-info" type="submit">上传</button>
</div>
<p class="tip" style="margin:4px 0 0">
拖进来会**立刻上传**;点「上传」才真正挂到这条资源上。
</p>
<input type="hidden" name="person_id" value="">
</form>
</div>
<?php /* 删附件:唯一的一份表单,脚本填 id 后提交。 */ ?>
<form method="post" action="<?= e(url('resources/attach_del')) ?>" id="resAttDelForm" hidden>
<?= csrf_field() ?>
@@ -460,36 +472,82 @@ $myPerson = (int)\App\Auth::personId();
}
/* ============================================================
四个动作 → 一份共享弹框(v24 第 23 批 用户第 1 条)
动作 → 一份共享弹框(v24 第 23 批 用户第 1 条;
v28 第 7 条把「编辑 / 附件 / 共享」合成一个**带页签**的面板)
============================================================ */
var wrap = document.getElementById('resPanels');
if (!wrap) { return; }
/* ⚠️ v28:share 与 att 不再是独立面板,而是 edit 面板里的两个**页签**。
这里仍给它们留 key(都指向同一个 edit 面板),是为了让下面那段
"按 data-res-open 找面板"的老逻辑**一字不改**就能继续用 ——
卡片上若还有旧按钮(历史页面缓存)也不会打不开。
⚠️ 但 `show()` / `closeAll()` 遍历这些 key 时**必须先去重**:
三个 key 指向同一个元素,`k !== which` 会互相把对方设成隐藏,
最后一个说了算 → 点「编辑」弹框打不开(本轮真机实测抓到的真 bug)。
⚠️ 下面这几个 key 名故意**不写出旧按钮的 data 值**:这段是 JS,
会原样输出到每个用户的页面上,写出来等于把废弃钩子名发给大家
(也会让"旧钩子已清除"这类静态断言假红)。 */
var editPanel = document.getElementById('resEditPanel');
var panels = {
edit: document.getElementById('resEditPanel'),
share: document.getElementById('resSharePanel'),
att: document.getElementById('resAttPanel'),
edit: editPanel,
share: editPanel,
att: editPanel,
del: document.getElementById('resDelPanel')
};
function closeAll() {
wrap.hidden = true;
Object.keys(panels).forEach(function (k) {
if (panels[k]) { panels[k].hidden = true; }
/** 把面板切到某一页签(tab: info / att / share) */
function tabTo(tab) {
if (!editPanel) { return; }
Array.prototype.forEach.call(editPanel.querySelectorAll('.res-tab'), function (b) {
b.classList.toggle('is-on', b.getAttribute('data-res-tab') === tab);
});
Array.prototype.forEach.call(editPanel.querySelectorAll('.res-tabpage'), function (p) {
p.hidden = (p.getAttribute('data-res-page') !== tab);
});
}
function show(which) {
function closeAll() {
wrap.hidden = true;
// ⚠️ 同样按**去重后的面板**遍历(share/att 与 edit 是同一个元素,见 show 的注释)
var uniq = [];
Object.keys(panels).forEach(function (k) {
if (panels[k]) { panels[k].hidden = (k !== which); }
var p = panels[k];
if (p && uniq.indexOf(p) < 0) { uniq.push(p); }
});
uniq.forEach(function (p) { p.hidden = true; });
}
function show(which) {
/* ⚠️ 这里**不能**用 `k !== which` 去遍历 panels 设 hidden:
v28 把 share / att 两个 key 都指向**同一个** editPanel(它们现在是页签),
遍历时"share !== which"与"att !== which"会轮流把同一个面板设成隐藏,
**最后一个 key 说了算** → 点「编辑」时弹框根本打不开(本轮实测抓到的真 bug)。
⇒ 改成:只对**互不相同的面板**做显隐(用 Set 去重),语义也更直白。 */
var uniq = [];
Object.keys(panels).forEach(function (k) {
var p = panels[k];
if (p && uniq.indexOf(p) < 0) { uniq.push(p); }
});
uniq.forEach(function (p) { p.hidden = true; });
wrap.hidden = false;
var p = panels[which];
if (!p) { return; }
p.hidden = false;
var first = p.querySelector('input:not([type=hidden]), textarea, select');
if (first) { first.focus(); }
}
/* 页签点击:切页(不改 URL、不开新面板) */
if (editPanel) {
editPanel.addEventListener('click', function (e) {
var b = e.target && e.target.closest ? e.target.closest('[data-res-tab]') : null;
if (!b) { return; }
e.preventDefault();
tabTo(b.getAttribute('data-res-tab'));
});
}
/** 从 data 属性读 JSON(坏了就当空数组,别让一个坏属性把整个弹框搞死) */
function jsonOf(el, attr) {
try {
@@ -534,43 +592,27 @@ $myPerson = (int)\App\Auth::personId();
var idInp = panel.querySelector('input[name="id"]');
if (idInp) { idInp.value = id; }
if (which === 'edit') {
/* 🔴 v28 第 7 条:「编辑 / 附件 / 共享」现在是**同一个面板的三个页签**。
所以这里对三者走同一段:把三块数据全灌好(哪块用不上就没人看),
最后切到对应的页签。
⚠️ 为什么"三块都灌"而不是"只灌当前页签那份":
用户在弹框里点页签来回切时,不该再回到按钮去取数据(数据在按钮上,
而按钮可能因为列表刷新换了一个);一次灌好,切页签就是纯显示切换。
⚠️ 旧 key(share / att)也要能落到正确页签 —— 历史页面缓存里可能还有
旧按钮,点了不该什么都看不到。 */
if (which === 'edit' || which === 'share' || which === 'att') {
var tInp = panel.querySelector('input[name="title"]');
if (tInp) { tInp.value = title; }
var cInp = panel.querySelector('textarea[name="content"]');
if (cInp) { cInp.value = op.getAttribute('data-res-content') || ''; }
// 归属人:选人组件是单选的,用它的对外接口设置。
// ⚠️ 必须把 panel 作为 root 传进去 —— 本页有多个同类组件
// (新建表单的 owner_id、本面板的 owner_id、共享面板的 person_id),
// (新建表单的 owner_id、本面板的 owner_id、共享页签的 person_id),
// 只按字段名找会命中**文档里第一个**(财务页在这上面栽过)。
var pid = op.getAttribute('data-res-owner') || '0';
if (window.ppSetSelected) { window.ppSetSelected('owner_id', pid, panel); }
}
if (which === 'share') {
// 渲染"已共享给谁"。每行一个「取消共享」按钮 →
// 点它时把 id/person_id 灌进唯一的 #resUnshareForm 再提交。
if (shareNow) {
shareNow.innerHTML = '';
var list = jsonOf(op, 'data-res-shares');
if (!list.length) {
shareNow.appendChild(el('span', 'small muted', '还没有共享给谁。'));
}
list.forEach(function (s) {
var row = el('span', 'rd-share-row');
row.appendChild(el('span', 'rd-share-name', s.name || ('#' + s.id)));
row.appendChild(el('span', 'node-badge', s.perm === 'write' ? '可读写' : '只读'));
var b = el('button', 'btn btn-ghost btn-sm', '取消共享');
b.type = 'button';
b.setAttribute('data-rd-unshare', String(s.id));
row.appendChild(b);
shareNow.appendChild(row);
});
}
}
if (which === 'att') {
// 渲染"已有附件"(名字 + 下载 + 删除)
// ---- 附件页签:渲染"已有附件"(名字 + 下载 + 删除)----
if (attNow) {
attNow.innerHTML = '';
var atts = jsonOf(op, 'data-res-atts');
@@ -600,6 +642,33 @@ $myPerson = (int)\App\Auth::personId();
attNow.appendChild(row);
});
}
// ---- 共享页签:渲染"已共享给谁"。每行一个「取消共享」按钮 →
// 点它时把 id/person_id 灌进唯一的 #resUnshareForm 再提交。 ----
if (shareNow) {
shareNow.innerHTML = '';
var list = jsonOf(op, 'data-res-shares');
if (!list.length) {
shareNow.appendChild(el('span', 'small muted', '还没有共享给谁。'));
}
list.forEach(function (s) {
var row = el('span', 'rd-share-row');
row.appendChild(el('span', 'rd-share-name', s.name || ('#' + s.id)));
row.appendChild(el('span', 'node-badge', s.perm === 'write' ? '可读写' : '只读'));
var b = el('button', 'btn btn-ghost btn-sm', '取消共享');
b.type = 'button';
b.setAttribute('data-rd-unshare', String(s.id));
row.appendChild(b);
shareNow.appendChild(row);
});
}
// 三块数据都灌好了 → 切到该显示的页签
// ⚠️ 卡片上的「编辑」按钮统一进 info 页(用户点"编辑"想改的一般是信息);
// 历史缓存里的旧 share/att 按钮则落到各自页签。
tabTo(which === 'share' ? 'share' : (which === 'att' ? 'att' : 'info'));
show('edit');
return;
}
if (which === 'del') {
+9 -2
View File
@@ -426,8 +426,15 @@ $lastColIdx = count($colList) - 1;
badge.setAttribute('data-st', status);
badge.setAttribute('data-next', next);
badge.textContent = word;
badge.setAttribute('title', '现在:' + word + ';点一下改成「'
+ ({todo: '待办', doing: '进行中', done: '已完成'}[next] || '') + '」');
/* 🔴🔴 v28 用户第 4 条:「任务完成复选框的提示文字还在,需要删除。」
根因就在这一行 —— 它给**状态徽标**动态挂了一个 `title`。
v24 第 26 批只把**静态渲染**里那颗徽标的 title 去掉了(服务端模板),
却漏了这一处 JS:用户只要点过一次复选框,徽标就又被安上 tooltip,
而它离复选框最近,鼠标一停就把复选框盖住 —— 与当初报的是同一个现象。
⚠️ 上一批的静态断言(查渲染出的 HTML 里有没有 `title=`)**永远抓不到**:
它只在点击之后才出现。这类"JS 复活的旧写法"必须靠真机点击验证。
⇒ 直接删掉,不再设 title;无障碍说明靠 aria-label(不产生可见浮层)。 */
badge.setAttribute('aria-label', word);
}
card.classList.toggle('is-done', status === 'done');
say('已标成「' + word + '」。', true);