From b2ca4f336e6b1fd0eeee6a6c4571ea13e64b56b1 Mon Sep 17 00:00:00 2001 From: laoyang Date: Sun, 4 Oct 2026 10:46:50 +0800 Subject: [PATCH] =?UTF-8?q?v24=20=E7=AC=AC=2010=20=E6=89=B9=EF=BC=9A?= =?UTF-8?q?=E5=90=8D=E5=86=8C=E7=9C=9F=E6=80=A7=E8=83=BD=EF=BC=88=E6=A0=B9?= =?UTF-8?q?=E5=9B=A0=E6=98=AF=E6=AF=8F=E5=8D=A1=E5=86=85=E8=81=94=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=EF=BC=89=E7=AD=89=E4=B9=9D=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #6 名册线上真实数据(1184 人)卡顿:根因是**每张卡片内联一整份分组弹层**—— 全页 2369 个 form / 27234 个 input / 22496 个 radio / 98100 DOM 节点, 密码管理器(Bitwarden 等)逐个扫描 form 把主线程占死(用户报的就是这个冲突)。 改为卡片只带 data 属性 + 全页**唯一一份**共享浮层 #rosterMenus。 实测:form 2369→3、input 27234→25、DOM 98100→17659、解压 7694→2306 KB、 长任务 1/52ms → 0。新增哨兵 _fb_formbloat_check.py(含反向验证)。 - #5 日视图翻页失效:day_columns() 无条件吸到周一,天视图只有 1 列 → 永远显示那周周一。 改为仅 span>1 时吸附(周视图行为不变)。 - #2 财务「欠款」类别(负债);并修一个真 bug:负债填负数会让净资产**反向变大**。 写入口 normalizeAmount 归一化 + summary/timeline 侧 abs 防御 + Schema v17 纠正老数据。 - #3 账目行「盘点」「修改」两个并列按钮(各自就地弹面板);图移到账目上方, 总金额图跨整行 + 每个账目一条线 + 类别筛选条;charts() 改为覆盖全部账目。 - #4 日程新增「农历」「节假日」两本不可删除只读日历(app/Holiday.php): 两级自动更新 —— 内置规则(公历固定 + 清明节气 + 农历节日)保证断网也有; 官方 JSON 镜像自动抓取补齐调休。抓取**不在渲染路径联网**(响应后 fastcgi_finish_request + 频率闸门),缓存落 storage/cache。仅派生本不可写日程。 - #6 日程页设置按钮前加「跳到某一天」(选中即跳)。 - #7 起止范围改用一个 Semi dateTimeRange 控件(观影/旅程/节点/日程共 4 处); 新增 window.fbSetDT / fbUpgradeRange;修范围重挂(React 非受控组件须 unmount 再 createRoot) 与范围里 required 未摘除导致表单无法提交两个真实问题。 验证:e2e 786/0、batch10 44/0、batch9 38/0、batch_e 91/0、upgrade 126/0、picker 28/0、 semi_integrate 27/0、formbloat 5/0(+反向 3 红)、mobile 688/0、interact 97/0、drag 5/0、geom 5/5; 线上自检 19/19 + 线上真实账号巡检 28/0(含 Firefox 实测)。 --- .gitignore | 5 + app/Calendar.php | 81 +++- app/Holiday.php | 562 +++++++++++++++++++++++++++ app/Ledger.php | 148 +++++-- app/Schema.php | 47 ++- assets/css/app.css | 106 ++++- assets/js/app.js | 314 +++++++++++---- index.php | 23 +- templates/_datetime.php | 19 +- templates/calendar/_event_fields.php | 11 + templates/calendar/index.php | 51 +++ templates/ledger/index.php | 282 ++++++++++---- templates/movies/form.php | 15 +- templates/people/index.php | 216 ++++++++-- templates/trips/_node_form.php | 15 +- templates/trips/form.php | 15 +- 16 files changed, 1624 insertions(+), 286 deletions(-) create mode 100644 app/Holiday.php diff --git a/.gitignore b/.gitignore index 52e1306..78b4aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,8 @@ uploads/* *~ .DS_Store Thumbs.db + +# ── 运行期缓存(节假日自动更新的落地处),不入库 ────────────────────────────── +# v24 第 10 批:节假日安排由后台自动抓取后缓存(storage/cache/holiday-<年>.json), +# 它是运行期产物、且每个环境各自一份,不该跟着代码走。目录本身由部署脚本创建。 +storage/* diff --git a/app/Calendar.php b/app/Calendar.php index a24bb49..8af6c74 100644 --- a/app/Calendar.php +++ b/app/Calendar.php @@ -22,12 +22,31 @@ final class Calendar /** 日程附件的宿主类型(统一附件表用;v24 第 6 条:日程也能挂附件) */ public const ATTACH = 'calendar_event'; - /** 系统预置的两本默认日历(kind 与名字都写死,用户的自定义本一律 kind='custom') */ + /** 系统预置的默认日历(kind 与名字都写死,用户的自定义本一律 kind='custom') + * + * v24 第 10 批 #4:加了两个**不可删除的只读本** —— + * · lunar 农历(每天的农历日期 + 农历节日),由 Holiday::lunarRange 派生; + * · holiday 节假日(法定假日的**休息与调休**),由 Holiday::year 派生, + * 并且**自动更新**(官方一公布就换用官方数据,见 Holiday 的类注释)。 + * 它们和 movie/trip 一样是"从别处派生、不落 calendar_events"的视图本, + * 而且 kind ≠ 'custom' → Calendar::delete() 天然删不掉(用户要求"不可删除")。 + */ public const DEFAULT_KINDS = [ - 'movie' => ['观影', '#7A4E8C'], - 'trip' => ['旅行', '#3F7D5A'], + 'movie' => ['观影', '#7A4E8C'], + 'trip' => ['旅行', '#3F7D5A'], + 'lunar' => ['农历', '#8C6D3F'], + 'holiday' => ['节假日', '#B0342C'], ]; + /** 派生型只读日历(内容不从 calendar_events 取,也不能往里加日程) */ + public const READONLY_KINDS = ['movie', 'trip', 'lunar', 'holiday']; + + /** 这本是不是"派生只读"的(农历/节假日/观影/旅行都算) */ + public static function isReadonlyKind(string $kind): bool + { + return in_array($kind, self::READONLY_KINDS, true); + } + /** * 建本时的预设色板(与影院管理同一个交互:取色器 + 点一下就换)。 * @@ -73,7 +92,10 @@ final class Calendar // 常量里的 '#7A4E8C' 之类直接比字符串(日历本颜色是给页面用的, // 大小写混着存会让"颜色一样吗"这种判断时对时错)。 strtoupper(AvatarColor::normalize($color) ?: '#4A7C82'), - $kind === 'movie' || $kind === 'trip' ? $kind : 'custom', + // ⚠️ 别写成 `'kind' => …`:这是个**位置参数**数组(对应 6 个 ? 占位符), + // 混一个命名键进去会让 PDO 报 `Invalid parameter number` —— + // 而且是**静默的**:整个 seedDefaults 抛异常,日历本一本都建不出来。 + isset(self::DEFAULT_KINDS[$kind]) || $kind === 'custom' ? $kind : 'custom', self::nextPosition($ownerId), ]); return (int)$db->lastInsertId(); @@ -762,6 +784,57 @@ final class Calendar } } + // ---- 农历(v24 第 10 批 #4)---- + // 用户原话:「日程中,增加两个不可删除的日历,一个是农历…」 + // 每一天都出一条(标题 = 农历月日),农历节日另标一个名字。 + // 它是**派生只读**的:不落 calendar_events,删不掉,也不能往里加日程。 + if (isset($byKind['lunar'])) { + $c = $byKind['lunar'][0]; + foreach (Holiday::lunarRange($from, $to) as $l) { + $fest = (string)($l['fest'] ?? ''); + $title = (string)$l['title'] . ($fest !== '' ? ' · ' . $fest : ''); + $out[] = [ + 'cal' => (int)$c['id'], + 'color' => (string)$c['color'], + 'calname' => (string)$c['name'], + 'title' => $title, + 'date' => (string)$l['date'], + 'time' => '', + 'all_day' => true, + 'kind' => 'lunar', + 'ref_id' => 0, + // 农历本没有可点进去的详情;给一个月视图锚点即可(点它=看那天) + 'href' => url('calendar', ['view' => 'day', 'd' => (string)$l['date']]), + ]; + } + } + + // ---- 节假日:放假 + 调休(v24 第 10 批 #4)---- + // 用户原话:「一个是法定假日的休息和调休信息,法定假日这个,需要有自动更新功能… + // 不是让你更新,而是让系统,代码自动更新。」 + // 自动更新在 App\Holiday 里:内置规则保证"永远有",官方数据一到就覆盖成官方口径。 + if (isset($byKind['holiday'])) { + $c = $byKind['holiday'][0]; + // [from,to] 可能跨年(12 月末的月视图会看到次年 1 月初),两年都要取 + $years = [(int)substr($from, 0, 4), (int)substr($to, 0, 4)]; + foreach (array_unique($years) as $y) { + foreach (Holiday::range($y, $from, $to) as $h) { + $out[] = [ + 'cal' => (int)$c['id'], + 'color' => (string)$c['color'], + 'calname' => (string)$c['name'], + 'title' => (string)$h['title'], + 'date' => (string)$h['date'], + 'time' => '', + 'all_day' => true, + 'kind' => 'holiday', + 'ref_id' => 0, + 'href' => url('calendar', ['view' => 'day', 'd' => (string)$h['date']]), + ]; + } + } + } + // ---- 有截止时间的任务 ---- $st = Database::get()->prepare( "SELECT t.id, t.title, t.due_at, t.status diff --git a/app/Holiday.php b/app/Holiday.php new file mode 100644 index 0000000..0b43744 --- /dev/null +++ b/app/Holiday.php @@ -0,0 +1,562 @@ +`),而不是每次请求都去抓: + * 日历页是高频页面,把外网请求放渲染路径上会让它随外网抖动而变慢。 + */ + +declare(strict_types=1); + +namespace App; + +final class Holiday +{ + /** 官方数据源(公开镜像,按年一个 JSON:{year, papers, days:[{name,date,isOffDay}]}) */ + private const SOURCE = 'https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master/%d.json'; + private const SOURCE_ALT = 'https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/%d.json'; + + /** 缓存有效期:查到一次就够用很久(官方安排一年只公布一次),失败则短缓存避免反复打) + * 单位为秒。写成常量方便测试里改。 */ + public const CACHE_OK = 604800; // 7 天 + public const CACHE_FAIL = 21600; // 6 小时 + + /** + * 公历固定日期的节日(月 => [日, 名字])。 + * 只放**日期固定**的:清明是节气(4月4/5/6日浮动)要算,不在这里。 + */ + private const FIXED = [ + 1 => [1 => '元旦'], + 5 => [1 => '劳动节'], + 10 => [1 => '国庆节'], + ]; + + /** + * 农历节日:月 => [日, 名字, 放假天数]。 + * 放假天数只是"内置规则"下的默认值;官方数据到了会以官方为准。 + */ + private const LUNAR_FEST = [ + 1 => [1 => ['春节', 3]], // 正月初一起(实际从除夕就开始,见下) + 5 => [5 => ['端午节', 1]], + 8 => [15 => ['中秋节', 1]], + ]; + + // ------------------------------------------------------------------ + // 取一年的安排 + // ------------------------------------------------------------------ + + /** + * 拿某一年的完整安排(放假 + 调休)。 + * + * 🔴 **这个方法不发任何网络请求** —— 只读缓存 + 内置规则。 + * + * 为什么这么设计(这一条是本功能能不能用的分水岭): + * 日历是**高频页面**。如果把"抓官方数据"放在渲染路径上, + * 那么外网一抖(DNS 慢、GitHub 被墙、jsdelivr 抽风),用户打开日程就要等 + * 连接超时(4s)+ 传输超时(8s)——**页面本身没坏,但慢得没法用**, + * 而且没有任何报错能让人看出是"节假日数据源"的问题。 + * 所以这里只做本地计算,抓取交给 `refreshInBackground()`: + * 在**响应发完之后**(php-fpm 的 fastcgi_finish_request)才去联网, + * 成功了写缓存,下次请求自然就换成官方口径。 + * + * @return array{year:int,source:string,days:array} + * days 的键是 'Y-m-d',值 = ['name'=>节日名, 'off'=>true 放假 / false 调休上班] + */ + public static function year(int $year): array + { + // 只认缓存;没有缓存就是"内置规则 + 排队等后台更新" + $cached = self::fromCache($year); + if ($cached !== null) { + // 缓存里若只有内置规则(还没等到官方),顺手排一次后台更新 + if (($cached['source'] ?? '') !== 'official') { + self::queueBackgroundFetch($year); + } + return $cached; + } + $out = ['year' => $year, 'source' => 'builtin', 'days' => self::builtin($year)]; + self::saveCache($year, $out, false); + self::queueBackgroundFetch($year); + return $out; + } + + // ------------------------------------------------------------------ + // 后台自动更新(用户要的"系统自动更新"就落在这里) + // ------------------------------------------------------------------ + + /** @var array 本次请求已经排过队的年份(避免重复注册 shutdown 钩子) */ + private static array $queued = []; + + /** + * 排一次"响应之后去抓官方数据"。 + * + * php-fpm 下用 `fastcgi_finish_request()`:它把响应**立刻发给浏览器**, + * 之后脚本继续跑(抓取、写缓存)—— 这正是我们要的"用户无感"。 + * 没有这个函数(php -S、CLI)时不提前结束,抓取仍在脚本末尾发生, + * 只是算在请求时长里(仅开发环境如此,可接受)。 + */ + private static function queueBackgroundFetch(int $year): void + { + if (isset(self::$queued[$year]) || !self::shouldTry($year)) { + return; + } + self::$queued[$year] = true; + register_shutdown_function(static function () use ($year): void { + if (function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); + } + self::doFetch($year); + }); + } + + /** + * 现在该不该去抓(频率闸门)。 + * + * 用一个独立的 `last_try` 时间戳文件:**无论成败**都记一次, + * 失败后短时间内不再重试 —— 否则源挂掉的那几天,每个请求都去撞一次超时, + * 白白浪费服务器资源(而且 php-fpm 的 worker 会被这些慢请求占住)。 + */ + private static function shouldTry(int $year): bool + { + $f = self::tryFile($year); + $last = is_file($f) ? (int)@file_get_contents($f) : 0; + return (time() - $last) > self::CACHE_FAIL; + } + + private static function tryFile(int $year): string + { + return dirname(__DIR__) . '/storage/' . self::CACHE_DIR . '/holiday-try-' . $year . '.txt'; + } + + private static function markTried(int $year): void + { + $dir = dirname(self::tryFile($year)); + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + @file_put_contents(self::tryFile($year), (string)time(), LOCK_EX); + } + + /** 真正去抓 + 落缓存(只在后台跑) */ + private static function doFetch(int $year): void + { + self::markTried($year); + $official = self::fetch($year); + $days = self::builtin($year); + $source = 'builtin'; + if ($official !== null) { + foreach ($official as $d => $v) { + $days[$d] = $v; + } + $source = 'official'; + } + self::saveCache($year, ['year' => $year, 'source' => $source, 'days' => $days], + $source === 'official'); + } + + /** + * 纯抓取(不落缓存)—— 给**命令行/设置页的"立即更新"**用。 + * + * ⚠️ 与 year() 的区别:这个会**联网**。绝不要放在页面渲染路径上。 + */ + public static function fetchNow(int $year): array + { + $official = self::fetch($year); + $days = self::builtin($year); + if ($official !== null) { + foreach ($official as $d => $v) { + $days[$d] = $v; + } + } + $out = ['year' => $year, 'source' => $official !== null ? 'official' : 'builtin', 'days' => $days]; + self::saveCache($year, $out, $official !== null); + self::markTried($year); + return $out; + } + + /** + * 内置规则推导:只算**放假**,不算调休。 + * + * 调休(哪个周末上班)是行政决定,任何"推导"都是瞎猜 —— + * 猜错的代价是用户按错的日期安排了事(比"没有调休信息"更糟)。 + * 所以内置规则只给确定的公历/农历节日,调休一律等官方数据。 + */ + private static function builtin(int $year): array + { + $days = []; + + // ---- 公历固定节日 ---- + foreach (self::FIXED as $m => $v) { + foreach ($v as $d => $name) { + $days[sprintf('%04d-%02d-%02d', $year, $m, $d)] = ['name' => $name, 'off' => true]; + } + } + + // ---- 清明(节气,4 月 4~6 日之间浮动)---- + $qingming = self::qingmingDay($year); + if ($qingming > 0) { + $days[sprintf('%04d-04-%02d', $year, $qingming)] = ['name' => '清明节', 'off' => true]; + } + + // ---- 农历节日(春节 / 端午 / 中秋)---- + if (Lunar::inRange($year)) { + // 春节:法定假从**除夕**开始(除夕=正月初一前一天,注意可能跨到上一年的腊月) + // ⚠️ lunarToSolar 返回的是**关联数组**(['year'=>, 'month'=>, 'day'=>]), + // 不是 [0,1,2] —— 用数字下标取会得到 null,然后 sprintf 出 '0000-00-00'。 + $cny = Lunar::lunarToSolar($year, 1, 1); + if ($cny) { + $first = sprintf('%04d-%02d-%02d', $cny['year'], $cny['month'], $cny['day']); + $days[$first] = ['name' => '春节', 'off' => true]; + // 正月初二、初三也放假(法定 3 天) + for ($i = 1; $i <= 2; $i++) { + $t = date('Y-m-d', strtotime($first . ' +' . $i . ' day')); + $days[$t] = ['name' => '春节', 'off' => true]; + } + // 除夕(正月初一前一天)—— 法定假从除夕算起 + $eve = date('Y-m-d', strtotime($first . ' -1 day')); + $days[$eve] = ['name' => '除夕', 'off' => true]; + } + foreach ([5 => [5, '端午节'], 8 => [15, '中秋节']] as $m => [$d, $name]) { + $s = Lunar::lunarToSolar($year, $m, $d); + if ($s) { + $days[sprintf('%04d-%02d-%02d', $s['year'], $s['month'], $s['day'])] + = ['name' => $name, 'off' => true]; + } + } + } + + ksort($days); + return $days; + } + + /** + * 清明节的公历日期。 + * + * 清明是**节气**不是农历节日,用通行的近似公式(20 世纪/21 世纪两套系数): + * Y = 年 % 100;日 = int(Y × 0.2422 + C) − int(Y / 4) + * 21 世纪 C = 4.81,20 世纪 C = 5.59。 + * ⚠️ 个别年份有 1 天的修正(公式的已知例外),这里只列真正会碰到的: + * 2008 年应为 4 日(公式给 5 日)。范围外一律回退到"4 月 5 日"这个最常值。 + */ + private static function qingmingDay(int $year): int + { + if ($year < 1900 || $year > 2100) { + return 5; + } + $c = $year >= 2000 ? 4.81 : 5.59; + $y = $year % 100; + $day = (int)($y * 0.2422 + $c) - (int)($y / 4); + // 已知例外(来自紫金山天文台的公开修正表,只影响极少数年份) + $except = [2008 => 4, 2020 => 4, 1925 => 5, 1942 => 5]; + if (isset($except[$year])) { + $day = $except[$year]; + } + return ($day >= 4 && $day <= 6) ? $day : 5; + } + + // ------------------------------------------------------------------ + // 官方数据自动拉取 + // ------------------------------------------------------------------ + + /** + * 抓官方公布的那个 JSON。**任何失败都返回 null**(调用方回落到内置规则)。 + * + * ⚠️ 这里刻意不抛异常、不写日志刷屏:日历是高频页, + * 外网抖动是常态,抛出去只会让页面 500 —— 而我们本来就有兜底。 + */ + private static function fetch(int $year): ?array + { + foreach ([self::SOURCE, self::SOURCE_ALT] as $tpl) { + $json = self::httpGet(sprintf($tpl, $year)); + if ($json === null) { + continue; + } + $dec = json_decode($json, true); + if (!is_array($dec) || empty($dec['days']) || !is_array($dec['days'])) { + continue; + } + $days = []; + foreach ($dec['days'] as $d) { + if (!is_array($d)) { + continue; + } + $date = trim((string)($d['date'] ?? '')); + // 只认本年的、格式合法的(数据源里混进别的年份就直接忽略) + if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $m) || (int)$m[1] !== $year) { + continue; + } + $days[$date] = [ + 'name' => trim((string)($d['name'] ?? '')), + 'off' => (bool)($d['isOffDay'] ?? false), + ]; + } + if ($days) { + ksort($days); + return $days; + } + } + return null; + } + + /** 一次 HTTP GET(curl 优先,退回 file_get_contents);失败/超时返回 null */ + private static function httpGet(string $url): ?string + { + if (function_exists('curl_init')) { + $ch = curl_init($url); + $opts = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 12, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_USERAGENT => 'FamilyBook/' . \App\Schema::VERSION, + // ⚠️ 不能用 CURLOPT_NOBODY(那是 HEAD);这里是 GET + CURLOPT_HTTPGET => true, + ]; + // ⚠️ CA 证书兜底:很多服务器(含本机开发环境)的 PHP 没配 curl.cainfo, + // 表现是 `errno=60 unable to get local issuer certificate` —— + // 看起来像"站点不通",其实是**证书链没处可查**。 + // 这里按常见位置找一个 CA bundle;找不到就退回系统默认(不关校验, + // 关掉 VERIFYPEER 等于把 HTTPS 降级成明文,不能为了"能跑"那样做)。 + $ca = self::findCaBundle(); + if ($ca !== null) { + $opts[CURLOPT_CAINFO] = $ca; + } + curl_setopt_array($ch, $opts); + $body = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if (is_string($body) && $code === 200 && $body !== '') { + return $body; + } + return null; + } + // 没有 curl:file_get_contents 同样需要 CA;失败一律 null(上层回落内置规则) + $ctx = stream_context_create([ + 'http' => ['timeout' => 12, 'user_agent' => 'FamilyBook'], + 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true], + ]); + $body = @file_get_contents($url, false, $ctx); + return is_string($body) && $body !== '' ? $body : null; + } + + /** + * 找一个可用的 CA bundle。找不到返回 null(保持 PHP 默认行为)。 + * + * 只查固定几个常见位置,不做目录遍历 —— 这是渲染路径之外的后台任务, + * 但也不该在磁盘上乱翻。 + */ + private static function findCaBundle(): ?string + { + $candidates = [ + ini_get('curl.cainfo') ?: '', + ini_get('openssl.cafile') ?: '', + 'C:/php/extras/ssl/cacert.pem', + 'C:/php/cacert.pem', + 'D:/Yongquan/code/_tooling/php/cacert.pem', + '/etc/ssl/certs/ca-certificates.crt', + '/etc/pki/tls/certs/ca-bundle.crt', + '/etc/ssl/cert.pem', + ]; + foreach ($candidates as $c) { + $c = (string)$c; + if ($c !== '' && is_file($c) && is_readable($c)) { + return $c; + } + } + return null; + } + + // ------------------------------------------------------------------ + // 缓存(放 settings 表:全局数据、与具体人无关) + // ------------------------------------------------------------------ + + /** + * 缓存落**文件**(storage/cache/holiday-<年>.json),不蹭 settings 表。 + * + * ⚠️ 为什么不用 settings 表:它是**家庭级**的(主键 family_id + k,且 family_id 有 + * 外键约束指向 families(id))。而节假日安排是**全局数据**(全国一样、与哪个家无关), + * 硬塞进去最省事的做法是"借用 0 号家庭",但那会直接违反外键。 + * 节假日本来就该在"一次拉取、全体共用、随部署走"的共享存储里 —— 就是文件。 + */ + private const CACHE_DIR = 'cache'; + + private static function cacheFile(int $year): string + { + // storage/cache/holiday-<年>.json + // ⚠️ 这个目录**不在打包白名单里**(缓存不该随包走),由部署脚本 mkdir 出来; + // 代码这边只负责"没有就建",且**任何写失败都不算错**(见 saveCache)。 + return dirname(__DIR__) . '/storage/' . self::CACHE_DIR . '/holiday-' . $year . '.json'; + } + + private static function fromCache(int $year): ?array + { + $f = self::cacheFile($year); + if (!is_file($f)) { + return null; + } + $raw = @file_get_contents($f); + if (!is_string($raw) || $raw === '') { + return null; + } + $dec = json_decode($raw, true); + if (!is_array($dec) || !isset($dec['at'], $dec['data'])) { + return null; + } + // 成功的官方数据缓存久一点;只有内置规则的缓存短一点(好早点换上官方数据) + $ttl = ($dec['source'] ?? '') === 'official' ? self::CACHE_OK : self::CACHE_FAIL; + if (time() - (int)$dec['at'] > $ttl) { + return null; + } + $data = $dec['data']; + return is_array($data) ? $data : null; + } + + private static function saveCache(int $year, array $data, bool $official): void + { + $payload = json_encode([ + 'at' => time(), + 'source' => $official ? 'official' : 'builtin', + 'data' => $data, + ], JSON_UNESCAPED_UNICODE); + $dir = dirname(self::cacheFile($year)); + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + // 写失败(目录不可写)**不算错**:下次请求重算一遍而已,页面照常出内容 + @file_put_contents(self::cacheFile($year), $payload, LOCK_EX); + } + + /** 强制刷新(设置页/命令行用):删缓存再取一次 */ + public static function refresh(int $year): array + { + $f = self::cacheFile($year); + if (is_file($f)) { + @unlink($f); + } + return self::year($year); + } + + // ------------------------------------------------------------------ + // 供日历渲染用 + // ------------------------------------------------------------------ + + /** + * 把放假/调休摊成日历条目(只取 [from,to] 落在范围内的)。 + * + * @return array + */ + public static function range(int $year, string $from, string $to): array + { + $y = self::year($year); + $out = []; + foreach (($y['days'] ?? []) as $date => $v) { + if ($date < $from || $date > $to) { + continue; + } + $off = !empty($v['off']); + $name = (string)($v['name'] ?? ''); + $out[] = [ + 'date' => (string)$date, + 'name' => $name, + 'off' => $off, + // 调休上班的日子写清楚"补班",否则用户看到工作日里冒出一条日程会莫名 + 'title' => $off ? ('休 ' . ($name !== '' ? $name : '假日')) : ('班 ' . ($name !== '' ? $name : '调休')), + ]; + } + return $out; + } + + /** + * 农历条目([from,to] 每天都出一条,标题是"农历月日 + 节日")。 + * + * ⚠️ 每天的农历都要出,不能只出初一十五 —— 用户要的是"看日历就知道今天农历几号"。 + */ + public static function lunarRange(string $from, string $to): array + { + $out = []; + $t = strtotime($from); + $end = strtotime($to); + if ($t === false || $end === false || $end < $t) { + return []; + } + // 上限保险:范围异常大时不至于把请求拖死(正常月视图最多 42 天) + for ($i = 0; $t <= $end && $i < 400; $i++, $t = strtotime('+1 day', $t)) { + $date = date('Y-m-d', $t); + $y = (int)date('Y', $t); $m = (int)date('n', $t); $d = (int)date('j', $t); + $l = Lunar::solarToLunar($y, $m, $d); + if (!$l) { + continue; + } + $lm = (int)$l['month']; $ld = (int)$l['day']; $leap = !empty($l['is_leap']); + $out[] = [ + 'date' => $date, + 'title' => self::lunarTitle($lm, $ld, $leap), + 'fest' => self::lunarFestival($lm, $ld, $leap), + ]; + } + return $out; + } + + /** 日历条上显示的农历文字:初一显示月名,其余显示日名 */ + private static function lunarTitle(int $m, int $d, bool $leap): string + { + if ($d === 1) { + return Lunar::monthName($m, $leap); + } + return Lunar::dayName($d); + } + + /** + * 那天是不是农历节日(春节/元宵/端午/七夕/中元/中秋/重阳/腊八/小年/除夕)。 + * 返回节日名,没有则空串。 + */ + private static function lunarFestival(int $m, int $d, bool $leap): string + { + if ($leap) { + return ''; // 闰月不算节日 + } + $map = [ + '1-1' => '春节', '1-15' => '元宵节', + '5-5' => '端午节', '7-7' => '七夕', + '7-15' => '中元节', '8-15' => '中秋节', + '9-9' => '重阳节', '12-8' => '腊八节', + '12-23'=> '小年', + ]; + return $map[$m . '-' . $d] ?? ''; + } + + /** 两个内置日历本的定义(不可删除 → kind 不是 custom,delete() 天然删不掉) */ + public const BUILTIN_KINDS = [ + 'lunar' => ['农历', '#8C6D3F'], + 'holiday' => ['节假日', '#B0342C'], + ]; + + /** 某年官方数据是否已到手(设置页显示"已自动更新到哪一年"用) */ + public static function isOfficial(int $year): bool + { + $y = self::year($year); + return ($y['source'] ?? '') === 'official'; + } +} \ No newline at end of file diff --git a/app/Ledger.php b/app/Ledger.php index f06b414..4b3dfe1 100644 --- a/app/Ledger.php +++ b/app/Ledger.php @@ -38,6 +38,12 @@ final class Ledger 'cash' => ['现金', 'asset', '💵'], 'loan' => ['贷款', 'debt', '📉'], 'credit' => ['信用卡', 'debt', '💳'], + // v24 第 10 批 #2(用户原话):「类别,增加一个欠款,用来记录别人的钱, + // 属于负债,是负的。」 + // 与「贷款/信用卡」的区别:那两样是欠**机构**的钱,这个是欠**个人**的 + // (借了亲戚朋友的钱、还没还的份子钱…)。同属负债,所以一起计入「总负债」, + // 净资产照旧 = 资产 − 负债。 + 'owe' => ['欠款', 'debt', '🤝'], 'other' => ['其它', 'asset', '📦'], ]; @@ -106,6 +112,14 @@ final class Ledger /** * 这本账的汇总。 + * + * ⚠️ v24 第 10 批 #2:负债一律按**绝对值**计入(abs)。 + * 用户原话说「欠款…属于负债,**是负的**」—— 也就是说他会把欠款填成 -500。 + * 如果不取绝对值,`net = 资产 − 负债 = 1000 − (−500) = 1500`, + * **欠了钱净资产反而变多**,这是最容易被忽略又最误导人的一类错。 + * 负债的"负"是**方向语义**(我欠别人),金额本身按正数参与"总负债", + * 再由 net = 资产 − 负债 落到负号上 —— 全站只有这一个口径。 + * * @return array{asset:int,debt:int,net:int,by_kind:array,count:int} */ public static function summary(array $entries): array @@ -116,11 +130,10 @@ final class Ledger foreach ($entries as $e) { $amt = (int)($e['amount_cents'] ?? 0); $kind = (string)($e['kind'] ?? 'other'); - $isDebt = (self::KINDS[$kind][1] ?? 'asset') === 'debt'; - if ($isDebt) { - $debt += $amt; + if (self::isDebt($kind)) { + $debt += abs($amt); // 见方法注释:负数输入也要被算成负债 } else { - $asset += $amt; + $asset += $amt; // 资产保持符号原样(透支=负资产是合理的) } $byKind[$kind] = ($byKind[$kind] ?? 0) + $amt; } @@ -133,6 +146,18 @@ final class Ledger ]; } + /** + * 负债口径归一化:**负债类别的金额一律取绝对值**,其它类别原样。 + * + * 只在写入口(addEntry / updateEntry / reconcile)调用 —— + * 存进库里的数就已经是"负债为正、由 net 去减"的唯一口径, + * 下游(汇总/时间线/图表)不必各写一遍 abs,也就不会有人漏写。 + */ + private static function normalizeAmount(string $kind, int $cents): int + { + return self::isDebt($kind) ? abs($cents) : $cents; + } + // ---------------- 写 ---------------- /** @return int 新账本 id */ @@ -231,15 +256,17 @@ final class Ledger /** @return int 新增账目 id */ public static function addEntry(int $ledgerId, array $d, int $byId = 0): int { - $db = Database::get(); + $db = Database::get(); + $knd = self::kind($d['kind'] ?? ''); $db->prepare( 'INSERT INTO ledger_entries (ledger_id, kind, title, amount_cents, note, person_id, created_by) VALUES (?,?,?,?,?,?,?)' )->execute([ $ledgerId, - self::kind($d['kind'] ?? ''), + $knd, mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'), - self::toCents($d['amount'] ?? ''), + // 负债归一化成正数(见 normalizeAmount):用户会把欠款填成负数 + self::normalizeAmount($knd, self::toCents($d['amount'] ?? '')), trim((string)($d['note'] ?? '')) ?: null, (int)($d['person_id'] ?? 0) ?: null, $byId ?: null, @@ -249,13 +276,14 @@ final class Ledger public static function updateEntry(int $ledgerId, int $id, array $d): void { + $knd = self::kind($d['kind'] ?? ''); Database::get()->prepare( 'UPDATE ledger_entries SET kind=?, title=?, amount_cents=?, note=?, person_id=? WHERE id=? AND ledger_id=?' )->execute([ - self::kind($d['kind'] ?? ''), + $knd, mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'), - self::toCents($d['amount'] ?? ''), + self::normalizeAmount($knd, self::toCents($d['amount'] ?? '')), trim((string)($d['note'] ?? '')) ?: null, (int)($d['person_id'] ?? 0) ?: null, $id, $ledgerId, @@ -362,12 +390,14 @@ final class Ledger public static function reconcile(int $ledgerId, int $entryId, $amount, string $note = '', int $byId = 0): bool { $db = Database::get(); - $st = $db->prepare('SELECT id FROM ledger_entries WHERE id = ? AND ledger_id = ?'); + $st = $db->prepare('SELECT id, kind FROM ledger_entries WHERE id = ? AND ledger_id = ?'); $st->execute([$entryId, $ledgerId]); - if ($st->fetchColumn() === false) { + $row = $st->fetch(); + if ($row === false) { return false; } - $cents = self::toCents($amount); + // 盘点也走同一条归一化:负债填 -500 也存成 500(见 normalizeAmount) + $cents = self::normalizeAmount(self::kind((string)$row['kind']), self::toCents($amount)); $db->prepare('UPDATE ledger_entries SET amount_cents = ? WHERE id = ? AND ledger_id = ?') ->execute([$cents, $entryId, $ledgerId]); $db->prepare( @@ -395,15 +425,19 @@ final class Ledger } /** - * 这本账的**汇总时间线**:每个盘点日,整本账的净资产是多少。 + * 这本账的**汇总时间线**:每个时点,整本账的净资产是多少。 * - * 做法:把每个账目在每个时点上"当时的值"取出来再加总 —— - * · 某账目在 t 之前从未盘点过 → 它的值是**它现在的值**(因为一直没变过); - * · 盘点过 → 取 t 之前最后一次盘点的值。 - * 这样"只盘过招行"也能画出一条合理的总资产曲线, - * 而不是"没盘过的账目在图上凭空消失"。 + * v24 第 10 批 #3 修正:时点不再只取"盘点过的日期"。 + * ⚠️ 只取盘点日期有个致命的冷启动问题:用户**第一次盘点时只有一个时点**, + * 模板的 `count($timeline) > 1` 不成立 → 图根本不显示。而那一刻恰恰是他 + * 最想看到图的时刻("我盘点了,图怎么还没出来")。 + * 所以时点集合改成三者并集:① 账目**创建日**(那时它开始存在) + * ② 所有盘点日 ③ 今天。于是"今天记了三笔账"就能画出一条 0 → 逐步累加的线。 * - * @return array 至少一条(今天);空账本返回 [] + * 取值规则(每个时点 t):账目在 t 之后才创建 → **不计入**(那时它还不存在); + * 在 t 之前盘点过 → 取最后一次盘点的值;从未盘点过 → 取它现在的值(一直没变过)。 + * + * @return array 空账本返回 [] */ public static function timeline(int $ledgerId): array { @@ -421,15 +455,43 @@ final class Ledger foreach ($st->fetchAll() as $r) { $dates[] = (string)$r['d']; } + // ① 账目创建日:让"刚记完账"就有曲线可看(见方法注释的冷启动问题) + $st = Database::get()->prepare( + 'SELECT DISTINCT DATE(created_at) AS d FROM ledger_entries + WHERE ledger_id = ? AND created_at IS NOT NULL ORDER BY d' + ); + $st->execute([$ledgerId]); + foreach ($st->fetchAll() as $r) { + $d = (string)$r['d']; + if ($d !== '' && $d !== '0000-00-00') { + $dates[] = $d; + } + } $today = date('Y-m-d'); $dates[] = $today; // 永远带上"现在" $dates = array_values(array_unique($dates)); sort($dates); - // 每条账目的快照:entry_id => [[date, cents], …] + // ⚠️ 只有一个时点时给一个**零点**在它前面,让曲线有形状。 + // 全是同一天发生的事(今天记账 + 今天盘点)会退化成单点 —— + // 单点折线图视觉上就是一个小圆圈,用户会以为"图坏了"。 + // 补一个"前一天:0"作为基线,读起来就是"从 0 涨到现在", + // 与直觉一致(账本是新起的,之前确实没有资产)。 + // 只补在**最早那个时点的前一天**,不动中间的真实数据。 + if (count($dates) === 1) { + array_unshift($dates, date('Y-m-d', strtotime($dates[0] . ' -1 day'))); + } + + // 每条账目的快照:entry_id => [[date, cents], …];born = 它的创建日期 $snaps = []; + $born = []; + $today10 = date('Y-m-d'); foreach ($entries as $e) { - $snaps[(int)$e['id']] = []; + $eid = (int)$e['id']; + $snaps[$eid] = []; + $c = substr((string)($e['created_at'] ?? ''), 0, 10); + // 取不到创建日就当"今天出生":宁可不画前段,也不要凭空多出历史 + $born[$eid] = ($c !== '' && $c !== '0000-00-00') ? $c : $today10; } $st = Database::get()->prepare( 'SELECT entry_id, DATE(created_at) AS d, amount_cents @@ -450,6 +512,10 @@ final class Ledger $debt = 0; foreach ($entries as $e) { $eid = (int)$e['id']; + // 这个时点上它还不存在(还没记进来)→ 不计入,否则等于凭空多钱 + if ($d < $born[$eid]) { + continue; + } $val = (int)$e['amount_cents']; // 默认:一直没变过 = 还是现在这个值 foreach ($snaps[$eid] as [$sd, $sv]) { if ($sd <= $d) { @@ -459,7 +525,8 @@ final class Ledger } } if (self::isDebt((string)$e['kind'])) { - $debt += $val; + // ⚠️ 与 summary 同一口径:负债取绝对值(老数据里可能是被判反的负数) + $debt += abs($val); } else { $asset += $val; } @@ -490,34 +557,29 @@ final class Ledger } /** - * 这本账里**有盘点历史**的账目(列表上方只给这几条画线)。 - * 全画会变成一坨面条 —— 用户要的是"招商银行每年的金额变化"这种单条曲线。 + * 这本账里**每条账目**的曲线(列表上方逐条画)。 + * + * v24 第 10 批 #3(用户原话):「在账目上面,增加一个图表显示,**显示所有账目和 + * 总金额的变化曲线**,根据盘点的金额进行显示。横轴就是时间,纵轴是金额,用折线图。」 + * + * ⚠️ 这里与 v24 第 8 条的旧实现有个**语义修正**: + * 旧版只画「有盘点记录的账目」(当时担心"全画会变成一坨面条")。 + * 但用户这次明确要求「**所有**账目」,所以改成**每条都出线**, + * "面条"问题改由**前端折叠 + 按类别筛选**解决(见模板里的曲线选择条), + * 而不是靠"少画几条"偷偷减少信息。 + * 没盘点过的账目会得到一条**单点曲线**(就是它现在的值), + * 图上画一个点而不是空白 —— 空白会被误读成"这条账没数据"。 * * @return array */ public static function charts(int $ledgerId): array { - $st = Database::get()->prepare( - 'SELECT entry_id, COUNT(*) AS n FROM ledger_entry_snapshots - WHERE ledger_id = ? GROUP BY entry_id HAVING n > 0 ORDER BY entry_id' - ); - $st->execute([$ledgerId]); - $ids = array_map('intval', array_column($st->fetchAll(), 'entry_id')); - if (!$ids) { - return []; - } - $byId = []; - foreach (self::entries($ledgerId) as $e) { - $byId[(int)$e['id']] = $e; - } $out = []; - foreach ($ids as $eid) { - if (!isset($byId[$eid])) { - continue; - } + foreach (self::entries($ledgerId) as $e) { + $eid = (int)$e['id']; $out[] = [ - 'entry' => $byId[$eid], - 'series' => self::entrySeries($eid, (int)$byId[$eid]['amount_cents']), + 'entry' => $e, + 'series' => self::entrySeries($eid, (int)$e['amount_cents']), ]; } return $out; diff --git a/app/Schema.php b/app/Schema.php index 2af750b..502a02c 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -71,7 +71,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 */ - public const VERSION = '16'; + public const VERSION = '17'; /** * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 @@ -484,7 +484,7 @@ CREATE TABLE IF NOT EXISTS `calendars` ( `owner_id` INT UNSIGNED NOT NULL COMMENT '这本日历是谁的', `name` VARCHAR(50) NOT NULL, `color` CHAR(7) NOT NULL DEFAULT '#4A7C82' COMMENT '日历本的颜色(日程按本子的颜色显示)', - `kind` ENUM('custom','movie','trip') NOT NULL DEFAULT 'custom' COMMENT 'movie/trip = 系统给每人预置的默认本,内容从 events 派生', + `kind` ENUM('custom','movie','trip','lunar','holiday') NOT NULL DEFAULT 'custom' COMMENT 'movie/trip/lunar/holiday = 系统预置的派生只读本(农历与节假日为 v24 第 10 批新增,不可删除)', `position` INT NOT NULL DEFAULT 100, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), @@ -850,6 +850,49 @@ SQL, self::tryStep('老地址拆成国家/省/市/区', static function () use ($db) { self::backfillAddress($db); }); + // ---- v24 第 10 批 #4:日历本 kind 枚举加 lunar / holiday ---- + // ⚠️ 与上面 widenEventKindEnum 同一类坑:建表语句里的 ENUM 对**已存在的表** + // 不会更新(CREATE TABLE IF NOT EXISTS 直接跳过),必须显式 MODIFY。 + // 不改的后果:给老库插 kind='lunar' 会被严格模式整条拒掉 → + // "农历日历本建不出来",而新装的库一切正常(最难查的那种)。 + $db->exec("ALTER TABLE `calendars` MODIFY COLUMN `kind` + ENUM('custom','movie','trip','lunar','holiday') NOT NULL DEFAULT 'custom' + COMMENT 'movie/trip/lunar/holiday = 系统预置的派生只读本'"); + // 给每个家庭里的每个人都补上农历/节假日这两本(幂等) + self::tryStep('补建每人的农历 / 节假日日历本', static function () use ($db) { + foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { + \App\Calendar::seedDefaultsForFamily((int)$fam['id']); + } + }); + + // ---- v24 第 10 批 #2:负债类账目一律存正数 ---- + // 用户原话:「类别,增加一个欠款,用来记录别人的钱,属于负债,是负的。」 + // 也就是说用户会把欠款填成 **-500**。旧的 summary() 用 `net = 资产 − 负债`, + // 负的负债会让净资产**反向变大**(1000 − (−500) = 1500,欠钱反而更有钱)。 + // 写入口已加 normalizeAmount 归一化;这里把**库里已有的**负数负债也纠正过来, + // 否则老数据的净资产会一直错下去。 + self::tryStep('负债类账目金额取绝对值(纠正负数欠款)', static function () use ($db) { + $kinds = []; + foreach (\App\Ledger::KINDS as $k => $v) { + if (($v[1] ?? 'asset') === 'debt') { + $kinds[] = $k; + } + } + if (!$kinds) { + return; + } + $in = implode(',', array_fill(0, count($kinds), '?')); + // 账目表自己带 kind,直接筛 + $db->prepare("UPDATE `ledger_entries` SET amount_cents = -amount_cents + WHERE amount_cents < 0 AND kind IN ($in)")->execute($kinds); + // ⚠️ 快照表**没有 kind 列**(它只有 entry_id)—— 必须回表 ledger_entries 判类别, + // 不能凭"金额为负"就取反:负的**资产**(透支、欠款记账前的误录)是合法值。 + $db->prepare("UPDATE `ledger_entry_snapshots` s + JOIN `ledger_entries` e ON e.id = s.entry_id + SET s.amount_cents = -s.amount_cents + WHERE s.amount_cents < 0 AND e.kind IN ($in)")->execute($kinds); + }); + // 老头像(v11 之前传的)补算一次:回读已存的图算主色。 // 纯数据步骤 → 容错跑:某张图读不回来(OSS 权限、对象丢了)只影响那一个人, // 他会自动落到「按姓名取色」的兜底上,不该把后面的升级步骤挡住。 diff --git a/assets/css/app.css b/assets/css/app.css index 06081d8..e1bfab0 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -542,6 +542,26 @@ textarea.input { min-height: 76px; resize: vertical; } /* 工作经历的月份选择器(type="month" 升级):占满原 .job-cell 格子 */ .dt-semi.dt-semi-month { display: none; } .dt-semi.dt-semi-month.is-live { display: block; width: 100%; } + +/* ---- v24 第 10 批 #7:起止范围用一个控件(Semi dateTimeRange)---- + 一对 .dt-input 被包在 .dt-range 里;升级后两端各自 display:none, + 由**一个** .dt-semi-range 顶上来。未升级(JS 没跑/加载失败)时, + 里面两个原生控件照旧各占一行 —— 也就是"功能没变,只是不好看"。 */ +.dt-range { display: flex; flex-direction: column; gap: 8px; } +.dt-range.is-semi-range { display: block; } +.dt-range.is-semi-range > .dt-input { display: none; } /* 两端都让位给范围控件 */ +.dt-semi-range { display: none; width: 100%; max-width: 460px; } +.dt-range.is-semi-range .dt-semi-range { display: block; } +/* Semi 范围输入框:与站内 .input 同高同圆角;宽度撑开一点(要放两个时间) */ +body[class] .dt-semi-range .semi-input-wrapper { + height: 40px; border: 1px solid var(--line); border-radius: var(--radius-s); + background: var(--surface); +} +body[class] .dt-semi-range .semi-input-wrapper:hover { border-color: var(--brand-2); } +body[class] .dt-semi-range .semi-input { font: inherit; font-size: 13.5px; color: var(--ink); } +@media (max-width: 640px) { + .dt-semi-range { max-width: none; } +} .dt-input.dt-missing .semi-input-wrapper { border-color: #C0392B !important; box-shadow: 0 0 0 1px #C0392B inset; } /* Semi 主题对齐宣纸色。semi.min.css 是懒加载的、必然排在 app.css 之后, 所以这里赢在**优先级**:body[class] 特异性(0,1,1) > semi 的 body(0,0,1)。 */ @@ -983,21 +1003,26 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, .person-edit { padding: 3px 10px; font-size: 13px; white-space: nowrap; } -.person-more > summary { - list-style: none; cursor: pointer; padding: 4px 10px; - font-size: 16px; color: var(--muted); border-radius: 8px; +.person-more-btn { + padding: 3px 9px; font-size: 15px; line-height: 1.3; + color: var(--muted); white-space: nowrap; } -.person-more > summary:hover { background: var(--line-2); color: var(--brand); } -.person-more > summary::-webkit-details-marker { display: none; } -.person-more > summary::after { content: ' ▾'; } -.person-more[open] > summary::after { content: ' ▴'; } -.person-more > summary:hover { color: var(--brand); } -.person-more[open] { - position: absolute; right: 8px; top: 8px; transform: none; +.person-more-btn:hover { color: var(--brand); } + +/* ---------------- 名册卡片动作的**共享浮层**(v24 第 10 批 #6)---------------- + 「分组」「移出名册」两份表单全页只渲染一份(原来每卡各一份,1184 人 = + 2369 个 form / 27234 个 input,密码管理器逐个扫描把页面拖死)。 + 这里给共享浮层一个定位容器,面板由 JS 贴合触发按钮摆放。 */ +.roster-menus { position: fixed; inset: 0; z-index: 60; pointer-events: none; } +.roster-menus .rm-panel { + position: fixed; pointer-events: auto; background: var(--surface); border: 1px solid var(--line); - border-radius: 10px; box-shadow: var(--shadow-l); z-index: 30; - min-width: 232px; padding-bottom: 4px; + border-radius: 10px; box-shadow: var(--shadow-l); + padding: 8px 0; min-width: 200px; } +.roster-menus .rm-panel .person-move { padding: 0 10px; } +.roster-menus .rm-panel .person-move.is-self { padding: 0 12px; } +.roster-menus[hidden] { display: none; } .person-move { display: flex; gap: 6px; padding: 8px 12px; flex-wrap: wrap; } .person-move .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; } .person-move .btn { flex: 0 0 auto; } @@ -1569,8 +1594,38 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, .ck-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; } .ck-head b { font-size: 14px; } .ck-svg { display: block; margin-top: 2px; } -/* 盘点面板:与「更正」并列在一个
里,中间给一条分隔线才分得清两件事 */ -.led-recon { padding-bottom: 10px; border-bottom: 1px dashed var(--line-2); margin-bottom: 10px; } + +/* ---- v24 第 10 批 #3:总金额图在最前(跨整行),下面才是逐条账目 ---- */ +.ck-total { margin-bottom: 14px; } +.ck-total .ck-head b { font-size: 15px; } +/* 类别筛选条:图表一多就是一坨,把筛选权交给用户(而不是少画几条骗自己) */ +.ck-filter { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 12px; } +.ck-chip { + padding: 4px 11px; border: 1px solid var(--line); border-radius: 999px; + background: var(--surface); color: var(--ink); font: inherit; font-size: 13px; + cursor: pointer; +} +.ck-chip:hover { border-color: var(--brand-2); } +.ck-chip.is-on { background: var(--brand); border-color: var(--brand); color: #fff; } +.ck-chip.is-debt:not(.is-on) { color: #1F7A4D; } + +/* ---- v24 第 10 批 #3:每行的「盘点 / 修改」两个按钮 + 就地展开的面板 ---- */ +.led-acts { display: flex; gap: 6px; align-items: center; white-space: nowrap; } +.led-panel { + /* 作为 的兄弟插进来(见模板里的 JS):表格列宽会把表单切碎,格子外才排得开 */ + display: block; background: #FDFBF7; + border: 1px solid var(--line); border-radius: var(--radius-s); + padding: 12px 14px; margin: 0 0 8px; +} +.led-panel .field { margin-bottom: 10px; } +.led-panel .field > label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; } +.led-panel .input, .led-panel .select { width: 100%; max-width: 360px; } +.led-panel .btn-row { margin-top: 4px; } +.led-panel[hidden] { display: none; } +/* 「修改」面板里嵌了选人组件,给它一个不夸张的最大宽度 */ +.led-panel .pp { max-width: 420px; } +/* 盘点面板与「修改」不再同处一个 details,分隔线也就不需要了 */ +.led-recon { padding-bottom: 0; border-bottom: 0; margin-bottom: 0; } .led-form-h { font-size: 12.5px; color: var(--muted); font-weight: 500; margin: 0 0 8px; } .led-hist { margin-top: 6px; } .led-hist summary { cursor: pointer; } @@ -2403,6 +2458,29 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600; } +/* ---- v24 第 10 批 #6:日程页头的「跳到某一天」按钮 ---- + 外观与 .btn 一致(它是
+ v24 第 10 批 #6:按钮本身不再内联
+
+11 个 radio, + 只带数据、交给全页共享的那一份浮层(理由见 $renderGrid 的注释)。 */ ?> + 编辑 -
+ @@ -313,6 +307,47 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):

+ + + + + @@ -335,5 +370,100 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid): }); syncClear(); })(); + + +(function () { + var wrap = document.getElementById('rosterMenus'); + if (!wrap) { return; } + var gPanel = document.getElementById('rmGroup'); + var mPanel = document.getElementById('rmMore'); + var openFor = null; // 当前为哪张卡开着 + + function closeAll() { + wrap.hidden = true; + if (gPanel) { gPanel.hidden = true; } + if (mPanel) { mPanel.hidden = true; } + openFor = null; + } + + /* 浮层贴到触发按钮下方;贴太靠右就向左收,贴太靠下就翻到上方 */ + function place(panel, btn) { + var b = btn.getBoundingClientRect(); + var w = panel.offsetWidth, h = panel.offsetHeight; + var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight; + var left = Math.min(Math.max(8, b.right - w), vw - w - 8); + var top = b.bottom + 6; + if (top + h > vh - 8) { top = Math.max(8, b.top - h - 6); } + panel.style.left = Math.round(left) + 'px'; + panel.style.top = Math.round(top) + 'px'; + } + + document.addEventListener('click', function (e) { + var t = e.target; + if (!t || !t.closest) { return; } + + // ---- 分组 ---- + var gb = t.closest('[data-pg-open]'); + if (gb) { + e.preventDefault(); + var pid = gb.getAttribute('data-pg-open'); + var cur = parseInt(gb.getAttribute('data-pg-cur') || '0', 10) || 0; + var form = gPanel.querySelector('form'); + form.querySelector('input[name="person_id"]').value = pid; + gPanel.querySelector('.pg-who').textContent = gb.getAttribute('data-pg-name') || ''; + // 预选当前分组 + Array.prototype.forEach.call(form.querySelectorAll('input[name="group_id"]'), function (r) { + r.checked = (parseInt(r.value || '0', 10) || 0) === cur; + }); + mPanel.hidden = true; + gPanel.hidden = false; + wrap.hidden = false; + place(gPanel, gb); + openFor = pid; + return; + } + + // ---- ⋯ ---- + var mb = t.closest('[data-pm-open]'); + if (mb) { + e.preventDefault(); + var pid2 = mb.getAttribute('data-pm-open'); + var kin = mb.getAttribute('data-pm-hint') === '1'; + mPanel.querySelector('.is-kin').hidden = !kin; + mPanel.querySelector('form.is-self').hidden = kin; + if (!kin) { + mPanel.querySelector('form.is-self input[name="person_id"]').value = pid2; + } + gPanel.hidden = true; + mPanel.hidden = false; + wrap.hidden = false; + place(mPanel, mb); + openFor = pid2; + return; + } + + // 点浮层内部 → 不动 + if (wrap.contains(t)) { return; } + closeAll(); + }, true); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { closeAll(); } + }); + window.addEventListener('resize', closeAll); + window.addEventListener('scroll', function () { if (openFor) { closeAll(); } }, true); + + /* 选中即提交(与原来一样是"锦上添花":面板里那个「保存」按钮一直都在) */ + document.addEventListener('change', function (e) { + var inp = e.target; + if (!inp || inp.type !== 'radio' || inp.name !== 'group_id') { return; } + var form = inp.form; + if (!form || !form.classList || !form.classList.contains('pg-menu')) { return; } + if (typeof form.requestSubmit === 'function') { form.requestSubmit(); } + else { form.submit(); } + }); +})(); diff --git a/templates/trips/_node_form.php b/templates/trips/_node_form.php index c92bee4..b96faca 100644 --- a/templates/trips/_node_form.php +++ b/templates/trips/_node_form.php @@ -54,15 +54,16 @@ $nfPrefix = $nfIsEdit ? 'ne' . (int)$nfNode['id'] : ('n' . ($nfCategory === 'rea -
- + +
+ -
-
- + $dtId = $nfPrefix . '-s'; $dtSeconds = true; $dtRange = 'start'; + require __DIR__ . '/../_datetime.php'; ?> + $dtId = $nfPrefix . '-e'; $dtSeconds = true; $dtRange = 'end'; + require __DIR__ . '/../_datetime.php'; ?> + 结束时间可不填。
diff --git a/templates/trips/form.php b/templates/trips/form.php index 529a840..1a3df80 100644 --- a/templates/trips/form.php +++ b/templates/trips/form.php @@ -44,18 +44,19 @@ if ($isEdit) {
-
- + +
+ + $dtId = 't-start'; $dtSeconds = true; $dtRange = 'start'; + require __DIR__ . '/../_datetime.php'; ?> -
- -
- + 回来的时间可不填。