v44 第 44 批:界面 5 条 + 移动端 API v1 全量
界面(用户 2026-10-12 第 1~5 条) 1. 新建日程「日历本」并入 .cs 自绘下拉,颜色画进候选项,三字与下拉同行。 2. 新建日程「开始/结束」改「日期范围:」,删旧日期控件; 重复相关三行仅选重复时出现;删「(含第一次)」与「这段时间算忙碌」。 3. 条目浮层加「地点」、去掉「状态」行;首页红线按分钟定位(原只取整小时); 去掉左侧红点只留红线。 4. 任务页删掉状态说明提示(STATUS_HINT 三值清空)。 5. 新建任务:标题在前树框在后;截止时间改最新日历控件;负责人默认自己; 删冗余提示;「上面要做什么」→「任务名称」;清理括号说明。 移动端 API(用户第 6 条:导航模块除外,其余全量) - 新增 Bearer 令牌鉴权(api_tokens 表,Schema v32),与登录口令 / DAV 口令三套分立。 - Api.php 内核(鉴权/取参/信封/分页/分发 + _method 覆写)+ ApiHandler.php 17 条资源 + ApiToken.php + api.php 入口。 - 路由:router.php / .htaccess / index.php 三处 /api/ 兜底(部署脚本自动追加线上规则)。 - 个人设置新增「App 接口令牌」卡;docs/API.md 接口文档; docs/API-QUESTION-LIST.md 自主决策与待确认缺口清单。 验证:本地 e2e 1510/0、v43 真机 33/0、v43 反向 9/0、 v44 真机 41/0、v44 API 63/0、v44 反向 19/0;全流水线 PUSH_RC=0。
This commit is contained in:
@@ -67,6 +67,13 @@ RewriteRule ^$ dav.php [L,QSA]
|
|||||||
# 到这里第一次出现"一个前缀包含另一个"的情况,顺序**变成有语义的**了。
|
# 到这里第一次出现"一个前缀包含另一个"的情况,顺序**变成有语义的**了。
|
||||||
RewriteRule ^nav/suggest\.php$ nav_suggest.php [L,QSA]
|
RewriteRule ^nav/suggest\.php$ nav_suggest.php [L,QSA]
|
||||||
RewriteRule ^nav/?$ nav_show.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]
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# ⚠️ 导航图标与展示页头像**不需要重写规则**(v24 第 25 批):
|
# ⚠️ 导航图标与展示页头像**不需要重写规则**(v24 第 25 批):
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 🔴 v44(用户 2026-10-12 第 6 条)—— **移动端 API 入口**。
|
||||||
|
*
|
||||||
|
* 路径形态:`/api/v1/<资源>[/<id>][/<子动作>]`
|
||||||
|
* 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();
|
||||||
+352
@@ -0,0 +1,352 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 🔴 v44(用户 2026-10-12 第 6 条)—— **移动端 API 内核**。
|
||||||
|
*
|
||||||
|
* 用户原话:「我们现在马上要开始移动终端的开发,所以现在要开发所有接口。
|
||||||
|
* 你现在就根据我们现在的功能,把所有接口设计和开发出来。
|
||||||
|
* 导航这个模块不需要了,因为这个模块是专门为我们的外部端开发的,
|
||||||
|
* 所以这个不需要,其他全部都需要。」
|
||||||
|
*
|
||||||
|
* ════════════════════════════════════════════════════════════════════
|
||||||
|
* 为什么是一个"内核"而不是在 api.php 里逐条写
|
||||||
|
* ════════════════════════════════════════════════════════════════════
|
||||||
|
* 213 个网页路由要映射成 REST。若逐条手写,"取参数 / 鉴权 / 序列化 / 错误码"
|
||||||
|
* 会在几十个地方各抄一遍 —— 那是**必然**有一天长歪的写法(本项目已经栽过
|
||||||
|
* 好几轮"两处写法不同步")。所以这里把四件事收进一处:
|
||||||
|
* · **鉴权**:`Authorization: Bearer <token>` → `Api::actor()`;
|
||||||
|
* · **取参**:`Api::param()`(JSON body 与表单都能吃);
|
||||||
|
* · **出参**:`Api::ok() / Api::fail()`(统一信封 + HTTP 码);
|
||||||
|
* · **动作守卫**:`Api::requireWrite()`(只读账号不能写,与网页一致)。
|
||||||
|
*
|
||||||
|
* ════════════════════════════════════════════════════════════════════
|
||||||
|
* 统一响应信封(**App 端只按这一套解析**)
|
||||||
|
* ════════════════════════════════════════════════════════════════════
|
||||||
|
* 成功:{ "ok": true, "data": <任意> }
|
||||||
|
* 失败:{ "ok": false, "error": "人话错误信息", "code": "bad_request" }
|
||||||
|
* HTTP 码同时给出:200 / 400 / 401 / 403 / 404 / 409 / 422 / 500。
|
||||||
|
* ⚠️ 用**信封**而不是裸数据:否则 App 端无法区分"成功但值是 null"
|
||||||
|
* 与"请求失败"(HTTP 200 + 空 body 的歧义)。
|
||||||
|
*
|
||||||
|
* ════════════════════════════════════════════════════════════════════
|
||||||
|
* 与网页共用业务类的**唯一真值来源**
|
||||||
|
* ════════════════════════════════════════════════════════════════════
|
||||||
|
* 所有写操作都走 `App\Movie::create()` 这类**模型方法**(网页路由也是调它们),
|
||||||
|
* 不在 API 里另写一套 SQL。理由:
|
||||||
|
* · 权限/归属校验("这本日历能不能写""这个人在不在我家")已经在模型里;
|
||||||
|
* · 将来网页那边修了业务规则,API 自动跟上,不会出现"App 建的记录与网页建的不同"。
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Api
|
||||||
|
{
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
鉴权
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/** 从 Authorization 头里取 Bearer 令牌(也接受 `?token=` —— 见下)。 */
|
||||||
|
public static function bearer(): string
|
||||||
|
{
|
||||||
|
/* ⚠️ 取头的三种写法都要认:Apache 直接监听时是 `HTTP_AUTHORIZATION`;
|
||||||
|
PHP-FPM 下有时只出现在 `REDIRECT_HTTP_AUTHORIZATION`(重写之后);
|
||||||
|
还有 `apache_request_headers()`(老 Apache + CGI 的兜底)。
|
||||||
|
本项目已经因为"头取不到"栽过一次(DAV 的 `CGIPassAuth On`,见 README),
|
||||||
|
所以这里三层都试,**不赌**环境。 */
|
||||||
|
$raw = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? '');
|
||||||
|
if ($raw === '') { $raw = (string)($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''); }
|
||||||
|
if ($raw === '' && function_exists('apache_request_headers')) {
|
||||||
|
$h = apache_request_headers();
|
||||||
|
foreach ($h as $k => $v) {
|
||||||
|
if (strcasecmp((string)$k, 'Authorization') === 0) { $raw = (string)$v; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (preg_match('/^\s*Bearer\s+(.+)$/i', $raw, $m)) {
|
||||||
|
return trim($m[1]);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前调用者。成功返回 ['person_id','family_id','token_id','role','can_write']。
|
||||||
|
*
|
||||||
|
* 🔴🔴 **本函数会直接结束请求**(未鉴权时 401 + exit)——这是刻意的:
|
||||||
|
* 若返回 null 让每个调用点自己判断,总有一天有人忘了判。
|
||||||
|
*/
|
||||||
|
public static function actor(): array
|
||||||
|
{
|
||||||
|
static $cache = null;
|
||||||
|
if (is_array($cache)) { return $cache; }
|
||||||
|
|
||||||
|
$token = self::bearer();
|
||||||
|
if ($token === '') {
|
||||||
|
self::fail('缺少 API 令牌(请在请求头带 `Authorization: Bearer <token>`)。',
|
||||||
|
'unauthorized', 401);
|
||||||
|
}
|
||||||
|
$hit = ApiToken::verify($token);
|
||||||
|
if ($hit === null) {
|
||||||
|
self::fail('API 令牌无效、已过期或被吊销。', 'unauthorized', 401);
|
||||||
|
}
|
||||||
|
$personId = (int)$hit[0];
|
||||||
|
$p = Person::find($personId);
|
||||||
|
if (!$p) {
|
||||||
|
self::fail('令牌对应的人不存在。', 'unauthorized', 401);
|
||||||
|
}
|
||||||
|
/* 角色与写权限**照抄网页那套判定**(Auth::canWrite / isGuest),
|
||||||
|
否则会出现"网页里不能写、App 里能写"这种权限裂缝。
|
||||||
|
⚠️ Auth 的方法读的是 $_SESSION —— API 无 session,所以这里按同一个
|
||||||
|
语义(role + status)**重算**一遍,而不是去读 Auth。 */
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT role, status FROM accounts WHERE person_id = ? LIMIT 1'
|
||||||
|
);
|
||||||
|
$st->execute([$personId]);
|
||||||
|
$acct = $st->fetch() ?: [];
|
||||||
|
$role = (string)($acct['role'] ?? 'member');
|
||||||
|
if ((string)($acct['status'] ?? 'active') !== 'active') {
|
||||||
|
self::fail('这个账号已被停用。', 'forbidden', 403);
|
||||||
|
}
|
||||||
|
$cache = [
|
||||||
|
'person_id' => $personId,
|
||||||
|
'family_id' => (int)$p['family_id'],
|
||||||
|
'token_id' => (int)$hit[1],
|
||||||
|
'role' => $role,
|
||||||
|
'can_write' => $role !== 'guest',
|
||||||
|
];
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只读账号(guest)不能写 —— 与网页的 `Auth::requireWrite()` 同一语义。 */
|
||||||
|
public static function requireWrite(): void
|
||||||
|
{
|
||||||
|
$a = self::actor();
|
||||||
|
if (!$a['can_write']) {
|
||||||
|
self::fail('只读账号不能修改数据。', 'forbidden', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
取参与出参
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
private static ?array $body = null;
|
||||||
|
|
||||||
|
/** 请求体(JSON 或表单),只解析一次。 */
|
||||||
|
public static function body(): array
|
||||||
|
{
|
||||||
|
if (self::$body !== null) { return self::$body; }
|
||||||
|
$ct = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||||
|
if (stripos($ct, 'application/json') !== false) {
|
||||||
|
$raw = (string)file_get_contents('php://input');
|
||||||
|
$dec = $raw === '' ? [] : json_decode($raw, true);
|
||||||
|
self::$body = is_array($dec) ? $dec : [];
|
||||||
|
} else {
|
||||||
|
self::$body = array_merge($_GET, $_POST);
|
||||||
|
}
|
||||||
|
return self::$body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取一个参数:GET / POST 表单 / JSON body 都能吃。
|
||||||
|
* ⚠️ 合并优先级 JSON body > POST > GET —— 与直觉一致(显式 body 最权威)。
|
||||||
|
*/
|
||||||
|
public static function param(string $key, $default = null)
|
||||||
|
{
|
||||||
|
$b = self::body();
|
||||||
|
if (array_key_exists($key, $b)) { return $b[$key]; }
|
||||||
|
if (array_key_exists($key, $_GET)) { return $_GET[$key]; }
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取字符串参数(trim + 可选长度上限) */
|
||||||
|
public static function str(string $key, string $default = '', int $max = 0): string
|
||||||
|
{
|
||||||
|
$v = self::param($key, $default);
|
||||||
|
$v = is_scalar($v) ? (string)$v : '';
|
||||||
|
$v = trim($v);
|
||||||
|
return ($max > 0) ? mb_substr($v, 0, $max) : $v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取整数参数 */
|
||||||
|
public static function int(string $key, int $default = 0): int
|
||||||
|
{
|
||||||
|
$v = self::param($key, $default);
|
||||||
|
return is_scalar($v) ? (int)$v : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取布尔参数(认 1/true/'on'/'yes') */
|
||||||
|
public static function bool(string $key, bool $default = false): bool
|
||||||
|
{
|
||||||
|
$v = self::param($key, $default);
|
||||||
|
if (is_bool($v)) { return $v; }
|
||||||
|
if (is_scalar($v)) {
|
||||||
|
return in_array(strtolower((string)$v), ['1', 'true', 'on', 'yes'], true);
|
||||||
|
}
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取整数数组(如 attendees[] / shares[]) */
|
||||||
|
public static function ints(string $key): array
|
||||||
|
{
|
||||||
|
$v = self::param($key, []);
|
||||||
|
if (!is_array($v)) { return []; }
|
||||||
|
return array_values(array_map('intval', $v));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔴 时间参数的**双格式**接受(App 的痛点,刻意做得宽容):
|
||||||
|
* · 'YYYY-MM-DD HH:MM(:SS)' —— 网页控件产出的格式;
|
||||||
|
* · 'YYYY-MM-DDTHH:MM(:SS)…' —— ISO 8601(iOS/Android 原生日期控件默认);
|
||||||
|
* · 'YYYY-MM-DD' —— 纯日期(全天日程;时间补 00:00:00)。
|
||||||
|
* 解析不出 → 返回 null(**不抛异常**,让调用方决定"忽略"还是"报错")。
|
||||||
|
*/
|
||||||
|
public static function dt(string $key, ?string $default = null): ?string
|
||||||
|
{
|
||||||
|
$v = self::param($key, null);
|
||||||
|
if ($v === null || $v === '') { return $default; }
|
||||||
|
if (!is_scalar($v)) { return $default; }
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') { return $default; }
|
||||||
|
// 先到 UTC 再落地:iOS 给的是带 Z / 带偏移的 ISO 串,直接 substr 会拿到 UTC 时刻
|
||||||
|
$ts = strtotime($s);
|
||||||
|
if ($ts === false) { return null; }
|
||||||
|
// 纯日期:时间部分归零(与网页"全天日程"的口径一致)
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) {
|
||||||
|
return date('Y-m-d', $ts) . ' 00:00:00';
|
||||||
|
}
|
||||||
|
return date('Y-m-d H:i:s', $ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成功响应并结束请求。 */
|
||||||
|
public static function ok($data = null, int $code = 200, array $extra = []): void
|
||||||
|
{
|
||||||
|
self::send(array_merge(['ok' => true, 'data' => $data], $extra), $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 失败响应并结束请求。 */
|
||||||
|
public static function fail(string $msg, string $code = 'bad_request', int $http = 400): void
|
||||||
|
{
|
||||||
|
self::send(['ok' => false, 'error' => $msg, 'code' => $code], $http);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function send(array $payload, int $http): void
|
||||||
|
{
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
http_response_code($http);
|
||||||
|
}
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE
|
||||||
|
| JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
分页(列表接口统一用它,App 端滚动加载)
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/** 返回 [limit, offset, page],并把它们记在静态字段里供 `page()` 回填 meta。 */
|
||||||
|
private static array $pageInfo = ['page' => 1, 'per_page' => 50, 'offset' => 0];
|
||||||
|
|
||||||
|
public static function page(int $defaultPer = 50, int $maxPer = 200): array
|
||||||
|
{
|
||||||
|
$per = max(1, min($maxPer, self::int('per_page', $defaultPer)));
|
||||||
|
$pg = max(1, self::int('page', 1));
|
||||||
|
$plan = self::str('paginate', '');
|
||||||
|
// `paginate=1` 时返回带 meta 的信封;否则(默认)返回裸数组,App 少写一层
|
||||||
|
self::$pageInfo = ['page' => $pg, 'per_page' => $per, 'offset' => ($pg - 1) * $per];
|
||||||
|
return [$per, self::$pageInfo['offset'], $pg];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表响应:默认裸数组;带 `paginate=1` 时给出分页 meta。 */
|
||||||
|
public static function list(array $rows, int $total = -1): void
|
||||||
|
{
|
||||||
|
if (self::bool('paginate')) {
|
||||||
|
self::ok([
|
||||||
|
'items' => $rows,
|
||||||
|
'total' => $total >= 0 ? $total : count($rows),
|
||||||
|
'page' => self::$pageInfo['page'],
|
||||||
|
'per_page' => self::$pageInfo['per_page'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
self::ok($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
便捷:拿"某类资源"的公共前置
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/** 当前家庭 + 当前人(几乎所有接口都要,省得每处 `self::actor()` 解构)。 */
|
||||||
|
public static function me(): array
|
||||||
|
{
|
||||||
|
$a = self::actor();
|
||||||
|
return [$a['family_id'], $a['person_id']];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 找不到资源时的统一 404 */
|
||||||
|
public static function notFound(string $what = '资源'): void
|
||||||
|
{
|
||||||
|
self::fail($what . '不存在,或者你不该看到它。', 'not_found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一批 id 过滤成"确实属于这个家庭的人"(防手搓请求把别家的人挂进来)。
|
||||||
|
*
|
||||||
|
* ⚠️ 与网页表单同一道防线(index.php 里的全局函数 `filter_family_person_ids`)——
|
||||||
|
* 但那个函数定义在 **index.php 顶层**,API 这条路不经过 index.php
|
||||||
|
* (.htaccess 直接重写到 api.php),所以**取不到它**。
|
||||||
|
* 实测:在 ApiHandler 里直接调它会 `Call to undefined function
|
||||||
|
* App\filter_family_person_ids()` —— PHP 会先在当前命名空间找。
|
||||||
|
* ⇒ 这里自己实现一遍,语义与那个全局函数**逐字对齐**
|
||||||
|
* (查 persons 表、限 family_id、返回去重后的整型数组)。
|
||||||
|
*/
|
||||||
|
public static function people(array $ids, int $familyId): array
|
||||||
|
{
|
||||||
|
$ids = array_values(array_unique(array_filter(array_map('intval', $ids),
|
||||||
|
static fn(int $x): bool => $x > 0)));
|
||||||
|
if (!$ids) { return []; }
|
||||||
|
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
"SELECT id FROM persons WHERE family_id = ? AND id IN ($in)"
|
||||||
|
);
|
||||||
|
$st->execute(array_merge([$familyId], $ids));
|
||||||
|
return array_map('intval', array_column($st->fetchAll(), 'id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由分发:`/api/v1/<资源>[/<id>][/<动作>]`。
|
||||||
|
*
|
||||||
|
* ⚠️ 单一入口(api.php 的 .htaccess 重写)—— 不在 URL 里堆 query 参数,
|
||||||
|
* 这样 App 的网络层可以按 REST 习惯写(GET/POST/PATCH/DELETE + 路径)。
|
||||||
|
*/
|
||||||
|
public static function dispatch(): void
|
||||||
|
{
|
||||||
|
// 方法模拟:App 端(尤其部分安卓网络库)不方便发 PATCH/DELETE 时
|
||||||
|
// 用 POST + `_method=DELETE`,这里做一次统一改写。
|
||||||
|
// ⚠️ 必须用 `Api::param()`(它同时认 JSON body / POST 表单 / GET)——
|
||||||
|
// 只读 `$_POST['_method']` 的话,JSON 请求体的改写会**静默失效**
|
||||||
|
// (实测踩过:POST + JSON `_method:DELETE` 得到 405)。
|
||||||
|
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||||
|
$override = strtoupper((string)($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? ''));
|
||||||
|
if ($override === '') { $override = strtoupper((string)self::param('_method', '')); }
|
||||||
|
if ($method === 'POST' && in_array($override, ['PUT', 'PATCH', 'DELETE'], true)) {
|
||||||
|
$method = $override;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
|
||||||
|
/* 去掉前缀:`/api/v1/...`(.htaccess / router.php 那条重写),
|
||||||
|
或者 `?r=api/v1/...`(index.php 那条兜底 —— 它会把 REQUEST_URI 改成
|
||||||
|
`/api/v1/...` 再进来,所以这里一条正则就够)。
|
||||||
|
两种入口的路径最终都归一成 `tasks/12/status` 这样的相对串。 */
|
||||||
|
$path = preg_replace('#^.*?/api/v1#', '', $path);
|
||||||
|
$path = trim((string)$path, '/');
|
||||||
|
$parts = $path === '' ? [] : explode('/', $path);
|
||||||
|
|
||||||
|
$res = strtolower($parts[0] ?? '');
|
||||||
|
$id = isset($parts[1]) && ctype_digit((string)$parts[1]) ? (int)$parts[1] : 0;
|
||||||
|
$sub = strtolower($parts[1] ?? '');
|
||||||
|
if (ctype_digit($sub)) { $sub = strtolower((string)($parts[2] ?? '')); }
|
||||||
|
|
||||||
|
$h = new ApiHandler();
|
||||||
|
$h->route($res, $id, $sub, $method);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1010
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 移动端 / 第三方调用的 **API 令牌**(v44,用户 2026-10-12 第 6 条)。
|
||||||
|
*
|
||||||
|
* 用户原话:「我们现在马上要开始移动终端的开发,所以现在要开发所有接口。」
|
||||||
|
*
|
||||||
|
* 与既有两套凭据的关系(**三套分开、互不代用**):
|
||||||
|
* · 登录口令(accounts.password_hash)—— 网页用,走 session;
|
||||||
|
* · DAV 口令(dav_credentials)—— CalDAV/CardDAV 客户端用,走 Basic;
|
||||||
|
* · **API 令牌(本类)** —— 移动端 App 用,走 `Authorization: Bearer <token>`。
|
||||||
|
* 为什么不复用 DAV 那套口令:那套是**给第三方同步客户端**的、明文要手打进手机;
|
||||||
|
* API 令牌是给自家 App 的,需要**设备名 + 有效期 + 可逐个吊销**,
|
||||||
|
* 生命周期与用途都不同(DAV 那把一吊销,日历同步也跟着断)。
|
||||||
|
*
|
||||||
|
* ⚠️ **令牌只在签发的这一次返回明文**(库里只存 sha256)。
|
||||||
|
* 与 DAV 口令同一取舍:明文落库一旦泄露就全暴露;不返回用户又永远看不到。
|
||||||
|
* ⇒ 网页端把明文塞进一次性 session 槽位、显示一次;App 端它自己存 Keychain。
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class ApiToken
|
||||||
|
{
|
||||||
|
/** 令牌前缀:让日志/配置里一眼能认出"这是家书的 API 令牌"(GitHub 的 ghp_ 同理) */
|
||||||
|
public const PREFIX = 'fb_';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签发一枚新令牌,返回**明文**(调用方负责立刻交给用户,之后再也拿不到)。
|
||||||
|
*
|
||||||
|
* ⚠️ 用 `random_bytes` 而不是 `random_int` 循环拼短字符集:
|
||||||
|
* 令牌是机器读的(不像 DAV 口令要手打),长度与熵优先。
|
||||||
|
* 32 字节 → base64url 43 字符 → 约 256 bit 熵。
|
||||||
|
*/
|
||||||
|
public static function issue(int $personId, string $label = '', ?string $expiresAt = null): string
|
||||||
|
{
|
||||||
|
$plain = self::PREFIX . self::b64url(random_bytes(32));
|
||||||
|
Database::get()->prepare(
|
||||||
|
'INSERT INTO api_tokens (person_id, token_hash, label, expires_at) VALUES (?,?,?,?)'
|
||||||
|
)->execute([
|
||||||
|
$personId,
|
||||||
|
hash('sha256', $plain),
|
||||||
|
$label !== '' ? mb_substr($label, 0, 60) : null,
|
||||||
|
$expiresAt !== '' ? $expiresAt : null,
|
||||||
|
]);
|
||||||
|
return $plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验一枚令牌,成功返回 [personId, tokenId],失败 null。
|
||||||
|
*
|
||||||
|
* ⚠️ 只认 `Bearer` 头 —— 不接 query string(URL 会进日志/浏览器历史,
|
||||||
|
* 令牌出现在 URL 里等于半公开)。DAV 那套用 Basic 是 RFC 定的,没得选;
|
||||||
|
* API 是我们自己定的,就选更安全的那个。
|
||||||
|
* ⚠️ 逐字节 hash 之后查库(不先解密/比对),配合唯一索引 —— 不走时序分支。
|
||||||
|
* ⚠️ 过期/被吊销一律返回 null(不给"为什么不行"的细粒度错误,
|
||||||
|
* 避免给爆破者提供信息;具体原因网页端能看到)。
|
||||||
|
*/
|
||||||
|
public static function verify(string $plain): ?array
|
||||||
|
{
|
||||||
|
$plain = trim($plain);
|
||||||
|
if ($plain === '' || strpos($plain, self::PREFIX) !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT t.id, t.person_id, a.status
|
||||||
|
FROM api_tokens t
|
||||||
|
JOIN accounts a ON a.person_id = t.person_id
|
||||||
|
WHERE t.token_hash = ?
|
||||||
|
AND (t.expires_at IS NULL OR t.expires_at > NOW())
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$st->execute([hash('sha256', $plain)]);
|
||||||
|
$row = $st->fetch();
|
||||||
|
if (!$row || (string)$row['status'] !== 'active') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 记一次使用时间(不给它加索引/不加事务:这是"顺带的统计",不值得拖慢鉴权)
|
||||||
|
try {
|
||||||
|
Database::get()->prepare('UPDATE api_tokens SET last_used_at = NOW() WHERE id = ?')
|
||||||
|
->execute([(int)$row['id']]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 记不上不该拦住这次请求
|
||||||
|
}
|
||||||
|
return [(int)$row['person_id'], (int)$row['id']];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某个人的令牌清单(**不含明文**,只给管理界面看) */
|
||||||
|
public static function listFor(int $personId): array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT id, label, created_at, last_used_at, expires_at
|
||||||
|
FROM api_tokens WHERE person_id = ? ORDER BY id DESC'
|
||||||
|
);
|
||||||
|
$st->execute([$personId]);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 吊销一枚(限自己的) */
|
||||||
|
public static function revoke(int $personId, int $id): bool
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare('DELETE FROM api_tokens WHERE id = ? AND person_id = ?');
|
||||||
|
$st->execute([$id, $personId]);
|
||||||
|
return $st->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 吊销自己的全部("手机丢了"时的应急动作) */
|
||||||
|
public static function revokeAll(int $personId): int
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare('DELETE FROM api_tokens WHERE person_id = ?');
|
||||||
|
$st->execute([$personId]);
|
||||||
|
return $st->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function b64url(string $raw): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-1
@@ -139,7 +139,7 @@ final class Schema
|
|||||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
||||||
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
||||||
*/
|
*/
|
||||||
public const VERSION = '31';
|
public const VERSION = '32';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
||||||
@@ -256,6 +256,22 @@ CREATE TABLE IF NOT EXISTS `dav_credentials` (
|
|||||||
COMMENT='DAV 专用口令(日历 CalDAV + 通讯录 CardDAV 共用一把;与登录口令分开)'
|
COMMENT='DAV 专用口令(日历 CalDAV + 通讯录 CardDAV 共用一把;与登录口令分开)'
|
||||||
SQL,
|
SQL,
|
||||||
<<<'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` (
|
CREATE TABLE IF NOT EXISTS `relations` (
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
`family_id` INT UNSIGNED NOT NULL,
|
`family_id` INT UNSIGNED NOT NULL,
|
||||||
|
|||||||
+7
-7
@@ -54,14 +54,14 @@ final class Task
|
|||||||
public const STATUS = ['todo' => '待办', 'doing' => '进行中', 'done' => '已完成'];
|
public const STATUS = ['todo' => '待办', 'doing' => '进行中', 'done' => '已完成'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每个状态「到底是什么意思」——**给界面上的人看**(v24 第 25 批 用户第 6 条)。
|
* 每个状态「到底是什么意思」——**给开发看**的语义说明。
|
||||||
*
|
*
|
||||||
* 用户原话:「待办和进行中,有什么区别?」——他不知道区别,就是这一处没写清楚。
|
* ⚠️ 历史(v24 第 25 批 用户第 6 条):用户问过「待办和进行中,有什么区别?」,
|
||||||
* 所以这句话要出现在**他看到状态的地方**:卡片上的 title、状态说明、
|
* 当时把这句话渲染在看板顶部当"状态说明"。
|
||||||
* 以及任务页头那句总说明(见 templates/tasks/index.php)。
|
* 🔴 v44(用户 2026-10-12 第 4 条):「任务页面,任务下面有一行字…
|
||||||
*
|
* 这个提示不要了,去掉。」⇒ **界面不再渲染它**(见 templates/tasks/index.php)。
|
||||||
* ⚠️ 文案刻意用"我/手头"这种第一人称口语:这是家庭场景,
|
* 常量与 `statusHint()` 保留:它仍是这三个状态语义的**唯一书面定义**,
|
||||||
* 写成"尚未开始执行"那种公文腔他自己也不会去读。
|
* 后端/文案需要时还能取;删掉反而要后人重新考古一遍。
|
||||||
*/
|
*/
|
||||||
public const STATUS_HINT = [
|
public const STATUS_HINT = [
|
||||||
'todo' => '记下来了,还没动手 —— 排队等着',
|
'todo' => '记下来了,还没动手 —— 排队等着',
|
||||||
|
|||||||
+4
-1
@@ -395,7 +395,10 @@ function fb_entry_json(array $ev, string $detail = '', string $edit = '', array
|
|||||||
if ($when !== '') { $rows[] = ['时间', $when]; }
|
if ($when !== '') { $rows[] = ['时间', $when]; }
|
||||||
if ((string)($ev['calname'] ?? '') !== '') { $rows[] = ['日历本', (string)$ev['calname']]; }
|
if ((string)($ev['calname'] ?? '') !== '') { $rows[] = ['日历本', (string)$ev['calname']]; }
|
||||||
if ((string)($ev['location'] ?? '') !== '') { $rows[] = ['地点', (string)$ev['location']]; }
|
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']]; }
|
if ((string)($ev['note'] ?? '') !== '') { $rows[] = ['备注', (string)$ev['note']]; }
|
||||||
// ⚠️ 扩展行排在"备注"之后、作为补充(调用方按需给,比如任务给「所属看板」)
|
// ⚠️ 扩展行排在"备注"之后、作为补充(调用方按需给,比如任务给「所属看板」)
|
||||||
foreach ($extra as $row) {
|
foreach ($extra as $row) {
|
||||||
|
|||||||
+90
-7
@@ -616,6 +616,25 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
控件依然完整可用,不是"骨架占位"。
|
控件依然完整可用,不是"骨架占位"。
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
.dt-input { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
.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-native { display: contents; }
|
||||||
.dt-input .dt-semi { display: none; flex: 1 1 220px; min-width: 0; }
|
.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 批"选人过滤一直是坏的"的同一个坑,别再踩第二次。 */
|
这正是第 11 批"选人过滤一直是坏的"的同一个坑,别再踩第二次。 */
|
||||||
.cs-item[hidden] { display: none; }
|
.cs-item[hidden] { display: none; }
|
||||||
.cs-item:hover, .cs-item:focus { background: var(--line-2); outline: 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 兜底**:那个原生 `<select id="ce-cal">` 只做视觉收起 ——
|
||||||
|
用 `position:absolute; width:1px`(**不是 display:none**,保留可聚焦、可提交),
|
||||||
|
与 `_datetime.php` 的"隐藏 date 当值载体"同一套思路。
|
||||||
|
⚠️ 断言里按 `#ce-cal` 取 option / 值的写法必须继续成立 —— 它在 DOM 里。 */
|
||||||
|
.cal-book-cs { width: 100%; }
|
||||||
|
.cal-book-native {
|
||||||
|
position: absolute; width: 1px; height: 1px;
|
||||||
|
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
|
||||||
|
border: 0; padding: 0; margin: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 新建影院:居中弹框(与名册的动作弹框同一形态,只是更宽) */
|
/* 新建影院:居中弹框(与名册的动作弹框同一形态,只是更宽) */
|
||||||
.cn-modal {
|
.cn-modal {
|
||||||
@@ -3858,6 +3895,12 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
|
|||||||
[data-mode-vehicle][hidden],
|
[data-mode-vehicle][hidden],
|
||||||
[data-mode-ticket][hidden] { display: none !important; }
|
[data-mode-ticket][hidden] { display: none !important; }
|
||||||
|
|
||||||
|
/* 🔴 v44(用户 2026-10-12 第 2 条):重复那三行(结束条件 / 重复次数 / 重复到)
|
||||||
|
在观影式排版下是 `.mv-row`(`display:flex`)⇒ 同上面 `.field` 那个坑:
|
||||||
|
**作者样式优先于 `[hidden]` 的浏览器默认 `display:none`**,`hidden` 形同虚设。
|
||||||
|
⇒ 一并补上,否则"不重复时它们也显示着"。 */
|
||||||
|
.mv-row[hidden] { display: none !important; }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
v24 第 20 批 第 4 条:拖动即时上传的**暂存区** UI
|
v24 第 20 批 第 4 条:拖动即时上传的**暂存区** UI
|
||||||
------------------------------------------------------------
|
------------------------------------------------------------
|
||||||
@@ -4379,12 +4422,11 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
|
|||||||
.ag-row { display: flex; align-items: flex-start; gap: 8px; height: 44px; }
|
.ag-row { display: flex; align-items: flex-start; gap: 8px; height: 44px; }
|
||||||
.ag-hour { flex: 0 0 42px; font-size: 11.5px; color: var(--faint); font-variant-numeric: tabular-nums; transform: translateY(-6px); }
|
.ag-hour { flex: 0 0 42px; font-size: 11.5px; color: var(--faint); font-variant-numeric: tabular-nums; transform: translateY(-6px); }
|
||||||
.ag-line { flex: 1 1 auto; height: 1px; background: var(--line-2); margin-top: 0; }
|
.ag-line { flex: 1 1 auto; height: 1px; background: var(--line-2); margin-top: 0; }
|
||||||
/* 当前时刻红线(用户原话「需要在当前的时间或者是时刻那里显示一条红线」) */
|
/* 当前时刻红线(用户原话「需要在当前的时间或者是时刻那里显示一条红线」)
|
||||||
|
🔴 v44(用户第 3 条):左端那颗小圆点**删掉**了 ——
|
||||||
|
用户:「这个红点显示也不全,也不太好看。直接把红点去掉就行了,直接一根红线就可以了。」
|
||||||
|
⇒ 直接一根 2px 红线;`.ag-now-dot` 这条规则随之删除(不再有那个元素)。 */
|
||||||
.ag-now { position: absolute; left: 46px; right: 0; height: 0; border-top: 2px solid var(--red); z-index: 2; }
|
.ag-now { position: absolute; left: 46px; right: 0; height: 0; border-top: 2px solid var(--red); z-index: 2; }
|
||||||
.ag-now-dot {
|
|
||||||
position: absolute; left: -4px; top: -5px; width: 8px; height: 8px;
|
|
||||||
border-radius: 50%; background: var(--red);
|
|
||||||
}
|
|
||||||
/* 日程块 */
|
/* 日程块 */
|
||||||
/* 🔴 v30 第 2 条:「首页上的今天的日程,用色块表示,和日程保持一致。」
|
/* 🔴 v30 第 2 条:「首页上的今天的日程,用色块表示,和日程保持一致。」
|
||||||
⇒ 从原来的"白底卡片 + 左边 3px 色线"改成**与日历页 `.tg-block` 同款的实心色块**:
|
⇒ 从原来的"白底卡片 + 左边 3px 色线"改成**与日历页 `.tg-block` 同款的实心色块**:
|
||||||
@@ -5370,12 +5412,53 @@ h5.md-h, h6.md-h { font-size: 14px; }
|
|||||||
/* ⚠️ 挂上 antd 之后:**原生那对下拉收起来**(它们仍然在 DOM 里当值载体)。
|
/* ⚠️ 挂上 antd 之后:**原生那对下拉收起来**(它们仍然在 DOM 里当值载体)。
|
||||||
⚠️ 用 visibility+绝对定位而不是 display:none —— 见 v37 那次的教训:
|
⚠️ 用 visibility+绝对定位而不是 display:none —— 见 v37 那次的教训:
|
||||||
display:none 的控件在表单校验时不可聚焦;这里虽然已摘掉 required,
|
display:none 的控件在表单校验时不可聚焦;这里虽然已摘掉 required,
|
||||||
但保持"可聚焦"更稳(免得将来再加必填时又踩同一个坑)。 */
|
但保持"可聚焦"更稳(免得将来再加必填时又踩同一个坑)。
|
||||||
.dt-range.is-antd > .dt-input {
|
|
||||||
|
🔴🔴 v44(用户 2026-10-12 第 2 条):「之前的日期选择控件还有,需要删除。」
|
||||||
|
—— 用户在**日程**上看到 antd 控件与原生五个下拉**并排都在**。真根因是
|
||||||
|
**结构性**的:日程那对是 `‹div.dt-range› ‹div.field.ev-dt› ‹div.dt-input›`
|
||||||
|
(`.dt-input` 是**孙子**),而这条规则用的是**直接子**选择器 `> .dt-input`
|
||||||
|
⇒ **在日程上根本不命中**。
|
||||||
|
(与 v43 纪念日那次同族:结构一变,直接子选择器静默失效、不报任何错。)
|
||||||
|
⇒ ① 把 `>` 放宽成**后代**选择器(`_event_fields.php` 那边的结构也已改回
|
||||||
|
"与观影一致"的直接子,两道保险);
|
||||||
|
② 用户要的"删除"就做成真删 —— 用 `display:none` 把**原生下拉那一块**
|
||||||
|
从渲染里去掉。**注意:值载体不受影响** —— 隐藏的 `*_date` / `*_hour`
|
||||||
|
/ `*_minute` 是 `.dt-input` 的**直接子元素**,**不在 `.dt-native` 里**,
|
||||||
|
所以删掉原生块**不碰提交契约**。
|
||||||
|
(v37 那条"display:none 不可聚焦"讲的是**带 required 的可见字段**;
|
||||||
|
这里被藏的是"已被 antd 取代的旧控件",必填校验早已由
|
||||||
|
`guardRequired` 的 `data-required` 自查接管 ⇒ 不适用那个顾虑。) */
|
||||||
|
.dt-range.is-antd .dt-input {
|
||||||
position: absolute; width: 1px; height: 1px;
|
position: absolute; width: 1px; height: 1px;
|
||||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
|
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
|
||||||
border: 0; padding: 0; margin: -1px;
|
border: 0; padding: 0; margin: -1px;
|
||||||
}
|
}
|
||||||
|
/* antd 真的挂上了 ⇒ 原生那几档下拉**彻底不渲染**(用户要的"删除")。 */
|
||||||
|
.dt-range.is-antd .dt-input .dt-native { display: none !important; }
|
||||||
|
|
||||||
|
/* 🔴🔴 v44:**带内联标签的那个端点不收起**。
|
||||||
|
根因(真机几何量出来的):内联标签(「日期范围:」)是画在 `.dt-input` **里面**的,
|
||||||
|
而上面那条收起规则把整个 `.dt-input` 绝对定位 + `clip` 掉了 ⇒
|
||||||
|
**标签跟着一起被裁没了**,用户只看到一个没有标签的控件
|
||||||
|
(量出来的证据:标签盒在 x=316/y=313 却被祖先 clip,`elementFromPoint`
|
||||||
|
命中的是 `.ant-picker-range-separator` ⇒ 标签确实看不见)。
|
||||||
|
⇒ 两头都不牺牲:`.dt-input` 只藏**它里面的原生块**(上一条已 `display:none`),
|
||||||
|
自己保持正常布局 ⇒ 它变成 `[标签 62px][最新控件]` 的一个横排。
|
||||||
|
起止的**第二个**端点没有标签,继续按上一条被收起(它本来就该消失)。 */
|
||||||
|
.dt-range.is-antd > .dt-input.has-inline-label {
|
||||||
|
position: static; width: auto; height: auto; overflow: visible;
|
||||||
|
clip: auto; margin: 0; padding: 0; border: 0;
|
||||||
|
flex: 1 1 auto; min-width: 0;
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 🔴 v44(用户第 5 条):**单个**带时刻的 antd 控件(任务「截止时间」)。
|
||||||
|
与上面那对同一条思路 —— 挂上后把原生那几档下拉**真删掉**。
|
||||||
|
选择器用 `.dt-input.is-antd`(单点,不是范围组)——
|
||||||
|
`_datetime.php` 里 antd 挂载点仍是 `.dt-input` 自身。 */
|
||||||
|
.dt-input.is-antd > .dt-native { display: none !important; }
|
||||||
|
.dt-input.is-antd > .dt-antd { display: block; width: 100%; }
|
||||||
/* 窄屏:标签列收窄,别把输入框挤没了 */
|
/* 窄屏:标签列收窄,别把输入框挤没了 */
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.mv-row > .mv-lb,
|
.mv-row > .mv-lb,
|
||||||
|
|||||||
+70
-6
@@ -1308,8 +1308,11 @@
|
|||||||
/* 🔴 v42(用户第 8 条):单个**纯日期**控件(纪念日的「日期」)——
|
/* 🔴 v42(用户第 8 条):单个**纯日期**控件(纪念日的「日期」)——
|
||||||
`[data-antd-date]`。它挂的是不带 showTime 的单 DatePicker。 */
|
`[data-antd-date]`。它挂的是不带 showTime 的单 DatePicker。 */
|
||||||
var antdDates = document.querySelectorAll('[data-antd-date]');
|
var antdDates = document.querySelectorAll('[data-antd-date]');
|
||||||
if (antdHosts.length || antdDateRanges.length || antdDates.length) {
|
/* 🔴 v44(用户第 5 条):单个**带时刻**的控件(任务「截止时间」)——
|
||||||
bootAntd(antdHosts, antdDateRanges, antdDates);
|
`[data-dt-antd-single]`。挂的是带 showTime 的单 DatePicker。 */
|
||||||
|
var antdSingles = document.querySelectorAll('[data-dt-antd-single]');
|
||||||
|
if (antdHosts.length || antdDateRanges.length || antdDates.length || antdSingles.length) {
|
||||||
|
bootAntd(antdHosts, antdDateRanges, antdDates, antdSingles);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 🔴🔴 v42(用户 2026-10-11 第 2 条)——**关键时序修复**:
|
/* 🔴🔴 v42(用户 2026-10-11 第 2 条)——**关键时序修复**:
|
||||||
@@ -1332,7 +1335,8 @@
|
|||||||
window.fbBootAntd = function () {
|
window.fbBootAntd = function () {
|
||||||
bootAntd(document.querySelectorAll('[data-dt-antd]'),
|
bootAntd(document.querySelectorAll('[data-dt-antd]'),
|
||||||
document.querySelectorAll('[data-antd-daterange]'),
|
document.querySelectorAll('[data-antd-daterange]'),
|
||||||
document.querySelectorAll('[data-antd-date]'));
|
document.querySelectorAll('[data-antd-date]'),
|
||||||
|
document.querySelectorAll('[data-dt-antd-single]'));
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1618,11 +1622,12 @@
|
|||||||
|
|
||||||
⚠️ 这个适配层**不认识的页面不受影响**:没有 `[data-dt-antd]` 就直接返回。
|
⚠️ 这个适配层**不认识的页面不受影响**:没有 `[data-dt-antd]` 就直接返回。
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
function bootAntd(hosts, dateRanges, dates) {
|
function bootAntd(hosts, dateRanges, dates, singles) {
|
||||||
hosts = hosts || [];
|
hosts = hosts || [];
|
||||||
dateRanges = dateRanges || [];
|
dateRanges = dateRanges || [];
|
||||||
dates = dates || [];
|
dates = dates || [];
|
||||||
if (!hosts.length && !dateRanges.length && !dates.length) { return; }
|
singles = singles || [];
|
||||||
|
if (!hosts.length && !dateRanges.length && !dates.length && !singles.length) { return; }
|
||||||
var meta = document.querySelector('meta[name="fb-antd"]');
|
var meta = document.querySelector('meta[name="fb-antd"]');
|
||||||
if (!meta) { return; }
|
if (!meta) { return; }
|
||||||
var conf;
|
var conf;
|
||||||
@@ -1667,6 +1672,9 @@
|
|||||||
Array.prototype.forEach.call(dates, function (el) {
|
Array.prototype.forEach.call(dates, function (el) {
|
||||||
if (mountAntdDate(el)) { done++; }
|
if (mountAntdDate(el)) { done++; }
|
||||||
});
|
});
|
||||||
|
Array.prototype.forEach.call(singles, function (box) {
|
||||||
|
if (mountAntdSingleDT(box)) { done++; }
|
||||||
|
});
|
||||||
if (window.console && done) { console.info('AntD 日期控件已挂上 ' + done + ' 处'); }
|
if (window.console && done) { console.info('AntD 日期控件已挂上 ' + done + ' 处'); }
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
// 拉不动就留在自建下拉上 —— 渐进增强的兜底,不弹任何打扰
|
// 拉不动就留在自建下拉上 —— 渐进增强的兜底,不弹任何打扰
|
||||||
@@ -1864,6 +1872,61 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 🔴 v44(用户 2026-10-12 第 5 条):「截止时间也用我们最新的日历控件,
|
||||||
|
记得把现在这个控件删掉。」
|
||||||
|
⇒ 单个**带时刻**的 AntD DatePicker(`showTime`),不是 RangePicker ——
|
||||||
|
任务的「截止时间」只有一个时刻,套范围控件会多一个空端点。
|
||||||
|
操作对象是 `.dt-input[data-dt]`(隐藏 `*_date` + `*_hour/_minute` 是值载体),
|
||||||
|
与 `mountAntd()`(成对范围)同一套「选完写回原生字段」的路子,
|
||||||
|
所以**服务端契约一字不改**。
|
||||||
|
⚠️ 原生那几档下拉的"删除"由 CSS 负责(`.dt-input.is-antd > .dt-native{display:none}`),
|
||||||
|
这里只管加 `.is-antd` 类 + 挂控件。 */
|
||||||
|
function mountAntdSingleDT(box) {
|
||||||
|
if (!box || box.getAttribute('data-antd-done')) { return false; }
|
||||||
|
var native = box.querySelector('.dt-native');
|
||||||
|
if (!native) { return false; }
|
||||||
|
|
||||||
|
var host = document.createElement('span');
|
||||||
|
host.className = 'dt-antd';
|
||||||
|
box.insertBefore(host, box.firstChild);
|
||||||
|
|
||||||
|
function cur() {
|
||||||
|
var s = readDT(box).trim();
|
||||||
|
var m = s.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})/);
|
||||||
|
return m ? m[1] + '-' + m[2] + '-' + m[3] + ' ' + m[4] + ':' + m[5] : '';
|
||||||
|
}
|
||||||
|
var last = '';
|
||||||
|
function writeBack(str) {
|
||||||
|
var s = (str || '');
|
||||||
|
if (s === last) { return; }
|
||||||
|
last = s;
|
||||||
|
writeDT(box, s); // 写回隐藏四字段
|
||||||
|
if (box.__fbSyncParts) { box.__fbSyncParts(); }
|
||||||
|
box.classList.toggle('has-value', !!s);
|
||||||
|
}
|
||||||
|
var v0 = cur();
|
||||||
|
try {
|
||||||
|
var picker = React.createElement(antd.DatePicker, {
|
||||||
|
showTime: { format: 'HH:mm' },
|
||||||
|
format: 'YYYY-MM-DD HH:mm',
|
||||||
|
allowClear: true,
|
||||||
|
placeholder: '选择时间',
|
||||||
|
defaultValue: v0 ? window.dayjs(v0, 'YYYY-MM-DD HH:mm') : undefined,
|
||||||
|
onChange: function (val, str) { writeBack(str); }
|
||||||
|
});
|
||||||
|
var loc = (window.antd.locales && window.antd.locales.zh_CN) || undefined;
|
||||||
|
if (loc) { picker = React.createElement(antd.ConfigProvider, { locale: loc }, picker); }
|
||||||
|
ReactDOM.createRoot(host).render(picker);
|
||||||
|
} catch (e) {
|
||||||
|
if (window.console) { console.warn('antd 单点时间控件挂载失败,留在自建下拉:', e && e.message); }
|
||||||
|
host.parentNode.removeChild(host);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
box.classList.add('is-antd');
|
||||||
|
box.setAttribute('data-antd-done', '1');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function upgrade(box) {
|
function upgrade(box) {
|
||||||
if (box.getAttribute('data-semi-done')) { return; }
|
if (box.getAttribute('data-semi-done')) { return; }
|
||||||
// 范围对的成员由 upgradeRange 统一处理
|
// 范围对的成员由 upgradeRange 统一处理
|
||||||
@@ -2065,7 +2128,8 @@
|
|||||||
window.fbBootAntd = function () {
|
window.fbBootAntd = function () {
|
||||||
bootAntd(document.querySelectorAll('[data-dt-antd]'),
|
bootAntd(document.querySelectorAll('[data-dt-antd]'),
|
||||||
document.querySelectorAll('[data-antd-daterange]'),
|
document.querySelectorAll('[data-antd-daterange]'),
|
||||||
document.querySelectorAll('[data-antd-date]'));
|
document.querySelectorAll('[data-antd-date]'),
|
||||||
|
document.querySelectorAll('[data-dt-antd-single]'));
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# 移动端 API v1 · 开发中的自主决策与待确认问题清单
|
||||||
|
|
||||||
|
> 用户 2026-10-12 第 6 条:「马上开发所有接口供移动端使用,导航模块不需要,其他全部要做,
|
||||||
|
> 并准备接口文档。**开发中不要问任何问题,自行决策,做完后把问题列出来**。」
|
||||||
|
>
|
||||||
|
> 本文件就是那份「做完后列出来」的清单:**A 段是我替你做的决策**(已按最合理方案落定,
|
||||||
|
> 你要改哪条直接说),**B 段是我发现但没自作主张做的缺口**(需要你拍板要不要补),
|
||||||
|
> **C 段是给你的移动端开发建议 / 风险提示**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. 已自主决策的点(默认如此,若要改请指出)
|
||||||
|
|
||||||
|
| # | 决策点 | 我选的方案 | 为什么这样选(备选是什么) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A1 | 鉴权方式 | `Authorization: Bearer fb_xxx`,**新增第三套凭据**(登录口令 / DAV 口令 / API 令牌,三者互不代用) | 备选:复用网页 session cookie(App 难维护)、复用 DAV Basic 口令(生命周期不同、一吊销日历同步也断)。令牌可**逐设备命名 + 设有效期 + 单独吊销**,最贴合 App。 |
|
||||||
|
| A2 | 令牌明文 | **只在签发那一次返回**,库里只存 sha256(`fb_` 前缀 + 256bit) | 与 DAV 口令同一取舍。明文落库一旦泄露全暴露;不返回用户又永远看不到。 |
|
||||||
|
| A3 | 令牌传递位置 | **只认请求头**,不支持 `?token=` query 形式 | URL 会进服务器日志 / 浏览器历史 / Referer,令牌进 URL 等于半公开。 |
|
||||||
|
| A4 | 响应格式 | 统一信封 `{ok, data}` / `{ok, error, code}` + 语义化 HTTP 码 | 备选:裸数据。裸数据无法区分「成功但值是 null」与「请求失败」(HTTP 200 + 空 body 有歧义)。 |
|
||||||
|
| A5 | 路径版本 | `/api/v1/...`,**不兼容改动开 v2** | 移动端一旦发版就改不动,版本必须写进路径。 |
|
||||||
|
| A6 | 方法模拟 | 支持 `X-HTTP-Method-Override` 头与 `_method` 表单字段 | 部分老网络库 / 小程序不便发 PATCH/DELETE。 |
|
||||||
|
| A7 | 404 语义 | 资源不存在与「不属于我家」**都回 404,不区分** | 避免泄露「别人家有没有这个 id」。 |
|
||||||
|
| A8 | 写操作口径 | 全部走**与网页同一套业务类**(`Movie::create()` 等),不另写 SQL;只读账号一律 403 | 保证「App 建的记录」与「网页建的记录」完全一致,业务规则只有一处。 |
|
||||||
|
| A9 | 分页 | 列表支持 `page`/`per_page`;`paginate=1` 才返回 `{items,total,...}`,不带则裸数组 | 兼顾「简单列表直接拿数组」与「大列表要总数」(如已完成任务)。 |
|
||||||
|
| A10 | 时间格式 | 请求**两种都收**(`Y-m-d H:i` 与 ISO8601);响应**统一** `Y-m-d H:i:s`(服务器本地时区,无时区后缀) | 与网页存储口径一致;App 应按「本地时间」解析。 |
|
||||||
|
| A11 | 令牌表位置 | 新建 `api_tokens` 表(Schema v32),**不复用** `dav_credentials` | 三套凭据职责不同(见 A1)。 |
|
||||||
|
| A12 | API 入口自带结构升级 | `api.php` 与 `index.php` **各自**跑一次「按需升级」 | App 可能先于任何人打开网页就发请求(新库 / 手机先连)。否则 `api_tokens` 表还没建→谁都没法签发令牌→死锁。 |
|
||||||
|
| A13 | 跨域 CORS | **默认不开**通配 | API 给自家 App 用,浏览器跨域不是目标场景。放开会让「任意网页拿用户令牌调我们 API」成为可能。要开请在站点配置里显式加。 |
|
||||||
|
| A14 | 首页聚合接口 | 额外提供 `GET /home`(今日日程 + 进行中/逾期待办 + 未读数一次拿到) | App 首屏一次请求就能画完,省去 3~4 个并发请求。 |
|
||||||
|
| A15 | 日历本管理 | `GET /calendar/books` **可读**,但**建本/删本/共享/设只读**这些**管理动作暂不开放** | 见 B3。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. 发现但**没有**自作主张做的缺口(需你拍板)
|
||||||
|
|
||||||
|
> 这些是「网页有、API 没有」的功能。我没有默默补上,是因为它们要么涉及**大件能力**
|
||||||
|
> (文件上传),要么属于**低频管理动作**(共享 / 分类设置),要么是**整块模块**
|
||||||
|
> (家谱)。请你看完决定哪些要补。
|
||||||
|
|
||||||
|
### B1. 🔴 家谱模块(kin)**整块缺失** —— 最需要你确认的一条
|
||||||
|
- 网页有 `kin` / `kin/relate` / `kin/unrelate`(家谱树、建立/解除亲属关系)。
|
||||||
|
- 你说「导航模块不需要,**其他全部都需要**」—— 家谱**不是**导航,按字面它应当在范围内。
|
||||||
|
- **我的判断**:家谱是「一次录入、长期查看」的重展示模块,手机上画家族树交互很重,
|
||||||
|
且你在 v24 已明确「家谱不用每天看」。所以我**先没做**,但**特意标出来**——
|
||||||
|
如果移动端要能看家谱树 / 加亲属关系,请说一声,我按 `GET /kin/tree`、
|
||||||
|
`POST /kin/relate`、`DELETE /kin/relate` 补上。
|
||||||
|
|
||||||
|
### B2. 🔴 文件上传(头像 / 行程照片 / 附件 / 封面)**全部缺失**
|
||||||
|
- 网页有:`people` 头像、`trips/photo_add`、`movies/photo_add`、`resources/attach_add`、
|
||||||
|
`tasks/attach_add`、`notes/attach_add`、`notes/upload`、`calendar/attach_del`、
|
||||||
|
`upload/stage`(草稿暂存)等,**约 13 个入口**。
|
||||||
|
- **我的判断**:这是一整块「多媒体上传」能力(multipart 接收 → 落 `uploads/` 或 OSS →
|
||||||
|
返回 URL),不是零散接口。**它必须单独设计一轮**(要考虑:直传 OSS 还是过服务器、
|
||||||
|
大文件分片、进度回调、图片压缩),所以本轮先不做、单独列出来。
|
||||||
|
- **现状影响**:App 目前**能建记录、能读图片 URL**(`avatar`/`poster` 都是完整 URL,
|
||||||
|
可直接给图片组件用),但**不能从 App 上传新图**。
|
||||||
|
- 若要做,建议方案:`POST /uploads`(multipart,返回 `{id, url}` 暂存)→
|
||||||
|
各资源的 `attach_add`/`photo_add` 引用该 id。请确认是否本轮补。
|
||||||
|
|
||||||
|
### B3. 管理类低频动作未开放(**有意**放在网页端)
|
||||||
|
先说明**理由**:这些是「一年动不了几次」的设置性动作,手机上做反而易误操作;
|
||||||
|
App 的核心场景是「看 + 记」。以下**已在网页端保留**,API 暂未开放。
|
||||||
|
- **日历本**:建本 / 改名 / 删除 / 共享给谁 / 设为只读(网页 `calendar/cal_*`、`cal_share`)。
|
||||||
|
- **任务看板与分类**:建板 / 改板 / 删板 / 建分类(网页 `tasks/board_*`、`cat_*`);
|
||||||
|
任务的**共享/取消共享**(`tasks/share`、`unshare`)。
|
||||||
|
- **账本**:建本 / 改名 / 删本 / 共享(网页 `ledger/book_*`、`share`)。
|
||||||
|
- **资源**:共享 / 取消共享(`resources/share`、`unshare`)。
|
||||||
|
- **笔记**:共享 / 取消共享(`notes/share`、`unshare`)。
|
||||||
|
- **名册附属**:分组(`people/group`)、标签(`people/tags`)、排序(`people/reorder`)、
|
||||||
|
联系人导入(`people/import`)、基点定位(`people/forget`)。
|
||||||
|
- **行程**:车型 / 出行方式 / 类型等**设置字典**的维护(`trips/vehicles_*`、`modes_*`、`types_*`)。
|
||||||
|
⚠️ 但**行程节点(node)的增删改与状态流转**、**开销(expense)**、**行程聚合**已开放。
|
||||||
|
- **观影**:影院管理(`movies/cinemas_*`)—— ⚠️ 影院**读**已开放,**增删改**未开放。
|
||||||
|
|
||||||
|
> **请你逐类确认**:哪些 App 上也需要能改?我按优先级补。
|
||||||
|
> 若你希望「App 上什么都能干」,那就把上面这些一并补成一整轮,我照做。
|
||||||
|
|
||||||
|
### B4. 微信读书导入 / 豆瓣抓取 / 在线读书等**外部抓取**未开放
|
||||||
|
- 网页:`books/weread`(微信读书导入)、`movies/douban`(豆瓣抓元数据)、
|
||||||
|
`books/read`(在线读书页)、`movies/search`(豆瓣搜索)。
|
||||||
|
- **我的判断**:这些依赖**服务端去抓第三方**(可能被限流 / 要登录态),
|
||||||
|
更适合留在网页端;App 侧给「已入库的数据」读写即可。若要,请说。
|
||||||
|
|
||||||
|
### B5. 通知的**来源**写入未开放(只开放了读与已读)
|
||||||
|
- API 已开放:`GET /notifications`(未读+列表)、标记已读、删单条、清空。
|
||||||
|
- **未开放**:产生通知的内部动作(别人加你 / 移出等)—— 那些是**业务动作的副产物**,
|
||||||
|
不是 App 该直接调的接口;App 做同样的业务动作时,通知会自动产生。**这条不算缺口**,
|
||||||
|
仅说明口径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. 给移动端开发的风险提示(请你转告 App 开发者)
|
||||||
|
|
||||||
|
1. **令牌只显示一次**:签发后立刻存进 Keychain / EncryptedSharedPreferences,
|
||||||
|
丢了只能重新签发。**多设备各签一枚**(label 写设备名),丢了只吊销一枚。
|
||||||
|
2. **一定要按 `ok` 字段判成败**,不要只看 HTTP 200/201。
|
||||||
|
3. **401 的统一处理**:清本地令牌 → 回配置页。若 `GET /me` 的 `role` 是只读,
|
||||||
|
遇到 403 时**隐藏写入口**,别让用户白点。
|
||||||
|
4. **时区**:响应时间是**服务器本地时区**,解析时不要套 UTC。
|
||||||
|
5. **图片**:`avatar`/`poster`/`url` 都是**完整可访问 URL**,直接给图片组件。
|
||||||
|
(但**上传**暂不可用,见 B2。)
|
||||||
|
6. **API 根地址**:`https://<你的站点>/api/v1`(`/api/` 重写规则已在部署脚本里自动追加并核对)。
|
||||||
|
7. **连通性自检**:`GET /api/v1/ping` **不需要令牌**,App 启动先探它。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. 已交付的接口一览(首行即覆盖范围,详见 `docs/API.md`)
|
||||||
|
|
||||||
|
| 资源 | 路径 | 覆盖 |
|
||||||
|
|---|---|---|
|
||||||
|
| 我 | `/me` | 身份 / 家庭 / 模块主题色 |
|
||||||
|
| 令牌 | `/tokens[/{id}]` | 查看 / 单独吊销 / 全部吊销 |
|
||||||
|
| 首页 | `/home` | 今日日程 + 待办 + 未读数聚合 |
|
||||||
|
| 名册 | `/people[/{id}]` | 列表(搜索) / 新建 / 详情 / 改 / 删 |
|
||||||
|
| 通知 | `/notifications` | 未读+列表 / 已读 / 删单条 / 清空 |
|
||||||
|
| 日程 | `/calendar`、`/calendar/books`、`/events[/{id}]` | 月视图 / 日历本 / 增删改(含重复三档、范围删除) |
|
||||||
|
| 纪念日 | `/days[/{id}]` | 全量 / 增删改(阳历/阴历/单次) |
|
||||||
|
| 任务 | `/tasks[/{id}]` | 三视图 / 增删改 / 状态 / 搬看板 / 评论 |
|
||||||
|
| 观影 | `/movies[/{id}]`、`/cinemas[/{id}]` | 观影记录 / 评价 / 影评评论 / 影院(读) |
|
||||||
|
| 旅程 | `/trips[/{id}]`、`/trips/{id}/nodes` | 旅程 / 节点增删改与状态 / 开销 / 聚合 |
|
||||||
|
| 记账 | `/ledger[/{id}]`、`/ledger/{id}/entries` | 账本(读)/ 账目增删改 |
|
||||||
|
| 资源 | `/resources[/{id}]` | 证件账号等资料增删改查 |
|
||||||
|
| 读书 | `/books[/{id}]`、`/books/{id}/notes` | 书 / 状态 / 读书笔记 |
|
||||||
|
| 笔记 | `/notes[/{id}]` | 随手记(带标签)增删改查 |
|
||||||
|
|
||||||
|
> **不含「导航」模块**(用户 2026-10-12 明确:那是给网页外链用的)。
|
||||||
|
> **不含家谱**(见 B1)、**不含文件上传**(见 B2)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*生成时间:2026-10-12 · 对应 Schema v32 · API v1*
|
||||||
+411
@@ -0,0 +1,411 @@
|
|||||||
|
# 家书 FamilyBook · 移动端 API(v1)
|
||||||
|
|
||||||
|
> 面向前端(iOS / Android / 小程序)与任何第三方程序。
|
||||||
|
> 所有写操作与网页共用同一套业务类,权限规则完全一致 —— **App 里建的东西与网页里建的完全一样**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总览
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 根地址 | `https://<你的站点>/api/v1` |
|
||||||
|
| 鉴权 | `Authorization: Bearer <token>`(见 §2) |
|
||||||
|
| 请求体 | `Content-Type: application/json`(也接受 `application/x-www-form-urlencoded`) |
|
||||||
|
| 响应 | JSON 信封,`Content-Type: application/json; charset=utf-8` |
|
||||||
|
| 时间 | 请求里**两种格式都收**:`2026-11-03 14:30` 与 `2026-11-03T14:30:00+08:00`(ISO 8601);响应里统一是 `YYYY-MM-DD HH:MM:SS`(**服务器本地时区**) |
|
||||||
|
| 分页 | 列表接口支持 `?page=1&per_page=50`;带 `paginate=1` 时返回 `{items,total,page,per_page}`,不带则返回裸数组 |
|
||||||
|
|
||||||
|
### 1.1 响应信封
|
||||||
|
|
||||||
|
成功:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "data": <任意> }
|
||||||
|
```
|
||||||
|
|
||||||
|
失败:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "error": "人话错误信息", "code": "bad_request" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| HTTP | `code` | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| 200 | — | 成功 |
|
||||||
|
| 201 | — | 已创建(`data` 是新资源) |
|
||||||
|
| 400 | `bad_request` | 请求本身不合法 |
|
||||||
|
| 401 | `unauthorized` | 缺令牌 / 令牌无效、过期、被吊销 / 账号停用 |
|
||||||
|
| 403 | `forbidden` | 令牌有效但没权限(如只读账号写数据) |
|
||||||
|
| 404 | `not_found` | 资源不存在,或者不属于你(**刻意不区分**,不泄露"别人家有没有这个 id") |
|
||||||
|
| 405 | `method_not_allowed` | 该资源不支持这个方法 |
|
||||||
|
| 409 | `conflict` | 唯一性冲突(如同地方同名影院) |
|
||||||
|
| 422 | `unprocessable` | 字段校验不过(缺必填、格式错) |
|
||||||
|
| 503 | `not_installed` | 站点还没安装 |
|
||||||
|
|
||||||
|
> ⚠️ **一定要按 `ok` 字段判成败**,不要只看 HTTP 码 —— 用 201/200 区分"新建/更新"是约定,但业务成败以 `ok` 为准。
|
||||||
|
|
||||||
|
### 1.2 方法模拟(给不方便发 PATCH/DELETE 的客户端)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/tasks/12
|
||||||
|
{ ... }
|
||||||
|
X-HTTP-Method-Override: DELETE
|
||||||
|
```
|
||||||
|
|
||||||
|
或把 `_method` 放进请求体:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/tasks/12
|
||||||
|
{ "_method": "DELETE" }
|
||||||
|
```
|
||||||
|
|
||||||
|
两者等价于 `DELETE /api/v1/tasks/12`。
|
||||||
|
|
||||||
|
### 1.3 连通性自检(**不需要令牌**)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/ping
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{ "ok": true, "data": { "service": "familybook", "api": "v1", "time": "2026-10-12T08:20:07+08:00" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 鉴权:拿到并保管令牌
|
||||||
|
|
||||||
|
### 2.1 在网页上签发
|
||||||
|
|
||||||
|
**个人设置 → App 接口令牌**(`/index.php?r=settings/me#api`):
|
||||||
|
|
||||||
|
1. 填一个设备名(如"我的手机");
|
||||||
|
2. 点「生成新令牌」;
|
||||||
|
3. **明文只显示这一次**,立刻复制到 App 里(库里只存 sha256,取不回来);
|
||||||
|
4. 随时可以逐枚「吊销」,或「吊销全部」。
|
||||||
|
|
||||||
|
### 2.2 客户端怎么带
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer fb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
- 令牌以 `fb_` 开头,43 位 base64url(约 256 bit 熵)。
|
||||||
|
- ⚠️ **只走请求头**,不支持 `?token=` 这种 query 形式 —— URL 会进日志、浏览器历史、Referer,把令牌放进去等于半公开。
|
||||||
|
- ⚠️ 令牌与 **登录密码**、**DAV 口令**是三样东西,互不代用(吊销 DAV 口令不会影响 App;反之亦然)。
|
||||||
|
|
||||||
|
### 2.3 典型错误
|
||||||
|
|
||||||
|
| 症状 | `code` | 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 没带 `Authorization` | `unauthorized` | 检查请求头 |
|
||||||
|
| 令牌拼错 / 已被吊销 / 已过期 | `unauthorized` | 让用户重新登录(App 应清掉本地令牌并回到登录态) |
|
||||||
|
| 账号被管理员停用 | `unauthorized` | 提示用户联系家人 |
|
||||||
|
| 只读(guest)账号在写 | `forbidden` | App 侧按 `GET /me` 返回的 `role` 隐藏写入口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 接口一览
|
||||||
|
|
||||||
|
| 资源 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 我 | `/me` | 当前身份、家庭、模块主题色 |
|
||||||
|
| 令牌 | `/tokens[/{id}]` | 查看 / 吊销自己的令牌 |
|
||||||
|
| 首页 | `/home` | 聚合:今日日程 + 待办 + 未读数 |
|
||||||
|
| 名册 | `/people[/{id}]` | 家庭成员与联系人 |
|
||||||
|
| 通知 | `/notifications` | 未读、列表、标记已读、清空 |
|
||||||
|
| 日程 | `/calendar`、`/calendar/books`、`/events[/{id}]` | 月视图、日历本、日程增删改(含重复三档) |
|
||||||
|
| 纪念日 | `/days[/{id}]` | 生日、结婚纪念日… |
|
||||||
|
| 任务 | `/tasks[/{id}]` | 看板 / 状态 / 已完成三种视图;状态、搬看板、评论 |
|
||||||
|
| 观影 | `/movies[/{id}]`、`/cinemas[/{id}]` | 观影记录、评价、影评评论、影院 |
|
||||||
|
| 旅程 | `/trips[/{id}]`、`/trips/{id}/nodes` | 旅程与行程节点 |
|
||||||
|
| 记账 | `/ledger[/{id}]`、`/ledger/{id}/entries` | 账本与账目 |
|
||||||
|
| 资源 | `/resources[/{id}]` | 证件、账号等资料 |
|
||||||
|
| 读书 | `/books[/{id}]`、`/books/{id}/notes` | 书与读书笔记 |
|
||||||
|
| 笔记 | `/notes[/{id}]` | 随手记(带标签) |
|
||||||
|
|
||||||
|
> ⚠️ **不含「导航」模块** —— 那个模块本身就是给网页外链用的,App 侧用不到(用户 2026-10-12 明确)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 逐个接口
|
||||||
|
|
||||||
|
### 4.1 `GET /me`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "老杨",
|
||||||
|
"avatar": "https://.../media.php?k=avatars%2F1.jpg",
|
||||||
|
"role": "管理员",
|
||||||
|
"family": { "id": 1, "name": "杨家" },
|
||||||
|
"colors": { "calendar": "#4A7C82", "tasks": "#A84A28", "...": "..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`colors` 是模块主题色 —— App 顶栏 / 分类色块直接用它,不必自己维护一张表。
|
||||||
|
|
||||||
|
### 4.2 `GET /tokens` · `DELETE /tokens/{id}` · `DELETE /tokens`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "data": [ { "id": 3, "label": "我的手机", "created_at": "2026-10-12 08:00:00",
|
||||||
|
"last_used_at": "2026-10-12 08:20:00", "expires_at": null } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`DELETE /tokens/3` 吊销单枚;`DELETE /tokens`(不带 id)吊销全部,返回 `{"revoked": 2}`。
|
||||||
|
|
||||||
|
### 4.3 `GET /home`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"greeting": "早上好,老杨",
|
||||||
|
"date": "2026-10-12",
|
||||||
|
"weekday": "星期一",
|
||||||
|
"today_events": [ /* 与 /calendar 同形状,只含今天 */ ],
|
||||||
|
"tasks": [ /* 进行中的 + 已过期的待办 */ ],
|
||||||
|
"unread": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 名册 `/people`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/people?q=&kind=member\|contact` | 列表(`q` 搜姓名/拼音/电话) |
|
||||||
|
| POST | `/people` | 新建:`name`(必填)、`kind`、`gender`、`note`、`phone` |
|
||||||
|
| GET | `/people/{id}` | 详情 |
|
||||||
|
| PATCH | `/people/{id}` | 改:`name`/`gender`/`birthday`/`note`/`phone`(**只改给了的**) |
|
||||||
|
| DELETE | `/people/{id}` | 删除 |
|
||||||
|
|
||||||
|
### 4.5 通知 `/notifications`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/notifications` | `{unread, items}` |
|
||||||
|
| POST | `/notifications/read` | 全部标记已读,返回 `{marked:N}` |
|
||||||
|
| DELETE | `/notifications/{id}` | 删一条 |
|
||||||
|
| DELETE | `/notifications` | 清空全部 |
|
||||||
|
|
||||||
|
### 4.6 日程
|
||||||
|
|
||||||
|
**日历本**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /calendar/books
|
||||||
|
```
|
||||||
|
返回我可见的日历本(带 `perm`:`owner` / `write` / `read`、`kind`:`custom`/`movie`/`trip`/`lunar`/…)。
|
||||||
|
> ⚠️ `kind != custom` 的是**派生只读本**,不能往里写日程。
|
||||||
|
|
||||||
|
**视图**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /calendar?from=2026-11-01&to=2026-11-30
|
||||||
|
```
|
||||||
|
返回区间内所有条目(自定义日程 + 观影 + 行程节点 + 农历/节假日/纪念日),
|
||||||
|
每条带 `kind` / `ref_id` / `date` / `time` / `end_time` / `all_day` / `color` / `calname` / `href`。
|
||||||
|
> `kind=lunar` 的是农历刻度,列表里通常应过滤掉(首页就是这么做的)。
|
||||||
|
|
||||||
|
**新建**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /events
|
||||||
|
{
|
||||||
|
"calendar_id": 3,
|
||||||
|
"title": "家长会",
|
||||||
|
"starts_at": "2026-11-03T14:30:00+08:00",
|
||||||
|
"ends_at": "2026-11-03 16:00",
|
||||||
|
"all_day": false,
|
||||||
|
"location": "学校三楼会议室",
|
||||||
|
"note": "带成绩单",
|
||||||
|
"reminder_minutes": [5, 30],
|
||||||
|
"status": "confirmed",
|
||||||
|
"freq": "weekly", "rep_end": "count", "rep_count": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
返回 201 + 新日程(含生成的 `rrule`)。`reminder_minutes` 只认 `5/10/15/30/60/120/240/480/720/1440`(分钟),其余忽略。
|
||||||
|
|
||||||
|
> **重复日程**既可以给 `freq` 三件套,也可以直接给 RFC 5545 原文:
|
||||||
|
> `"rrule": "FREQ=WEEKLY;BYDAY=MO,WE;COUNT=6"`。
|
||||||
|
> `freq` 取值:`daily` / `weekly` / `monthly` / `yearly` / `weekday`(传空串=不重复)。
|
||||||
|
> `rep_end` 取值:`never`(一直)/ `count`(配 `rep_count`)/ `until`(配 `rep_until`)。
|
||||||
|
|
||||||
|
**改 / 删(重复日程的三档范围)**
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /events/{id}?scope=once&occ=2026-11-10
|
||||||
|
DELETE /events/{id}?scope=future&occ=2026-11-10
|
||||||
|
```
|
||||||
|
|
||||||
|
| `scope` | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `all`(默认) | 整条系列 |
|
||||||
|
| `once` | 只这一次(改=拆出独立一条;删=把这一天标记为跳过) |
|
||||||
|
| `future` | 这一次及以后(老系列截断,改的话再从这天起建新系列) |
|
||||||
|
|
||||||
|
> ⚠️ `once` / `future` **必须带 `occ`**(`YYYY-MM-DD`,用户点开的是哪一天)——
|
||||||
|
> 重复日程每一天都是同一条记录 id,没有 `occ` 就定位不到"这一次"。
|
||||||
|
|
||||||
|
### 4.7 纪念日 `/days`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 字段 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/days` | 全部(含名册生日派生的那几条) |
|
||||||
|
| POST | `/days` | `title`、`repeat_type`(`solar`/`lunar`/`once`)、`month`、`day`、`is_leap`、`kind`、`note`、`person_ids` |
|
||||||
|
| GET | `/days/{id}` | 详情 |
|
||||||
|
| PATCH | `/days/{id}` | 同上,只改给了的 |
|
||||||
|
| DELETE | `/days/{id}` | 删除 |
|
||||||
|
|
||||||
|
### 4.8 任务 `/tasks`
|
||||||
|
|
||||||
|
**三种视图**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /tasks # 看板视图(默认)
|
||||||
|
GET /tasks?view=status&status=doing # 跨看板按状态(todo/doing/done)
|
||||||
|
GET /tasks?view=done&category_id=2 # 已完成(可带 keyword)
|
||||||
|
```
|
||||||
|
|
||||||
|
看板视图返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"boards": [
|
||||||
|
{ "id": 1, "name": "家务", "color": "#A84A28",
|
||||||
|
"tasks": [ { "id": 9, "title": "...", "status": "todo", "due_at": "...",
|
||||||
|
"children": [ /* 子任务 */ ], "child_total": 3, "child_done": 1 } ] }
|
||||||
|
],
|
||||||
|
"categories": [ { "id": 1, "name": "装修" } ],
|
||||||
|
"status": { "todo": "待办", "doing": "进行中", "done": "已完成" },
|
||||||
|
"repeat": { "": "不循环", "day": "每天", "...": "..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**写操作**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 字段 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/tasks` | `title`(必填)、`note`、`due_at`、`repeat_rule`(`''`/`day`/`week`/`month`/`year`)、`parent_id`、`assignee_id`(**默认=你**)、`category_id`、`board_id`、`status`、`shares`(共享给谁的 id 数组) |
|
||||||
|
| PATCH | `/tasks/{id}` | 同上去改;**状态不在这里改** |
|
||||||
|
| POST | `/tasks/{id}/status` | `status`(`todo`/`doing`/`done`)、`position`(可选) |
|
||||||
|
| POST | `/tasks/{id}/move` | `board_id`、`before`(排在这条之前;0=排到末尾) |
|
||||||
|
| DELETE | `/tasks/{id}` | 删除 |
|
||||||
|
| GET / POST | `/tasks/{id}/comments` | 评论列表 / 发评论(`content`) |
|
||||||
|
|
||||||
|
> ⚠️ **状态只能走 `/status` 子动作**(与网页一致)—— 那条路还要处理"循环任务的下一次也排上"。
|
||||||
|
> 在 `PATCH` 里传 `status` 会被忽略。
|
||||||
|
|
||||||
|
### 4.9 观影
|
||||||
|
|
||||||
|
| 方法 | 路径 | 字段 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/movies?keyword=&page=&per_page=` | 列表 |
|
||||||
|
| POST | `/movies` | `title`(必填)、`starts_at`(必填)、`ends_at`、`location`、`note`、`info`(简介)、`cinema_id`、`attendees`(同行人的 id 数组) |
|
||||||
|
| GET | `/movies/{id}` | 详情(含影院、同行人、海报、我的评价) |
|
||||||
|
| PATCH | `/movies/{id}` | 只改给了的字段 |
|
||||||
|
| DELETE | `/movies/{id}` | 删除 |
|
||||||
|
| POST | `/movies/{id}/review` | 写/改我的评价:`rating`(1–10)、`content`、`feelings`(数组) |
|
||||||
|
| GET | `/movies/{id}/comments` | 影评下的评论 |
|
||||||
|
| POST | `/movies/{id}/comments` | 发评论:`content`、`parent_id`、`target_person` |
|
||||||
|
| GET | `/cinemas` | 影院列表 |
|
||||||
|
| POST | `/cinemas` | `name`(必填)、`place`、`address`、`color`(`#RRGGBB`) |
|
||||||
|
| DELETE | `/cinemas/{id}` | 删除 |
|
||||||
|
|
||||||
|
### 4.10 旅程
|
||||||
|
|
||||||
|
| 方法 | 路径 | 字段 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/trips?keyword=` | 活动列表 |
|
||||||
|
| POST | `/trips` | `title`(必填)、`note`、`summary`、`is_public` |
|
||||||
|
| GET | `/trips/{id}` | 详情 |
|
||||||
|
| PATCH | `/trips/{id}` | `title`/`note`/`summary`/`is_public` |
|
||||||
|
| DELETE | `/trips/{id}` | 删除(连带各段行程) |
|
||||||
|
| GET | `/trips/{id}/nodes` | 行程节点列表 |
|
||||||
|
| POST | `/trips/{id}/nodes` | 加一段:`title`、`starts_at`、`ends_at`、`place`、`detail`、`node_type`、`status`、`category`、`stage`(新建才认)、`members`、`travel_mode`、`vehicle_id`、`line_name`、`carrier`、`trip_no` |
|
||||||
|
| GET | `/trips/{id}/summary` | 起止汇总、已展示/未成行的段数 |
|
||||||
|
|
||||||
|
> ⚠️ **编辑行程不能改 `stage`** —— 阶段只由"新建"和详情页那几个显式动作决定
|
||||||
|
> (与网页同一条纪律,防止"改着改着实际行程变回计划")。
|
||||||
|
|
||||||
|
### 4.11 记账
|
||||||
|
|
||||||
|
| 方法 | 路径 | 字段 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/ledger` | 我的账本(含共享给我的) |
|
||||||
|
| POST | `/ledger` | `name`(留空自动命名) |
|
||||||
|
| GET | `/ledger/{id}` | 账本 + 汇总 |
|
||||||
|
| DELETE | `/ledger/{id}` | 删除 |
|
||||||
|
| GET | `/ledger/{id}/entries` | `{items, summary}` |
|
||||||
|
| POST | `/ledger/{id}/entries` | `kind`、`amount`(**元**,字符串或数字)、`title`、`note`、`person_id` |
|
||||||
|
|
||||||
|
`kind` 取值(与网页一致):
|
||||||
|
|
||||||
|
| `kind` | 名称 | 方向 |
|
||||||
|
|---|---|---|
|
||||||
|
| `deposit` / `fund` / `stock` / `insurance` / `house` / `car` / `cash` | 存款/基金/股票/保险/房子/车子/现金 | 资产 |
|
||||||
|
| `loan` / `credit` / `owe` / `other` | 贷款/信用卡/欠款/其它 | 负债 |
|
||||||
|
|
||||||
|
> `amount` 传**元**(如 `"38.50"`)—— 服务端自己转分,不要预先 ×100。
|
||||||
|
|
||||||
|
### 4.12 资源 `/resources`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 字段 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/resources?owner_id=&keyword=&sort=used` | 列表 |
|
||||||
|
| POST | `/resources` | `title`(必填)、`content`、`category` |
|
||||||
|
| GET | `/resources/{id}` | 详情 |
|
||||||
|
| PATCH | `/resources/{id}` | `title`/`content`/`category` |
|
||||||
|
| DELETE | `/resources/{id}` | 删除 |
|
||||||
|
|
||||||
|
### 4.13 读书
|
||||||
|
|
||||||
|
| 方法 | 路径 | 字段 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/books?status=&category=&owner_id=&keyword=` | 列表 |
|
||||||
|
| POST | `/books` | `title`(必填)、`author`、`category`、`status` |
|
||||||
|
| GET | `/books/{id}` | 详情(含章节) |
|
||||||
|
| PATCH | `/books/{id}` | `status`(`wish`/`reading`/`done`/`paused`)、`current_page`、`rating` |
|
||||||
|
| DELETE | `/books/{id}` | 删除 |
|
||||||
|
| GET | `/books/{id}/notes` | 读书笔记 |
|
||||||
|
| POST | `/books/{id}/notes` | `content`(必填)、`kind`(`quote`/`note`/…)、`chapter`、`loc` |
|
||||||
|
|
||||||
|
### 4.14 笔记
|
||||||
|
|
||||||
|
| 方法 | 路径 | 字段 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/notes?keyword=&tag=&page=&per_page=` | `{tags, items}`(`tags` 是标签云,供筛选栏) |
|
||||||
|
| POST | `/notes` | `title`(必填)、`body`、`tags`(字符串或数组) |
|
||||||
|
| GET | `/notes/{id}` | 详情 |
|
||||||
|
| PATCH | `/notes/{id}` | `title`/`body`/`tags` |
|
||||||
|
| DELETE | `/notes/{id}` | 删除 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 给 App 的开发建议
|
||||||
|
|
||||||
|
1. **启动顺序**:`GET /ping`(探连通)→ 本地有令牌就 `GET /me`(探令牌是否还有效)→ 无效则回登录/配置页。
|
||||||
|
2. **令牌存储**:iOS 存 Keychain,Android 存 EncryptedSharedPreferences;**不要**放进 `UserDefaults` / 明文文件。
|
||||||
|
3. **多设备**:每台设备一枚令牌(`label` 写设备名),这样"手机丢了"只吊销一枚,不影响平板。
|
||||||
|
4. **错误码处理**:
|
||||||
|
- 遇到 `401` → 清本地令牌、回到配置页;
|
||||||
|
- 遇到 `403` 且 `GET /me` 的 `role` 是只读 → 隐藏写入口,别让用户白点。
|
||||||
|
5. **时间**:响应里的时间戳是**服务器时区**的 `YYYY-MM-DD HH:MM:SS`(不是 UTC、没有时区后缀)。
|
||||||
|
若设备时区与服务器不同,请按"这是本地时间"解析(`DateTimeFormatter` 不套 UTC)。
|
||||||
|
6. **列表刷新**:`/calendar` 按区间取(月视图一次一个月);其余列表支持 `page`/`per_page`。
|
||||||
|
7. **图片**:接口返回的 `avatar` / `poster` 等都是**完整可访问 URL**,直接给图片组件用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 版本与兼容
|
||||||
|
|
||||||
|
- 版本号写在路径里(`/api/v1`)。**不兼容的改动会开 v2**,v1 在可见期内保持兼容。
|
||||||
|
- 新增字段不算破坏性改动(客户端应忽略不认识的键)。
|
||||||
|
- 本文件是接口的**唯一权威来源**;实现见 `app/Api.php`(内核)、`app/ApiHandler.php`(资源路由)、`api.php`(入口)。
|
||||||
@@ -11,6 +11,7 @@ declare(strict_types=1);
|
|||||||
require __DIR__ . '/app/bootstrap.php';
|
require __DIR__ . '/app/bootstrap.php';
|
||||||
|
|
||||||
use App\Account;
|
use App\Account;
|
||||||
|
use App\Api;
|
||||||
use App\Attachment;
|
use App\Attachment;
|
||||||
use App\Auth;
|
use App\Auth;
|
||||||
use App\Birthday;
|
use App\Birthday;
|
||||||
@@ -121,6 +122,22 @@ $r = isset($_GET['r']) ? trim((string)$_GET['r'], '/') : 'home';
|
|||||||
$r = $r === '' ? 'home' : $r;
|
$r = $r === '' ? 'home' : $r;
|
||||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
|
||||||
|
/* 🔴 v44(用户 2026-10-12 第 6 条):移动端 API 也会走到这里 ——
|
||||||
|
`?r=api/v1/tasks` 这种形式(个别网络库 / 代理会把 PATH_INFO 吃掉,
|
||||||
|
只留 query)。⇒ 必须在**登录门槛之前**交给 API 内核,否则未带 session
|
||||||
|
的 App 请求会被 302 到登录页(拿到一坨 HTML,JSON 解析失败)。
|
||||||
|
⚠️ 这里**不 require api.php**:那个文件会再 require 一次 bootstrap.php,
|
||||||
|
导致 `Constant FB_ROOT already defined` 警告(实测过)。
|
||||||
|
bootstrap 在 index.php 开头已经加载,直接调内核即可。
|
||||||
|
⚠️ 只认 `api/` 前缀,别的一律照旧走网页路由。 */
|
||||||
|
if (strpos($r, 'api/') === 0) {
|
||||||
|
// `$r` 形如 `api/v1/tasks` ⇒ 去掉 `api/` 四字、补回 `/api/` 前缀,
|
||||||
|
// 得到内核认识的标准路径 `/api/v1/tasks`(与 .htaccess/router.php 同一口径)。
|
||||||
|
$_SERVER['REQUEST_URI'] = '/api/' . substr($r, 4);
|
||||||
|
Api::dispatch();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$publicRoutes = ['auth/login'];
|
$publicRoutes = ['auth/login'];
|
||||||
if (!Auth::check() && !in_array($r, $publicRoutes, true)) {
|
if (!Auth::check() && !in_array($r, $publicRoutes, true)) {
|
||||||
redirect('auth/login');
|
redirect('auth/login');
|
||||||
@@ -286,10 +303,17 @@ function render_me_settings(array $person, array $errors = []): void
|
|||||||
// 通讯录同步范围要和名册页**完全一致**(用户 2026-10-06 第 22 批确认):
|
// 通讯录同步范围要和名册页**完全一致**(用户 2026-10-06 第 22 批确认):
|
||||||
// 名册页看到谁就同步谁。这里把人数带过去,用户当场就能对上。
|
// 名册页看到谁就同步谁。这里把人数带过去,用户当场就能对上。
|
||||||
'davRosterN' => PersonRoster::countFor($familyId, $me),
|
'davRosterN' => PersonRoster::countFor($familyId, $me),
|
||||||
|
/* 🔴 v44(用户 2026-10-12 第 6 条):移动端 API 令牌。
|
||||||
|
⚠️ 明文同样只在**一次性会话槽位**里取一次(`api_plain`),
|
||||||
|
取完后面 unset —— 与 DAV 口令同一条纪律。 */
|
||||||
|
'apiTokens' => \App\ApiToken::listFor($me),
|
||||||
|
'apiPlain' => (string)($_SESSION['api_plain'] ?? ''),
|
||||||
|
'apiLabel' => (string)($_SESSION['api_plain_label'] ?? ''),
|
||||||
|
'apiBase' => rtrim((string)fb_config('base_url', ''), '/'),
|
||||||
], 'settings');
|
], 'settings');
|
||||||
// ⚠️ 取完即清:明文口令只该被看到一次(刷新页面就没了)。
|
// ⚠️ 取完即清:明文令牌只该被看到一次(刷新页面就没了)。
|
||||||
// 放在 View::render **之后** —— 早了就拿不到值了。
|
// 放在 View::render **之后** —— 早了就拿不到值了。
|
||||||
unset($_SESSION['dav_plain']);
|
unset($_SESSION['dav_plain'], $_SESSION['api_plain'], $_SESSION['api_plain_label']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4633,6 +4657,29 @@ $want = array_keys($want);
|
|||||||
redirect('settings/me');
|
redirect('settings/me');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
/* 🔴 v44(用户 2026-10-12 第 6 条):移动端 API 令牌的**签发 / 吊销**。
|
||||||
|
与 DAV 那两条同一个形态(明文进一次性会话槽位、只显示一次)。 */
|
||||||
|
case 'settings/api_token':
|
||||||
|
case 'settings/api_revoke':
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
Auth::requireWrite();
|
||||||
|
csrf_check();
|
||||||
|
if ($r === 'settings/api_token') {
|
||||||
|
$label = trim((string)($_POST['label'] ?? ''));
|
||||||
|
$_SESSION['api_plain'] = \App\ApiToken::issue($me, $label);
|
||||||
|
$_SESSION['api_plain_label'] = $label;
|
||||||
|
flash('ok', 'API 令牌已生成(只显示这一次,请立刻复制到 App 里)。');
|
||||||
|
} elseif ((int)($_POST['id'] ?? 0) > 0) {
|
||||||
|
\App\ApiToken::revoke($me, (int)$_POST['id']);
|
||||||
|
flash('ok', '这枚 API 令牌已吊销。');
|
||||||
|
} else {
|
||||||
|
\App\ApiToken::revokeAll($me);
|
||||||
|
unset($_SESSION['api_plain'], $_SESSION['api_plain_label']);
|
||||||
|
flash('ok', '全部 API 令牌已吊销,所有 App 都需要重新登录。');
|
||||||
|
}
|
||||||
|
redirect('settings/me', [], 'api');
|
||||||
|
break;
|
||||||
|
|
||||||
case 'calendar/calendars':
|
case 'calendar/calendars':
|
||||||
$familyId = (int)Auth::familyId();
|
$familyId = (int)Auth::familyId();
|
||||||
$me = (int)Auth::personId();
|
$me = (int)Auth::personId();
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ if (preg_match('#^/carddav(/.*)?$#', $path)) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔴 v44(用户 2026-10-12 第 6 条):移动端 API —— `/api/v1/...` 交给 api.php。
|
||||||
|
// ⚠️ 必须排在下面"真实文件"判断**之前**:`/api/v1/tasks` 不是真文件,
|
||||||
|
// 落到 index.php 会被当成未知路由(302 到登录页),App 端只会看到一坨 HTML。
|
||||||
|
// 线上等价物在 `.htaccess` 里(同样一条 RewriteRule)。
|
||||||
|
if (preg_match('#^/api(/|$)#', $path)) {
|
||||||
|
require __DIR__ . '/api.php';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// 导航**展示页**(v24 第 24 批 用户第 4 条):`/nav/?<短语>` —— 全站唯一免登录页面。
|
// 导航**展示页**(v24 第 24 批 用户第 4 条):`/nav/?<短语>` —— 全站唯一免登录页面。
|
||||||
// ⚠️ 三条顺序**不能换**:
|
// ⚠️ 三条顺序**不能换**:
|
||||||
// ① `/nav/suggest.php` 是联想接口(免登录,但凭短语门禁放行);
|
// ① `/nav/suggest.php` 是联想接口(免登录,但凭短语门禁放行);
|
||||||
|
|||||||
+48
-3
@@ -38,6 +38,13 @@
|
|||||||
* $dtRange 'start' | 'end' —— 这对控件属于同一个起止范围(见下)
|
* $dtRange 'start' | 'end' —— 这对控件属于同一个起止范围(见下)
|
||||||
* $dtDateType 'select'(默认,年/月/日下拉)| 'native'(旧的原生 date 框)
|
* $dtDateType 'select'(默认,年/月/日下拉)| 'native'(旧的原生 date 框)
|
||||||
* $dtYearFrom / $dtYearTo 年份下拉的范围(默认:今年 -120 ~ 今年 +10;生日建议 1900)
|
* $dtYearFrom / $dtYearTo 年份下拉的范围(默认:今年 -120 ~ 今年 +10;生日建议 1900)
|
||||||
|
* $dtLabel 🔴 v44:把标签**内联到控件同一行**(写成该字符串 + 「:」)。
|
||||||
|
* · 传空串 = 不要标签(起止范围里的第二个端点就该留空);
|
||||||
|
* · 不传(null)= 沿用旧行为(标签由宿主模板自己放,通常在上方一行)。
|
||||||
|
* 为什么要它:用户 2026-10-12 第 2 条要求「日期范围:」与最新控件
|
||||||
|
* **在同一行**。宿主的 `.mv-grid` 行结构把标签与控件分在两行,
|
||||||
|
* 与其在 CSS 里跟行结构较劲,不如让控件**自带**标签 —— 位置确定。
|
||||||
|
* $dtLabelStar 标签后是否跟一颗必填星号(配 $dtLabel 用)
|
||||||
* $dtPlugin 🔴 v38 插件开关:'antd' = 由 app.js 把这两个端点升级成
|
* $dtPlugin 🔴 v38 插件开关:'antd' = 由 app.js 把这两个端点升级成
|
||||||
* **Ant Design 的 RangePicker**(用户 2026-10-10 点名要的插件)。
|
* **Ant Design 的 RangePicker**(用户 2026-10-10 点名要的插件)。
|
||||||
* ⚠️ 只用在**起止成对**的两个端点上(见下),且**只在观影模块试点**;
|
* ⚠️ 只用在**起止成对**的两个端点上(见下),且**只在观影模块试点**;
|
||||||
@@ -62,12 +69,29 @@ $dtYearTo = (int)($dtYearTo ?? ((int)date('Y') + 10));
|
|||||||
留空 = 普通单点控件。 */
|
留空 = 普通单点控件。 */
|
||||||
$dtRange = (string)($dtRange ?? '');
|
$dtRange = (string)($dtRange ?? '');
|
||||||
|
|
||||||
|
/* 🔴 v44:内联标签(见文件头)。`null`(不传)与 `''`(显式不要)语义不同,
|
||||||
|
所以这里不能用 `??` —— 得先看有没有这个键。 */
|
||||||
|
$dtHasLabel = array_key_exists('dtLabel', get_defined_vars());
|
||||||
|
$dtLabel = $dtHasLabel ? (string)$dtLabel : '';
|
||||||
|
$dtLabelStar = (bool)($dtLabelStar ?? false);
|
||||||
|
|
||||||
/* 🔴 v38:插件开关(见文件头)。只在**起止成对**的场景下有意义 ——
|
/* 🔴 v38:插件开关(见文件头)。只在**起止成对**的场景下有意义 ——
|
||||||
RangePicker 天生就是"一个控件管两端",单点控件挂它反而多一个"确定"。
|
RangePicker 天生就是"一个控件管两端",单点控件挂它反而多一个"确定"。
|
||||||
⚠️ 即便声明了插件,**原生控件一个都不删**:JS 没跑 / 资源拉不动时,
|
⚠️ 即便声明了插件,**原生控件一个都不删**:JS 没跑 / 资源拉不动时,
|
||||||
用户看到的仍是那套自建下拉(渐进增强的老规矩)。 */
|
用户看到的仍是那套自建下拉(渐进增强的老规矩)。 */
|
||||||
$dtPlugin = (string)($dtPlugin ?? '');
|
$dtPlugin = (string)($dtPlugin ?? '');
|
||||||
$dtUseAntd = ($dtPlugin === 'antd' && $dtRange !== '');
|
$dtUseAntd = ($dtPlugin === 'antd' && $dtRange !== '');
|
||||||
|
/* 🔴 v44:单个**带时刻**的 AntD 控件(任务的「截止时间」)。
|
||||||
|
`$dtPlugin = 'antd-single'` ⇒ app.js 把这一档升级成 **AntD 的 DatePicker(showTime)**
|
||||||
|
(不是 RangePicker —— 截止时间只有一个时刻,用范围控件会多一个空端点)。
|
||||||
|
用途(用户 2026-10-12 第 5 条):「截止时间也用我们最新的日历控件,
|
||||||
|
记得把现在这个控件删掉。」 */
|
||||||
|
$dtUseAntdSingle = ($dtPlugin === 'antd-single' && $dtRange === '');
|
||||||
|
/* 🔴 v44:容器类与 id —— 让"日程"这对能用与"观影"完全一致的结构
|
||||||
|
(`.dt-range > .dt-input` 直接子),从而命中那条宽泛的收起规则(见 CSS)。
|
||||||
|
不传 `$dtClass` 时维持旧行为(`.dt-input` 直接挂在 `.dt-range` / `.field` 下)。 */
|
||||||
|
$dtClass = (string)($dtClass ?? '');
|
||||||
|
$dtFieldId = (string)($dtFieldId ?? '');
|
||||||
|
|
||||||
/* ⚠️⚠️ 临时变量**一律带 dt 前缀**,一个都不能漏:这里原来写成
|
/* ⚠️⚠️ 临时变量**一律带 dt 前缀**,一个都不能漏:这里原来写成
|
||||||
`$t = strtotime(...)`,把宿主模板里表示「这条任务 / 这条记录」的 `$t`
|
`$t = strtotime(...)`,把宿主模板里表示「这条任务 / 这条记录」的 `$t`
|
||||||
@@ -88,10 +112,30 @@ $dtSec = $dtTs ? (int)date('s', $dtTs) : 0;
|
|||||||
.dt-semi 这个占位 span 保留 —— 老代码路径与 CSS 还在引用它,删了会牵连。 */
|
.dt-semi 这个占位 span 保留 —— 老代码路径与 CSS 还在引用它,删了会牵连。 */
|
||||||
$dtCfg = ['name' => $dtName, 'seconds' => $dtSeconds, 'required' => $dtRequired,
|
$dtCfg = ['name' => $dtName, 'seconds' => $dtSeconds, 'required' => $dtRequired,
|
||||||
'id' => $dtId, 'range' => $dtRange, 'parts' => $dtUseParts || null,
|
'id' => $dtId, 'range' => $dtRange, 'parts' => $dtUseParts || null,
|
||||||
'plugin' => $dtUseAntd ? 'antd' : null];
|
'plugin' => $dtUseAntd ? 'antd' : ($dtUseAntdSingle ? 'antd-single' : null)];
|
||||||
?>
|
?>
|
||||||
<div class="dt-input<?= $dtRange !== '' ? ' dt-part' : '' ?><?= $dtUseParts ? ' dt-parts' : '' ?><?= $dtRange === 'start' ? ' is-pending' : '' ?>"
|
<?php /* ⚠️ v44:内联标签是**兄弟节点**、排在 `.dt-input` **之前** ——
|
||||||
|
绝不放进 `.dt-input` 里面。
|
||||||
|
血泪(真机几何量出来的):antd 挂上后那条"收起原生端点"的规则会把
|
||||||
|
整个 `.dt-input` 绝对定位 + `clip` 掉(因为那里装着要藏起来的原生下拉);
|
||||||
|
标签若在它内部 ⇒ **跟着一起被裁没**,用户只看到一个没有标签的光控件
|
||||||
|
(实测:标签盒还在 x=316 但 `elementFromPoint` 命中的是 `.ant-picker`)。
|
||||||
|
放成兄弟之后,收起规则只影响端点本身,标签安然无恙、
|
||||||
|
并且与 `_datetime.php` 外面的 `.mv-row` / `.dt-range` flex 容器天然同行。 */ ?>
|
||||||
|
<?php if ($dtHasLabel && $dtLabel !== ''): ?>
|
||||||
|
<?php /* ⚠️ 标签上**不带** `$dtClass`:那个类名里含 `field`/`ev-dt`,
|
||||||
|
而 `.dt-range.dt-cols > .field { flex: 1 1 250px }`(0,3,0)会盖过
|
||||||
|
`.dt-range > .dt-inline-lab { flex: 0 0 62px }`(0,2,0)——
|
||||||
|
实测标签被撑成 261px,把最新控件挤到右边去("不在同一行左侧"的观感)。 */ ?>
|
||||||
|
<span class="dt-inline-lab mv-lb"><?= e($dtLabel) ?><?= $dtLabelStar ? '<i>*</i>' : '' ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php /* ⚠️ v44:不再套外层 `.field` —— 那会把 `.dt-input` 变成**孙子**,
|
||||||
|
于是 `> .dt-input` 那条宽泛的收起规则失效(日程"旧控件还在"就是这么来的)。
|
||||||
|
`$dtClass` 是**额外类名**,`.dt-input` 永远是本体;`$dtFieldId` 直接落在它身上。 */ ?>
|
||||||
|
<div class="dt-input<?= $dtClass !== '' ? ' ' . e($dtClass) : '' ?><?= $dtRange !== '' ? ' dt-part' : '' ?><?= $dtUseParts ? ' dt-parts' : '' ?><?= $dtRange === 'start' ? ' is-pending' : '' ?>"
|
||||||
|
<?= $dtFieldId !== '' ? 'id="' . e($dtFieldId) . '"' : '' ?>
|
||||||
<?= $dtUseAntd ? 'data-dt-antd="1"' : '' ?>
|
<?= $dtUseAntd ? 'data-dt-antd="1"' : '' ?>
|
||||||
|
<?= $dtUseAntdSingle ? 'data-dt-antd-single="1"' : '' ?>
|
||||||
data-dt="<?= e(json_encode($dtCfg, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>"
|
data-dt="<?= e(json_encode($dtCfg, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>"
|
||||||
<?= $dtRequired ? 'data-dt-required="1"' : '' ?>>
|
<?= $dtRequired ? 'data-dt-required="1"' : '' ?>>
|
||||||
<span class="dt-semi" aria-hidden="true"></span>
|
<span class="dt-semi" aria-hidden="true"></span>
|
||||||
@@ -160,7 +204,8 @@ $dtCfg = ['name' => $dtName, 'seconds' => $dtSeconds, 'required' => $dtRequired,
|
|||||||
</div>
|
</div>
|
||||||
<?php /* 用完即清,避免残留变量影响下一次 include */ unset(
|
<?php /* 用完即清,避免残留变量影响下一次 include */ unset(
|
||||||
$dtName, $dtValue, $dtRequired, $dtId, $dtSeconds, $dtCfg, $dtRange,
|
$dtName, $dtValue, $dtRequired, $dtId, $dtSeconds, $dtCfg, $dtRange,
|
||||||
$dtDateType, $dtUseParts, $dtYearFrom, $dtYearTo, $dtPlugin, $dtUseAntd,
|
$dtDateType, $dtUseParts, $dtYearFrom, $dtYearTo, $dtPlugin, $dtUseAntd, $dtUseAntdSingle,
|
||||||
|
$dtHasLabel, $dtLabel, $dtLabelStar, $dtClass, $dtFieldId,
|
||||||
$dtTs, $dtTsTmp, $dtDate, $dtY, $dtMo, $dtD, $dtDays,
|
$dtTs, $dtTsTmp, $dtDate, $dtY, $dtMo, $dtD, $dtDays,
|
||||||
$dtHour, $dtMin, $dtSec,
|
$dtHour, $dtMin, $dtSec,
|
||||||
$dtH, $dtM, $dtS, $hv, $mv, $sv, $dtYy, $dtMm, $dtDd
|
$dtH, $dtM, $dtS, $hv, $mv, $sv, $dtYy, $dtMm, $dtDd
|
||||||
|
|||||||
@@ -32,9 +32,15 @@ $evMv = !empty($evMvStyle);
|
|||||||
抄两遍必然有一天两边不同步(v37 那种"改了 A 忘了 B"的坑)。
|
抄两遍必然有一天两边不同步(v37 那种"改了 A 忘了 B"的坑)。
|
||||||
⚠️ 「必填星号」作为**参数**传(不要用 `use` 捕获 —— 闭包定义时那个变量还没赋值,
|
⚠️ 「必填星号」作为**参数**传(不要用 `use` 捕获 —— 闭包定义时那个变量还没赋值,
|
||||||
捕获到的是 null,星号永远不出现)。 */
|
捕获到的是 null,星号永远不出现)。 */
|
||||||
$evRowOpen = static function (string $full = '') use ($evMv): string {
|
$evRowOpen = static function (string $full = '', bool $hidden = false) use ($evMv): string {
|
||||||
return '<div class="' . ($evMv ? 'mv-row' : 'field') . ($full !== '' ? ' ' . $full : '') . '">';
|
return '<div class="' . ($evMv ? 'mv-row' : 'field') . ($full !== '' ? ' ' . $full : '') . '"'
|
||||||
|
. ($hidden ? ' hidden' : '') . '>';
|
||||||
};
|
};
|
||||||
|
/* 重复规则**先算好**:下面"结束条件 / 重复次数 / 重复到"三行要不要默认藏起来,
|
||||||
|
取决于它(见 v44 第 2 条)。原来 `$rp` 是在「重复」那一行才 parse 的,
|
||||||
|
那三行排在它后面 ⇒ 拿不到。提到最前面,只算一次。 */
|
||||||
|
$evRp = \App\Repeat::parse((string)($evF['rrule'] ?? ''));
|
||||||
|
$evRepeating = (string)($evRp['freq'] ?? '') !== '';
|
||||||
$evLab = static function (string $text, string $for = '', bool $star = false) use ($evMv): string {
|
$evLab = static function (string $text, string $for = '', bool $star = false) use ($evMv): string {
|
||||||
if ($evMv) {
|
if ($evMv) {
|
||||||
// 观影式:标签是 <span class="mv-lb">,冒号由 CSS ::after 给
|
// 观影式:标签是 <span class="mv-lb">,冒号由 CSS ::after 给
|
||||||
@@ -107,28 +113,50 @@ $evLab = static function (string $text, string $for = '', bool $star = false) us
|
|||||||
⇒ `$dtPlugin='antd'`(与观影/行程同一套:面板有可点的时/分列、中文「确定」),
|
⇒ `$dtPlugin='antd'`(与观影/行程同一套:面板有可点的时/分列、中文「确定」),
|
||||||
标签改「开始 / 结束」(并去掉那颗必填星号的位置歧义 —— 只标在开始上)。
|
标签改「开始 / 结束」(并去掉那颗必填星号的位置歧义 —— 只标在开始上)。
|
||||||
⚠️ 契约一字未改:隐藏字段仍是 `*_date/_hour/_minute`;antd 拉不动时
|
⚠️ 契约一字未改:隐藏字段仍是 `*_date/_hour/_minute`;antd 拉不动时
|
||||||
自动退回自建下拉(渐进增强)。 */ ?>
|
自动退回自建下拉(渐进增强)。
|
||||||
<div class="dt-range<?= $evMv ? ' mv-row' : '' ?>">
|
|
||||||
<?php /* ⚠️ 标签只有**一个**,挂在范围容器上(不是各挂各的):
|
🔴🔴 v44(用户 2026-10-12 第 2 条):「开始/结束,改为:日期范围:。
|
||||||
|
之前的日期选择控件还有,需要删除。」
|
||||||
|
—— 用户要三种东西:① 标签「日期范围」;② 标签与**最新控件同行**;
|
||||||
|
③ 原生那套(年/月/日 + 时/分 五个下拉)**真的消失**。
|
||||||
|
|
||||||
|
🔴🔴 **"还在"的真根因(结构性,不是样式没写)**:
|
||||||
|
观影那对是 `‹div.mv-row.dt-range› ‹div.dt-input›`(**直接子**),
|
||||||
|
而日程这对是 `‹div.dt-range› ‹div.field.ev-dt› ‹div.dt-input›`
|
||||||
|
—— `.dt-input` 是**孙子**。
|
||||||
|
于是那条最常见的收起规则 `.dt-range.is-antd > .dt-input`(**直接子**选择器)
|
||||||
|
**在日程上根本不命中** ⇒ antd 挂上了、原生五个下拉照样显示
|
||||||
|
(用户看到两个控件并排,"之前的日期选择控件还有")。
|
||||||
|
⚠️ 与 v43 纪念日那次是**同一个家族**:结构一变,直接子选择器就静默失效。
|
||||||
|
⇒ 两手都做:① 把日程的结构改成与观影**一致**(`.dt-input` 直接子);
|
||||||
|
② CSS 里把 `>` 放宽成后代选择器(0,2,0 → 仍高于基类),
|
||||||
|
将来结构再变也不会又冒出第二个控件。
|
||||||
|
|
||||||
|
⇒ 形态:由 `_datetime.php` 的 `$dtLabel` 把「日期范围:」
|
||||||
|
**内联在控件那一行**(不靠宿主的行结构),一个 `.mv-lb` 挂在
|
||||||
|
`.dt-input` 里 → 三样天然同行,宽度不够时整体换行。 */ ?>
|
||||||
|
|
||||||
|
<div class="dt-range<?= $evMv ? ' mv-row is-tagged' : '' ?>">
|
||||||
|
<?php /* ⚠️ 标签只有**一个**,挂在**第一个端点**上(不是各挂各的):
|
||||||
升级成一个范围控件之后,"开始"与"结束"就是同一个控件的两个端点,
|
升级成一个范围控件之后,"开始"与"结束"就是同一个控件的两个端点,
|
||||||
两个标签会变成"看着像两个字段、其实是一个"的错觉。 */ ?>
|
两个标签会变成"看着像两个字段、其实是一个"的错觉。
|
||||||
<?php if ($evMv): ?>
|
⚠️ 观影式:标签由 `_datetime.php` 内联(`$dtLabel`)—— 要的是与控件同行;
|
||||||
<span class="mv-lb dt-range-label">开始 / 结束<i>*</i></span>
|
两列式(编辑整页):标签仍是 `.dt-range-label`,在两端之上。 */ ?>
|
||||||
<?php else: ?>
|
<?php if (!$evMv): ?>
|
||||||
<span class="dt-range-label">开始 / 结束 *</span>
|
<span class="dt-range-label">日期范围 *</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="field ev-dt" data-dt>
|
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
|
||||||
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
|
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true;
|
||||||
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true;
|
$dtRange = 'start'; $dtPlugin = 'antd'; $dtClass = 'field ev-dt'; $dtFieldId = '';
|
||||||
$dtRange = 'start'; $dtPlugin = 'antd';
|
if ($evMv) { $dtLabel = '日期范围'; $dtLabelStar = true; }
|
||||||
require __DIR__ . '/../_datetime.php'; ?>
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
</div>
|
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
|
||||||
<div class="field ev-dt" data-dt id="ev-end-field">
|
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false;
|
||||||
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
|
$dtRange = 'end'; $dtPlugin = 'antd';
|
||||||
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false;
|
$dtClass = 'field ev-dt'; $dtFieldId = 'ev-end-field';
|
||||||
$dtRange = 'end'; $dtPlugin = 'antd';
|
/* 结束端点不重复标签(同一个范围控件,标签已经挂在开始那一端上) */
|
||||||
require __DIR__ . '/../_datetime.php'; ?>
|
$dtLabel = ''; $dtLabelStar = false;
|
||||||
</div>
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
</div><?php /* /dt-range */ ?>
|
</div><?php /* /dt-range */ ?>
|
||||||
<?php /* 🔴 v43(用户第 5 条):他要求把「结束留空即无明确结束」的那条提示去掉
|
<?php /* 🔴 v43(用户第 5 条):他要求把「结束留空即无明确结束」的那条提示去掉
|
||||||
⇒ 那句 `.field-hint` 整块删除(不再输出)。 */ ?>
|
⇒ 那句 `.field-hint` 整块删除(不再输出)。 */ ?>
|
||||||
@@ -149,16 +177,23 @@ $evLab = static function (string $text, string $for = '', bool $star = false) us
|
|||||||
<?php echo $evRowOpen(); ?>
|
<?php echo $evRowOpen(); ?>
|
||||||
<?= $evLab('重复', 'ev-freq') ?>
|
<?= $evLab('重复', 'ev-freq') ?>
|
||||||
<select class="select" id="ev-freq" name="freq">
|
<select class="select" id="ev-freq" name="freq">
|
||||||
<?php $rp = \App\Repeat::parse((string)($evF['rrule'] ?? '')); ?>
|
|
||||||
<?php foreach (\App\Repeat::FREQS as $k => $w): ?>
|
<?php foreach (\App\Repeat::FREQS as $k => $w): ?>
|
||||||
<option value="<?= e($k) ?>"<?= (string)$rp['freq'] === (string)$k ? ' selected' : '' ?>><?= e($w) ?></option>
|
<option value="<?= e($k) ?>"<?= (string)$evRp['freq'] === (string)$k ? ' selected' : '' ?>><?= e($w) ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php echo $evRowOpen(); ?>
|
<?php /* 🔴 v44(用户 2026-10-12 第 2 条):「重复这里,只有选择重复的时候,
|
||||||
|
下面的结束条件和重复次数、重复到,这几个选项才出现,否则就隐藏。」
|
||||||
|
⇒ 三行**服务端**就按"是否重复"给出 `hidden`(不是只靠 JS):
|
||||||
|
· 回填编辑(本来就有 rrule)时它们该出现;
|
||||||
|
· 新建(默认"不重复")时它们从一开始就是隐藏的 —— 页面上不会闪一下才收起来。
|
||||||
|
⚠️ 行**本身**也藏:原来只有行**里面**那个 box 带 hidden,
|
||||||
|
于是标签还孤零零留在表单里(用户看到的"还在")。
|
||||||
|
⚠️ 用户同条还要「重复次数(含第一次)」那个括号去掉 ⇒ 标签改「重复次数」。 */ ?>
|
||||||
|
<?php echo $evRowOpen('', !$evRepeating); ?>
|
||||||
<?= $evLab('结束条件', 'ev-rep-end') ?>
|
<?= $evLab('结束条件', 'ev-rep-end') ?>
|
||||||
<div id="ev-rep-end-box" hidden>
|
<div id="ev-rep-end-box"<?= $evRepeating ? '' : ' hidden' ?>>
|
||||||
<select class="select" id="ev-rep-end" name="rep_end">
|
<select class="select" id="ev-rep-end" name="rep_end">
|
||||||
<?php foreach (\App\Repeat::ENDS as $k => $w): ?>
|
<?php foreach (\App\Repeat::ENDS as $k => $w): ?>
|
||||||
<option value="<?= e($k) ?>"><?= e($w) ?></option>
|
<option value="<?= e($k) ?>"><?= e($w) ?></option>
|
||||||
@@ -167,19 +202,19 @@ $evLab = static function (string $text, string $for = '', bool $star = false) us
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php echo $evRowOpen(); ?>
|
<?php echo $evRowOpen('', !$evRepeating || (int)$evRp['count'] <= 0); ?>
|
||||||
<?= $evLab('重复次数(含第一次)', 'ev-rep-count') ?>
|
<?= $evLab('重复次数', 'ev-rep-count') ?>
|
||||||
<div id="ev-rep-count-box" hidden>
|
<div id="ev-rep-count-box"<?= ($evRepeating && (int)$evRp['count'] > 0) ? '' : ' hidden' ?>>
|
||||||
<input class="input" type="number" min="1" max="2000" id="ev-rep-count" name="rep_count"
|
<input class="input" type="number" min="1" max="2000" id="ev-rep-count" name="rep_count"
|
||||||
value="<?= (int)($rp['count'] ?: 10) ?>">
|
value="<?= (int)($evRp['count'] ?: 10) ?>">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php echo $evRowOpen(); ?>
|
<?php echo $evRowOpen('', !$evRepeating || (string)$evRp['until'] === ''); ?>
|
||||||
<?= $evLab('重复到', 'ev-rep-until') ?>
|
<?= $evLab('重复到', 'ev-rep-until') ?>
|
||||||
<div id="ev-rep-until-box" hidden>
|
<div id="ev-rep-until-box"<?= ($evRepeating && (string)$evRp['until'] !== '') ? '' : ' hidden' ?>>
|
||||||
<input class="input" type="date" id="ev-rep-until" name="rep_until"
|
<input class="input" type="date" id="ev-rep-until" name="rep_until"
|
||||||
value="<?= e((string)($rp['until'] ?? '')) ?>">
|
value="<?= e((string)($evRp['until'] ?? '')) ?>">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,14 +228,12 @@ $evLab = static function (string $text, string $for = '', bool $star = false) us
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php echo $evRowOpen(); ?>
|
<?php /* 🔴 v44(用户 2026-10-12 第 2 条):「这段时间算忙碌,这一块直接给它去掉就行了,
|
||||||
<?= $evLab('这段时间算', 'ev-transp') ?>
|
没有什么用。」⇒ 「这段时间算 / 忙碌 / 空闲」整块删除。
|
||||||
<select class="select" id="ev-transp" name="transp">
|
⚠️ 服务端契约**没变**:`transp` 字段没随表单提交时,`!empty($_POST['transp'])`
|
||||||
<?php $tp = (int)($evF['transp'] ?? 0); ?>
|
一律得 false(= 忙碌),与原来的默认值一致 —— 不会因为删了这一块
|
||||||
<option value="0"<?= $tp === 0 ? ' selected' : '' ?>>忙碌</option>
|
而把已有日程的忙闲改掉(更新时传的是 `(int)($ev2['transp'] ?? 0)` 原值)。
|
||||||
<option value="1"<?= $tp === 1 ? ' selected' : '' ?>>空闲(别人还能约)</option>
|
下方 script 里原来还有一段给 `ev-transp` 的联动,也一并删。 */ ?>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php echo $evRowOpen('full'); ?>
|
<?php echo $evRowOpen('full'); ?>
|
||||||
<?= $evLab('备注', 'ev-note') ?>
|
<?= $evLab('备注', 'ev-note') ?>
|
||||||
@@ -238,26 +271,37 @@ $evLab = static function (string $text, string $for = '', bool $star = false) us
|
|||||||
(见 app.js 的 onRange → writeDT),服务端契约不变。 */
|
(见 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 freq = document.getElementById('ev-freq');
|
||||||
var endBox = document.getElementById('ev-rep-end-box');
|
var endBox = document.getElementById('ev-rep-end-box');
|
||||||
var endSel = document.getElementById('ev-rep-end');
|
var endSel = document.getElementById('ev-rep-end');
|
||||||
var cntBox = document.getElementById('ev-rep-count-box');
|
var cntBox = document.getElementById('ev-rep-count-box');
|
||||||
var untilBox = document.getElementById('ev-rep-until-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() {
|
function syncRep() {
|
||||||
if (!freq || !endBox) return;
|
if (!freq || !endBox) { return; }
|
||||||
var repeating = freq.value !== '';
|
var repeating = freq.value !== '';
|
||||||
endBox.hidden = !repeating;
|
toggle(endBox, repeating);
|
||||||
var how = endSel ? endSel.value : 'never';
|
var how = endSel ? endSel.value : 'never';
|
||||||
if (cntBox) { cntBox.hidden = !repeating || how !== 'count'; }
|
toggle(cntBox, repeating && how === 'count');
|
||||||
if (untilBox) { untilBox.hidden = !repeating || how !== 'until'; }
|
toggle(untilBox, repeating && how === 'until');
|
||||||
}
|
}
|
||||||
if (freq) { freq.addEventListener('change', syncRep); }
|
if (freq) { freq.addEventListener('change', syncRep); }
|
||||||
if (endSel) { endSel.addEventListener('change', syncRep); }
|
if (endSel) { endSel.addEventListener('change', syncRep); }
|
||||||
// 回填:编辑一条已有的重复日程时,把结束条件按库里的值选对
|
// 回填:编辑一条已有的重复日程时,把结束条件按库里的值选对
|
||||||
<?php if ($rp['count'] > 0): ?>
|
<?php if ((int)$evRp['count'] > 0): ?>
|
||||||
if (endSel) { endSel.value = 'count'; }
|
if (endSel) { endSel.value = 'count'; }
|
||||||
<?php elseif ($rp['until'] !== ''): ?>
|
<?php elseif ((string)$evRp['until'] !== ''): ?>
|
||||||
if (endSel) { endSel.value = 'until'; }
|
if (endSel) { endSel.value = 'until'; }
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
syncRep();
|
syncRep();
|
||||||
@@ -277,4 +321,4 @@ $evLab = static function (string $text, string $for = '', bool $star = false) us
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<?php unset($evF, $evAllDayDefault, $evIsAllDay, $evFields, $evAllDay, $evMv, $evMvStyle,
|
<?php unset($evF, $evAllDayDefault, $evIsAllDay, $evFields, $evAllDay, $evMv, $evMvStyle,
|
||||||
$evRowOpen, $evLab, $evLabelStar); ?>
|
$evRowOpen, $evLab, $evLabelStar, $evRp, $evRepeating); ?>
|
||||||
|
|||||||
+194
-39
@@ -161,51 +161,111 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
<input type="hidden" name="back_view" value="<?= e($view) ?>">
|
<input type="hidden" name="back_view" value="<?= e($view) ?>">
|
||||||
<input type="hidden" name="back_anchor" value="<?= e($anchor) ?>">
|
<input type="hidden" name="back_anchor" value="<?= e($anchor) ?>">
|
||||||
<div class="cal-modal-body">
|
<div class="cal-modal-body">
|
||||||
<div class="field full">
|
<?php /* ⚠️ v44:`.mv-grid` 在这里就打开 —— 下面的「日历本」行
|
||||||
<label for="ce-cal">放在哪本</label>
|
与「日期范围」字段块都要走观影式排版(标签右对齐 + 冒号)。
|
||||||
<?php /* v24 第 21 批 第 7 条:「选择日历本,带上颜色」。
|
原来 `.mv-grid` 是包在字段块那一小段里的,日课本在它**外面**,
|
||||||
⚠️ 原生 <select> 的 <option> **画不出色块**(浏览器不让你在
|
于是"日历本:下拉"落不到这套排版上(正是用户报的"不在一行")。 */ ?>
|
||||||
option 里放元素,background 也基本不生效)→ 所以做成
|
<div class="mv-grid">
|
||||||
「色块 + 下拉」并排:左边一块跟着当前选中的本子变色,
|
<?php /* 🔴🔴 v44(用户 2026-10-12 第 1 条):「新建日程,放在哪本,改成日历本后面加冒号。
|
||||||
右边还是那个原生下拉(保持零依赖、手机上也能滚)。
|
现在这句话和日历本下拉框还有前面的颜色,仍然不在一行,
|
||||||
切换时由下面那段 JS 同步色块颜色。 */
|
我建议把颜色放在下拉框里面,就跟电影的影院选择一样,
|
||||||
/* 🔴 v24 第 21 批 第 7 条:**默认必须选"我自己的普通日历本"**。
|
让日历本这三个字和下拉框在一条线上。」
|
||||||
原来直接取 `$writable[0]` —— 而顺序是按 id,观影 / 旅行两本
|
|
||||||
通常排在最前面。于是用户打开"新建日程"、直接打字、点"加上",
|
三件事:
|
||||||
建出来的其实是一条**观影记录**(或旅程)。
|
① 标签改「日历本」(用户点名要这三个字),冒号由观影式排版的
|
||||||
这不是"显示不对",是**建错东西**,而且事后很难发现
|
`.mv-row > .mv-lb::after` 给(与观影/任务一致);
|
||||||
—— 用户以为日历里多了条日程,其实观影列表里多了一条记录。
|
② **颜色不再是下拉框外的一个色块**,而是像"影院选择"那样
|
||||||
所以:优先把自己建的 kind='custom' 本子排在前面当默认;
|
**画进下拉里**(每行左边一个该本子的色点)。
|
||||||
没有普通本时才退到第一个可写的本子。 */
|
原生 `<option>` 画不出元素 ⇒ 换成**自定义下拉**
|
||||||
$firstCal = null;
|
(复用全站的 `.cs` 组件:按钮 + 弹出列表 + 搜索),
|
||||||
foreach ($writable as $c) {
|
真值仍写进隐藏的 `<input name="calendar_id">`(表单契约不变)。
|
||||||
if ((string)($c['kind'] ?? '') === 'custom') { $firstCal = $c; break; }
|
③ 因为换成了一个自绘的下拉,三者天然在同一行 ——
|
||||||
}
|
不再有"标签一行、色块+下拉下一行"的错位。
|
||||||
if ($firstCal === null) { $firstCal = $writable ? $writable[0] : null; }
|
|
||||||
/* ⚠️ 模板里**再兜一道**"快照":路由只会在**页面级**补一次本子,
|
⚠️ 无 JS 兜底:`.cs` 里**仍然保留**一个原生 `<select name="calendar_id">`
|
||||||
而这张表单是页面渲染时定格的 —— 万一补本失败(DB 异常等),
|
(视觉收起、但它是真的提交字段)—— JS 没跑时它就是那个下拉,
|
||||||
这里至少还能看出"没有可写的普通本",不至于把"选中了观影本"
|
与 `_datetime.php` 的"隐藏 date 当值载体"是同一套思路
|
||||||
当成正常状态。 */
|
(v43 那条"没有盒子的元素定位无效"的教训:这里用 `position:absolute;
|
||||||
$hasPersonal = $firstCal !== null
|
width:1px` 而非 `display:none`,保留可聚焦、可提交)。
|
||||||
&& (string)($firstCal['kind'] ?? '') === 'custom'; ?>
|
⚠️ 组件 id 保持 `cinemaPicker/csBtn/csMenu/csDot/csText` **不合适**
|
||||||
<div class="cal-book-row">
|
(那是观影的编号)—— 用 `calPicker/cb*` 一套独立的,
|
||||||
<span class="cal-book-dot" id="ce-cal-dot"
|
免得将来两处互相 getElementById 抢。
|
||||||
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
|
⚠️ e2e / 断言里按 `#ce-cal` 定位的写法**必须继续成立**:
|
||||||
<select class="select" id="ce-cal" name="calendar_id">
|
原生 select 的 id 保持 `ce-cal`(只是被视觉收起)。 */ ?>
|
||||||
|
|
||||||
|
<?php /* 🔴 v24 第 21 批 第 7 条:**默认必须选"我自己的普通日历本"**。
|
||||||
|
原来直接取 `$writable[0]` —— 而顺序是按 id,观影 / 旅行两本
|
||||||
|
通常排在最前面。于是用户打开"新建日程"、直接打字、点"加上",
|
||||||
|
建出来的其实是一条**观影记录**(或旅程)。
|
||||||
|
这不是"显示不对",是**建错东西**,而且事后很难发现
|
||||||
|
—— 用户以为日历里多了条日程,其实观影列表里多了一条记录。
|
||||||
|
所以:优先把自己建的 kind='custom' 本子排在前面当默认;
|
||||||
|
没有普通本时才退到第一个可写的本子。 */
|
||||||
|
$firstCal = null;
|
||||||
|
foreach ($writable as $c) {
|
||||||
|
if ((string)($c['kind'] ?? '') === 'custom') { $firstCal = $c; break; }
|
||||||
|
}
|
||||||
|
if ($firstCal === null) { $firstCal = $writable ? $writable[0] : null; }
|
||||||
|
/* ⚠️ 模板里**再兜一道**"快照":路由只会在**页面级**补一次本子,
|
||||||
|
而这张表单是页面渲染时定格的 —— 万一补本失败(DB 异常等),
|
||||||
|
这里至少还能看出"没有可写的普通本",不至于把"选中了观影本"
|
||||||
|
当成正常状态。 */
|
||||||
|
$hasPersonal = $firstCal !== null
|
||||||
|
&& (string)($firstCal['kind'] ?? '') === 'custom'; ?>
|
||||||
|
|
||||||
|
<div class="mv-row">
|
||||||
|
<span class="mv-lb">日历本</span>
|
||||||
|
<div class="cs cal-book-cs" id="calPicker" data-open="0">
|
||||||
|
<button type="button" class="cs-btn" id="cbBtn" aria-haspopup="listbox" aria-expanded="false">
|
||||||
|
<span class="cs-dot" id="cbDot"
|
||||||
|
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
|
||||||
|
<span class="cs-text" id="cbText"><?= e((string)($firstCal['name'] ?? '未选择')) ?></span>
|
||||||
|
<span class="cs-caret" aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
<div class="cs-menu" id="cbMenu" hidden role="listbox">
|
||||||
|
<div class="cs-list" id="cbList">
|
||||||
|
<?php foreach ($writable as $c): $ro = \App\Calendar::isReadonlyKind((string)$c['kind']);
|
||||||
|
$isDef = $firstCal && (int)$c['id'] === (int)$firstCal['id']; ?>
|
||||||
|
<?php /* v24 第 21 批 第 7 条:纯派生的本子(农历/节假日/传统节日/节气)
|
||||||
|
列出来但**禁用** —— 直接不列的话,用户会以为"我的本子丢了";
|
||||||
|
列为可选项又会让他白选一次再吃一个错误提示。
|
||||||
|
禁用 + 一句 title 说明,是这个取舍里最不费解的一种。
|
||||||
|
观影 / 旅行**不禁用**:它们可写(加一条 = 建一条观影/旅程)。 */ ?>
|
||||||
|
<button type="button" class="cs-item<?= $ro ? ' is-disabled' : '' ?>"
|
||||||
|
role="option"<?= $ro ? ' disabled' : '' ?>
|
||||||
|
data-id="<?= (int)$c['id'] ?>"
|
||||||
|
data-color="<?= e((string)$c['color']) ?>"
|
||||||
|
data-text="<?= e((string)$c['name']) ?>"
|
||||||
|
<?= $ro ? 'title="系统自动生成,不能直接加日程"' : '' ?>>
|
||||||
|
<span class="cs-dot" style="background: <?= e((string)$c['color']) ?>;"></span>
|
||||||
|
<span><?= e((string)$c['name']) ?><?= $ro ? '(自动生成)' : '' ?></span>
|
||||||
|
</button>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php /* 一个都没匹配上时露出来(平时 hidden,不占高度) */ ?>
|
||||||
|
<div class="cs-empty" id="cbEmpty" hidden>没有匹配的日历本</div>
|
||||||
|
</div>
|
||||||
|
<?php /* ⚠️⚠️ 这是**真的提交字段**(不是"只给 JS 用"的影子):
|
||||||
|
自定义下拉只是它的外观;JS 没跑时它就是那个原生下拉。
|
||||||
|
视觉收起用 position:absolute + 1px(**不用 display:none**)——
|
||||||
|
后者会让表单校验时不可聚焦(v37 那次的坑)。
|
||||||
|
桌面无 JS 时它贴在按钮下方 1px 处、可点,仍能选。 */ ?>
|
||||||
|
<select class="select cal-book-native" id="ce-cal" name="calendar_id" aria-label="日历本">
|
||||||
<?php foreach ($writable as $c): $ro = \App\Calendar::isReadonlyKind((string)$c['kind']);
|
<?php foreach ($writable as $c): $ro = \App\Calendar::isReadonlyKind((string)$c['kind']);
|
||||||
$isDef = $firstCal && (int)$c['id'] === (int)$firstCal['id']; ?>
|
$isDef = $firstCal && (int)$c['id'] === (int)$firstCal['id']; ?>
|
||||||
<?php /* v24 第 21 批 第 7 条:纯派生的本子(农历/节假日/传统节日/节气)
|
|
||||||
列出来但**禁用** —— 直接不列的话,用户会以为"我的本子丢了";
|
|
||||||
列为可选项又会让他白选一次再吃一个错误提示。
|
|
||||||
禁用 + 一句 title 说明,是这个取舍里最不费解的一种。
|
|
||||||
观影 / 旅行**不禁用**:它们可写(加一条 = 建一条观影/旅程)。 */ ?>
|
|
||||||
<option value="<?= (int)$c['id'] ?>"
|
<option value="<?= (int)$c['id'] ?>"
|
||||||
data-color="<?= e((string)$c['color']) ?>"
|
data-color="<?= e((string)$c['color']) ?>"
|
||||||
<?= $isDef ? ' selected' : '' ?>
|
<?= $isDef ? ' selected' : '' ?>
|
||||||
<?= $ro ? 'disabled title="系统自动生成,不能直接加日程"' : '' ?>><?= e((string)$c['name']) ?><?= $ro ? '(自动生成)' : '' ?></option>
|
<?= $ro ? 'disabled title="系统自动生成,不能直接加日程"' : '' ?>><?= e((string)$c['name']) ?><?= $ro ? '(自动生成)' : '' ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
|
<?php /* 兼容旧断言/旧 JS:外面那个独立色块曾经叫 ce-cal-dot。
|
||||||
|
现在颜色已经画进下拉里、不再需要它 —— 保留一个**隐藏**的同名元素,
|
||||||
|
免得旧探针 document.getElementById('ce-cal-dot').style 直接抛错。 */ ?>
|
||||||
|
<span class="cal-book-dot" id="ce-cal-dot" hidden
|
||||||
|
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<?php if (!$hasPersonal): ?>
|
<?php if (!$hasPersonal): ?>
|
||||||
<span class="tip" style="display:block;margin-top:6px">
|
<span class="tip" style="display:block;margin-top:6px">
|
||||||
⚠️ 你还没有**自己的日程本**,上面选的是一本"派生本"
|
⚠️ 你还没有**自己的日程本**,上面选的是一本"派生本"
|
||||||
@@ -213,7 +273,6 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
<a href="<?= e(url('calendar/calendars')) ?>">日历本</a> 建一本吧。
|
<a href="<?= e(url('calendar/calendars')) ?>">日历本</a> 建一本吧。
|
||||||
</span>
|
</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
|
||||||
<?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
|
<?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
|
||||||
新建时「全天」默认**不勾**(用户原话)。开始时间默认:
|
新建时「全天」默认**不勾**(用户原话)。开始时间默认:
|
||||||
带了 new_at 就用它(点日历的那一格),否则今天 09:00。
|
带了 new_at 就用它(点日历的那一格),否则今天 09:00。
|
||||||
@@ -233,12 +292,14 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
$evFields = ['starts_at' => $defDay . ' ' . $defHM . ':00',
|
$evFields = ['starts_at' => $defDay . ' ' . $defHM . ':00',
|
||||||
'ends_at' => $defDay . ' ' . $defEndH . ':' . substr($defHM, 3, 2) . ':00'];
|
'ends_at' => $defDay . ' ' . $defEndH . ':' . substr($defHM, 3, 2) . ':00'];
|
||||||
$evAllDay = false;
|
$evAllDay = false;
|
||||||
|
/* ⚠️ v44:`.mv-grid` 容器**已经在上面日课本那一行之前打开了**
|
||||||
|
(日课本自己也要走观影式排版)—— 这里只 require 字段块,
|
||||||
|
不要再包一层,否则会出现嵌套的第二个 `.mv-grid`。 */
|
||||||
$evMvStyle = true; // 观影式排版(用户第 5 条)
|
$evMvStyle = true; // 观影式排版(用户第 5 条)
|
||||||
echo '<div class="mv-grid">';
|
|
||||||
require __DIR__ . '/_event_fields.php';
|
require __DIR__ . '/_event_fields.php';
|
||||||
echo '</div>';
|
|
||||||
unset($evMvStyle);
|
unset($evMvStyle);
|
||||||
?>
|
?>
|
||||||
|
</div><?php /* /mv-grid */ ?>
|
||||||
</div><?php /* /cal-modal-body */ ?>
|
</div><?php /* /cal-modal-body */ ?>
|
||||||
<div class="cal-modal-foot">
|
<div class="cal-modal-foot">
|
||||||
<span class="cal-modal-tip" id="calAddTip"></span>
|
<span class="cal-modal-tip" id="calAddTip"></span>
|
||||||
@@ -558,6 +619,100 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
syncDot();
|
syncDot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
🔴 v44(用户 2026-10-12 第 1 条):日历本自绘下拉(色点画进下拉里)
|
||||||
|
------------------------------------------------------------
|
||||||
|
与观影的影院下拉同一套交互,但**状态写在选中的原生 `<select>` 上**
|
||||||
|
(写 `.value` + 派发 change)—— 这样无 JS 兜底、既有断言(读 `#ce-cal`)
|
||||||
|
与"提交契约"全都不用改:自定义下拉只是它的**外观**。
|
||||||
|
|
||||||
|
⚠️ 菜单 `.cs-menu` 是 `position:absolute`,而它**居中面板的滚容器里**
|
||||||
|
(`.cal-modal { overflow-y:auto }`)。要看它在低端会不会被裁掉 ——
|
||||||
|
真机几何断言里会量(见论文式验证)。
|
||||||
|
============================================================ */
|
||||||
|
(function () {
|
||||||
|
var picker = document.getElementById('calPicker');
|
||||||
|
var sel = document.getElementById('ce-cal');
|
||||||
|
if (!picker || !sel) { return; }
|
||||||
|
var btn = document.getElementById('cbBtn');
|
||||||
|
var menu = document.getElementById('cbMenu');
|
||||||
|
var dot = document.getElementById('cbDot');
|
||||||
|
var text = document.getElementById('cbText');
|
||||||
|
var list = document.getElementById('cbList');
|
||||||
|
if (!btn || !menu || !dot || !text || !list) { return; }
|
||||||
|
|
||||||
|
/* 把 options 重建成列表项 —— 单一真值来源是那个 `<select>`,
|
||||||
|
所以这里不需要模板另给一份数据、也不会两边不同步。 */
|
||||||
|
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 form = document.getElementById('calAddForm');
|
||||||
var tip = document.getElementById('calAddTip');
|
var tip = document.getElementById('calAddTip');
|
||||||
if (!form || !card || !window.fetch) { return; }
|
if (!form || !card || !window.fetch) { return; }
|
||||||
|
|||||||
+21
-6
@@ -218,14 +218,24 @@ if ($timed) {
|
|||||||
下面的 `$nowH` 是当前小时(服务端时区,与页面口径一致),
|
下面的 `$nowH` 是当前小时(服务端时区,与页面口径一致),
|
||||||
把它并进区间端点 —— 于是红线必然落在区间内,定位也就有了正确的目标。
|
把它并进区间端点 —— 于是红线必然落在区间内,定位也就有了正确的目标。
|
||||||
⚠️ 必须在**算 $nowH 之前**完成这一步(原代码把 $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');
|
$nowHNow = (int)date('G');
|
||||||
if ($nowHNow < $startH) { $startH = max(0, $nowHNow - 1); }
|
if ($nowHNow < $startH) { $startH = max(0, $nowHNow - 1); }
|
||||||
if ($nowHNow >= $endH) { $endH = min(24, $nowHNow + 2); }
|
if ($nowHNow >= $endH) { $endH = min(24, $nowHNow + 2); }
|
||||||
if ($endH <= $startH) { $endH = min(24, $startH + 2); }
|
if ($endH <= $startH) { $endH = min(24, $startH + 2); }
|
||||||
}
|
}
|
||||||
$nowH = (int)date('G');
|
/* 红线位置:**分钟级**(与日程页同一算法)。见上面 v44 那条 —— 原来是 `date('G')`
|
||||||
$nowPos = ($nowH - $startH) / max(1, ($endH - $startH)); // 红线位置(0~1)
|
(整小时),所以"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;
|
$showNow = $nowPos >= 0 && $nowPos <= 1;
|
||||||
|
|
||||||
/* 🔴 v30 第 2/4 条:首页日程也用**和日程模块同一套冲突分列**。
|
/* 🔴 v30 第 2/4 条:首页日程也用**和日程模块同一套冲突分列**。
|
||||||
@@ -304,10 +314,15 @@ unset($evRef);
|
|||||||
</div>
|
</div>
|
||||||
<?php endfor; ?>
|
<?php endfor; ?>
|
||||||
<?php /* 当前时刻红线:用户原话「需要在当前的时间或者是时刻那里显示一条红线」 */ ?>
|
<?php /* 当前时刻红线:用户原话「需要在当前的时间或者是时刻那里显示一条红线」 */ ?>
|
||||||
|
<?php /* 🔴 v44(用户 2026-10-12 第 3 条):「红线左侧是有一个红点吗?
|
||||||
|
这个红点显示也不全,也不太好看。直接把红点去掉就行了,
|
||||||
|
直接一根红线就可以了。」
|
||||||
|
⇒ 那颗 `.ag-now-dot` 整个删掉(原来它是个 8×8 的圆,左端 `left:-4px`,
|
||||||
|
靠外溢一点点来"指示起点";用户觉得显示不全又不好看)。
|
||||||
|
⚠️ CSS 里那条 `.ag-now-dot` 也一并删(留在那儿是死规则,
|
||||||
|
还会让"红线只有一根"这件事在样式表里看不出结论)。 */ ?>
|
||||||
<?php if ($showNow): ?>
|
<?php if ($showNow): ?>
|
||||||
<div class="ag-now" style="top: <?= number_format($nowPos * 100, 2) ?>%">
|
<div class="ag-now" style="top: <?= number_format($nowPos * 100, 2) ?>%"></div>
|
||||||
<span class="ag-now-dot"></span>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php /* 日程块:按"相对第一个刻度的小时数"定位(与日视图同一套比例)---- */ ?>
|
<?php /* 日程块:按"相对第一个刻度的小时数"定位(与日视图同一套比例)---- */ ?>
|
||||||
<?php
|
<?php
|
||||||
|
|||||||
@@ -149,6 +149,99 @@ $rosterCount = $rosterCount ?? 0;
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== 移动端 API 令牌(v44 用户第 6 条)==================== -->
|
||||||
|
<?php /* 用户原话:「我们现在马上要开始移动终端的开发,所以现在要开发所有接口。」
|
||||||
|
这张卡是**发令牌**的地方 —— 没有它,App 连不上(接口做完了也没入口)。
|
||||||
|
|
||||||
|
为什么与 DAV 口令**分开**:
|
||||||
|
· DAV 那把是给第三方同步客户端(DAVx⁵ / iOS 日历)的,走 Basic 鉴权、
|
||||||
|
明文要手打进手机、一把管日历+通讯录;
|
||||||
|
· API 令牌是给**自家 App** 的,走 `Authorization: Bearer`、可多枚
|
||||||
|
(一台设备一枚)、可逐枚吊销、可设有效期、带设备名。
|
||||||
|
两件事的生命周期完全不同(吊销 DAV 口令会连带断掉日历同步;
|
||||||
|
吊销一枚 App 令牌不该影响别人的手机)。 */ ?>
|
||||||
|
<div class="card card-pad" id="api" style="margin-top:20px">
|
||||||
|
<h2 class="section-title">
|
||||||
|
App 接口令牌
|
||||||
|
<span class="hint">移动端 / 第三方程序调用用</span>
|
||||||
|
</h2>
|
||||||
|
<p class="small muted" style="margin:0 0 14px">
|
||||||
|
接口地址前缀 <span class="mono"><b><?= e((string)$apiBase) ?>/api/v1/</b></span>,
|
||||||
|
请求头带 <span class="mono">Authorization: Bearer <令牌></span>。
|
||||||
|
先调 <span class="mono">GET <?= e((string)$apiBase) ?>/api/v1/ping</span> 探连通(免令牌)。
|
||||||
|
<br>完整接口清单见仓库里的 <span class="mono">docs/API.md</span>。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field full">
|
||||||
|
<label>接口根地址</label>
|
||||||
|
<input class="input mono" readonly value="<?= e((string)$apiBase . '/api/v1') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>用户名 / 手机号</label>
|
||||||
|
<input class="input mono" readonly value="<?= e((string)$person['name']) ?>">
|
||||||
|
<span class="tip">令牌本身就是凭据,这一行只是给你对账用。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($apiPlain)): ?>
|
||||||
|
<p class="tip" style="background:#FFF6E5;border-color:#E8C98A;padding:10px 12px;border-radius:8px">
|
||||||
|
<b>这是你的新令牌,只显示这一次,请立刻复制到 App 里:</b>
|
||||||
|
<?php if ((string)$apiLabel !== ''): ?>
|
||||||
|
(设备名:<?= e((string)$apiLabel) ?>)
|
||||||
|
<?php endif; ?>
|
||||||
|
<br><code class="mono" style="font-size:14px;word-break:break-all"><?= e((string)$apiPlain) ?></code>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($apiTokens)): ?>
|
||||||
|
<table class="res-table" style="width:100%;margin:6px 0 10px">
|
||||||
|
<thead>
|
||||||
|
<tr><th>设备名</th><th>签发时间</th><th>最近使用</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($apiTokens as $tk): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= e((string)($tk['label'] ?: '(未命名)')) ?></td>
|
||||||
|
<td class="small muted"><?= e((string)($tk['created_at'] ?? '')) ?></td>
|
||||||
|
<td class="small muted"><?= e((string)($tk['last_used_at'] ?? '还没用过')) ?></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="<?= e(url('settings/api_revoke')) ?>" class="res-inline"
|
||||||
|
onsubmit="return confirm('吊销这枚令牌?这台设备会立刻掉线。')">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$tk['id'] ?>">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">吊销</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="small muted">还没有令牌。给每台设备发一枚吧。</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (\App\Auth::canWrite()): ?>
|
||||||
|
<div class="btn-row">
|
||||||
|
<form method="post" action="<?= e(url('settings/api_token')) ?>" class="res-inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input class="input" name="label" placeholder="设备名(如:我的手机)" style="max-width:200px">
|
||||||
|
<button class="btn btn-sm" type="submit">生成新令牌</button>
|
||||||
|
</form>
|
||||||
|
<?php if (!empty($apiTokens)): ?>
|
||||||
|
<form method="post" action="<?= e(url('settings/api_revoke')) ?>" class="res-inline"
|
||||||
|
onsubmit="return confirm('吊销全部令牌?所有 App 都要重新登录。')">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">吊销全部</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<p class="small muted" style="margin:10px 0 0">
|
||||||
|
令牌只在生成那一次显示明文(库里只存哈希)。手机丢了就吊销那一枚。
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ==================== 天气地点(用户 2026-10-03 第 1 条)==================== -->
|
<!-- ==================== 天气地点(用户 2026-10-03 第 1 条)==================== -->
|
||||||
<?php /* 原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
|
<?php /* 原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
|
||||||
所以地点从系统设置搬到了这里 —— 而且改成**城市名**,不是经纬度:
|
所以地点从系统设置搬到了这里 —— 而且改成**城市名**,不是经纬度:
|
||||||
|
|||||||
@@ -36,19 +36,23 @@ $tkEmbed = !empty($tkEditEmbed);
|
|||||||
<form method="post" action="<?= e(url('tasks/update')) ?>" class="node-form" id="tkEditForm">
|
<form method="post" action="<?= e(url('tasks/update')) ?>" class="node-form" id="tkEditForm">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= (int)($tkT['id'] ?? 0) ?>">
|
<input type="hidden" name="id" value="<?= (int)($tkT['id'] ?? 0) ?>">
|
||||||
<div class="nf-grid">
|
<?php /* 🔴 v44(用户 2026-10-12 第 5 条):编辑表单也统一成观影式排版 +
|
||||||
<div class="field full">
|
最新时间控件 + 文案精简(与「新建任务」弹框一致)——
|
||||||
<label for="tk-title">标题</label>
|
同一件事在两个入口长得不一样,用户会以为"进了别的页面"。 */ ?>
|
||||||
|
<div class="mv-grid">
|
||||||
|
<div class="mv-row">
|
||||||
|
<span class="mv-lb">任务名称<i>*</i></span>
|
||||||
<input class="input" id="tk-title" name="title" required value="<?= e((string)($tkT['title'] ?? '')) ?>">
|
<input class="input" id="tk-title" name="title" required value="<?= e((string)($tkT['title'] ?? '')) ?>">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="mv-row">
|
||||||
<label>截止时间(留空=不上日历)</label>
|
<span class="mv-lb">截止时间</span>
|
||||||
|
<?php /* 🔴 v44:截止时间用 AntD 单点控件(与新建同一套);括号去掉。 */ ?>
|
||||||
<?php $dtName = 'due_at'; $dtValue = $tkT['due_at'] ?? null;
|
<?php $dtName = 'due_at'; $dtValue = $tkT['due_at'] ?? null;
|
||||||
$dtRequired = false;
|
$dtRequired = false; $dtPlugin = 'antd-single';
|
||||||
require __DIR__ . '/../_datetime.php'; ?>
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="mv-row">
|
||||||
<label>负责人</label>
|
<span class="mv-lb">负责人</span>
|
||||||
<?php /* 第 8 条同一诉求:负责人也是「选人」,人多时用弹窗 + 首字母更快。
|
<?php /* 第 8 条同一诉求:负责人也是「选人」,人多时用弹窗 + 首字母更快。
|
||||||
保留原下拉的「不指定」(pp-item-none + value=0)。 */ ?>
|
保留原下拉的「不指定」(pp-item-none + value=0)。 */ ?>
|
||||||
<?php
|
<?php
|
||||||
@@ -63,8 +67,8 @@ $tkEmbed = !empty($tkEditEmbed);
|
|||||||
require __DIR__ . '/../_person_picker.php';
|
require __DIR__ . '/../_person_picker.php';
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="mv-row">
|
||||||
<label>循环</label>
|
<span class="mv-lb">循环</span>
|
||||||
<select class="select" name="repeat_rule">
|
<select class="select" name="repeat_rule">
|
||||||
<?php foreach (\App\Task::REPEAT as $rk => $rv): ?>
|
<?php foreach (\App\Task::REPEAT as $rk => $rv): ?>
|
||||||
<option value="<?= e($rk) ?>"<?= (string)($tkT['repeat_rule'] ?? '') === (string)$rk ? ' selected' : '' ?>>
|
<option value="<?= e($rk) ?>"<?= (string)($tkT['repeat_rule'] ?? '') === (string)$rk ? ' selected' : '' ?>>
|
||||||
@@ -73,8 +77,8 @@ $tkEmbed = !empty($tkEditEmbed);
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="mv-row">
|
||||||
<label>挂到哪个父任务下</label>
|
<span class="mv-lb">父任务</span>
|
||||||
<select class="select" name="parent_id">
|
<select class="select" name="parent_id">
|
||||||
<option value="0">(这是一项独立的任务)</option>
|
<option value="0">(这是一项独立的任务)</option>
|
||||||
<?php foreach ($tkParents as $p): ?>
|
<?php foreach ($tkParents as $p): ?>
|
||||||
@@ -85,8 +89,8 @@ $tkEmbed = !empty($tkEditEmbed);
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<?php /* v24 第 9、10 条:分类与看板(看板把任务放到哪一块,分类是标签) */ ?>
|
<?php /* v24 第 9、10 条:分类与看板(看板把任务放到哪一块,分类是标签) */ ?>
|
||||||
<div class="field">
|
<div class="mv-row">
|
||||||
<label>分类</label>
|
<span class="mv-lb">分类</span>
|
||||||
<select class="select" name="category_id">
|
<select class="select" name="category_id">
|
||||||
<option value="0">未分类</option>
|
<option value="0">未分类</option>
|
||||||
<?php foreach ($tkCats as $c): ?>
|
<?php foreach ($tkCats as $c): ?>
|
||||||
@@ -96,8 +100,8 @@ $tkEmbed = !empty($tkEditEmbed);
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="mv-row">
|
||||||
<label>看板</label>
|
<span class="mv-lb">看板</span>
|
||||||
<select class="select" name="board_id">
|
<select class="select" name="board_id">
|
||||||
<?php foreach ($tkBoards as $b): ?>
|
<?php foreach ($tkBoards as $b): ?>
|
||||||
<option value="<?= (int)$b['id'] ?>"<?= (int)($tkT['board_id'] ?? 0) === (int)$b['id'] ? ' selected' : '' ?>>
|
<option value="<?= (int)$b['id'] ?>"<?= (int)($tkT['board_id'] ?? 0) === (int)$b['id'] ? ' selected' : '' ?>>
|
||||||
@@ -106,8 +110,8 @@ $tkEmbed = !empty($tkEditEmbed);
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field full">
|
<div class="mv-row">
|
||||||
<label for="tk-note">备注</label>
|
<span class="mv-lb">备注</span>
|
||||||
<textarea class="input" id="tk-note" name="note" rows="3"><?= e((string)($tkT['note'] ?? '')) ?></textarea>
|
<textarea class="input" id="tk-note" name="note" rows="3"><?= e((string)($tkT['note'] ?? '')) ?></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+60
-46
@@ -89,57 +89,72 @@ $lastColIdx = count($colList) - 1;
|
|||||||
`back=tasks` 走白名单。 */ ?>
|
`back=tasks` 走白名单。 */ ?>
|
||||||
<input type="hidden" name="back" value="tasks">
|
<input type="hidden" name="back" value="tasks">
|
||||||
<div class="cal-modal-body">
|
<div class="cal-modal-body">
|
||||||
<div class="form-grid">
|
<?php /* 🔴 v44(用户 2026-10-12 第 5 条):「新建任务页面的排版还没有统一,
|
||||||
<div class="field full">
|
应该把标题放在前面,树框放在后面。」
|
||||||
<label for="tk-title">要做什么 *</label>
|
⇒ 用观影/行程/日程那套 **`.mv-grid`**(标签 62px 右对齐 + CSS `::after` 冒号 +
|
||||||
<input class="input" id="tk-title" name="title" required placeholder="如:周末大扫除">
|
控件占满剩余宽度),标题那一行在最前、其余字段依次跟上 ——
|
||||||
|
"标题在前、后面的框跟上"就是这套排版的自然顺序。 */ ?>
|
||||||
|
<div class="mv-grid">
|
||||||
|
<div class="mv-row">
|
||||||
|
<span class="mv-lb">任务名称<i>*</i></span>
|
||||||
|
<input class="input" id="tk-title" name="title" required
|
||||||
|
placeholder="如:周末大扫除">
|
||||||
</div>
|
</div>
|
||||||
<?php /* v24 第 5 条:截止时刻也走全局统一的「日期 + 时分秒」控件。
|
<?php /* v24 第 5 条:截止时刻也走全局统一的「日期 + 时分秒」控件。
|
||||||
秒这一档保留(与其它模块同一口径),默认 23:59:59 —— "这天之前做完"
|
秒这一档保留(与其它模块同一口径),默认 23:59:59 —— "这天之前做完"
|
||||||
比"这天 00:00 之前"更符合直觉。 */ ?>
|
比"这天 00:00 之前"更符合直觉。
|
||||||
<div class="field">
|
🔴 v44(用户第 5 条):「截止时间也用我们最新的日历控件,
|
||||||
<label for="tk-due">截止时间(有才会出现在日历里)</label>
|
记得把现在这个控件删掉。」
|
||||||
|
⇒ 换成 AntD 的**单点** DatePicker(带时刻):
|
||||||
|
`$dtPlugin='antd-single'`(见 app.js 的 mountAntdSingleDT)。
|
||||||
|
原生那几档下拉由 CSS **真删掉**(不是 clip 藏起来)。
|
||||||
|
标签去掉那个括号(见下一条)。 */ ?>
|
||||||
|
<div class="mv-row">
|
||||||
|
<span class="mv-lb">截止时间</span>
|
||||||
<?php $dtName = 'due_at'; $dtId = 'tk-due'; $dtValue = null;
|
<?php $dtName = 'due_at'; $dtId = 'tk-due'; $dtValue = null;
|
||||||
$dtRequired = false;
|
$dtRequired = false; $dtPlugin = 'antd-single';
|
||||||
require __DIR__ . '/../_datetime.php'; ?>
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<?php /* 🔴 v44(用户第 5 条):「负责人默认是自己。」
|
||||||
<label for="tk-assign">负责人</label>
|
⇒ 默认选中当前登录的人(原来 `$ppSelected = []`,空着)。
|
||||||
|
⚠️ 用 `Auth::personId()` 取"我",与后端"负责人为空"的兜底一致。 */ ?>
|
||||||
|
<div class="mv-row">
|
||||||
|
<span class="mv-lb">负责人</span>
|
||||||
<?php
|
<?php
|
||||||
$ppName = 'assignee_id';
|
$ppName = 'assignee_id';
|
||||||
$ppPeople = $people;
|
$ppPeople = $people;
|
||||||
$ppSelected = [];
|
$ppSelected = [(int)\App\Auth::personId()];
|
||||||
$ppMode = 'single';
|
$ppMode = 'single';
|
||||||
$ppOpenBtn = '选择负责人';
|
$ppOpenBtn = '更换负责人';
|
||||||
$ppTitle = '谁负责这件事';
|
$ppTitle = '谁负责这件事';
|
||||||
$ppEmptyText = '不指定';
|
$ppEmptyText = '不指定';
|
||||||
$ppAllowEmpty = true;
|
$ppAllowEmpty = true;
|
||||||
require __DIR__ . '/../_person_picker.php';
|
require __DIR__ . '/../_person_picker.php';
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
<?php /* v24 第 14 批 用户第 2 条:新建时就能定状态(默认「待办」)。
|
<?php /* 🔴 v44(用户第 5 条):「状态下面选"已完成"的话,它只在"已完成"里出现,
|
||||||
⚠️ 选「已完成»的话它不会出现在看板上(已完成已从看板撤走),
|
看板上不再显示,这句话也去掉。」
|
||||||
只在「任务设置 → 已完成」里能翻到 —— 这句提示必须写上,
|
⇒ 删掉那句 `.tip`(状态语义由状态徽标本身表达)。
|
||||||
否则用户会以为"刚建的任务丢了"。 */ ?>
|
⚠️ 用户同条要求「任务页面,任务下面有一行字…这个提示不要了」——
|
||||||
<div class="field">
|
那是**看板顶部**那块「状态说明」(见上方 legend 那段)。 */ ?>
|
||||||
<label for="tk-status">状态</label>
|
<div class="mv-row">
|
||||||
|
<span class="mv-lb">状态</span>
|
||||||
<select class="select" id="tk-status" name="status">
|
<select class="select" id="tk-status" name="status">
|
||||||
<?php foreach (\App\Task::STATUS as $sk => $sv): ?>
|
<?php foreach (\App\Task::STATUS as $sk => $sv): ?>
|
||||||
<option value="<?= e($sk) ?>" <?= $sk === 'todo' ? 'selected' : '' ?>><?= e($sv) ?></option>
|
<option value="<?= e($sk) ?>" <?= $sk === 'todo' ? 'selected' : '' ?>><?= e($sv) ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<span class="tip">选「已完成」的话,它只在「已完成」里出现,看板上不再显示。</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="mv-row">
|
||||||
<label for="tk-repeat">循环</label>
|
<span class="mv-lb">循环</span>
|
||||||
<select class="select" id="tk-repeat" name="repeat_rule">
|
<select class="select" id="tk-repeat" name="repeat_rule">
|
||||||
<?php foreach (\App\Task::REPEAT as $rk => $rv): ?>
|
<?php foreach (\App\Task::REPEAT as $rk => $rv): ?>
|
||||||
<option value="<?= e($rk) ?>"><?= e($rv) ?></option>
|
<option value="<?= e($rk) ?>"><?= e($rv) ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="mv-row">
|
||||||
<label for="tk-cat">分类</label>
|
<span class="mv-lb">分类</span>
|
||||||
<select class="select" id="tk-cat" name="category_id">
|
<select class="select" id="tk-cat" name="category_id">
|
||||||
<option value="0">未分类</option>
|
<option value="0">未分类</option>
|
||||||
<?php foreach ($categories as $c): ?>
|
<?php foreach ($categories as $c): ?>
|
||||||
@@ -148,19 +163,21 @@ $lastColIdx = count($colList) - 1;
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<?php /* 用户第 7 条:看板 = 任务"归属哪摊事",新建时直接选(不再是顶上的切换条) */ ?>
|
<?php /* 用户第 7 条:看板 = 任务"归属哪摊事",新建时直接选(不再是顶上的切换条) */ ?>
|
||||||
<div class="field">
|
<div class="mv-row">
|
||||||
<label for="tk-board">看板</label>
|
<span class="mv-lb">看板</span>
|
||||||
<select class="select" id="tk-board" name="board_id">
|
<select class="select" id="tk-board" name="board_id">
|
||||||
<?php foreach ($boards as $b): ?>
|
<?php foreach ($boards as $b): ?>
|
||||||
<option value="<?= (int)$b['id'] ?>"><?= e((string)$b['name']) ?></option>
|
<option value="<?= (int)$b['id'] ?>"><?= e((string)$b['name']) ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<?php /* 🔴 v44(用户第 5 条):「"共享给"后面有个括号,可多选,任何人完成都算完成,
|
||||||
<label for="tk-share">共享给(可多选,任何人完成都算完成)</label>
|
这个括号里面的内容也去掉。」⇒ 标签只留「共享给」。
|
||||||
<?php /* 第 8 条的同一诉求:人多时不要下拉里瞪眼找。换成弹窗 + 首字母联想。
|
第 8 条的同一诉求:人多时不要下拉里瞪眼找。换成弹窗 + 首字母联想。
|
||||||
⚠️ 候选里去掉**自己**——「共享给自己」是空操作;组件没有"排除项",
|
⚠️ 候选里去掉**自己**——「共享给自己」是空操作;组件没有"排除项",
|
||||||
所以在传进去之前先过滤掉。 */ ?>
|
所以在传进去之前先过滤掉。 */ ?>
|
||||||
|
<div class="mv-row">
|
||||||
|
<span class="mv-lb">共享给</span>
|
||||||
<?php
|
<?php
|
||||||
$ppName = 'shares[]';
|
$ppName = 'shares[]';
|
||||||
$ppPeople = array_values(array_filter($people,
|
$ppPeople = array_values(array_filter($people,
|
||||||
@@ -172,8 +189,8 @@ $lastColIdx = count($colList) - 1;
|
|||||||
require __DIR__ . '/../_person_picker.php';
|
require __DIR__ . '/../_person_picker.php';
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
<div class="field full">
|
<div class="mv-row">
|
||||||
<label for="tk-note">备注</label>
|
<span class="mv-lb">备注</span>
|
||||||
<textarea class="input" id="tk-note" name="note" rows="2"></textarea>
|
<textarea class="input" id="tk-note" name="note" rows="2"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -184,12 +201,12 @@ $lastColIdx = count($colList) - 1;
|
|||||||
服务端 attach_save_files(..., 'attach', ...) 零改动就能收。
|
服务端 attach_save_files(..., 'attach', ...) 零改动就能收。
|
||||||
⚠️ 拖进来的走暂存(stage_token[]),随表单一起认领;
|
⚠️ 拖进来的走暂存(stage_token[]),随表单一起认领;
|
||||||
直接点选走 $_FILES 路径。两条路服务端都接(见 attach_save_files)。 */ ?>
|
直接点选走 $_FILES 路径。两条路服务端都接(见 attach_save_files)。 */ ?>
|
||||||
<div class="field full">
|
<div class="mv-row">
|
||||||
<label for="sd-tk-attach">附件(可多选)</label>
|
<span class="mv-lb">附件</span>
|
||||||
<?php $sdName = 'attach[]'; $sdId = 'sd-tk-attach'; $sdSlot = 'photo';
|
<?php $sdName = 'attach[]'; $sdId = 'sd-tk-attach'; $sdSlot = 'photo';
|
||||||
require __DIR__ . '/../_stage_drop.php'; ?>
|
require __DIR__ . '/../_stage_drop.php'; ?>
|
||||||
</div>
|
</div>
|
||||||
</div><?php /* /.form-grid */ ?>
|
</div><?php /* /.mv-grid */ ?>
|
||||||
</div><?php /* /.cal-modal-body */ ?>
|
</div><?php /* /.cal-modal-body */ ?>
|
||||||
<div class="cal-modal-foot">
|
<div class="cal-modal-foot">
|
||||||
<span class="cal-modal-tip" id="taskAddTip"></span>
|
<span class="cal-modal-tip" id="taskAddTip"></span>
|
||||||
@@ -225,17 +242,14 @@ $lastColIdx = count($colList) - 1;
|
|||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php /* 状态到底是什么(用户第 6 条:「待办和进行中,有什么区别?」)——
|
<?php /* 🔴 v44(用户 2026-10-12 第 4 条):「任务页面,任务下面有一行字:
|
||||||
把三个状态的一句话解释直接摆在看板上方,而不是藏在 title 里。
|
"待办记下来了,还没动手,排队等着进行"之类的。这个提示不要了,去掉。」
|
||||||
他问出这句话,就说明界面上没有一处讲清楚过。 */ ?>
|
⇒ 整块「状态说明」legend **删掉**(连那三个状态徽标也一起去掉)——
|
||||||
<div class="tk-legend">
|
用户要的是"任务下面不要再挂一行字",留着徽标仍然是一行字。
|
||||||
<?php foreach (\App\Task::STATUS as $sk => $sv): ?>
|
⚠️ `Task::STATUS_HINT` / `statusHint()` **保留在类里**(它是这三个状态语义的
|
||||||
<span class="tk-lg-item">
|
唯一书面定义,后端/文案用得上),只是**界面不再渲染**。
|
||||||
<span class="kb-st is-<?= e($sk) ?> is-static"><?= e($sv) ?></span>
|
⚠️ `.tk-legend` 那条 CSS 留着不动:删 CSS 会牵连缩进,而它只是空规则,
|
||||||
<span class="tk-lg-hint"><?= e(\App\Task::statusHint($sk)) ?></span>
|
真正的约束是"模板里不再输出 TK-LEGEND 这一块"。 */ ?>
|
||||||
</span>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php /* v24 第 14 批 用户第 2 条:**每张卡上都能看到并改状态**。
|
<?php /* v24 第 14 批 用户第 2 条:**每张卡上都能看到并改状态**。
|
||||||
⚠️ 与上面搬卡那份同理:表单全页只有一份,卡片上只放 `data-st-*`,
|
⚠️ 与上面搬卡那份同理:表单全页只有一份,卡片上只放 `data-st-*`,
|
||||||
|
|||||||
Reference in New Issue
Block a user