Files
laoyang 69c47c3c43 网页端下拉框改成自绘浮层(照 FeedLite 的做法)(#85)
用户 2026-11-05:「网页版的下拉框也太丑了。你参考一下那个反馈系统里面的
下拉框到底是怎么做的?按它的做法做,它也有主题颜色,我们这个也有主题颜色,
就按照一样的做法去做。」

看了 FeedLite(~/code/FeedLite)的实现,关键结论:
  **原生 <select> 收起时能改样式,但展开的选项列表由浏览器绘制,CSS 完全改不动**
  (只能改 option 底色,各平台还不统一)⇒ 想好看只有自绘这条路。

照它的做法移植:

① CSS(assets/css/app.css)
   .sel-native  原生 select 视觉隐藏(**不能 display:none**,那样不参与提交)
   .sel         外层 inline-flex
   .sel-btn     触发按钮 —— 外观与普通输入框完全一致(40 高、同圆角同边框)
   .sel-caret   自绘箭头(CSS mask + 内联 SVG),**打开时转 180° 并变主题色**
   .sel-panel   浮层,position:fixed 挂 body
   .sel-item    列表项:34 高、圆角 7
   .sel-item.on 选中项:深一档主题底色 + 主题色文字 + 右侧对勾

② 主题色三档浅色(照 FeedLite 的比例)
   helpers.php 新增 fb_color_tint()(把 hex 往白色混)。
   layout.php 在 <body> 的 style 里输出:
     --brand-tint-1(混 91% 白)→ hover
     --brand-tint-2(混 80% 白)→ 选中项
     --brand-tint-3(混 72% 白)→ 选中项 hover
   ⚠️ 在 PHP 里算好,**不用 CSS 的 color-mix()** —— FeedLite 实测过:
      `color-mix(in srgb, var(--brand) 9%, #fff)` 在 Chrome 里解析成透明。

③ JS(assets/js/app.js 新增 fbBindSelects())
   遍历所有 select,在旁边生成按钮 + 浮层,原生 select 保留参与提交。
   · 派发原生 change → 兼容 onchange="this.form.submit()" 的内联写法
   · 浮层 fixed 定位(面板/浮层里大量 overflow:hidden,放旁边会被裁)
   · 下方空间不足且上方更宽裕时**向上翻**
   · 键盘:↑↓ / Home / End / Enter / Esc
   · 浮层 minWidth 跟随触发按钮 → "和输入框一样宽"
   · data-sel-enhanced 幂等标记

④ AJAX 路径也要增强
   task_panel.paint() 与 entry_popup.loadPanel() 里,紧跟 fbBootAntd() 之后
   调一次 window.fbBindSelects(document) —— 否则动态插入的 select 是裸的。

⚠️ app.js 整个是 IIFE 包裹的(结尾 `})();`),新函数必须插在闭包内部;
   直接追加到结尾会跑到全局作用域。

实测(Playwright + 站点真实 app.js/css):
  增强:data-sel-enhanced=1、原生 select 已隐藏、按钮文字「工作相关」✅
  尺寸:按钮 648 x 40,行宽 648 → 占满整行 ✅
  浮层:宽 648(与按钮同宽)✅ 6 项、选中项有对勾 ✅
  打开态:底色 rgb(250,242,238)=tint-1、文字 rgb(168,74,40)=brand、
          箭头 rotate 180° ✅
  选「个人事务」→ select.value=3、按钮文字同步、浮层关闭 ✅
2026-11-05 20:23:16 +08:00

645 lines
27 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 全局辅助函数(由 bootstrap.php 引入,页面无需重复 require)
*/
declare(strict_types=1);
/** HTML 转义:所有输出到页面的动态值都必须过一道 */
function e($value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
/**
* 「多久之前」—— 通知列表用(v30 第 5 条)。
*
* ⚠️ 只做**粗粒度**(刚刚 / N 分钟前 / N 小时前 / 昨天 / N 天前 / 月-日),
* 不引 Carbon、也不做"1 个月前"这种会随月长变化的措辞 ——
* 通知的时间只是让人一眼看出"新不新",精确到秒反而不好读。
*/
function fb_time_ago(?string $dt): string
{
$s = trim((string)$dt);
if ($s === '') {
return '';
}
$t = strtotime($s);
if ($t === false) {
return '';
}
$d = time() - $t;
if ($d < 0) {
return date('n 月 j 日', $t); // 时间在未来(服务器时钟偏差)→ 直接给日期
}
if ($d < 60) {
return '刚刚';
}
if ($d < 3600) {
return intdiv($d, 60) . ' 分钟前';
}
if ($d < 86400) {
return intdiv($d, 3600) . ' 小时前';
}
if ($d < 172800) {
return '昨天';
}
if ($d < 86400 * 30) {
return intdiv($d, 86400) . ' 天前';
}
return date('n 月 j 日', $t);
}
/** 静态资源地址,带版本号(mtime + size)——没版本号的话浏览器会一直用旧缓存 */
function asset(string $rel): string
{
$p = dirname(__DIR__) . $rel;
if (!is_file($p)) {
return $rel;
}
return $rel . '?v=' . substr(md5(filemtime($p) . filesize($p)), 0, 8);
}
/**
* 附件(头像等)地址 —— 全站唯一出口。
*
* ⚠️ 现在**一律指向 media.php 代理**,不再直接把 uploads/ 或 OSS 的地址吐给浏览器:
* · 统一:本地/云端的形态差别被代理吃掉,前端只认一个 media.php?k=…
* · 安全:桶不必开公共读;uploads/ 可以直接封死不让 Web 访问,
* 附件是否可读由**登录态**决定,而不是"知道文件名就能看"。
* · 标准:Content-Type / 缓存 / 下载头由服务端统一给,而不是靠存储桶配得对不对。
*/
function storage_url(?string $key): string
{
return media_url($key);
}
/** 头像地址(无头像自动回退内置剪影) */
function avatar_url(?string $key): string
{
$key = trim((string)$key);
if ($key === '') {
return asset('/assets/avatar-default.svg');
}
return media_url($key);
}
/**
* 附件代理地址。
* @param array $extra 额外参数,如 ['dl' => 1] 表示按下附件下载
*/
/**
* 附件访问地址(走 media.php 代理)。
*
* @param array $extra 额外参数:`w`(压缩档位 1600)、`dl`(下载)等
* @param int $ver 内容版本(通常是附件 size)。
* **变了就让 URL 变**,从而绕过一切缓存。
*
* 🔴 #57(用户:「他说是H.265,还是不让播呢?」):
* 转码后服务端确实是 h264 了(15 个视频全部实测过),但浏览器还在放旧的。
*
* 原因是**媒体缓存**:`Cache-Control: max-age=604800`(7 天),
* 而浏览器对 `<video>` 有自己的一套:
* · 不完全遵守 ETag
* · `Range` 分段缓存会把每段当独立资源
* · `src` 不变时直接复用已有媒体对象
*
* ⇒ 最可靠的做法是**让 URL 变**。URL 一变任何缓存层都不会命中。
*
* ⚠️ 版本号用 `size` 而不是 `mtime`/`md5`:
* · `mtime`:对象在 OSS 上,本地拿不到可靠时间
* · `md5`:要读整个文件,几百 MB 的视频不可接受
* · `size` 够用(转码前后体积必然不同:1.22MB → 2.19MB),
* 而且它本来就在数据库里,零开销。
*/
function media_url(?string $key, array $extra = [], int $ver = 0): string
{
$key = trim((string)$key);
if ($key === '') {
return asset('/assets/avatar-default.svg');
}
// 已经是绝对地址(历史数据/外部链接)→ 不代理,原样用
if (preg_match('#^https?://#i', $key)) {
return $key;
}
$q = array_merge(['k' => ltrim($key, '/')], $extra);
/* 🔴🔴 #56 严重修复(用户 2026-11-05:「你好像把 .mp4 后面也带上了问号,
写成了 1600 这样的参数…是你更新了这个功能之后,出了这个问题」):
**`w`(压缩档位)只允许给图片。** 给视频传
`x-oss-process=image/resize,l_1600` 会让 OSS 图片处理服务
对非图片返回错误内容 ⇒ `<video>` 播不了。
这里用扩展名判断(只有 key、没有 MIME):
· 图片扩展名 → 保留 w
· 其它(mp4/pdf…) → **去掉 w**
⚠️ `v` 版本号**所有类型都保留**:视频转码后也要靠它破缓存。
它只是查询串,不参与 OSS 签名。 */
if (isset($q['w'])) {
$isImg = (bool)preg_match('#\.(jpe?g|png|gif|webp|bmp|avif|heic)$#i', $key);
if (!$isImg) {
unset($q['w']);
}
}
if ($ver > 0) {
$q['v'] = $ver;
}
return 'media.php?' . http_build_query($q);
}
// ---------------- 人:怎么称呼、什么颜色 ----------------
/**
* 页面上「这个人叫什么」的**唯一出口**。
*
* 用户 2026-10-01 第 3 条:凡是出现人员的地方,默认显示「我」对他的昵称/称呼,
* 不要直接冒出姓名。所以这里先看 nicknames 里的 my_nickname,没有才退回姓名。
*
* @param array $p 至少要有 name;有 my_nickname 就优先用;`shown` 是某些查询
* 已经算好的显示名(如观影表单的已选人),也认。
*/
function person_name(array $p): string
{
$nick = trim((string)($p['my_nickname'] ?? ''));
if ($nick !== '') {
return $nick;
}
$shown = trim((string)($p['shown'] ?? ''));
if ($shown !== '') {
return $shown;
}
return (string)($p['name'] ?? '');
}
/**
* 这个人的底色(浅色底)与字色(深色字)—— 都由他自己的主色推出来。
*
* 主色来自头像(上传时算好存 persons.avatar_color),没头像或头像是灰片时
* 由姓名稳定地推一个。页面上一律走这两个函数,不要再自己写 '#eee'。
*/
function person_bg(array $p): string
{
return \App\AvatarColor::bg(\App\AvatarColor::for($p));
}
function person_fg(array $p): string
{
return \App\AvatarColor::fg(\App\AvatarColor::for($p));
}
function person_bd(array $p): string
{
return \App\AvatarColor::bd(\App\AvatarColor::for($p));
}
/**
* 一个人的彩色标签(头像 + 称呼 + 他自己的底色)—— 全站唯一画法。
*
* ⚠️ `$href` 为空时输出 `<span>`:瀑布流里整张卡片已经是 `<a>` 了,
* `<a>` 套 `<a>` 是非法 HTML,浏览器会把外层链接切开(卡片点不动)。
* 所以列表/瀑布一律传空,只有详情页那种独立标签才传链接。
*/
function person_chip(array $p, string $href = '', string $extraClass = ''): string
{
$name = person_name($p);
$style = 'background:' . person_bg($p) . ';color:' . person_fg($p)
. ';border-color:' . person_bd($p) . ';';
if (!empty($p['avatar'])) {
$av = '<img class="chip-av" src="' . e(media_url((string)$p['avatar'])) . '" alt="">';
} else {
$av = '<span class="chip-av chip-av-text">'
. e(mb_substr($name, 0, 1, 'UTF-8')) . '</span>';
}
$cls = 'viewer-tag' . ($extraClass !== '' ? ' ' . $extraClass : '');
$inner = $av . '<span class="chip-name">' . e($name) . '</span>';
if ($href !== '') {
return '<a class="' . e($cls) . '" style="' . e($style) . '" href="' . e($href) . '">'
. $inner . '</a>';
}
return '<span class="' . e($cls) . '" style="' . e($style) . '">' . $inner . '</span>';
}
/**
* 读「日期 + 24 小时制时:分:秒」组合控件(templates/_datetime.php 产出)。
*
* 为什么不用 <input type="datetime-local">:它的显示格式由**浏览器 UI 语言 /
* 系统时间格式**决定,<html lang> 压不过它 —— 中文环境会渲染成「下午 03:30」,
* 和"精确到时分秒"的要求对不上,用户还会把上午下午填反。
* 拆成「原生日期选择器 + 时/分/秒三个下拉」后,24 小时制由控件本身保证。
*
* 只选了时间没选日期 → 视为没填(与 TripLog 的行为一致)。
*/
function read_dt_input(string $name): ?string
{
$date = trim((string)($_POST[$name . '_date'] ?? ''));
$hRaw = trim((string)($_POST[$name . '_hour'] ?? ''));
$mRaw = trim((string)($_POST[$name . '_minute'] ?? ''));
$sRaw = trim((string)($_POST[$name . '_second'] ?? ''));
// 兼容一次性传整串的老表单(如 CSV 导入、内部调用)
if ($date === '' && $hRaw === '' && $mRaw === '') {
return \App\Birthday::toDateTime((string)($_POST[$name] ?? ''));
}
if ($date === '') {
return null;
}
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $mt)) {
return null;
}
if (!checkdate((int)$mt[2], (int)$mt[3], (int)$mt[1])) {
return null;
}
$h = $hRaw === '' ? 0 : (int)$hRaw;
$i = $mRaw === '' ? 0 : (int)$mRaw;
$s = $sRaw === '' ? 0 : (int)$sRaw;
if ($h < 0 || $h > 23 || $i < 0 || $i > 59 || $s < 0 || $s > 59) {
return null;
}
return sprintf('%s %02d:%02d:%02d', $date, $h, $i, $s);
}
/** 站内链接(统一走 query-string 路由,不依赖 Apache 重写);$anchor 是页面内锚点(不带 #) */
function url(string $route = 'home', array $query = [], ?string $anchor = null): string
{
$q = array_merge(['r' => $route], $query);
return 'index.php?' . http_build_query($q) . ($anchor !== null && $anchor !== '' ? '#' . rawurlencode($anchor) : '');
}
function redirect(string $route = 'home', array $query = [], ?string $anchor = null): void
{
header('Location: ' . url($route, $query, $anchor));
exit;
}
function is_post(): bool
{
return ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
}
/**
* 读配置。
*
* ⚠️ `?string $key = null` 里的 `?` 不能省:PHP 8.4 起,
* 「有类型 + 默认 null」而没写 `?` 的形式(隐式可空)会报 Deprecated。
* 这个警告一旦在页面顶部打出来,就等于响应体已经输出 ——
* 后面所有 header() 全部失效(典型症状:安装完/登录后**不再跳转**,
* 返回 200 而不是 302,但功能其实是好的,极难排查)。
* 线上跑 PHP 7.4/8.1 时不报,所以本地用新 PHP 才抓得到。
*/
function fb_config(?string $key = null, $default = null)
{
$c = $GLOBALS['config'];
if ($key === null) {
return $c;
}
return array_key_exists($key, $c) ? $c[$key] : $default;
}
// ---------------- CSRF ----------------
function csrf_token(): string
{
if (empty($_SESSION['_csrf'])) {
$_SESSION['_csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['_csrf'];
}
function csrf_field(): string
{
return '<input type="hidden" name="_csrf" value="' . e(csrf_token()) . '">';
}
/**
* 校验 CSRF。失败直接结束请求 —— 不做这种假返回:
* 「返回 false 让调用层自己处理」的写法,迟早有人忘了判断。
*/
function csrf_check(): void
{
$ok = isset($_POST['_csrf'], $_SESSION['_csrf'])
&& hash_equals($_SESSION['_csrf'], (string)$_POST['_csrf']);
if (!$ok) {
http_response_code(419);
echo '页面已过期,请刷新后重试(CSRF 校验失败)';
exit;
}
}
// ---------------- 一次性提示 ----------------
function flash(string $type, string $message): void
{
$_SESSION['_flash'][] = ['type' => $type, 'message' => $message];
}
function take_flash(): array
{
$f = $_SESSION['_flash'] ?? [];
unset($_SESSION['_flash']);
return $f;
}
/** 表单回填(校验失败时保留用户输入) */
function old(string $key, $default = '')
{
return $_SESSION['_old'][$key] ?? $default;
}
function set_old(array $data): void
{
$_SESSION['_old'] = $data;
}
function clear_old(): void
{
unset($_SESSION['_old']);
}
/**
* 生日相关:年龄与距下次生日天数。
*
* ⚠️ 输入可能是 DATETIME('1990-05-12 14:30:00')—— 一律先截到日期再算,
* 否则 'm-d' 之类会带上时间,闰年/跨年边界容易算歪。
* 真正的"下一次生日"请用 App\Birthday::nextOccurrences():
* 它会分别处理身份证/阳历/农历三个生日,这里只是给页面用的简版。
*/
function age_from(?string $birthday): ?int
{
$d = \App\Birthday::dateOnly($birthday);
if ($d === '') {
return null;
}
try {
$b = new DateTimeImmutable($d);
} catch (Exception $ex) {
return null;
}
return (int)$b->diff(new DateTimeImmutable('today'))->y;
}
function days_until_birthday(?string $birthday): ?int
{
$d = \App\Birthday::dateOnly($birthday);
if ($d === '') {
return null;
}
try {
$b = new DateTimeImmutable($d);
$now = new DateTimeImmutable('today');
$next = new DateTimeImmutable($now->format('Y') . '-' . $b->format('m-d'));
if ($next < $now) {
$next = $next->modify('+1 year');
}
return (int)$now->diff($next)->days;
} catch (Exception $ex) {
return null;
}
}
/**
* 🔴 v43(用户 2026-10-11 第 6 条)—— 把一条日历/任务条目编成**详情浮层**能直接渲染的 JSON。
*
* 用户原话:
* 「首页上的日程、首页上的近期任务,以及日程里面的日程,点击它们的时候,
* 都会弹出一个框显示更详细的信息。…如果是观影啊、旅程啊,点击详细信息就会
* 进入到观影或者旅程的那个相关的页面。如果是日程的话,点击详细信息就进到
* 日程的详细页面。当然还可以直接点击编辑,点击编辑不同的来源就进入不同的编辑页面,
* 就在当前的页面弹窗…」
*
* ⇒ 每种来源由这里给出**三样**:
* `detail` 「详细信息」跳哪(该来源的详情页)
* `edit` 「编辑」取哪份编辑表单片段(`?embed=1` 的那些路由)
* `rows` 浮层正文那几行「标签:值」
*
* 🔴 v49(用户 2026-10-14 第 2 条)再补三样,见下方 `$payload` 上方的说明:
* `source` / `del` / `info`(+ `repeat` / `occ`)—— 让浮层能按来源分形态。
*
* ⚠️ 模板里只调这一个函数,不要在各模板里各拼一份 JSON ——
* "三个入口三套写法"必然有一天漏改一个(用户第 6 条点名了三处入口)。
* ⚠️ 返回**已经 json_encode 好的字符串**,直接写进 `data-fb-entry='…'`;
* 模板里用单引号属性包住(JSON 里的双引号不会撞)。
* 用 `JSON_HEX_APOS | JSON_HEX_QUOT` 把引号也转成实体,双保险。
*
* @param array $ev 日历条目(Calendar::month 的形状:kind/ref_id/title/date/time/…)
* @param string $detail 详情页地址(没有就不显「详细信息」)
* @param string $edit 编辑表单片段地址(没有就不显「编辑」)
* @param array $extra 额外要显示的行 [[标签, 值], …]
* @return string 可直接放进 data-fb-entry 的 JSON 字符串(已转义)
*/
/**
* 把 hex 主题色往白色方向混,得到一档浅色。
*
* 🔴 #85(用户 2026-11-05):「参考一下那个反馈系统里面的下拉框到底是怎么做的…
* 它也有主题颜色,我们这个也有主题颜色,就按照一样的做法去做。」
*
* FeedLite 那边用三档浅色区分下拉项的三态:
* tint-1(混 91% 白)→ hover
* tint-2(混 80% 白)→ 选中项(比 hover 深一档,一眼分开)
* tint-3(混 72% 白)→ 选中项 hover
*
* ⚠️ 在 PHP 里算好再输出,**不用 CSS 的 `color-mix()`** ——
* FeedLite 实测过:`color-mix(in srgb, var(--brand) 9%, #fff)`
* 在 Chrome 里解析成透明(规则匹配上了但不生效)。
* ⚠️ `$ratio` 是"往白里混多少",不是"保留多少":
* 0.91 = 91% 白 + 9% 原色(很浅),0.0 = 原色本身。
*/
function fb_color_tint(string $hex, float $ratio): string
{
$hex = trim($hex);
if (preg_match('/^#?([0-9a-fA-F]{6})$/', $hex, $m) !== 1) {
return ''; // 不是 6 位 hex(比如 rgba()/色名)就放弃,调用方退回默认
}
$h = $m[1];
$r = (int)hexdec(substr($h, 0, 2));
$g = (int)hexdec(substr($h, 2, 2));
$b = (int)hexdec(substr($h, 4, 2));
$ratio = max(0.0, min(1.0, $ratio));
return sprintf('#%02x%02x%02x',
(int)round($r + (255 - $r) * $ratio),
(int)round($g + (255 - $g) * $ratio),
(int)round($b + (255 - $b) * $ratio));
}
function fb_entry_json(array $ev, string $detail = '', string $edit = '', array $extra = []): string
{
$date = (string)($ev['date'] ?? '');
$time = (string)($ev['time'] ?? '');
$et = (string)($ev['end_time'] ?? '');
if ($date !== '') {
$when = $date;
if (!empty($ev['all_day'])) {
$when .= ' 全天';
} elseif ($time !== '') {
$when .= ' ' . $time . ($et !== '' ? '–' . $et : '');
}
} else {
$when = '';
}
$rows = [];
if ($when !== '') { $rows[] = ['时间', $when]; }
if ((string)($ev['calname'] ?? '') !== '') { $rows[] = ['日历本', (string)$ev['calname']]; }
if ((string)($ev['location'] ?? '') !== '') { $rows[] = ['地点', (string)$ev['location']]; }
/* 🔴 #67(用户 2026-11-05):「如果行程里面有地址,也就是有出发地和目的地,
应该像行程里显示的那样,把它显示在下面,这样一点就可以导航。」
⇒ 行程派生条目(kind=trip)额外给两行:出发地 / 目的地。
@ 用两行而不是一行「A → B」:详情卡是 `dl.kv` 两列表格,
一行一个地名读起来更清楚,也便于将来给单个地名挂导航链接。
@ 普通日程没有这两个字段(),下面的 if 自然不产出。 */
$evFrom = (string)($ev['from_place'] ?? '');
$evTo = (string)($ev['to_place'] ?? '');
if ($evFrom !== '') { $rows[] = ['出发地', $evFrom]; }
if ($evTo !== '') { $rows[] = ['目的地', $evTo]; }
/* 🔴 v44(用户 2026-10-12 第 3 条):「"状态确定"这个不要了,把"状态确定"换成地点。
当然,如果没有地点,这一行就不要显示了。」
⇒ **删掉「状态」那一行**(地点上一行已经加了,"换成地点"就是把状态让位给地点)。
没有地点时上面那条 `if` 自然不产出 ⇒ 整行不显示,正是用户要的。 */
if ((string)($ev['note'] ?? '') !== '') { $rows[] = ['备注', (string)$ev['note']]; }
// ⚠️ 扩展行排在"备注"之后、作为补充(调用方按需给,比如任务给「所属看板」)
foreach ($extra as $row) {
if (is_array($row) && count($row) >= 2 && (string)$row[1] !== '') {
$rows[] = [(string)$row[0], (string)$row[1]];
}
}
/* 🔴 v49(用户 2026-10-14 第 2 条)——按来源分两种形态。
用户原话:「点击详细的时候,如果这是一个日程,就是正常的日程里面的一个事件,
不是来自于其他的模块…那么就应该直接在当前的页面,让这个模态框变大,
显示出来更多的信息。…如果当前显示的这个日程是来自于其他的模块,比如观影、
旅程…点击详情的时候就进入到这些模块展示它…这种情况下,下面就没有编辑了,
也没有删除,就只有一个详情。」
⇒ 多带四个字段:
`source` 'event'(自定义日程)/ 'derived'(观影·旅程·任务·纪念日·农历·节假日)
`del` 删除用的 POST 地址(**只有 source='event' 且可写**时才给)
`info` 详情片段地址(`calendar/event?embed=detail&id=…`;source='event' 才有)
`repeat` / `occ` 重复日程的删除范围要用("这一次 / 以后 / 全部"靠它定位)
⚠️ 判据是 `kind === 'custom'`(**不是**"有没有 edit"):只有 custom 是
calendar_events 里真属于某本日历的自定义日程;其余全是**从别的模块派生**的,
不该在这里改/删(用户明确要求)。
⚠️ `del`/`edit` 为空 ⇒ 浮层自动不显那两颗按钮
⇒ "只读日历里的自定义日程"天然变成"只有详情",前端不用再判一次。 */
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evOwn = ($evKind === 'custom' && $evRef > 0);
$evW = !empty($ev['_can_write']); // 由调用方按日历权限给出(见模板)
/* 🔴🔴 v93(用户 2026-10-25):「编辑重复日历时,也会有这三个选项
(修改当前 / 修改从现在到以后 / 修改全部)」
—— 浮层里的编辑表单要能给出这三档,就必须知道**打开的是哪一次**:
`calendar/event` 路由从 `?d=YYYY-MM-DD` 读它(`index.php` 里的 `$occ`),
`templates/calendar/event.php` 再据此输出那三档单选。
而浮层的 `edit` 地址原来是 `url('calendar/event', ['id'=>…, 'embed'=>1])` ——
**没带 d** ⇒ 表单里只显示"改全部",另外两档连出现都不出现,
用户会以为"重复日程不能只改这一次"(实测就是这样)。
⚠️ 只在 `source='event'`(自定义日程)且 occ 是合法日期时追加:
观影 / 旅程 / 任务 / 纪念日这些派生条目没有"第几次"的概念。
⚠️ 是否加 `&` 按 `$edit` 里有没有 `?` 判 —— `url()` 生成的一定带,
但别赌(换一处调用方就会静默拼出 `?id=1&embed=1d=2026-10-25` 这种废地址)。 */
$editUrl = $edit;
$occDay = (string)($ev['occ'] ?? '');
if ($evOwn && $editUrl !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $occDay)) {
$editUrl .= (strpos($editUrl, '?') === false ? '?' : '&') . 'd=' . rawurlencode($occDay);
}
$payload = [
'title' => trim((string)($ev['title'] ?? '')),
'rows' => $rows,
'detail' => $detail,
'edit' => $editUrl,
// 新增(v49)
'source' => $evOwn ? 'event' : 'derived',
'ref' => $evOwn ? $evRef : 0, // 日程 id(前端删/改要按它定位,别从 URL 里抠)
'del' => ($evOwn && $evW) ? url('calendar/event_del') : '',
'info' => $evOwn ? url('calendar/event', ['id' => $evRef, 'embed' => 'detail']) : '',
'repeat' => (string)($ev['repeat'] ?? '') !== '',
'occ' => $occDay,
/* 🔴 v78(用户 2026-10-22):**就地面板**的地址。
首页「近期任务」点开时不再只给一行行只读信息,而是直接给出这张
面板(状态 / 看板 / 子任务 / 附件 / 评论 都能就地改),用户不必离开首页。
⚠️ 只有**任务**会给这个字段(`_card_tasks.php` 里传进来);
日程/观影/旅程仍然走原来的"详情 + 编辑/删除"三颗按钮 ——
它们没有"在首页上简单处理一下"的诉求,硬塞一个面板只会让浮层变复杂。 */
'panel' => (string)($ev['panel'] ?? ''),
];
return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT);
}
/**
* 这次请求是不是「首页任务面板」发来的(v78)。
*
* 面板里的每一个动作都打**既有路由**,只多带一个 `ajax_panel=1`;
* 服务端据此把"flash + 302 跳转"换成"一段新面板 + 一句人话"。
*
* ⚠️ 判据只认 POST 里那个字段,**不要**顺手用 `X-Requested-With` 代替:
* `tasks/status` 早就用那个头区分"看板上的就地改(只要状态)"和"表单提交",
* 两件事混在一个判据里,改一处必然破坏另一处。
*/
function task_panel_wanted(): bool
{
return (string)($_POST['ajax_panel'] ?? '') === '1';
}
/**
* 把「这一条任务的最新面板」当 JSON 回给前端(v78)。
*
* 为什么**每次都重画整块面板**,而不是让前端自己把那一行改掉:
* · 一个动作的影响面比看起来大 —— 勾一条子任务会改"2/3"这个计数;
* 传一个附件会改附件张数与缩略图墙;挪看板会改上面那排 chip 的选中态。
* 逐处同步(本项目在 v55 的状态下拉上已经手写过一次)是"漏一处就自相矛盾"。
* · 面板很小(一次 Task::find + 一个片段),重画一次比维护 N 处 DOM 同步便宜。
*
* ⚠️ `html` 在失败(ok=false)时**也照样给**:失败的典型原因是"权限不够/内容为空",
* 这正是最需要让用户看到"现在的真实状态"的时候,回一个空壳反而把旧状态留在屏幕上。
*
* @param int $taskId 要重画哪一条(子任务新增时传**父任务**,否则会把用户弹到子任务上)
* @param string $msg 给用户看的一句话(落在浮层底栏左侧)
* @param bool $ok 这次动作成不成功(只影响提示语气与 HTTP 码,不影响 html)
*/
function task_panel_json(int $taskId, string $msg = '', bool $ok = true): void
{
$familyId = (int)\App\Auth::familyId();
$me = (int)\App\Auth::personId();
$t = \App\Task::find($familyId, $taskId, $me);
if (!$t) {
header('Content-Type: application/json; charset=utf-8', true, 404);
echo json_encode(['ok' => false, 'msg' => '找不到这项任务。'], JSON_UNESCAPED_UNICODE);
exit;
}
$people = \App\Person::roster($familyId, $me, 'member', '');
/* @人 高亮的候选名:与 tasks/show.php 同一份口径(真名 + 我的昵称都算),
否则同一句话在详情页高亮、在首页面板不高亮。 */
$names = [];
foreach ($people as $p) {
$names[] = person_name($p);
$nm = trim((string)($p['name'] ?? ''));
if ($nm !== '') {
$names[] = $nm;
}
}
$names = array_values(array_unique(array_filter($names)));
$html = \App\View::renderPartial('tasks/_panel', [
't' => $t,
'people' => $people,
'names' => $names,
'boards' => \App\Task::boards($familyId),
'categories' => \App\Task::categories($familyId),
'canWrite' => \App\Auth::canWrite() && \App\Task::canWrite($t, $me),
'meId' => $me,
]);
header('Content-Type: application/json; charset=utf-8', true, $ok ? 200 : 422);
echo json_encode([
'ok' => $ok,
'msg' => $msg,
'id' => $taskId,
'status' => (string)($t['status'] ?? ''),
'word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
/* 🔴 #79(用户 2026-11-05):「首页卡片没有刷新。」
前端 `syncRow()` 原来只同步状态徽标,不同步标题。
标题可以从面板输入框读,但那条路依赖 DOM 还在 ——
服务端直接回一份才是稳的(同一个函数给所有面板动作复用)。 */
'title' => (string)($t['title'] ?? ''),
'html' => $html,
], JSON_UNESCAPED_UNICODE);
exit;
}