diff --git a/app/ApiHandler.php b/app/ApiHandler.php index 046251d..8cb4868 100644 --- a/app/ApiHandler.php +++ b/app/ApiHandler.php @@ -47,6 +47,12 @@ final class ApiHandler 'account' => 1, 'account_password' => 1, 'home_memories' => 1, + /* 🔴 v62:影院管理补齐(用户点名「增加影院/修改影院/高级搜索/ + 统计/年度报告」)。端上可据此探测:老服务端没有这三条时 + 只显示影院列表,不显示搜索/统计/报告的入口。 */ + 'movies_stats' => 1, + 'movies_report' => 1, + 'movies_search' => 1, // v58:同步可以"先问一句哪些模块变了",只拉变了的 // (见 `changes()` 的说明;老端上没有这个标志也不会坏) 'changes' => 1, @@ -524,6 +530,12 @@ final class ApiHandler $row['initials'] = Pinyin::initials( (string)$row['name'] . ' ' . (string)($row['my_nickname'] ?? '') ); + /* 🔴 v62(用户 2026-10-19):「现在名册的列表太空了,应该显示更多内容, + 例如**工作单位、职务**。」 + ⇒ 端上列表要显示"当前那段工作经历"的单位/职务。 + ⚠️ 具体经历在下面**一次批量**取回(`PersonJob::mapFor`), + 这里只准备字段位——**绝不在这里逐人查库**(N+1)。 */ + $row['jobs'] = []; // 🔴 脱敏:名册行同样是 `SELECT p.*`,敏感密文一律不下发 // (见 stripSensitivePerson 的说明)。 @@ -540,6 +552,15 @@ final class ApiHandler )); } + /* 🔴 v62:**一次**把所有人的工作经历取回来(`IN (…)` 单条 SQL)—— + ⚠️ 不能在装饰循环里逐人 `PersonJob::list()`:名册上千人就是上千次查询。 + 排序与 `PersonJob::list()` 一致,端上取第一段即"当前/最近的那段"。 */ + $jobsOf = PersonJob::mapFor(array_map(static fn(array $r): int => (int)$r['id'], $rows)); + foreach ($rows as &$row2) { + $row2['jobs'] = $jobsOf[(int)$row2['id']] ?? []; + } + unset($row2); + Api::ok([ 'people' => array_values($rows), // 标签云(筛选条用)+ 标签词表(编辑时选已有标签) @@ -1742,6 +1763,58 @@ final class ApiHandler { [$fam, $pid] = Api::me(); + /* 🔴 v62(用户 2026-10-19):「设置里面的影院管理,这些功能都还没有, + 应该补充,就是增加影院呢、修改影院呢、**高级搜索、统计、年度报告**, + 这些全部都应该补充进去。」 + ⇒ 网页端早就有 `movies/search` / `movies/stats` / `movies/report` + (见 `MovieStats`),但 **API 一个都没暴露** —— 端上只能干看着。 + 这里按网页同一套 `MovieStats` 暴露成子路由,口径与网页**完全一致** + (统计数字两边必须一样,否则用户会发现"网页 124 部、App 121 部")。 + ⚠️ 这三个都是 **家族级**(不针对某一条观影记录)⇒ 必须在 `$id > 0` + 分支**之前**处理,否则 `/movies/stats` 会被当成"id=0 的详情"。 */ + if ($sub === 'stats' && $id === 0) { + if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } + Api::ok([ + 'overview' => MovieStats::overview($fam), + 'by_cinema'=> MovieStats::byCinema($fam), + 'by_person'=> MovieStats::byPerson($fam, $pid), + 'by_month' => MovieStats::byMonth($fam, 12), + 'by_year' => MovieStats::byYear($fam), + ]); + } + if ($sub === 'report' && $id === 0) { + if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } + $years = MovieStats::years($fam); + $year = (int)Api::int('year'); + // 没给年份 / 给的年份没有数据 ⇒ 退回最新有数据的那一年(与网页同一规则) + if ($year <= 0 || !in_array($year, array_map('intval', $years), true)) { + $year = count($years) > 0 ? (int)$years[0] : (int)date('Y'); + } + Api::ok([ + 'year' => $year, + 'years' => count($years) > 0 ? array_map('intval', $years) : [$year], + 'stats' => MovieStats::annual($fam, $pid, $year), + ]); + } + if ($sub === 'search' && $id === 0) { + if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } + $p = [ + 'title' => Api::str('title'), + 'cinema_id' => Api::int('cinema_id'), + 'person_id' => Api::int('person_id'), + 'date_from' => (string)(Api::param('date_from') ?? ''), + 'date_to' => (string)(Api::param('date_to') ?? ''), + ]; + $has = false; + foreach ($p as $v) { if ($v !== '' && $v !== 0) { $has = true; break; } } + // ⚠️ 条件全空时**不查全库**(那等于"列出所有观影",不是搜索)—— + // 与网页 `hasCond` 同一行为,端上据此提示"先设条件"。 + Api::ok([ + 'has_cond' => $has ? 1 : 0, + 'rows' => $has ? MovieStats::search($fam, $p, $pid) : [], + ]); + } + if ($id > 0) { $m = Movie::find($fam, $id, $pid); if (!$m) { Api::notFound('这条观影记录'); } diff --git a/app/PersonJob.php b/app/PersonJob.php index d502683..6de2b20 100644 --- a/app/PersonJob.php +++ b/app/PersonJob.php @@ -39,6 +39,45 @@ final class PersonJob return $st->fetchAll(); } + /** + * **批量**取一批人的工作经历(一个人的当前段在前,其余按开始时间倒序)。 + * + * 🔴 v62(用户 2026-10-19):「现在名册的列表太空了,应该显示更多内容, + * 例如**工作单位、职务**。」 + * ⇒ 名册列表(上千人)要显示单位/职务,**不能**对每个人各调一次 + * `list()` —— 那是 N+1 查询(1000 人 = 1000 次 SQL)。 + * 这里一次 `IN (...)` 取回来,再按 person_id 分桶。 + * + * ⚠️ 返回的是**每个 id 的完整经历数组**(不是只给第一段): + * 端上列表只显示第一段(当前/最近的那段),但详情页要全部 —— + * 同一份数据一次给足,省得端上为详情再打一次接口。 + * ⚠️ 排序与 `list()` **逐字一致**(当前段在前 → 开始时间倒序 → id 倒序): + * 否则"列表显示的单位"和"详情里第一段"会是两个不同的单位。 + * + * @param int[] $personIds + * @return array person_id => [经历, …](没有经历的人**不出现**在结果里) + */ + public static function mapFor(array $personIds): array + { + $ids = array_values(array_unique(array_filter( + array_map('intval', $personIds), static fn(int $v): bool => $v > 0))); + if (count($ids) === 0) { + return []; + } + // ⚠️ `IN (?,?,…)` 的占位符按实际个数生成 —— 不能直接把数组塞进去 + $ph = implode(',', array_fill(0, count($ids), '?')); + $st = Database::get()->prepare( + "SELECT * FROM person_jobs WHERE person_id IN ({$ph}) + ORDER BY person_id, (end_ym IS NULL) DESC, COALESCE(start_ym, \"\") DESC, id DESC" + ); + $st->execute($ids); + $out = []; + foreach ($st->fetchAll() as $r) { + $out[(int)$r['person_id']][] = $r; + } + return $out; + } + /** * 整组替换(与 Person::replaceContacts 同一套做法:表单里就是一组行, * 删行/改行/加行一起提交,最省心 —— 逐行增删要额外的行 id 体系)。