v24 第 24 批:选人冲掉已有成员 / 首页改版 / 个人导航 / 微信读书 / 财务图

用户 6 条原话(要点):
  ①「选择名册里面人员的那个弹出框有个问题,就是会把页面里面已有的人员给冲掉。
     比方说电影里面的观影人或者是旅程里面的参与人…这是一个 bug 应该通篇检查。」
    并按「输入拼音 → 空格选中第一个 → 输入框清空回焦 → 再输下一个 → 回车=确定」重做交互。
  ② 首页简要信息改成:问候 + 姓名 + 日期 + 星期 + 农历 + 节气/节日/纪念日/生日,
     下面「今天几条日程 / 几件待办」,天气**彩色图标**放**最右侧**,温度风力,最下面地址。
  ③「家里的镜框这里呃全部不要了」→ 删模块卡片墙 + 未来七天;日程与任务**左右各半**、
     高度固定、内部滚动且**不显滚动条**;日程**掐头去尾** + 当前时刻**红线**;
     任务显示标题+状态+**所在看板名**;最下面往年的今天。
  ④「把个人导航项目完整搬过来,放在读书后面」;免登录展示页 `/nav/?<短语>`;
     右上角给**登录**入口;展示页头像用**用户自己的头像**。
  ⑤ 读书加笔记 + 导入微信读书笔记(用户明确「**用微信读书 api key**」)。
  ⑥ 财务页第二张多线图不随类别标签筛选。

用户拍板(AskUserQuestion):① 首页删「模块卡片墙 + 未来七天」两块;
② 微信读书用官方 API Key;③ 导航短语「自己起名 + 可一键换随机码」都做;
④ 6 条一次做完再上线。

════ 一、 🔴 第 1 条:复现出的真 bug(全站性)════
**根因**:选人清单是**打开弹框时才建 DOM**(懒建,为性能),而承载"已选"的
checkbox 就在那份清单里 → **没打开过弹框就提交**时表单里一个值都没有 →
后端把"表单里没有 attendees"理解成"用户要把人全删掉" → **静默清空**。
实测复现:库里同行人 `2,3,4` → 只改标题保存后变成 **NULL**。

修法:模板加**承运字段**(`.pp-carry` 隐藏 input,承载当前已选);
JS 建清单时**撤掉**它(否则同一个 name 会带两份值)。
⚠️ 裸 POST(完全不带 attendees)**本来就该清空** —— 那是显式地"不选任何人",
所以断言也必须照着"浏览器实际提交的样子"(带承运字段)来测。

**键盘流程**(用户第 1 条后半):空格 = 选**当前可见列表里第一个还没选的**
(⚠️ 必须跳过已选中的 —— 否则会把它**取消**,与"我要选人"正好相反,本轮真机跑出来的缺陷);
选中后清空输入框 + 光标回输入框;回车 = 确定。空格必须 `preventDefault`
(否则输入框里多一个空格字符)。

覆盖范围:4 个多选(`attendees[]` / `members[]` / `shares[]` / 修订 `members[]`)
+ 全部单选(归属人 / 负责人 / 纪念日人等)。

════ 二、 首页改版(第 2、3 条)════
自上而下只有三块:
  ① 信息面板:问候(早/中/晚 + 姓名)→ 日期 / 星期 / 农历 → 日子标记
     (法定节假日 / 节气 / 传统节日 / 纪念日 / 生日,各一色)
     → 今日数字(今天几条日程、几件待办)| **最右**:彩色天气图标 + 温度 + 风力 + 地址
  ② 日程 / 任务 **左右各半**(各 360px 固定高、内部滚动、**不显滚动条**三套写法)
     日程是**日视图形式的刻度**、**掐头去尾**(首末日程前后各 1h)、**当前时刻红线**
     任务是标题 + **状态** + **看板名** + 截止
  ③ 往年的今天

新增 `Home::todayMarks()`(把五类来源合成一句话:"今天是国庆节 · 大暑")与
天气的 `tone` 色调档(彩色图标靠 CSS 上色,**不用 emoji** —— 避免旧系统豆腐块)。
删掉:`mod-cards` 模块卡片墙、`未来七天`。

════ 三、 个人导航(第 4 条,SimpleNav 完整搬运)════
`app/Nav.php`(4 张 `nav_*` 表 + 稀疏排序 + 短语)· `app/NavFetch.php`
(抓取与图标生成,27KB,从原项目 functions.php 机械搬运,**原文那批"线上实测"注释全部保留**)
· `nav_show.php`(**全站唯一免登录页**)· `nav_suggest.php`(联想代理,凭短语门禁)
· `templates/nav/index.php`(管理页:板块/站点/引擎/设置 + 拖拽排序)。

搬运只改两处(不改会坏):① 独立 SQLite → 家书的 MySQL;② **所有查询加 `person_id`**
(原项目是单用户系统,不加这一维全家人会共用同一份导航)。

免登录展示页:`/nav/?<短语>`(也认 `/nav/?<短语>` 不带参数名);
短语用户自起名(4~32 位字母数字`_-`,全局唯一、保留词拦截)或一键换随机码;
右上角**登录**入口(进家书登录页);头像用**他自己**的头像;空态给一句话。

路由三处同步(`router.php` / `.htaccess` / 部署脚本 5c **独立标记块**)——
这是本仓库第三次复用这条规律:**每加一个"站根上的独立入口",就必须是自己的标记块**。
⚠️ 线上重写规则**顺序有语义**:`^nav/suggest\.php$` 必须在 `^nav/?$` 之前
(后者会把前者吞掉)。

════ 四、 微信读书(第 5 条)════
用户提示「用微信读书 api key」是对的 —— **我的知识过时了**:2026 年官方已开放
(网关 `POST https://i.weread.qq.com/api/agent/gateway`、`Bearer wrk-` 认证、
接口 `/user/notebooks`(游标 lastSort 分页)、`/book/bookmarklist`、
`/review/list/mine`、`/book/chapterinfo`)。

`app/WeRead.php`:Key 用 `Crypto::encrypt` **加密落库**、界面只回显掩码
(官方原话:这串 Key 等价于账号凭据);导入走「拉预览 → 勾选 → 导入」,
落 `book_notes`(去重 = kind + 内容 + 位置)。
⚠️ 业务参数必须**平铺在顶层**,不能包进 `params`(官方文档专门列了这个反例)。
另一条路「手动粘贴」保留(`books/notes_import`,早已有)。

════ 五、 财务第二张图(第 6 条,**上一批我引入的 bug**)════
`g.hidden = true` 对 **SVG 的 `<g>` 无效**(它没有 `hidden` 属性)→ 线条一条都没被藏;
**而我的探针也读同一个属性** → 两边一起绿(假绿)。
改为 `style.display`(恢复用 `''` 而不是 `'block'`);探针改读
`getComputedStyle(el).display !== 'none'`(落在**渲染结果**上)。
⚠️ "探针与实现读同一个属性"是**互相掩盖**的典型,比单纯写错断言更危险。

════ 六、 验证 ════
e2e **1218/0**(本批新增 102 条)· 真机探针 **19/0**(选人)+ **24/0**(首页/导航)
+ **34/0**(导航模块)· 桌面几何 0 失败 · 移动端几何 674/0 · 老库升级 149/0
· 部署静态断言空跑 **310/0** · **反向验证 13/13**(逐条改回旧行为,点名断言都变红)
· 线上验收:9 个新文件全在、4 张 nav 表建好(schema_version=24)、
  三条重写规则顺序正确、免登录页 / 空短语 / 错短语 / 联想门禁 全部响应正确。

════ 七、 本批的"夹具与断言"教训(都写进注释了)════
· `calendar_events.all_day` **默认值是 1** → 插夹具不写它就成了全天事件 →
  `time` 被抹成 `''` → "刻度掐头去尾"看着像没做(白查三轮)。
· 日历本要挑 `kind='custom'`(我原以为"普通本没有 kind",实际**普通本就是 custom**)。
· 量"同高/同排"要拿**内容区**(去 padding)比,不能拿 border-box(`.topline` 有 14px 上内边距)。
· 判据不能把**边界上的正确行为**当 bug(22 点有日程时末刻度**就是** 23:00)。
· 「HTML 写了、CSS 没写」e2e 永远查不出来 —— 导航展示页的登录按钮就落到了左边
  (实测按钮右 116px / 整行右 1210px),只有真机量几何才抓得到。
· 反向验证里 e2e 会**重铺副本**把注入覆盖掉 → 给 e2e 加了 `FB_PATCH_FILE` 钩子
  (重铺完自动打补丁),13 例里 8 例"没牙齿"其实是这个假象。

════ 八、 新增/改动文件 ════
新增:`app/Nav.php` · `app/NavFetch.php` · `app/WeRead.php` · `nav_show.php` ·
`nav_suggest.php` · `templates/nav/index.php` · `templates/books/weread.php` ·
`assets/css/nav-show.css` · `assets/js/nav-show.js`
改动:`app/Schema.php`(v24 + 4 张 nav 表)· `app/Module.php`(+nav)· `app/Home.php` ·
`index.php` · `templates/_person_picker.php` · `assets/js/person_picker.js` ·
`templates/home.php` · `templates/ledger/index.php` · `templates/books/_settings_menu.php` ·
`assets/css/app.css` · `router.php` · `.htaccess`
This commit is contained in:
2026-10-07 05:52:40 +08:00
parent ae3060f552
commit 09e30d27d4
21 changed files with 5034 additions and 180 deletions
+17
View File
@@ -144,6 +144,23 @@ $ppHasSel = count(array_filter($ppSelected)) > 0;
<div class="pp-empty" hidden>没有匹配的人。</div>
</div>
<?php /* 🔴🔴 **承运字段**(v24 第 24 批 用户第 1 条修的真 bug)—— 别删!
未在弹框里用过时,提交表单**必须**把"当前已选"一起带上。 */ ?>
<?php /* ⚠️ 我第一版在这里凭空写了 `<?= $ppName ?>_ver` / `_nickname` 两个"旁路字段",
以为 chips 会把它们一起送上去 —— 那是**猜的**,实际 chips 只渲染
`<span class="pp-chip" data-id>`,**一个 input 都不产生**。
已核实后才写成现在这样:按 $ppName 原样把已选 id 各写一个 hidden。
(教训:写代码前先把"实际的渲染产物"读出来,别照着自己脑补的结构写。) */ ?>
<?php if ($ppSelected): ?>
<?php foreach ($ppSelected as $ppSelId): ?>
<?php if ((int)$ppSelId > 0): ?>
<input type="hidden" class="pp-carry"
name="<?= e($ppName) ?>" value="<?= (int)$ppSelId ?>">
<?php endif; ?>
<?php endforeach; ?>
<?php unset($ppSelId); ?>
<?php endif; ?>
<?php /* 无 JS 兜底:启用脚本时这段只是**纯文本**(HTML5 规定),
不会变成 DOM 节点、也就不会造成上面的膨胀;
禁用脚本时才渲染出来,选人照样能用。 */ ?>
+5
View File
@@ -21,6 +21,9 @@ $bkSetIcon = static function (string $name): string {
'stats' => '<path d="M3 13V9M6.5 13V5M10 13V7.5M13.5 13V3"/>',
// 导入笔记:一支笔写在纸上
'import' => '<path d="M9.5 2.5l4 4L5 15H2v-3z"/><path d="M8 4l4 4"/>',
// 微信读书授权(v24 第 24 批):钥匙
'weread' => '<path d="M6.8 9.2a2.6 2.6 0 1 0 0-.1z"/><path d="M8.6 7.4l5-5"/>'
. '<path d="M11.4 4.6l1.5 1.5M12.9 3.1l1.5 1.5"/>',
];
$d = $paths[$name] ?? '';
if ($d === '') {
@@ -36,6 +39,8 @@ $bkSetIcon = static function (string $name): string {
<div class="menu-list">
<a href="<?= e(url('books/stats')) ?>"<?= $bkSetOn('stats') ?>><?= $bkSetIcon('stats') ?><span>读书统计</span></a>
<a href="<?= e(url('books/notes_import')) ?>"<?= $bkSetOn('import') ?>><?= $bkSetIcon('import') ?><span>导入读书笔记</span></a>
<?php /* v24 第 24 批 用户第 5 条:微信读书(官方 API Key 授权,能直接读划线与想法) */ ?>
<a href="<?= e(url('books/weread')) ?>"<?= $bkSetOn('weread') ?>><?= $bkSetIcon('weread') ?><span>微信读书导入</span></a>
</div>
</details>
<?php
+167
View File
@@ -0,0 +1,167 @@
<?php
/**
* 微信读书导入(v24 第 24 批 用户第 5 条)。
*
* 两种导入方式,**这里管的是"官方 API Key"那一种**:
* ① 手动粘贴 —— 见 `books/notes_import`(早已有,App 里「导出笔记」复制出来即可);
* ② 官方授权 —— 本页:填一次微信读书开放平台的 API Key(`wrk-` 开头),
* 之后系统就能直接读你的书架笔记,选中要导的书一键导入。
*
* 用户原话:「你看一下能不能导入微信的读书笔记,就是每一个人设置一下,比如说扫一下
* 微信的这个码呀,或者什么之类的授权一下」→ 后续明确「用微信读书 api key」。
* ⚠️ 官方在 2026 年开了开放平台,走**扫码登录后一键生成 API Key**(`wrk-` 前缀),
* 不再需要早期那种"复制 cookie"的野路子。Key 的获取页就在本页给出。
*
* ⚠️ Key 是**账号凭据**(官方原话:等价于账号凭证,泄露等于账号泄露):
* 所以界面上**只回显掩码**(`wrk-********abcd`),完整值只在提交那一刻存在。
*
* @param array|null $preview 「拉取预览」的结果(每本书 + 前几条笔记)
* @param int $nbCount 预览时一共发现多少本有笔记的书
*/
$preview = $preview ?? null;
$nbCount = (int)($nbCount ?? 0);
$me = (int)\App\Auth::personId();
$hasKey = \App\WeRead::hasKey($me);
$masked = \App\WeRead::maskedKey($me);
$canWrite = \App\Auth::canWrite();
?>
<div class="page-head">
<h1>微信读书导入</h1>
<span class="sub">填一次 API Key,之后就能直接把你微信读书里的划线与想法导进来</span>
<div class="actions">
<?php $bkSetCurrent = 'import'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<?php /* ==================== ① 授权(填 API Key)==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
授权
<?php if ($hasKey): ?>
<span class="node-badge">已授权</span>
<?php else: ?>
<span class="node-badge">还没授权</span>
<?php endif; ?>
</h2>
<p class="small muted" style="margin:0 0 12px">
去 <a href="https://weread.qq.com/r/weread-skills" target="_blank" rel="noopener">微信读书开放平台</a>
扫码登录,页面上点「获取 API Key」,会得到一串 <code class="mono">wrk-</code> 开头的字符串,
复制到下面就行。
</p>
<p class="tip" style="background:#FFF6E5;border-color:#E8C98A;padding:8px 12px;border-radius:8px">
<b>这串 Key 等于你的账号凭据</b> —— 我们会<b>加密存放</b>,页面上也只显示掩码;
不想再用时,把它清空保存即可(或去开放平台重置那串 Key)。
</p>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('books/weread')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="key_save">
<div class="field">
<label>微信读书 API Key</label>
<input class="input mono" name="api_key" autocomplete="off"
placeholder="<?= $hasKey ? e($masked) . '(已保存,留空并保存 = 清除)' : 'wrk-xxxxxxxxxxxx' ?>">
<span class="tip">
<?php if ($hasKey): ?>
当前已保存:<b class="mono"><?= e($masked) ?></b>。要换就把新的整串贴进来(会覆盖)。
<?php else: ?>
形如 <code class="mono">wrk-NVbX…dgAA</code>,只在你自己的账号里可见。
<?php endif; ?>
</span>
</div>
<div class="btn-row" style="margin-top:0">
<button class="btn" type="submit">保存</button>
</div>
</form>
<?php else: ?>
<p class="empty-hint">只读账号不能配置导入。</p>
<?php endif; ?>
</div>
<?php /* ==================== ② 拉取预览(先看再导)====================
⚠️ 必须有这一步:直接"一键全导"会把别人(或自己多年前)的书一次性灌进书架,
想撤回只能一本本删。先给个清单、让用户自己勾,是最省事也最不容易后悔的做法。 */ ?>
<?php if ($canWrite && $hasKey): ?>
<div class="card card-pad">
<h2 class="cinema-h2">从微信读书取数据</h2>
<form method="post" action="<?= e(url('books/weread')) ?>" class="inline-form">
<?= csrf_field() ?>
<input type="hidden" name="act" value="fetch">
<div class="btn-row" style="margin-top:0">
<button class="btn btn-sm" type="submit">拉取我的笔记清单</button>
</div>
<p class="tip" style="margin-top:6px">
会去微信读书取一次数据(你有笔记的书 + 每本书的划线/想法),可能要十几秒。
</p>
</form>
</div>
<?php endif; ?>
<?php if ($preview !== null): ?>
<div class="card card-pad">
<h2 class="cinema-h2">
发现 <?= (int)$nbCount ?> 本有笔记的书
<span class="hint">勾选要导入的,然后点下面的「导入选中」</span>
</h2>
<?php if (!$preview): ?>
<p class="empty-hint">微信读书里还没有带笔记的书。</p>
<?php else: ?>
<form method="post" action="<?= e(url('books/weread')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="import">
<div class="wr-list">
<?php foreach ($preview as $p): ?>
<label class="wr-book">
<input type="checkbox" name="pick[]" value="<?= e((string)$p['bookId']) ?>"
<?= (int)$p['count'] > 0 ? 'checked' : '' ?>>
<span class="wr-main">
<span class="wr-title"><?= e((string)$p['title']) ?></span>
<?php if ((string)$p['author'] !== ''): ?>
<span class="small muted"><?= e((string)$p['author']) ?></span>
<?php endif; ?>
<span class="node-badge"><?= (int)$p['count'] ?> 条</span>
<?php if ((int)$p['local'] > 0): ?>
<span class="node-badge">书架里已有</span>
<?php else: ?>
<span class="node-badge">会新建</span>
<?php endif; ?>
</span>
<?php if (!empty($p['items'])): ?>
<span class="wr-sample">
<?php foreach ($p['items'] as $it): ?>
<span class="wr-q">
<?php if ((string)($it['quote'] ?? '') !== ''): ?>
<i><?= e(mb_substr((string)$it['quote'], 0, 60, 'UTF-8')) ?></i>
<?php endif; ?>
<?php if ((string)($it['content'] ?? '') !== ''): ?>
<em><?= e(mb_substr((string)$it['content'], 0, 60, 'UTF-8')) ?></em>
<?php endif; ?>
</span>
<?php endforeach; ?>
</span>
<?php endif; ?>
</label>
<?php endforeach; ?>
</div>
<div class="btn-row">
<button class="btn" type="submit">导入选中</button>
</div>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
<?php /* ==================== ③ 另一条路:手动粘贴 ====================
⚠️ 两种方式都留着,不是重复:
· API Key 走"全自动",但要求用户愿意把 Key 交出来;
· 粘贴走"零授权",App 里「导出笔记」复制一段文字即可。
用户可能两个都用(比如只想导某一本书的几段)。 */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">或者:手动粘贴笔记</h2>
<p class="small muted" style="margin:0 0 10px">
不想配 Key 也行 —— 在微信读书 App 里打开一本书 → 右上角「…」→ 导出笔记,
把复制到的文字粘到那里,效果一样。
</p>
<div class="btn-row" style="margin-top:0">
<a class="btn btn-ghost btn-sm" href="<?= e(url('books/notes_import')) ?>">去粘贴导入</a>
</div>
</div>
+224 -142
View File
@@ -1,21 +1,44 @@
<?php
/**
* 首页 —— 一块 dashboard(用户 2026-10-01 第 13 条)。
* 首页 —— 一块 dashboard。
*
* 用户 2026-10-03 第 3 条把它重排成:
* · hero:标题 + 个人 slogan + 两个图标按钮(我的资料 / 我的家谱)
* · 统计信息:随机问候语 + 今日天气 + 近七天日程数 / 待办数
* · 未来七天的日程(自定义 + 观影 + 旅行 + 有截止时间的任务,一次汇总)
* · 手头在做(进行中 + 已逾期的任务)
* · 各模块的统计与模块内信息(**可按人在个人设置里开关与排序**)
* · 往年今日
* ⚠️ 「接下来的日子(两个月内的生日)」已去掉:生日/纪念日本来就是日程,
* 已经在「未来七天」里,两块并排是重复。
* ────────────────────────────────────────────────────────────────
* v24 第 24 批 用户第 2、3 条(本次重排,用户原话):
*
* ⚠️ **家人卡片已经不在首页了**(用户明确要求):看人去「名册」、看关系去「家谱」,
* 首页留给"今天该关心什么"。统计数字仍在,因为那是唯一的全局概览。
* 第 2 条:「首页上的功能…一个就是上面的简要信息,这样去写。早上好,中午好,
* 或者晚上好,某某某今天是几月几日?星期几农历几月几日?如果有节气的
* 话,或者有重要的节日的话,在这里显示。比如说今天是这个国庆节啊,
* 今天是端午节,或者是今天是中秋节,或者是今天是芒种,今天是大寒,
* 今天是小寒,今天是什么纪念日,今天是谁的生日等等。显示在这里
* 紧接着它的下面显示今天一共有多少条日程,今天有几件代办正在进行。
* 把天气放在这个信息面板的最右侧,以图标的形式,彩色图标的形式去进行
* 展示。比如说晴天多云下雨,然后下面显示呃温度、风力等等,在最下面
* 再显示呃地址。就是比如说他选中的是他在个人设置里面设置的是青岛,
* 那么就显示山东青岛就是让它放在这个信息面板的最右侧,靠右放。」
*
* ⚠️ 标题下的那句个人口号已按用户要求撤掉(v8):hero 区只留标题与三个统计。
* 第 3 条:「家里的镜框这里呃全部不要了…紧接着直接显示日程和任务日程和任务在
* 整个页面里面左右,并排放各占一半,日程就显示今天的日程,也就是
* 日视图那样形式,为了好看高度需要固定。这样的话,鼠标放上去之后,
* 向下可以滚动,暂时不要出现滚动条太丑了。为了节省上下滚动的这个空间,
* 我们可以把没有日程的那些时间掐头去尾给它去掉。…同样的和日视图一样,
* 需要在当前的时间或者是时刻那里显示一条红线。右侧显示任务模块里面的
* 数据显示当前正在进行当中的所有的任务,除了显示任务的标题之外,
* 还要显示他的状态。比如说正在进行中待确认等等。除了这之外,
* 还要显示他所处的看板的名称,就是他现在被放在哪一个看板里面,
* 就后最下面显示的是往年的今天这个模块。」
*
* ⇒ 所以现在首页自上而下只有三块:
* ① 信息面板(问候 + 日子标记 + 今日数字 | 最右:彩色天气图标 + 温度风力 + 地址)
* ② 日程 / 任务 **左右各半**(各自固定高度、内部滚动、不显滚动条)
* ③ 往年的今天
*
* ⚠️ 被删掉的(用户明确说"全部不要了"):
* · 「家里的近况」模块卡片墙(moduleCards)—— 那张一片一片的统计卡
* · 「未来七天」清单 —— 与左侧「今天日程」重复
* 两块都不再渲染。服务端仍照旧算 moduleCards(个人设置里那个开关还留着),
* 但首页不再用 —— 留着它是为了"以后要恢复"时不用动后端。
*
* ⚠️ 「家人卡片」「两个月内的生日」早先就已按用户要求撤掉,本批不变。
*/
// 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出
$today = date('Y-m-d');
@@ -24,6 +47,7 @@ $homeMods = array_values(array_filter(
(array)($homeModules ?? []),
static fn($k) => \App\Module::exists((string)$k)
));
$todayMarks = $todayMarks ?? ['tags' => [], 'line' => ''];
?>
<?php /* 用户 2026-10-03 第 3 条:
① 标题下显示**个人 slogan**(原来撤掉了,现在要回来);
@@ -56,7 +80,7 @@ $homeMods = array_values(array_filter(
<a class="icon-btn" href="<?= e(url('kin')) ?>"
title="我的家谱" aria-label="我的家谱">
<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="7.4" y="2.2" width="5.2" height="4.2" rx="1"/>
<rect x="2.2" y="13.6" width="5.2" height="4.2" rx="1"/>
<rect x="12.6" y="13.6" width="5.2" height="4.2" rx="1"/>
@@ -66,151 +90,209 @@ $homeMods = array_values(array_filter(
</div>
</section>
<?php /* 用户 2026-10-03 第 3 条:「接下来的日子(两个月内)」整块**去掉** ——
它列的是生日,而生日/纪念日本来就是日程,已经在下面的「未来七天」里出现过,
两块并排是重复信息。生日的"提前提醒"由日历与纪念日模块负责。 */
?>
<?php /* ==================== 统计信息(用户第 3 条) ====================
一句话问候 + 今天的天气 + 几个数字。放在最上面,因为它回答的是
"今天是个什么日子、我该关心什么"——比下面任何一个列表都更该先看到。 */ ?>
<?php /* ==================== ① 信息面板(用户第 2 条)====================
左:问候 + 姓名 → 日期/星期/农历 → 日子标记(节气/节日/纪念日/生日)→ 今日数字
右:**彩色**天气图标 + 温度 + 风力 → 地址
⚠️ 天气靠右:用户原话「把天气放在这个信息面板的最右侧…靠右放」。
用 flex + margin-left:auto 把它顶到右端(窄屏会自动折到下一行,见 CSS)。 */ ?>
<section class="section">
<div class="card card-pad home-brief">
<div class="home-brief-hello">
<b><?= e((string)($greeting ?? '')) ?></b>
<span class="muted small"><?= e(date('n 月 j 日')) ?> <?= e((string)($weekday ?? '')) ?></span>
<div class="hb-left">
<?php /* ① 问候(早上好/中午好…)+ 我的称呼 —— 问候语里已经带了名字,
但用户明确要求「早上好,某某某」这样的形式,所以名字单独再强调一次。
(Home::greeting 本身会拼 "早上好,老杨,今天有 3 件事",
这里只用它的"时段"部分,尾巴换成面板里更该看的信息。) */ ?>
<?php
$hbPart = '你好';
$hbH = (int)date('G');
if ($hbH < 5) { $hbPart = '夜深了'; }
elseif ($hbH < 11) { $hbPart = '早上好'; }
elseif ($hbH < 13) { $hbPart = '中午好'; }
elseif ($hbH < 18) { $hbPart = '下午好'; }
else { $hbPart = '晚上好'; }
?>
<div class="hb-hello">
<b><?= e($hbPart) ?></b><?php if (trim((string)($meName ?? '')) !== ''): ?>,<?= e((string)$meName) ?><?php endif; ?>
</div>
<div class="hb-date">
<span class="hb-d"><?= e((string)($todayLabel ?? date('n 月 j 日'))) ?></span>
<span class="hb-w"><?= e((string)($weekday ?? '')) ?></span>
<?php if (($lunarShort ?? '') !== ''): ?>
<span class="hb-lunar" title="<?= e((string)($lunarLine ?? '')) ?>"><?= e((string)$lunarShort) ?></span>
<?php endif; ?>
</div>
<?php if (!empty($todayMarks['tags'])): ?>
<div class="hb-marks">
<?php foreach ($todayMarks['tags'] as $mk): ?>
<span class="hb-mark is-<?= e((string)$mk['kind']) ?>"><?= e((string)$mk['word']) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="hb-stats">
<a class="hbs" href="<?= e(url('calendar')) ?>">
<b><?= count($todayEvents ?? []) ?></b> 条日程(今天)
</a>
<a class="hbs" href="<?= e(url('tasks')) ?>">
<b><?= (int)($stat7['todos'] ?? 0) ?></b> 件待办
</a>
<?php if ((int)($stat7['overdue'] ?? 0) > 0): ?>
<a class="hbs is-late" href="<?= e(url('tasks')) ?>">
<b><?= (int)$stat7['overdue'] ?></b> 件逾期
</a>
<?php endif; ?>
</div>
</div>
<?php if (!empty($weather)): ?>
<div class="home-brief-weather">
<span class="w-icon" aria-hidden="true"><?= e((string)($weather['icon'] ?? '')) ?></span>
<span class="w-temp"><?= e((string)($weather['temp'] ?? '')) ?></span>
<span class="w-desc"><?= e((string)($weather['text'] ?? '')) ?></span>
<div class="hb-weather is-<?= e((string)($weather['tone'] ?? 'cloud')) ?>">
<span class="hbw-icon" aria-hidden="true"><?= e((string)($weather['icon'] ?? '')) ?></span>
<div class="hbw-main">
<div class="hbw-temp"><?= e((string)($weather['temp'] ?? '')) ?></div>
<div class="hbw-text"><?= e((string)($weather['text'] ?? '')) ?></div>
<?php if (($weather['wind'] ?? '') !== ''): ?>
<div class="hbw-wind">
<?= $weather['wdir'] !== '' ? e((string)$weather['wdir']) . ' ' : '' ?><?= e((string)$weather['wind']) ?>
</div>
<?php endif; ?>
</div>
<?php if (($weather['place'] ?? '') !== ''): ?>
<span class="muted small"><?= e((string)$weather['place']) ?></span>
<div class="hbw-place"><?= e((string)$weather['place']) ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="home-brief-stats">
<span class="hbs"><b><?= (int)($stat7['events'] ?? 0) ?></b> 条日程(近七天)</span>
<span class="hbs"><b><?= (int)($stat7['todos'] ?? 0) ?></b> 件待办</span>
<span class="hbs"><b><?= (int)($stat7['overdue'] ?? 0) ?></b> 件逾期</span>
</div>
</div>
</section>
<?php /* ==================== 各模块统计(用户第 3 条) ====================
每个模块给一个"一眼看得懂的小结"。用户在个人设置里能挑显示哪些、以及顺序。 */ ?>
<?php if ($homeMods && !empty($moduleCards)): ?>
<section class="section">
<div class="page-head" style="margin-bottom:12px">
<h1 style="font-size:15px">家里的近况</h1>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('settings/me')) ?>#home-mods">调整显示</a>
<?php /* ==================== ② 日程 / 任务 左右各半(用户第 3 条)====================
⚠️ 高度**固定**(CSS 里给 .hd-pane-body 一个 max-height):
用户原话「为了好看高度需要固定。这样的话,鼠标放上去之后,向下可以滚动,
暂时不要出现滚动条太丑了」——
所以是 `overflow-y:auto` + 隐藏滚动条(`scrollbar-width:none` 等三套写法)。
⚠️ 日程掐头去尾:见下面 $hr 的算法 —— 只画"第一个日程前一小时"到"最后一个日程后一小时",
中间空档不裁(裁了会让人以为日程连在一起)。全天日程永远在顶上。 */ ?>
<?php
// ---- 日程时间轴:掐头去尾(用户第 3 条"没有日程的那些时间掐头去尾给它去掉")----
$allDay = [];
$timed = [];
foreach (($todayEvents ?? []) as $ev) {
if ((string)($ev['time'] ?? '') === '') { $allDay[] = $ev; }
else { $timed[] = $ev; }
}
$startH = 0; $endH = 24;
if ($timed) {
$mins = array_map(static function (array $e): int {
$p = explode(':', (string)$e['time']);
return ((int)($p[0] ?? 0)) * 60 + (int)($p[1] ?? 0);
}, $timed);
// 前后各留一小时:紧了会让人以为"到点就结束了",松了又浪费高度
$startH = max(0, intdiv(min($mins), 60) - 1);
$endH = min(24, intdiv(max($mins), 60) + 2); // +2 = 跨过那一小时再收尾
if ($endH <= $startH) { $endH = min(24, $startH + 2); }
}
$nowH = (int)date('G');
$nowPos = ($nowH - $startH) / max(1, ($endH - $startH)); // 红线位置(0~1)
$showNow = $nowPos >= 0 && $nowPos <= 1;
?>
<section class="section">
<div class="home-duo">
<?php /* ---- 左:今天的日程(日视图形式)---- */ ?>
<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>
</div>
<div class="hd-pane-body">
<?php if (!$todayEvents): ?>
<p class="empty-hint">今天没有安排。</p>
<?php else: ?>
<?php if ($allDay): ?>
<div class="ag-allday">
<?php foreach ($allDay as $ev): ?>
<a class="ag-allday-item" href="<?= e((string)$ev['href']) ?>">
<span class="dash-dot" style="background:<?= e((string)$ev['color']) ?>;"></span>
<span class="ag-ad-t">全天</span>
<span class="ag-ad-w"><?= e((string)$ev['title']) ?></span>
<span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="ag-grid">
<?php for ($h = $startH; $h < $endH; $h++): ?>
<div class="ag-row">
<span class="ag-hour"><?= sprintf('%02d:00', $h) ?></span>
<span class="ag-line"></span>
</div>
<?php endfor; ?>
<?php /* 当前时刻红线:用户原话「需要在当前的时间或者是时刻那里显示一条红线」 */ ?>
<?php if ($showNow): ?>
<div class="ag-now" style="top: <?= number_format($nowPos * 100, 2) ?>%">
<span class="ag-now-dot"></span>
</div>
<?php endif; ?>
<?php /* 日程块:按"相对第一个刻度的小时数"定位(与日视图同一套比例)---- */ ?>
<?php foreach ($timed as $ev): ?>
<?php
$p = explode(':', (string)$ev['time']);
$evMin = ((int)($p[0] ?? 0)) * 60 + (int)($p[1] ?? 0);
$topPct = (($evMin / 60) - $startH) / max(1, ($endH - $startH)) * 100;
$topPct = max(0, min(96, $topPct));
?>
<a class="ag-ev" href="<?= e((string)$ev['href']) ?>"
style="top: <?= number_format($topPct, 2) ?>%; border-left-color: <?= e((string)$ev['color']) ?>;">
<span class="ag-ev-t"><?= e((string)$ev['time']) ?></span>
<span class="ag-ev-w"><?= e((string)$ev['title']) ?></span>
<span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<div class="mod-cards">
<?php foreach ($homeMods as $mk): ?>
<?php $mc = $moduleCards[$mk] ?? null; if (!$mc) { continue; } ?>
<a class="mod-card" href="<?= e((string)$mc['href']) ?>"
style="--mc: <?= e((string)($mc['color'] ?? '#888')) ?>">
<div class="mod-card-head">
<span class="mod-card-icon"><?= $mc['icon'] /* 行内 SVG,自己人写的,不转义 */ ?></span>
<span class="mod-card-name"><?= e((string)$mc['name']) ?></span>
</div>
<?php if (($mc['big'] ?? '') !== ''): ?>
<div class="mod-card-big"><?= e((string)$mc['big']) ?></div>
<?php endif; ?>
<?php if (!empty($mc['lines'])): ?>
<ul class="mod-card-lines">
<?php foreach ($mc['lines'] as $ln): ?>
<li><?= e((string)$ln) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php /* ==================== 日程(未来七天) ==================== */ ?>
<section class="section">
<div class="page-head" style="margin-bottom:12px">
<h1 style="font-size:15px">未来七天 <span class="sub">日程 · 观影 · 旅行 · 到期任务</span></h1>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('calendar')) ?>">看整月</a>
<?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>
</div>
<div class="hd-pane-body">
<?php if (!$tasks): ?>
<p class="empty-hint">没有进行中的任务。</p>
<?php else: ?>
<ul class="dash-list tk-list">
<?php foreach ($tasks as $t): ?>
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
<li class="dash-row tk-row">
<span class="dash-dot" style="background: <?= e($late ? 'var(--red)' : 'var(--brand)') ?>;"></span>
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>">
<?= e((string)$t['title']) ?>
</a>
<?php /* 用户第 3 条:「除了显示任务的标题之外,还要显示他的状态。
比如说正在进行中待确认等等。」 */ ?>
<span class="kb-st is-<?= e((string)($t['status'] ?? 'todo')) ?> is-static">
<?= e(\App\Task::statusWord((string)($t['status'] ?? 'todo'))) ?>
</span>
<?php /* 用户第 3 条:「还要显示他所处的看板的名称,就是他现在被放在哪一个看板里面」 */ ?>
<?php if (trim((string)($t['board_name'] ?? '')) !== ''): ?>
<span class="tk-board"><?= e((string)$t['board_name']) ?></span>
<?php endif; ?>
<?php if ((int)($t['child_total'] ?? 0) > 0): ?>
<span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span>
<?php endif; ?>
<?php if (($t['due_at'] ?? '') !== ''): ?>
<span class="tk-due"><?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
<?php if (!$soon): ?>
<div class="card card-pad empty"><b>这七天还没有安排</b>有事先记一笔,日后再翻才有得看。</div>
<?php else: ?>
<div class="card card-pad">
<ul class="dash-list">
<?php foreach ($soon as $ev): ?>
<li class="dash-row">
<span class="dash-dot" style="background: <?= e((string)$ev['color']) ?>;"></span>
<span class="dash-when">
<?= e((string)$ev['date'] === $today ? '今天' : substr((string)$ev['date'], 5)) ?>
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b><?php endif; ?>
</span>
<a class="dash-what" href="<?= e((string)$ev['href']) ?>"><?= e((string)$ev['title']) ?></a>
<?php /* 用户 2026-10-04 第 11 批 #1:「右侧的日历本,现在看不清,
应该用一个色块,颜色就用日历的颜色,文字是日历本的文字,用白色。」
原来是 .dash-tag(--faint 灰色 11px 小字),在浅色纸上几乎看不见 ——
现在做成实心色块:底色 = 这本日历的颜色,字 = 白,一眼能对上左侧那个小圆点。 */ ?>
<span class="dash-cal"
style="background: <?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</section>
<?php /* ==================== 进行中的任务 ==================== */ ?>
<section class="section">
<div class="page-head" style="margin-bottom:12px">
<h1 style="font-size:15px">手头在做 <span class="sub">进行中 · 已逾期</span></h1>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('tasks')) ?>">任务看板</a>
</div>
</div>
<?php if (!$tasks): ?>
<div class="card card-pad empty"><b>没有进行中的任务</b>也没有逾期的事,挺好。</div>
<?php else: ?>
<div class="card card-pad">
<ul class="dash-list">
<?php foreach ($tasks as $t): ?>
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
<li class="dash-row">
<span class="dash-dot" style="background: <?= e($late ? 'var(--red)' : 'var(--brand)') ?>;"></span>
<span class="dash-when">
<?php if (($t['due_at'] ?? '') !== ''): ?>
<?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?>
<?php else: ?>
进行中
<?php endif; ?>
</span>
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>">
<?= e((string)$t['title']) ?>
</a>
<?php /* v24 第 14 批 用户第 2 条:首页这一块是**按状态**捞出来的
(进行中 + 已逾期的待办,跨看板),所以把状态标出来 ——
否则"待办为什么出现在这里"看不出来。 */ ?>
<span class="kb-st is-<?= e((string)($t['status'] ?? 'todo')) ?> is-static">
<?= e(\App\Task::statusWord((string)($t['status'] ?? 'todo'))) ?>
</span>
<?php if ((int)($t['child_total'] ?? 0) > 0): ?>
<span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</section>
<?php /* ==================== 往年今日 ==================== */ ?>
<?php /* ==================== ③ 往年的今天 ==================== */ ?>
<section class="section">
<div class="page-head" style="margin-bottom:12px">
<?php /* v24 第 19 条:只认「往年的今天」,不再退回同月 —— 见 home_memories() 的说明。 */ ?>
@@ -240,4 +322,4 @@ $homeMods = array_values(array_filter(
</ul>
</div>
<?php endif; ?>
</section>
</section>
+21 -3
View File
@@ -602,16 +602,34 @@ $ledMe = (int)($me ?? 0);
}
// ③ 多线图:按类别显隐线条(几何不动,只切可见性)
//
// 🔴🔴 v24 第 24 批 用户第 6 条修的真 bug(我上一批引入的):
// 这里原来写的是 `g.hidden = ...` —— **对 SVG 元素完全无效**。
// `hidden` 是 **HTML 元素**的属性(映射到 `[hidden]{display:none}`);
// `<g>` 是 SVG 元素,它身上根本没有 `hidden` 这个 IDL 属性,
// 赋值只是往对象上挂了一个**谁都不看的 JS 属性** → 线条一条都没被藏起来。
// 用户原话:「选了存款,它本来有五条线,但是这还是显示的所有的线。」
// 症状最迷惑的地方:第一张图(用 `w.hidden` 藏 **div**)**真的会换**,
// 所以看起来"筛选基本是好的,只是第二张图有点不太对"。
// ⇒ 正确的隐藏方式是 **`style.display`**(SVG 认这个)。
// 必须同时给 `g` 与 `li`/`tr` 用同一种写法:
// · `<g>` 是 SVG → 只能 display
// · `.ck-lg-item` / `<tr>` 是 HTML,两样都行;统一用 display 少一个特例。
// ⚠️ 反向也要对:`style.display = ''` 是"恢复默认",不是 `'block'` ——
// 给 `<g>` 写 display:block 会把 SVG 的显示模型搞乱(不同浏览器表现不一致)。
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-line]'), function (g) {
g.hidden = kind !== '' && (g.getAttribute('data-ck-line') || '') !== kind;
g.style.display = (kind !== '' && (g.getAttribute('data-ck-line') || '') !== kind)
? 'none' : '';
});
Array.prototype.forEach.call(document.querySelectorAll('.ck-lg-item'), function (li) {
li.hidden = kind !== '' && (li.getAttribute('data-ck-kind') || '') !== kind;
li.style.display = (kind !== '' && (li.getAttribute('data-ck-kind') || '') !== kind)
? 'none' : '';
});
// ④ 账目表
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-row]'), function (tr) {
tr.hidden = kind !== '' && (tr.getAttribute('data-ck-row') || '') !== kind;
tr.style.display = (kind !== '' && (tr.getAttribute('data-ck-row') || '') !== kind)
? 'none' : '';
});
// ⑤ 概览卡里的高亮条
+545
View File
@@ -0,0 +1,545 @@
<?php
/**
* 个人导航 · 管理页(v24 第 24 批 用户第 4 条,从 SimpleNav 的 admin 搬过来)
*
* ⚠️ 与 SimpleNav 的 admin 的区别(只有一处,但很要紧):
* 原项目是**单用户**系统,admin 直接改全局数据;
* 这里**每人一份**,所有操作都带 `person_id`,且**只能动自己的**。
* 路由层(index.php 的 nav/save)已经把 person_id 焊死在每个动作里 ——
* 本模板只负责把"我自己的"数据摆出来。
*
* ⚠️ 拖拽排序与开关走 AJAX(fetch + X-Requested-With),与 SimpleNav 的 admin.js 同一套;
* 其余表单是普通 POST(保存后 302 回本页,刷一下就看到结果,比 AJAX 简单可靠)。
*
* @param array $gridGroups 我的网格板块
* @param array $tabGroups 我的合集板块
* @param array $allGroups 我的全部板块(下拉用)
* @param array $sitesBy group_id => 站点列表
* @param array $engines 我的搜索引擎
* @param array $navSet 我的导航设置
* @param string $phrase 我的短语(空 = 还没设,展示页不可用)
* @param string $showUrl 展示页完整地址(短语为空时是空串)
* @param string $meAvatar 我的头像地址
*/
$canWrite = \App\Auth::canWrite();
$editSite = null;
$editGroup = null;
$editEngine = null;
// 点「编辑」时用 ?edit_site=12 / edit_group=3 / edit_engine=5 带出既有值
$eSite = (int)($_GET['edit_site'] ?? 0);
$eGrp = (int)($_GET['edit_group'] ?? 0);
$eEng = (int)($_GET['edit_engine'] ?? 0);
foreach ($sitesBy as $gid => $rows) {
foreach ($rows as $r) {
if ((int)$r['id'] === $eSite) { $editSite = $r; }
}
}
foreach ($allGroups as $g) {
if ((int)$g['id'] === $eGrp) { $editGroup = $g; }
}
foreach ($engines as $e) {
if ((int)$e['id'] === $eEng) { $editEngine = $e; }
}
$sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d);
?>
<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>
</div>
</div>
<?php /* ==================== ① 展示页地址与短语(用户第 4 条的"特殊地址")====================
用户原话:「给他一个非常特殊的地址…地址后面可以跟着用户添加的一个短语,就跟类似
于一个密钥一样,他可以给自己起一个短语…这个页面是不需要登录的。」
⚠️ 这卡片放在**最上面**:它是这个模块与其它模块最不一样的地方(免登录),
也最容易让人困惑("我的导航页在哪?怎么发给别人?")。 */ ?>
<div class="card card-pad" id="navLink">
<h2 class="cinema-h2">
我的导航页地址
<span class="hint">这个地址<b>不需要登录</b>,可以直接发给别人看</span>
</h2>
<?php if ($phrase !== ''): ?>
<div class="field">
<label>展示页地址</label>
<input class="input mono" readonly value="<?= e($showUrl) ?>">
<span class="tip">
短语 <b><?= e($phrase) ?></b> 就是这页的"钥匙"—— 换一个短语,旧地址立刻失效。
<?php /* 两种地址写法都支持:?q=xxx 与 ?xxx(见 nav_show.php 的说明) */ ?>
也支持写成 <code class="mono"><?= e(rtrim((string)fb_config('base_url', ''), '/')) ?>/nav/?<?= e($phrase) ?></code>。
</span>
</div>
<?php else: ?>
<p class="tip" style="background:#FFF6E5;border-color:#E8C98A;padding:8px 12px;border-radius:8px">
<b>还没设短语</b> —— 设一个短语之后,才能通过地址把导航页分享出去(那页不需要登录)。
</p>
<?php endif; ?>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('nav/save')) ?>" class="inline-form">
<?= csrf_field() ?>
<input type="hidden" name="act" value="phrase_save">
<div class="field">
<label>我的短语</label>
<input class="input mono" name="phrase" value="<?= e($phrase) ?>"
placeholder="如 yangyq(4~32 位字母/数字/下划线/连字符)" autocomplete="off">
<span class="tip">要 <b>4~32 位</b>,只能用字母、数字、下划线、连字符;不能与别人重复。</span>
</div>
<div class="btn-row" style="margin-top:0">
<button class="btn btn-sm" type="submit">保存短语</button>
</div>
</form>
<form method="post" action="<?= e(url('nav/save')) ?>" class="inline-form"
onsubmit="return confirm('换成一串随机短语?之前的地址会立刻失效。')">
<?= csrf_field() ?>
<input type="hidden" name="act" value="phrase_random">
<button class="btn btn-ghost btn-sm" type="submit">换一串随机的</button>
</form>
<?php endif; ?>
</div>
<?php /* ==================== ② 设置(照搬 SimpleNav 的 settings)==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">导航设置</h2>
<form method="post" action="<?= e(url('nav/save')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="settings_save">
<div class="form-grid">
<div class="field">
<label>站点名</label>
<input class="input" name="site_name" value="<?= e($sc('site_name', '我的导航')) ?>">
<span class="tip">展示页最上方的大标题。</span>
</div>
<div class="field">
<label>副标题</label>
<input class="input" name="subtitle" value="<?= e($sc('subtitle')) ?>" placeholder="留空就不显示">
<?php /* ⚠️ SimpleNav 原文把"副标题清空后仍然显示"单列为 bug 现场,
这里保留它的行为:**空就是空**,不回退成默认文案。 */ ?>
<span class="tip">留空就<b>不显示</b>(不会自动填回默认值)。</span>
</div>
<div class="field">
<label>主题色</label>
<input class="input" type="color" name="primary_color"
value="<?= e(\App\Nav::safeColor($sc('primary_color', '#2F6BF7'), '#2F6BF7')) ?>"
style="height:38px;padding:3px">
</div>
<div class="field">
<label>默认搜索引擎</label>
<select class="select" name="default_engine">
<?php foreach ($engines as $en): ?>
<option value="<?= e((string)$en['slug']) ?>"
<?= (string)$en['slug'] === $sc('default_engine') ? 'selected' : '' ?>>
<?= e((string)$en['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>链接打开方式</label>
<select class="select" name="open_blank">
<option value="1" <?= $sc('open_blank', '1') === '1' ? 'selected' : '' ?>>新标签页</option>
<option value="0" <?= $sc('open_blank', '1') === '0' ? 'selected' : '' ?>>当前页面</option>
</select>
</div>
<div class="field">
<label>联想词按键</label>
<select class="select" name="suggest_keys">
<option value="both" <?= $sc('suggest_keys', 'both') === 'both' ? 'selected' : '' ?>>↓/↑ 与 , . 都认(推荐)</option>
<option value="arrows" <?= $sc('suggest_keys', 'both') === 'arrows' ? 'selected' : '' ?>>只认 ↓/↑</option>
<option value="punct" <?= $sc('suggest_keys', 'both') === 'punct' ? 'selected' : '' ?>>只认 , 和 .</option>
</select>
<span class="tip">在展示页的搜索框里,用哪组键选中联想词。</span>
</div>
<div class="field">
<label>合集显示方式</label>
<select class="select" name="collections_layout">
<option value="tabs" <?= $sc('collections_layout', 'tabs') === 'tabs' ? 'selected' : '' ?>>标签页折叠(一次看一个)</option>
<option value="flat" <?= $sc('collections_layout', 'tabs') === 'flat' ? 'selected' : '' ?>>全部平铺(一眼看全)</option>
</select>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit">保存设置</button>
<?php if ($canWrite): ?>
<button class="btn btn-ghost" type="submit" formaction="<?= e(url('nav/save')) ?>"
name="act" value="fetch_all"
onclick="return confirm('会把所有站点的名称和图标重新抓一遍,可能比较慢。自己传过图标的不会被覆盖。继续?')">
全部更新(抓名称与图标)
</button>
<?php endif; ?>
</div>
</form>
</div>
<?php /* ==================== ③ 站点:新增/编辑 ==================== */ ?>
<?php if ($canWrite): ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= $editSite ? '编辑站点' : '新增站点' ?></h2>
<form method="post" action="<?= e(url('nav/save')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="site_save">
<?php if ($editSite): ?>
<input type="hidden" name="id" value="<?= (int)$editSite['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label>网址 *</label>
<input class="input mono" name="url" required
value="<?= e((string)($editSite['url'] ?? '')) ?>" placeholder="https://example.com">
</div>
<div class="field">
<label>所属板块 *</label>
<select class="select" name="group_id" required>
<?php foreach ($allGroups as $g): ?>
<option value="<?= (int)$g['id'] ?>"
<?= (int)($editSite['group_id'] ?? 0) === (int)$g['id'] ? 'selected' : '' ?>>
<?= e((string)$g['name']) ?><?= (string)$g['display'] === 'tabs' ? '(合集)' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>显示名</label>
<input class="input" name="name" value="<?= e((string)($editSite['name'] ?? '')) ?>"
placeholder="留空会自动抓">
</div>
<div class="field">
<label>简称</label>
<input class="input" name="short_name" value="<?= e((string)($editSite['short_name'] ?? '')) ?>"
placeholder="页面上显示的字(优先于显示名)">
</div>
<div class="field full">
<label class="chk">
<input type="checkbox" name="is_visible" value="1" checked> 在展示页上显示
</label>
<?php if (!$editSite): ?>
<label class="chk">
<input type="checkbox" name="do_fetch" value="1" checked>
顺便抓取网站名与图标(联网,稍慢)
</label>
<?php endif; ?>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $editSite ? '保存' : '添加' ?></button>
<?php if ($editSite): ?>
<a class="btn btn-ghost" href="<?= e(url('nav')) ?>">取消</a>
<?php endif; ?>
</div>
</form>
</div>
<?php /* ==================== ④ 站点列表(按板块分组,可拖拽排序)==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
站点列表
<span class="hint">按住左边的 ⋮⋮ 可以拖动排序</span>
</h2>
<?php if (!$sitesBy): ?>
<p class="empty-hint">还没有站点。上面「新增站点」加一个吧。</p>
<?php else: ?>
<?php foreach ($allGroups as $g): ?>
<?php $rows = $sitesBy[(int)$g['id']] ?? []; if (!$rows) { continue; } ?>
<div class="nav-grp-block">
<h3 class="nav-sub"><?= e((string)$g['name']) ?>
<span class="small muted">(<?= count($rows) ?>)</span>
</h3>
<ul class="nav-sortlist" data-sort-list
data-reorder-url="<?= e(url('nav/save')) ?>"
data-group-id="<?= (int)$g['id'] ?>">
<?php foreach ($rows as $r): ?>
<li class="nav-sortrow<?= empty($r['is_visible']) ? ' is-off' : '' ?>"
data-id="<?= (int)$r['id'] ?>">
<span class="nav-drag" title="拖动排序">⋮⋮</span>
<?php $icon = \App\Nav::iconUrl((string)$r['icon']); ?>
<?php if ($icon !== ''): ?>
<img class="nav-ico" src="<?= e($icon) ?>" alt="">
<?php else: ?>
<span class="nav-ico nav-ico-letter"
style="background:<?= e(\App\Nav::letterColor(\App\Nav::siteLabel($r))) ?>">
<?= e(mb_substr(\App\Nav::siteLabel($r), 0, 1, 'UTF-8')) ?>
</span>
<?php endif; ?>
<span class="nav-sname"><?= e(\App\Nav::siteLabel($r)) ?></span>
<span class="nav-surl small muted"><?= e(\App\Nav::domainOf((string)$r['url'])) ?></span>
<?php if (empty($r['is_visible'])): ?><span class="node-badge">已隐藏</span><?php endif; ?>
<?php if (!empty($r['icon_custom'])): ?><span class="node-badge">自定义图标</span><?php endif; ?>
<span class="nav-acts">
<a class="btn btn-ghost btn-sm" href="<?= e(url('nav', ['edit_site' => (int)$r['id']])) ?>">编辑</a>
<button type="button" class="btn btn-ghost btn-sm"
data-nav-toggle="site" data-nav-id="<?= (int)$r['id'] ?>">
<?= empty($r['is_visible']) ? '显示' : '隐藏' ?>
</button>
<button type="button" class="btn btn-ghost btn-sm"
data-nav-del="site" data-nav-id="<?= (int)$r['id'] ?>"
data-nav-name="<?= e(\App\Nav::siteLabel($r)) ?>">删除</button>
</span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<?php /* ==================== ⑤ 板块 ==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= $editGroup ? '编辑板块' : '新增板块' ?></h2>
<form method="post" action="<?= e(url('nav/save')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="group_save">
<?php if ($editGroup): ?>
<input type="hidden" name="id" value="<?= (int)$editGroup['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label>板块名 *</label>
<input class="input" name="name" required value="<?= e((string)($editGroup['name'] ?? '')) ?>"
placeholder="如 常用 / 工具">
</div>
<div class="field">
<label>显示方式</label>
<select class="select" name="display">
<option value="grid" <?= (string)($editGroup['display'] ?? 'grid') === 'grid' ? 'selected' : '' ?>>普通网格(一直平铺)</option>
<option value="tabs" <?= (string)($editGroup['display'] ?? '') === 'tabs' ? 'selected' : '' ?>>合集标签页(折叠,点开才看)</option>
</select>
</div>
<div class="field full">
<label class="chk"><input type="checkbox" name="is_visible" value="1" checked> 在展示页上显示</label>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $editGroup ? '保存' : '添加' ?></button>
<?php if ($editGroup): ?>
<a class="btn btn-ghost" href="<?= e(url('nav')) ?>">取消</a>
<?php endif; ?>
</div>
</form>
</div>
<div class="card card-pad">
<h2 class="cinema-h2">板块列表 <span class="hint">拖动排序</span></h2>
<ul class="nav-sortlist" data-sort-list
data-reorder-url="<?= e(url('nav/save')) ?>">
<?php foreach ($allGroups as $g): ?>
<li class="nav-sortrow<?= empty($g['is_visible']) ? ' is-off' : '' ?>" data-id="<?= (int)$g['id'] ?>">
<span class="nav-drag" title="拖动排序">⋮⋮</span>
<span class="nav-sname"><?= e((string)$g['name']) ?></span>
<span class="node-badge"><?= (string)$g['display'] === 'tabs' ? '合集' : '网格' ?></span>
<span class="small muted"><?= count($sitesBy[(int)$g['id']] ?? []) ?> 个站点</span>
<?php if (empty($g['is_visible'])): ?><span class="node-badge">已隐藏</span><?php endif; ?>
<span class="nav-acts">
<a class="btn btn-ghost btn-sm" href="<?= e(url('nav', ['edit_group' => (int)$g['id']])) ?>">编辑</a>
<button type="button" class="btn btn-ghost btn-sm"
data-nav-toggle="group" data-nav-id="<?= (int)$g['id'] ?>">
<?= empty($g['is_visible']) ? '显示' : '隐藏' ?>
</button>
<button type="button" class="btn btn-ghost btn-sm"
data-nav-del="group" data-nav-id="<?= (int)$g['id'] ?>"
data-nav-name="<?= e((string)$g['name']) ?>">删除</button>
</span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php /* ==================== ⑥ 搜索引擎 ==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= $editEngine ? '编辑搜索引擎' : '新增搜索引擎' ?></h2>
<form method="post" action="<?= e(url('nav/save')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="engine_save">
<?php if ($editEngine): ?>
<input type="hidden" name="id" value="<?= (int)$editEngine['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label>名称 *</label>
<input class="input" name="name" required value="<?= e((string)($editEngine['name'] ?? '')) ?>">
</div>
<div class="field">
<label>标识</label>
<input class="input mono" name="slug" value="<?= e((string)($editEngine['slug'] ?? '')) ?>"
placeholder="留空自动生成">
</div>
<div class="field">
<label>搜索地址 *</label>
<input class="input mono" name="search_url" required
value="<?= e((string)($editEngine['search_url'] ?? '')) ?>"
placeholder="https://cn.bing.com/search">
</div>
<div class="field">
<label>关键词参数 *</label>
<input class="input mono" name="param"
value="<?= e((string)($editEngine['param'] ?? 'q')) ?>" placeholder="q">
<span class="tip">百度是 wd,搜狗是 query,多数是 q。</span>
</div>
<div class="field">
<label>联想接口(可选)</label>
<input class="input mono" name="suggest_url"
value="<?= e((string)($editEngine['suggest_url'] ?? '')) ?>"
placeholder="https://api.bing.com/osjson.aspx?query={q}">
<span class="tip">用 <code class="mono">{q}</code> 占位关键词;留空 = 这个引擎没有联想。</span>
</div>
<div class="field">
<label>联想类型</label>
<input class="input mono" name="suggest_type"
value="<?= e((string)($editEngine['suggest_type'] ?? '')) ?>"
placeholder="bing_osjson / baidu_sugrec / so360">
</div>
<div class="field">
<label>颜色</label>
<input class="input" type="color" name="color"
value="<?= e(\App\Nav::safeColor((string)($editEngine['color'] ?? ''), '#2F6BF7')) ?>"
style="height:38px;padding:3px">
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $editEngine ? '保存' : '添加' ?></button>
<?php if ($editEngine): ?>
<a class="btn btn-ghost" href="<?= e(url('nav')) ?>">取消</a>
<?php endif; ?>
</div>
</form>
</div>
<div class="card card-pad">
<h2 class="cinema-h2">搜索引擎列表</h2>
<ul class="nav-sortlist" data-sort-list data-reorder-url="<?= e(url('nav/save')) ?>">
<?php foreach ($engines as $en): ?>
<li class="nav-sortrow<?= empty($en['is_active']) ? ' is-off' : '' ?>" data-id="<?= (int)$en['id'] ?>">
<span class="nav-drag" title="拖动排序">⋮⋮</span>
<span class="nav-ico nav-ico-letter"
style="background:<?= e(\App\Nav::safeColor((string)$en['color'], '#2F6BF7')) ?>">
<?= e(mb_substr((string)$en['name'], 0, 1, 'UTF-8')) ?>
</span>
<span class="nav-sname"><?= e((string)$en['name']) ?></span>
<span class="small muted mono"><?= e((string)$en['slug']) ?></span>
<?php if (empty($en['is_active'])): ?><span class="node-badge">未启用</span><?php endif; ?>
<span class="nav-acts">
<a class="btn btn-ghost btn-sm" href="<?= e(url('nav', ['edit_engine' => (int)$en['id']])) ?>">编辑</a>
<button type="button" class="btn btn-ghost btn-sm"
data-nav-toggle="engine" data-nav-id="<?= (int)$en['id'] ?>">
<?= empty($en['is_active']) ? '启用' : '停用' ?>
</button>
<button type="button" class="btn btn-ghost btn-sm"
data-nav-del="engine" data-nav-id="<?= (int)$en['id'] ?>"
data-nav-name="<?= e((string)$en['name']) ?>">删除</button>
</span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<script>
/* 导航管理页的交互(v24 第 24 批)。
⚠️ 拖拽排序与开关走 AJAX(fetch + X-Requested-With),服务端据此回 JSON;
其余表单是普通 POST —— 保存后整页刷新,结果一眼可见,比 AJAX 可靠。
⚠️ 排序提交的是**整组 id 顺序**(SimpleNav 的做法:整组归一化,
每次拖完间隙都恢复整齐,之后新增仍天然排最后)。 */
(function () {
var TOKEN = (function () {
var el = document.querySelector('input[name="_csrf"]');
return el ? el.value : '';
})();
function post(data) {
var fd = new FormData();
fd.append('_csrf', TOKEN);
Object.keys(data).forEach(function (k) {
if (Array.isArray(data[k])) {
data[k].forEach(function (v) { fd.append(k + '[]', v); });
} else {
fd.append(k, data[k]);
}
});
return fetch(NAV_SAVE_URL, {
method: 'POST', body: fd, credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) { return r.json().catch(function () { return { ok: false, error: '服务器返回的不是 JSON' }; }); });
}
var NAV_SAVE_URL = <?= json_encode(url('nav/save'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
/* ---- 拖拽排序(原生 HTML5 DnD,不引库)---- */
Array.prototype.forEach.call(document.querySelectorAll('[data-sort-list]'), function (list) {
var dragEl = null;
list.addEventListener('dragstart', function (e) {
var row = e.target.closest ? e.target.closest('.nav-sortrow') : null;
if (!row) { return; }
dragEl = row;
row.classList.add('is-dragging');
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; }
});
list.addEventListener('dragover', function (e) {
if (!dragEl) { return; }
e.preventDefault();
var row = e.target.closest ? e.target.closest('.nav-sortrow') : null;
if (!row || row === dragEl) { return; }
var r = row.getBoundingClientRect();
var after = (e.clientY - r.top) > r.height / 2;
list.insertBefore(dragEl, after ? row.nextSibling : row);
});
list.addEventListener('dragend', function () {
if (!dragEl) { return; }
dragEl.classList.remove('is-dragging');
dragEl = null;
var ids = Array.prototype.map.call(list.querySelectorAll('.nav-sortrow'),
function (r) { return r.getAttribute('data-id'); });
post({
act: 'site_reorder', ids: ids,
group_id: list.getAttribute('data-group-id') || 0
}).then(function () { window.location.reload(); });
});
/* 让行可拖:整行 draggable,但只有按住 ⋮⋮ 才起拖(避免误拖) */
Array.prototype.forEach.call(list.querySelectorAll('.nav-sortrow'), function (row) {
var handle = row.querySelector('.nav-drag');
if (!handle) { return; }
handle.addEventListener('mousedown', function () { row.draggable = true; });
row.addEventListener('mouseup', function () { row.draggable = false; });
row.addEventListener('mouseleave', function () { row.draggable = false; });
});
});
/* ⚠️ 上面的排序对"方块列表"和"站点列表"是同一套,但两者提交的 act 不同:
站点那组带 data-group-id(属于某个板块),板块/引擎那组不带。
服务端据此区分 —— 这里补上"没有 group-id 时用 group_reorder / engine_reorder"。 */
Array.prototype.forEach.call(document.querySelectorAll('[data-sort-list]'), function (list) {
if (list.getAttribute('data-group-id') !== null) { return; } // 站点组,已在上面处理
// 用列表里第一行的线索判断是"板块"还是"引擎"
var isEngine = list.closest('.card') && /搜索引擎/.test(list.closest('.card').textContent);
var act = isEngine ? 'engine_reorder' : 'group_reorder';
list.addEventListener('dragend', function () {
var ids = Array.prototype.map.call(list.querySelectorAll('.nav-sortrow'),
function (r) { return r.getAttribute('data-id'); });
post({ act: act, ids: ids }).then(function () { window.location.reload(); });
});
});
/* ---- 显示/隐藏 与 删除(都走 AJAX)---- */
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var tg = t.closest('[data-nav-toggle]');
if (tg) {
e.preventDefault();
post({ act: tg.getAttribute('data-nav-toggle') + '_toggle',
id: tg.getAttribute('data-nav-id') })
.then(function () { window.location.reload(); });
return;
}
var del = t.closest('[data-nav-del]');
if (del) {
e.preventDefault();
var kind = del.getAttribute('data-nav-del');
var nm = del.getAttribute('data-nav-name') || '';
var warn = kind === 'group'
? '删掉板块「' + nm + '」?里面的站点会一起删掉。'
: '删掉「' + nm + '」?';
if (!window.confirm(warn)) { return; }
post({ act: kind + '_delete', id: del.getAttribute('data-nav-id') })
.then(function () { window.location.reload(); });
}
});
})();
</script>