v44 第 44 批:界面 5 条 + 移动端 API v1 全量

界面(用户 2026-10-12 第 1~5 条)
1. 新建日程「日历本」并入 .cs 自绘下拉,颜色画进候选项,三字与下拉同行。
2. 新建日程「开始/结束」改「日期范围:」,删旧日期控件;
   重复相关三行仅选重复时出现;删「(含第一次)」与「这段时间算忙碌」。
3. 条目浮层加「地点」、去掉「状态」行;首页红线按分钟定位(原只取整小时);
   去掉左侧红点只留红线。
4. 任务页删掉状态说明提示(STATUS_HINT 三值清空)。
5. 新建任务:标题在前树框在后;截止时间改最新日历控件;负责人默认自己;
   删冗余提示;「上面要做什么」→「任务名称」;清理括号说明。

移动端 API(用户第 6 条:导航模块除外,其余全量)
- 新增 Bearer 令牌鉴权(api_tokens 表,Schema v32),与登录口令 / DAV 口令三套分立。
- Api.php 内核(鉴权/取参/信封/分页/分发 + _method 覆写)+
  ApiHandler.php 17 条资源 + ApiToken.php + api.php 入口。
- 路由:router.php / .htaccess / index.php 三处 /api/ 兜底(部署脚本自动追加线上规则)。
- 个人设置新增「App 接口令牌」卡;docs/API.md 接口文档;
  docs/API-QUESTION-LIST.md 自主决策与待确认缺口清单。

验证:本地 e2e 1510/0、v43 真机 33/0、v43 反向 9/0、
v44 真机 41/0、v44 API 63/0、v44 反向 19/0;全流水线 PUSH_RC=0。
This commit is contained in:
2026-10-12 15:31:03 +08:00
parent 8c651b43eb
commit 075ac6bbeb
21 changed files with 2886 additions and 185 deletions
+352
View File
@@ -0,0 +1,352 @@
<?php
/**
* 🔴 v44(用户 2026-10-12 第 6 条)—— **移动端 API 内核**。
*
* 用户原话:「我们现在马上要开始移动终端的开发,所以现在要开发所有接口。
* 你现在就根据我们现在的功能,把所有接口设计和开发出来。
* 导航这个模块不需要了,因为这个模块是专门为我们的外部端开发的,
* 所以这个不需要,其他全部都需要。」
*
* ════════════════════════════════════════════════════════════════════
* 为什么是一个"内核"而不是在 api.php 里逐条写
* ════════════════════════════════════════════════════════════════════
* 213 个网页路由要映射成 REST。若逐条手写,"取参数 / 鉴权 / 序列化 / 错误码"
* 会在几十个地方各抄一遍 —— 那是**必然**有一天长歪的写法(本项目已经栽过
* 好几轮"两处写法不同步")。所以这里把四件事收进一处:
* · **鉴权**:`Authorization: Bearer <token>` → `Api::actor()`;
* · **取参**:`Api::param()`(JSON body 与表单都能吃);
* · **出参**:`Api::ok() / Api::fail()`(统一信封 + HTTP 码);
* · **动作守卫**:`Api::requireWrite()`(只读账号不能写,与网页一致)。
*
* ════════════════════════════════════════════════════════════════════
* 统一响应信封(**App 端只按这一套解析**)
* ════════════════════════════════════════════════════════════════════
* 成功:{ "ok": true, "data": <任意> }
* 失败:{ "ok": false, "error": "人话错误信息", "code": "bad_request" }
* HTTP 码同时给出:200 / 400 / 401 / 403 / 404 / 409 / 422 / 500。
* ⚠️ 用**信封**而不是裸数据:否则 App 端无法区分"成功但值是 null"
* 与"请求失败"(HTTP 200 + 空 body 的歧义)。
*
* ════════════════════════════════════════════════════════════════════
* 与网页共用业务类的**唯一真值来源**
* ════════════════════════════════════════════════════════════════════
* 所有写操作都走 `App\Movie::create()` 这类**模型方法**(网页路由也是调它们),
* 不在 API 里另写一套 SQL。理由:
* · 权限/归属校验("这本日历能不能写""这个人在不在我家")已经在模型里;
* · 将来网页那边修了业务规则,API 自动跟上,不会出现"App 建的记录与网页建的不同"。
*/
declare(strict_types=1);
namespace App;
final class Api
{
/* ══════════════════════════════════════════════════════════════
鉴权
══════════════════════════════════════════════════════════════ */
/** 从 Authorization 头里取 Bearer 令牌(也接受 `?token=` —— 见下)。 */
public static function bearer(): string
{
/* ⚠️ 取头的三种写法都要认:Apache 直接监听时是 `HTTP_AUTHORIZATION`;
PHP-FPM 下有时只出现在 `REDIRECT_HTTP_AUTHORIZATION`(重写之后);
还有 `apache_request_headers()`(老 Apache + CGI 的兜底)。
本项目已经因为"头取不到"栽过一次(DAV 的 `CGIPassAuth On`,见 README),
所以这里三层都试,**不赌**环境。 */
$raw = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? '');
if ($raw === '') { $raw = (string)($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''); }
if ($raw === '' && function_exists('apache_request_headers')) {
$h = apache_request_headers();
foreach ($h as $k => $v) {
if (strcasecmp((string)$k, 'Authorization') === 0) { $raw = (string)$v; break; }
}
}
if (preg_match('/^\s*Bearer\s+(.+)$/i', $raw, $m)) {
return trim($m[1]);
}
return '';
}
/**
* 当前调用者。成功返回 ['person_id','family_id','token_id','role','can_write']。
*
* 🔴🔴 **本函数会直接结束请求**(未鉴权时 401 + exit)——这是刻意的:
* 若返回 null 让每个调用点自己判断,总有一天有人忘了判。
*/
public static function actor(): array
{
static $cache = null;
if (is_array($cache)) { return $cache; }
$token = self::bearer();
if ($token === '') {
self::fail('缺少 API 令牌(请在请求头带 `Authorization: Bearer <token>`)。',
'unauthorized', 401);
}
$hit = ApiToken::verify($token);
if ($hit === null) {
self::fail('API 令牌无效、已过期或被吊销。', 'unauthorized', 401);
}
$personId = (int)$hit[0];
$p = Person::find($personId);
if (!$p) {
self::fail('令牌对应的人不存在。', 'unauthorized', 401);
}
/* 角色与写权限**照抄网页那套判定**(Auth::canWrite / isGuest),
否则会出现"网页里不能写、App 里能写"这种权限裂缝。
⚠️ Auth 的方法读的是 $_SESSION —— API 无 session,所以这里按同一个
语义(role + status)**重算**一遍,而不是去读 Auth。 */
$st = Database::get()->prepare(
'SELECT role, status FROM accounts WHERE person_id = ? LIMIT 1'
);
$st->execute([$personId]);
$acct = $st->fetch() ?: [];
$role = (string)($acct['role'] ?? 'member');
if ((string)($acct['status'] ?? 'active') !== 'active') {
self::fail('这个账号已被停用。', 'forbidden', 403);
}
$cache = [
'person_id' => $personId,
'family_id' => (int)$p['family_id'],
'token_id' => (int)$hit[1],
'role' => $role,
'can_write' => $role !== 'guest',
];
return $cache;
}
/** 只读账号(guest)不能写 —— 与网页的 `Auth::requireWrite()` 同一语义。 */
public static function requireWrite(): void
{
$a = self::actor();
if (!$a['can_write']) {
self::fail('只读账号不能修改数据。', 'forbidden', 403);
}
}
/* ══════════════════════════════════════════════════════════════
取参与出参
══════════════════════════════════════════════════════════════ */
private static ?array $body = null;
/** 请求体(JSON 或表单),只解析一次。 */
public static function body(): array
{
if (self::$body !== null) { return self::$body; }
$ct = (string)($_SERVER['CONTENT_TYPE'] ?? '');
if (stripos($ct, 'application/json') !== false) {
$raw = (string)file_get_contents('php://input');
$dec = $raw === '' ? [] : json_decode($raw, true);
self::$body = is_array($dec) ? $dec : [];
} else {
self::$body = array_merge($_GET, $_POST);
}
return self::$body;
}
/**
* 取一个参数:GET / POST 表单 / JSON body 都能吃。
* ⚠️ 合并优先级 JSON body > POST > GET —— 与直觉一致(显式 body 最权威)。
*/
public static function param(string $key, $default = null)
{
$b = self::body();
if (array_key_exists($key, $b)) { return $b[$key]; }
if (array_key_exists($key, $_GET)) { return $_GET[$key]; }
return $default;
}
/** 取字符串参数(trim + 可选长度上限) */
public static function str(string $key, string $default = '', int $max = 0): string
{
$v = self::param($key, $default);
$v = is_scalar($v) ? (string)$v : '';
$v = trim($v);
return ($max > 0) ? mb_substr($v, 0, $max) : $v;
}
/** 取整数参数 */
public static function int(string $key, int $default = 0): int
{
$v = self::param($key, $default);
return is_scalar($v) ? (int)$v : $default;
}
/** 取布尔参数(认 1/true/'on'/'yes') */
public static function bool(string $key, bool $default = false): bool
{
$v = self::param($key, $default);
if (is_bool($v)) { return $v; }
if (is_scalar($v)) {
return in_array(strtolower((string)$v), ['1', 'true', 'on', 'yes'], true);
}
return $default;
}
/** 取整数数组(如 attendees[] / shares[]) */
public static function ints(string $key): array
{
$v = self::param($key, []);
if (!is_array($v)) { return []; }
return array_values(array_map('intval', $v));
}
/**
* 🔴 时间参数的**双格式**接受(App 的痛点,刻意做得宽容):
* · 'YYYY-MM-DD HH:MM(:SS)' —— 网页控件产出的格式;
* · 'YYYY-MM-DDTHH:MM(:SS)…' —— ISO 8601(iOS/Android 原生日期控件默认);
* · 'YYYY-MM-DD' —— 纯日期(全天日程;时间补 00:00:00)。
* 解析不出 → 返回 null(**不抛异常**,让调用方决定"忽略"还是"报错")。
*/
public static function dt(string $key, ?string $default = null): ?string
{
$v = self::param($key, null);
if ($v === null || $v === '') { return $default; }
if (!is_scalar($v)) { return $default; }
$s = trim((string)$v);
if ($s === '') { return $default; }
// 先到 UTC 再落地:iOS 给的是带 Z / 带偏移的 ISO 串,直接 substr 会拿到 UTC 时刻
$ts = strtotime($s);
if ($ts === false) { return null; }
// 纯日期:时间部分归零(与网页"全天日程"的口径一致)
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) {
return date('Y-m-d', $ts) . ' 00:00:00';
}
return date('Y-m-d H:i:s', $ts);
}
/** 成功响应并结束请求。 */
public static function ok($data = null, int $code = 200, array $extra = []): void
{
self::send(array_merge(['ok' => true, 'data' => $data], $extra), $code);
}
/** 失败响应并结束请求。 */
public static function fail(string $msg, string $code = 'bad_request', int $http = 400): void
{
self::send(['ok' => false, 'error' => $msg, 'code' => $code], $http);
}
private static function send(array $payload, int $http): void
{
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
http_response_code($http);
}
echo json_encode($payload, JSON_UNESCAPED_UNICODE
| JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
exit;
}
/* ══════════════════════════════════════════════════════════════
分页(列表接口统一用它,App 端滚动加载)
══════════════════════════════════════════════════════════════ */
/** 返回 [limit, offset, page],并把它们记在静态字段里供 `page()` 回填 meta。 */
private static array $pageInfo = ['page' => 1, 'per_page' => 50, 'offset' => 0];
public static function page(int $defaultPer = 50, int $maxPer = 200): array
{
$per = max(1, min($maxPer, self::int('per_page', $defaultPer)));
$pg = max(1, self::int('page', 1));
$plan = self::str('paginate', '');
// `paginate=1` 时返回带 meta 的信封;否则(默认)返回裸数组,App 少写一层
self::$pageInfo = ['page' => $pg, 'per_page' => $per, 'offset' => ($pg - 1) * $per];
return [$per, self::$pageInfo['offset'], $pg];
}
/** 列表响应:默认裸数组;带 `paginate=1` 时给出分页 meta。 */
public static function list(array $rows, int $total = -1): void
{
if (self::bool('paginate')) {
self::ok([
'items' => $rows,
'total' => $total >= 0 ? $total : count($rows),
'page' => self::$pageInfo['page'],
'per_page' => self::$pageInfo['per_page'],
]);
}
self::ok($rows);
}
/* ══════════════════════════════════════════════════════════════
便捷:拿"某类资源"的公共前置
══════════════════════════════════════════════════════════════ */
/** 当前家庭 + 当前人(几乎所有接口都要,省得每处 `self::actor()` 解构)。 */
public static function me(): array
{
$a = self::actor();
return [$a['family_id'], $a['person_id']];
}
/** 找不到资源时的统一 404 */
public static function notFound(string $what = '资源'): void
{
self::fail($what . '不存在,或者你不该看到它。', 'not_found', 404);
}
/**
* 把一批 id 过滤成"确实属于这个家庭的人"(防手搓请求把别家的人挂进来)。
*
* ⚠️ 与网页表单同一道防线(index.php 里的全局函数 `filter_family_person_ids`)——
* 但那个函数定义在 **index.php 顶层**,API 这条路不经过 index.php
* (.htaccess 直接重写到 api.php),所以**取不到它**。
* 实测:在 ApiHandler 里直接调它会 `Call to undefined function
* App\filter_family_person_ids()` —— PHP 会先在当前命名空间找。
* ⇒ 这里自己实现一遍,语义与那个全局函数**逐字对齐**
* (查 persons 表、限 family_id、返回去重后的整型数组)。
*/
public static function people(array $ids, int $familyId): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids),
static fn(int $x): bool => $x > 0)));
if (!$ids) { return []; }
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT id FROM persons WHERE family_id = ? AND id IN ($in)"
);
$st->execute(array_merge([$familyId], $ids));
return array_map('intval', array_column($st->fetchAll(), 'id'));
}
/**
* 路由分发:`/api/v1/<资源>[/<id>][/<动作>]`。
*
* ⚠️ 单一入口(api.php 的 .htaccess 重写)—— 不在 URL 里堆 query 参数,
* 这样 App 的网络层可以按 REST 习惯写(GET/POST/PATCH/DELETE + 路径)。
*/
public static function dispatch(): void
{
// 方法模拟:App 端(尤其部分安卓网络库)不方便发 PATCH/DELETE 时
// 用 POST + `_method=DELETE`,这里做一次统一改写。
// ⚠️ 必须用 `Api::param()`(它同时认 JSON body / POST 表单 / GET)——
// 只读 `$_POST['_method']` 的话,JSON 请求体的改写会**静默失效**
// (实测踩过:POST + JSON `_method:DELETE` 得到 405)。
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$override = strtoupper((string)($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? ''));
if ($override === '') { $override = strtoupper((string)self::param('_method', '')); }
if ($method === 'POST' && in_array($override, ['PUT', 'PATCH', 'DELETE'], true)) {
$method = $override;
}
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
/* 去掉前缀:`/api/v1/...`(.htaccess / router.php 那条重写),
或者 `?r=api/v1/...`(index.php 那条兜底 —— 它会把 REQUEST_URI 改成
`/api/v1/...` 再进来,所以这里一条正则就够)。
两种入口的路径最终都归一成 `tasks/12/status` 这样的相对串。 */
$path = preg_replace('#^.*?/api/v1#', '', $path);
$path = trim((string)$path, '/');
$parts = $path === '' ? [] : explode('/', $path);
$res = strtolower($parts[0] ?? '');
$id = isset($parts[1]) && ctype_digit((string)$parts[1]) ? (int)$parts[1] : 0;
$sub = strtolower($parts[1] ?? '');
if (ctype_digit($sub)) { $sub = strtolower((string)($parts[2] ?? '')); }
$h = new ApiHandler();
$h->route($res, $id, $sub, $method);
}
}
+1010
View File
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
<?php
/**
* 移动端 / 第三方调用的 **API 令牌**(v44,用户 2026-10-12 第 6 条)。
*
* 用户原话:「我们现在马上要开始移动终端的开发,所以现在要开发所有接口。」
*
* 与既有两套凭据的关系(**三套分开、互不代用**):
* · 登录口令(accounts.password_hash)—— 网页用,走 session;
* · DAV 口令(dav_credentials)—— CalDAV/CardDAV 客户端用,走 Basic;
* · **API 令牌(本类)** —— 移动端 App 用,走 `Authorization: Bearer <token>`。
* 为什么不复用 DAV 那套口令:那套是**给第三方同步客户端**的、明文要手打进手机;
* API 令牌是给自家 App 的,需要**设备名 + 有效期 + 可逐个吊销**,
* 生命周期与用途都不同(DAV 那把一吊销,日历同步也跟着断)。
*
* ⚠️ **令牌只在签发的这一次返回明文**(库里只存 sha256)。
* 与 DAV 口令同一取舍:明文落库一旦泄露就全暴露;不返回用户又永远看不到。
* ⇒ 网页端把明文塞进一次性 session 槽位、显示一次;App 端它自己存 Keychain。
*/
declare(strict_types=1);
namespace App;
final class ApiToken
{
/** 令牌前缀:让日志/配置里一眼能认出"这是家书的 API 令牌"(GitHub 的 ghp_ 同理) */
public const PREFIX = 'fb_';
/**
* 签发一枚新令牌,返回**明文**(调用方负责立刻交给用户,之后再也拿不到)。
*
* ⚠️ 用 `random_bytes` 而不是 `random_int` 循环拼短字符集:
* 令牌是机器读的(不像 DAV 口令要手打),长度与熵优先。
* 32 字节 → base64url 43 字符 → 约 256 bit 熵。
*/
public static function issue(int $personId, string $label = '', ?string $expiresAt = null): string
{
$plain = self::PREFIX . self::b64url(random_bytes(32));
Database::get()->prepare(
'INSERT INTO api_tokens (person_id, token_hash, label, expires_at) VALUES (?,?,?,?)'
)->execute([
$personId,
hash('sha256', $plain),
$label !== '' ? mb_substr($label, 0, 60) : null,
$expiresAt !== '' ? $expiresAt : null,
]);
return $plain;
}
/**
* 校验一枚令牌,成功返回 [personId, tokenId],失败 null。
*
* ⚠️ 只认 `Bearer` 头 —— 不接 query string(URL 会进日志/浏览器历史,
* 令牌出现在 URL 里等于半公开)。DAV 那套用 Basic 是 RFC 定的,没得选;
* API 是我们自己定的,就选更安全的那个。
* ⚠️ 逐字节 hash 之后查库(不先解密/比对),配合唯一索引 —— 不走时序分支。
* ⚠️ 过期/被吊销一律返回 null(不给"为什么不行"的细粒度错误,
* 避免给爆破者提供信息;具体原因网页端能看到)。
*/
public static function verify(string $plain): ?array
{
$plain = trim($plain);
if ($plain === '' || strpos($plain, self::PREFIX) !== 0) {
return null;
}
$st = Database::get()->prepare(
'SELECT t.id, t.person_id, a.status
FROM api_tokens t
JOIN accounts a ON a.person_id = t.person_id
WHERE t.token_hash = ?
AND (t.expires_at IS NULL OR t.expires_at > NOW())
LIMIT 1'
);
$st->execute([hash('sha256', $plain)]);
$row = $st->fetch();
if (!$row || (string)$row['status'] !== 'active') {
return null;
}
// 记一次使用时间(不给它加索引/不加事务:这是"顺带的统计",不值得拖慢鉴权)
try {
Database::get()->prepare('UPDATE api_tokens SET last_used_at = NOW() WHERE id = ?')
->execute([(int)$row['id']]);
} catch (\Throwable $e) {
// 记不上不该拦住这次请求
}
return [(int)$row['person_id'], (int)$row['id']];
}
/** 某个人的令牌清单(**不含明文**,只给管理界面看) */
public static function listFor(int $personId): array
{
$st = Database::get()->prepare(
'SELECT id, label, created_at, last_used_at, expires_at
FROM api_tokens WHERE person_id = ? ORDER BY id DESC'
);
$st->execute([$personId]);
return $st->fetchAll();
}
/** 吊销一枚(限自己的) */
public static function revoke(int $personId, int $id): bool
{
$st = Database::get()->prepare('DELETE FROM api_tokens WHERE id = ? AND person_id = ?');
$st->execute([$id, $personId]);
return $st->rowCount() > 0;
}
/** 吊销自己的全部("手机丢了"时的应急动作) */
public static function revokeAll(int $personId): int
{
$st = Database::get()->prepare('DELETE FROM api_tokens WHERE person_id = ?');
$st->execute([$personId]);
return $st->rowCount();
}
private static function b64url(string $raw): string
{
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
}
+17 -1
View File
@@ -139,7 +139,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '31';
public const VERSION = '32';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -256,6 +256,22 @@ CREATE TABLE IF NOT EXISTS `dav_credentials` (
COMMENT='DAV 专用口令(日历 CalDAV + 通讯录 CardDAV 共用一把;与登录口令分开)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `api_tokens` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`person_id` INT UNSIGNED NOT NULL,
`token_hash` CHAR(64) NOT NULL COMMENT 'API 令牌的 sha256;令牌本身不落库',
`label` VARCHAR(60) DEFAULT NULL COMMENT '设备名(用户自己填,如「我的手机」)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_used_at` DATETIME DEFAULT NULL,
`expires_at` DATETIME DEFAULT NULL COMMENT '到期时间;NULL = 不过期',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_api_token` (`token_hash`),
KEY `idx_api_person` (`person_id`),
CONSTRAINT `fk_api_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='移动端 / 第三方调用的 API 令牌(与 DAV 口令、登录口令分开)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `relations` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
+7 -7
View File
@@ -54,14 +54,14 @@ final class Task
public const STATUS = ['todo' => '待办', 'doing' => '进行中', 'done' => '已完成'];
/**
* 每个状态「到底是什么意思」——**给界面上的人看**(v24 第 25 批 用户第 6 条)。
* 每个状态「到底是什么意思」——**给开发看**的语义说明。
*
* 用户原话:「待办和进行中,有什么区别?」——他不知道区别,就是这一处没写清楚。
* 所以这句话要出现在**他看到状态的地方**:卡片上的 title、状态说明、
* 以及任务页头那句总说明(见 templates/tasks/index.php)。
*
* ⚠️ 文案刻意用"我/手头"这种第一人称口语:这是家庭场景,
* 写成"尚未开始执行"那种公文腔他自己也不会去读。
* ⚠️ 历史(v24 第 25 批 用户第 6 条):用户问过「待办和进行中,有什么区别?」,
* 当时把这句话渲染在看板顶部当"状态说明"。
* 🔴 v44(用户 2026-10-12 第 4 条):「任务页面,任务下面有一行字…
* 这个提示不要了,去掉。」⇒ **界面不再渲染它**(见 templates/tasks/index.php)。
* 常量与 `statusHint()` 保留:它仍是这三个状态语义的**唯一书面定义**,
* 后端/文案需要时还能取;删掉反而要后人重新考古一遍。
*/
public const STATUS_HINT = [
'todo' => '记下来了,还没动手 —— 排队等着',
+4 -1
View File
@@ -395,7 +395,10 @@ function fb_entry_json(array $ev, string $detail = '', string $edit = '', array
if ($when !== '') { $rows[] = ['时间', $when]; }
if ((string)($ev['calname'] ?? '') !== '') { $rows[] = ['日历本', (string)$ev['calname']]; }
if ((string)($ev['location'] ?? '') !== '') { $rows[] = ['地点', (string)$ev['location']]; }
if ((string)($ev['status_word'] ?? '') !== '') { $rows[] = ['状态', (string)$ev['status_word']]; }
/* 🔴 v44(用户 2026-10-12 第 3 条):「"状态确定"这个不要了,把"状态确定"换成地点。
当然,如果没有地点,这一行就不要显示了。」
⇒ **删掉「状态」那一行**(地点上一行已经加了,"换成地点"就是把状态让位给地点)。
没有地点时上面那条 `if` 自然不产出 ⇒ 整行不显示,正是用户要的。 */
if ((string)($ev['note'] ?? '') !== '') { $rows[] = ['备注', (string)$ev['note']]; }
// ⚠️ 扩展行排在"备注"之后、作为补充(调用方按需给,比如任务给「所属看板」)
foreach ($extra as $row) {