行程:#47~#51 打包 —— 双轨状态、动态类型、动作按钮、去提示 (#51)
#47 编辑浮层居中、类型换行、去掉手动状态选择、按钮改胶囊 #48 计划/实际双轨:完成即转实际行程,取消≠删除,记真实开始/结束时间 #50 类型点击无反应(@Builder 值传参不建立依赖) #51 节点类型动态加载(服务端9种,原端上硬编码5种)+ 地址结构化回调 API 新增:GET /trips/<id>/node-types、PATCH /trips/<id>/nodes/action DB 新增:trip_nodes.actual_started_at / actual_ended_at
This commit is contained in:
+322
-1
@@ -102,6 +102,17 @@ final class ApiHandler
|
||||
case 'movies': $this->movies($id, $sub, $method); return;
|
||||
case 'cinemas': $this->cinemas($id, $method); return;
|
||||
case 'trips': $this->trips($id, $sub, $method); return;
|
||||
/* 🔴 2026-11-05:**暂存上传**(用户要求"先上传拿到地址,保存时再挂")。
|
||||
|
||||
网页端的拖拽上传走的就是这条:文件先落暂存区拿 stoken,
|
||||
保存表单时把 stoken 一起提交,后端建好宿主(行程节点)后
|
||||
再 claim 到它名下。
|
||||
|
||||
⚠️ 必须单独一个资源(`/uploads/stage`)而不是挂在 trips 下面:
|
||||
`Api::dispatch()` 只给三段,挂 `/trips/<id>/stage` 会和
|
||||
"某趟旅程的某个子动作"语义混淆;而暂存文件是**与宿主无关**的。
|
||||
⚠️ 只能上传,不能列举/删除 —— 暂存区是内部中间态,不暴露管理接口。 */
|
||||
case 'uploads': $this->uploads($sub, $method); return;
|
||||
/* 🔴 v66:旅程三项设置里的两项(第三项「出行方式」是枚举常量,没有接口)。
|
||||
⚠️ 必须与 `trips` 平级,不能做成 `trips/vehicles` 子路由 ——
|
||||
`Api::dispatch()` 只解析三段(`/<res>/<id>/<sub>`),
|
||||
@@ -122,6 +133,9 @@ final class ApiHandler
|
||||
必须验旧密码、两次新密码一致、且过 `Account::validatePassword()`
|
||||
(≥8 位 + 字母与数字都有)—— 端上不该比网页松。 */
|
||||
case 'account': $this->account($sub, $method); return;
|
||||
/* 🔴 2026-11-03:地址联想(端上地点输入框的下拉候选)。
|
||||
频控口径见 `geo()` 的注释(每小时 300 次)。 */
|
||||
case 'geo': $this->geo($method); return;
|
||||
}
|
||||
|
||||
Api::fail('未知的接口资源:' . $res, 'not_found', 404);
|
||||
@@ -1371,6 +1385,54 @@ final class ApiHandler
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 地址联想:`GET /api/v1/geo/suggest?q=招商银行&limit=8`
|
||||
*
|
||||
* 🔴 2026-11-03 新增。鸿蒙端 Map Kit 报 `1002600003`(AGC 认证失败),
|
||||
* 而 AGC 那条链路要开地图服务 + 重新生成 Profile,可能等 24 小时。
|
||||
* ⇒ 端上改调我们的服务器,服务器用**高德 Web 服务 key** 转发 inputtips。
|
||||
*
|
||||
* 为什么这比 Map Kit 更好(不只是绕过 AGC):
|
||||
* · **数据源与网页端完全一致** —— 网页 `addr_suggest.js` 也走高德,
|
||||
* 两端搜到的 POI 是同一套,不会再出现"网页能搜到、手机搜不到";
|
||||
* · 高德 inputtips 直接给 `district`(省市区)+ `address`(门牌)+ `location`(坐标),
|
||||
* 地址天生完整,不用像 Map Kit 那样拿几个字段自己拼。
|
||||
*
|
||||
* ## 频控
|
||||
*
|
||||
* 每小时 300 次(按令牌计)。理由:
|
||||
* · 地址联想是**打字触发**的,一次输入可能查 3~5 次(防抖后);
|
||||
* · 正常用户一小时输入十几个地点,300 次足够宽松;
|
||||
* · 高德 Web 服务 key 有日配额,被刷会**影响整个账号**,
|
||||
* 所以这里必须有闸(宁可偶尔误伤,也不能让配额被打光)。
|
||||
*
|
||||
* ⚠️ 超限**不报错**,返回空数组 —— 联想是可选能力,
|
||||
* 失败时输入框退化成普通文本框,用户手敲照常保存。
|
||||
*/
|
||||
private function geo(string $method): void
|
||||
{
|
||||
[, $pid] = Api::me();
|
||||
|
||||
$q = trim((string)($_GET['q'] ?? $_GET['keyword'] ?? ''));
|
||||
if ($q === '') {
|
||||
Api::ok(['tips' => []]);
|
||||
}
|
||||
if (mb_strlen($q, 'UTF-8') < 2) {
|
||||
// 一个字不查(与网页端同一阈值,省配额)
|
||||
Api::ok(['tips' => []]);
|
||||
}
|
||||
if (mb_strlen($q, 'UTF-8') > 40) {
|
||||
$q = mb_substr($q, 0, 40, 'UTF-8');
|
||||
}
|
||||
|
||||
$limit = (int)($_GET['limit'] ?? 8);
|
||||
if ($limit <= 0 || $limit > 20) {
|
||||
$limit = 8;
|
||||
}
|
||||
|
||||
Api::ok(['tips' => \App\Geo::suggest($q, $limit)]);
|
||||
}
|
||||
|
||||
private function notifications(int $id, string $sub, string $method): void
|
||||
{
|
||||
[, $pid] = Api::me();
|
||||
@@ -2463,6 +2525,91 @@ final class ApiHandler
|
||||
旅程 / 行程
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* `/api/v1/uploads/<sub>` —— 暂存上传。
|
||||
*
|
||||
* 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,
|
||||
* 连带着跟其他参数一起提交,后台接口拿到数据,先插入行程,
|
||||
* 拿到 id,再把图片插入进去。」
|
||||
*
|
||||
* 正是这条流程 ⇒ 文件先落暂存区(`App\StagedUpload`),拿 stoken;
|
||||
* 保存节点时把 stoken 一起提交,`Trip::saveNode` 之后由 `claimStaged()` 挂上去。
|
||||
*
|
||||
* ⚠️ 为什么不直接把文件挂在节点上(上一版的做法):
|
||||
* 新增时节点还没有 id,无处可挂 —— 这是用户指出的核心问题。
|
||||
*/
|
||||
private function uploads(string $sub, string $method): void
|
||||
{
|
||||
[$fam, $pid] = Api::me();
|
||||
Api::requireWrite();
|
||||
|
||||
if ($sub !== 'stage') {
|
||||
Api::notFound('这个上传入口');
|
||||
}
|
||||
if ($method !== 'POST') {
|
||||
Api::fail('不支持的方法。', 'method_not_allowed', 405);
|
||||
}
|
||||
|
||||
$file = $_FILES['file'] ?? null;
|
||||
if (!is_array($file)) {
|
||||
Api::fail('没有收到文件。', 'unprocessable', 422);
|
||||
}
|
||||
/* 槽位:行程照片用 'photo'(free 等比,只缩不裁)。
|
||||
⚠️ 允许调用方指定 `slot`,但只认 ImageSlot 里真实存在的
|
||||
(StagedUpload::stage 内部会把未知值回落到 'photo')。 */
|
||||
$slot = Api::str('slot', 'photo');
|
||||
$res = \App\StagedUpload::stage($fam, $pid, $file, $slot);
|
||||
if (empty($res['ok'])) {
|
||||
$m = (string)($res['error'] ?? '');
|
||||
Api::fail($m !== '' ? $m : '文件没存下来', 'unprocessable', 422);
|
||||
}
|
||||
Api::ok([
|
||||
'stoken' => (string)($res['stoken'] ?? ''),
|
||||
'name' => (string)($res['name'] ?? ''),
|
||||
'is_image' => (bool)($res['is_image'] ?? false),
|
||||
'kind' => (string)($res['kind'] ?? ''),
|
||||
'size' => (int)($res['size'] ?? 0),
|
||||
'size_word' => (string)($res['size_word'] ?? ''),
|
||||
'url' => (string)($res['url'] ?? ''),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把请求体里的 `stage_tokens` 认领到某个行程节点上。
|
||||
*
|
||||
* @return array{warns:string[]} 认领过程中的提示(数量不足等)
|
||||
*/
|
||||
private function claimStaged(int $fam, int $pid, int $nodeId): array
|
||||
{
|
||||
$body = Api::body();
|
||||
$raw = $body['stage_tokens'] ?? null;
|
||||
$tokens = [];
|
||||
if (is_array($raw)) {
|
||||
foreach ($raw as $t) {
|
||||
$t = trim((string)$t);
|
||||
if ($t !== '') {
|
||||
$tokens[] = $t;
|
||||
}
|
||||
}
|
||||
} elseif (is_string($raw) && trim($raw) !== '') {
|
||||
/* 也接受逗号串(手搓请求/别的客户端图省事时) */
|
||||
foreach (explode(',', $raw) as $t) {
|
||||
$t = trim($t);
|
||||
if ($t !== '') {
|
||||
$tokens[] = $t;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$tokens) {
|
||||
return ['warns' => []];
|
||||
}
|
||||
/* ⚠️ `target_type` 必须是 'trip' —— 与网页端 `node_form.php` 的落点一致
|
||||
(`Attachment::list('trip', $nodeId)` 读的也是它)。
|
||||
写成 'trip_node' 会导致网页端看不到端上传的照片。 */
|
||||
$res = \App\StagedUpload::claim($fam, $pid, $tokens, 'trip', $nodeId);
|
||||
return ['warns' => (array)($res['warns'] ?? [])];
|
||||
}
|
||||
|
||||
private function trips(int $id, string $sub, string $method): void
|
||||
{
|
||||
[$fam, $pid] = Api::me();
|
||||
@@ -2478,9 +2625,183 @@ final class ApiHandler
|
||||
if ($method === 'POST') {
|
||||
Api::requireWrite();
|
||||
$nid = Trip::saveNode($fam, $id, $this->nodeData(null));
|
||||
Api::ok(Trip::node($nid), 201);
|
||||
/* 🔴 2026-11-05:**先建节点、拿到 id,再挂暂存的照片**。
|
||||
用户原话:「先上传,拿到地址,然后提交的时候,连带着跟其他参数
|
||||
一起提交,后台接口拿到数据,先插入行程,拿到 id,
|
||||
再把图片插入进去。」
|
||||
⚠️ 顺序不能反:claim 需要 targetId,节点不存在就无处可挂
|
||||
(上一版"新增时传不了图"就是这个原因)。
|
||||
⚠️ 认领失败**不影响节点本身**(节点已建成)—— 只带提示返回,
|
||||
不把整次保存判失败。 */
|
||||
$claimRes = $this->claimStaged($fam, $pid, $nid);
|
||||
$nodeOut = Trip::node($nid) ?? [];
|
||||
if (!empty($claimRes['warns'])) {
|
||||
$nodeOut['warns'] = $claimRes['warns'];
|
||||
}
|
||||
Api::ok($nodeOut, 201);
|
||||
}
|
||||
/* 🔴 2026-11-05 新增:**编辑一段行程**(用户:「鸿蒙端现在行程还不能编辑」)。
|
||||
|
||||
为什么之前不能编辑:这里只有 GET / POST,端上无从调用,
|
||||
只能删了重建(会丢 id 和关联数据:成员、车辆、附件都挂在 node id 上)。
|
||||
|
||||
⚠️ 节点 id 从**请求体**读,不放进路径 —— `Api::dispatch()`
|
||||
只解析三段(`/<res>/<id>/<sub>`),四段路径进不来。
|
||||
改 dispatch 会波及全部接口,不值得。
|
||||
|
||||
⚠️ `nodeData($cur)` 的语义就是"没传的字段沿用旧值"(PATCH 口径),
|
||||
所以这里传当前行进去,只传想改的字段即可。
|
||||
⚠️ 必须校验节点属于这趟旅程:否则拿别人的 nodeId 就能改别人的行程
|
||||
(`Trip::saveNode` 内部按 id UPDATE,不做归属校验)。 */
|
||||
if ($method === 'PATCH' || $method === 'PUT') {
|
||||
Api::requireWrite();
|
||||
$nid = Api::int('id', 0);
|
||||
if ($nid <= 0) {
|
||||
Api::fail('要改哪一段行程?没传 id。', 'bad_request', 400);
|
||||
}
|
||||
/* 验证节点属于这趟旅程(防越权) */
|
||||
$chk = Database::get()->prepare(
|
||||
'SELECT * FROM trip_nodes WHERE id = ? AND event_id = ? AND deleted_at IS NULL'
|
||||
);
|
||||
$chk->execute([$nid, $id]);
|
||||
$cur = $chk->fetch();
|
||||
if (!$cur) {
|
||||
Api::notFound('这段行程');
|
||||
}
|
||||
Trip::saveNode($fam, $id, $this->nodeData($cur), $nid);
|
||||
/* 同上:编辑时也能把新暂存的照片一起挂上
|
||||
(用户在表单里加了图、点保存 → 图跟着这次提交一起生效)。 */
|
||||
$claimRes2 = $this->claimStaged($fam, $pid, $nid);
|
||||
$nodeOut2 = Trip::node($nid) ?? [];
|
||||
if (!empty($claimRes2['warns'])) {
|
||||
$nodeOut2['warns'] = $claimRes2['warns'];
|
||||
}
|
||||
Api::ok($nodeOut2);
|
||||
}
|
||||
/* 🔴 2026-11-05 新增:**行程节点的照片/附件**。
|
||||
用户:「它不能这样传图片吗?网页端有什么功能,这个就应该有什么功能。」
|
||||
|
||||
网页端 `node_form.php` 的照片落到 `attachments` 表:
|
||||
target_type = 'trip' target_id = nodeId
|
||||
⇒ 端上必须用**同一个 target_type**,否则两边各存一份、互相看不见。
|
||||
|
||||
⚠️ 路径沿用三段(节点 id 走 query)—— dispatch 只解析三段。 */
|
||||
if ($sub === 'attach') {
|
||||
$nid = Api::int('node_id', 0);
|
||||
if ($nid <= 0) {
|
||||
Api::fail('要哪一段行程的附件?没传 node_id。', 'bad_request', 400);
|
||||
}
|
||||
/* 校验节点属于这趟旅程(防越权) */
|
||||
$chkA = Database::get()->prepare(
|
||||
'SELECT id FROM trip_nodes WHERE id = ? AND event_id = ? AND deleted_at IS NULL'
|
||||
);
|
||||
$chkA->execute([$nid, $id]);
|
||||
if (!$chkA->fetch()) {
|
||||
Api::notFound('这段行程');
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
Api::ok(['items' => \App\Attachment::list('trip', $nid)]);
|
||||
}
|
||||
Api::requireWrite();
|
||||
if ($method === 'POST') {
|
||||
$file = $_FILES['file'] ?? null;
|
||||
if (!is_array($file)) {
|
||||
Api::fail('没有收到文件。', 'unprocessable', 422);
|
||||
}
|
||||
$resA = \App\Attachment::save($fam, 'trip', $nid, $file, $pid);
|
||||
if (empty($resA['ok'])) {
|
||||
$mA = (string)($resA['error'] ?? '');
|
||||
/* error 为空 = 用户没选文件(不算错误,但也别装作成功) */
|
||||
Api::fail($mA !== '' ? $mA : '没选文件', 'unprocessable', 422);
|
||||
}
|
||||
Api::ok(['id' => (int)($resA['id'] ?? 0)], 201);
|
||||
}
|
||||
if ($method === 'DELETE') {
|
||||
$aid = Api::int('attachment_id', 0);
|
||||
if ($aid <= 0) {
|
||||
Api::fail('要删哪个附件?没传 attachment_id。', 'bad_request', 400);
|
||||
}
|
||||
if (!\App\Attachment::removeFrom('trip', $nid, $aid)) {
|
||||
Api::notFound('这个附件');
|
||||
}
|
||||
Api::ok(['deleted' => $aid]);
|
||||
}
|
||||
Api::fail('不支持的方法。', 'method_not_allowed', 405);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 🔴 #47(2026-11-05):**行程状态切换**。
|
||||
用户要求状态由"动作"驱动,不再由编辑表单手工选:
|
||||
· 新建 → 固定 planned
|
||||
· 点「开始执行」 → ongoing
|
||||
· 点「已完成」 → done
|
||||
· 点「已取消」 → cancelled
|
||||
|
||||
⚠️ 之前**两端都没有这个动作** —— 状态只能靠编辑表单改,
|
||||
这正是用户要改掉的。
|
||||
⚠️ 节点 id 走请求体(dispatch 只解析三段,四段进不来)。
|
||||
⚠️ 不做状态机校验:允许任意 → 任意,用户改错了要能改回来。
|
||||
只校验值是否合法。 */
|
||||
|
||||
/* 🔴 #48(2026-11-05):**行程动作**(取代 #47 的 status 接口)。
|
||||
用户要求:状态只属于计划行程,动作驱动流转:
|
||||
· start → status=ongoing,记录 actual_started_at
|
||||
· done → **stage 转成 actual**,记录 actual_ended_at
|
||||
· cancel → stage=skipped(保留数据,不是删除)
|
||||
|
||||
⚠️ 为什么替换掉 status 接口:那个允许任意状态互转,
|
||||
与本单"完成即转实际、实际行程无状态"的模型冲突。
|
||||
两套并存会让"哪个算数"不可考。
|
||||
⚠️ 节点 id 走 body(dispatch 只解析三段)。 */
|
||||
if ($sub === 'action') {
|
||||
if ($method !== 'PATCH' && $method !== 'POST') {
|
||||
Api::fail('不支持的方法。', 'method_not_allowed', 405);
|
||||
}
|
||||
Api::requireWrite();
|
||||
$nidA = Api::int('node_id', 0);
|
||||
if ($nidA <= 0) {
|
||||
Api::fail('要操作哪一段行程?没传 node_id。', 'bad_request', 400);
|
||||
}
|
||||
$act = (string)Api::str('action', '');
|
||||
$resA = Trip::nodeAction($fam, $id, $nidA, $act);
|
||||
if (empty($resA['ok'])) {
|
||||
Api::fail((string)($resA['error'] ?? '没成功'), 'unprocessable', 422);
|
||||
}
|
||||
Api::ok(Trip::node($nidA));
|
||||
}
|
||||
}
|
||||
|
||||
/* 🔴 #51(2026-11-05):**节点类型列表**(`GET /trips/<id>/node-types`)。
|
||||
用户:「这个类型和网页端不一样啊,网页端比这个多得多呀,
|
||||
我们这上面的类型怎么这么少呢?」
|
||||
|
||||
实测库里 9 种(游玩/用餐/赶路/住宿/其他/休息/突发/购物/拜访亲友),
|
||||
而端上硬编码 5 种 —— 那是照抄了 `Trip::NODE_TYPES` 这个
|
||||
**兜底常量**,真实数据在 `node_types` 表、用户可增删。
|
||||
|
||||
⚠️ 之前 API **完全没有**这个接口,端上只能硬编码。
|
||||
⚠️ 类型按 **family** 存,与旅程同域,所以挂在 trips 下。
|
||||
⚠️⚠️ 本段**必须**与 `if ($sub === 'attach')` **同级**。
|
||||
第一版被我插进了 attach 块内部 ⇒ 请求落到默认分支
|
||||
返回旅程本体(实测 `data` 里是 id/title/kind…)。
|
||||
语法检查查不出这种错,只有真打接口才看得出来。 */
|
||||
if ($sub === 'node-types' && $method === 'GET') {
|
||||
$rows = \App\NodeType::all($fam);
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$out[] = [
|
||||
'id' => (int)($r['id'] ?? 0),
|
||||
'name' => (string)($r['name'] ?? ''),
|
||||
'icon' => (string)($r['icon'] ?? ''),
|
||||
'is_system' => (int)($r['is_system'] ?? 0),
|
||||
];
|
||||
}
|
||||
Api::ok(['items' => $out]);
|
||||
}
|
||||
|
||||
|
||||
if ($sub === 'summary' && $method === 'GET') {
|
||||
Api::ok(Trip::summary($id, $pid));
|
||||
}
|
||||
|
||||
+225
@@ -474,6 +474,101 @@ final class Geo
|
||||
* 注意「青岛胶东国际机场」能提出来是因为"青岛"是**已知城市名**(离线表里有),
|
||||
* 不是靠猜。
|
||||
*/
|
||||
/**
|
||||
* 把任意「行政区划串」归一化为**纯市名**(「青岛市」)。
|
||||
*
|
||||
* 🔴 2026-11-05 新增。用户原话:
|
||||
* 「它那个城市原来我说的是指定城市就可以,比如北京市、青岛市。
|
||||
* 现在弄得很乱,什么省也带上了,市也带上了,区甚至都带上了,
|
||||
* 这没必要,直接城市就可以。」
|
||||
*
|
||||
* ## 为什么必须在服务端做
|
||||
*
|
||||
* 前端(网页 `addr_suggest.js`、鸿蒙端)都可能传进「山东省青岛市市南区」,
|
||||
* 这是历史行为。靠"前端自觉"不可靠:
|
||||
* · 老版本 App 还在用;
|
||||
* · 第三方/脚本调用不经过我们的前端;
|
||||
* · 老数据已经脏了。
|
||||
* ⇒ 服务端统一归一化,前端传什么都剥成市名。**这是唯一的真防线。**
|
||||
*
|
||||
* ## 取值顺序
|
||||
*
|
||||
* ① 已经就是纯市名(「青岛市」)→ 原样返回
|
||||
* ② 含「XX市」→ 取**第一个**(中文地址是 省→市→区 层级顺序)
|
||||
* ⚠️ 不能取最后一个:「黑龙江省黑河市五大连池市」会得到县级市
|
||||
* 「五大连池市」;「山东省青岛市市南区」的"市南区"里还有"市"字
|
||||
* ③ 直辖市不带"市"字(「北京朝阳区」)→ 开头匹配四直辖市
|
||||
* ④ 都没有 → 返回空串(**不猜**)
|
||||
*
|
||||
* @param string $raw 可能带省/区/空格/「·」分隔的行政区划串
|
||||
* @return string 纯市名;认不出返回空串
|
||||
*/
|
||||
public static function cityOnly(string $raw): string
|
||||
{
|
||||
$s = trim($raw);
|
||||
if ($s === '') {
|
||||
return '';
|
||||
}
|
||||
/* 已经是纯市名(最常见的正常情况)→ 直接返回,不做正则 */
|
||||
if (mb_substr($s, -1, 1, 'UTF-8') === '市' && mb_strpos($s, ' ') === false
|
||||
&& mb_strpos($s, '·') === false && mb_strpos($s, '省') === false
|
||||
&& mb_strpos($s, '区') === false && mb_strpos($s, '县') === false) {
|
||||
return $s;
|
||||
}
|
||||
/* 带「·」的(老格式「山东·青岛」)→ 取后半段再走一遍 */
|
||||
$dot = mb_strpos($s, '·');
|
||||
if ($dot !== false) {
|
||||
$s = trim(mb_substr($s, $dot + 1, null, 'UTF-8'));
|
||||
if ($s !== '') {
|
||||
/* 「青岛」这种不带市的也要认 —— 补上市字再返回 */
|
||||
if (mb_substr($s, -1, 1, 'UTF-8') === '市') {
|
||||
return $s;
|
||||
}
|
||||
if (!preg_match('/[省区县旗盟]/u', $s)) {
|
||||
return $s . '市';
|
||||
}
|
||||
}
|
||||
}
|
||||
/* 空格分隔的(「山东省 青岛市 市南区」)→ 逐段找第一个「XX市」 */
|
||||
$segs = preg_split('/\s+/u', $s) ?: [];
|
||||
foreach ($segs as $seg) {
|
||||
$got = self::firstCityWord($seg);
|
||||
if ($got !== '') {
|
||||
return $got;
|
||||
}
|
||||
}
|
||||
/* 整串里找第一个「XX市」 */
|
||||
$got = self::firstCityWord($s);
|
||||
if ($got !== '') {
|
||||
return $got;
|
||||
}
|
||||
/* 直辖市不带"市"字(「北京朝阳区」) */
|
||||
foreach (self::MUNICIPALITIES as $m) {
|
||||
$short = rtrim($m, '市');
|
||||
if ($short !== '' && mb_strpos($s, $short) === 0) {
|
||||
return $m;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一个片段里取**第一个**「XX市」,XX 中不允许出现层级字眼。
|
||||
*
|
||||
* ⚠️ `[^省市县区旗盟]` 这个字符类不能省:
|
||||
* 否则「山东省青岛市」会匹配到「山东省市」(把"山东省"当成了城市名前缀)。
|
||||
*/
|
||||
private static function firstCityWord(string $s): string
|
||||
{
|
||||
if ($s === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('/([^\s省市县区旗盟的了吧呢啊个这那有字方地]{2,6})市/u', $s, $m)) {
|
||||
return $m[1] . '市';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function cityFromText(string $text): string
|
||||
{
|
||||
$t = trim($text);
|
||||
@@ -636,6 +731,136 @@ final class Geo
|
||||
}
|
||||
|
||||
/** 高德 **Web 服务** key(服务端逆地理用;没配返回空串 → 自动走离线兜底) */
|
||||
/**
|
||||
* 地址联想(高德输入提示)。
|
||||
*
|
||||
* 🔴 2026-11-03 新增。鸿蒙端 Map Kit 报 1002600003(AGC 认证失败),
|
||||
* 而 AGC 那条链路需要开地图服务 + 重新生成 Profile,可能等 24 小时。
|
||||
* ⇒ 加这个接口:端上走我们的服务器,服务器用**高德 Web 服务 key** 调 inputtips。
|
||||
*
|
||||
* 好处(不只是绕过 AGC):
|
||||
* · **数据源与网页端完全一致** —— 网页 `addr_suggest.js` 也用高德,
|
||||
* 两端选中的地址是同一套 POI,不会再出现"网页能搜到、手机搜不到";
|
||||
* · inputtips 直接给 `district`(省市区)+ `address`(门牌)+ `location`(坐标),
|
||||
* 地址天生完整,不用像 Map Kit 那样自己拼。
|
||||
*
|
||||
* @param string $kw 关键词
|
||||
* @param int $limit 最多几条
|
||||
* @return array<int,array{name:string,detail:string,district:string,address:string,lng:string,lat:string}>
|
||||
* **任何失败都返回空数组**,不抛错(联想挂掉不能拖垮"记一条日程")
|
||||
*/
|
||||
public static function suggest(string $kw, int $limit = 8): array
|
||||
{
|
||||
$q = trim($kw);
|
||||
if ($q === '' || mb_strlen($q, 'UTF-8') < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$key = self::webKey();
|
||||
if ($key === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = 'https://restapi.amap.com/v3/assistant/inputtips'
|
||||
. '?key=' . urlencode($key)
|
||||
. '&keywords=' . urlencode($q)
|
||||
. '&datatype=all'
|
||||
. '&citylimit=false';
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 6,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($err !== '' || !is_string($body) || $body === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$j = json_decode($body, true);
|
||||
if (!is_array($j) || (string)($j['status'] ?? '') !== '1' || empty($j['tips'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($j['tips'] as $t) {
|
||||
/* ⚠️ 高德的字段类型**不保证是字符串**:
|
||||
实测 `district`/`address`/`location` 在部分条目里是**数组**(空数组),
|
||||
直接当字符串用会触发 "Array to string conversion" 警告。
|
||||
⇒ 统一过一遍 toStr()。 */
|
||||
$name = self::toStr($t['name'] ?? '');
|
||||
$dist = self::toStr($t['district'] ?? '');
|
||||
$addr = self::toStr($t['address'] ?? '');
|
||||
$loc = self::toStr($t['location'] ?? '');
|
||||
|
||||
/* ⚠️ 有些条目是"纯行政区划"(如 name=招商银行、district=空、address=空),
|
||||
没有地址也没有坐标 ⇒ 面板上就是一行空白,跳过。 */
|
||||
if ($name === '' || ($addr === '' && $dist === '' && $loc === '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lng = '';
|
||||
$lat = '';
|
||||
if ($loc !== '' && strpos($loc, ',') !== false) {
|
||||
$seg = explode(',', $loc);
|
||||
if (count($seg) === 2) {
|
||||
// 高德给的是 "经度,纬度"
|
||||
$lng = (string)$seg[0];
|
||||
$lat = (string)$seg[1];
|
||||
}
|
||||
}
|
||||
|
||||
// 详细地址 = 省市区 + 门牌(用户要的"参考信息")
|
||||
$detail = $dist;
|
||||
if ($addr !== '') {
|
||||
$detail = $detail === '' ? $addr : ($detail . $addr);
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'detail' => $detail,
|
||||
'district' => $dist,
|
||||
'address' => $detail,
|
||||
'lng' => $lng,
|
||||
'lat' => $lat,
|
||||
];
|
||||
if (count($out) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把高德返回的值统一成字符串。
|
||||
*
|
||||
* 高德的 PHP 侧字段类型不稳定(字符串 / 空数组 / 数组都见过),
|
||||
* 直接 `(string)` 会触发 "Array to string conversion" 警告并得到 "Array"。
|
||||
*/
|
||||
private static function toStr($v): string
|
||||
{
|
||||
if (is_string($v)) {
|
||||
return trim($v);
|
||||
}
|
||||
if (is_int($v) || is_float($v)) {
|
||||
return (string)$v;
|
||||
}
|
||||
// 数组/对象/null 一律当空(高德用 [] 表示"没这个字段")
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 端上地址联想用的 key(Web 服务类型) */
|
||||
public static function webKey(): string
|
||||
{
|
||||
$a = fb_config('amap_web');
|
||||
return is_array($a) ? trim((string)($a['key'] ?? '')) : '';
|
||||
}
|
||||
|
||||
public static function apiKey(): string
|
||||
{
|
||||
$a = fb_config('amap');
|
||||
|
||||
@@ -1542,6 +1542,22 @@ SQL,
|
||||
"ADD COLUMN `revised_at` DATETIME DEFAULT NULL COMMENT '最后一次修正时间' AFTER `stage`");
|
||||
self::addColumn($db, 'trip_nodes', 'revision_n',
|
||||
"ADD COLUMN `revision_n` INT NOT NULL DEFAULT 0 COMMENT '修正次数' AFTER `revised_at`");
|
||||
|
||||
/* 🔴 #48(2026-11-05):**真实开始 / 结束时间**。
|
||||
用户:「我计划今天 8:00 到 10:00 开车去公园,但我可能出门晚了,
|
||||
8:10 或 8:30 才点『开始』按钮。这时候就要记录住 8:30 这个时间,
|
||||
因为 8:30 会成为实际行程的开始时间。也就是说,
|
||||
**原来计划的时间和现在真正开始的时间都要记录**。」
|
||||
|
||||
⚠️ 不能复用 `starts_at` / `ends_at`(那是**计划**时间),
|
||||
改掉等于把计划丢了。两者并存才能"计划 8:00–10:00 / 实际 8:30–10:30"。
|
||||
⚠️ 列名带 `actual_` 前缀,与 `starts_at` 形成清晰对照。 */
|
||||
self::addColumn($db, 'trip_nodes', 'actual_started_at',
|
||||
"ADD COLUMN `actual_started_at` DATETIME DEFAULT NULL
|
||||
COMMENT '真实开始时间(点「开始」那一刻;NULL=还没开始)' AFTER `ends_at`");
|
||||
self::addColumn($db, 'trip_nodes', 'actual_ended_at',
|
||||
"ADD COLUMN `actual_ended_at` DATETIME DEFAULT NULL
|
||||
COMMENT '真实结束时间(点「完成」那一刻;NULL=还没完成)' AFTER `actual_started_at`");
|
||||
// ② 建修正历史表(老库没有)
|
||||
self::tryStep('建行程修正历史表', static function () use ($db) {
|
||||
$db->exec(<<<'SQL'
|
||||
|
||||
+215
-10
@@ -904,9 +904,152 @@ final class Trip
|
||||
* 这里一律**存下来**,界面按出行方式决定显示哪几个(详情页只显示有值的 +
|
||||
* 该出行方式相关的),这样换个方式再换回来,填过的东西还在。
|
||||
*/
|
||||
/**
|
||||
* 行程动作常量(#48)。
|
||||
*
|
||||
* ⚠️ 只做三个动作,**不含"删除"**:删除走既有软删(`deleted_at`),
|
||||
* 与"取消"是两件完全不同的事 —— 用户明确要求区分:
|
||||
* · 取消 = 不去了,数据保留,列在「未成行」组
|
||||
* · 删除 = 彻底不要了,不再显示
|
||||
*/
|
||||
public const NODE_ACTIONS = ['start', 'done', 'cancel'];
|
||||
|
||||
/**
|
||||
* 执行一个行程动作(#48 的核心逻辑)。
|
||||
*
|
||||
* 用户原话(2026-11-05):
|
||||
* 「添加计划行程时,刚开始添加的时候,它的状态就是"计划中"。
|
||||
* 当我开始执行这个计划时…这时候就要记录住 8:30 这个时间,因为 8:30 会
|
||||
* 成为实际行程的开始时间。也就是说,原来计划的时间和现在真正开始的时间都要记录。
|
||||
* 一旦开始,这个行程的状态就变成"进行中"。等到走完了…我点"完成"。
|
||||
* 这时候这个内容或者计划行程就变成一个实际行程。
|
||||
* **一个实际行程没有再没有状态了,因为实际已经发生完了。**
|
||||
* 但如果这个计划我没有去做…点击一个按钮,然后它就变成了"取消"或者"删除"…
|
||||
* **取消和删除是不一样的**。」
|
||||
*
|
||||
* ## 三个动作
|
||||
*
|
||||
* | action | stage | status | 真实时间 |
|
||||
* |---|---|---|---|
|
||||
* | `start` | 不变(plan) | `ongoing` | 写 `actual_started_at` = 此刻 |
|
||||
* | `done` | **plan → actual** | `done` | 写 `actual_ended_at` = 此刻 |
|
||||
* | `cancel` | **plan → skipped** | `cancelled` | 不写 |
|
||||
*
|
||||
* ⚠️ `done` 会把 stage 改成 `actual` —— 这是"完成即转实际"的落点。
|
||||
* 转成 actual 之后,界面**不再显示任何状态**(用户:实际行程没状态)。
|
||||
* ⚠️ `cancel` 改成 `skipped` 而不是软删:数据要保留,
|
||||
* 最后旅程结束时要能看到"哪些计划没做"。
|
||||
*
|
||||
* @param string $action start / done / cancel
|
||||
* @return array{ok:bool,stage?:string,status?:string,error?:string}
|
||||
*/
|
||||
public static function nodeAction(int $familyId, int $eventId, int $nodeId, string $action): array
|
||||
{
|
||||
if (!in_array($action, self::NODE_ACTIONS, true)) {
|
||||
return ['ok' => false, 'error' => '不认识的动作。'];
|
||||
}
|
||||
$db = Database::get();
|
||||
$st = $db->prepare(
|
||||
'SELECT * FROM trip_nodes WHERE id = ? AND event_id = ? AND deleted_at IS NULL'
|
||||
);
|
||||
$st->execute([$nodeId, $eventId]);
|
||||
$cur = $st->fetch();
|
||||
if (!$cur) {
|
||||
return ['ok' => false, 'error' => '找不到这段行程。'];
|
||||
}
|
||||
/* ⚠️ 已经转成实际行程的,不再接受状态类动作。
|
||||
用户:「如果这个行程已经转变成实际行程了…那这个行程就不再有其他状态了。」
|
||||
⇒ `done` 之后点 `start` / `cancel` 都无意义,直接拒绝。 */
|
||||
$curStage = (string)($cur['stage'] ?? 'plan');
|
||||
if ($curStage === 'actual' && $action !== 'done') {
|
||||
return ['ok' => false, 'error' => '这段已经是实际行程了,不再有状态。'];
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$stage = $curStage;
|
||||
$status = (string)($cur['status'] ?? 'planned');
|
||||
|
||||
if ($action === 'start') {
|
||||
$status = 'ongoing';
|
||||
/* 只在**第一次**开始时记录:重复点不该刷新真实开始时间 */
|
||||
if ((string)($cur['actual_started_at'] ?? '') === '') {
|
||||
$up = $db->prepare('UPDATE trip_nodes SET actual_started_at = ? WHERE id = ?');
|
||||
$up->execute([$now, $nodeId]);
|
||||
}
|
||||
} elseif ($action === 'done') {
|
||||
$stage = 'actual';
|
||||
$status = 'done';
|
||||
/* 若用户没点过「开始」直接点「完成」,用计划开始时间兜底记实际开始
|
||||
(否则实际行程缺开始时间,展示上少一半信息) */
|
||||
if ((string)($cur['actual_started_at'] ?? '') === '') {
|
||||
$up0 = $db->prepare(
|
||||
'UPDATE trip_nodes SET actual_started_at = COALESCE(starts_at, ?) WHERE id = ?'
|
||||
);
|
||||
$up0->execute([$now, $nodeId]);
|
||||
}
|
||||
$up2 = $db->prepare('UPDATE trip_nodes SET actual_ended_at = ? WHERE id = ?');
|
||||
$up2->execute([$now, $nodeId]);
|
||||
} else { // cancel
|
||||
$stage = 'skipped';
|
||||
$status = 'cancelled';
|
||||
}
|
||||
|
||||
/* 同步 category(老的双轨字段,由 stage 派生,两边不能打架) */
|
||||
$category = $stage === 'actual' ? 'reality' : 'plan';
|
||||
|
||||
$up3 = $db->prepare(
|
||||
'UPDATE trip_nodes SET stage = ?, status = ?, category = ?, updated_at = NOW()
|
||||
WHERE id = ?'
|
||||
);
|
||||
$up3->execute([$stage, $status, $category, $nodeId]);
|
||||
|
||||
return ['ok' => true, 'stage' => $stage, 'status' => $status];
|
||||
}
|
||||
|
||||
/**
|
||||
* 这段行程该显示哪些动作(#48)。
|
||||
*
|
||||
* ⚠️ 前后端**共用同一份规则**,避免"网页端能点、端上不能点"这种不一致。
|
||||
*
|
||||
* 规则:
|
||||
* · `actual` → 空数组(实际行程没有状态类动作,只有编辑/删除)
|
||||
* · `skipped` → 空数组(已经取消,不再转)
|
||||
* · `plan` + planned → 开始
|
||||
* · `plan` + ongoing → 完成
|
||||
* · `plan` + 其他 → 开始
|
||||
*
|
||||
* @return string[] 动作 key 列表
|
||||
*/
|
||||
public static function nodeAvailableActions(string $stage, string $status): array
|
||||
{
|
||||
if ($stage === 'actual' || $stage === 'skipped') {
|
||||
/* ⚠️ 实际行程 / 未成行**没有状态动作** ——
|
||||
用户:「实际行程是没有状态的…实际已经发生完了,它再有状态就没有意义了。」 */
|
||||
return [];
|
||||
}
|
||||
if ($status === 'ongoing') {
|
||||
return ['done'];
|
||||
}
|
||||
return ['start'];
|
||||
}
|
||||
|
||||
public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int
|
||||
{
|
||||
$db = Database::get();
|
||||
/* 🔴 2026-11-05(#27 事故):**先取出当前行**,供下面"没传就沿用原值"用。
|
||||
⚠️ 不要用 `$cur ?? []` —— `saveNode()` 这层根本没有 `$cur`
|
||||
(那是 `ApiHandler::trips()` 的局部变量),
|
||||
写成 `?? []` 会永远拿到空数组,"保留原值"形同虚设。
|
||||
CUR_ROW_LOADED */
|
||||
$curRow = [];
|
||||
if ($id !== null && $id > 0) {
|
||||
$sc = $db->prepare('SELECT * FROM trip_nodes WHERE id = ?');
|
||||
$sc->execute([$id]);
|
||||
$got = $sc->fetch();
|
||||
if (is_array($got)) {
|
||||
$curRow = $got;
|
||||
}
|
||||
}
|
||||
// v24 第 13 批:新加时由用户选「计划行程 / 实际行程」(用户原话第 2 条)。
|
||||
// 编辑时若表单带了 stage 就一并改(修订面板里"标记为实际"走的是另一个方法)。
|
||||
$stage = in_array($d['stage'] ?? '', array_keys(self::NODE_STAGES), true)
|
||||
@@ -923,18 +1066,54 @@ final class Trip
|
||||
// 用户第 3 条要「用高德地图的接口,记录详细地址」,第 1 条要按**城市**聚合统计。
|
||||
// 用户第 3 条又明确「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
|
||||
// → `seat_info` / `gate` 不再从表单读(列还留着,老数据不丢)。
|
||||
$fromPlace = (string)($d['from_place'] ?? '');
|
||||
$toPlace = (string)($d['to_place'] ?? '');
|
||||
$fromAddr = trim((string)($d['from_addr'] ?? ''));
|
||||
$toAddr = trim((string)($d['to_addr'] ?? ''));
|
||||
$fromLng = self::coordOrNull($d['from_lng'] ?? null);
|
||||
$fromLat = self::coordOrNull($d['from_lat'] ?? null);
|
||||
$toLng = self::coordOrNull($d['to_lng'] ?? null);
|
||||
$toLat = self::coordOrNull($d['to_lat'] ?? null);
|
||||
/* 🔴🔴 2026-11-05 事故修复(#27「北京返回青岛」的出发地/到达地被清空)。
|
||||
|
||||
原来这 8 行是 `(string)($d['xxx'] ?? '')` —— **无条件取**:
|
||||
调用方没传 `from_place` 就得到空串,接着 `textOrNull2('')` 写成 NULL,
|
||||
**库里原有的地点被静默抹掉**。
|
||||
|
||||
踩到的调用方:
|
||||
· 端上编辑浮层(旧版只传 `place`,不传 from_place/to_place)
|
||||
· 任何脚本/第三方调用
|
||||
· 我的验证脚本(就是这次事故的直接原因)
|
||||
|
||||
⇒ 改成 PATCH 语义:**没传就沿用原值,传了空串才是明确清空**。
|
||||
这样"用户主动删掉地点"仍然生效(他传空串),
|
||||
而"调用方不关心这个字段"不会造成破坏。
|
||||
|
||||
⚠️ `$cur` 是本方法开头查出来的当前行(编辑时有值,新建时为 null)。
|
||||
取不到时退回空串 —— 新建本来就该是空。 */
|
||||
$hasF = static fn(string $k): bool => array_key_exists($k, $d);
|
||||
$fromPlace = $hasF('from_place')
|
||||
? (string)$d['from_place'] : (string)($curRow['from_place'] ?? '');
|
||||
$toPlace = $hasF('to_place')
|
||||
? (string)$d['to_place'] : (string)($curRow['to_place'] ?? '');
|
||||
$fromAddr = $hasF('from_addr')
|
||||
? trim((string)$d['from_addr']) : (string)($curRow['from_addr'] ?? '');
|
||||
$toAddr = $hasF('to_addr')
|
||||
? trim((string)$d['to_addr']) : (string)($curRow['to_addr'] ?? '');
|
||||
$fromLng = $hasF('from_lng')
|
||||
? self::coordOrNull($d['from_lng']) : self::coordOrNull($curRow['from_lng'] ?? null);
|
||||
$fromLat = $hasF('from_lat')
|
||||
? self::coordOrNull($d['from_lat']) : self::coordOrNull($curRow['from_lat'] ?? null);
|
||||
$toLng = $hasF('to_lng')
|
||||
? self::coordOrNull($d['to_lng']) : self::coordOrNull($curRow['to_lng'] ?? null);
|
||||
$toLat = $hasF('to_lat')
|
||||
? self::coordOrNull($d['to_lat']) : self::coordOrNull($curRow['to_lat'] ?? null);
|
||||
// 城市:**优先用表单传来的**(前端联想选中时高德已经把城市带回来了,零成本),
|
||||
// 没有才去解析。解析绝不抛异常(Geo 内部全兜底)。
|
||||
$fromCity = mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8');
|
||||
$toCity = mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8');
|
||||
/* 🔴 2026-11-05 修(用户:「城市…省也带上了,市也带上了,区甚至都带上了,
|
||||
这没必要,直接城市就可以」)。
|
||||
|
||||
前端传来的可能是「山东省青岛市市南区」(高德的 district 整串),
|
||||
直接存会让旅程的城市聚合变成一长串行政区划。
|
||||
⇒ 过一道 `Geo::cityOnly()` 剥成纯市名「青岛市」。
|
||||
|
||||
⚠️ 不能指望前端改好就完事:老版本 App 还在用、
|
||||
第三方调用不经过我们的前端、老数据已经脏了。
|
||||
服务端归一化是**唯一的真防线**。 */
|
||||
$fromCity = \App\Geo::cityOnly(mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8'));
|
||||
$toCity = \App\Geo::cityOnly(mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8'));
|
||||
if ($fromCity === '') {
|
||||
$fromCity = \App\Geo::cityFromPoint($fromLng, $fromLat, $fromPlace !== '' ? $fromPlace : $fromAddr);
|
||||
}
|
||||
@@ -1464,6 +1643,32 @@ final class Trip
|
||||
return self::NODE_TYPES[$key][1] ?? '📌';
|
||||
}
|
||||
|
||||
/**
|
||||
* 行程卡左侧的图标:**出行方式优先,节点类型兜底**。
|
||||
*
|
||||
* 🔴 2026-11-05(用户:「明明选的是坐火车,但网页版的图标显示的还是一辆汽车」)。
|
||||
*
|
||||
* 原来左侧只按 `node_type` 取 ⇒「赶路」永远显示 🚗,
|
||||
* 哪怕这段实际是坐火车(`travel_mode = 'train'` 应该是 🚄)。
|
||||
*
|
||||
* 现在:
|
||||
* ① 有出行方式 → 用它(火车 🚄 / 飞机 ✈️ / 轮船 🚢 / 自驾 🚗…)
|
||||
* ② 没有 → 退回节点类型(游玩 🎡 / 用餐 🍜 / 住宿 🏨)
|
||||
*
|
||||
* ⚠️ 端上 `fbNodeLeadIcon()` 必须是**同一套优先级**,否则两边图标不一致。
|
||||
*/
|
||||
public static function leadIcon(string $nodeType, string $mode, int $familyId = 0): string
|
||||
{
|
||||
if ($mode !== '') {
|
||||
$mi = self::modeIcon($mode);
|
||||
/* modeIcon 对未知 key 回 '🧭'(不是空串),所以这里判空保底 */
|
||||
if ($mi !== '') {
|
||||
return $mi;
|
||||
}
|
||||
}
|
||||
return self::typeIcon($nodeType, $familyId);
|
||||
}
|
||||
|
||||
/** 出行方式的名字 / 图标 */
|
||||
public static function modeWord(string $key): string
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user