diff --git a/.htaccess b/.htaccess index 360b382..afc93e9 100644 --- a/.htaccess +++ b/.htaccess @@ -67,6 +67,13 @@ RewriteRule ^$ dav.php [L,QSA] # 到这里第一次出现"一个前缀包含另一个"的情况,顺序**变成有语义的**了。 RewriteRule ^nav/suggest\.php$ nav_suggest.php [L,QSA] RewriteRule ^nav/?$ nav_show.php [L,QSA] + + # 🔴 v44(用户 2026-10-12 第 6 条):移动端 API —— `/api/v1/...` → api.php。 + # ⚠️ `/api/v1/tasks` 不是真实文件,没有这条规则就会被交给 index.php, + # 而 index.php 不认识它 ⇒ 302 到登录页 ⇒ App 拿到一坨 HTML(JSON 解析失败)。 + # ⚠️ 顺序:这条前缀(`^api`)与上面 dav/carddav/nav 互不包含,放哪都行; + # 但**只能在 `RewriteEngine On` 之后**。本地等价物在 router.php 里。 + RewriteRule ^api(/.*)?$ api.php [L,QSA] # ⚠️ 导航图标与展示页头像**不需要重写规则**(v24 第 25 批): diff --git a/api.php b/api.php new file mode 100644 index 0000000..e6ad159 --- /dev/null +++ b/api.php @@ -0,0 +1,79 @@ +[/][/<子动作>]` + * GET /api/v1/ping + * GET /api/v1/me + * GET /api/v1/tasks + * POST /api/v1/tasks + * PATCH /api/v1/tasks/12 + * POST /api/v1/tasks/12/status + * DELETE /api/v1/events/9?scope=future&occ=2026-10-20 + * + * ⚠️ 鉴权**不在这个文件里**(见 `App\Api::actor()`): + * 这里只负责"把请求交进去",鉴权与出参格式都在 Api 内核里统一做。 + * (分散在入口做鉴权,是"某个入口忘了加"的经典温床。) + * + * ⚠️ 与 DAV 那套(dav.php / carddav.php)**完全并列**,互不影响: + * DAV 走 Basic + XML,是给第三方同步客户端的; + * API 走 Bearer + JSON,是给自家 App 的。 + * + * ⚠️ `.htaccess` / `router.php` 里各有一条把 `/api/` 交给本文件的重写 —— + * 两条都要有:Apache 线上走 .htaccess,本地 `php -S` 走 router.php。 + * (只写一条的话,会出现"本地能调、上线 404"或者反过来。) + */ +declare(strict_types=1); + +require __DIR__ . '/app/bootstrap.php'; + +// App 端常常会发 OPTIONS 预检(跨域或自定义头)—— 直接给 204,别让它落到业务里。 +if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'OPTIONS') { + header('Allow: GET, POST, PATCH, PUT, DELETE, OPTIONS'); + // ⚠️ 这里**不**开 CORS 通配:API 给自家 App 用,浏览器跨域不是目标场景。 + // 真需要时由用户在站点配置里显式加,而不是我们默认放开(放开会让 + // "浏览器里的任意页面拿着用户令牌调我们的 API"变得可能)。 + http_response_code(204); + exit; +} + +if (!\App\Database::hasTables()) { + \App\Api::fail('还没安装。', 'not_installed', 503); +} + +/* 🔴 v44:**API 入口自己也要跑一次结构升级** —— + 网页那条路(index.php 开头)用"源码指纹"判要不要跑 `Schema::ensure()`; + 而 App 可能**先于任何人打开网页**就发请求(新装的库、或手机先连上)。 + 那时 `api_tokens` 表还不存在 ⇒ 谁都没法签发令牌 ⇒ 死锁。 + ⚠️ 指纹判据是**每个家庭**一份(`Setting::get($familyId, …)`), + 所以这里按"当前令牌属于谁"取家庭;没令牌时退回第 1 个家庭/全局。 + ⚠️ 与 index.php 那段同样**容错**:升级失败不该把 API 整个挡住 + (失败会在下次请求重试,且错误进 error_log)。 */ +$schemaFam = 0; +$tok = \App\Api::bearer(); +if ($tok !== '') { + $hit = \App\ApiToken::verify($tok); + if ($hit !== null) { + $p = \App\Person::find((int)$hit[0]); + $schemaFam = (int)($p['family_id'] ?? 0); + } +} +try { + if ($schemaFam > 0 ? \App\Schema::needsUpgrade($schemaFam) : true) { + \App\Schema::ensure(); + if ($schemaFam > 0) { \App\Schema::markUpgraded($schemaFam); } + } +} catch (\Throwable $e) { + error_log('[familybook] API 侧结构升级失败(已跳过):' . $e->getMessage()); +} + +/* ⚠️ 走路由 query 传进来的 `r`(`?r=api/v1/tasks`)也要认: + 个别老网络库 / 代理会把 PATH_INFO 吃掉,只留 query。 + 两条入口共用一个 dispatch,不复制逻辑。 */ +$viaQuery = trim((string)($_GET['r'] ?? '')); +if ($viaQuery !== '' && strpos($viaQuery, 'api/') === 0) { + // `api/v1/tasks` → `/api/v1/tasks`(与上面那条重写、以及 index.php 的兜底同一口径) + $_SERVER['REQUEST_URI'] = '/api/' . substr($viaQuery, 4); +} + +\App\Api::dispatch(); \ No newline at end of file diff --git a/app/Api.php b/app/Api.php new file mode 100644 index 0000000..29ed334 --- /dev/null +++ b/app/Api.php @@ -0,0 +1,352 @@ +` → `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 `)。', + '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/<资源>[/][/<动作>]`。 + * + * ⚠️ 单一入口(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); + } +} \ No newline at end of file diff --git a/app/ApiHandler.php b/app/ApiHandler.php new file mode 100644 index 0000000..f65aa8e --- /dev/null +++ b/app/ApiHandler.php @@ -0,0 +1,1010 @@ +//<子动作>` 后调这里。 + * + * ⚠️ 为什么不做成"每资源一个文件":本项目的部署与静态断言都是**按文件白名单**走的 + * (见 `familybook_deploy.sh`),几十个小文件会让"哪些该进包"变成一件要维护的事。 + * 一个文件 + 清晰分段,是这里更稳的选择。 + * ⚠️ 每个写操作**都**先调 `Api::requireWrite()` —— 只读账号在网页上不能写, + * 在 API 里也必须不能写(权限裂缝是最不该出现的那种 bug)。 + */ +declare(strict_types=1); + +namespace App; + +final class ApiHandler +{ + /** + * @param string $res 资源名(people / movies / …) + * @param int $id 路径里的数字 id(没有则 0) + * @param string $sub 子动作(如 tasks/12/status 里的 status) + * @param string $method GET / POST / PATCH / DELETE + */ + public function route(string $res, int $id, string $sub, string $method): void + { + // 健康检查 / 版本探测:**不需要令牌**(App 启动时先探这个) + if ($res === 'ping' || $res === '') { + Api::ok([ + 'service' => 'familybook', + 'api' => 'v1', + 'time' => date('c'), + ]); + } + + switch ($res) { + case 'me': $this->me($method); return; + case 'tokens': $this->tokens($id, $method); return; + case 'home': $this->home(); return; + case 'people': $this->people($id, $sub, $method); return; + case 'notifications': $this->notifications($id, $sub, $method); return; + case 'calendar': $this->calendar($sub, $method); return; + case 'events': $this->events($id, $sub, $method); return; + case 'days': $this->days($id, $method); return; // 纪念日 + case 'tasks': $this->tasks($id, $sub, $method); return; + case 'movies': $this->movies($id, $sub, $method); return; + case 'cinemas': $this->cinemas($id, $method); return; + case 'trips': $this->trips($id, $sub, $method); return; + case 'ledger': $this->ledger($id, $sub, $method); return; + case 'resources': $this->resources($id, $method); return; + case 'books': $this->books($id, $sub, $method); return; + case 'notes': $this->notes($id, $method); return; + } + + Api::fail('未知的接口资源:' . $res, 'not_found', 404); + } + + /* ═══════════════════════════════════════════════════════════════ + 我 / 令牌 / 首页 + ═══════════════════════════════════════════════════════════════ */ + + private function me(string $method): void + { + [$fam, $pid] = Api::me(); + $p = Person::find($pid) ?: []; + Api::ok([ + 'id' => $pid, + 'name' => (string)($p['name'] ?? ''), + 'avatar' => avatar_url($p['avatar'] ?? null), + 'role' => Auth::roleWord(), + 'family' => (function () use ($fam): array { + $f = Family::find($fam) ?: []; + return ['id' => $fam, 'name' => (string)($f['name'] ?? '')]; + })(), + // 各模块主题色:App 端顶部/分类色块直接用它,不必自己维护一张表 + 'colors' => $this->moduleColors($fam), + ]); + } + + private function moduleColors(int $fam): array + { + $out = []; + foreach (['calendar', 'tasks', 'movies', 'trips', 'ledger', 'resources', 'books', 'notes', 'people'] as $k) { + $out[$k] = Module::color($k, $fam); + } + return $out; + } + + /** 令牌自助管理(App 里"退出登录/吊销设备") */ + private function tokens(int $id, string $method): void + { + [, $pid] = Api::me(); + if ($method === 'GET') { + Api::ok(ApiToken::listFor($pid)); + } + if ($method === 'DELETE') { + Api::requireWrite(); + if ($id > 0) { + Api::ok(['revoked' => ApiToken::revoke($pid, $id) ? 1 : 0]); + } + Api::ok(['revoked' => ApiToken::revokeAll($pid)]); + } + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + + private function home(): void + { + [$fam, $pid] = Api::me(); + $today = date('Y-m-d'); + $cals = Calendar::mine($fam, $pid); + $soon = Calendar::month($fam, $cals, $pid, $today, date('Y-m-d', strtotime('+7 days'))); + $soon = array_values(array_filter($soon, static fn(array $e): bool + => (string)($e['kind'] ?? '') !== 'lunar')); + $todayEvents = array_values(array_filter($soon, static fn(array $e): bool + => (string)($e['date'] ?? '') === $today)); + + $tasks = Task::byStatus($fam, $pid, 'doing'); + foreach (Task::byStatus($fam, $pid, 'todo') as $t) { + if (Task::isOverdue($t['due_at'] ?? null)) { $tasks[] = $t; } + } + Api::ok([ + 'greeting' => Home::greeting( + Nickname::display($pid, null) ?: '', $today, count($soon), + count(array_filter($tasks, static fn($t) => Task::isOverdue($t['due_at'] ?? null)))), + 'date' => $today, + 'weekday' => Home::weekday($today), + 'today_events' => $todayEvents, + 'tasks' => $tasks, + 'unread' => Notification::unreadCount($pid), + ]); + } + + /* ═══════════════════════════════════════════════════════════════ + 名册 / 人 + ═══════════════════════════════════════════════════════════════ */ + + private function people(int $id, string $sub, string $method): void + { + [$fam, $pid] = Api::me(); + + if ($id > 0) { + $p = Person::find($id); + // 只认本家的(跨家 id 直接当不存在,不泄露"别人家有没有这个人") + if (!$p || (int)$p['family_id'] !== $fam) { Api::notFound('这个人'); } + if ($method === 'GET') { + Api::ok($p); + } + if ($method === 'PATCH' || $method === 'PUT') { + Api::requireWrite(); + Person::update($id, [ + 'name' => Api::str('name', (string)$p['name'], 50), + 'gender' => Api::str('gender', (string)($p['gender'] ?? '')), + 'birthday'=> Api::dt('birthday', $p['birthday'] ?? null), + 'note' => Api::str('note', (string)($p['note'] ?? '')), + 'phone' => Api::str('phone', (string)($p['phone'] ?? ''), 40), + ]); + Api::ok(Person::find($id)); + } + if ($method === 'DELETE') { + Api::requireWrite(); + Person::delete($id); + Api::ok(['deleted' => $id]); + } + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + + if ($method === 'POST') { + Api::requireWrite(); + $name = Api::str('name', '', 50); + if ($name === '') { Api::fail('得写个名字。', 'unprocessable', 422); } + $newId = Person::create($fam, [ + 'name' => $name, + 'kind' => Api::str('kind', 'member'), + 'gender' => Api::str('gender', ''), + 'note' => Api::str('note', ''), + 'phone' => Api::str('phone', '', 40), + ]); + Api::ok(Person::find($newId), 201); + } + + // 列表:支持 ?q= 搜索、?kind=member|contact + $list = Person::all($fam, Api::str('q'), Api::str('kind', 'member'), $pid); + Api::list(array_values($list)); + } + + /* ═══════════════════════════════════════════════════════════════ + 通知 + ═══════════════════════════════════════════════════════════════ */ + + private function notifications(int $id, string $sub, string $method): void + { + [, $pid] = Api::me(); + if ($sub === 'read' || ($method === 'POST' && $sub === 'read')) { + Api::ok(['marked' => Notification::markAllRead($pid)]); + } + if ($sub === 'clear' || $method === 'DELETE') { + if ($id > 0) { + Api::ok(['deleted' => Notification::delete($id, $pid)]); + } + Api::ok(['cleared' => Notification::clearMine($pid)]); + } + Api::ok([ + 'unread' => Notification::unreadCount($pid), + 'items' => Notification::mine($pid, 50), + ]); + } + + /* ═══════════════════════════════════════════════════════════════ + 日程 + ═══════════════════════════════════════════════════════════════ */ + + private function calendar(string $sub, string $method): void + { + [$fam, $pid] = Api::me(); + + if ($sub === 'books') { // GET /calendar/books + Api::ok(Calendar::mine($fam, $pid)); + } + + // GET /calendar?from=2026-10-01&to=2026-10-31 (默认本月) + $from = Api::str('from', date('Y-m-01')); + $to = Api::str('to', date('Y-m-t')); + $cals = Calendar::mine($fam, $pid); + $rows = Calendar::month($fam, $cals, $pid, $from, $to); + Api::ok($rows); + } + + private function events(int $id, string $sub, string $method): void + { + [$fam, $pid] = Api::me(); + + if ($id > 0) { + $ev = $this->eventFor($fam, $id); + if (!$ev) { Api::notFound('这条日程'); } + if (!Calendar::canWriteEvent($ev, $pid)) { + Api::fail('这本日历你没有写权限。', 'forbidden', 403); + } + + if ($method === 'GET') { Api::ok($ev); } + if ($method === 'PATCH' || $method === 'PUT') { + Api::requireWrite(); + $this->writeEvent($pid, $ev); + } + if ($method === 'DELETE') { + Api::requireWrite(); + $this->deleteEvent($ev); + } + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + + if ($method === 'POST') { + Api::requireWrite(); + $calId = Api::int('calendar_id'); + $cal = Calendar::mineOne($fam, $pid, $calId); + if (!$cal || !Calendar::canWrite($cal, $pid) || Calendar::isReadonlyKind((string)$cal['kind'])) { + Api::fail('这本日历不能写入。', 'forbidden', 403); + } + $d = $this->eventData(null); + if ($d['starts_at'] === '') { Api::fail('开始时间不能为空。', 'unprocessable', 422); } + if ($d['title'] === '') { Api::fail('日程得有个标题。', 'unprocessable', 422); } + $d['created_by'] = $pid; + $newId = Calendar::addEvent((int)$cal['id'], $d); + Api::ok($this->eventFor($fam, $newId), 201); + } + + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + + /** 取一条日程(本家范围内;不存在返回 null) */ + private function eventFor(int $fam, int $id): ?array + { + $ev = Calendar::event($id); + if (!$ev) { return null; } + $cal = Calendar::find((int)$ev['calendar_id']); + if (!$cal || (int)$cal['family_id'] !== $fam) { return null; } + $ev['skips'] = Calendar::skipsOf($id); + return $ev; + } + + /** + * 日程写入字段(新建与更新共用;`$cur` 非空做"只改给了的字段"回退)。 + * + * 🔴 重复日程的**三档范围**(改/删这一次、这一次及以后、全部)—— + * 网页那两段逻辑(event_update / event_del)比较长,这里**不复制**它们, + * 而是让 App 用同一组 `scope` + `occ` 参数,并在 API 里做同样的三档处理。 + * (复制两份业务规则是"必然长歪"的写法;这里把差异收在一个私有方法里。) + */ + private function eventData(?array $cur): array + { + $has = static fn(string $k): bool + => array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET); + $starts = $has('starts_at') ? Api::dt('starts_at') : (string)($cur['starts_at'] ?? ''); + $ends = $has('ends_at') ? Api::dt('ends_at') : ($cur['ends_at'] ?? null); + $allDay = $has('all_day') ? Api::bool('all_day') : !empty($cur['all_day']); + if ($allDay) { + $starts = $starts !== null && $starts !== '' ? substr((string)$starts, 0, 10) . ' 00:00:00' : (string)$starts; + $ends = $ends !== null && $ends !== '' ? substr((string)$ends, 0, 10) . ' 00:00:00' : null; + } + return [ + 'title' => $has('title') ? Api::str('title', '', 200) : (string)($cur['title'] ?? ''), + 'location' => $has('location') ? Api::str('location', '', 255) : (string)($cur['location'] ?? ''), + 'starts_at' => (string)($starts ?? ''), + 'ends_at' => $ends, + 'all_day' => $allDay, + 'reminder_minutes' => $has('reminder_minutes') ? Api::param('reminder_minutes', []) + : ($cur['reminder_minutes'] ?? ''), + 'status' => $has('status') ? Api::str('status', 'confirmed') + : (string)($cur['status'] ?? 'confirmed'), + 'transp' => $has('transp') ? Api::bool('transp') : !empty($cur['transp']), + 'rrule' => $has('freq') || $has('rrule') ? $this->rruleFromBody() + : (string)($cur['rrule'] ?? ''), + 'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''), + ]; + } + + /** 更新一条日程(含重复日程的三档范围) */ + private function writeEvent(int $pid, array $ev): void + { + $d = $this->eventData($ev); + $scope = Api::str('scope', 'all'); + $occ = substr(Api::str('occ'), 0, 10); + $rrule = (string)($ev['rrule'] ?? ''); + $hasOcc = (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $occ); + + if ($rrule !== '' && $hasOcc && $scope === 'once') { + // 只改这一次:原系列跳掉那天 + 落一条独立单次 + Calendar::detachOnce(['id' => (int)$ev['id'], 'calendar_id' => (int)$ev['calendar_id']], + $occ, $d, $pid); + Api::ok(['updated' => (int)$ev['id'], 'scope' => 'once']); + } + if ($rrule !== '' && $hasOcc && $scope === 'future') { + // 这一次及以后:老系列截到前一天,新系列从这天起 + $rp = Repeat::parse($rrule); + $cut = date('Y-m-d', strtotime($occ . ' -1 day')); + $rp['until'] = $cut; + $rp['count'] = 0; + Calendar::updateEvent((int)$ev['id'], [ + 'title' => (string)($ev['title'] ?? ''), + 'location' => (string)($ev['location'] ?? ''), + 'starts_at' => (string)$ev['starts_at'], + 'ends_at' => $ev['ends_at'] ?: null, + 'all_day' => (int)($ev['all_day'] ?? 0) === 1, + 'reminder_minutes' => $ev['reminder_minutes'] ?? '', + 'status' => (string)($ev['status'] ?? 'confirmed'), + 'transp' => (int)($ev['transp'] ?? 0) === 1, + 'rrule' => 'FREQ=' . strtoupper((string)$rp['freq']) + . ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '') + . ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z', + 'note' => (string)($ev['note'] ?? ''), + ]); + $d2 = $d; + $d2['starts_at'] = $occ . ' ' . substr((string)$d['starts_at'], 11, 8); + $d2['ends_at'] = $d['ends_at'] !== null ? $occ . ' ' . substr((string)$d['ends_at'], 11, 8) : null; + $d2['created_by'] = $pid; + $newId = Calendar::addEvent((int)$ev['calendar_id'], $d2); + Api::ok(['updated' => (int)$ev['id'], 'new_series' => $newId, 'scope' => 'future']); + } + Calendar::updateEvent((int)$ev['id'], $d); + Api::ok($this->eventFor((int)Api::me()[0], (int)$ev['id'])); + } + + /** 删除一条日程(含重复日程的三档范围) */ + private function deleteEvent(array $ev): void + { + $id = (int)$ev['id']; + $scope = Api::str('scope', 'all'); + $occ = substr(Api::str('occ'), 0, 10); + $rrule = (string)($ev['rrule'] ?? ''); + $hasOcc = (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $occ); + + if ($rrule !== '' && $hasOcc && $scope === 'once') { + Calendar::skipOnce($id, $occ); + Api::ok(['skipped' => $occ, 'scope' => 'once']); + } + if ($rrule !== '' && $hasOcc && $scope === 'future') { + $rp = Repeat::parse($rrule); + $cut = date('Y-m-d', strtotime($occ . ' -1 day')); + $rp['count'] = 0; + $rp['until'] = $cut; + $firstDay = substr((string)$ev['starts_at'], 0, 10); + if ($cut < $firstDay) { + Calendar::deleteEvent($id); + Api::ok(['deleted' => $id, 'scope' => 'future']); + } + $newRule = 'FREQ=' . strtoupper((string)$rp['freq']) + . (!empty($rp['interval']) && (int)$rp['interval'] > 1 ? ';INTERVAL=' . (int)$rp['interval'] : '') + . ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '') + . ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z'; + Database::get()->prepare('UPDATE calendar_events SET rrule = ?, sequence = sequence + 1 WHERE id = ?') + ->execute([$newRule, $id]); + Api::ok(['truncated_to' => $cut, 'scope' => 'future']); + } + Calendar::deleteEvent($id); + Api::ok(['deleted' => $id]); + } + + /** + * 重复规则:接受两种写法 —— + * · `rrule` 直接给 RFC 5545 原文(如 `FREQ=WEEKLY;COUNT=5`); + * · 或用 `freq` + `rep_end` + `rep_count` / `rep_until` 三个字段(与网页表单同构)。 + * ⚠️ 后者是关键:App 端做重复设置时最自然的就是"选频率、选结束方式", + * 不该逼它去拼 RRULE 字符串(那是把服务端的实现细节漏给了客户端)。 + */ + private function rruleFromBody(): string + { + $raw = Api::str('rrule'); + if ($raw !== '') { return $raw; } + $freq = Api::str('freq'); + if ($freq === '') { return ''; } + return Repeat::build([ + 'freq' => $freq, + 'rep_end' => Api::str('rep_end', 'never'), + 'rep_count' => Api::int('rep_count', 10), + 'rep_until' => Api::str('rep_until', ''), + ]); + } + + /* ═══════════════════════════════════════════════════════════════ + 纪念日(days) + ═══════════════════════════════════════════════════════════════ */ + + private function days(int $id, string $method): void + { + [$fam, $pid] = Api::me(); + + if ($id > 0) { + $a = Anniversary::find($fam, $id); + if (!$a) { Api::notFound('这条纪念日'); } + if ($method === 'GET') { Api::ok($a); } + if ($method === 'DELETE') { + Api::requireWrite(); + Anniversary::remove($fam, $id); + Api::ok(['deleted' => $id]); + } + if ($method === 'PATCH' || $method === 'PUT') { + Api::requireWrite(); + Anniversary::save($fam, $this->annivData($a), $id); + Api::ok(Anniversary::find($fam, $id)); + } + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + + if ($method === 'POST') { + Api::requireWrite(); + $d = $this->annivData(null); + if ($d['title'] === '') { Api::fail('纪念日得有个名字。', 'unprocessable', 422); } + $newId = Anniversary::save($fam, $d, 0); + Api::ok(Anniversary::find($fam, $newId), 201); + } + + Api::ok(Anniversary::all($fam, $pid)); + } + + /** 纪念日字段(契约与 days/save 路由一致) */ + private function annivData(?array $cur): array + { + $has = static fn(string $k): bool + => array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET); + return [ + 'title' => $has('title') ? Api::str('title', '', 100) : (string)($cur['title'] ?? ''), + 'repeat_type' => $has('repeat_type') ? Api::str('repeat_type', 'solar') + : (string)($cur['repeat_type'] ?? 'solar'), + 'month' => $has('month') ? Api::int('month', 1) : (int)($cur['month'] ?? 1), + 'day' => $has('day') ? Api::int('day', 1) : (int)($cur['day'] ?? 1), + 'is_leap' => $has('is_leap') ? Api::bool('is_leap') : !empty($cur['is_leap']), + 'kind' => $has('kind') ? Api::str('kind', '', 40) : (string)($cur['kind'] ?? ''), + 'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''), + 'person_ids' => $has('person_ids') ? Api::ints('person_ids') + : array_map(static fn($p) => (int)$p['id'], (array)($cur['people'] ?? [])), + ]; + } + + /* ═══════════════════════════════════════════════════════════════ + 任务 + ═══════════════════════════════════════════════════════════════ */ + + private function tasks(int $id, string $sub, string $method): void + { + [$fam, $pid] = Api::me(); + + if ($id > 0) { + $t = Task::find($fam, $id, $pid); + if (!$t) { Api::notFound('这项任务'); } + + // 子动作:状态 / 搬看板 / 评论 + if ($sub === 'status' && ($method === 'POST' || $method === 'PATCH')) { + Api::requireWrite(); + Task::setStatus($fam, $id, Api::str('status', 'todo'), Api::int('position')); + Api::ok(Task::find($fam, $id, $pid)); + } + if ($sub === 'move' && $method === 'POST') { + Api::requireWrite(); + Task::moveToBoard($fam, $id, Api::int('board_id'), Api::int('before'), $pid); + Api::ok(Task::find($fam, $id, $pid)); + } + if ($sub === 'comments') { + if ($method === 'GET') { + Api::ok(Task::comments($id)); + } + if ($method === 'POST') { + Api::requireWrite(); + $txt = Api::str('content', '', 4000); + if ($txt === '') { Api::fail('评论不能是空的。', 'unprocessable', 422); } + Api::ok(['id' => Task::addComment($id, $pid, $txt)], 201); + } + } + + if ($method === 'GET') { Api::ok($t); } + if ($method === 'PATCH' || $method === 'PUT') { + Api::requireWrite(); + Task::update($fam, $id, [ + 'title' => Api::str('title', (string)$t['title'], 200), + 'note' => Api::str('note', (string)($t['note'] ?? '')), + 'due_at' => Api::dt('due_at', $t['due_at'] ?? null), + 'repeat_rule' => Api::str('repeat_rule', (string)($t['repeat_rule'] ?? '')), + 'parent_id' => Api::int('parent_id', (int)($t['parent_id'] ?? 0)), + 'assignee_id' => Api::int('assignee_id', (int)($t['assignee_id'] ?? 0)), + 'category_id' => Api::int('category_id', (int)($t['category_id'] ?? 0)), + 'board_id' => Api::int('board_id', (int)($t['board_id'] ?? 0)), + 'status' => (string)$t['status'], // 状态只走 /status 子动作 + ]); + Api::ok(Task::find($fam, $id, $pid)); + } + if ($method === 'DELETE') { + Api::requireWrite(); + Task::destroy($fam, $id); + Api::ok(['deleted' => $id]); + } + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + + if ($method === 'POST') { + Api::requireWrite(); + $title = Api::str('title', '', 200); + if ($title === '') { Api::fail('任务得写清楚要做什么。', 'unprocessable', 422); } + $newId = Task::create($fam, $pid, [ + 'title' => $title, + 'note' => Api::str('note', ''), + 'due_at' => Api::dt('due_at', ''), + 'repeat_rule' => Api::str('repeat_rule', ''), + 'parent_id' => Api::int('parent_id'), + // 🔴 v44 第 5 条「负责人默认是自己」——API 建任务时同样默认自己, + // 与网页表单一致(不传 assignee_id 就是"我负责")。 + 'assignee_id' => Api::int('assignee_id', $pid), + 'category_id' => Api::int('category_id'), + 'board_id' => Api::int('board_id'), + 'status' => Api::str('status', 'todo'), + ]); + foreach (Api::people(Api::ints('shares'), $fam) as $sp) { + Task::setShare($newId, $sp, 'write'); + } + Api::ok(Task::find($fam, $newId, $pid), 201); + } + + // ---- 列表:三种视图 ---- + // ?view=board → 按看板分组(默认) + // ?view=status&status=doing → 跨看板按状态 + // ?view=done → 已完成(含分类/关键词过滤) + $view = Api::str('view', 'board'); + if ($view === 'status') { + Api::ok(Task::byStatus($fam, $pid, Api::str('status', 'todo'))); + } + if ($view === 'done') { + Api::ok(Task::doneList($fam, $pid, Api::int('category_id'), Api::str('keyword'))); + } + // 看板视图:把"列"与"任务"一起给出来(App 一次请求就够画整屏) + $boards = Task::boards($fam); + /* ⚠️ `Task::boardColumns()` 返回的是**包了一层的数组** + (`['cols'=>[boardId => rows], 'boards'=>…, 'defId'=>…]`), + 不是"boardId => rows"本身 —— 直接按 boardId 取会永远拿到空列 + (实测:接口返回的 boards 里 tasks 全是 [],而断言只看"键在不在"就漏过去了)。 + ⇒ 必须取 `['cols']` 那一层。 */ + $bc = Task::boardColumns($fam, $pid); + $cols = $bc['cols'] ?? []; + $out = []; + foreach ($boards as $b) { + $out[] = [ + 'id' => (int)$b['id'], + 'name' => (string)$b['name'], + 'color' => Task::boardColor($b['color'] ?? null, (string)$b['name']), + 'tasks' => array_values($cols[(int)$b['id']] ?? []), + ]; + } + if (!empty($cols[0])) { // 还没归到任何看板的兜底列 + $out[] = ['id' => 0, 'name' => '其他', 'color' => '', 'tasks' => array_values($cols[0])]; + } + Api::ok([ + 'boards' => $out, + 'categories' => Task::categories($fam), + 'status' => Task::STATUS, + 'repeat' => Task::REPEAT, + ]); + } + + /* ═══════════════════════════════════════════════════════════════ + 观影 + ═══════════════════════════════════════════════════════════════ */ + + private function movies(int $id, string $sub, string $method): void + { + [$fam, $pid] = Api::me(); + + if ($id > 0) { + $m = Movie::find($fam, $id, $pid); + if (!$m) { Api::notFound('这条观影记录'); } + + if ($sub === 'comments') { + if ($method === 'GET') { + $rv = MovieReview::mine($id, $pid); + Api::ok($rv ? MovieReview::forEvent($id) : []); + } + if ($method === 'POST') { + Api::requireWrite(); + $rv = MovieReview::mine($id, $pid); + if (!$rv) { Api::fail('先写一条评价,才能评论。', 'unprocessable', 422); } + $txt = Api::str('content', '', 4000); + if ($txt === '') { Api::fail('评论不能是空的。', 'unprocessable', 422); } + $cid = MovieReview::addComment((int)$rv['id'], $pid, $txt, + Api::int('parent_id'), Api::str('target_person', '')); + if ($cid <= 0) { Api::fail('评论没保存下来(可能内容太长)。', 'unprocessable', 422); } + Api::ok(['id' => $cid], 201); + } + } + if ($sub === 'review' && ($method === 'POST' || $method === 'PUT' || $method === 'PATCH')) { + Api::requireWrite(); + $my = MovieReview::mine($id, $pid); + $err = MovieReview::save( + $id, $fam, $pid, + Api::int('rating'), + Api::str('content', '', 8000), + array_values((array)Api::param('feelings', [])), + $my !== null + ); + if ($err !== '') { Api::fail($err, 'unprocessable', 422); } + Api::ok(MovieReview::mine($id, $pid)); + } + + if ($method === 'GET') { Api::ok($m); } + if ($method === 'PATCH' || $method === 'PUT') { + Api::requireWrite(); + Movie::update($fam, $id, $this->movieData($m)); + Api::ok(Movie::find($fam, $id, $pid)); + } + if ($method === 'DELETE') { + Api::requireWrite(); + Movie::destroy($fam, $id); + Api::ok(['deleted' => $id]); + } + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + + if ($method === 'POST') { + Api::requireWrite(); + $d = $this->movieData(null); + if ($d['title'] === '') { Api::fail('片名不能为空。', 'unprocessable', 422); } + if ($d['starts_at'] === '') { Api::fail('观影时间不能为空。', 'unprocessable', 422); } + $newId = Movie::create($fam, $pid, $d); + Api::ok(Movie::find($fam, $newId, $pid), 201); + } + + $list = Movie::all($fam, Api::str('keyword'), $pid); + [$per, $off] = Api::page(50); + Api::list(array_slice(array_values($list), $off, $per), count($list)); + } + + /** 观影写入字段(新建与更新共用;`$cur` 非空时做"只改给了的字段"的回退) */ + private function movieData(?array $cur): array + { + $b = Api::body(); + $pick = static function (string $k, $def) use ($b) { + return array_key_exists($k, $b) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET) + ? null : $def; // null = "有人给了",下面按 Api::* 取真实值 + }; + $has = static fn(string $k): bool + => array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET); + + return [ + 'title' => $has('title') ? Api::str('title', '', 200) : (string)($cur['title'] ?? ''), + 'starts_at' => $has('starts_at') ? (string)(Api::dt('starts_at') ?? '') : (string)($cur['starts_at'] ?? ''), + 'ends_at' => $has('ends_at') ? (string)(Api::dt('ends_at') ?? '') : (string)($cur['ends_at'] ?? ''), + 'location' => $has('location') ? Api::str('location', '', 255) : (string)($cur['location'] ?? ''), + 'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''), + 'info' => $has('info') ? Api::str('info', '') : (string)($cur['info'] ?? ''), + 'cinema_id' => $has('cinema_id') ? Api::int('cinema_id') : (int)($cur['cinema_id'] ?? 0), + 'attendees' => $has('attendees') ? Api::ints('attendees') : array_map( + static fn($a) => (int)$a['id'], (array)($cur['attendees'] ?? [])), + ]; + } + + private function cinemas(int $id, string $method): void + { + [$fam] = Api::me(); + if ($method === 'GET') { Api::ok(Cinema::all($fam)); } + if ($method === 'POST' || $method === 'PATCH' || $method === 'PUT') { + Api::requireWrite(); + $cur = $id > 0 ? Cinema::find($fam, $id) : null; + if ($id > 0 && !$cur) { Api::notFound('这家影院'); } + $name = Api::str('name', (string)($cur['name'] ?? ''), 100); + if ($name === '') { Api::fail('影院名称不能为空', 'unprocessable', 422); } + $err = Cinema::save( + $fam, $id, + Api::str('place', (string)($cur['place'] ?? ''), 100), + $name, + Api::str('address', (string)($cur['address'] ?? ''), 200) ?: null, + Cinema::normalizeColor(Api::str('color', (string)($cur['color'] ?? '#888888'), 16)) + ); + if ($err !== '') { Api::fail($err, 'conflict', 409); } + // save() 不返回 id ⇒ 新建时按"同地方同名"回查(表上有唯一性校验,够精确) + if ($id === 0) { + $id = 0; + foreach (Cinema::all($fam) as $c) { + if ((string)$c['name'] === $name + && (string)($c['place'] ?? '') === Api::str('place')) { + $id = (int)$c['id']; break; + } + } + } + Api::ok(Cinema::find($fam, $id), $cur ? 200 : 201); + } + if ($method === 'DELETE' && $id > 0) { + Api::requireWrite(); + Cinema::destroy($fam, $id); + Api::ok(['deleted' => $id]); + } + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + + /* ═══════════════════════════════════════════════════════════════ + 旅程 / 行程 + ═══════════════════════════════════════════════════════════════ */ + + private function trips(int $id, string $sub, string $method): void + { + [$fam, $pid] = Api::me(); + + if ($id > 0) { + $t = Trip::find($fam, $id, $pid); + if (!$t) { Api::notFound('这趟旅程'); } + + if ($sub === 'nodes') { + if ($method === 'GET') { + Api::ok(Trip::nodes($id, $pid)); + } + if ($method === 'POST') { + Api::requireWrite(); + $nid = Trip::saveNode($fam, $id, $this->nodeData(null)); + Api::ok(Trip::node($nid), 201); + } + } + if ($sub === 'summary' && $method === 'GET') { + Api::ok(Trip::summary($id, $pid)); + } + + if ($method === 'GET') { Api::ok($t); } + if ($method === 'PATCH' || $method === 'PUT') { + Api::requireWrite(); + $d = [ + 'title' => Api::str('title', (string)$t['title'], 200), + 'note' => Api::str('note', (string)($t['note'] ?? '')), + 'summary' => Api::str('summary', (string)($t['summary'] ?? '')), + ]; + if (array_key_exists('is_public', Api::body())) { $d['is_public'] = Api::bool('is_public') ? '1' : '0'; } + Trip::update($fam, $id, $d); + Api::ok(Trip::find($fam, $id, $pid)); + } + if ($method === 'DELETE') { + Api::requireWrite(); + Trip::destroy($fam, $id); + Api::ok(['deleted' => $id]); + } + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + + if ($method === 'POST') { + Api::requireWrite(); + $title = Api::str('title', '', 200); + if ($title === '') { Api::fail('旅程得有个名字。', 'unprocessable', 422); } + $d = ['title' => $title, 'note' => Api::str('note', ''), 'summary' => Api::str('summary', '')]; + if (array_key_exists('is_public', Api::body())) { $d['is_public'] = Api::bool('is_public') ? '1' : '0'; } + $newId = Trip::create($fam, $pid, $d); + Api::ok(Trip::find($fam, $newId, $pid), 201); + } + + Api::ok(Trip::all($fam, Api::str('keyword'))); + } + + /** 行程节点字段(与网页 node_add 同一份契约) */ + private function nodeData(?array $cur): array + { + $has = static fn(string $k): bool + => array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET); + return [ + 'title' => $has('title') ? Api::str('title', '', 200) : (string)($cur['title'] ?? ''), + 'detail' => $has('detail') ? Api::str('detail', '') : (string)($cur['detail'] ?? ''), + 'place' => $has('place') ? Api::str('place', '', 200) : (string)($cur['place'] ?? ''), + 'starts_at' => $has('starts_at') ? (string)(Api::dt('starts_at') ?? '') : (string)($cur['starts_at'] ?? ''), + 'ends_at' => $has('ends_at') ? (string)(Api::dt('ends_at') ?? '') : (string)($cur['ends_at'] ?? ''), + 'node_type' => $has('node_type') ? Api::str('node_type', 'other') : (string)($cur['node_type'] ?? 'other'), + 'status' => $has('status') ? Api::str('status', 'planned') : (string)($cur['status'] ?? 'planned'), + 'category' => $has('category') ? Api::str('category', 'plan') : (string)($cur['category'] ?? 'plan'), + // stage 不在这里改:只由新建/显式动作决定(与网页 node_add 同一条纪律) + 'stage' => $cur === null ? Api::str('stage', 'plan') : (string)($cur['stage'] ?? 'plan'), + 'members' => $has('members') ? Api::ints('members') : [], + 'travel_mode' => Api::str('travel_mode', (string)($cur['travel_mode'] ?? '')), + 'vehicle_id' => Api::int('vehicle_id', (int)($cur['vehicle_id'] ?? 0)), + 'line_name' => Api::str('line_name', ''), + 'carrier' => Api::str('carrier', ''), + 'trip_no' => Api::str('trip_no', ''), + ]; + } + + /* ═══════════════════════════════════════════════════════════════ + 记账 + ═══════════════════════════════════════════════════════════════ */ + + private function ledger(int $id, string $sub, string $method): void + { + [$fam, $pid] = Api::me(); + + if ($id > 0) { + $lg = Ledger::find($fam, $id, $pid); + if (!$lg) { Api::notFound('这本账本'); } + + if ($sub === 'entries') { + if ($method === 'GET') { + $es = Ledger::entries($id); + Api::ok(['items' => $es, 'summary' => Ledger::summary($es)]); + } + if ($method === 'POST') { + Api::requireWrite(); + // ⚠️ `amount` 交给模型自己转分(Ledger::toCents)—— App 传元, + // 与网页表单同一口径;这里**不要**先转一次(会双重换算)。 + $amount = Api::param('amount', ''); + if (trim((string)$amount) === '') { + Api::fail('金额不能为空。', 'unprocessable', 422); + } + $eid = Ledger::addEntry($id, [ + 'kind' => Api::str('kind', 'expense'), + 'amount' => $amount, + 'title' => Api::str('title', '', 120), + 'note' => Api::str('note', ''), + 'person_id' => Api::int('person_id'), + ], $pid); + Api::ok(['id' => $eid], 201); + } + } + if ($method === 'GET') { + $es = Ledger::entries($id); + Api::ok(['ledger' => $lg, 'summary' => Ledger::summary($es)]); + } + if ($method === 'DELETE') { + Api::requireWrite(); + Ledger::remove($fam, $id, $pid); + Api::ok(['deleted' => $id]); + } + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + + if ($method === 'POST') { + Api::requireWrite(); + $name = Api::str('name', '', 80); + /* ⚠️ `Ledger::defaultName()` **需要一个 ownerId 参数**(实测: + 不给参数会 ArgumentCountError)—— 用当前人补上。 */ + $newId = Ledger::create($fam, $pid, $name !== '' ? $name : Ledger::defaultName($pid)); + Api::ok(Ledger::find($fam, $newId, $pid), 201); + } + + Api::ok(Ledger::mine($fam, $pid)); + } + + /* ═══════════════════════════════════════════════════════════════ + 资源 / 书 / 笔记 + ═══════════════════════════════════════════════════════════════ */ + + private function resources(int $id, string $method): void + { + [$fam, $pid] = Api::me(); + if ($id > 0) { + $r = Resource::find($fam, $id, $pid); + if (!$r) { Api::notFound('这条资源'); } + if ($method === 'GET') { Api::ok($r); } + if ($method === 'PATCH' || $method === 'PUT') { + Api::requireWrite(); + Resource::update($fam, $id, [ + 'title' => Api::str('title', (string)$r['title'], 200), + 'content' => Api::str('content', (string)($r['content'] ?? '')), + 'category'=> Api::str('category', (string)($r['category'] ?? '')), + ]); + Api::ok(Resource::find($fam, $id, $pid)); + } + if ($method === 'DELETE') { + Api::requireWrite(); + Resource::destroy($fam, $id); + Api::ok(['deleted' => $id]); + } + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + if ($method === 'POST') { + Api::requireWrite(); + $title = Api::str('title', '', 200); + if ($title === '') { Api::fail('资源得有个标题。', 'unprocessable', 422); } + $newId = Resource::create($fam, $pid, $pid, [ + 'title' => $title, + 'content' => Api::str('content', ''), + 'category' => Api::str('category', ''), + ]); + Api::ok(Resource::find($fam, $newId, $pid), 201); + } + Api::ok(Resource::all($fam, $pid, Api::int('owner_id'), Api::str('keyword'), + Api::str('sort', 'used'))); + } + + private function books(int $id, string $sub, string $method): void + { + [$fam, $pid] = Api::me(); + if ($id > 0) { + $bk = Book::find($fam, $id); + if (!$bk) { Api::notFound('这本书'); } + if ($sub === 'notes' && $method === 'GET') { Api::ok(Book::notes($id, $pid)); } + if ($sub === 'notes' && $method === 'POST') { + Api::requireWrite(); + $txt = Api::str('content', '', 8000); + if ($txt === '') { Api::fail('笔记内容不能为空。', 'unprocessable', 422); } + $nid = Book::addNote($fam, $id, $pid, [ + 'content' => $txt, + 'kind' => Api::str('kind', 'note'), + 'chapter' => Api::str('chapter', '', 120), + 'loc' => Api::str('loc', '', 120), + ]); + if ($nid <= 0) { Api::fail('笔记没保存下来。', 'unprocessable', 422); } + Api::ok(['id' => $nid], 201); + } + if ($method === 'GET') { Api::ok($bk); } + if ($method === 'PATCH' || $method === 'PUT') { + Api::requireWrite(); + $d = $bk; + if (array_key_exists('status', Api::body())) { $d['status'] = Api::str('status'); } + if (array_key_exists('current_page', Api::body())) { $d['current_page'] = Api::str('current_page'); } + if (array_key_exists('rating', Api::body())) { $d['rating'] = Api::int('rating'); } + Book::update($fam, $id, $d); + Api::ok(Book::find($fam, $id)); + } + if ($method === 'DELETE') { + Api::requireWrite(); + Book::destroy($fam, $id); + Api::ok(['deleted' => $id]); + } + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + if ($method === 'POST') { + Api::requireWrite(); + $title = Api::str('title', '', 200); + if ($title === '') { Api::fail('书名不能为空。', 'unprocessable', 422); } + $newId = Book::create($fam, $pid, [ + 'title' => $title, + 'author' => Api::str('author', '', 120), + 'category' => Api::str('category', ''), + 'status' => Api::str('status', 'want'), + ]); + Api::ok(Book::find($fam, $newId), 201); + } + Api::ok(Book::all($fam, [ + 'status' => Api::str('status'), + 'category' => Api::str('category'), + 'owner' => Api::int('owner_id'), + 'keyword' => Api::str('keyword'), + ])); + } + + private function notes(int $id, string $method): void + { + [$fam, $pid] = Api::me(); + if ($id > 0) { + $n = Note::find($fam, $id, $pid); + if (!$n) { Api::notFound('这条笔记'); } + if ($method === 'GET') { Api::ok($n); } + if ($method === 'PATCH' || $method === 'PUT') { + Api::requireWrite(); + Note::update($fam, $id, $pid, [ + 'title' => Api::str('title', (string)$n['title'], 200), + 'body' => Api::str('body', (string)($n['body'] ?? '')), + 'tags' => Api::param('tags', $n['tags'] ?? ''), + ]); + Api::ok(Note::find($fam, $id, $pid)); + } + if ($method === 'DELETE') { + Api::requireWrite(); + Note::destroy($fam, $id, $pid); + Api::ok(['deleted' => $id]); + } + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + if ($method === 'POST') { + Api::requireWrite(); + $title = Api::str('title', '', 200); + if ($title === '') { Api::fail('笔记得有个标题。', 'unprocessable', 422); } + $newId = Note::create($fam, $pid, [ + 'title' => $title, + 'body' => Api::str('body', ''), + 'tags' => Api::param('tags', []), + ]); + Api::ok(Note::find($fam, $newId, $pid), 201); + } + [$per, $off] = Api::page(50); + Api::ok([ + 'tags' => Note::tagCloud($fam, $pid), + 'items' => Note::all($fam, $pid, Api::str('keyword'), Api::str('tag'), $per, $off), + ]); + } +} \ No newline at end of file diff --git a/app/ApiToken.php b/app/ApiToken.php new file mode 100644 index 0000000..bec6d47 --- /dev/null +++ b/app/ApiToken.php @@ -0,0 +1,119 @@ +`。 + * 为什么不复用 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), '+/', '-_'), '='); + } +} \ No newline at end of file diff --git a/app/Schema.php b/app/Schema.php index 88c7e93..d5cc3f0 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -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, diff --git a/app/Task.php b/app/Task.php index 4224893..b42bf2f 100644 --- a/app/Task.php +++ b/app/Task.php @@ -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' => '记下来了,还没动手 —— 排队等着', diff --git a/app/helpers.php b/app/helpers.php index a080a1b..c2807e5 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -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) { diff --git a/assets/css/app.css b/assets/css/app.css index e7afc9c..852d516 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -616,6 +616,25 @@ textarea.input { min-height: 76px; resize: vertical; } 控件依然完整可用,不是"骨架占位"。 ============================================================================ */ .dt-input { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +/* 🔴 v44:内联标签(`_datetime.php` 的 `$dtLabel`)—— 让"日期范围:"与最新控件 + **同一行**。宽度与观影式 `.mv-lb`(62px)一致,右对齐、冒号由 `::after` 给, + 与「日历本」「标题」那几行的标签左缘对齐。 + ⚠️ 它是 `.dt-input` 的**兄弟节点**(不是子节点)—— 见 `_datetime.php` 里的说明: + 放进去会被"收起原生端点"那条规则一起 clip 掉。选择器用 `+`(紧随其后)。 */ +.dt-inline-lab { + flex: 0 0 auto; width: 62px; text-align: right; + font-size: 14px; font-weight: 600; color: var(--ink); + line-height: 1.4; word-break: keep-all; +} +/* ⚠️ 它同时带 `.mv-lb`(在 `.mv-row` 里时由那条规则给固定宽度与冒号); + 这里再写一遍是为了**不在 `.mv-row` 里**(编辑整页那种两列排版)也成立。 */ +.dt-inline-lab::after { content: ':'; font-weight: 400; color: var(--muted); } +.dt-inline-lab > i { font-style: normal; color: var(--red, #C0392B); margin-left: 1px; } +/* 范围组里带标签时:标签在前、控件在后,横向排(`.dt-range.dt-cols` 已是 flex row) */ +.dt-range > .dt-inline-lab { flex: 0 0 62px; } +@media (max-width: 560px) { + .dt-inline-lab { flex: 0 0 52px; width: 52px; font-size: 13px; } +} .dt-input .dt-native { display: contents; } .dt-input .dt-semi { display: none; flex: 1 1 220px; min-width: 0; } @@ -2968,6 +2987,24 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, 这正是第 11 批"选人过滤一直是坏的"的同一个坑,别再踩第二次。 */ .cs-item[hidden] { display: none; } .cs-item:hover, .cs-item:focus { background: var(--line-2); outline: none; } +.cs-item.is-disabled { opacity: .5; cursor: not-allowed; } +.cs-item.is-disabled:hover { background: transparent; } + +/* ---- 🔴 v44:日程「日历本」下拉 ---- + 用户 2026-10-12 第 1 条:「把颜色放在下拉框里面,就跟电影的影院选择一样, + 让日历本这三个字和下拉框在一条线上。」 + ⇒ 复用观影那套 `.cs`(按钮 + 色点 + 弹出列表 + 搜索),只补两点: + ① 这个下拉在 `.mv-row` 里,宽度由 `> *:not(.mv-lb)` 那条 `flex:1` 接管,够用; + ② **无 JS 兜底**:那个原生 ` - $w): ?> - + - + + - - + - @@ -193,14 +228,12 @@ $evLab = static function (string $text, string $for = '', bool $star = false) us - - - - + @@ -238,26 +271,37 @@ $evLab = static function (string $text, string $for = '', bool $star = false) us (见 app.js 的 onRange → writeDT),服务端契约不变。 */ /* 重复:选了周期才显示"结束条件";结束条件选"次数"才显示次数框、选"到某天"才显示日期框。 - 三个框默认都 hidden —— 不重复时一个都不该出现(少即是清楚)。 */ + 三行默认都 hidden —— 不重复时一个都不该出现(少即是清楚)。 + 🔴 v44(用户第 2 条):藏的是**整行**(`.mv-row` / `.field`),不是行里那个 box —— + 只藏 box 的话标签还孤零零留在表单里。行 = box 的 `closest('.mv-row, .field')`。 */ var freq = document.getElementById('ev-freq'); var endBox = document.getElementById('ev-rep-end-box'); var endSel = document.getElementById('ev-rep-end'); var cntBox = document.getElementById('ev-rep-count-box'); var untilBox = document.getElementById('ev-rep-until-box'); + function rowOf(el) { + return el && el.closest ? el.closest('.mv-row, .field') : null; + } + function toggle(el, on) { + if (!el) { return; } + el.hidden = !on; + var row = rowOf(el); + if (row) { row.hidden = !on; } // 连带整行一起显隐 + } function syncRep() { - if (!freq || !endBox) return; + if (!freq || !endBox) { return; } var repeating = freq.value !== ''; - endBox.hidden = !repeating; + toggle(endBox, repeating); var how = endSel ? endSel.value : 'never'; - if (cntBox) { cntBox.hidden = !repeating || how !== 'count'; } - if (untilBox) { untilBox.hidden = !repeating || how !== 'until'; } + toggle(cntBox, repeating && how === 'count'); + toggle(untilBox, repeating && how === 'until'); } if (freq) { freq.addEventListener('change', syncRep); } if (endSel) { endSel.addEventListener('change', syncRep); } // 回填:编辑一条已有的重复日程时,把结束条件按库里的值选对 - 0): ?> + 0): ?> if (endSel) { endSel.value = 'count'; } - + if (endSel) { endSel.value = 'until'; } syncRep(); @@ -277,4 +321,4 @@ $evLab = static function (string $text, string $for = '', bool $star = false) us })(); + $evRowOpen, $evLab, $evLabelStar, $evRp, $evRepeating); ?> diff --git a/templates/calendar/index.php b/templates/calendar/index.php index 1ce3417..7609f76 100644 --- a/templates/calendar/index.php +++ b/templates/calendar/index.php @@ -161,51 +161,111 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
-
- - 的
⚠️ 你还没有**自己的日程本**,上面选的是一本"派生本" @@ -213,7 +273,6 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts) 日历本 建一本吧。 -
$defDay . ' ' . $defHM . ':00', 'ends_at' => $defDay . ' ' . $defEndH . ':' . substr($defHM, 3, 2) . ':00']; $evAllDay = false; + /* ⚠️ v44:`.mv-grid` 容器**已经在上面日课本那一行之前打开了** + (日课本自己也要走观影式排版)—— 这里只 require 字段块, + 不要再包一层,否则会出现嵌套的第二个 `.mv-grid`。 */ $evMvStyle = true; // 观影式排版(用户第 5 条) - echo '
'; require __DIR__ . '/_event_fields.php'; - echo '
'; unset($evMvStyle); ?> +
@@ -558,6 +619,100 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts) syncDot(); } + /* ============================================================ + 🔴 v44(用户 2026-10-12 第 1 条):日历本自绘下拉(色点画进下拉里) + ------------------------------------------------------------ + 与观影的影院下拉同一套交互,但**状态写在选中的原生 ``, + 所以这里不需要模板另给一份数据、也不会两边不同步。 */ + function build() { + list.innerHTML = ''; + Array.prototype.forEach.call(sel.options, function (o) { + var b = document.createElement('button'); + b.type = 'button'; + b.className = 'cs-item' + (o.disabled ? ' is-disabled' : ''); + b.setAttribute('role', 'option'); + if (o.disabled) { b.disabled = true; b.title = o.getAttribute('title') || ''; } + b.setAttribute('data-id', o.value); + b.setAttribute('data-color', o.getAttribute('data-color') || ''); + b.setAttribute('data-text', o.textContent || ''); + var d = document.createElement('span'); + d.className = 'cs-dot'; + d.style.background = o.getAttribute('data-color') || ''; + var s = document.createElement('span'); + s.textContent = o.textContent || ''; + b.appendChild(d); b.appendChild(s); + list.appendChild(b); + }); + } + function paint() { + var o = sel.options[sel.selectedIndex]; + if (!o) { return; } + text.textContent = o.textContent || ''; + var c = o.getAttribute('data-color') || ''; + if (c) { dot.hidden = false; dot.style.background = c; } else { dot.hidden = true; } + } + function closeMenu() { + menu.hidden = true; + btn.setAttribute('aria-expanded', 'false'); + picker.setAttribute('data-open', '0'); + } + function openMenu() { + menu.hidden = false; + btn.setAttribute('aria-expanded', 'true'); + picker.setAttribute('data-open', '1'); + } + + build(); + paint(); + /* ⚠️ 原生 select 的 change 会由别处(旧 syncDot、无 JS 路径)触发, + 这里也监听一次,保证显示与真值永远一致。 */ + sel.addEventListener('change', paint); + + btn.addEventListener('click', function (e) { + e.stopPropagation(); + if (menu.hidden) { openMenu(); } else { closeMenu(); } + }); + menu.addEventListener('click', function (e) { + var it = e.target.closest ? e.target.closest('.cs-item') : null; + if (!it || it.disabled) { return; } + e.stopPropagation(); + sel.value = it.getAttribute('data-id'); + sel.dispatchEvent(new Event('change', { bubbles: true })); + closeMenu(); + }); + /* 点空白处收起(用 document 监听 + 判定 inside,不额外造遮罩节点) + ⚠️ 必须在**捕获阶段**判定:菜单项的 click 会 stopPropagation, + 但那是冒泡阶段 —— 捕获阶段先于它,这里判"点在不在 picker 里" + 就够精确了(点在菜单里 → 不收)。 */ + document.addEventListener('click', function (e) { + if (menu.hidden) { return; } + if (picker.contains(e.target)) { return; } + closeMenu(); + }, true); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && !menu.hidden) { closeMenu(); } + }); + })(); + var form = document.getElementById('calAddForm'); var tip = document.getElementById('calAddTip'); if (!form || !card || !window.fetch) { return; } diff --git a/templates/home.php b/templates/home.php index e85932c..3656095 100644 --- a/templates/home.php +++ b/templates/home.php @@ -218,14 +218,24 @@ if ($timed) { 下面的 `$nowH` 是当前小时(服务端时区,与页面口径一致), 把它并进区间端点 —— 于是红线必然落在区间内,定位也就有了正确的目标。 ⚠️ 必须在**算 $nowH 之前**完成这一步(原代码把 $nowH 放在区间之后算, - 这里把它提上来,避免"改了区间却没重新算位置"这种漏改)。 */ + 这里把它提上来,避免"改了区间却没重新算位置"这种漏改)。 + 🔴🔴 v44(用户 2026-10-12 第 3 条):「首页上的红线刻度显示不准确。 + 现在明明已经 7:44 了,但它那个红线还停留在最早的 7 点钟, + 并没有走到 7:44 那个位置。这个只有在首页上是这样的。我点到日程里面, + 那个红线的刻度好像是对的。」 + ⇒ 真根因:首页用的是 `date('G')`(**只取整小时**),而日程页用的是 + `(int)date('G')*60 + (int)date('i')`(**精确到分**)。 + 7:44 时首页算出 7 → 红线停在 7:00,正是用户看到的。 + ⇒ 这里改成与日程页**同一套**(小时*60+分钟),两处的口径不再分叉。 */ $nowHNow = (int)date('G'); if ($nowHNow < $startH) { $startH = max(0, $nowHNow - 1); } if ($nowHNow >= $endH) { $endH = min(24, $nowHNow + 2); } if ($endH <= $startH) { $endH = min(24, $startH + 2); } } -$nowH = (int)date('G'); -$nowPos = ($nowH - $startH) / max(1, ($endH - $startH)); // 红线位置(0~1) +/* 红线位置:**分钟级**(与日程页同一算法)。见上面 v44 那条 —— 原来是 `date('G')` + (整小时),所以"7:44 的红线停在 7:00"。 */ +$nowMinutes = ((int)date('G')) * 60 + (int)date('i'); +$nowPos = (($nowMinutes / 60) - $startH) / max(1, ($endH - $startH)); // 红线位置(0~1) $showNow = $nowPos >= 0 && $nowPos <= 1; /* 🔴 v30 第 2/4 条:首页日程也用**和日程模块同一套冲突分列**。 @@ -304,10 +314,15 @@ unset($evRef);
+ -
- -
+
+ + +
+

+ App 接口令牌 + 移动端 / 第三方程序调用用 +

+

+ 接口地址前缀 /api/v1/, + 请求头带 Authorization: Bearer <令牌>。 + 先调 GET /api/v1/ping 探连通(免令牌)。 +
完整接口清单见仓库里的 docs/API.md。 +

+ +
+
+ + +
+
+ + + 令牌本身就是凭据,这一行只是给你对账用。 +
+
+ + +

+ 这是你的新令牌,只显示这一次,请立刻复制到 App 里: + + (设备名:) + +
+

+ + + + + + + + + + + + + + + + + +
设备名签发时间最近使用
+
+ + + +
+
+ +

还没有令牌。给每台设备发一枚吧。

+ + + +
+
+ + + +
+ +
+ + +
+ +
+

+ 令牌只在生成那一次显示明文(库里只存哈希)。手机丢了就吊销那一枚。 +

+ +
+ " class="node-form" id="tkEditForm"> -
-
- + +
+
+ 任务名称*
-
- +
+ 截止时间 +
-
- +
+ 负责人
-
- +
+ 循环
-
- +
+ 父任务
-
- +
+ 分类
-
- +
+ 看板
-
- +
+ 备注
diff --git a/templates/tasks/index.php b/templates/tasks/index.php index aca7bcf..6b95448 100644 --- a/templates/tasks/index.php +++ b/templates/tasks/index.php @@ -89,57 +89,72 @@ $lastColIdx = count($colList) - 1; `back=tasks` 走白名单。 */ ?>
-
-
- - + +
+
+ 任务名称* +
-
- + 比"这天 00:00 之前"更符合直觉。 + 🔴 v44(用户第 5 条):「截止时间也用我们最新的日历控件, + 记得把现在这个控件删掉。」 + ⇒ 换成 AntD 的**单点** DatePicker(带时刻): + `$dtPlugin='antd-single'`(见 app.js 的 mountAntdSingleDT)。 + 原生那几档下拉由 CSS **真删掉**(不是 clip 藏起来)。 + 标签去掉那个括号(见下一条)。 */ ?> +
+ 截止时间
-
- + +
+ 负责人
- -
- + +
+ 状态 - 选「已完成」的话,它只在「已完成」里出现,看板上不再显示。
-
- +
+ 循环
-
- +
+ 分类
-
- +
+ 看板
-
- - + +
+ 共享给
-
- +
+ 备注
@@ -184,12 +201,12 @@ $lastColIdx = count($colList) - 1; 服务端 attach_save_files(..., 'attach', ...) 零改动就能收。 ⚠️ 拖进来的走暂存(stage_token[]),随表单一起认领; 直接点选走 $_FILES 路径。两条路服务端都接(见 attach_save_files)。 */ ?> -
- +
+ 附件
-
+
@@ -225,17 +242,14 @@ $lastColIdx = count($colList) - 1; - -
- $sv): ?> - - - - - -
+