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
+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; ?>