用户 2026-11-05 三条: 1. 「图标应该来自里面具体的交通工具,如果是火车就应该显示火车」 原来 Calendar::month() 的 trip 派生段用 Trip::typeIcon(node_type), typeIcon(move) 恒为 🚗 ⇒「赶路」永远显示汽车,哪怕实际坐的是火车。 改用 Trip::leadIcon(node_type, travel_mode, familyId) —— 出行方式优先、节点类型兜底,与旅行详情页行程卡同一个方法。 2. 「后面这个对号是什么意思?表示它是一个实际行程吗?我感觉没必要,直接去掉」 删掉标题拼装里的 ✓ (原来用来区分 stage=actual)。 3. 「如果行程里面有地址,也就是有出发地和目的地,应该像行程里显示的那样, 把它显示在下面,这样一点就可以导航」 这段 SQL 原来**完全没查** from_*/to_*,日历里只剩一个标题。 补 n.from_place/to_place + 四个坐标字段,输出加 from_place/to_place/ from_lng/from_lat/to_lng/to_lat。 端上: - FbCalEvent 加 6 个字段(RawCalEvent / FbParse / FbCalStore / DemoData 同步) - 坐标用 numStr() 转字符串(服务端 DECIMAL 可能给 number,s() 只收 string) - FbDayEventPanel 列表卡片:时间行上面单独一行「出发地 → 目的地」, 目的地带下划线 + 日历本色,点击调 fbOpenNavigation(有坐标带坐标, 没有让 FbNav 自己地理编码) - FbDayTimeline 全天块:一行文字「出发地 → 目的地」,不给点击 (26vp 高的块切两个热区会点不准,导航入口在详情卡里) 网页端: - fb_entry_json() 的 rows 补「出发地」「目的地」两行 - 日历网格里的条目太窄,只显示标题,不加这两行 实测(family 1 全年): title = [✈️ 从青岛坐飞机去哈尔滨] from = [青岛胶东国际机场] to = [哈尔滨太平国际机场] to_lng= [126.241474] to_lat=[45.611938] —— 飞机显示 ✈️、没有 ✓、地址坐标齐全
606 lines
26 KiB
PHP
606 lines
26 KiB
PHP
<?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 字符串(已转义)
|
||
*/
|
||
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')),
|
||
'html' => $html,
|
||
], JSON_UNESCAPED_UNICODE);
|
||
exit;
|
||
}
|