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