Files
FamilyBook/templates/calendar/calendars.php
T
laoyang 91c55038c9 v30 第 30 批(3 条):ICS 导入读完即弃 / 导航联想词根因修复 / 导航设置改纯图标
三条都是「静态检查与 e2e 恒绿、只有真机才现形」的,每条先写真机诊断探针
拿到真根因再改,并逐条反向验证(改回旧行为必须被抓住)。

1. 日程 ICS 导入:文件已上传仍提示「请选择一个文本」
   真根因:_stage_drop.php 是"拖进来即刻上传到暂存区"的唯一形态——
   拖放时文件走 upload_stage.php 落到 uploads/_staging/,而 <input>.files
   始终是空的;两个导入框(ics/通讯录)都带 required → 浏览器原生校验把提交
   拦下,弹它自己的文案。就算绕过校验,路由只读 $_FILES['ics']、从不看
   stage_token[],暂存文件永远没人认领。
   改:片段新增 $sdStage(默认 true)。导入型传 false → 框上不带
   data-drop-stage → app.js 走"写回 input.files、随表单提交"的老路,文件走
   PHP 临时文件,路由读完即弃;不落存储、不建附件行、不进 OSS。
   同时把 app.js 找框的选择器从 [data-drop-stage][data-sd-for=…] 放宽成
   [data-sd-for=…]——否则这些框点不开也接不住拖动。

2. 导航联想词"还是不好用"
   真根因:nav_show.php 给出的 suggest 地址已带 ?p=<短语>,而 nav-show.js 又
   硬拼 '?slug=…' → 实际是 …?p=abc?slug=bing&q=…;PHP 认为第一个 ? 之后整串
   都是查询值 → p 匹配不上短语 → 403 → 下拉永远空。服务端一直是对的(直连
   200 + 10 条词),e2e 探针自己拼对了参数,所以 HTTP 层恒绿、只有真页面能抓。
   改:nav-show.js 新增 withQuery(),按 base 里有没有 ? 决定分隔符;
   nav_suggest.php 加短语容错(切掉第一个 ?/& 之后的内容,救缓存里的旧 JS)。

3. 导航设置按钮改纯图标 ☰
   v29 只改成了下拉菜单,summary 仍是 btn-ghost btn-sm + 文字「设置」+ 一个
   向下箭头。改:换成与其他七个模块逐字节相同的 btn-icon + 三条横线。

验证:e2e 1464/0(新增 2 条)、升级 153/0、vCard 40 项、城市 32/0、
桌面几何全过、移动 680/0、交互 92/0、窄屏 30/0、b24_nav 41/0、b24_ui 26/0、
b28 39/0、b29 38/0、b27 34/0;新真机探针 _b30 22/0、反向验证 11/0;
部署静态断言新增 8 条守卫并逐条证伪通过(本轮抓到 2 条恒真守卫 + 1 处
_code_only 的 locale 差异)。线上 7 个文件 SHA256 与本地一致。
2026-10-08 14:46:16 +08:00

261 lines
13 KiB
PHP
Raw 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
/**
* 日历本管理(v12):建本 / 改名 / 换色 / 删除 / 共享给别人(只读或可写)。
*
* ⚠️ 「观影」「旅行」两本是系统预置的,不能改名也不能删 ——
* 它们的日程是从观影模块与旅程模块实时汇总出来的,删掉就再也回不来了
* (Calendar::delete 里也只删 kind='custom',这里是同一道栅栏的界面侧)。
*
* @param array $cals 我自己的日历本(kind 任意)
* @param array $shared 别人共享给我的
* @param array $people 名册(共享下拉用)
*/
$isEditing = !empty($editing);
$canWrite = \App\Auth::canWrite();
?>
<div class="page-head">
<h1>日历本</h1>
<span class="sub">共 <?= count($cals) ?> 本<?= $shared ? ',别人共享给我 ' . count($shared) . ' 本' : '' ?></span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('calendar')) ?>">返回日程</a>
</div>
</div>
<?php if (!$canWrite): ?>
<div class="card card-pad empty"><b>只读账号</b>访客只能查看日历本,不能新建或修改。</div>
<?php else: ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= $isEditing ? '编辑日历本' : '新建日历本' ?></h2>
<form method="post" action="<?= e(url($isEditing ? 'calendar/cal_update' : 'calendar/cal_add')) ?>">
<?= csrf_field() ?>
<?php if ($isEditing): ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label for="cb-name">名字 *</label>
<input class="input" id="cb-name" name="name" required
value="<?= e(old('name', $editing['name'] ?? '')) ?>" placeholder="如:体检 / 值日">
</div>
<div class="field">
<label for="cb-color">颜色</label>
<div class="color-row">
<input type="color" id="cb-color" name="color" class="color-picker"
value="<?= e(old('color', $editing['color'] ?? '#4A7C82')) ?>">
<span class="color-hex"><?= e(old('color', $editing['color'] ?? '#4A7C82')) ?></span>
<span class="preset-palette">
<?php foreach (\App\Calendar::PRESET_CAL_COLORS as $pc): ?>
<span class="preset-color" data-color="<?= $pc ?>" style="background: <?= $pc ?>;" title="<?= $pc ?>"></span>
<?php endforeach; ?>
</span>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEditing ? '保存' : '新建' ?></button>
<?php if ($isEditing): ?>
<a class="btn btn-ghost" href="<?= e(url('calendar/calendars')) ?>">取消</a>
<?php endif; ?>
</div>
</form>
</div>
<?php endif; ?>
<div class="card card-pad">
<h2 class="cinema-h2">我自己的</h2>
<?php if (!$cals): ?>
<p class="empty-hint">还没有日历本。</p>
<?php else: ?>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>名字</th><th>颜色</th><th>共享给</th><?php if ($canWrite): ?><th>操作</th><?php endif; ?></tr>
</thead>
<tbody>
<?php foreach ($cals as $c): ?>
<tr>
<td>
<b><?= e((string)$c['name']) ?></b>
<?php if (($c['kind'] ?? '') !== 'custom'): ?>
<span class="node-badge">系统预置</span>
<?php endif; ?>
</td>
<td>
<span class="color-preview" style="background: <?= e((string)$c['color']) ?>;"></span>
<span class="mono"><?= e((string)$c['color']) ?></span>
</td>
<td>
<?php $sh = $shares[(int)$c['id']] ?? []; ?>
<?php if (!$sh): ?>
<span class="link-quiet">不共享</span>
<?php else: ?>
<div class="trip-people">
<?php foreach ($sh as $s): ?>
<?= person_chip($s) ?>
<span class="node-badge"><?= ($s['perm'] ?? '') === 'write' ? '可写' : '只读' ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canWrite && $people): ?>
<details class="node-add">
<summary class="link-quiet">共享设置</summary>
<form class="inline-form" method="post" action="<?= e(url('calendar/cal_share')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<?php
$ppName = 'person_id';
$ppPeople = array_values(array_filter($people,
fn($p) => (int)$p['id'] !== (int)\App\Auth::personId()));
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<select class="select" name="perm">
<option value="read">只读</option>
<option value="write">可写</option>
</select>
<button class="btn btn-sm" type="submit">共享</button>
</form>
<?php foreach ($sh as $s): ?>
<form class="inline-form" method="post" action="<?= e(url('calendar/cal_unshare')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<input type="hidden" name="person_id" value="<?= (int)$s['person_id'] ?>">
<span class="cal-share-name"><?= e(person_name($s)) ?></span>
<button class="btn btn-ghost btn-sm" type="submit">取消共享</button>
</form>
<?php endforeach; ?>
</details>
<?php endif; ?>
</td>
<?php if ($canWrite): ?>
<td>
<div class="actions">
<?php /* v24 第 7 条:每本都能导出 .ics(预置本也能 —— 导的是派生出来的内容)。 */ ?>
<a class="btn btn-ghost btn-sm"
href="<?= e(url('calendar/export', ['id' => (int)$c['id']])) ?>">导出 ics</a>
<?php if (($c['kind'] ?? '') === 'custom'): ?>
<a class="btn btn-ghost btn-sm"
href="<?= e(url('calendar/calendars', ['edit' => (int)$c['id']])) ?>">编辑</a>
<form method="post" action="<?= e(url('calendar/cal_del')) ?>"
onsubmit="return confirm('删除「<?= e((string)$c['name']) ?>」?本子里的日程会一起删掉。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<button class="btn btn-danger btn-sm" type="submit">删除</button>
</form>
<?php else: ?>
<span class="link-quiet">不可改</span>
<?php endif; ?>
</div>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<?php /* v24 第 7 条:把一个 .ics 文件里的日程整批导进某一本。
只能导进自定义本 —— 观影/旅行两本是派生的,塞进去就成了孤儿日程
(日历里有、观影列表里没有)。 */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
导入 ics
<span class="hint">把别的日历导出的 .ics 文件整批装进来</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
同一个文件导两次也<b>不会重复</b>:每条日程有个全球唯一的 UID,
已经导过的会被更新,而不是再插一条。
</p>
<form method="post" action="<?= e(url('calendar/import')) ?>" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="form-grid">
<div class="field">
<label for="im-cal">导进哪一本 *</label>
<select class="select" id="im-cal" name="calendar_id">
<?php foreach ($cals as $c): ?>
<?php if (($c['kind'] ?? '') !== 'custom') { continue; } ?>
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="im-file">.ics 文件 *</label>
<?php /* 🔴 v30 用户第 1 条:$sdStage = false —— 导入型上传**不走暂存**。
用户原话:「这个文件导入之后,以后应该就不会再访问它…把它上传到服务器上
个临时目录里面,把数据读出来之后,写入到我们的日程的数据库表中,
然后再把这个文件删除就可以了。」
⇒ 文件走 PHP 自带的临时文件($_FILES['ics']['tmp_name']),
路由读完即弃;不落存储、不建附件行、不进 OSS。
⚠️ 走暂存模式时 `<input>.files` 始终是空的 → 带 required 的提交会被
浏览器原生校验拦下,弹「请选择一个文本。」(用户报的就是这句)。 */
$sdName = 'ics'; $sdId = 'im-file'; $sdAccept = '.ics,text/calendar';
$sdMultiple = false; $sdRequired = true; $sdStage = false;
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div>
<div class="btn-row">
<button class="btn btn-sm" type="submit">导入</button>
</div>
</form>
</div>
<?php endif; ?>
<?php /* v24 第 7 条后半:CalDAV —— 把我的日历本订阅到手机/别的日历程序里。
v24 第 22 批:口令改成**通用 DAV 口令**(日历 + 通讯录共用一把),
管理入口也搬到了「个人设置 → DAV 同步」—— 这里只留地址与一个指路按钮。
⚠️ 这一页**不再有**生成/撤销表单:同一个口令在两处各放一套按钮,
迟早出现"在这里重置了、那页还显示已配置"的对不上(而且密码类的东西
有两个入口本身就是坏味道)。 */ ?>
<div class="card card-pad" id="caldav">
<h2 class="cinema-h2">
CalDAV 订阅
<span class="hint">在手机日历 / DAVx⁵ / Thunderbird 里订阅你的日历本</span>
</h2>
<p class="small muted" style="margin:0 0 12px">
填下面这个地址 + 你的<b>姓名或手机号</b> + <b>DAV 口令</b>,就能把「我的日历本」
同步到别的日历程序里。这是<b>只读</b>订阅的 —— 在手机上改日程请回到本站来改。
<br>口令与登录密码是<b>分开的</b>,而且<b>通讯录同步也用同一把</b>(不用配两遍)。
</p>
<div class="field">
<label>服务器地址</label>
<input class="input mono" readonly
value="<?= e(rtrim((string)fb_config('base_url', ''), '/') . '/dav/') ?>">
<span class="tip">在日历客户端里填这一条即可,会列出你所有的日历本。</span>
</div>
<div class="btn-row">
<a class="btn btn-sm" href="<?= e(url('settings/me', [], 'dav')) ?>">
<?= !empty($cdHas) ? 'DAV 口令已配置(去管理 / 重置)' : '去生成 DAV 口令' ?>
</a>
<span class="node-badge">口令在「个人设置 → DAV 同步」里管理</span>
</div>
</div>
<?php if ($shared): ?>
<div class="card card-pad">
<h2 class="cinema-h2">别人共享给我的</h2>
<div class="table-wrap">
<table class="table">
<thead><tr><th>名字</th><th>谁的</th><th>我的权限</th></tr></thead>
<tbody>
<?php foreach ($shared as $c): ?>
<tr>
<td><b><?= e((string)$c['name']) ?></b></td>
<?php /* 共享过来的行里"人"的字段带着前缀(owner_name / owner_nick),
不是 person_name() 直接认的 name / my_nickname —— 在这里适配一次。 */ ?>
<td><?= e(person_name(['name' => $c['owner_name'] ?? '', 'my_nickname' => $c['owner_nick'] ?? ''])) ?></td>
<td><span class="node-badge"><?= ($c['perm'] ?? '') === 'write' ? '可写' : '只读' ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>