diff --git a/assets/style.css b/assets/style.css index ae40274..9e425b0 100644 --- a/assets/style.css +++ b/assets/style.css @@ -444,6 +444,31 @@ input[type="datetime-local"], input[type="date"] { cursor: pointer; } input[type="datetime-local"]::-webkit-calendar-picker-indicator, input[type="date"]::-webkit-calendar-picker-indicator { cursor: pointer; padding: 2px; } +/* ── 日期 + 24 小时制「时:分」组合输入(templates/_datetime.php)────────────── + 原生 datetime-local 的显示格式由浏览器 UI 语言/系统时间格式决定,中文环境渲染成 + 「下午 03:30」,页面和元素的 lang 都压不住(实测),所以拆成 date + 时/分两个下拉。 + 24 小时制由控件本身决定,浏览器改不掉。 */ +.dt-input { display: flex; align-items: center; gap: 6px; } +.dt-input .dt-date { flex: 1 1 120px; min-width: 0; } +.dt-input .dt-hour, +.dt-input .dt-minute { + flex: 0 0 64px; width: 64px; padding: 9px 4px; + text-align: center; text-align-last: center; +} +.dt-input .dt-colon { color: var(--text-mute); font-weight: 650; } + +/* 裁剪入口(节点表单):「逐张裁剪」按钮 + 一句说明 */ +.crop-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; } +.crop-toolbar .muted { margin-top: 0; font-size: 12px; } + +/* 节点表单待上传卡片上的「已裁剪」角标。 + .photo-badge 本来是给照片页 .photo-item(flex 纵向布局)的流式元素用的, + 这里换到 .photo-card(position:relative)就得改成绝对定位,压在缩略图左上角。 */ +.photo-card > .photo-badge { + position: absolute; top: 6px; left: 6px; margin: 0; + background: rgba(18, 133, 90, .92); color: #fff; +} + .form-actions { display: flex; align-items: center; gap: 10px; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); diff --git a/src/Controllers/MemberController.php b/src/Controllers/MemberController.php index a7a440f..d037ded 100644 --- a/src/Controllers/MemberController.php +++ b/src/Controllers/MemberController.php @@ -28,8 +28,12 @@ class MemberController (int)($_POST['user_id'] ?? 0), array_map('intval', $roleTagIds), trim((string)($_POST['emotion'] ?? '')), - (string)($_POST['check_in_time'] ?? '') + // 「日期 + 24 小时制时/分」组合控件;留空时 read_dt_input 返回 null,语义是「未签到」 + (string)(read_dt_input('check_in_time') ?? '') ); + if (!$result['ok']) { + flash_old($_POST); // 失败也别让人白填 + } flash('error', $result['ok'] ? '已保存参与人' : $result['error']); redirect('nodes/show', ['id' => $nodeId], 'members'); diff --git a/src/Controllers/NodeController.php b/src/Controllers/NodeController.php index 0df6c22..6120939 100644 --- a/src/Controllers/NodeController.php +++ b/src/Controllers/NodeController.php @@ -64,6 +64,8 @@ class NodeController $input = $this->collectInput($db, $activity, $category); if ($input['error'] !== null) { + // 校验失败必须把用户刚填的内容原样带回去,否则一重定向表单就是空的,等于白填 + flash_old($_POST); flash('error', $input['error']); redirect('nodes/create', ['activity_id' => $activity['id'], 'category' => $category]); } @@ -118,6 +120,7 @@ class NodeController $input = $this->collectInput($db, $activity, $category, $node); if ($input['error'] !== null) { + flash_old($_POST); // 同 store():失败不能让用户白填 flash('error', $input['error']); redirect('nodes/edit', ['id' => (int)$node['id']]); } @@ -378,8 +381,9 @@ class NodeController */ private function collectInput(\PDO $db, array $activity, string $category, ?array $node = null): array { - $startTime = $this->normalizeDateTime((string)($_POST['start_time'] ?? '')); - $endTime = $this->normalizeDateTime((string)($_POST['end_time'] ?? '')); + // 「日期 + 24 小时制时/分」组合控件(read_dt_input 里说明了为什么不用 datetime-local) + $startTime = read_dt_input('start_time'); + $endTime = read_dt_input('end_time'); if ($startTime === null || $endTime === null) { return ['error' => '开始和结束时间必填', 'data' => []]; } @@ -451,17 +455,6 @@ class NodeController return ['error' => null, 'data' => $data]; } - /** datetime-local 传上来是 2026-09-12T10:30,统一成 MySQL 认的 "Y-m-d H:i:s" */ - private function normalizeDateTime(string $value): ?string - { - $value = trim($value); - if ($value === '') return null; - $value = str_replace('T', ' ', $value); - $ts = strtotime($value); - if ($ts === false) return null; - return date('Y-m-d H:i:s', $ts); - } - private function fetchOwnActivity(int $id): array { if ($id <= 0) { @@ -530,7 +523,8 @@ class NodeController /** * 请求体超限的统一提示。 * - * 这时 $_POST 已经被 PHP 丢掉了,拿不到 activity_id,只能尽量退回一个合理页面。 + * 这时 $_POST 已经被 PHP 丢掉了,**无从回填**(服务端拿不到任何字段), + * 只能尽量退回一个合理页面;已填内容的恢复靠表单里的 sessionStorage 草稿。 */ private function flashTooLarge(): void { diff --git a/src/helpers.php b/src/helpers.php index 7128b7b..5b00f69 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -88,6 +88,113 @@ function csrf_token(): string return $_SESSION['_csrf']; } +/** + * 把这次提交的原始字段存进 session,供紧接着的那次渲染回填。 + * + * 为什么需要:控制器校验失败时只能 redirect(POST/Redirect/GET),一重定向表单就被重新渲染成 + * 空的了——用户填了半天的内容全没了。把 $_POST 原样留着,模板用 old() 取回来即可。 + * + * 生命周期是「一次渲染」:layout.php 渲染完正文就 unset(见那里的注释), + * 所以它不会跟着用户跑到别的页面,也不会把上一次的残留混进下一次新建。 + */ +function flash_old(array $data): void +{ + $_SESSION['_old'] = $data; +} + +/** 回填值:读取上一次提交的同名字段;没有就给 $default。数组字段(如 xxx[])不参与回填 */ +function old(string $key, ?string $default = null): ?string +{ + $v = $_SESSION['_old'][$key] ?? null; + if ($v === null || is_array($v)) { + return $default; + } + return (string)$v; +} + +/** 回填「多选」字段(如 delete_attachments[])——用于恢复勾选状态 */ +function old_array(string $key): array +{ + $v = $_SESSION['_old'][$key] ?? null; + if (!is_array($v)) { + return []; + } + return array_map(static fn($x) => (string)$x, $v); +} + +/** + * 回填「日期 + 时:分」组合控件(见 templates/_datetime.php)。 + * + * 三个子字段要合并回一个 "Y-m-d H:i:s" 才好喂给模板里的拆分逻辑; + * 只要日期存在就认为用户在填(时分缺省按 00 补)。 + */ +function old_dt(string $name, ?string $fallback): string +{ + $date = old($name . '_date'); + if ($date === null || trim($date) === '') { + return (string)($fallback ?? ''); + } + $h = old($name . '_hour', '00'); + $m = old($name . '_minute', '00'); + return trim($date) . ' ' . str_pad((string)$h, 2, '0', STR_PAD_LEFT) + . ':' . str_pad((string)$m, 2, '0', STR_PAD_LEFT) . ':00'; +} + +/** 把各种写法的时间串统一成 MySQL 认的 "Y-m-d H:i:s";认不出来返回 null */ +function normalize_dt_string(string $value): ?string +{ + $value = trim($value); + if ($value === '') { + return null; + } + // datetime-local 传上来是 2026-09-12T10:30 + $value = str_replace('T', ' ', $value); + $ts = strtotime($value); + if ($ts === false) { + return null; + } + return date('Y-m-d H:i:s', $ts); +} + +/** + * 读取「日期 + 24 小时制时/分」三个控件拼成的时间。 + * + * ⚠️ 为什么不用 :它的**显示格式由浏览器 UI 语言 / + * 系统时间格式决定**,页面和元素的 lang 属性完全无效。实测(chromium 1243): + * --lang=zh-CN → 「2026/09/23 下午 03:30」(12 小时制) + * --lang=en-GB → 「23/09/2026 15:30」(24 小时制) + * 连 + 元素 lang="en-GB" 都压不过它,所以只能换成 + * 「原生日期选择器 + 时/分两个下拉」,24 小时制是控件本身决定的,不可能被浏览器改掉。 + * + * 兼容旧的单个字段名(值形如 2026-09-23T15:30)——旧书签、接口测试可能还在这么传。 + */ +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'] ?? '')); + + if ($date === '' && $hRaw === '' && $mRaw === '') { + return normalize_dt_string((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; + $m = $mRaw === '' ? 0 : (int)$mRaw; + if ($h < 0 || $h > 23 || $m < 0 || $m > 59) { + return null; + } + return sprintf('%s %02d:%02d:00', $date, $h, $m); +} + /** * 校验 CSRF。两个通道都收: * 表单 → 隐藏字段 _csrf diff --git a/templates/_crop_modal.php b/templates/_crop_modal.php new file mode 100644 index 0000000..4b1449f --- /dev/null +++ b/templates/_crop_modal.php @@ -0,0 +1,35 @@ + +
+ diff --git a/templates/_datetime.php b/templates/_datetime.php new file mode 100644 index 0000000..a5da586 --- /dev/null +++ b/templates/_datetime.php @@ -0,0 +1,44 @@ +:它的显示格式由**浏览器 UI 语言 / + * 系统时间格式**决定, 与元素 lang 都压不过它(实测 chromium 1243: + * --lang=zh-CN 渲染成「下午 03:30」,用户会混淆;--lang=en-GB 才是 24 小时制)。 + * 拆成「原生日期选择器 + 时/分两个下拉」后,24 小时制由控件本身决定,浏览器改不掉。 + * 日期部分仍是原生 date 控件(layout.php 里的 showPicker 全局脚本对它同样生效, + * 点输入框任意位置都能弹出日历)。 + * + * 调用方式:include 前 set 好这几个变量(模板里只有它们会被读): + * $dtName 字段基名,如 'start_time' → 产出 *_date / *_hour / *_minute 三个字段 + * $dtValue 可空;"Y-m-d H:i:s" / "Y-m-d H:i" / null。服务端取值用 read_dt_input($dtName) + * $dtRequired 日期是否必填(默认 false;必填时在日期控件上加 required) + * $dtId 可选;给日期控件的 id(供