1010 lines
49 KiB
PHP
1010 lines
49 KiB
PHP
<?php
|
||||
|
|
/**
|
|||
|
|
* 🔴 v44(用户 2026-10-12 第 6 条)—— **API 资源路由表**。
|
|||
|
|
*
|
|||
|
|
* 一个 `switch` 管所有资源:`App\Api::dispatch()` 解析出 `<资源>/<id>/<子动作>` 后调这里。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 为什么不做成"每资源一个文件":本项目的部署与静态断言都是**按文件白名单**走的
|
|||
|
|
* (见 `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),
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
}
|