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
+280
View File
@@ -0,0 +1,280 @@
<?php
/**
* 轻量 Markdown 渲染(v28 用户第 12 条)。
*
* 用户原话:「笔记功能,段落是需要支持富文本的,可以使用 markdown,但是需要使用
* 富文本的方式进行编辑,同时可以使用 markdown 源码观看编辑。就是说,
* 允许所见即所得编辑,也允许 markdown 编辑。」
*
* 为什么自己写而**不引入第三方库**(wyc 2026-10-07 已确认):
* · 本项目全站只有这一处要 Markdown,为一个功能引一个几百 KB~数 MB 的编辑器,
* 与"轻量级"直接冲突(Semi 那 4 MB 已经在按需加载了,不该再加一个);
* · 我们支持的语法是**有界的**(标题 / 粗斜体 / 行内代码 / 代码块 / 列表 /
* 引用 / 链接 / 分割线),不是完整 CommonMark —— 有界的东西自己写**更可靠**
* (不会在某次依赖升级后行为漂移)。
*
* 🔴 安全:**不允许任何原始 HTML 通过**。所有输入先 `htmlspecialchars`,
* 再按我们自己的语法加标记。这样 `<script>` 只会被画成一行字,
* 而不是被执行 —— 富文本编辑器最常见的 XSS 来源就是"允许内嵌 HTML"。
* 链接只允许 http/https/mailto,`javascript:` 一律降级成纯文本。
*/
declare(strict_types=1);
namespace App;
final class Markdown
{
/**
* 把一段 Markdown 渲染成安全的 HTML 片段。
*
* @param bool $inline true = 只做行内语法(用于列表/表格单元格),不产生 <p> 包裹
*/
public static function render(string $src, bool $inline = false): string
{
// 统一换行,去掉 BOM(从别处粘贴来的文本常带)
$src = str_replace(["\r\n", "\r"], "\n", $src);
$src = preg_replace('/^\xEF\xBB\xBF/', '', $src) ?? $src;
$lines = explode("\n", $src);
$out = [];
$i = 0;
$n = count($lines);
while ($i < $n) {
$line = $lines[$i];
// ---- 代码块 ``` 或 ~~~ ----------------------------------------
if (preg_match('/^\s*(```+|~~~+)\s*([A-Za-z0-9_+-]*)\s*$/', $line, $m)) {
$fence = $m[1][0];
$lang = (string)$m[2];
$buf = [];
$i++;
while ($i < $n && !preg_match('/^\s*' . preg_quote(str_repeat($fence, 3), '/') . '+/', $lines[$i])) {
$buf[] = $lines[$i];
$i++;
}
$i++; // 跳过收尾的围栏
$cls = $lang !== '' ? ' class="md-code lang-' . e($lang) . '"' : ' class="md-code"';
$out[] = '<pre' . $cls . '><code>' . e(implode("\n", $buf)) . '</code></pre>';
continue;
}
// ---- 标题 # ~ ###### ------------------------------------------
if (preg_match('/^(#{1,6})\s+(.*)$/', $line, $m)) {
$lv = strlen($m[1]);
// ⚠️ 从 h4 起(h1/h2 留给页面本身),免得笔记里的标题盖过页面标题
$tag = 'h' . min(6, $lv + 2);
$out[] = '<' . $tag . ' class="md-h">' . self::inline(trim($m[2])) . '</' . $tag . '>';
$i++;
continue;
}
// ---- 分割线 ----------------------------------------------------
if (preg_match('/^\s*([-*_])(\s*\1){2,}\s*$/', $line)) {
$out[] = '<hr class="md-hr">';
$i++;
continue;
}
// ---- 引用 > (支持多层,用前缀数决定层级)----------------------
if (preg_match('/^\s*>\s?(.*)$/', $line)) {
$buf = [];
while ($i < $n && preg_match('/^\s*>\s?(.*)$/', $lines[$i], $mm)) {
$buf[] = $mm[1];
$i++;
}
$out[] = '<blockquote class="md-quote">' . self::render(implode("\n", $buf)) . '</blockquote>';
continue;
}
// ---- 无序列表(支持**多级**:按前导空格数决定层级)--------------
if (preg_match('/^(\s*)[-*+]\s+(.*)$/', $line, $m)) {
[$html, $i] = self::list($lines, $i, $n, false);
$out[] = $html;
continue;
}
// ---- 有序列表 --------------------------------------------------
if (preg_match('/^(\s*)\d+[.)]\s+(.*)$/', $line, $m)) {
[$html, $i] = self::list($lines, $i, $n, true);
$out[] = $html;
continue;
}
// ---- 空行 ------------------------------------------------------
if (trim($line) === '') {
$i++;
continue;
}
// ---- 普通段落:连续非空行合成一段(软换行)-----------------------
$buf = [$line];
$i++;
while ($i < $n && trim($lines[$i]) !== ''
&& !preg_match('/^\s*(#{1,6}\s|>|```|~~~|[-*+]\s|\d+[.)]\s)/', $lines[$i])) {
$buf[] = $lines[$i];
$i++;
}
$para = implode("\n", $buf);
// 段内换行 → <br>(Markdown 的"两个空格结尾"太隐晦,这里一律当硬换行)
$html = self::inline($para);
$html = str_replace("\n", "<br>", $html);
$out[] = '<p class="md-p">' . $html . '</p>';
}
$html = implode("\n", $out);
return $inline ? $html : $html;
}
/**
* 解析一段列表(含**多级**:更深缩进的项嵌进上一项的里层)。
*
* 做法是经典的"按缩进栈":把每一项的缩进记下来,遇到更深的就开一层新 <ul>,
* 更浅就逐层关掉。这样"手工敲两个空格"也能自然形成层级,
* 不需要用户去记"必须用 4 个空格"。
*
* @return array{0:string,1:int} [html, 新行号]
*/
private static function list(array $lines, int $i, int $n, bool $ordered): array
{
$html = '';
$stack = []; // 每层的缩进宽度
$tag = $ordered ? 'ol' : 'ul';
while ($i < $n) {
$line = $lines[$i];
$pat = $ordered ? '/^(\s*)\d+[.)]\s+(.*)$/' : '/^(\s*)[-*+]\s+(.*)$/';
if (!preg_match($pat, $line, $m)) {
// 非列表行:若是空行且后面还有更深的列表项,继续;否则结束
if (trim($line) === '') {
$i++;
continue;
}
break;
}
$indent = strlen(str_replace("\t", ' ', $m[1]));
$content = $m[2];
if (!$stack) {
$html .= '<' . $tag . ' class="md-list">';
$stack[] = $indent;
} elseif ($indent > end($stack)) {
// 更深 → 开新的一层(塞进上一个 <li> 里,成为它的子列表)
$html .= '<' . $tag . ' class="md-list">';
$stack[] = $indent;
} elseif ($indent < end($stack)) {
// 更浅 → 逐层关掉
while ($stack && $indent < end($stack)) {
array_pop($stack);
$html .= '</li></' . $tag . '>';
}
if ($stack) {
$html .= '</li>';
}
} else {
$html .= '</li>';
}
$html .= '<li class="md-li">' . self::inline(trim($content));
$i++;
}
// 收尾:把还开着的层全部关掉(每层一个 </li> + </ul>)
while ($stack) {
array_pop($stack);
$html .= '</li></' . $tag . '>';
}
return [$html, $i];
}
/**
* 行内语法:`code` → 粗体 → 斜体 → 链接 → 删除线。
*
* ⚠️ 顺序要紧:**先处理行内代码**,再处理粗斜体 ——
* 否则 `` `a*b*c` `` 里的星号会被当斜体吃掉(代码里的字符不该被解释)。
* ⚠️ 先 escape 再加标记:所有 `<` `>` `&` 在进来时就已经是实体了,
* 我们只往里加**自己的**标签,不可能被注入。
*/
public static function inline(string $s): string
{
$s = e($s);
// `code`(先做,且内部不再解释其它语法)
$s = preg_replace_callback('/`([^`\n]+)`/', static function ($m): string {
return '<code class="md-icode">' . $m[1] . '</code>';
}, $s) ?? $s;
// ![alt](url) 图片(笔记里可能有,但我们**不内联渲染外部图片** ——
// 那是隐私与外链风险;只显示成一个链接,点了自己开)
$s = preg_replace_callback('/!\[([^\]]*)\]\(([^)\s]+)\)/', static function ($m): string {
$u = self::safeUrl($m[2]);
return $u === '' ? $m[1] : '<a class="md-link" href="' . $u . '" target="_blank" rel="noopener noreferrer">'
. ($m[1] !== '' ? $m[1] : $u) . '</a>';
}, $s) ?? $s;
// [文字](url)
// ⚠️ URL 里遇到右括号就截断 —— 所以 `javascript:alert(1)` 这种带括号的
// 会被切掉后半截(只剩 `javascript:alert(1`)→ 协议判定仍然不过 → 降级成纯文本。
// 剩下的那截尾巴(`)`)会留在正文里,看着像个小瑕疵。
// ⇒ 用回调把"不合法链接"的**整段原文**还原回去,而不是只留前半截。
$s = preg_replace_callback('/\[([^\]]+)\]\(([^)\s]*)\)/', static function ($m): string {
$u = self::safeUrl($m[2]);
if ($u === '') {
return $m[0]; // 不合法 → 原样保留(用户看到自己写的东西,而不是被切一半)
}
return '<a class="md-link" href="' . $u . '" target="_blank" rel="noopener noreferrer">'
. $m[1] . '</a>';
}, $s) ?? $s;
// **粗体** / __粗体__
$s = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $s) ?? $s;
$s = preg_replace('/__(.+?)__/s', '<strong>$1</strong>', $s) ?? $s;
// *斜体* / _斜体_(** 已经先被吃掉,所以这里不会误伤)
$s = preg_replace('/\*(.+?)\*/s', '<em>$1</em>', $s) ?? $s;
// ~~删除线~~
$s = preg_replace('/~~(.+?)~~/s', '<del>$1</del>', $s) ?? $s;
return $s;
}
/** 只放行 http / https / mailto;其它(含 javascript:)返回空串 */
private static function safeUrl(string $u): string
{
$u = trim($u);
if ($u === '') {
return '';
}
// 已 escape 过,还原常见实体再做协议判定
$raw = html_entity_decode($u, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if (!preg_match('#^(https?:|mailto:)#i', $raw)) {
return '';
}
return $u;
}
/**
* 纯文本摘要(列表页卡片用):把 Markdown 记号去掉,只留可读的字。
* 比"渲染成 HTML 再 strip_tags"更省,也不会把 `**` 这种记号露给用户。
*/
public static function excerpt(string $src, int $len = 120): string
{
$s = str_replace(["\r\n", "\r"], "\n", $src);
// 去掉代码围栏标记、标题号、引用号、列表号
$s = preg_replace('/^\s*(```+|~~~+).*$/m', '', $s) ?? $s;
$s = preg_replace('/^\s*#{1,6}\s*/m', '', $s) ?? $s;
$s = preg_replace('/^\s*>\s?/m', '', $s) ?? $s;
$s = preg_replace('/^\s*(?:[-*+]|\d+[.)])\s+/m', '', $s) ?? $s;
// 去掉行内标记
$s = preg_replace('/\*\*(.+?)\*\*/s', '$1', $s) ?? $s;
$s = preg_replace('/\*(.+?)\*/s', '$1', $s) ?? $s;
$s = preg_replace('/~~(.+?)~~/s', '$1', $s) ?? $s;
$s = preg_replace('/`([^`\n]+)`/', '$1', $s) ?? $s;
$s = preg_replace('/!?\[([^\]]*)\]\([^)\s]+\)/', '$1', $s) ?? $s;
$s = preg_replace('/\s+/u', ' ', $s) ?? $s;
$s = trim($s);
if (mb_strlen($s, 'UTF-8') > $len) {
$s = mb_substr($s, 0, $len, 'UTF-8') . '…';
}
return $s;
}
}
+44
View File
@@ -983,6 +983,50 @@ final class Nav
['DuckGo', 'ddg', 'https://duckduckgo.com/', 'q', '', '', '#DE5833', 0], ['DuckGo', 'ddg', 'https://duckduckgo.com/', 'q', '', '', '#DE5833', 0],
]; ];
/**
* **联想接口库**(v28 用户第 10 条)。
*
* 用户原话:「导航功能,联想接口还是不能用啊,需要你补充几个进去,让用户选择。
* 搜索引擎列表,联想接口,是**对所有用户可见**的。」
*
* ⇒ 提供一份**内置的、大家都能看到**的联想接口清单(不是每人各存一份)。
* 用户在「搜索引擎设置」里点一下就能把某个接口用到自己的引擎上。
*
* ⚠️ 这份清单里的每一条都必须**实测可用**(下方注释标了来源)。
* 国内网络下不可达的(谷歌 / DuckDuckGo)**放进去也没用** ——
* 用户点了发现永远空下拉,比不提供更差。所以只列走通的。
* ⚠️ `type` 决定解析方式(见 nav_suggest.php 的 nav_parse_suggest),
* 必须是那边认得的取值,加新类型要**两处一起改**。
*/
public const SUGGEST_LIBRARY = [
// [显示名, 联想地址模板, 解析类型, 说明]
['百度', 'https://www.baidu.com/sugrec?prod=pc&wd={q}', 'baidu_sugrec',
'实测最快最稳(约 0.1 秒)'],
['360', 'https://sug.so.360.cn/suggest?encodein=utf-8&encodeout=utf-8&format=json&word={q}',
'so360', '速度次之(约 0.2 秒)'],
['必应', 'https://api.bing.com/osjson.aspx?query={q}', 'bing_osjson',
'可用但偏慢(0.5~1.6 秒)'],
['搜狗', 'https://suggestion.sogou.com/suggnew/ajajjson?key={q}&type=web', 'sogou',
'备用(域名时好时坏)'],
['知乎', 'https://www.zhihu.com/api/v4/search/suggest?q={q}&token=', 'zhihu',
'内容向站点,适合当第二来源'],
['B站', 'https://s.search.bilibili.com/main/suggest?term={q}', 'bili',
'内容向站点'],
];
/**
* 某个人现在**能选**的联想接口 = 内置库里的全部(对所有用户可见)。
* @return array<int,array{name:string,url:string,type:string,note:string}>
*/
public static function suggestLibrary(): array
{
$out = [];
foreach (self::SUGGEST_LIBRARY as $row) {
$out[] = ['name' => $row[0], 'url' => $row[1], 'type' => $row[2], 'note' => $row[3]];
}
return $out;
}
/** /**
* 把**缺失的内置引擎**补给这个人(v24 第 26 批 用户第 4 条)。 * 把**缺失的内置引擎**补给这个人(v24 第 26 批 用户第 4 条)。
* *
+106 -5
View File
@@ -40,14 +40,20 @@ namespace App;
final class Note final class Note
{ {
/** 附件(图片块)在统一 attachments 表里的宿主类型 */ /** 附件(图片块 / 笔记底部附件)在统一 attachments 表里的宿主类型 */
public const ATTACH = 'note'; public const ATTACH = 'note';
/** 块类型白名单(写在服务端;前端传别的值一律归一成 text) */ /** 块类型白名单(写在服务端;前端传别的值一律归一成 text) */
public const BLOCK_KINDS = ['text', 'image', 'list']; public const BLOCK_KINDS = ['text', 'image', 'list'];
/** 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类) */ /**
public const MAX_BLOCKS = 60; * 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类)
*
* 🔴 v28 第 12 条:从 60 提到 200 —— 现在正文支持 Markdown,
* 用户会把它当"一页文档"来写(原来更多是"随手记几句话")。
* ⚠️ 上限仍然要有:它是防"误贴一整本书"的护栏,不是体验设计。
*/
public const MAX_BLOCKS = 200;
public const MAX_TEXT = 20000; public const MAX_TEXT = 20000;
/** 列表页默认显示多少条(超过给"加载更多") */ /** 列表页默认显示多少条(超过给"加载更多") */
@@ -156,6 +162,10 @@ final class Note
"SELECT * FROM note_blocks WHERE note_id IN ($in) ORDER BY note_id, position, id" "SELECT * FROM note_blocks WHERE note_id IN ($in) ORDER BY note_id, position, id"
); );
foreach ($st->fetchAll() as $b) { foreach ($st->fetchAll() as $b) {
// v28:text 块同时算好 Markdown 渲染结果(列表页要显示富文本)
$b['html'] = (string)$b['kind'] === 'text'
? Markdown::render((string)$b['content'])
: '';
$blocks[(int)$b['note_id']][] = $b; $blocks[(int)$b['note_id']][] = $b;
} }
@@ -171,11 +181,47 @@ final class Note
$id = (int)$r['id']; $id = (int)$r['id'];
$r['blocks'] = $blocks[$id] ?? []; $r['blocks'] = $blocks[$id] ?? [];
$r['tags'] = $tags[$id] ?? []; $r['tags'] = $tags[$id] ?? [];
/* v28 第 12 条:列表卡片要用摘要(定尺寸网格里放不下全文)。
摘要按 Markdown 去记号后取前 N 字 —— 卡片上不渲染 HTML,
这样列表页再长也不会因为 Markdown 变慢。 */
$r['excerpt'] = self::excerptOf($r);
} }
unset($r); unset($r);
return $rows; return $rows;
} }
/**
* 从一条笔记的块里生成**纯文本摘要**(列表卡片用)。
* 图片块计作 [图片],清单块取前几行,段落走 Markdown 去记号。
*/
private static function excerptOf(array $note, int $len = 110): string
{
$buf = [];
foreach ((array)($note['blocks'] ?? []) as $b) {
$kind = (string)$b['kind'];
$c = (string)$b['content'];
if ($kind === 'image') {
$buf[] = '[图片]';
} elseif ($kind === 'list') {
$buf[] = Markdown::excerpt($c, 60);
} else {
$buf[] = Markdown::excerpt($c, 200);
}
// 凑够长度就不再往下拼(长笔记没必要全量处理一遍)
if (mb_strlen(implode(' ', $buf), 'UTF-8') >= $len) {
break;
}
}
$txt = trim((string)preg_replace('/\s+/u', ' ', implode(' ', $buf)));
if ($txt === '') {
return '';
}
if (mb_strlen($txt, 'UTF-8') > $len) {
$txt = mb_substr($txt, 0, $len, 'UTF-8') . '…';
}
return $txt;
}
/** 单条(带权限判定)。看不到返回 null。 */ /** 单条(带权限判定)。看不到返回 null。 */
public static function find(int $familyId, int $id, int $viewer): ?array public static function find(int $familyId, int $id, int $viewer): ?array
{ {
@@ -191,18 +237,73 @@ final class Note
} }
$r['blocks'] = self::blocks($id); $r['blocks'] = self::blocks($id);
$r['tags'] = self::tags($id); $r['tags'] = self::tags($id);
$r['files'] = self::files($id); // v28:底部附件
$r['is_mine'] = (int)$r['person_id'] === $viewer ? 1 : 0; $r['is_mine'] = (int)$r['person_id'] === $viewer ? 1 : 0;
return $r; return $r;
} }
/** 某条笔记的块(按位置) */ /**
* 一条笔记的**底部附件**(v28 第 12 条:「再加上附件上传功能,
* 附件,都放在笔记最下面就行了。」)。
*
* ⚠️ 与**图片块**(kind=image)不是一回事,别混:
* · 图片块 = 插在正文中间、参与图文混排的图(用户第 12 条要的"图片和正文混排");
* · 底部附件 = 挂在笔记末尾的任意文件(PDF / 压缩包 / 表格…)。
* 两者共用 `attachments` 表(target_type='note'),靠这个顺序体现区别:
* 图片块在上传时就由 `claimUploads` 认领(target_id=真实笔记 id),
* 底部附件则由 `attach_add` 路由显式挂上来 —— **都是同一张表的行**,
* 所以删笔记时外键级联一把全部带走,不会留孤儿。
*
* 怎么区分两者(同一个 target_id 下):图片块的那些**已经作为 image 块的内容**
* 出现在 note_blocks 里;渲染底部附件时把它们排掉即可。
*
* @return array<int,array> attachments 行(按 position)
*/
public static function files(int $noteId): array
{
if ($noteId <= 0) {
return [];
}
// 先取出"用作图片块的那些 key",避免它们在底部附件里重复出现
$used = [];
foreach (self::blocks($noteId) as $b) {
if ((string)$b['kind'] === 'image') {
$used[(string)$b['content']] = true;
}
}
$out = [];
foreach (Attachment::list(self::ATTACH, $noteId) as $a) {
if (isset($used[(string)$a['key']])) {
continue;
}
$out[] = $a;
}
return $out;
}
/**
* 某条笔记的块(按位置)
*
* ⚠️ 这里给每个 text 块算好 `html`(Markdown 渲染结果)——**在模型层算一次**,
* 列表页与详情页共用,不必各自再调一遍 Markdown::render
* (两处各算一次,迟早有一处忘了转义或忘了渲染)。
*/
public static function blocks(int $noteId): array public static function blocks(int $noteId): array
{ {
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT * FROM note_blocks WHERE note_id = ? ORDER BY position, id' 'SELECT * FROM note_blocks WHERE note_id = ? ORDER BY position, id'
); );
$st->execute([$noteId]); $st->execute([$noteId]);
return $st->fetchAll(); $rows = $st->fetchAll();
foreach ($rows as &$r) {
$kind = (string)$r['kind'];
// 只有段落支持 Markdown(清单走自己的渲染、图片走 media)
$r['html'] = $kind === 'text'
? Markdown::render((string)$r['content'])
: '';
}
unset($r);
return $rows;
} }
/** 某条笔记的标签 */ /** 某条笔记的标签 */
+28 -5
View File
@@ -153,6 +153,20 @@ final class PersonRoster
* @param array $columns [['g' => 分组id(0=未分类), 'ids' => [personId, ...]], ...] * @param array $columns [['g' => 分组id(0=未分类), 'ids' => [personId, ...]], ...]
* @return int 实际写入的条数 * @return int 实际写入的条数
*/ */
/**
* 保存名册布局(拖动排序/改栏)。返回真正写了几条。
*
* 🔴 v28 用户第 5 条:名册**平铺**之后不再有"改栏"这回事(一个人挂多个标签,
* 分栏会让他在多栏重复出现)。前端会把整个平铺列表当成"唯一一栏"(`g=0`)报上来。
* ⚠️ 这时**绝对不能写 group_id**:
* · `g=0` 按下面那条规则会被落成 NULL —— 拖一次就把所有人原有的分组清空了;
* · 而 `person_roster.group_id` 正是"旧分组→同名标签"迁移的数据来源
* (见 Schema 的迁移步骤),也不能被随手抹掉;
* · 它同时还是 vCard CATEGORIES 的读点(导出通讯录要用)。
* ⇒ 判据:`g` 缺省(前端没报这一栏的组号)时**只更新 sort_order**,
* 完全不碰 group_id。这样"拖动只改顺序"这个新语义是结构上保证的,
* 而不是靠调用方记得别传错值。
*/
public static function applyLayout(int $familyId, int $ownerId, array $columns): int public static function applyLayout(int $familyId, int $ownerId, array $columns): int
{ {
$mine = self::mine($familyId, $ownerId); $mine = self::mine($familyId, $ownerId);
@@ -161,7 +175,11 @@ final class PersonRoster
} }
$okGroups = PersonGroup::visibleIds($familyId, $ownerId); $okGroups = PersonGroup::visibleIds($familyId, $ownerId);
$db = Database::get(); $db = Database::get();
$up = $db->prepare( $upSort = $db->prepare(
'UPDATE person_roster SET sort_order = ?
WHERE family_id = ? AND owner_id = ? AND person_id = ?'
);
$upBoth = $db->prepare(
'UPDATE person_roster SET group_id = ?, sort_order = ? 'UPDATE person_roster SET group_id = ?, sort_order = ?
WHERE family_id = ? AND owner_id = ? AND person_id = ?' WHERE family_id = ? AND owner_id = ? AND person_id = ?'
); );
@@ -171,10 +189,11 @@ final class PersonRoster
if (!is_array($col)) { if (!is_array($col)) {
continue; continue;
} }
// 只有**显式带了组号**的栏才去动 group_id(见方法注释)
$hasG = array_key_exists('g', $col);
$gid = (int)($col['g'] ?? 0); $gid = (int)($col['g'] ?? 0);
// 分组不合法(别人家的私自分组 / 已删掉的分组)→ 落成未分类,绝不静默挂错 if ($hasG && $gid !== 0 && !isset($okGroups[$gid])) {
if ($gid !== 0 && !isset($okGroups[$gid])) { $gid = 0; // 别人家的私自分组 / 已删的分组 → 未分类,绝不静默挂错
$gid = 0;
} }
foreach ((array)($col['ids'] ?? []) as $pid) { foreach ((array)($col['ids'] ?? []) as $pid) {
$pid = (int)$pid; $pid = (int)$pid;
@@ -182,7 +201,11 @@ final class PersonRoster
continue; // 不在我名册里的人,忽略 continue; // 不在我名册里的人,忽略
} }
$sort += 10; $sort += 10;
$up->execute([$gid ?: null, $sort, $familyId, $ownerId, $pid]); if ($hasG) {
$upBoth->execute([$gid ?: null, $sort, $familyId, $ownerId, $pid]);
} else {
$upSort->execute([$sort, $familyId, $ownerId, $pid]);
}
$written++; $written++;
} }
} }
+187
View File
@@ -0,0 +1,187 @@
<?php
/**
* 名册标签(v28 用户 2026-10-07 第 5 条)。
*
* 用户原话:「名册的分组,用下来不太好,我感觉应该改成打标签,就是说,一个人,
* 他可能并不是只在一个分组,例如,他既是我的小学同学,又是我的初中同学,
* 就没法分组,改成标签,似乎更合理。」
*
* 于是把「一人一组」换成「一人多标签」。三条关键设计:
*
* ① **每个人一份**(`owner_id`)—— 与 person_roster 同口径。
* 我给他打「小学同学」,不该让全家的名册都多出这个标签;
* 而且同一个人在我这儿是「球友」、在我妈那儿是「外甥」,本来就该各说各的。
*
* ② **标签词直接存字符串,不建标签表** —— 与笔记模块同一条轻量取舍。
* 建一张标签表就要处理"改名后历史怎么办""两个标签合并",那是重型知识库才值
* 的复杂度;这里标签就是"随手打的词",靠 GROUP BY 现算完事。
*
* ③ **不动 person_roster.group_id** —— 那一列是 vCard CATEGORIES 与老代码的读点,
* 删掉会让"没跟上升级"的地方静默少一个字段。旧分组值已在 Schema 里
* 一次性迁成同名标签(幂等),之后两边各走各的:新界面只认标签。
*/
declare(strict_types=1);
namespace App;
final class PersonTag
{
/** 标签长度与数量上限(挡住"把一句话当标签") */
public const MAX_LEN = 24;
public const MAX_EACH = 12;
/**
* 我的名册里每个人的标签:person_id => [tag, tag, ...]
*
* 一次取完(列表页要整页渲染),不做 N+1 —— 名册上千人时那是致命的。
*/
public static function mapFor(int $familyId, int $ownerId): array
{
if ($familyId <= 0 || $ownerId <= 0) {
return [];
}
$st = Database::get()->prepare(
'SELECT person_id, tag FROM person_tags
WHERE family_id = ? AND owner_id = ?
ORDER BY person_id, position, id'
);
$st->execute([$familyId, $ownerId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['person_id']][] = (string)$r['tag'];
}
return $out;
}
/** 单个(详情/编辑页用) */
public static function of(int $ownerId, int $personId): array
{
if ($ownerId <= 0 || $personId <= 0) {
return [];
}
$st = Database::get()->prepare(
'SELECT tag FROM person_tags WHERE owner_id = ? AND person_id = ?
ORDER BY position, id'
);
$st->execute([$ownerId, $personId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[] = (string)$r['tag'];
}
return $out;
}
/**
* 我用过的全部标签 + 各挂了几个(顶部筛选条用)。
*
* ⚠️ 只统计**我的名册里还在的人** —— 否则会出现"标签上写着 5 个,
* 点进去只有 3 个"(另外 2 个已经被移出名册,标签行还在)。
*/
public static function cloud(int $familyId, int $ownerId): array
{
if ($familyId <= 0 || $ownerId <= 0) {
return [];
}
$st = Database::get()->prepare(
'SELECT t.tag, COUNT(*) AS n
FROM person_tags t
JOIN person_roster r ON r.owner_id = t.owner_id AND r.person_id = t.person_id
WHERE t.family_id = ? AND t.owner_id = ?
GROUP BY t.tag
ORDER BY n DESC, t.tag ASC'
);
$st->execute([$familyId, $ownerId]);
return $st->fetchAll();
}
/** 建议用的标签词(写标签时的联想;按人气排序) */
public static function suggest(int $familyId, int $ownerId, int $limit = 30): array
{
$out = [];
foreach (self::cloud($familyId, $ownerId) as $r) {
$out[] = (string)$r['tag'];
if (count($out) >= $limit) {
break;
}
}
return $out;
}
/**
* 整组替换某人的标签(名册卡片上改标签就用它)。
*
* 为什么是"整组替换"而不是逐个增删:界面是一次提交一整套标签的,
* 逐个 diff 反而要处理"哪些是新增/哪些是删除",代码多且容易漏。
* 标签没有历史价值,全量重写最简单可靠。
*
* @param array|string $raw 数组,或一段用空格/逗号分隔的标签
* @return array<int,string> 归一后真正写进去的标签(调用方可以回显)
*/
public static function setFor(int $familyId, int $ownerId, int $personId, $raw): array
{
if ($familyId <= 0 || $ownerId <= 0 || $personId <= 0) {
return [];
}
$tags = self::clean($raw);
$db = Database::get();
$db->prepare('DELETE FROM person_tags WHERE owner_id = ? AND person_id = ?')
->execute([$ownerId, $personId]);
if (!$tags) {
return [];
}
$st = $db->prepare(
'INSERT INTO person_tags (family_id, owner_id, person_id, tag, position)
VALUES (?,?,?,?,?)'
);
$pos = 10;
foreach ($tags as $t) {
$st->execute([$familyId, $ownerId, $personId, $t, $pos]);
$pos += 10;
}
return $tags;
}
/** 删某人时把他的标签一起删(persons 外键也会级联,这里显式一次更稳)。 */
public static function forgetPerson(int $personId): void
{
if ($personId <= 0) {
return;
}
Database::get()->prepare('DELETE FROM person_tags WHERE person_id = ?')->execute([$personId]);
}
/**
* 标签归一:`#小学同学` / `小学同学 ` / `小学,同学` 都要变成干净的词。
*
* 与 Note::cleanTags 同一套规则(去 `#`、去空白、去重、限长限数)——
* 两处各写一份迟早不一致(用户在笔记里能打 `#`,在名册里却打不了,最恼人)。
*/
public static function clean($raw): array
{
$list = [];
if (is_array($raw)) {
$list = $raw;
} elseif (is_string($raw)) {
$list = preg_split('/[\s,,]+/u', $raw) ?: [];
}
$out = [];
foreach ($list as $t) {
$t = trim((string)$t);
$t = ltrim($t, '#');
// 空白与 `#` 都不能出现在标签内部(它们是分隔符)
$t = (string)preg_replace('/[\s#]+/u', '', $t);
if ($t === '') {
continue;
}
$t = mb_substr($t, 0, self::MAX_LEN, 'UTF-8');
if (!in_array($t, $out, true)) {
$out[] = $t;
}
if (count($out) >= self::MAX_EACH) {
break;
}
}
return $out;
}
}
+80 -1
View File
@@ -101,6 +101,13 @@ final class Schema
* + `note_tags`(单层标签)。参考 flomo / Obsidian / 思源,但刻意**只取轻的**: * + `note_tags`(单层标签)。参考 flomo / Obsidian / 思源,但刻意**只取轻的**:
* 块只做 text/image/list 三种、不做块引用;标签只存字符串、不建标签表; * 块只做 text/image/list 三种、不做块引用;标签只存字符串、不建标签表;
* 权限默认私有、单条可一键共享给全家。详见 App\Note 头部。 * 权限默认私有、单条可一键共享给全家。详见 App\Note 头部。
* 28 → **名册「分组」改「标签」(用户 2026-10-07 第 5 条)**:新增 `person_tags`
* (每人一份、一人多标签、标签词存字符串)。动机是用户的原话:
* 「一个人可能并不是只在一个分组,例如他既是我的小学同学,又是我的初中同学,
* 就没法分组,改成标签,似乎更合理。」
* ⚠️ `person_roster.group_id` **保留**(vCard CATEGORIES 与老代码的读点),
* 启动时把已有分组**逐一迁成同名标签**(幂等:按 (owner,person,tag) 唯一键)。
* 名册页从「按分栏展示」改成「平铺 + 顶部标签筛选」,拖动仍可排序。
*/ */
/** /**
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。 * 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
@@ -108,7 +115,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/ */
public const VERSION = '27'; public const VERSION = '28';
/** /**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -970,6 +977,37 @@ CREATE TABLE IF NOT EXISTS `note_tags` (
CONSTRAINT `fk_nt_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE CONSTRAINT `fk_nt_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='笔记标签(单层、多对多;标签词本身不建表,见 App\\Note)' COMMENT='笔记标签(单层、多对多;标签词本身不建表,见 App\\Note)'
SQL,
// ---- 名册标签(v28 用户第 5 条)----
// 用户原话:「名册的分组,用下来不太好,我感觉应该改成打标签,就是说,
// 一个人,他可能并不是只在一个分组,例如,他既是我的小学同学,
// 又是我的初中同学,就没法分组,改成标签,似乎更合理。」
//
// ⇒ 分组(一人一组)→ 标签(一人多标签)。**表结构为什么这样切**:
// · `person_tags` 是**每人一份**的(owner_id = 谁的名册),
// 与 person_roster 同口径:标签是"我和他的关系",不是这个人的属性
// (我给他打"小学同学"不该让全家的名册都变)。
// · `tag` 直接存字符串,**不建标签表** —— 与笔记模块同一条轻量取舍:
// 标签是"随手打的词",不需要改名/合并/配色这些重型能力。
// · ⚠️ `person_roster.group_id` **保留不删**:它是老库升级与 CardDAV
// 导出的读点(vCard 的 CATEGORIES 由它派生),删掉会让"没跟上升级"
// 的地方静默少一个字段。改成"以新表为准、老列只做兼容回填"。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `person_tags` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED NOT NULL COMMENT '谁的名册(persons.id)—— 标签是"我和他的关系"',
`person_id` INT UNSIGNED NOT NULL COMMENT '被打标签的人(persons.id)',
`tag` VARCHAR(24) NOT NULL COMMENT '标签词(不含 #;同一人同一标签不重复)',
`position` INT NOT NULL DEFAULT 10 COMMENT '同一人多个标签时的顺序',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_pt` (`owner_id`, `person_id`, `tag`),
KEY `idx_pt_owner_tag` (`owner_id`, `tag`),
KEY `idx_pt_person` (`person_id`),
CONSTRAINT `fk_pt_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_pt_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='名册标签(v28:一个人可挂多个标签,取代"一人一组")'
SQL, SQL,
]; ];
} }
@@ -1815,6 +1853,47 @@ SQL);
); );
}); });
// v28:把「分组」逐一迁成**同名标签**(用户第 5 条:一人一组 → 一人多标签)。
/* 🔴🔴 必须用**一次性哨兵**,不能"每次 ensure 都尝试搬一遍"。
*
* 我第一版写的是「该人还没有任何标签时才搬」(NOT EXISTS 判 owner+person),
* 想当然地以为那就能防住"删除被撤销"。**实测当场推翻了这个假设**:
* · 用户在界面上把某人的标签**清空** → 那人身上一条标签都没有
* → `NOT EXISTS` 成立 → 下一次 ensure **把他旧的分组名又灌回来**。
* 症状:删掉的标签自己回来(与"移出的人又回来""纪念日关联人被老列覆盖"
* 是同一类坑,本项目已经撞过三次)。
*
* ⇒ 正确做法是"这件事**只做一次**":用一个设置键当哨兵,
* 做完了就记下,之后无论库里变成什么样都不再碰。
* 这也符合迁移的本质 —— 它是**一次性的数据转换**,不是持续同步。
* ⚠️ 哨兵键名带年份/版本(`person_tags_migrated_v28`),
* 将来真有第二次迁移要另起一个键,不要复用。
*/
self::tryStep('名册分组搬到标签(一人一组 → 一人多标签)', static function () use ($db) {
$flag = 'person_tags_migrated_v28';
$has = $db->query("SELECT COUNT(*) FROM `settings` WHERE `k` = '" . $flag . "'")->fetchColumn();
if ((int)$has > 0) {
return; // 已经搬过了,永不再搬(见上面的说明)
}
if (self::hasTable($db, 'person_tags') && self::hasTable($db, 'person_groups')) {
$db->exec(
"INSERT IGNORE INTO `person_tags` (family_id, owner_id, person_id, tag, position)
SELECT r.family_id, r.owner_id, r.person_id, g.name, 10
FROM `person_roster` r
JOIN `person_groups` g ON g.id = r.group_id
WHERE r.group_id IS NOT NULL
AND g.name IS NOT NULL AND g.name <> ''"
);
}
// 记哨兵(settings 是**家庭级**的,用最小 family_id 当归属,与
// schema_upgrade_warning 同一做法)
$fid = (int)$db->query('SELECT MIN(id) FROM families')->fetchColumn();
if ($fid > 0) {
$db->prepare("INSERT IGNORE INTO `settings` (family_id, `k`, `v`) VALUES (?,?,?)")
->execute([$fid, $flag, date('Y-m-d H:i:s')]);
}
});
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 —— // 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。 // 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
if (self::$stepErrors) { if (self::$stepErrors) {
+58 -4
View File
@@ -65,6 +65,32 @@ final class WeRead
*/ */
public const SYNC_TTL = 21600; public const SYNC_TTL = 21600;
/**
* 一次同步的**总时长预算**(秒)。
*
* 🔴🔴 v28 用户第 9 条:「读书,同步微信读书后,感觉有时候点击进来很慢,
* 需要加载很长时间,后来就好了,过一段时间又慢。」
*
* 根因(实测推演,症状完全吻合):
* 自动同步挂在**读书列表页**上(见 index.php 的 books 路由),
* 而 `syncNow()` 最多扫 60 本书,**每一本**都要打 1~3 次网关
* (bookInfo / progress / notes,缺一座就补一次封面还要再下图片)。
* 单次 `call()` 的超时是 **20 秒**(见 call() 的 CURLOPT_TIMEOUT)——
* 于是"6 小时到期的那一次"最坏能挂几分钟:用户点进来就是白屏等待,
* 过后(时间戳已写、间隔未到)再进来只读一个时间戳,立刻就快了;
* 再过 6 小时又是那一下 —— 这正是用户描述的"时快时慢、过一阵又慢"。
*
* ⇒ 修法:给整轮同步一个**总预算**。到点就**停下来、正常返回**,
* 已处理的部分照常落库,剩下的等下一轮接着做(游标从头再来一遍也无妨:
* `matchLocalBook` 与 `store` 都是幂等的,"已存在的会跳过")。
* ⚠️ 为什么设 8 秒:这是"用户能忍的一次页面等待"的量级。
* 真实耗时取决于网关,宁少勿多 —— 慢一点同步完,比页面打不开好得多。
*/
public const SYNC_BUDGET = 8;
/** 每次同步最多扫几本(与预算双保险:预算防"慢",条数防"多") */
public const SYNC_MAX_BOOKS = 25;
// ================================================================ // ================================================================
// Key 的存取(加密) // Key 的存取(加密)
// ================================================================ // ================================================================
@@ -140,8 +166,14 @@ final class WeRead
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true, CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json, CURLOPT_POSTFIELDS => $json,
CURLOPT_TIMEOUT => 20, // 🔴 v28 用户第 9 条:单次调用超时从 20 秒收到 **8 秒**。
CURLOPT_CONNECTTIMEOUT => 8, // 同步一轮要打好几次网关,20 秒 × N 次可以直接把页面拖垮;
// 而网关正常时是零点几秒就回的(见 nav_suggest.php 里那批实测:
// 同类接口 0.1~1.6 秒)。8 秒足以覆盖"网关偶发慢",又不会让
// 一次页面请求长时间挂着。慢的代价只是"这一轮少同步几本",
// 下一轮会接着做(幂等)。
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => [ CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key, 'Authorization: Bearer ' . $key,
@@ -685,6 +717,10 @@ final class WeRead
* 绝不让"同步失败"把读书页也打不开 —— 外部依赖不能拖死主流程。 * 绝不让"同步失败"把读书页也打不开 —— 外部依赖不能拖死主流程。
* *
* @param bool $force true = 忽略间隔,立刻同步(用户点「立即同步」时用) * @param bool $force true = 忽略间隔,立刻同步(用户点「立即同步」时用)
* 🔴 v28 用户第 9 条:$force 时给**更大的扫描量**(用户是
* 主动点的,愿意等;而且手动同步本来就该一次做完整)。
* 页面上的自动同步则用小量 —— 它挂在读书列表页上,
* 每多扫一本就多一次"打开页面就等"。
* @return array{ok:bool,ran:bool,error:string,books:int,notes:int,covers:int,at:string} * @return array{ok:bool,ran:bool,error:string,books:int,notes:int,covers:int,at:string}
*/ */
public static function maybeSync(int $familyId, int $personId, bool $force = false): array public static function maybeSync(int $familyId, int $personId, bool $force = false): array
@@ -698,12 +734,16 @@ final class WeRead
if (!$force && $last > 0 && (time() - $last) < self::SYNC_TTL) { if (!$force && $last > 0 && (time() - $last) < self::SYNC_TTL) {
return $empty; return $empty;
} }
// ⚠️ 先记时间再干活:万一下面 20 秒里超时/抛错,也不会在同一个页面 // ⚠️ 先记时间再干活:万一下面某一轮超时/抛错,也不会在同一个页面
// 反复重试("每次打开读书页都卡 20 秒"是最糟的症状)。 // 反复重试("每次打开读书页都卡 20 秒"是最糟的症状)。
// 🔴 v28:现在同步**有总预算**(SYNC_BUDGET),不会再长时间卡住;
// 这条"先记时间"依然要留 —— 它是"网关整个挂掉时别每次访问都重试"的兜底。
UserSetting::set($personId, self::SYNC_AT_KEY, (string)time()); UserSetting::set($personId, self::SYNC_AT_KEY, (string)time());
try { try {
$res = self::syncNow($familyId, $personId); // 自动同步只扫一小批(页面等待),手动同步扫完整批(用户愿意等)
$limit = $force ? 60 : self::SYNC_MAX_BOOKS;
$res = self::syncNow($familyId, $personId, $limit);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return ['ok' => false, 'ran' => true, 'error' => $e->getMessage(), return ['ok' => false, 'ran' => true, 'error' => $e->getMessage(),
'books' => 0, 'notes' => 0, 'covers' => 0, 'at' => date('Y-m-d H:i:s')]; 'books' => 0, 'notes' => 0, 'covers' => 0, 'at' => date('Y-m-d H:i:s')];
@@ -727,6 +767,16 @@ final class WeRead
*/ */
public static function syncNow(int $familyId, int $personId, int $maxBooks = 60): array public static function syncNow(int $familyId, int $personId, int $maxBooks = 60): array
{ {
/* 🔴 v28 用户第 9 条:**整轮的总时长预算**(见 SYNC_BUDGET 的注释)。
* 到点就停、正常返回已处理的部分,剩下的下一轮继续。
* ⚠️ 用"开始时刻 + 每轮循环都检查"而不是"包一个超时异常":
* PHP 的 curl 超时是按**单次调用**算的,没有"整个函数超时"这种机制;
* 要在中途刹车,只能在循环里自己判。
* ⚠️ 判据放在**每本书的开头**(不是每本书之间某处):一本书内部还会打
* 2~3 次网关,若在书中途判断就会留下半本书(字段补一半)。
* 整本为单位地停,重来时那本书会被幂等地重做一遍。 */
$deadline = microtime(true) + self::SYNC_BUDGET;
$nb = self::notebooks($personId); $nb = self::notebooks($personId);
if (!$nb['ok']) { if (!$nb['ok']) {
return ['ok' => false, 'error' => (string)$nb['error'], 'books' => 0, return ['ok' => false, 'error' => (string)$nb['error'], 'books' => 0,
@@ -738,6 +788,10 @@ final class WeRead
if ($scanned >= $maxBooks) { if ($scanned >= $maxBooks) {
break; break;
} }
// 总预算到点 → 停在这里(已处理的都已落库;剩下的下一轮再做)
if (microtime(true) >= $deadline) {
break;
}
$title = trim((string)$b['title']); $title = trim((string)$b['title']);
if ($title === '') { if ($title === '') {
continue; continue;
+301
View File
@@ -4571,3 +4571,304 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
/* 长英文/URL 不要把卡片撑出横向滚动(全站反复栽在这条上) */ /* 长英文/URL 不要把卡片撑出横向滚动(全站反复栽在这条上) */
.note-text, .note-list, .note-title { overflow-wrap: anywhere; } .note-text, .note-list, .note-title { overflow-wrap: anywhere; }
} }
/* ============================================================
v28 用户第 2 / 3 条:首页两块右上角的「更多日程 / 全部任务」按钮
要**用所属模块的主题色做背景**(原来一律 .btn-ghost 的白底灰字,
与"日程=橙、任务=砖红"的分区色完全看不出关系)。
做法:模板把模块色注入 `--hd-c`(见 home.php 的 style 属性),
这里只负责"拿这个色当底 + 白字 + 悬停加深"三件事 ——
不为日程/任务各写一套(那是两份迟早漂移的样式)。
⚠️ 用 color-mix 压暗悬停色而不是写死两个色:模块色在「个人设置」里可改,
写死就会出现"用户换了主题色、按钮悬停还是原来的颜色"。
============================================================ */
.hd-more {
background: var(--hd-c, var(--brand));
border-color: var(--hd-c, var(--brand));
color: #fff;
}
.hd-more:hover {
background: color-mix(in srgb, var(--hd-c, var(--brand)) 82%, #000);
border-color: color-mix(in srgb, var(--hd-c, var(--brand)) 82%, #000);
color: #fff;
}
/* 老浏览器不认 color-mix 时的兜底:至少不要变成透明/消失 */
@supports not (background: color-mix(in srgb, red 50%, blue)) {
.hd-more:hover { background: var(--hd-c, var(--brand)); filter: brightness(.88); }
}
/* ============================================================
v28 用户第 5 / 6 条:名册「分组」改「标签」+ 卡片按钮要有底色
============================================================ */
/* ---- 顶部标签筛选条(替代原来的分栏)---- */
.roster-tagbar {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
margin: 0 0 12px; padding: 10px 12px;
border: 1px solid var(--line); border-radius: var(--radius-s);
background: var(--surface);
}
.roster-tagbar-k { font-size: 12.5px; color: var(--faint); margin-right: 2px; }
.roster-tagbar-clear { margin-left: auto; font-size: 12.5px; }
/* ---- 平铺列表:卡片单列铺开,不再有"栏"的分隔线 ----
⚠️ `.people-grid` 原来在多栏里是 grid;平铺后让它 2 列自适应(宽屏两列、
窄屏一列),既不像单列那样长得没边,也不至于把姓名行挤断。
用 auto-fill + minmax 而不是写死列数:名册人数从 3 到 1000+ 都用同一套。 */
.roster.is-flat .people-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 8px;
}
@media (max-width: 720px) {
.roster.is-flat .people-grid { grid-template-columns: 1fr; }
}
/* ---- 🔴 用户第 6 条:「名册,每个人后面的按钮,也是要有背景颜色的。」
原来三颗按钮全是 .btn-ghost(白底 + 极浅描边),在纸白卡片上几乎看不见边界。
给它们各自一个**语义底色**(编辑=品牌淡、标签=中性灰、删除=危险淡),
而不是统一刷成一种 —— 一眼能分清哪个是危险动作。 ---- */
.person-edit,
.person-tag-btn,
.person-more-btn {
background: var(--line-2);
border-color: var(--line);
color: var(--ink);
}
/* 编辑 = 品牌色淡底(主要动作用它) */
.person-edit {
background: var(--brand-soft, #FDF3EC);
border-color: color-mix(in srgb, var(--brand) 28%, transparent);
color: var(--brand-ink);
}
.person-edit:hover { background: color-mix(in srgb, var(--brand) 16%, #fff); }
/* 标签 = 中性灰底;有标签时稍深一点,一眼能看出"这个人是有标记的" */
.person-tag-btn { background: var(--line-2); color: var(--muted); }
.person-tag-btn:hover { background: var(--line); color: var(--ink); }
.person-tag-btn.is-none { color: var(--faint); }
/* ⋯ = 中性 */
.person-more-btn { background: var(--line-2); color: var(--muted); }
.person-more-btn:hover { background: var(--line); color: var(--brand); }
/* 删除已经是 .btn-danger(自带红底),不动 */
/* ---- 标签编辑浮层里的候选标签 ---- */
.pt-picks { display: flex; flex-wrap: wrap; gap: 6px; }
.pt-pick {
display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
padding: 4px 10px; border: 1px solid var(--line); border-radius: 999px;
background: #fff; font-size: 12.5px; color: var(--muted);
}
.pt-pick:hover { border-color: var(--brand-2); color: var(--brand-ink); }
/* 勾上时整颗变实心(不是只动那个小方框)—— 点一下的反馈要一眼看得见 */
.pt-pick:has(input:checked) {
border-color: var(--brand); background: var(--brand-soft, #FDF3EC);
color: var(--brand-ink);
}
.pt-pick input { accent-color: var(--brand); }
/* ============================================================
v28 用户第 7 条:资源的「编辑」弹框改成带页签(信息 / 附件 / 共享)
============================================================ */
.res-tabs {
display: flex; gap: 4px; padding: 0 16px 10px;
border-bottom: 1px solid var(--line); margin-bottom: 12px;
}
.res-tab {
padding: 5px 14px; border: 1px solid transparent; border-radius: var(--radius-s);
background: transparent; color: var(--muted); cursor: pointer; font-size: 13.5px;
}
.res-tab:hover { background: var(--line-2); color: var(--ink); }
/* 当前页签:品牌淡底 + 品牌字色(与全站分段控件同一套观感) */
.res-tab.is-on {
background: var(--brand-soft, #FDF3EC);
border-color: color-mix(in srgb, var(--brand) 30%, transparent);
color: var(--brand-ink); font-weight: 500;
}
/* ============================================================
v28 用户第 10 条:内置联想接口库(点一下填入表单)
============================================================ */
.sug-lib { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
.sug-lib-item {
display: inline-flex; flex-direction: column; align-items: flex-start; gap: 2px;
padding: 6px 12px; border: 1px solid var(--line); border-radius: var(--radius-s);
background: #fff; cursor: pointer; text-align: left;
}
.sug-lib-item b { font-size: 13px; font-weight: 500; color: var(--ink); }
.sug-lib-item span { font-size: 11.5px; color: var(--faint); }
.sug-lib-item:hover { border-color: var(--brand-2); }
/* 刚被点选的那一个:品牌底 + 品牌边,明确"我选了它" */
.sug-lib-item.is-picked {
border-color: var(--brand); background: var(--brand-soft, #FDF3EC);
}
.sug-lib-item.is-picked b { color: var(--brand-ink); }
/* ============================================================
v28 用户第 12 条:笔记 —— 富文本双模式 / 附件 / 多级清单 / 定尺寸卡片网格
============================================================ */
/* ---- 列表页:顶部条(搜索 + 标签),去掉左侧标签栏 ---- */
.note-bar { margin-bottom: 12px; }
.note-search { margin: 0 0 8px; }
.note-tagbar { margin-top: 2px; }
/* ---- 🔴 定尺寸卡片网格(用户:"一行几个块,但是块大小要确定")----
高度**固定**:内容装不下就靠摘要 + 溢出裁切,卡片之间永远整齐。
用 auto-fill + minmax 让"一行几个"随宽度自适应,而不是写死列数。 */
.note-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.note-card {
display: flex; flex-direction: column;
/* 固定高度 = 用户要的"块大小确定"。240px 大约放得下:一行头 + 标题 + 3~4 行摘要 + 缩略图 */
height: 240px; overflow: hidden;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 12px 14px;
box-shadow: var(--shadow);
}
.note-card:hover { border-color: var(--brand-2); }
.note-head { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
.note-meta { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1 1 auto; }
.note-time { font-size: 11.5px; color: var(--faint); white-space: nowrap; }
.note-ops { display: flex; align-items: center; gap: 4px; flex: 0 0 auto; }
.note-ops .inline-form { display: inline; }
.note-op {
font-size: 12px; padding: 1px 7px; border-radius: 6px; cursor: pointer;
border: 1px solid var(--line); background: var(--line-2); color: var(--muted);
text-decoration: none; line-height: 1.6;
}
.note-op:hover { background: var(--line); color: var(--ink); text-decoration: none; }
.note-op-del:hover { border-color: var(--red); color: var(--red); background: #FDF2F0; }
/* 点卡片进编辑(整块可点,但不用 <a> 包全文 —— 里面还有表单与链接) */
.note-hit { display: block; flex: 1 1 auto; min-height: 0; overflow: hidden; }
.note-hit:hover { text-decoration: none; }
.note-title {
margin: 8px 0 5px; font-size: 14.5px; font-weight: 500; color: var(--ink);
/* 标题最多两行,超出省略 —— 固定高度下不能让它无限长 */
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.note-ex {
margin: 6px 0 0; font-size: 13px; line-height: 1.6; color: var(--muted);
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
}
.note-ex-empty { color: var(--faint); font-style: italic; }
/* 图片:等比缩放到卡片宽,放在摘要下方(用户:"图片缩放后,放在下面") */
.note-thumb { margin-top: 8px; flex: 0 0 auto; overflow: hidden; border-radius: var(--radius-s); }
.note-thumb img {
width: 100%; height: 88px; object-fit: cover; display: block;
border: 1px solid var(--line); border-radius: var(--radius-s);
}
.note-card .note-tags-inline { margin-top: auto; padding-top: 8px; flex: 0 0 auto; }
.note-card .note-tag { font-size: 11.5px; padding: 2px 7px; }
/* ---- 编辑页:模式提示条 ---- */
.note-modebar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--line-2);
}
.note-modebar code {
font-size: 11.5px; background: var(--line-2); padding: 1px 5px;
border-radius: 4px; color: var(--ink);
}
.note-live { margin-left: auto; display: inline-flex; align-items: center; gap: 5px;
font-size: 12.5px; color: var(--muted); cursor: pointer; }
.note-live input { accent-color: var(--brand); }
/* ---- 段落的 Markdown 实时预览块 ---- */
.note-block-pv {
margin-top: 6px; padding: 0 2px; font-size: 14px; line-height: 1.7; color: var(--ink);
}
.note-block-pv:not(.is-on) { display: none; }
/* 预览区左边一条细线,与输入区在视觉上分开(不然分不清哪块是输入、哪块是结果) */
.note-block-pv.is-on {
border-left: 3px solid var(--brand-soft, #F3D9CB);
padding-left: 10px;
}
/* ---- Markdown 渲染出来的元素(列表页/预览共用同一套)---- */
.md-p { margin: 0 0 8px; }
.md-p:last-child { margin-bottom: 0; }
.md-h { margin: 10px 0 6px; font-weight: 500; color: var(--ink); }
h3.md-h { font-size: 16px; }
h4.md-h { font-size: 15px; }
h5.md-h, h6.md-h { font-size: 14px; }
.md-list { margin: 4px 0 8px; padding-left: 22px; }
.md-li { margin: 2px 0; }
/* 多级清单:子层用空心点,层级一眼可分(用户第 12 条"清单可以多级") */
.md-list .md-list { list-style-type: circle; margin: 2px 0 2px; }
.md-list .md-list .md-list { list-style-type: square; }
.md-quote {
margin: 6px 0; padding: 4px 12px; border-left: 3px solid var(--line);
color: var(--muted); background: var(--line-2); border-radius: 0 6px 6px 0;
}
.md-code {
background: #F6F4F0; border: 1px solid var(--line); border-radius: 6px;
padding: 8px 10px; overflow-x: auto; font-size: 12.5px; line-height: 1.6;
font-family: ui-monospace, Consolas, monospace; color: var(--ink);
}
.md-icode {
background: var(--line-2); padding: 1px 5px; border-radius: 4px;
font-size: 12.5px; font-family: ui-monospace, Consolas, monospace;
}
.md-link { color: var(--brand); text-decoration: underline; }
.md-hr { border: 0; border-top: 1px solid var(--line); margin: 10px 0; }
/* ---- 编辑页底部附件区 ---- */
.note-files { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.note-file {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border: 1px solid var(--line); border-radius: var(--radius-s);
background: #FDFCFA;
}
.note-file-ico {
font-size: 11px; padding: 1px 6px; border-radius: 4px;
background: var(--line-2); color: var(--muted); flex: 0 0 auto;
}
.note-file-nm { flex: 1 1 auto; min-width: 0; font-size: 13.5px; color: var(--ink);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.note-file-del {
flex: 0 0 auto; border: 1px solid var(--line); background: #fff; border-radius: 6px;
color: var(--muted); cursor: pointer; padding: 2px 8px; font-size: 12px;
}
.note-file-del:hover { border-color: var(--red); color: var(--red); }
/* 拖放区:虚线框,与全站其它模块的上传区同一观感 */
.note-att-drop {
border: 1px dashed var(--line); border-radius: var(--radius-s);
padding: 14px; text-align: center; background: #FDFCFA;
}
.note-att-drop.is-over { border-color: var(--brand); background: var(--brand-soft, #FDF3EC); }
.note-att-drop input[type=file] { display: none; }
.note-att-tip { font-size: 13px; color: var(--muted); }
.note-att-tip label { color: var(--brand); cursor: pointer; text-decoration: underline; }
/* ---- 窄屏:网格收成一列(固定高度在小屏上会显得很挤)---- */
@media (max-width: 640px) {
.note-grid { grid-template-columns: 1fr; }
.note-card { height: auto; min-height: 150px; }
}
/* ---- v28 修:名册卡片在窄屏被"标签按钮"撑破(真机几何探针抓到)----
根因:卡片右侧的动作区是一条 flex,里面那颗「标签」按钮的文字是
**所有标签拼起来**(`#小学同学 #初中同学 …`),且 `white-space: nowrap` ——
标签一多它就无限变宽,把整行、整张卡、整张网格一起顶出视口
(实测 360px 下 scrollW=2090,越界元素就是那一串 <a>/<button>)。
⇒ 三件事一起做,缺一不可:
① 动作区**允许换行**(标签多了折到第二行,而不是往外顶);
② 标签按钮**限最大宽度 + 溢出截断**(再长的标签也不会撑破);
③ 卡片主体 `min-width: 0`(flex/grid 子项默认 min-width:auto,不肯收缩)。 */
.person-actions { flex-wrap: wrap; justify-content: flex-end; }
.person-tag-btn {
max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.person { min-width: 0; }
.person-main { min-width: 0; }
.person .pb { min-width: 0; }
/* 窄屏更严:动作区整体不参与撑宽,标签按钮再收一档 */
@media (max-width: 720px) {
.person-actions { max-width: 100%; }
.person-tag-btn { max-width: 110px; }
/* 姓名那一栏允许收缩并截断,否则长名字也会顶破 */
.pname { overflow-wrap: anywhere; }
}
+19 -10
View File
@@ -824,8 +824,16 @@
function each(list, fn) { Array.prototype.forEach.call(list, fn); } function each(list, fn) { Array.prototype.forEach.call(list, fn); }
/* 🔴 v28 用户第 5 条:名册从「分栏」改成**平铺**(一个人可挂多个标签,
分栏会让他在多栏里重复出现)。拖动因此只剩"排序"这一件事。
⇒ 把整个 `.roster` 当成**唯一一栏**返回:下游的 placeAt / save / refreshCounts
全都按"栏"来写,只要这里返回一个元素,它们不用改就能继续工作
(比重写三条逻辑安全得多,也不会漏掉其中一条)。
⚠️ 返回的是 `[roster]` 而不是 `.people-grid`:placeAt 里会 `block.querySelector('.people-grid')`,
所以仍要有一个带 `.people-grid` 的容器;模板里 roster 直接包含 .people-grid。 */
function blocks() { function blocks() {
return roster.querySelectorAll('.group-block'); var flat = roster.querySelector('.people-grid');
return flat ? [roster] : [];
} }
/** 从事件目标往上找那张可拖的卡片(点在卡片里的链接/表单上时也算这张卡) */ /** 从事件目标往上找那张可拖的卡片(点在卡片里的链接/表单上时也算这张卡) */
@@ -848,19 +856,16 @@
* 留着一个没人调用的函数只会让下一个人以为还有第二条拖拽路径。 * 留着一个没人调用的函数只会让下一个人以为还有第二条拖拽路径。
*/ */
/** 每栏人数与"空栏"标记跟着 DOM 走 —— 不然拖完之后标题上的「N 人」还是旧的 */ /** 每栏人数与"空栏"标记跟着 DOM 走 —— 不然拖完之后标题上的「N 人」还是旧的。
* ⚠️ v28:平铺之后只剩"一栏"(就是 .roster 本身),它**没有 .group-n 也没有
* data-group**,所以下面这些查询都会拿到 null —— 全部做了存在性判断,
* 并且**绝不能**再把 `b.hidden` 设成"没人就隐藏":
* 那会把整张名册(roster 自己)藏起来。平铺下没有"空栏"这个概念。 */
function refreshCounts() { function refreshCounts() {
each(blocks(), function (b) { each(blocks(), function (b) {
var n = b.querySelectorAll('.people-grid .person').length; var n = b.querySelectorAll('.people-grid .person').length;
var nEl = b.querySelector('.group-n .n'); var nEl = b.querySelector('.group-n .n');
if (nEl) { nEl.textContent = String(n); } if (nEl) { nEl.textContent = String(n); }
b.classList.toggle('is-empty', n === 0);
// 🔴 empty 的栏被 filter.js 标了 hidden(筛完没人剩就整栏收起)。
// 拖了一张卡进来之后必须**把 hidden 也清掉** —— 只去掉 is-empty 没用:
// 作者样式里 .group-block 没有写 display,于是浏览器默认的
// [hidden]{display:none} 照样生效,那一栏连同刚放进去的人一起看不见
// (症状就是"拖到后面的分组里,松手什么都没有")。
b.hidden = (n === 0);
}); });
} }
@@ -887,7 +892,11 @@
var pid = parseInt(c.getAttribute('data-person'), 10); var pid = parseInt(c.getAttribute('data-person'), 10);
if (pid > 0) { ids.push(pid); } if (pid > 0) { ids.push(pid); }
}); });
cols.push({ g: parseInt(b.getAttribute('data-group'), 10) || 0, ids: ids }); /* ⚠️ v28:平铺之后**不报 `g`** —— 见 PersonRoster::applyLayout 的注释:
报了组号(哪怕是 0)就会覆盖 group_id,拖一次就把所有人的旧分组清空,
而那是"旧分组→同名标签"迁移的数据源、也是 vCard 的读点。
不报 `g` = 只更新顺序,这正是"拖动只改先后"的新语义。 */
cols.push({ ids: ids });
}); });
var body = new URLSearchParams(); var body = new URLSearchParams();
+108 -4
View File
@@ -73,15 +73,73 @@
ta.rows = kind === 'list' ? 4 : 3; ta.rows = kind === 'list' ? 4 : 3;
ta.value = content || ''; ta.value = content || '';
ta.placeholder = kind === 'list' ta.placeholder = kind === 'list'
? '一行一项,回车换行' // v28 第 12 条要求"清单可以多级":缩进即层级(两个空格一层)
: (kind === 'text' ? '写点什么…' : ''); ? '一行一项;行首缩进两个空格 = 子项(可多级)'
// 清单块自动在每行前加「· 」的视觉提示(内容本身不加,保持纯文本) : '写点什么…支持 Markdown:**粗体** *斜体* # 标题 > 引用 `代码`';
body.appendChild(ta); body.appendChild(ta);
/* 🔴 v28 第 12 条:段落支持 Markdown,且要**所见即所得**。
做法:在 textarea 下方跟一个预览块,输入时(防抖)请服务端渲染。
⚠️ 为什么预览请求服务端而不是前端自己渲染:两套渲染器必然漂移
(预览是粗体、保存后不是),而用户看到的就是预览。
清单块**不给**预览:清单是"一行一项"的结构化输入,预览意义不大,
反而会占掉一半高度(这里是"轻量"的取舍,不是漏了)。 */
/* 🔴 v28 第 12 条:段落与**清单**都支持 Markdown 渲染(清单的多级就靠它)。
段落:原文直接渲染。
清单:把每一行包成 `- 行内容` 再渲染 —— 行首的缩进原样保留,
于是"缩进两个空格 = 子项"这条规则自然生效(多级清单)。
这样做还有个好处:**渲染规则只有一份**(都在服务端 Markdown 里),
不必为清单再写一套层级解析(两套必然漂移)。 */
if (kind === 'text' || kind === 'list') {
var pv = document.createElement('div');
pv.className = 'note-block-pv';
pv.setAttribute('data-pv', '1');
body.appendChild(pv);
}
} }
wrap.appendChild(body); wrap.appendChild(body);
return wrap; return wrap;
} }
/* ---- Markdown 实时预览(服务端渲染,防抖 350ms)---- */
var pvTimer = 0;
function renderPreview(block) {
var pv = block.querySelector('[data-pv]');
var ta = block.querySelector('textarea');
if (!pv || !ta) { return; }
var box = document.getElementById('noteLivePreview');
if (box && !box.checked) { pv.innerHTML = ''; pv.classList.remove('is-on'); return; }
var md = ta.value;
if (md.trim() === '') { pv.innerHTML = ''; pv.classList.remove('is-on'); return; }
// 清单块:每行包成 `- xxx`(缩进原样),交给服务端同一套 Markdown 渲染 → 天然多级
var block = pv.closest ? pv.closest('.note-block') : null;
if (block && block.getAttribute('data-kind') === 'list') {
md = md.split(/\r?\n/).map(function (line) {
return line.trim() === '' ? '' : ('- ' + line.replace(/^(\s*)[-*+]\s+/, '$1'));
}).join('\n');
}
var form = document.getElementById('noteForm');
var tok = form ? form.querySelector('input[name="_csrf"]') : null;
var fd = new FormData();
fd.append('md', md);
if (tok) { fd.append('_csrf', tok.value); }
fetch('index.php?r=notes/preview', {
method: 'POST', body: fd, credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (d) {
if (!d || !d.ok) { return; }
// ⚠️ 响应回来时用户可能已经又改了:只在内容仍一致时才贴上去
// (否则会把"旧的渲染结果"盖在"新的输入"下面,看着像卡住了)
if (ta.value !== md) { return; }
pv.innerHTML = d.html;
pv.classList.add('is-on');
}).catch(function () { /* 预览失败不打扰用户,正文照旧可保存 */ });
}
function schedulePreview(block) {
window.clearTimeout(pvTimer);
pvTimer = window.setTimeout(function () { renderPreview(block); }, 350);
}
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// 序列化:从 DOM 读回块数组,写进隐藏域 // 序列化:从 DOM 读回块数组,写进隐藏域
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -210,7 +268,48 @@
}); });
// textarea 输入时同步(这样即便用户直接点提交,内容也是最新的) // textarea 输入时同步(这样即便用户直接点提交,内容也是最新的)
list.addEventListener('input', serialize); // + v28:段落块顺手刷新 Markdown 实时预览
list.addEventListener('input', function (e) {
serialize();
var ta = e.target;
if (ta && ta.tagName === 'TEXTAREA') {
var block = ta.closest ? ta.closest('.note-block') : null;
if (block && block.getAttribute('data-kind') === 'text') {
schedulePreview(block);
}
}
});
/* 实时预览开关(用户第 12 条:"允许所见即所得编辑,也允许 markdown 编辑")——
关掉之后预览块收起,textarea 能占满整块(专心改 Markdown 源码时更清爽)。 */
var liveBox = document.getElementById('noteLivePreview');
if (liveBox) {
liveBox.addEventListener('change', function () {
Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'),
function (b) {
if (liveBox.checked) { renderPreview(b); }
else {
var pv = b.querySelector('[data-pv]');
if (pv) { pv.innerHTML = ''; pv.classList.remove('is-on'); }
}
});
});
}
/* ---------------- 底部附件:删除(v28 第 12 条)----------------
⚠️ 用全页唯一的那一份 #noteFileDelForm(与项目其它模块同一做法):
每个附件一份 form 会在附件多时把页面撑爆。 */
var fileDelForm = document.getElementById('noteFileDelForm');
if (fileDelForm) {
document.addEventListener('click', function (e) {
var b = e.target && e.target.closest ? e.target.closest('[data-note-file-del]') : null;
if (!b) { return; }
if (!confirm('删掉这个附件?')) { return; }
var inp = fileDelForm.querySelector('input[name="id"]');
if (inp) { inp.value = b.getAttribute('data-note-file-del'); }
fileDelForm.submit();
});
}
// 提交前再序列化一次(双保险) // 提交前再序列化一次(双保险)
form.addEventListener('submit', serialize); form.addEventListener('submit', serialize);
@@ -226,6 +325,11 @@
initial.forEach(function (b) { addBlock(b.kind, b.content); }); initial.forEach(function (b) { addBlock(b.kind, b.content); });
serialize(); serialize();
/* v28:编辑已有笔记时,让段落**一进来就看到渲染结果**(否则用户要动一下
键盘才第一次看到"所见即所得",会以为这功能没生效)。 */
Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'),
function (b) { renderPreview(b); });
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// 标签联想:点一下把 #标签 追加进输入框 // 标签联想:点一下把 #标签 追加进输入框
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
+190 -10
View File
@@ -35,6 +35,7 @@ use App\Ics;
use App\Kinship; use App\Kinship;
use App\Ledger; use App\Ledger;
use App\Lunar; use App\Lunar;
use App\Markdown;
use App\Movie; use App\Movie;
use App\MovieReview; use App\MovieReview;
use App\Module; use App\Module;
@@ -50,6 +51,7 @@ use App\PersonJob;
use App\PersonNote; use App\PersonNote;
use App\PersonQuery; use App\PersonQuery;
use App\PersonRoster; use App\PersonRoster;
use App\PersonTag;
use App\Pinyin; use App\Pinyin;
use App\Relations; use App\Relations;
use App\Repeat; use App\Repeat;
@@ -917,12 +919,26 @@ switch ($r) {
} }
unset($row); unset($row);
// 按关系分组分栏。顺序用**当前用户自己的**(没自定义过就是默认顺序); // 🔴🔴 v28 用户第 5 条:「名册的分组,用下来不太好,我感觉应该改成打标签,
// 归到键 0 的那一堆是还没分类的人,模板单独放在最后一栏。 // 就是说,一个人,他可能并不是只在一个分组,例如,他既是我的小学同学,
$grouped = []; // 又是我的初中同学,就没法分组,改成标签,似乎更合理。」
foreach ($rows as $row) { // ⇒ 不再**按分组分栏**(`$grouped`),改成:
$grouped[(int)($row['group_id'] ?? 0)][] = $row; // · 名册**平铺**成一张列表(带拖动排序,用户明确要求保留);
// · 顶部一条**标签筛选**(`?tag=xxx`),点哪个看哪个;
// · 每张卡片上挂着他自己的那几个标签(一个人可以有好几个)。
// ⚠️ 仍然传 `groups`:这就是"标签的候选来源"(用户可以在卡片上选已有的标签),
// 它本身没废 —— 只是从"分栏依据"降级成"可选的标签词表"。
$tagFilter = trim((string)($_GET['tag'] ?? ''));
$tagsOf = PersonTag::mapFor($familyId, $me);
// 按标签筛选:留在这个标签里的人(`?tag=` 为空 = 全部)
if ($tagFilter !== '') {
$rows = array_values(array_filter($rows,
static fn(array $r): bool => in_array($tagFilter, $tagsOf[(int)$r['id']] ?? [], true)));
} }
foreach ($rows as &$row) {
$row['tags'] = $tagsOf[(int)$row['id']] ?? [];
}
unset($row);
// v24 第 9 批 #5:千人名册的 HTML 有 3~4 MB。本机 php -S 与部分虚拟机 // v24 第 9 批 #5:千人名册的 HTML 有 3~4 MB。本机 php -S 与部分虚拟机
// 都不会替 PHP 输出开 gzip,原样吐 3.8 MB 又慢又费流量; // 都不会替 PHP 输出开 gzip,原样吐 3.8 MB 又慢又费流量;
@@ -936,8 +952,12 @@ switch ($r) {
View::render('people/index', [ View::render('people/index', [
'title' => '家庭名册', 'title' => '家庭名册',
'people' => $rows, 'people' => $rows,
'grouped'=> $grouped, // v28:`$grouped`(按分栏分堆)已不再需要 —— 名册平铺(见上面注释)。
// 不再传它,模板也不再读(多传一个变量迟早有人用它,把分栏又带回来)。
'groups' => PersonGroup::orderFor($familyId, $me), 'groups' => PersonGroup::orderFor($familyId, $me),
// v28 用户第 5 条:标签(筛选条 + 卡片上的标签 + 编辑标签时的候选)
'tagCloud' => PersonTag::cloud($familyId, $me),
'tagFilter' => $tagFilter,
'q' => $q, 'q' => $q,
'meId' => $me, 'meId' => $me,
// 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口, // 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口,
@@ -1022,12 +1042,81 @@ switch ($r) {
break; break;
/** /**
* 名册拖动排序 / 拖动换栏 —— 整版提交。 * 名册卡片上的「标签」(v28 用户第 5 条:分组 → 标签)。
* *
* 前端把每一栏(含「未分类」)的有序 id 列表发过来,服务端一次写完。 * 与 people/group 同一套做法(AJAX 回 JSON 就地更新 / 普通提交 302 兜底),
* 只是把"选一个分组"换成"整组替换他的标签"。
*
* ⚠️ 标签是**每人一份**的(owner_id = 我),改它只影响我自己的名册 ——
* 我给他打「球友」不该让全家的名册都多出这个词。
*/
case 'people/tags':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');
if (!Auth::canWrite()) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 403);
echo json_encode(['ok' => false, 'msg' => '你的身份是访客,不能改名册标签。'],
JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '你的身份是访客,不能改名册标签。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$p = Person::find($pid);
if (!$p || (int)$p['family_id'] !== $familyId) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 404);
echo json_encode(['ok' => false, 'msg' => '找不到这个人。'], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', '找不到这个人。');
redirect('people');
}
// ⚠️ 只允许给**在我名册里的人**打标签 ——
// 否则会给一份"看不见的名册条目"写上标签,用户永远看不到、也删不掉。
if (!isset(PersonRoster::visibleIds($familyId, $me)[$pid])) {
$msg = '这个人不在你的名册里。';
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 200);
echo json_encode(['ok' => false, 'msg' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
flash('err', $msg);
redirect('people');
}
$saved = PersonTag::setFor($familyId, $me, $pid, (string)($_POST['tags'] ?? ''));
$msg = $saved
? '标签已改成:' . implode('、', array_map(static fn($t): string => '#' . $t, $saved)) . '。'
: '标签已清空。';
if ($isAjax) {
// ⚠️ 前端要用这个数组去改卡片上那颗按钮的文字(空数组 = 显示「标签」)
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'pid' => $pid,
'tags' => $saved,
'msg' => $msg,
], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $msg);
redirect('people');
break;
/**
* 名册拖动排序 —— 整版提交。
*
* 🔴 v28:名册已**平铺**(用户第 5 条:分组→标签),所以这里只剩"排序"这一件事,
* 不再有"换栏"。前端发上来的是一个不含 `g` 的栏(见 app.js 的 save),
* 服务端 `applyLayout` 据此只更新 sort_order、不碰 group_id。
*
* 前端把有序 id 列表发过来,服务端一次写完。
* 为什么不"移动一条算一次":拖拽的结果是一组**相对顺序**, * 为什么不"移动一条算一次":拖拽的结果是一组**相对顺序**,
* 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。 * 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
* 没有 JS 也能用:卡片上的「调整分组」表单还在(只是没有拖拽那么顺手)。
*/ */
case 'people/reorder': case 'people/reorder':
Auth::requireLogin(); Auth::requireLogin();
@@ -3694,7 +3783,33 @@ switch ($r) {
echo json_encode(['ok' => false, 'msg' => '账本不存在。'], JSON_UNESCAPED_UNICODE); echo json_encode(['ok' => false, 'msg' => '账本不存在。'], JSON_UNESCAPED_UNICODE);
exit; exit;
} }
$want = array_values(array_filter(array_map('strval', (array)($_GET['kinds'] ?? [])))); /* 🔴🔴 v28 用户第 8 条:「财务,多选分类的时候,净资产 · 金额变化这个图表,
里面的数字还是不改变。」
根因:**前后端的参数形状对不上** ——
· 前端(templates/ledger/index.php)发的是 `&kinds=deposit%2Chouse`
(把选中集合拼成**一个逗号串**,因为 URLSearchParams 里一个 key 只能一个值);
· 服务端这里写的是 `(array)($_GET['kinds'] ?? [])` —— 对字符串来说是
`['deposit,house']`(**一个元素**,值是带逗号的整串),
拿去和真实类别白名单求交 → 空 → 下面的 `if (!$kinds)` 分支把它当成"空集合"
→ **回落成"整本账"** → 图与数字当然不变。
⇒ 修法:把逗号串**也切开**(两种形状都收:`kinds=a,b` 与 `kinds[]=a&kinds[]=b`)。
只收一种形状就会在下次换前端写法时再断一次 —— 收两种是一行代码的事。
⚠️ 这与 `Ledger::timeline()` 的入参口径一致:它本来就同时接受字符串与数组。 */
$_rawKinds = $_GET['kinds'] ?? [];
if (!is_array($_rawKinds)) {
$_rawKinds = [$_rawKinds];
}
$want = [];
foreach ($_rawKinds as $_rk) {
// 每个值再按逗号切一次 —— `a,b` 与 `a` 都能吃
foreach (preg_split('/[,\s]+/', (string)$_rk) ?: [] as $_one) {
$_one = trim($_one);
if ($_one !== '') {
$want[$_one] = true;
}
}
}
$want = array_keys($want);
// 白名单:只留下这本账里真实存在的类别 // 白名单:只留下这本账里真实存在的类别
$present = array_map('strval', array_keys((array)Ledger::summary(Ledger::entries($lid))['by_kind'])); $present = array_map('strval', array_keys((array)Ledger::summary(Ledger::entries($lid))['by_kind']));
$kinds = array_values(array_intersect($want, $present)); $kinds = array_values(array_intersect($want, $present));
@@ -6324,6 +6439,71 @@ switch ($r) {
redirect('notes'); redirect('notes');
break; break;
/* 笔记底部附件(v28 用户第 12 条:「再加上附件上传功能,附件,都放在笔记最下面」)。
⚠️ 只允许给**我自己的**笔记加附件:附件是挂在笔记上的东西,
共享出去的笔记别人只能看,不该能往里塞文件。
⚠️ 复用全站统一的 `attach_save_files`(它处理多文件、类型判定、失败清单),
不自己遍历 $_FILES —— 各模块各写一遍必然有人漏掉"哪些字段是数组"。 */
case 'notes/attach_add':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$n0 = Note::find($familyId, $id, $me);
if (!$n0 || empty($n0['is_mine'])) {
flash('err', '这条笔记不能改。');
redirect('notes');
}
$warns = attach_save_files($familyId, Note::ATTACH, $id, 'file', $me);
flash($warns ? 'err' : 'ok',
$warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。');
redirect('notes/form', ['id' => $id]);
break;
case 'notes/attach_del':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$aid = (int)($_POST['id'] ?? 0);
// ⚠️ 先按附件 id 找到它挂在**哪条笔记**上,再判那条笔记是不是我的 ——
// 只信传进来的附件 id,等于谁都能构造请求删别人家的附件(资源页踩过)。
$st = Database::get()->prepare(
'SELECT target_id FROM attachments WHERE id = ? AND target_type = ?'
);
$st->execute([$aid, Note::ATTACH]);
$nid = (int)$st->fetchColumn();
$n0 = $nid > 0 ? Note::find($familyId, $nid, $me) : null;
if (!$n0 || empty($n0['is_mine'])) {
flash('err', '这个附件不能删。');
redirect('notes');
}
\App\Attachment::remove($aid);
flash('ok', '附件已删除。');
redirect('notes/form', ['id' => $nid]);
break;
/* 段落 Markdown 的**实时预览**(v28 用户第 12 条:"允许所见即所得编辑")。
⚠️ 为什么预览走**服务端**而不是在前端再写一份渲染器:
两套渲染器必然漂移(前端显示成粗体、保存后却不是),而用户看到的
就是"预览",一旦不一致,他会以为保存坏了。
服务端只有一份 `App\Markdown`,预览与最终渲染保证同源。
⚠️ 所以这段必须够轻:只做一段文本的行内+块级渲染,不碰数据库、不查权限
(不涉及任何数据,纯函数),前端再配合防抖调用。
⚠️ 它仍然要求登录 + 写权限:不是安全所需(没有数据泄露),而是不给
"未登录也能拿这个当 Markdown 渲染器用"的口子。 */
case 'notes/preview':
Auth::requireWrite();
csrf_check();
$src = (string)($_POST['md'] ?? '');
// 限长:预览是交互用的,不该被拿来转整本书
$src = mb_substr($src, 0, Note::MAX_TEXT, 'UTF-8');
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'html' => Markdown::render($src)],
JSON_UNESCAPED_UNICODE);
break;
/* 图片块上传:走统一附件表(target_type='note')。 /* 图片块上传:走统一附件表(target_type='note')。
⚠️ 为什么复用 attachments 而不在 notes 里另起一套: ⚠️ 为什么复用 attachments 而不在 notes 里另起一套:
图片的"存哪、怎么压缩、怎么通过 media.php 出去"这套逻辑全站只有一份, 图片的"存哪、怎么压缩、怎么通过 media.php 出去"这套逻辑全站只有一份,
+53
View File
@@ -198,6 +198,59 @@ function nav_parse_suggest(string $type, string $body, string $q): array
} }
break; break;
// ---- v28 用户第 10 条新增的三个联想来源(见 Nav::SUGGEST_LIBRARY)----
case 'sogou':
// {"query":"x","items":["a","b"]}
// ⚠️ 与旧的 `sogou_ajaj` 分开:那条是 GBK 老接口(现已不解析),
// 这条是新的 suggestion.sogou.com,返回 utf-8 JSON。
$j = json_decode($body, true);
if (is_array($j) && !empty($j['items']) && is_array($j['items'])) {
$out = $j['items'];
}
break;
case 'zhihu':
// {"data":[{"query":".."}]} 或 {"suggest":[{"query":".."}]}
$j = json_decode($body, true);
if (is_array($j)) {
foreach (['data', 'suggest'] as $k) {
if (!empty($j[$k]) && is_array($j[$k])) {
foreach ($j[$k] as $it) {
if (is_array($it)) {
foreach (['query', 'title', 'name'] as $f) {
if (isset($it[$f]) && is_string($it[$f])) { $out[] = $it[$f]; break; }
}
} elseif (is_string($it)) {
$out[] = $it;
}
}
break;
}
}
}
break;
case 'bili':
// {"code":0,"data":{"tag":[{"value":".."}]}}
// 也兼容 {"result":[{"term":".."}]}
$j = json_decode($body, true);
if (is_array($j)) {
if (!empty($j['data']['tag']) && is_array($j['data']['tag'])) {
foreach ($j['data']['tag'] as $it) {
if (is_array($it) && isset($it['value'])) { $out[] = $it['value']; }
}
} elseif (!empty($j['result']) && is_array($j['result'])) {
foreach ($j['result'] as $it) {
if (is_array($it)) {
foreach (['term', 'name', 'value'] as $f) {
if (isset($it[$f]) && is_string($it[$f])) { $out[] = $it[$f]; break; }
}
}
}
}
}
break;
default: default:
// 通用兜底:先试 JSON,再退化正则(照搬) // 通用兜底:先试 JSON,再退化正则(照搬)
$j = json_decode($body, true); $j = json_decode($body, true);
+25 -4
View File
@@ -148,7 +148,7 @@ $todayMarks = $todayMarks ?? ['tags' => [], 'line' => ''];
首页要回答的是"我手头在做什么",所以主数字用「在做」的口径; 首页要回答的是"我手头在做什么",所以主数字用「在做」的口径;
排队的那些(todo)另给一个小字,不必让人点进任务页才知道还有多少。 */ ?> 排队的那些(todo)另给一个小字,不必让人点进任务页才知道还有多少。 */ ?>
<a class="hbs" href="<?= e(url('tasks')) ?>" <a class="hbs" href="<?= e(url('tasks')) ?>"
title="手头在做(进行中 + 已逾期的待办)"> title="近期任务(进行中 + 已逾期的待办)">
<b><?= count($tasks ?? []) ?></b> 件在做 <b><?= count($tasks ?? []) ?></b> 件在做
</a> </a>
<?php if ((int)($stat7['todos'] ?? 0) > 0): ?> <?php if ((int)($stat7['todos'] ?? 0) > 0): ?>
@@ -208,6 +208,20 @@ if ($timed) {
// 前后各留一小时:紧了会让人以为"到点就结束了",松了又浪费高度 // 前后各留一小时:紧了会让人以为"到点就结束了",松了又浪费高度
$startH = max(0, intdiv(min($mins), 60) - 1); $startH = max(0, intdiv(min($mins), 60) - 1);
$endH = min(24, intdiv(max($mins), 60) + 2); // +2 = 跨过那一小时再收尾 $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); } if ($endH <= $startH) { $endH = min(24, $startH + 2); }
} }
$nowH = (int)date('G'); $nowH = (int)date('G');
@@ -220,7 +234,11 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
<div class="card card-pad hd-pane" id="homeAgenda"> <div class="card card-pad hd-pane" id="homeAgenda">
<div class="hd-pane-head"> <div class="hd-pane-head">
<h2 class="cinema-h2">今天的日程</h2> <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> </div>
<?php /* 🔴 v24 第 26 批 用户第 2 条(前半):「今天的日程,除了全天日程**一直显示在上面**外, <?php /* 🔴 v24 第 26 批 用户第 2 条(前半):「今天的日程,除了全天日程**一直显示在上面**外,
其他日程随着滚动」。 其他日程随着滚动」。
@@ -286,8 +304,11 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
<?php /* ---- 右:进行中的任务(标题 + 状态 + 看板名)---- */ ?> <?php /* ---- 右:进行中的任务(标题 + 状态 + 看板名)---- */ ?>
<div class="card card-pad hd-pane" id="homeTasks"> <div class="card card-pad hd-pane" id="homeTasks">
<div class="hd-pane-head"> <div class="hd-pane-head">
<h2 class="cinema-h2">手头在做</h2> <h2 class="cinema-h2">近期任务</h2>
<a class="btn btn-ghost btn-sm" href="<?= e(url('tasks')) ?>">任务看板</a> <?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>
<div class="hd-pane-body"> <div class="hd-pane-body">
<?php /* 用户第 6 条:「所有的任务,前面都带一个复选框,在任何地方,点这个复选框, <?php /* 用户第 6 条:「所有的任务,前面都带一个复选框,在任何地方,点这个复选框,
+74 -1
View File
@@ -41,15 +41,40 @@ foreach ($engines as $e) {
if ((int)$e['id'] === $eEng) { $editEngine = $e; } if ((int)$e['id'] === $eEng) { $editEngine = $e; }
} }
$sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d); $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"> <div class="page-head">
<h1>导航</h1> <h1>导航</h1>
<span class="sub">你个人用的网址导航 · 每人一份</span> <span class="sub">你个人用的网址导航 · 每人一份</span>
<div class="actions"> <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>
</div> </div>
<?php /* 设置视图里的四块(展示地址 / 导航设置 / 板块 / 搜索引擎)。
只在设置态渲染 —— 默认进来看到的是"添加网址 + 网址列表"(用户原话)。 */ ?>
<?php if ($inSettings): ?><?php /* ==== 设置视图开始 ==== */ ?>
<?php /* ==================== ① 展示页地址与短语(用户第 4 条的"特殊地址")==================== <?php /* ==================== ① 展示页地址与短语(用户第 4 条的"特殊地址")====================
用户原话:「给他一个非常特殊的地址…地址后面可以跟着用户添加的一个短语,就跟类似 用户原话:「给他一个非常特殊的地址…地址后面可以跟着用户添加的一个短语,就跟类似
于一个密钥一样,他可以给自己起一个短语…这个页面是不需要登录的。」 于一个密钥一样,他可以给自己起一个短语…这个页面是不需要登录的。」
@@ -171,6 +196,8 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
</form> </form>
</div> </div>
<?php endif; ?><?php /* ==== 设置视图(前半:展示地址 + 导航设置)结束 ==== */ ?>
<?php /* ==================== ③ 站点:新增/编辑 ==================== */ ?> <?php /* ==================== ③ 站点:新增/编辑 ==================== */ ?>
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<div class="card card-pad"> <div class="card card-pad">
@@ -301,6 +328,7 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if ($inSettings): ?><?php /* ==== 设置视图(后半:板块 + 搜索引擎)重新开始 ==== */ ?>
<?php /* ==================== ⑤ 板块 ==================== */ ?> <?php /* ==================== ⑤ 板块 ==================== */ ?>
<div class="card card-pad"> <div class="card card-pad">
<h2 class="cinema-h2"><?= $editGroup ? '编辑板块' : '新增板块' ?></h2> <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'] ?? '')) ?>" value="<?= e((string)($editEngine['suggest_type'] ?? '')) ?>"
placeholder="bing_osjson / baidu_sugrec / so360"> placeholder="bing_osjson / baidu_sugrec / so360">
</div> </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"> <div class="field">
<label>颜色</label> <label>颜色</label>
<input class="input" type="color" name="color" <input class="input" type="color" name="color"
@@ -468,6 +520,7 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
</ul> </ul>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php endif; ?><?php /* ==== 设置视图(后半:板块 + 搜索引擎)结束 ==== */ ?>
<script> <script>
/* 导航管理页的交互(v24 第 24 批)。 /* 导航管理页的交互(v24 第 24 批)。
@@ -672,5 +725,25 @@ $sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
lastLookup = ''; 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> </script>
+85
View File
@@ -81,6 +81,31 @@ if (!$blocks) {
</div> </div>
</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> <div id="noteBlockList" class="note-blocks"></div>
<?php /* 无 JS 兜底:脚本没跑时用户仍能写一条纯文字笔记 */ ?> <?php /* 无 JS 兜底:脚本没跑时用户仍能写一条纯文字笔记 */ ?>
@@ -125,12 +150,72 @@ if (!$blocks) {
</label> </label>
</div> </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"> <div class="note-actions">
<button class="btn" type="submit"><?= $isEditing ? '保存' : '记下来' ?></button> <button class="btn" type="submit"><?= $isEditing ? '保存' : '记下来' ?></button>
<a class="btn btn-ghost" href="<?= e(url('notes')) ?>">取消</a> <a class="btn btn-ghost" href="<?= e(url('notes')) ?>">取消</a>
</div> </div>
</form> </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-* —— <?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* ——
属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?> 属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?>
<script type="application/json" id="noteSeed"><?= json_encode( <script type="application/json" id="noteSeed"><?= json_encode(
+75 -80
View File
@@ -1,16 +1,23 @@
<?php <?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 array $tagCloud 标签 + 条数
* @param string $keyword 搜索词 * @param string $keyword 搜索词
* @param string $tag 当前筛的标签 * @param string $tag 当前筛的标签
@@ -19,7 +26,6 @@
* @param int $limit 列表默认条数 * @param int $limit 列表默认条数
*/ */
$canWrite = \App\Auth::canWrite(); $canWrite = \App\Auth::canWrite();
$myPerson = (int)\App\Auth::personId();
?> ?>
<div class="page-head"> <div class="page-head">
<h1>笔记</h1> <h1>笔记</h1>
@@ -33,15 +39,23 @@ $myPerson = (int)\App\Auth::personId();
</div> </div>
</div> </div>
<div class="note-layout"> <?php /* ---------------- 顶部:搜索 + 标签筛选(v28 去掉了左侧标签栏)---------------- */ ?>
<?php /* ---------------- 左侧:标签云 ---------------- */ ?> <div class="card card-pad note-bar">
<aside class="note-side"> <form class="search note-search" method="get" action="index.php">
<div class="card card-pad"> <input type="hidden" name="r" value="notes">
<h2 class="cinema-h2">标签</h2> <?php if ($tag !== ''): ?>
<?php if (!$tagCloud): ?> <input type="hidden" name="tag" value="<?= e($tag) ?>">
<p class="empty-hint">还没有标签。写笔记时加上 <code>#标签</code> 就会出现在这里。</p> <?php endif; ?>
<?php else: ?> <label class="sr-only" for="noteKw">搜索笔记</label>
<div class="note-tags"> <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')) ?>"> <a class="note-tag<?= $tag === '' ? ' is-on' : '' ?>" href="<?= e(url('notes')) ?>">
全部 <b><?= (int)$totalAll ?></b> 全部 <b><?= (int)$totalAll ?></b>
</a> </a>
@@ -53,28 +67,9 @@ $myPerson = (int)\App\Auth::personId();
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</aside>
<?php /* ---------------- 右侧:卡片流 ---------------- */ ?> <?php if (!$rows): ?>
<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"> <div class="card card-pad">
<p class="empty-hint"> <p class="empty-hint">
<?php if ($keyword !== '' || $tag !== ''): ?> <?php if ($keyword !== '' || $tag !== ''): ?>
@@ -86,63 +81,64 @@ $myPerson = (int)\App\Auth::personId();
<?php endif; ?> <?php endif; ?>
</p> </p>
</div> </div>
<?php else: ?> <?php else: ?>
<?php /* 定尺寸网格:一行多个,卡片高度固定(用户第 12 条"块大小要确定") */ ?>
<div class="note-grid">
<?php foreach ($rows as $n): ?> <?php foreach ($rows as $n): ?>
<?php $mine = !empty($n['is_mine']); ?> <?php
<article class="card card-pad note-card"> $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"> <header class="note-head">
<div class="note-meta"> <span 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'])): ?> <?php if (!empty($n['is_shared'])): ?>
<span class="note-share" title="全家可见">共享</span> <span class="note-share" title="全家可见">共享</span>
<?php else: ?> <?php else: ?>
<span class="note-private" title="只有自己可见">私有</span> <span class="note-private" title="只有自己可见">私有</span>
<?php endif; ?> <?php endif; ?>
<?php /* 只显示到分钟、且把 T 换成空格后的形态(全站惯例:不引第三方时间库) */ ?>
<span class="note-time"><?= e(substr((string)$n['updated_at'], 0, 16)) ?></span> <span class="note-time"><?= e(substr((string)$n['updated_at'], 0, 16)) ?></span>
</div> </span>
<?php if ($mine && $canWrite): ?> <?php if ($mine && $canWrite): ?>
<div class="note-ops"> <span class="note-ops">
<a class="btn btn-ghost btn-sm" href="<?= e(url('notes/form', ['id' => (int)$n['id']])) ?>">编辑</a> <a class="note-op" href="<?= e(url('notes/form', ['id' => $nid])) ?>"
title="编辑这条笔记">改</a>
<form method="post" action="<?= e(url('notes/destroy')) ?>" class="inline-form" <form method="post" action="<?= e(url('notes/destroy')) ?>" class="inline-form"
onsubmit="return confirm('删掉这条笔记?');"> onsubmit="return confirm('删掉这条笔记?');">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>"> <input type="hidden" name="id" value="<?= $nid ?>">
<button class="btn btn-ghost btn-sm" type="submit">删除</button> <button type="submit" class="note-op note-op-del" title="删除这条笔记">删</button>
</form> </form>
</div> </span>
<?php endif; ?> <?php endif; ?>
</header> </header>
<?php if (trim((string)$n['title']) !== ''): ?> <a class="note-hit" href="<?= e(url('notes/form', ['id' => $nid])) ?>">
<h3 class="note-title"><?= e((string)$n['title']) ?></h3> <?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; ?> <?php endif; ?>
<?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> </a>
<?php elseif ($kind === 'list'): ?>
<ul class="note-list"> <?php /* 图片:等比缩放到卡片宽度,放在摘要下方(用户第 12 条原话) */ ?>
<?php foreach (preg_split('/\R/u', $c) ?: [] as $line): ?> <?php if ($firstImg !== ''): ?>
<?php $line = trim((string)$line); ?> <div class="note-thumb">
<?php if ($line !== ''): ?> <img src="<?= e(media_url($firstImg)) ?>" alt="" loading="lazy" decoding="async">
<li><?= e($line) ?></li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="note-text"><?= nl2br(e($c)) ?></p>
<?php endif; ?>
<?php endforeach; ?>
</div> </div>
<?php endif; ?>
<?php if (!empty($n['tags'])): ?> <?php if (!empty($n['tags'])): ?>
<div class="note-tags note-tags-inline"> <div class="note-tags note-tags-inline">
@@ -155,6 +151,7 @@ $myPerson = (int)\App\Auth::personId();
<?php endif; ?> <?php endif; ?>
</article> </article>
<?php endforeach; ?> <?php endforeach; ?>
</div>
<?php if (!$showAll && (int)$totalAll > count($rows)): ?> <?php if (!$showAll && (int)$totalAll > count($rows)): ?>
<div class="note-more"> <div class="note-more">
@@ -163,6 +160,4 @@ $myPerson = (int)\App\Auth::personId();
</a> </a>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
</div>
</div>
+237 -216
View File
@@ -1,27 +1,33 @@
<?php <?php
/** /**
* 家庭名册 —— 按「关系分组」分栏显示,卡片可以拖动。 * 家庭名册 —— v28 起**平铺成一张列表**,顶部用「标签」筛选,卡片可拖动排序。
*
* 🔴 v28 用户第 5 条:「名册的分组,用下来不太好,我感觉应该改成打标签,
* 就是说,一个人,他可能并不是只在一个分组,例如,他既是我的小学同学,
* 又是我的初中同学,就没法分组,改成标签,似乎更合理。」
*
* ⇒ 从「一人一组的**分栏**」改成「**一人多标签** + 平铺 + 顶部标签筛选」。
* 为什么必须取消分栏:一个人挂 2 个标签时,分栏会让他在两栏里各出现一次,
* 看起来像"名册里有两个人";而且"未分类"那栏会越积越多(挂 2 个标签的人
* 反而更难归类)。平铺 + 筛选是唯一自洽的做法(手机通讯录也是这么做的)。
* *
* 三处刻意的设计: * 三处刻意的设计:
* ① **名册是每人一份的**(v5 起,见 App\PersonRoster):这里只显示"我加的人 + * ① **名册是每人一份的**(v5 起,见 App\PersonRoster):只显示"我加的人 +
* 别人分享给我的",归到哪一组、排第几也都只影响我自己。所以"改分组"不再是 * 别人分享给我的";标签也只影响我自己那份(我给他打"球友"不该让全家都变)。
* 改全家的信息,而是改我自己那份 —— 于是顺手就能做成拖动。 * ② **拖动仍然只改顺序**(用户明确要求保留拖动排序):不再表示"改分组",
* ② **拖动改分组、拖动改顺序**(需求原话:「拖动一个人的卡片进行排序…通过拖动 * 拖动只调整"我看到的先后"。标签在卡片上点一下就能改。
* 改变分组」):拖动本身是 JS,但**每个分组栏也是空的时候照样存在**, * ③ **没有 JS 也完全能用**:卡片上的「编辑」是普通链接;标签用 `?tag=` 链接筛选
* 拖动时统一露出来当落点,否则"想拖到一个现在没人的分组里"就没地方放。 * (服务端渲染,不依赖 JS)。
* ③ **没有 JS 也完全能用**:卡片上的 `<details>调整分组</details>` 还在,
* 而且手机上的 HTML5 拖放本来就不生效,那套表单就是窄屏的正路。
* *
* ⚠️ 卡片结构是 div > a + details,**不能写成 a > form**: * ⚠️ 卡片结构是 div > a + div,**不能写成 a > form**:
* 表单套在链接里是非法 HTML,浏览器会把 DOM 拆开,表单里那个「移动」按钮 * 表单套在链接里是非法 HTML,浏览器会把 DOM 拆开(本仓踩过)。
* 可能连提交机会都没有(或者把整张卡片点成跳转)。
*/ */
$canMove = \App\Auth::canWrite(); $canMove = \App\Auth::canWrite();
$pickable = $pickable ?? 0; $pickable = $pickable ?? 0;
$total = 0; $tagCloud = $tagCloud ?? [];
foreach ($grouped as $list) { $tagFilter = $tagFilter ?? '';
$total += count($list); // v28:平铺列表,人数直接数行数(不再按分组累加)
} $total = count($people ?? []);
/** /**
* 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片, * 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片,
@@ -53,7 +59,7 @@ foreach ($grouped as $list) {
* (拖动排序、即时筛选都不是无 JS 能用的),而且卡片上的「编辑」仍是普通链接, * (拖动排序、即时筛选都不是无 JS 能用的),而且卡片上的「编辑」仍是普通链接,
* 全站核心功能不依赖它。页面正文明说这一点,不静默降级。 * 全站核心功能不依赖它。页面正文明说这一点,不静默降级。
*/ */
$renderGrid = function (array $list) use ($meId, $canMove, $groups): void { $renderGrid = function (array $list) use ($meId, $canMove): void {
?> ?>
<div class="people-grid"> <div class="people-grid">
<?php foreach ($list as $p): ?> <?php foreach ($list as $p): ?>
@@ -86,16 +92,12 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
$metaBits[] = ['家谱', '他在你的家谱树上(配偶/父母/子女/祖孙…),自动加入你的名册']; $metaBits[] = ['家谱', '他在你的家谱树上(配偶/父母/子女/祖孙…),自动加入你的名册'];
} }
if (!empty($p['is_placeholder'])) { $metaBits[] = ['待确认', '']; } if (!empty($p['is_placeholder'])) { $metaBits[] = ['待确认', '']; }
// 当前分组:行上那个「分组」按钮要**显示它**(用户 2026-10-04 第 1 条)。 /* v28 用户第 5 条:原来这里算「当前分组名」给按钮显示。
// ⚠️ 只认「我能用的分组」里那几个 —— 挂在一个别人家的私自分组上时, 改成标签后不再需要 —— 标签直接从 `$p['tags']` 取(见下面按钮那段),
// 这里取不到名字,就按"未分类"显示(按钮照样能改成一个正常的分组)。 而且**一个人可能有好几个标签**,没法再塞进一个"当前值"里。 */
$curGid = (int)($p['group_id'] ?? 0);
$grpName = '';
foreach ($groups as $g) {
if ((int)$g['id'] === $curGid) { $grpName = (string)$g['name']; break; }
}
?> ?>
<div class="person" data-person="<?= $pid ?>" <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'] ?? '')) ?>" data-search="<?= e($pshow . ' ' . $pReal . ' ' . (string)($p['email'] ?? '') . ' ' . (string)($p['org'] ?? '') . ' ' . (string)($p['phone_plain'] ?? '')) ?>"
<?php /* ⚠️ 首字母**分字段**、用空格隔开(前端逐段独立判子序列)。 <?php /* ⚠️ 首字母**分字段**、用空格隔开(前端逐段独立判子序列)。
把"姓名+称呼"拼成一串会造出跨字段巧合:用户实测「输 yy 搜出岳父」 把"姓名+称呼"拼成一串会造出跨字段巧合:用户实测「输 yy 搜出岳父」
@@ -170,16 +172,23 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
就得继续猜偏移;而且「分组」按钮的宽度随分组名变化,写死必然重叠。 就得继续猜偏移;而且「分组」按钮的宽度随分组名变化,写死必然重叠。
容器贴右、内部自然排开,.person-main 也就不用再靠 padding 让位。 */ ?> 容器贴右、内部自然排开,.person-main 也就不用再靠 padding 让位。 */ ?>
<div class="person-actions"> <div class="person-actions">
<?php
/* 🔴 v28 用户第 5 条:分组 → 标签。
原按钮显示"当前分组"(一个人只有一个);现在显示**他身上的标签**
(可能好几个,也可能一个都没有)。点开就是标签编辑浮层:
可以勾已有的、也可以直接打新词 —— 这正是「一个人可以既是小学同学、
又是初中同学」在界面上的样子。
⚠️ 按钮文案:有标签就列出来,没有就显示「标签」,
与原来的「分组 / is-none」形态保持一致的心智。 */
$pTags = (array)($p['tags'] ?? []);
$tagLabel = $pTags ? implode(' ', $pTags) : '标签';
?>
<?php if ($canMove): ?> <?php if ($canMove): ?>
<?php /* 用户 2026-10-04 第 1 条:名册要有地方改分组 → 「分组」按钮, <button type="button" class="btn btn-ghost btn-sm person-tag-btn<?= $pTags ? '' : ' is-none' ?>"
**直接显示当前分组**,点开就是分组列表,选中即改。 data-pt-open="<?= $pid ?>"
v24 第 10 批 #6:按钮本身不再内联 <details>+<form>+11 个 radio, data-pt-name="<?= e($pshow) ?>"
只带数据、交给全页共享的那一份浮层(理由见 $renderGrid 的注释)。 */ ?> data-pt-tags="<?= e(implode(' ', $pTags)) ?>"
<button type="button" class="btn btn-ghost btn-sm person-group-btn<?= $grpName === '' ? ' is-none' : '' ?>" title="改 <?= e($pshow) ?> 的标签"><?= e($tagLabel) ?></button>
data-pg-open="<?= $pid ?>"
data-pg-name="<?= e($pshow) ?>"
data-pg-cur="<?= $curGid ?>"
title="改 <?= e($pshow) ?> 的分组"><?= e($grpName !== '' ? $grpName : '分组') ?></button>
<?php endif; ?> <?php endif; ?>
<a class="btn btn-ghost btn-sm person-edit" data-no-drag <a class="btn btn-ghost btn-sm person-edit" data-no-drag
href="<?= e(url('people/edit', ['id' => $pid])) ?>" href="<?= e(url('people/edit', ['id' => $pid])) ?>"
@@ -203,27 +212,14 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
aria-label="更多操作" title="更多操作">⋯</button> aria-label="更多操作" title="更多操作">⋯</button>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php <?php
}; };
/** 一栏(含空栏)。空栏平时收起来,拖动时由 JS 加 is-dragging 露出来当落点。 */ /* v28 用户第 5 条:`$renderBlock`(画"一栏")**整个删掉** ——
$renderBlock = function (int $gid, string $name, array $list) use ($renderGrid): void { 名册不再分栏,改成一整张平铺列表(见下面 `$renderGrid($people)`)。
?> 留着它只会让后人以为"还有个分栏路径"。 */
<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
};
?> ?>
<div class="page-head"> <div class="page-head">
@@ -231,9 +227,9 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
<span class="sub"> <span class="sub">
<?php /* v24 第 21 批 第 1 条:无刷新删除后要就地更新这个数 —— <?php /* v24 第 21 批 第 1 条:无刷新删除后要就地更新这个数 ——
所以「共 N 人」那个 N 单独包一个 <b>(加 data-roster-total 供 JS 定位)。 所以「共 N 人」那个 N 单独包一个 <b>(加 data-roster-total 供 JS 定位)。
⚠️ 不能把整段文字都塞进去:后面的「,分 6 个关系分组」是**另一件事**, v28:原来后面跟着「,分 6 个关系分组」—— 分组已成标签,
删一个人不该动它。 */ ?> 改成说"打了几个标签"(情报更贴近新模型)。 */ ?>
共 <b data-roster-total><?= $total ?></b> 人<?= $groups && $q === '' ? ',分 ' . count($groups) . ' 个关系分组' : '' ?> 共 <b data-roster-total><?= $total ?></b> 人<?= $tagCloud && $q === '' ? ',有 ' . count($tagCloud) . ' 个标签' : '' ?>
</span> </span>
<div class="actions"> <div class="actions">
<?php if ($canMove && $pickable > 0): ?> <?php if ($canMove && $pickable > 0): ?>
@@ -299,30 +295,42 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
</div> </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 else: ?>
<?php /* ⚠️ 搜索与否**都走同一张列表**(v24 用户第 2 条之后)。 <?php /* v28:**平铺**成一张列表(原来按分组分成好几栏)。
原来搜索时会把分组拆掉、平铺成一堆卡片,看着"搜的时候样式变了"; ⚠️ 拖动仍然可用,但语义已变:拖动只调整**我看到的先后**,
现在一律按分组呈现,filter.js 会把没命中的行藏起来, 不再表示"改分组"(标签在卡片上点一下就能改)。 */ ?>
整栏都不剩人时连栏一起收 —— 搜索结果与平时长得一样,只少了不匹配的。 */ ?> <div class="roster is-flat<?= $canMove ? ' is-droppable' : '' ?>" id="rosterBox"
<div class="roster<?= $canMove ? ' is-droppable' : '' ?>" id="rosterBox"
data-reorder="<?= e(url('people/reorder')) ?>" data-reorder="<?= e(url('people/reorder')) ?>"
data-csrf="<?= e(csrf_token()) ?>"> data-csrf="<?= e(csrf_token()) ?>">
<?php foreach ($groups as $g): ?> <?php $renderGrid($people); ?>
<?php $gid = (int)$g['id']; ?>
<?php $renderBlock($gid, (string)$g['name'], $grouped[$gid] ?? []); ?>
<?php endforeach; ?>
<?php <?php if ($tagFilter !== '' && !$people): ?>
// 「未分类」栏永远画出来(哪怕是空的)—— 它是把卡片从某个分组里拖出来的落点。 <p class="small muted" style="margin:0">这个标签下暂时没有人。</p>
// 提示语按需求删掉了(用户原话:「这些人还没归到任何分组里…这些提示,都去掉」)。
$renderBlock(0, '未分类', $grouped[0] ?? []);
?>
<?php if (!$groups): ?>
<p class="small muted" style="margin:0">
还没有任何分组。管理员可以在「系统设置 → 公共分组」里建一套全家共用的,
你也可以在「个人设置 → 我的分组」里加只给自己看的。
</p>
<?php endif; ?> <?php endif; ?>
</div> </div>
@@ -384,24 +392,42 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
改成居中的模态框后,外部点击只关模态本身,触发按钮的语义不再冲突。 */ ?> 改成居中的模态框后,外部点击只关模态本身,触发按钮的语义不再冲突。 */ ?>
<div class="rm-modal" id="rosterMenus" hidden> <div class="rm-modal" id="rosterMenus" hidden>
<div class="rm-mask" data-rm-close></div> <div class="rm-mask" data-rm-close></div>
<div class="rm-panel" id="rmGroup" role="dialog" aria-modal="true" aria-label="选择分组" hidden> <?php /* 🔴 v28 用户第 5 条:这个浮层从「选一个分组(radio 单选)」改成
<div class="rm-head"><b>把「<span class="pg-who"></span>」分到</b> 「**打标签(多选 + 自由输入)**」。
为什么用 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> <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() ?> <?= csrf_field() ?>
<input type="hidden" name="person_id" value=""> <input type="hidden" name="person_id" value="">
<div class="rm-body"> <div class="rm-body">
<label class="pg-item"> <?php /* 已有的标签(我名下全部用过的):点一下勾选/取消 */ ?>
<input type="radio" name="group_id" value=""> <?php if ($tagCloud): ?>
<span>未分类</span> <div class="pt-picks" id="ptPicks">
</label> <?php foreach ($tagCloud as $t): ?>
<?php foreach ($groups as $g): ?> <label class="pt-pick">
<label class="pg-item"> <input type="checkbox" value="<?= e((string)$t['tag']) ?>">
<input type="radio" name="group_id" value="<?= (int)$g['id'] ?>"> <span>#<?= e((string)$t['tag']) ?></span>
<span><?= e((string)$g['name']) ?></span>
</label> </label>
<?php endforeach; ?> <?php endforeach; ?>
</div> </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"> <div class="rm-foot">
<button class="btn" type="submit">保存</button> <button class="btn" type="submit">保存</button>
<button class="btn btn-ghost" type="button" data-rm-close>取消</button> <button class="btn btn-ghost" type="button" data-rm-close>取消</button>
@@ -500,28 +526,20 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
var totalEl = document.querySelector('[data-roster-total]'); var totalEl = document.querySelector('[data-roster-total]');
var searchInput = document.getElementById('rosterSearch'); var searchInput = document.getElementById('rosterSearch');
/** 摘掉一个人 = 摘卡片 + 更新"共 N 人" + 更新他那一栏的人数 */ /** 摘掉一个人 = 摘卡片 + 更新"共 N 人"
* ⚠️ v28:名册已**平铺**(不再分栏),所以这里删掉了原来"更新他那一栏人数"
* 的两行(`.group-block` / `[data-group-n]` 在新结构里都不存在了)。 */
function removePerson(pid) { function removePerson(pid) {
var card = document.querySelector('.person[data-person="' + pid + '"]'); var card = document.querySelector('.person[data-person="' + pid + '"]');
if (!card) { return false; } if (!card) { return false; }
var block = card.closest('.group-block');
card.parentNode.removeChild(card); card.parentNode.removeChild(card);
if (totalEl) { if (totalEl) {
var n = parseInt(totalEl.textContent || '0', 10) || 0; var n = parseInt(totalEl.textContent || '0', 10) || 0;
totalEl.textContent = String(Math.max(0, n - 1)); totalEl.textContent = String(Math.max(0, n - 1));
} }
if (block) { // 让 filter.js 重算筛选/空态(它监听的正是 input 事件)。
var gid = block.getAttribute('data-group'); // 没有 filter.js(极老浏览器)时什么也不做 —— 卡片本身已经摘掉了。
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(极老浏览器)时什么也不做 —— 只是栏头人数会略旧,
// 卡片本身已经摘掉了,不影响用户看到的结果。
if (searchInput) { if (searchInput) {
try { searchInput.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {} 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 () { (function () {
var wrap = document.getElementById('rosterMenus'); var wrap = document.getElementById('rosterMenus');
if (!wrap) { return; } if (!wrap) { return; }
var gPanel = document.getElementById('rmGroup'); var tPanel = document.getElementById('rmTags');
var dPanel = document.getElementById('rmDel'); var dPanel = document.getElementById('rmDel');
var mPanel = document.getElementById('rmMore'); var mPanel = document.getElementById('rmMore');
function closeAll() { function closeAll() {
wrap.hidden = true; 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) { 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; panel.hidden = false;
wrap.hidden = false; wrap.hidden = false;
// 焦点进面板:点开就能用键盘选(Esc 也能关,见下) // 焦点进面板:点开就能用键盘选(Esc 也能关,见下)
@@ -626,23 +644,28 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
if (first) { first.focus(); } if (first) { first.focus(); }
} }
// ---- 打开:分组 ---- // ---- 打开:标签(v28 用户第 5 条,原先是"分组")----
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
var t = e.target; var t = e.target;
if (!t || !t.closest) { return; } if (!t || !t.closest) { return; }
var gb = t.closest('[data-pg-open]'); var tb = t.closest('[data-pt-open]');
if (gb) { if (tb) {
e.preventDefault(); e.preventDefault();
var form = gPanel.querySelector('form'); var form = tPanel.querySelector('form');
form.querySelector('input[name="person_id"]').value = gb.getAttribute('data-pg-open'); form.querySelector('input[name="person_id"]').value = tb.getAttribute('data-pt-open');
gPanel.querySelector('.pg-who').textContent = gb.getAttribute('data-pg-name') || ''; tPanel.querySelector('.pt-who').textContent = tb.getAttribute('data-pt-name') || '';
// 预选当前分组(点开就知道他现在在哪一栏) // 预填他现在的标签(勾选框回勾 + 文本框回填)——
var cur = parseInt(gb.getAttribute('data-pg-cur') || '0', 10) || 0; // 点开就知道他打了什么,不用去卡片上找。
Array.prototype.forEach.call(form.querySelectorAll('input[name="group_id"]'), function (r) { // ⚠️ 回填函数由下面那段 IIFE 挂到 window.__ptFill(两段脚本解耦,
r.checked = (parseInt(r.value || '0', 10) || 0) === cur; // 顺序变了也不会"回填静默失效")。
}); if (typeof window.__ptFill === 'function') {
open(gPanel); 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; 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 () { (function () {
var wrap = document.getElementById('rosterMenus'); var wrap = document.getElementById('rosterMenus');
if (!wrap) { return; } if (!wrap) { return; }
var gPanel = document.getElementById('rmGroup'); var tPanel = document.getElementById('rmTags');
var form = gPanel ? gPanel.querySelector('form.pg-menu') : null; var form = tPanel ? tPanel.querySelector('form.pt-form') : null;
if (!form || !window.fetch || !window.FormData) { return; } if (!form || !window.fetch || !window.FormData) { return; }
var totalEl = document.querySelector('[data-roster-total]');
var searchInput = document.getElementById('rosterSearch'); var searchInput = document.getElementById('rosterSearch');
/** 就地把他挪到目标分组栏里 + 两栏人数跟着变 */ /* 勾选框 ↔ 文本框**双向同步**:
function moveCard(pid, gid, gname) { · 勾/取消一个已有标签 → 把它增删进文本框(文本框是唯一提交源);
var card = document.querySelector('.person[data-person="' + pid + '"]'); · 文本框里手打的新词 → 打开时按它回勾(见 fillPanel)。
if (!card) { return false; } 为什么用"文本框当唯一真相"而不是"提交时拼两部分":
var from = card.closest('.group-block'); 拼两部分就要处理"同一个标签既勾了又在文本框里"(重复)、
var toSec = document.querySelector('.group-block[data-group="' + gid + '"]'); "文本框里删了但勾还在"(该算删还是算加)—— 全是边界 bug 的温床。 */
if (!toSec) { return false; } // 目标栏不存在(分组被删了)→ 别乱挪 function syncFromPicks() {
var grid = toSec.querySelector('.people-grid'); if (!tPanel) { return; }
if (!grid) { return false; } var inp = tPanel.querySelector('#ptInput');
if (!inp) { return; }
// ① 先记下"他在哪一栏"(要减人数的那一栏) var on = [];
var fromGid = from ? from.getAttribute('data-group') : null; Array.prototype.forEach.call(tPanel.querySelectorAll('.pt-pick input:checked'), function (c) {
on.push(c.value);
// ② 挪过去(插到末尾:新归到一组的人在那一组最后,与拖拽的语义一致)
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);
}); });
// 保留文本框里**手打的、不在已有标签里**的那些(它们没有勾选框)
// ⑤ 按钮上显示的文字跟着换(用户第 1 条:那颗按钮**显示当前分组**) var known = {};
var btn = card.querySelector('.person-group-btn'); Array.prototype.forEach.call(tPanel.querySelectorAll('.pt-pick input'), function (c) {
if (btn) { known[c.value] = true;
btn.textContent = gname !== '' ? gname : '分组'; });
btn.classList.toggle('is-none', gname === ''); var keep = [];
btn.setAttribute('data-pg-cur', String(gid)); String(inp.value || '').split(/[\s,,]+/).forEach(function (w) {
btn.setAttribute('title', '改 ' + (btn.getAttribute('data-pg-name') || '') + ' 的分组'); w = w.replace(/^#/, '');
if (w !== '' && !known[w]) { keep.push(w); }
});
inp.value = on.concat(keep).join(' ');
} }
// ⑥ 让 filter.js 重算筛选/空态/分栏显隐(它监听的正是 input 事件) /** 打开面板时:把这个人已有的标签灌进去(勾选框回勾 + 文本框回填) */
if (searchInput) { function fillPanel(tagsStr) {
try { searchInput.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {} 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;
});
} }
return true; 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) { function say(ok, msg) {
var host = document.querySelector('.flash'); var host = document.querySelector('.flash');
if (!host) { if (!host) {
@@ -785,49 +849,6 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
host.insertBefore(el, host.firstChild); host.insertBefore(el, host.firstChild);
setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 4000); 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> </script>
<?php endif; ?> <?php endif; ?>
+163 -94
View File
@@ -189,29 +189,33 @@ $myPerson = (int)\App\Auth::personId();
<span class="node-badge"><?= e(\App\Resource::permWord((string)$r['perm'])) ?></span> <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 if ($as): ?><span class="res-n"><?= count($as) ?> 个附件</span><?php endif; ?>
<?php /* ---- 右上角四色按钮(与标题同一排)---- <?php /* ---- 右上角按钮(与标题同一排)----
⚠️ 必须用 <span> 包(phrasing content);`<div>`/`<form>` 放 <summary> 里是非法 HTML。 🔴 v28 用户第 7 条:「资源功能,编辑和附件两个按钮合并吧,就叫编辑,
⚠️ 每个按钮都带 `data-res-open`,脚本里对它 `preventDefault()` 既可以更改信息,也可以删除和增加附件,之前分开,操作的时候,
以阻止 <summary> 顺带把这一条展开。 */ ?> 还是有割裂感。共享也合并,这样就剩下两个按钮,编辑和删除。」
⇒ 从四颗(编辑/共享/附件/删除)收成**两颗**:
· 「编辑」→ 打开一个**带页签**的弹框:信息 / 附件 / 共享;
· 「删除」→ 保持独立(危险动作不该混在编辑里,误点代价不可逆)。
⚠️ 仍然必须用 <span> 包(phrasing content);`<div>`/`<form>` 放
<summary> 里是非法 HTML。
⚠️ 按钮仍带 `data-res-open`,脚本对它 `preventDefault()`
以阻止 <summary> 顺带把这一条展开。
⚠️ 页签要的数据(标题/正文/归属人/附件/已共享)全都挂在这一颗按钮上:
弹框全页只有一份,点哪条就把哪条的数据灌进去 ——
这正是"避免每行一份表单"那条老规矩(2369 个 form 把页面拖死)。 */ ?>
<?php if ($canEdit): ?> <?php if ($canEdit): ?>
<span class="res-ops"> <span class="res-ops">
<button type="button" class="btn btn-warn btn-sm res-op" <button type="button" class="btn btn-warn btn-sm res-op"
data-res-open="edit" data-res-id="<?= $rid ?>" data-res-open="edit" data-res-id="<?= $rid ?>"
data-res-title="<?= e((string)$r['title']) ?>" data-res-title="<?= e((string)$r['title']) ?>"
data-res-content="<?= e((string)($r['content'] ?? '')) ?>" data-res-content="<?= e((string)($r['content'] ?? '')) ?>"
data-res-owner="<?= (int)$r['owner_id'] ?>">编辑</button> data-res-owner="<?= (int)$r['owner_id'] ?>"
<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-shares="<?= e((string)json_encode(array_map( data-res-shares="<?= e((string)json_encode(array_map(
static fn($s): array => [ static fn($s): array => [
'id' => (int)$s['person_id'], 'id' => (int)$s['person_id'],
'name' => (string)$s['name'], 'name' => (string)$s['name'],
'perm' => (string)($s['perm'] ?? 'read'), 'perm' => (string)($s['perm'] ?? 'read'),
], $sh), JSON_UNESCAPED_UNICODE)) ?>">共享</button> ], $sh), JSON_UNESCAPED_UNICODE)) ?>"
<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']) ?>"
data-res-atts="<?= e((string)json_encode(array_map( data-res-atts="<?= e((string)json_encode(array_map(
static function ($a): array { static function ($a): array {
$k = \App\Attachment::kind($a); $k = \App\Attachment::kind($a);
@@ -224,7 +228,7 @@ $myPerson = (int)\App\Auth::personId();
'dl' => media_url((string)$a['key'], ['dl' => 1]), 'dl' => media_url((string)$a['key'], ['dl' => 1]),
'sz' => \App\Attachment::sizeWord($a['size'] ?? 0), '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" <button type="button" class="btn btn-del btn-sm res-op"
data-res-open="del" data-res-id="<?= $rid ?>" data-res-open="del" data-res-id="<?= $rid ?>"
data-res-title="<?= e((string)$r['title']) ?>" data-res-title="<?= e((string)$r['title']) ?>"
@@ -295,10 +299,32 @@ $myPerson = (int)\App\Auth::personId();
<div class="res-modal" id="resPanels" hidden> <div class="res-modal" id="resPanels" hidden>
<div class="res-mask" data-res-close></div> <div class="res-mask" data-res-close></div>
<?php /* ---------- ① 编辑信息(黄色按钮) ---------- */ ?> <?php /* ---------- 编辑(一个弹框,三个页签:信息 / 附件 / 共享)----------
<div class="res-panel-lg" id="resEditPanel" hidden role="dialog" aria-modal="true" aria-label="编辑资源"> 🔴 v28 用户第 7 条原话:「资源功能,编辑和附件两个按钮合并吧,就叫编辑,
<div class="res-ph"><b>编辑「<span class="rd-title"></span>」</b> 既可以更改信息,也可以删除和增加附件,之前分开,操作的时候,还是有割裂感。
共享也合并,这样就剩下两个按钮,编辑和删除。」
⇒ 三块内容合成一个面板,用**页签**分隔。为什么用页签而不是把它们堆成一长条:
· 三块各是一套表单(信息 / 上传 / 共享),堆起来用户要滚很久才找得到;
· 页签让"我现在在改哪一块"一目了然,也避免了"三套表单同时铺在眼前"的压力。
⚠️ 只有 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> <button type="button" class="res-x" data-res-close aria-label="关闭">✕</button></div>
<?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')) ?>"> <form method="post" action="<?= e(url('resources/update')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value=""> <input type="hidden" name="id" value="">
@@ -325,9 +351,6 @@ $myPerson = (int)\App\Auth::personId();
<textarea class="input" name="content" rows="3" <textarea class="input" name="content" rows="3"
placeholder="编号、有效期、放在哪里…"></textarea> placeholder="编号、有效期、放在哪里…"></textarea>
</div> </div>
<p class="tip" style="margin:0">
附件不在这里改 —— 用蓝色「附件」按钮(那边可以拖动上传、也能删)。
</p>
</div> </div>
<div class="res-pf"> <div class="res-pf">
<button class="btn btn-warn" type="submit">保存</button> <button class="btn btn-warn" type="submit">保存</button>
@@ -336,16 +359,33 @@ $myPerson = (int)\App\Auth::personId();
</form> </form>
</div> </div>
<?php /* ---------- ② 共享设置(绿色按钮)---------- <?php /* ---- 页签 ② 附件(原「附件」按钮那整套搬进来,一字未改)---- */ ?>
用户原话:「设置共享人员,**包括删除共享人**等」→ 一个面板里两件事: <div class="res-tabpage" data-res-page="att" hidden>
· 上面:给谁开共享(选人 + 权限 + 共享按钮) <div class="res-pb">
· 下面:已经共享给谁 + 逐个「取消共享」 <div class="rd-att-now">
⚠️ "已共享"那一段由脚本按按钮上的 `data-res-shares` 现渲染, <h3 class="res-sub">已有附件</h3>
**不是**每条资源渲染一份表单 —— 那正是要避免的膨胀。 <div class="rd-att-list" id="resAttNow"></div>
取消共享走下面这个唯一的 #resUnshareForm(脚本填 id/person_id 后提交)。 */ ?> </div>
<div class="res-panel-lg" id="resSharePanel" hidden role="dialog" aria-modal="true" aria-label="共享设置"> <form method="post" enctype="multipart/form-data"
<div class="res-ph"><b>共享「<span class="rd-title"></span>」</b> action="<?= e(url('resources/attach_add')) ?>" id="resAttForm">
<button type="button" class="res-x" data-res-close aria-label="关闭">✕</button></div> <?= 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 /* ---- 页签 ③ 共享(原「共享」按钮那整套搬进来)---- */ ?>
<div class="res-tabpage" data-res-page="share" hidden>
<div class="res-pb"> <div class="res-pb">
<form method="post" action="<?= e(url('resources/share')) ?>"> <form method="post" action="<?= e(url('resources/share')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
@@ -389,34 +429,6 @@ $myPerson = (int)\App\Auth::personId();
</form> </form>
</div> </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>
</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>
<?php /* 删附件:唯一的一份表单,脚本填 id 后提交。 */ ?> <?php /* 删附件:唯一的一份表单,脚本填 id 后提交。 */ ?>
<form method="post" action="<?= e(url('resources/attach_del')) ?>" id="resAttDelForm" hidden> <form method="post" action="<?= e(url('resources/attach_del')) ?>" id="resAttDelForm" hidden>
<?= csrf_field() ?> <?= csrf_field() ?>
@@ -460,36 +472,82 @@ $myPerson = (int)\App\Auth::personId();
} }
/* ============================================================ /* ============================================================
四个动作 → 一份共享弹框(v24 第 23 批 用户第 1 条) 动作 → 一份共享弹框(v24 第 23 批 用户第 1 条;
v28 第 7 条把「编辑 / 附件 / 共享」合成一个**带页签**的面板)
============================================================ */ ============================================================ */
var wrap = document.getElementById('resPanels'); var wrap = document.getElementById('resPanels');
if (!wrap) { return; } 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 = { var panels = {
edit: document.getElementById('resEditPanel'), edit: editPanel,
share: document.getElementById('resSharePanel'), share: editPanel,
att: document.getElementById('resAttPanel'), att: editPanel,
del: document.getElementById('resDelPanel') del: document.getElementById('resDelPanel')
}; };
function closeAll() { /** 把面板切到某一页签(tab: info / att / share) */
wrap.hidden = true; function tabTo(tab) {
Object.keys(panels).forEach(function (k) { if (!editPanel) { return; }
if (panels[k]) { panels[k].hidden = true; } 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) { 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; wrap.hidden = false;
var p = panels[which]; var p = panels[which];
if (!p) { return; } if (!p) { return; }
p.hidden = false;
var first = p.querySelector('input:not([type=hidden]), textarea, select'); var first = p.querySelector('input:not([type=hidden]), textarea, select');
if (first) { first.focus(); } 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(坏了就当空数组,别让一个坏属性把整个弹框搞死) */ /** 从 data 属性读 JSON(坏了就当空数组,别让一个坏属性把整个弹框搞死) */
function jsonOf(el, attr) { function jsonOf(el, attr) {
try { try {
@@ -534,43 +592,27 @@ $myPerson = (int)\App\Auth::personId();
var idInp = panel.querySelector('input[name="id"]'); var idInp = panel.querySelector('input[name="id"]');
if (idInp) { idInp.value = 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"]'); var tInp = panel.querySelector('input[name="title"]');
if (tInp) { tInp.value = title; } if (tInp) { tInp.value = title; }
var cInp = panel.querySelector('textarea[name="content"]'); var cInp = panel.querySelector('textarea[name="content"]');
if (cInp) { cInp.value = op.getAttribute('data-res-content') || ''; } if (cInp) { cInp.value = op.getAttribute('data-res-content') || ''; }
// 归属人:选人组件是单选的,用它的对外接口设置。 // 归属人:选人组件是单选的,用它的对外接口设置。
// ⚠️ 必须把 panel 作为 root 传进去 —— 本页有多个同类组件 // ⚠️ 必须把 panel 作为 root 传进去 —— 本页有多个同类组件
// (新建表单的 owner_id、本面板的 owner_id、共享面板的 person_id), // (新建表单的 owner_id、本面板的 owner_id、共享页签的 person_id),
// 只按字段名找会命中**文档里第一个**(财务页在这上面栽过)。 // 只按字段名找会命中**文档里第一个**(财务页在这上面栽过)。
var pid = op.getAttribute('data-res-owner') || '0'; var pid = op.getAttribute('data-res-owner') || '0';
if (window.ppSetSelected) { window.ppSetSelected('owner_id', pid, panel); } 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) { if (attNow) {
attNow.innerHTML = ''; attNow.innerHTML = '';
var atts = jsonOf(op, 'data-res-atts'); var atts = jsonOf(op, 'data-res-atts');
@@ -600,6 +642,33 @@ $myPerson = (int)\App\Auth::personId();
attNow.appendChild(row); 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') { if (which === 'del') {
+9 -2
View File
@@ -426,8 +426,15 @@ $lastColIdx = count($colList) - 1;
badge.setAttribute('data-st', status); badge.setAttribute('data-st', status);
badge.setAttribute('data-next', next); badge.setAttribute('data-next', next);
badge.textContent = word; badge.textContent = word;
badge.setAttribute('title', '现在:' + word + ';点一下改成「' /* 🔴🔴 v28 用户第 4 条:「任务完成复选框的提示文字还在,需要删除。」
+ ({todo: '待办', doing: '进行中', done: '已完成'}[next] || '') + '」'); 根因就在这一行 —— 它给**状态徽标**动态挂了一个 `title`。
v24 第 26 批只把**静态渲染**里那颗徽标的 title 去掉了(服务端模板),
却漏了这一处 JS:用户只要点过一次复选框,徽标就又被安上 tooltip,
而它离复选框最近,鼠标一停就把复选框盖住 —— 与当初报的是同一个现象。
⚠️ 上一批的静态断言(查渲染出的 HTML 里有没有 `title=`)**永远抓不到**:
它只在点击之后才出现。这类"JS 复活的旧写法"必须靠真机点击验证。
⇒ 直接删掉,不再设 title;无障碍说明靠 aria-label(不产生可见浮层)。 */
badge.setAttribute('aria-label', word);
} }
card.classList.toggle('is-done', status === 'done'); card.classList.toggle('is-done', status === 'done');
say('已标成「' + word + '」。', true); say('已标成「' + word + '」。', true);