Files
FamilyBook/templates/people/import.php
T
laoyang f0437e6c93 v24 第 21 批:无刷新删除与新建日程 / 实际行程不回退 / 行程按时间排 / 统一上传框 / 时间范围控件修复(用户 8 条)
用户 8 条原话要点:
  ①「名册页面,删除一个联系人后,能否不刷新页面,删除成功后,直接把这个人,
     从当前页面的 dom 中删除,页面保持不动。」
  ②「观影模块,海报和票根以及照片都带进来了,但是没有缩放,把页面撑破了,
     应该缩放成缩略图。」
  ③「个人设置页面,天气设置那里,把:只影响你自己首页上的那一格 / 填个城市名就行
     (如「青岛」)… 这些提示,都删除。把城市单独放一行,精度维度放在一行。」
  ④「旅程模块,实际行程编辑后,点击保存,竟然又变成计划行程了,这个不合理,
     实际行程永远是实际行程,不会再变回计划行程了。」
  ⑤「旅程模块,旅程列表,行程列表,都是按照行程的开始时间排序的,最新的在最上面,
     而不是按照编辑修改时间。」
  ⑥「旅程的详情页面,右上角的按钮,设置在最右侧,和其他模块保持统一。」
  ⑦「所有上传图片等附件的地方,都不再放按钮,而是放一个框,点击这个框实现点击上传
     按钮的功能,拖动图片进这个框里,实现拖动多个上传功能。框里面写一句话:
     点击或拖动进行上传,其他的功能和现在保持一致。」
  ⑧「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式进行添加,
     这样不影响当前日历的显示,添加完成后,动态修改当前视图,不要刷新。添加日历日程,
     开始时间和结束时间那里,用我们统一的时间范围选择器重做,现在功能无法使用。
     选择日历本,带上颜色。」

🔴 五个真根因(全都不像 bug):

 ① 🔴 **④「实际行程变回计划」= 表单字段同名合并**(本批最要紧的一条)
    暂存上传的令牌字段原叫 `stage[]`,与行程表单里那个隐藏的 `stage`(plan/actual)
    **同名**。PHP 解析表单时同名会**合并进同一个键**:
        `stage=actual` + `stage[]=tok` → `$_POST['stage'] = ['actual','tok']`
    (`[]` 只是"往同一个键追加"的记号,**不构成另一个键名**。)
    于是路由里 `(string)($_POST['stage'] ?? 'plan')` 得到 `"Array"` →
    `in_array('Array', ['plan','actual'])` 为假 → **静默回落成 'plan'**。
    ⚠️ 只有"拖过图片的行程"会犯(没拖就没有 `stage[]`)→ 症状是"时好时坏"。
    修法:令牌字段改名成 `stage_token[]`(改的是**字段名本身**,不是"是数组就取第一个"
    绕过去 —— 同名合并是 PHP 的表单语义,绕过去只会把坑留给下一次加字段)。
    **另加一道语义保险**:编辑路径**一律沿用库里那条自己的 stage**,不读表单 ——
    编辑表单里根本没有改阶段的控件(改阶段走详情页那几个显式动作)。
    这样即便将来又冒出某个字段名冲突,也不可能把「实际」悄悄变回「计划」。

 ② 🔴 **⑧「时间范围选择器无法使用」= 三道门叠在一起**
    ① `_event_fields.php` 的**结束端默认带 `hidden`**(要点「+ 添加结束时间」才出现);
    ② `app.js` 的 `upgradeRange()` 有「**两端都得可见**才升级」的判断 → 永远不成立
       (实测 `data-semi-range-done` 恒为 `null`);
    ③ CSS `html.js .dt-range:not(.is-semi-range) .dt-input { visibility: hidden }` →
       未升级时原生控件**也是不可见的** ⇒ 屏幕上一片空白、点哪儿都没反应。
    修法:两端**始终可见**("没有结束"由清空后半段表达,不再靠开关);
    并把旧的「+ 添加结束时间 / 去掉结束时间」开关整套删掉。
    ⚠️ 顺带挖出:`upgradeRange()` 里 `group.insertBefore(host, a)` 在"两端各自被
    `.field` 包着"时 `a` 不是 `group` 的子节点 → 直接抛
    `Failed to execute 'insertBefore' on 'Node'`。这个异常**以前从没触发过**
    (被 ② 的提前 return 挡住了)—— 一旦放出来就炸。修成插在"端点最外层包装"之前。
    ⚠️ 观察器加了 `attributes: true, attributeFilter: ['hidden']`(并把 childList
    分支的 `return` 去掉):弹框/折叠面板/tab 里"稍后才露出来"的日期控件从此都能自动升级。

 ③ 🔴 **⑧「新建日程默认建出观影记录」= 产品缺口,不只是显示问题**
    `Calendar::DEFAULT_KINDS` 原来只有 6 本**派生**本(观影/旅行/农历/节假日/传统节日/
    节气),**一本可写的普通本都没有**。于是"放在哪本"里全是派生本、默认选中排最前的
    **观影** → 用户直接打字点「加上」,建出来的是一条**观影记录**
    (日历里看着是多了条日程,观影列表里莫名多一条)。这是"建错东西",事后极难发现。
    → ① 默认本补成「我的日程」(kind='custom'),模板里默认选中也改成**优先 custom 本**;
      ② 选择器里把**纯派生只读本**禁用并标「(自动生成)」
         (直接不列会让人以为本子丢了;列为可选又会白选一次再吃错误)。
    ⚠️ 顺带发现 `Calendar::READONLY_KINDS` 把 `movie`/`trip` 也算进去了,而
    `event_add` 里**紧跟着**就有两段专门处理它们的分支(「已加进观影」/「已加进旅程」)
    —— 那两段**永远走不到**(校验先一步把请求打回去了)。判据要按**能不能写**分,
    不是按"是不是派生"。

 ④ 🔴 **②「观影编辑页图片撑破」= `.mv-slot` 这个类在样式表里根本不存在**
    第 21 批加模板时只写了结构没写样式 → `<img>` 按**原始像素**渲染:600×900 的海报
    在 236px 宽的格子里撑出 942px 高,还把整页横向顶破(实测 docW 1297 > vw 1280)。
    → 补 `.mv-slot` 规格:固定 108×162 缩略图 + `object-fit: cover`。
    ⚠️ **高度必须写死** —— 不写死就又会由原图比例决定,等于没修。

 ⑤ **⑤行程列表排序 = `category, position` 主导**
    `position` 默认同一个数(旧迁移给的多是 0/100),于是顺序被 position 挤乱。
    实测线上 #222 渲染成「返回青岛(10-5) → 出发去菏泽(9-30) → 去姥姥家(10-2)」。
    → 改成 `ORDER BY (starts_at IS NULL), starts_at, id`。
    ⚠️ 方向是**正序**(早的在前,已跟用户确认);旅程列表仍是 DESC(最新在最上)。
       两者方向不同是**刻意的**:一个是"挑哪趟旅行",一个是"读这趟旅行的行程"。
    ⚠️ 详情页**按阶段分三组**渲染(计划/实际/未成行),排序只需在**组内**有序。

本批新增能力:
- **统一上传框**(⑦):`templates/_stage_drop.php` 现在**同时**负责
  「sr-only 的真文件框 + 看得见的上传框 + 暂存清单」,调用方只 require 一行。
  框是 `role="button" tabindex="0"`,JS 转发 `input.click()`(键盘 Enter/Space 也通),
  拖文件进框走既有的暂存上传(拖一个立刻传、可续拖、失败标红、未传完拦住保存)。
  ⚠️ 框用 `<div>` 不是 `<button>`:里面要放暂存清单(含「撤掉这张」按钮),
     按钮里嵌按钮是非法 HTML(第 21 批刚在 `<form>` 嵌套上栽过同一个跟头)。
  ⚠️ 文件框用 `.sr-only` 不是 `display:none`:后者不可聚焦,带 `required` 时
     Chrome 会报 "An invalid form control is not focusable" —— 点了什么都不发生。
  落了 11 处:行程照片 / 观影海报·票根·合影 / 资源 / 任务 / 日程附件 / 书籍 /
  联系人导入 / ics 导入 / 观影补传 / 资源补传。
  ⚠️ 清单与提示行移到框**外面**(放框内会变成"一个大框套五张图"),
     所以 app.js 改从 `.upfield` 容器里找它们。
- **无刷新删除(①)与无刷新新建日程(⑧)**:路由按 `X-Requested-With`
  (或 `Accept: application/json`)区分调用者 —— 带它回 JSON、不带照旧 302
  (无 JS 的兜底**不变**,两边的反向都写了断言)。
  · 名册删除:成功 → 摘卡片 + 就地改「共 N 人」与分组人数 + 派发 `input` 让
    filter.js 自己重算(**不抄它的判据**);失败 → 把原因说出来(不刷页);
    拿不到 JSON → **退回原生提交**(绝不静默失败)。
  · 日历新建:JSON 带齐前端插入当前视图要用的字段(时间/颜色/链接/是否全天/cal)。
    月视图**按时间插入**(不能 append —— 服务端只渲染前 3 条、按时间序取,
    append 会让"新加的早间日程"掉进「+N 条」里看不见,刷新后又跑到第一位);
    周/日视图按"24h = 100%"算 top/height 插块。
  · 观影 / 旅行本是**可写派生本**:AJAX 时不再跳详情页(跳走就离开了日历),
    改在提示行给一个「去看详情 →」链接。
- **⑧日历本带颜色**:原生 `<option>` 画不出色块(浏览器不允许在 option 里放元素),
  所以做成「色块 + 下拉」并排,色块跟着当前选中的本子变色。
- **③天气卡片**:三句提示全删;城市单独一行、纬度/经度同一行(`.loc-row`)。
  删的只是说明文字,三个输入框 + 保存 + 状态回显都保留。
- **⑥设置按钮贴最右**:原来顺序是「… → 设置 ☰ → 删除 🗑」,设置被删除键挤到**倒数第二**;
  改成删除在前、设置恒为最后一个(与观影/日程/任务/资源同一个手势)。

验证(本地全套):
- e2e **1059/0**(本批新增约 40 条断言)· 结构升级 149/0 · 桌面几何 428/0
- **真机探针 75/0**(`_fb_b21_ui.py`):AJAX 删除真的没导航 / 卡片真被摘掉 /
  滚动位置保持;缩略图几何(108×162,不再溢出);上传框(框在、原生框不可见、
  点框真打开文件选择框、连拖两张都上去且清单可见);日历弹框(打开时网格一动不动、
  范围控件真的挂上且可见、能弹面板、色块跟着换、无刷新提交后当前视图里立刻出现、
  周视图也能插时间块、全程无 JS 报错)。
- **反向验证 12 条**(`_fb_b21_falsify.py`):逐条把修复改回旧行为看断言是否变红 ——
  令牌字段改回 stage[] / 编辑读表单 stage / 排序改回 position / 设置按钮挪走 /
  框不转发点击 / 文件框不再 sr-only / 删缩略图规格 / 删除不回 JSON /
  结束端改回 hidden / 日历本默认改回第一个 / 天气提示加回 / 弹框改回内联。
- 部署脚本静态断言段本地空跑 **307/0**。
2026-10-06 12:08:40 +08:00

97 lines
4.1 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
/**
* 名册导入(v24 用户 2026-10-03 第 1 条)。
*
* 页面就两件事:挑文件 → 看结果。所以做成**一页**(提交后同页出报告),
* 不跳到"导入历史"之类的第二页 —— 一次导入看完就该结束。
*
* @param array $groups 我能用的分组(导入进来的人可以先归到一组)
* @param array|null $report ContactImporter::run 的结果(null = 还没导入)
*/
$report = $report ?? null;
$canMove = \App\Auth::canWrite();
?>
<div class="page-head">
<h1>导入联系人</h1>
<span class="sub">从手机 / 邮箱导出的通讯录批量加进名册</span>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('people')) ?>">返回名册</a>
</div>
</div>
<div class="card card-pad">
<h2 class="cinema-h2">选一份文件</h2>
<p class="small muted" style="margin:0 0 14px">
支持 <b>.vcf</b>(vCard,iPhone / 安卓 / 谷歌联系人 / Outlook 都能导出)与
<b>.csv / .txt</b>(表头里有「姓名 / 手机 / 邮箱」之类就能认,中英文表头都行)。
<br>
导入规则:<b>手机号或姓名已经在名册里的,不会重复建人</b>,
只把「他还没有的信息」补上去(已有的一律不动);其余才新建。
</p>
<form method="post" action="<?= e(url('people/import')) ?>" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="form-grid">
<div class="field full">
<label for="imp-file">联系人文件 *</label>
<?php $sdName = 'contacts'; $sdId = 'imp-file';
$sdAccept = '.vcf,.csv,.txt,text/vcard,text/csv,text/plain';
$sdMultiple = false; $sdRequired = true;
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
<div class="field">
<label for="imp-group">加进哪一组</label>
<select class="select" id="imp-group" name="group_id">
<option value="0">先不分类</option>
<?php foreach ($groups as $g): ?>
<option value="<?= (int)$g['id'] ?>"><?= e((string)$g['name']) ?></option>
<?php endforeach; ?>
</select>
<span class="tip">只影响<b>新建</b>的那些人;已有的人保持他原来的分组。</span>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit">开始导入</button>
</div>
</form>
</div>
<?php if ($report): ?>
<div class="card card-pad">
<h2 class="cinema-h2">
导入结果
<span class="hint">新增 <?= (int)$report['added'] ?> · 补充 <?= (int)$report['updated'] ?> · 无需改动 <?= (int)$report['skipped'] ?></span>
</h2>
<?php if (!$report['people']): ?>
<p class="empty-hint">文件里没有可导入的联系人。</p>
<?php else: ?>
<?php /* ⚠️ 报告的是"这条记录做了什么",逐条列出来:
批量操作最怕"导完不知道改了什么"(尤其"补充"这一类会动到已有的人)。 */ ?>
<div class="table-wrap">
<table class="table">
<thead><tr><th>联系人</th><th>结果</th><th>补上了什么</th></tr></thead>
<tbody>
<?php foreach ($report['people'] as $row): ?>
<?php
$act = (string)$row['action'];
$word = $act === 'added' ? '新建' : ($act === 'updated' ? '补充信息' : '无需改动');
$cls = $act === 'added' ? 'led-asset' : ($act === 'updated' ? 'led-asset' : '');
?>
<tr>
<td><?= e((string)$row['name']) ?></td>
<td class="<?= $cls ?>"><?= e($word) ?></td>
<td class="small muted">
<?= $row['added'] ? e(implode('、', (array)$row['added'])) : '—' ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<div class="btn-row" style="margin-top:14px">
<a class="btn btn-ghost" href="<?= e(url('people')) ?>">去看名册</a>
<a class="btn btn-ghost" href="<?= e(url('people/import')) ?>">再导一份</a>
</div>
</div>
<?php endif; ?>